, ids: string[]) => void;
+}) {
+ const [visState, setVisState] = useSetState({
+ series: [] as BarSeriesOption[],
+ xAxis: null as ECOption['xAxis'] | null,
+ yAxis: null as ECOption['yAxis'] | null,
+ });
+
+ const hasSelected = React.useMemo(() => (selectedMap ? Object.values(selectedMap).some((selected) => selected) : false), [selectedMap]);
+
+ const gridLeft = React.useMemo(() => Math.min(longestLabelWidth + 20, containerWidth / 3), [containerWidth, longestLabelWidth]);
+
+ // TODO: @dv-usama-ansari: This should be moved to a pure function so that it could be unit tested.
+ const getDataForAggregationType = React.useCallback(
+ (group: string, selected: 'selected' | 'unselected') => {
+ if (aggregatedData) {
+ switch (config?.aggregateType) {
+ case EAggregateTypes.COUNT:
+ return (aggregatedData.categoriesList ?? []).map((category) => ({
+ value: aggregatedData.categories[category]?.groups[group]?.[selected]
+ ? normalizedValue({
+ config,
+ value: aggregatedData.categories[category].groups[group][selected].count,
+ total: aggregatedData.categories[category].total,
+ })
+ : 0,
+ category,
+ }));
+
+ case EAggregateTypes.AVG:
+ return (aggregatedData.categoriesList ?? []).map((category) => ({
+ value: aggregatedData.categories[category]?.groups[group]?.[selected]
+ ? normalizedValue({
+ config,
+ value: aggregatedData.categories[category].groups[group][selected].sum / aggregatedData.categories[category].groups[group][selected].count,
+ total: aggregatedData.categories[category].total,
+ })
+ : 0,
+ category,
+ }));
+
+ case EAggregateTypes.MIN:
+ return (aggregatedData.categoriesList ?? []).map((category) => ({
+ value: aggregatedData.categories[category]?.groups[group]?.[selected]
+ ? normalizedValue({
+ config,
+ value: aggregatedData.categories[category].groups[group][selected].min,
+ total: aggregatedData.categories[category].total,
+ })
+ : 0,
+ category,
+ }));
+
+ case EAggregateTypes.MAX:
+ return (aggregatedData.categoriesList ?? []).map((category) => ({
+ value: aggregatedData.categories[category]?.groups[group]?.[selected]
+ ? normalizedValue({
+ config,
+ value: aggregatedData.categories[category].groups[group][selected].max,
+ total: aggregatedData.categories[category].total,
+ })
+ : 0,
+ category,
+ }));
+
+ case EAggregateTypes.MED:
+ return (aggregatedData.categoriesList ?? []).map((category) => ({
+ value: aggregatedData.categories[category]?.groups[group]?.[selected]
+ ? normalizedValue({
+ config,
+ value: median(aggregatedData.categories[category].groups[group][selected].nums) as number,
+ total: aggregatedData.categories[category].total,
+ })
+ : 0,
+ category,
+ }));
+
+ default:
+ console.warn(`Aggregation type ${config?.aggregateType} is not supported by bar chart.`);
+ return [];
+ }
+ }
+ console.warn(`No data available`);
+ return null;
+ },
+ [aggregatedData, config],
+ );
+
+ const groupSortedSeries = React.useMemo(() => {
+ const filteredVisStateSeries = (visState.series ?? []).filter((series) => series.data?.some((d) => d !== null && d !== undefined));
+ const [knownSeries, unknownSeries] = filteredVisStateSeries.reduce(
+ (acc, series) => {
+ if ((series as typeof series & { group: string }).group === NAN_REPLACEMENT) {
+ acc[1].push(series);
+ } else {
+ acc[0].push(series);
+ }
+ return acc;
+ },
+ [[] as BarSeriesOption[], [] as BarSeriesOption[]],
+ );
+ if (isGroupedByNumerical) {
+ if (!knownSeries.some((series) => (series as typeof series & { group: string })?.group.includes(' to '))) {
+ const namedKnownSeries = knownSeries.map((series) => {
+ const name = String((series as typeof series).data?.[0]);
+ const color = groupColorScale?.(name as string) ?? VIS_NEUTRAL_COLOR;
+ return {
+ ...series,
+ name,
+ itemStyle: { color },
+ };
+ });
+ return [...namedKnownSeries, ...unknownSeries];
+ }
+
+ const sortedSeries = knownSeries.sort((a, b) => {
+ if (!(a as typeof a & { group: string }).group.includes(' to ')) {
+ return 0;
+ }
+ const [aMin, aMax] = (a as typeof a & { group: string }).group.split(' to ').map(Number);
+ const [bMin, bMax] = (b as typeof b & { group: string }).group.split(' to ').map(Number);
+ return (aMin as number) - (bMin as number) || (aMax as number) - (bMax as number);
+ });
+ return [...sortedSeries, ...unknownSeries];
+ }
+ return [...knownSeries, ...unknownSeries];
+ }, [groupColorScale, isGroupedByNumerical, visState.series]);
+
+ // prepare data
+ const barSeriesBase = React.useMemo(
+ () =>
+ ({
+ type: 'bar',
+ blur: { label: { show: false } },
+ barMaxWidth: BAR_WIDTH,
+ barMinWidth: config?.useResponsiveBarWidth ? 1 : BAR_WIDTH,
+
+ tooltip: {
+ trigger: 'item',
+ show: true,
+ confine: true,
+ backgroundColor: 'var(--tooltip-bg,var(--mantine-color-gray-9))',
+ borderWidth: 0,
+ borderColor: 'transparent',
+ textStyle: {
+ color: 'var(--tooltip-color,var(--mantine-color-white))',
+ },
+ axisPointer: {
+ type: 'shadow',
+ },
+ formatter: (params) => {
+ const facetString = selectedFacetValue ? generateHTMLString({ label: `Facet of ${config?.facets?.name}`, value: selectedFacetValue }) : '';
+
+ const groupString = (() => {
+ if (config?.group) {
+ const label = `Group of ${config.group.name}`;
+ const sanitizedSeriesName = sanitize(params.seriesName as string);
+ const name =
+ sanitizedSeriesName === SERIES_ZERO
+ ? config?.group?.id === config?.facets?.id
+ ? (selectedFacetValue as string)
+ : params.name
+ : sanitizedSeriesName;
+ const color =
+ sanitizedSeriesName === NAN_REPLACEMENT
+ ? VIS_NEUTRAL_COLOR
+ : config?.group?.id === config?.facets?.id
+ ? selectedFacetValue === NAN_REPLACEMENT
+ ? VIS_NEUTRAL_COLOR
+ : (groupColorScale?.(selectedFacetValue as string) ?? VIS_NEUTRAL_COLOR)
+ : (groupColorScale?.(name as string) ?? VIS_NEUTRAL_COLOR);
+
+ if (isGroupedByNumerical) {
+ if (sanitizedSeriesName === NAN_REPLACEMENT) {
+ return generateHTMLString({ label, value: name, color });
+ }
+ if (!name.includes(' to ')) {
+ return generateHTMLString({ label, value: name, color });
+ }
+ const [min, max] = (name ?? '0 to 0').split(' to ');
+ if (!Number.isNaN(Number(min)) && !Number.isNaN(Number(max))) {
+ const formattedMin = new Intl.NumberFormat('en-US', {
+ maximumFractionDigits: 4,
+ maximumSignificantDigits: 4,
+ notation: 'compact',
+ compactDisplay: 'short',
+ }).format(Number(min));
+ const formattedMax = new Intl.NumberFormat('en-US', {
+ maximumFractionDigits: 4,
+ maximumSignificantDigits: 4,
+ notation: 'compact',
+ compactDisplay: 'short',
+ }).format(Number(max));
+ return generateHTMLString({ label, value: `${formattedMin} to ${formattedMax}`, color });
+ }
+ return generateHTMLString({ label, value: params.value as string, color });
+ }
+ return generateHTMLString({ label, value: name, color });
+ }
+ return '';
+ })();
+
+ const aggregateString = generateHTMLString({
+ label: config?.aggregateType === EAggregateTypes.COUNT ? config?.aggregateType : `${config?.aggregateType} of ${config?.aggregateColumn?.name}`,
+ value: params.value as string,
+ });
+
+ const categoryString = generateHTMLString({ label: config?.catColumnSelected?.name as string, value: params.name });
+
+ const tooltipGrid = `${categoryString}${aggregateString}${facetString}${groupString}
`;
+ return tooltipGrid;
+ },
+ },
+
+ label: {
+ show: true,
+ formatter: (params) =>
+ config?.group && config?.groupType === EBarGroupingType.STACK && config?.display === EBarDisplayType.NORMALIZED
+ ? `${params.value}%`
+ : String(params.value),
+ },
+
+ labelLayout: {
+ hideOverlap: true,
+ },
+
+ sampling: 'average',
+ large: true,
+
+ // enable click events on bars -> handled by chartInstance callback in `useChart.mouseEvents.click`
+ triggerEvent: true,
+
+ clip: false,
+ catColumnSelected: config?.catColumnSelected,
+ group: config?.group,
+ }) as BarSeriesOption,
+ [
+ config?.useResponsiveBarWidth,
+ config?.catColumnSelected,
+ config?.group,
+ config?.facets?.name,
+ config?.facets?.id,
+ config?.aggregateType,
+ config?.aggregateColumn?.name,
+ config?.groupType,
+ config?.display,
+ selectedFacetValue,
+ groupColorScale,
+ isGroupedByNumerical,
+ ],
+ );
+
+ const optionBase = React.useMemo(() => {
+ return {
+ animation: false,
+
+ tooltip: {
+ trigger: 'axis',
+ axisPointer: {
+ type: 'shadow',
+ },
+ },
+
+ title: [
+ {
+ text: selectedFacetValue
+ ? `${config?.facets?.name}: ${selectedFacetValue} | ${config?.aggregateType === EAggregateTypes.COUNT ? config?.aggregateType : `${config?.aggregateType} of ${config?.aggregateColumn?.name}`}: ${config?.catColumnSelected?.name}`
+ : `${config?.aggregateType === EAggregateTypes.COUNT ? config?.aggregateType : `${config?.aggregateType} of ${config?.aggregateColumn?.name}`}: ${config?.catColumnSelected?.name}`,
+ triggerEvent: !!config?.facets,
+ left: '50%',
+ textAlign: 'center',
+ name: 'facetTitle',
+ textStyle: {
+ color: '#7F7F7F',
+ fontFamily: 'Roboto, sans-serif',
+ fontSize: '14px',
+ whiteSpace: 'pre',
+ },
+ },
+ ],
+
+ grid: {
+ containLabel: false,
+ left: config?.direction === EBarDirection.HORIZONTAL ? Math.min(gridLeft, containerWidth / 3) : 60, // NOTE: @dv-usama-ansari: Arbitrary fallback value!
+ top: config?.direction === EBarDirection.HORIZONTAL ? 55 : 70, // NOTE: @dv-usama-ansari: Arbitrary value!
+ bottom: config?.direction === EBarDirection.HORIZONTAL ? 55 : 85, // NOTE: @dv-usama-ansari: Arbitrary value!
+ right: 20, // NOTE: @dv-usama-ansari: Arbitrary value!
+ },
+
+ legend: {
+ orient: 'horizontal',
+ top: 30,
+ type: 'scroll',
+ icon: 'circle',
+ show: !!config?.group,
+ data: config?.group
+ ? groupSortedSeries.map((seriesItem) => ({
+ name: seriesItem.name,
+ itemStyle: { color: seriesItem.name === NAN_REPLACEMENT ? VIS_NEUTRAL_COLOR : groupColorScale?.(seriesItem.name as string) },
+ }))
+ : [],
+ formatter: (name: string) => {
+ if (isGroupedByNumerical) {
+ if (name === NAN_REPLACEMENT && !name.includes(' to ')) {
+ return name;
+ }
+ const [min, max] = name.split(' to ');
+ const formattedMin = new Intl.NumberFormat('en-US', {
+ maximumFractionDigits: 4,
+ maximumSignificantDigits: 4,
+ notation: 'compact',
+ compactDisplay: 'short',
+ }).format(Number(min));
+ if (max) {
+ const formattedMax = new Intl.NumberFormat('en-US', {
+ maximumFractionDigits: 4,
+ maximumSignificantDigits: 4,
+ notation: 'compact',
+ compactDisplay: 'short',
+ }).format(Number(max));
+ return `${formattedMin} to ${formattedMax}`;
+ }
+ return formattedMin;
+ }
+ return name;
+ },
+ },
+ } as ECOption;
+ }, [
+ config?.aggregateColumn?.name,
+ config?.aggregateType,
+ config?.catColumnSelected?.name,
+ config?.direction,
+ config?.facets,
+ config?.group,
+ containerWidth,
+ gridLeft,
+ groupColorScale,
+ groupSortedSeries,
+ isGroupedByNumerical,
+ selectedFacetValue,
+ ]);
+
+ const updateSortSideEffect = React.useCallback(
+ ({ barSeries = [] }: { barSeries: (BarSeriesOption & { categories: string[] })[] }) => {
+ if (barSeries.length > 0) {
+ if (config?.direction === EBarDirection.HORIZONTAL) {
+ const sortedSeries = sortSeries(
+ barSeries.map((item) => ({ categories: item.categories, data: item.data })),
+ { sortState: config?.sortState as { x: EBarSortState; y: EBarSortState }, direction: EBarDirection.HORIZONTAL },
+ );
+ setVisState((v) => ({
+ ...v,
+ // NOTE: @dv-usama-ansari: Reverse the data for horizontal bars to show the largest value on top for descending order and vice versa.
+ series: barSeries.map((item, itemIndex) => ({ ...item, data: [...sortedSeries[itemIndex]!.data!].reverse() })),
+ yAxis: {
+ ...v.yAxis,
+ type: 'category' as const,
+ data: [...(sortedSeries[0]?.categories as string[])].reverse(),
+ },
+ }));
+ }
+ if (config?.direction === EBarDirection.VERTICAL) {
+ const sortedSeries = sortSeries(
+ barSeries.map((item) => ({ categories: item.categories, data: item.data })),
+ { sortState: config?.sortState as { x: EBarSortState; y: EBarSortState }, direction: EBarDirection.VERTICAL },
+ );
+
+ setVisState((v) => ({
+ ...v,
+ series: barSeries.map((item, itemIndex) => ({ ...item, data: sortedSeries[itemIndex]!.data })),
+ xAxis: { ...v.xAxis, type: 'category' as const, data: sortedSeries[0]?.categories },
+ }));
+ }
+ }
+ },
+ [config?.direction, config?.sortState, setVisState],
+ );
+
+ const updateDirectionSideEffect = React.useCallback(() => {
+ const aggregationAxisNameBase =
+ config?.group && config?.display === EBarDisplayType.NORMALIZED
+ ? `Normalized ${config?.aggregateType} (%)`
+ : config?.aggregateType === EAggregateTypes.COUNT
+ ? config?.aggregateType
+ : `${config?.aggregateType} of ${config?.aggregateColumn?.name}`;
+ const aggregationAxisSortText =
+ config?.direction === EBarDirection.HORIZONTAL
+ ? SortDirectionMap[config?.sortState?.x as EBarSortState]
+ : config?.direction === EBarDirection.VERTICAL
+ ? SortDirectionMap[config?.sortState?.y as EBarSortState]
+ : '';
+ const aggregationAxisName = `${aggregationAxisNameBase} (${aggregationAxisSortText})`;
+
+ const categoricalAxisNameBase = config?.catColumnSelected?.name;
+ const categoricalAxisSortText =
+ config?.direction === EBarDirection.HORIZONTAL
+ ? SortDirectionMap[config?.sortState?.y as EBarSortState]
+ : config?.direction === EBarDirection.VERTICAL
+ ? SortDirectionMap[config?.sortState?.x as EBarSortState]
+ : '';
+ const categoricalAxisName = `${categoricalAxisNameBase} (${categoricalAxisSortText})`;
+
+ if (config?.direction === EBarDirection.HORIZONTAL) {
+ setVisState((v) => ({
+ ...v,
+
+ xAxis: {
+ type: 'value' as const,
+ name: aggregationAxisName,
+ nameLocation: 'middle',
+ nameGap: 32,
+ min: globalMin ?? 'dataMin',
+ max: globalMax ?? 'dataMax',
+ axisLabel: {
+ hideOverlap: true,
+ formatter: (value: number) => {
+ const formattedValue = new Intl.NumberFormat('en-US', {
+ maximumFractionDigits: 4,
+ maximumSignificantDigits: 4,
+ notation: 'compact',
+ compactDisplay: 'short',
+ }).format(value);
+ return formattedValue;
+ },
+ },
+ nameTextStyle: {
+ color: aggregationAxisSortText !== SortDirectionMap[EBarSortState.NONE] ? selectionColorDark : VIS_NEUTRAL_COLOR,
+ },
+ triggerEvent: true,
+ },
+
+ yAxis: {
+ type: 'category' as const,
+ name: categoricalAxisName,
+ nameLocation: 'middle',
+ nameGap: Math.min(gridLeft, containerWidth / 3) - 20,
+ data: (v.yAxis as { data: number[] })?.data ?? [],
+ axisLabel: {
+ show: true,
+ width: gridLeft - 20,
+ formatter: (value: string) => {
+ const truncatedText = labelsMap[value];
+ return truncatedText;
+ },
+ },
+ nameTextStyle: {
+ color: categoricalAxisSortText !== SortDirectionMap[EBarSortState.NONE] ? selectionColorDark : VIS_NEUTRAL_COLOR,
+ },
+ triggerEvent: true,
+ },
+ }));
+ }
+ if (config?.direction === EBarDirection.VERTICAL) {
+ setVisState((v) => ({
+ ...v,
+
+ // NOTE: @dv-usama-ansari: xAxis is not showing labels as expected for the vertical bar chart.
+ xAxis: {
+ type: 'category' as const,
+ name: categoricalAxisName,
+ nameLocation: 'middle',
+ nameGap: 60,
+ data: (v.xAxis as { data: number[] })?.data ?? [],
+ axisLabel: {
+ show: true,
+ formatter: (value: string) => {
+ const truncatedText = labelsMap[value];
+ return truncatedText;
+ },
+ rotate: 45,
+ },
+ nameTextStyle: {
+ color: categoricalAxisSortText !== SortDirectionMap[EBarSortState.NONE] ? selectionColorDark : VIS_NEUTRAL_COLOR,
+ },
+ triggerEvent: true,
+ },
+
+ yAxis: {
+ type: 'value' as const,
+ name: aggregationAxisName,
+ nameLocation: 'middle',
+ nameGap: 40,
+ min: globalMin ?? 'dataMin',
+ max: globalMax ?? 'dataMax',
+ axisLabel: {
+ hideOverlap: true,
+ formatter: (value: number) => {
+ const formattedValue = new Intl.NumberFormat('en-US', {
+ maximumFractionDigits: 4,
+ maximumSignificantDigits: 4,
+ notation: 'compact',
+ compactDisplay: 'short',
+ }).format(value);
+ return formattedValue;
+ },
+ },
+ nameTextStyle: {
+ color: aggregationAxisSortText !== SortDirectionMap[EBarSortState.NONE] ? selectionColorDark : VIS_NEUTRAL_COLOR,
+ },
+ triggerEvent: true,
+ },
+ }));
+ }
+ }, [
+ config?.aggregateColumn?.name,
+ config?.aggregateType,
+ config?.catColumnSelected?.name,
+ config?.direction,
+ config?.display,
+ config?.group,
+ config?.sortState?.x,
+ config?.sortState?.y,
+ containerWidth,
+ globalMax,
+ globalMin,
+ gridLeft,
+ labelsMap,
+ setVisState,
+ ]);
+
+ const updateCategoriesSideEffect = React.useCallback(() => {
+ const barSeries = (aggregatedData?.groupingsList ?? [])
+ .map((g) =>
+ (['selected', 'unselected'] as const).map((s) => {
+ const data = getDataForAggregationType(g, s);
+
+ if (!data) {
+ return null;
+ }
+ // avoid rendering empty series (bars for a group with all 0 values)
+ if (data.every((d) => Number.isNaN(Number(d.value)) || [Infinity, -Infinity, 0].includes(d.value as number))) {
+ return null;
+ }
+ const isGrouped = config?.group && groupColorScale != null;
+ const isSelected = s === 'selected';
+ const shouldLowerOpacity = hasSelected && isGrouped && !isSelected;
+ const lowerBarOpacity = shouldLowerOpacity ? { opacity: VIS_UNSELECTED_OPACITY } : {};
+ const fixLabelColor = shouldLowerOpacity ? { opacity: 0.5, color: DEFAULT_COLOR } : {};
+
+ return {
+ ...barSeriesBase,
+ name: aggregatedData?.groupingsList.length > 1 ? g : null,
+ label: {
+ ...barSeriesBase.label,
+ ...fixLabelColor,
+ show: config?.group?.id === config?.facets?.id ? true : !(config?.group && config?.groupType === EBarGroupingType.STACK),
+ },
+ emphasis: {
+ label: {
+ show: true,
+ },
+ },
+ itemStyle: {
+ color:
+ g === NAN_REPLACEMENT
+ ? isSelected
+ ? SELECT_COLOR
+ : VIS_NEUTRAL_COLOR
+ : isGrouped
+ ? groupColorScale(g) || VIS_NEUTRAL_COLOR
+ : VIS_NEUTRAL_COLOR,
+
+ ...lowerBarOpacity,
+ },
+ data: data.map((d) => (d.value === 0 ? null : d.value)) as number[],
+ categories: data.map((d) => d.category),
+ group: g,
+ selected: s,
+
+ // group = individual group names, stack = any fixed name
+ stack: config?.groupType === EBarGroupingType.STACK ? 'total' : g,
+ };
+ }),
+ )
+ .flat()
+ .filter(Boolean) as (BarSeriesOption & { categories: string[] })[];
+
+ updateSortSideEffect({ barSeries });
+ updateDirectionSideEffect();
+ }, [
+ aggregatedData?.groupingsList,
+ barSeriesBase,
+ config?.facets?.id,
+ config?.group,
+ config?.groupType,
+ getDataForAggregationType,
+ groupColorScale,
+ hasSelected,
+ updateDirectionSideEffect,
+ updateSortSideEffect,
+ ]);
+
+ const options = React.useMemo(() => {
+ return {
+ ...optionBase,
+ series: groupSortedSeries,
+ ...(visState.xAxis ? { xAxis: visState.xAxis } : {}),
+ ...(visState.yAxis ? { yAxis: visState.yAxis } : {}),
+ } as ECOption;
+ }, [optionBase, groupSortedSeries, visState.xAxis, visState.yAxis]);
+
+ // NOTE: @dv-usama-ansari: This effect is used to update the series data when the direction of the bar chart changes.
+ React.useEffect(() => {
+ updateDirectionSideEffect();
+ }, [config?.direction, updateDirectionSideEffect]);
+
+ // NOTE: @dv-usama-ansari: This effect is used to update the series data when the selected categorical column changes.
+ React.useEffect(() => {
+ updateCategoriesSideEffect();
+ }, [updateCategoriesSideEffect]);
+
+ const settings = React.useMemo(
+ () => ({
+ notMerge: true,
+ }),
+ [],
+ );
+
+ // NOTE: @dv-usama-ansari: Tooltip implementation from: https://codepen.io/plainheart/pen/jOGBrmJ
+ const axisLabelTooltip = React.useMemo(() => {
+ const dom = document.createElement('div');
+ dom.id = 'axis-tooltip';
+ dom.style.position = 'absolute';
+ dom.style.backgroundColor = 'rgba(50,50,50)';
+ dom.style.borderRadius = '4px';
+ dom.style.color = '#FFFFFF';
+ dom.style.fontFamily = 'sans-serif';
+ dom.style.fontSize = '14px';
+ dom.style.opacity = '0';
+ dom.style.padding = '4px 8px';
+ dom.style.transformOrigin = 'bottom';
+ dom.style.visibility = 'hidden';
+ dom.style.zIndex = '9999';
+ dom.style.transition = 'opacity 400ms';
+
+ const content = document.createElement('div');
+ dom.appendChild(content);
+
+ return { dom, content };
+ }, []);
+
+ const [getSortMetadata] = useBarSortHelper({ config: config! });
+
+ const { setRef, instance } = useChart({
+ options,
+ settings,
+ mouseEvents: {
+ click: [
+ {
+ query: { titleIndex: 0 },
+ handler: () => {
+ setConfig?.({ ...config!, focusFacetIndex: config?.focusFacetIndex === selectedFacetIndex ? null : selectedFacetIndex });
+ },
+ },
+ {
+ query: { seriesType: 'bar' },
+ handler: (params) => {
+ const event = params.event?.event as unknown as React.MouseEvent;
+ // NOTE: @dv-usama-ansari: Sanitization is required here since the seriesName contains \u000 which make github confused.
+ const seriesName = sanitize(params.seriesName ?? '') === SERIES_ZERO ? params.name : params.seriesName;
+ const ids: string[] = config?.group
+ ? config.group.id === config?.facets?.id
+ ? [
+ ...(aggregatedData?.categories[params.name]?.groups[selectedFacetValue!]?.unselected.ids ?? []),
+ ...(aggregatedData?.categories[params.name]?.groups[selectedFacetValue!]?.selected.ids ?? []),
+ ]
+ : [
+ ...(aggregatedData?.categories[params.name]?.groups[seriesName as string]?.unselected.ids ?? []),
+ ...(aggregatedData?.categories[params.name]?.groups[seriesName as string]?.selected.ids ?? []),
+ ]
+ : (aggregatedData?.categories[params.name]?.ids ?? []);
+
+ if (event.shiftKey) {
+ // NOTE: @dv-usama-ansari: `shift + click` on a bar which is already selected will deselect it.
+ // Using `Set` to reduce time complexity to O(1).
+ const newSelectedSet = new Set(selectedList);
+ ids.forEach((id) => {
+ if (newSelectedSet.has(id)) {
+ newSelectedSet.delete(id);
+ } else {
+ newSelectedSet.add(id);
+ }
+ });
+ const newSelectedList = [...newSelectedSet];
+ selectionCallback(event, [...new Set([...newSelectedList])]);
+ } else {
+ // NOTE: @dv-usama-ansari: Early return if the bar is clicked and it is already selected?
+ const isSameBarClicked = (selectedList ?? []).length > 0 && (selectedList ?? []).every((id) => ids.includes(id));
+ selectionCallback(event, isSameBarClicked ? [] : ids);
+ }
+ },
+ },
+ {
+ query:
+ config?.direction === EBarDirection.HORIZONTAL
+ ? { componentType: 'yAxis' }
+ : config?.direction === EBarDirection.VERTICAL
+ ? { componentType: 'xAxis' }
+ : { componentType: 'unknown' }, // No event should be triggered when the direction is not set.
+
+ handler: (params) => {
+ if (params.targetType === 'axisLabel') {
+ const event = params.event?.event as unknown as React.MouseEvent;
+ const ids = aggregatedData?.categories[params.value as string]?.ids ?? [];
+ if (event.shiftKey) {
+ const newSelectedSet = new Set(selectedList);
+ ids.forEach((id) => {
+ if (newSelectedSet.has(id)) {
+ newSelectedSet.delete(id);
+ } else {
+ newSelectedSet.add(id);
+ }
+ });
+ const newSelectedList = [...newSelectedSet];
+ selectionCallback(event, [...new Set([...newSelectedList])]);
+ } else {
+ const isSameBarClicked = (selectedList ?? []).length > 0 && (selectedList ?? []).every((id) => ids.includes(id));
+ selectionCallback(event, isSameBarClicked ? [] : ids);
+ }
+ }
+ },
+ },
+ {
+ query: { componentType: 'yAxis' },
+ handler: (params) => {
+ if (params.targetType === 'axisName' && params.componentType === 'yAxis') {
+ if (config?.direction === EBarDirection.HORIZONTAL) {
+ const sortMetadata = getSortMetadata(EBarSortParameters.CATEGORIES);
+ setConfig?.({ ...config!, sortState: sortMetadata.nextSortState });
+ }
+ if (config?.direction === EBarDirection.VERTICAL) {
+ const sortMetadata = getSortMetadata(EBarSortParameters.AGGREGATION);
+ setConfig?.({ ...config!, sortState: sortMetadata.nextSortState });
+ }
+ }
+ },
+ },
+ {
+ query: { componentType: 'xAxis' },
+ handler: (params) => {
+ if (params.targetType === 'axisName' && params.componentType === 'xAxis') {
+ if (config?.direction === EBarDirection.HORIZONTAL) {
+ const sortMetadata = getSortMetadata(EBarSortParameters.AGGREGATION);
+ setConfig?.({ ...config!, sortState: sortMetadata.nextSortState });
+ }
+ if (config?.direction === EBarDirection.VERTICAL) {
+ const sortMetadata = getSortMetadata(EBarSortParameters.CATEGORIES);
+ setConfig?.({ ...config!, sortState: sortMetadata.nextSortState });
+ }
+ }
+ },
+ },
+ ],
+ mouseover: [
+ {
+ query:
+ config?.direction === EBarDirection.HORIZONTAL
+ ? { componentType: 'yAxis' }
+ : config?.direction === EBarDirection.VERTICAL
+ ? { componentType: 'xAxis' }
+ : { componentType: 'unknown' }, // No event should be triggered when the direction is not set.
+ handler: (params) => {
+ if (params.targetType === 'axisLabel') {
+ const currLabel = params.event?.target;
+ const fullText = params.value;
+ const displayText = (currLabel as typeof currLabel & { style: { text: string } }).style.text;
+ if (config?.direction === EBarDirection.VERTICAL || fullText !== displayText) {
+ axisLabelTooltip.content.innerText = fullText as string;
+ axisLabelTooltip.dom.style.opacity = '1';
+ axisLabelTooltip.dom.style.visibility = 'visible';
+ axisLabelTooltip.dom.style.zIndex = '9999';
+
+ const topOffset =
+ config?.direction === EBarDirection.HORIZONTAL
+ ? axisLabelTooltip.dom.offsetHeight * -1.5
+ : config?.direction === EBarDirection.VERTICAL
+ ? axisLabelTooltip.dom.offsetHeight * -1.25
+ : 0;
+ const top = (currLabel?.transform[5] ?? 0) + topOffset;
+ const leftOffset =
+ config?.direction === EBarDirection.HORIZONTAL
+ ? axisLabelTooltip.dom.offsetWidth * -1
+ : config?.direction === EBarDirection.VERTICAL
+ ? axisLabelTooltip.dom.offsetWidth * -0.5
+ : 0;
+ const left = Math.max((currLabel?.transform[4] ?? 0) + leftOffset, 0);
+ axisLabelTooltip.dom.style.top = `${top}px`;
+ axisLabelTooltip.dom.style.left = `${left}px`;
+ }
+ }
+ },
+ },
+ ],
+ mouseout: [
+ {
+ query:
+ config?.direction === EBarDirection.HORIZONTAL
+ ? { componentType: 'yAxis' }
+ : config?.direction === EBarDirection.VERTICAL
+ ? { componentType: 'xAxis' }
+ : { componentType: 'unknown' }, // No event should be triggered when the direction is not set.
+ handler: (params) => {
+ if (params.targetType === 'axisLabel') {
+ axisLabelTooltip.dom.style.opacity = '0';
+ axisLabelTooltip.dom.style.visibility = 'hidden';
+ axisLabelTooltip.dom.style.zIndex = '-1';
+ }
+ },
+ },
+ ],
+ },
+ });
+
+ React.useEffect(() => {
+ if (instance && instance.getDom() && !instance?.getDom()?.querySelector('#axis-tooltip')) {
+ instance.getDom().appendChild(axisLabelTooltip.dom);
+ }
+ }, [axisLabelTooltip.dom, instance]);
+
+ return options ? (
+
+ ) : null;
+}
+
+export const SingleEChartsBarChart = React.memo(EagerSingleEChartsBarChart);
diff --git a/src/vis/bar/barComponents/FocusFacetSelector.tsx b/src/vis/bar/barComponents/FocusFacetSelector.tsx
deleted file mode 100644
index 7979339e8..000000000
--- a/src/vis/bar/barComponents/FocusFacetSelector.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import React from 'react';
-import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons';
-import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { ActionIcon, Group, Select, Tooltip } from '@mantine/core';
-import type { IBarConfig } from '../interfaces';
-import type { ICommonVisProps } from '../../interfaces';
-
-export function FocusFacetSelector({ config, setConfig, facets }: Pick, 'config' | 'setConfig'> & { facets: string[] }) {
- if (!config.facets && facets.length === 0) {
- return null;
- }
-
- return (
-
-
- );
-}
diff --git a/src/vis/bar/barComponents/Legend.tsx b/src/vis/bar/barComponents/Legend.tsx
deleted file mode 100644
index ea1a04da2..000000000
--- a/src/vis/bar/barComponents/Legend.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import { Group, ScrollArea } from '@mantine/core';
-import * as d3v7 from 'd3v7';
-import React from 'react';
-import { LegendItem } from '../../general/LegendItem';
-
-export function Legend({
- categories,
- colorScale,
- left,
- isNumerical = false,
- stepSize = 0,
- filteredOut,
- onFilteredOut,
-}: {
- categories: string[];
- colorScale: d3v7.ScaleOrdinal;
- left: number;
- isNumerical?: boolean;
- stepSize?: number;
- filteredOut: string[];
- onFilteredOut: (id: string) => void;
-}) {
- return (
-
-
- {categories.map((c) => {
- return (
- onFilteredOut(c)}
- />
- );
- })}
-
-
- );
-}
diff --git a/src/vis/bar/barComponents/SingleBar.tsx b/src/vis/bar/barComponents/SingleBar.tsx
deleted file mode 100644
index 9888c8d2a..000000000
--- a/src/vis/bar/barComponents/SingleBar.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import { Tooltip } from '@mantine/core';
-import React from 'react';
-import { animated, useSpring, easings } from 'react-spring';
-import { VIS_NEUTRAL_COLOR, VIS_UNSELECTED_OPACITY } from '../../general/constants';
-import { selectionColorDark } from '../../../utils';
-
-export function SingleBar({
- selectedPercent,
- x,
- width,
- y,
- height,
- tooltip,
- color = VIS_NEUTRAL_COLOR,
- isVertical = true,
- onClick,
- isGroupedOrStacked = false,
-}: {
- selectedPercent: number | null;
- x: number;
- width: number;
- y: number;
- height: number;
- tooltip?: JSX.Element;
- color?: string;
- isVertical?: boolean;
- onClick?: (e: React.MouseEvent) => void;
- isGroupedOrStacked?: boolean;
-}) {
- const style = useSpring({
- config: {
- duration: 500,
- easing: easings.easeOutSine,
- },
- immediate: true,
- to: {
- x,
- y,
- width,
- height,
- },
- });
-
- const selectedRectStyle = useSpring({
- config: {
- duration: 500,
- easing: easings.easeOutSine,
- },
- immediate: true,
- to: {
- x,
- y: isVertical ? y + height - height * selectedPercent : y,
- width: isVertical ? width : width * selectedPercent,
- height: isVertical ? height * selectedPercent : height,
- },
- });
-
- return (
-
- onClick(e)}>
- {selectedPercent === null ? (
-
- ) : (
-
-
-
-
- )}
-
-
- );
-}
diff --git a/src/vis/bar/barComponents/XAxis.tsx b/src/vis/bar/barComponents/XAxis.tsx
deleted file mode 100644
index 58a6c8e19..000000000
--- a/src/vis/bar/barComponents/XAxis.tsx
+++ /dev/null
@@ -1,171 +0,0 @@
-import { Center, Group, Space, Text, Tooltip, rem } from '@mantine/core';
-import * as d3 from 'd3v7';
-import * as React from 'react';
-import { useMemo } from 'react';
-import { useResizeObserver } from '@mantine/hooks';
-import {
- VIS_AXIS_LABEL_SIZE,
- VIS_AXIS_LABEL_SIZE_SMALL,
- VIS_GRID_COLOR,
- VIS_LABEL_COLOR,
- VIS_TICK_LABEL_SIZE,
- VIS_TICK_LABEL_SIZE_SMALL,
-} from '../../general/constants';
-import { ESortStates, SortIcon } from '../../general/SortIcon';
-import { getLabelOrUnknown } from '../../general/utils';
-
-function TickText({
- value,
- shouldRotate,
- setShouldRotateAxisTicks,
- compact,
-}: {
- value: string | number;
- shouldRotate: boolean;
- setShouldRotateAxisTicks?: React.Dispatch>;
- compact: boolean;
-}) {
- const [containerRef] = useResizeObserver();
- const [textRef] = useResizeObserver();
-
- React.useEffect(() => {
- setShouldRotateAxisTicks(textRef.current?.offsetWidth > containerRef.current?.clientWidth);
- }, [containerRef, setShouldRotateAxisTicks, textRef]);
-
- return (
-
-
-
- {getLabelOrUnknown(value)}
-
-
-
- );
-}
-
-// code taken from https://wattenberger.com/blog/react-and-d3
-export function XAxis({
- xScale,
- yRange,
- vertPosition,
- label,
- ticks,
- showLines,
- compact = false,
- sortedAsc = false,
- sortedDesc = false,
- setSortType,
- shouldRotate,
- selectionCallback,
-}: {
- showLines?: boolean;
- xScale: d3.ScaleBand | d3.ScaleLinear;
- yRange: [number, number];
- vertPosition: number;
- label: string;
- ticks: { value: string | number; offset: number }[];
- compact?: boolean;
- sortedAsc?: boolean;
- sortedDesc?: boolean;
- setSortType: (label: string, nextSortState: ESortStates) => void;
- shouldRotate?: boolean;
- selectionCallback?: (e: React.MouseEvent, ids: string[], label?: string) => void;
-}) {
- const tickWidth = useMemo(() => {
- if (ticks.length > 1) {
- return Math.abs(ticks[0].offset - ticks[1].offset);
- }
- return xScale.range()[1] - xScale.range()[0];
- }, [ticks, xScale]);
-
- const [shouldRotateAxisTicks, setShouldRotateAxisTicks] = React.useState(shouldRotate);
-
- return (
- <>
- {ticks.map(({ value, offset }) => (
- selectionCallback?.(e, [], getLabelOrUnknown(value))}
- >
- {/* Ticks for testing - should not be shown! */}
- {/* */}
-
- {showLines ? : null}
-
- {
- setShouldRotateAxisTicks(shouldRotate || v);
- }}
- />
-
-
- ))}
-
-
-
-
-
-
- {getLabelOrUnknown(label)}
-
-
-
- setSortType(label, nextSort)}
- />
-
-
-
- >
- );
-}
diff --git a/src/vis/bar/barComponents/YAxis.tsx b/src/vis/bar/barComponents/YAxis.tsx
deleted file mode 100644
index 8cbadfebc..000000000
--- a/src/vis/bar/barComponents/YAxis.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-import { Center, Group, Space, Text, Tooltip, rem } from '@mantine/core';
-import * as d3 from 'd3v7';
-import * as React from 'react';
-import { useMemo } from 'react';
-import {
- VIS_AXIS_LABEL_SIZE,
- VIS_AXIS_LABEL_SIZE_SMALL,
- VIS_GRID_COLOR,
- VIS_LABEL_COLOR,
- VIS_TICK_LABEL_SIZE,
- VIS_TICK_LABEL_SIZE_SMALL,
-} from '../../general/constants';
-import { ESortStates, SortIcon } from '../../general/SortIcon';
-import { getLabelOrUnknown } from '../../general/utils';
-
-// code taken from https://wattenberger.com/blog/react-and-d3
-export function YAxis({
- yScale,
- xRange,
- horizontalPosition,
- label,
- ticks,
- showLines,
- compact = false,
- sortedAsc = false,
- sortedDesc = false,
- setSortType,
-}: {
- yScale: d3.ScaleBand | d3.ScaleLinear;
- xRange: [number, number];
- horizontalPosition: number;
- label: string;
- ticks: { value: string | number; offset: number }[];
- showLines?: boolean;
- compact?: boolean;
- sortedAsc?: boolean;
- sortedDesc?: boolean;
- setSortType: (label: string, nextSortState: ESortStates) => void;
-}) {
- const labelSpacing = useMemo(() => {
- const maxLabelLength = ticks.reduce((max, { value }) => {
- const { length } = `${value}`;
- return length > max ? length : max;
- }, 0);
-
- return maxLabelLength > 5 ? 30 : maxLabelLength * 6;
- }, [ticks]);
-
- return (
- <>
-
-
-
-
-
-
- {getLabelOrUnknown(label)}
-
-
-
- setSortType(label, nextSort)}
- />
-
-
-
-
- {ticks.map(({ value, offset }) => (
-
- {showLines ? : null}
-
-
-
-
-
- {getLabelOrUnknown(value)}
-
-
-
-
-
-
- ))}
- >
- );
-}
diff --git a/src/vis/bar/barTypes/GroupedBars.tsx b/src/vis/bar/barTypes/GroupedBars.tsx
deleted file mode 100644
index 0eec71cf5..000000000
--- a/src/vis/bar/barTypes/GroupedBars.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import * as d3 from 'd3v7';
-import React from 'react';
-
-import ColumnTable from 'arquero/dist/types/table/column-table';
-
-import { Stack, Text } from '@mantine/core';
-import { escape } from 'arquero';
-import { getLabelOrUnknown } from '../../general/utils';
-import { EAggregateTypes } from '../../interfaces';
-import { SingleBar } from '../barComponents/SingleBar';
-
-export function GroupedBars({
- groupedTable,
- categoryScale,
- countScale,
- categoryName,
- groupName,
- height,
- margin,
- width,
- groupScale,
- groupColorScale,
- isVertical = true,
- hasSelected = false,
- selectionCallback,
- aggregateType,
- aggregateColumnName = null,
-}: {
- groupedTable: ColumnTable;
- categoryScale: d3.ScaleBand;
- countScale: d3.ScaleLinear;
- categoryName: string;
- groupName: string;
- groupScale: d3.ScaleBand;
- groupColorScale: d3.ScaleOrdinal;
- height: number;
- width: number;
- margin: { top: number; bottom: number; left: number; right: number };
- isVertical?: boolean;
- selectionCallback: (e: React.MouseEvent, ids: string[]) => void;
- hasSelected?: boolean;
- aggregateType?: EAggregateTypes;
- aggregateColumnName?: string;
-}) {
- const bars = React.useMemo(() => {
- if (groupedTable && width !== 0 && height !== 0) {
- return groupedTable
- .orderby(
- 'category',
- escape((d) => groupColorScale.domain().indexOf(d.group)),
- )
- .objects()
- .map((row: { category: string; group: string; count: number; aggregateVal: number; selectedCount: number; ids: string[] }) => {
- return (
- selectionCallback(e, row.ids)}
- isVertical={isVertical}
- selectedPercent={hasSelected ? row.selectedCount / row.count : null}
- key={row.category + row.group}
- x={isVertical ? categoryScale(row.category) + groupScale(row.group) : margin.left}
- width={isVertical ? groupScale.bandwidth() : width - margin.right - countScale(row.aggregateVal)}
- y={isVertical ? countScale(row.aggregateVal) : categoryScale(row.category) + groupScale(row.group)}
- tooltip={
-
- {`${categoryName}: ${getLabelOrUnknown(row.category)}`}
- {`${groupName}: ${getLabelOrUnknown(row.group)}`}
- {`${aggregateType}${aggregateColumnName ? ` ${aggregateColumnName}` : ''}: ${row.aggregateVal}`}
-
- }
- height={isVertical ? height - margin.bottom - countScale(row.aggregateVal) : groupScale.bandwidth()}
- color={groupColorScale(row.group)}
- isGroupedOrStacked
- />
- );
- });
- }
- return null;
- }, [
- groupedTable,
- width,
- height,
- isVertical,
- hasSelected,
- categoryScale,
- groupScale,
- margin.left,
- margin.right,
- margin.bottom,
- countScale,
- categoryName,
- groupName,
- aggregateType,
- aggregateColumnName,
- groupColorScale,
- selectionCallback,
- ]);
-
- return {bars};
-}
diff --git a/src/vis/bar/barTypes/SimpleBars.tsx b/src/vis/bar/barTypes/SimpleBars.tsx
deleted file mode 100644
index 48e647436..000000000
--- a/src/vis/bar/barTypes/SimpleBars.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-import React, { useMemo } from 'react';
-
-import * as d3 from 'd3v7';
-
-import ColumnTable from 'arquero/dist/types/table/column-table';
-
-import { Stack, Text } from '@mantine/core';
-import { SingleBar } from '../barComponents/SingleBar';
-import { EAggregateTypes } from '../../interfaces';
-import { getLabelOrUnknown } from '../../general/utils';
-
-export function SimpleBars({
- aggregatedTable,
- categoryScale,
- categoryName,
- countScale,
- height,
- width,
- margin,
- isVertical = true,
- selectionCallback,
- hasSelected = false,
- aggregateType,
- aggregateColumnName = null,
-}: {
- aggregatedTable: ColumnTable;
- categoryScale: d3.ScaleBand;
- categoryName: string;
- countScale: d3.ScaleLinear;
- height: number;
- width: number;
- margin: { top: number; bottom: number; left: number; right: number };
- isVertical?: boolean;
- selectionCallback: (e: React.MouseEvent, ids: string[]) => void;
- hasSelected?: boolean;
- aggregateType: EAggregateTypes;
- aggregateColumnName?: string;
-}) {
- const bars = useMemo(() => {
- if (aggregatedTable && categoryScale && countScale && width !== 0 && height !== 0) {
- return aggregatedTable.objects().map((row: { category: string; count: number; aggregateVal: number; selectedCount: number; ids: string[] }) => {
- return (
- selectionCallback(e, row.ids)}
- isVertical={isVertical}
- selectedPercent={hasSelected ? row.selectedCount / row.count : null}
- key={row.category}
- x={isVertical ? categoryScale(row.category) : margin.left}
- width={isVertical ? categoryScale.bandwidth() : width - margin.right - countScale(row.aggregateVal)}
- y={isVertical ? countScale(row.aggregateVal) : categoryScale(row.category)}
- tooltip={
-
- {`${categoryName}: ${getLabelOrUnknown(row.category)}`}
- {`${aggregateType}${aggregateColumnName ? ` ${getLabelOrUnknown(aggregateColumnName)}` : ''}: ${row.aggregateVal}`}
-
- }
- height={isVertical ? height - margin.bottom - countScale(row.aggregateVal) : categoryScale.bandwidth()}
- />
- );
- });
- }
-
- return null;
- }, [
- aggregatedTable,
- categoryScale,
- countScale,
- width,
- height,
- isVertical,
- hasSelected,
- margin.left,
- margin.right,
- margin.bottom,
- categoryName,
- aggregateType,
- aggregateColumnName,
- selectionCallback,
- ]);
-
- return {bars};
-}
diff --git a/src/vis/bar/barTypes/StackedBars.tsx b/src/vis/bar/barTypes/StackedBars.tsx
deleted file mode 100644
index b3306a141..000000000
--- a/src/vis/bar/barTypes/StackedBars.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-import * as d3 from 'd3v7';
-import React, { useMemo } from 'react';
-
-import { Stack, Text } from '@mantine/core';
-import { escape } from 'arquero';
-import ColumnTable from 'arquero/dist/types/table/column-table';
-import { getLabelOrUnknown } from '../../general/utils';
-import { EAggregateTypes } from '../../interfaces';
-import { SingleBar } from '../barComponents/SingleBar';
-
-export function StackedBars({
- groupedTable,
- categoryScale,
- countScale,
- categoryName,
- groupName,
- height,
- margin,
- groupColorScale,
- width,
- normalized = false,
- isVertical,
- selectionCallback,
- hasSelected,
- aggregateType,
- aggregateColumnName,
-}: {
- groupedTable: ColumnTable;
- categoryScale: d3.ScaleBand;
- categoryName: string;
- groupName: string;
- countScale: d3.ScaleLinear;
- groupColorScale: d3.ScaleOrdinal;
- height: number;
- margin: { top: number; bottom: number; left: number; right: number };
- width: number;
- normalized?: boolean;
- isVertical;
- selectionCallback: (e: React.MouseEvent, ids: string[]) => void;
- hasSelected?: boolean;
- aggregateType: EAggregateTypes;
- aggregateColumnName?: string;
-}) {
- const bars = useMemo(() => {
- if (groupedTable && width !== 0 && height !== 0) {
- let heightSoFar = 0;
- let currentCategory = '';
-
- return groupedTable
- .orderby(
- 'category',
- escape((d) => groupColorScale.domain().indexOf(d.group)),
- )
- .objects()
- .map((row: { category: string; group: string; count: number; aggregateVal: number; categoryCount: number; selectedCount: number; ids: string[] }) => {
- if (currentCategory !== row.category) {
- heightSoFar = 0;
- currentCategory = row.category;
- }
-
- const myHeight = heightSoFar;
- const normalizedCount = normalized ? countScale((countScale.domain()[1] / row.categoryCount) * row.aggregateVal) : countScale(row.aggregateVal);
- if (isVertical) {
- heightSoFar = myHeight + height - margin.bottom - normalizedCount;
- } else {
- heightSoFar = myHeight + width - margin.right - normalizedCount;
- }
-
- return (
- selectionCallback(e, row.ids)}
- isVertical={isVertical}
- selectedPercent={hasSelected ? row.selectedCount / row.count : null}
- key={row.category + row.group}
- x={isVertical ? categoryScale(row.category) : margin.left + myHeight}
- width={isVertical ? categoryScale.bandwidth() : width - margin.right - normalizedCount}
- y={isVertical ? normalizedCount - myHeight : categoryScale(row.category)}
- tooltip={
-
- {`${categoryName}: ${getLabelOrUnknown(row.category)}`}
- {`${groupName}: ${getLabelOrUnknown(row.group)}`}
- {`${aggregateType}${aggregateColumnName ? ` ${aggregateColumnName}` : ''}: ${row.aggregateVal}`}
-
- }
- height={isVertical ? height - margin.bottom - normalizedCount : categoryScale.bandwidth()}
- color={groupColorScale(row.group)}
- isGroupedOrStacked
- />
- );
- });
- }
- return null;
- }, [
- aggregateColumnName,
- aggregateType,
- categoryName,
- categoryScale,
- countScale,
- groupColorScale,
- groupName,
- groupedTable,
- hasSelected,
- height,
- isVertical,
- margin.bottom,
- margin.left,
- margin.right,
- normalized,
- selectionCallback,
- width,
- ]);
-
- return {bars};
-}
diff --git a/src/vis/bar/BarDirectionButtons.tsx b/src/vis/bar/components/BarDirectionButtons.tsx
similarity index 83%
rename from src/vis/bar/BarDirectionButtons.tsx
rename to src/vis/bar/components/BarDirectionButtons.tsx
index 371597b19..65d276e8a 100644
--- a/src/vis/bar/BarDirectionButtons.tsx
+++ b/src/vis/bar/components/BarDirectionButtons.tsx
@@ -1,6 +1,6 @@
import { Input, SegmentedControl } from '@mantine/core';
import * as React from 'react';
-import { EBarDirection } from './interfaces';
+import { EBarDirection } from '../interfaces';
interface BarDirectionProps {
callback: (s: EBarDirection) => void;
@@ -14,7 +14,9 @@ export function BarDirectionButtons({ callback, currentSelected }: BarDirectionP
fullWidth
size="xs"
value={currentSelected}
- onChange={callback}
+ onChange={(s) => {
+ callback(s as EBarDirection);
+ }}
data={[
{ label: EBarDirection.VERTICAL, value: EBarDirection.VERTICAL },
{ label: EBarDirection.HORIZONTAL, value: EBarDirection.HORIZONTAL },
diff --git a/src/vis/bar/components/BarDisplayTypeButtons.tsx b/src/vis/bar/components/BarDisplayTypeButtons.tsx
new file mode 100644
index 000000000..073d329c5
--- /dev/null
+++ b/src/vis/bar/components/BarDisplayTypeButtons.tsx
@@ -0,0 +1,31 @@
+import { Container, SegmentedControl, Stack, Tooltip } from '@mantine/core';
+import * as React from 'react';
+import { EBarDisplayType } from '../interfaces';
+
+interface BarDisplayProps {
+ callback: (s: EBarDisplayType) => void;
+ currentSelected: EBarDisplayType;
+ isCount: boolean;
+}
+
+export function BarDisplayButtons({ callback, currentSelected, isCount }: BarDisplayProps) {
+ return (
+
+
+
+ {
+ callback(s as EBarDisplayType);
+ }}
+ data={[
+ { label: EBarDisplayType.ABSOLUTE, value: EBarDisplayType.ABSOLUTE },
+ { label: EBarDisplayType.NORMALIZED, value: EBarDisplayType.NORMALIZED },
+ ]}
+ />
+
+
+
+ );
+}
diff --git a/src/vis/bar/BarGroupTypeButtons.tsx b/src/vis/bar/components/BarGroupTypeButtons.tsx
similarity index 83%
rename from src/vis/bar/BarGroupTypeButtons.tsx
rename to src/vis/bar/components/BarGroupTypeButtons.tsx
index ebfdf3fa8..d77767c15 100644
--- a/src/vis/bar/BarGroupTypeButtons.tsx
+++ b/src/vis/bar/components/BarGroupTypeButtons.tsx
@@ -1,6 +1,6 @@
import { Container, SegmentedControl, Stack } from '@mantine/core';
import * as React from 'react';
-import { EBarGroupingType } from './interfaces';
+import { EBarGroupingType } from '../interfaces';
interface BarGroupTypeProps {
callback: (s: EBarGroupingType) => void;
@@ -13,7 +13,9 @@ export function BarGroupTypeButtons({ callback, currentSelected }: BarGroupTypeP
{
+ callback(s as EBarGroupingType);
+ }}
data={[
{ label: EBarGroupingType.GROUP, value: EBarGroupingType.GROUP },
{ label: EBarGroupingType.STACK, value: EBarGroupingType.STACK },
diff --git a/src/vis/bar/components/FocusFacetSelector.tsx b/src/vis/bar/components/FocusFacetSelector.tsx
new file mode 100644
index 000000000..a5e34408b
--- /dev/null
+++ b/src/vis/bar/components/FocusFacetSelector.tsx
@@ -0,0 +1,53 @@
+import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { ActionIcon, Group, Select, Tooltip } from '@mantine/core';
+import React from 'react';
+import type { ICommonVisProps } from '../../interfaces';
+import { IBarConfig } from '../interfaces';
+
+export function FocusFacetSelector({ config, setConfig, facets }: Pick, 'config' | 'setConfig'> & { facets: string[] }) {
+ if (!config?.facets && facets.length === 0) {
+ return null;
+ }
+
+ return (
+ config && (
+
+
+ )
+ );
+}
diff --git a/src/vis/bar/GroupSelect.tsx b/src/vis/bar/components/GroupSelect.tsx
similarity index 75%
rename from src/vis/bar/GroupSelect.tsx
rename to src/vis/bar/components/GroupSelect.tsx
index 52946c139..15095bf38 100644
--- a/src/vis/bar/GroupSelect.tsx
+++ b/src/vis/bar/components/GroupSelect.tsx
@@ -1,13 +1,13 @@
import { Stack } from '@mantine/core';
import * as React from 'react';
-import { ColumnInfo, EAggregateTypes, VisColumn } from '../interfaces';
+import { ColumnInfo, EAggregateTypes, VisColumn } from '../../interfaces';
+import { SingleSelect } from '../../sidebar/SingleSelect';
+import { EBarGroupingType, EBarDisplayType } from '../interfaces';
import { BarDisplayButtons } from './BarDisplayTypeButtons';
import { BarGroupTypeButtons } from './BarGroupTypeButtons';
-import { EBarDisplayType, EBarGroupingType } from './interfaces';
-import { SingleSelect } from '../sidebar/SingleSelect';
interface GroupSelectProps {
- groupColumnSelectCallback: (c: ColumnInfo) => void;
+ groupColumnSelectCallback: (c: ColumnInfo | null) => void;
groupTypeSelectCallback: (c: EBarGroupingType) => void;
groupDisplaySelectCallback: (c: EBarDisplayType) => void;
groupType: EBarGroupingType;
@@ -31,9 +31,9 @@ export function GroupSelect({
groupColumnSelectCallback(e ? columns.find((c) => c.info.id === e.id)?.info : null)}
+ callback={(e) => groupColumnSelectCallback(e ? ((columns ?? []).find((c) => c.info.id === e.id)?.info ?? null) : null)}
columns={columns}
- currentSelected={currentSelected}
+ currentSelected={currentSelected!}
columnType={null}
/>
{currentSelected ? (
diff --git a/src/vis/bar/components/index.ts b/src/vis/bar/components/index.ts
new file mode 100644
index 000000000..404a37a50
--- /dev/null
+++ b/src/vis/bar/components/index.ts
@@ -0,0 +1,5 @@
+export * from './BarDirectionButtons';
+export * from './BarDisplayTypeButtons';
+export * from './BarGroupTypeButtons';
+export * from './FocusFacetSelector';
+export * from './GroupSelect';
diff --git a/src/vis/bar/hooks/BarSortHooks.tsx b/src/vis/bar/hooks/BarSortHooks.tsx
new file mode 100644
index 000000000..7d51a9297
--- /dev/null
+++ b/src/vis/bar/hooks/BarSortHooks.tsx
@@ -0,0 +1,137 @@
+import React from 'react';
+import { dvSort, dvSortAsc, dvSortDesc } from '../../../icons';
+import { selectionColorDark } from '../../../utils';
+import { EBarDirection, EBarSortParameters, EBarSortState, IBarConfig } from '../interfaces';
+
+export function useBarSortHelper({ config }: { config: IBarConfig }) {
+ const getSortMetadata = React.useCallback(
+ (sort: EBarSortParameters) => {
+ const fallbackSortState = { x: EBarSortState.NONE, y: EBarSortState.NONE };
+ // NOTE: @dv-usama-ansari: Code optimized for readability.
+ if (config.sortState) {
+ if (sort === EBarSortParameters.CATEGORIES) {
+ if (config.direction === EBarDirection.HORIZONTAL) {
+ if (config.sortState.y === EBarSortState.ASCENDING) {
+ return {
+ tooltipLabel: `Sort ${config.catColumnSelected?.name ?? 'selected category'} in descending order`,
+ icon: dvSortAsc,
+ color: selectionColorDark,
+ nextSortState: { ...fallbackSortState, y: EBarSortState.DESCENDING },
+ };
+ }
+ if (config.sortState.y === EBarSortState.DESCENDING) {
+ return {
+ tooltipLabel: `Remove sorting from ${config.catColumnSelected?.name ?? 'selected category'}`,
+ icon: dvSortDesc,
+ color: selectionColorDark,
+ nextSortState: fallbackSortState,
+ };
+ }
+ if (config.sortState.y === EBarSortState.NONE) {
+ return {
+ tooltipLabel: `Sort ${config.catColumnSelected?.name ?? 'selected category'} in ascending order`,
+ icon: dvSort,
+ color: 'dark',
+ nextSortState: { ...fallbackSortState, y: EBarSortState.ASCENDING },
+ };
+ }
+ }
+ if (config.direction === EBarDirection.VERTICAL) {
+ if (config.sortState.x === EBarSortState.ASCENDING) {
+ return {
+ tooltipLabel: `Sort ${config.catColumnSelected?.name ?? 'selected category'} in descending order`,
+ icon: dvSortAsc,
+ color: selectionColorDark,
+ nextSortState: { ...fallbackSortState, x: EBarSortState.DESCENDING },
+ };
+ }
+ if (config.sortState.x === EBarSortState.DESCENDING) {
+ return {
+ tooltipLabel: `Remove sorting from ${config.catColumnSelected?.name ?? 'selected category'}`,
+ icon: dvSortDesc,
+ color: selectionColorDark,
+ nextSortState: fallbackSortState,
+ };
+ }
+ if (config.sortState.x === EBarSortState.NONE) {
+ return {
+ tooltipLabel: `Sort ${config.catColumnSelected?.name ?? 'selected category'} in ascending order`,
+ icon: dvSort,
+ color: 'dark',
+ nextSortState: { ...fallbackSortState, x: EBarSortState.ASCENDING },
+ };
+ }
+ }
+ }
+ if (sort === EBarSortParameters.AGGREGATION) {
+ if (config.direction === EBarDirection.HORIZONTAL) {
+ if (config.sortState.x === EBarSortState.ASCENDING) {
+ return {
+ tooltipLabel: `Sort ${config.aggregateType} in descending order`,
+ icon: dvSortAsc,
+ color: selectionColorDark,
+ nextSortState: { ...fallbackSortState, x: EBarSortState.DESCENDING },
+ };
+ }
+ if (config.sortState.x === EBarSortState.DESCENDING) {
+ return {
+ tooltipLabel: `Remove sorting from ${config.aggregateType}`,
+ icon: dvSortDesc,
+ color: selectionColorDark,
+ nextSortState: fallbackSortState,
+ };
+ }
+ if (config.sortState.x === EBarSortState.NONE) {
+ return {
+ tooltipLabel: `Sort ${config.aggregateType} in ascending order`,
+ icon: dvSort,
+ color: 'dark',
+ nextSortState: { ...fallbackSortState, x: EBarSortState.ASCENDING },
+ };
+ }
+ }
+ if (config.direction === EBarDirection.VERTICAL) {
+ if (config.sortState.y === EBarSortState.ASCENDING) {
+ return {
+ tooltipLabel: `Sort ${config.aggregateType} in descending order`,
+ icon: dvSortAsc,
+ color: selectionColorDark,
+ nextSortState: { ...fallbackSortState, y: EBarSortState.DESCENDING },
+ };
+ }
+ if (config.sortState.y === EBarSortState.DESCENDING) {
+ return {
+ tooltipLabel: `Remove sorting from ${config.aggregateType}`,
+ icon: dvSortDesc,
+ color: selectionColorDark,
+ nextSortState: fallbackSortState,
+ };
+ }
+ if (config.sortState.y === EBarSortState.NONE) {
+ return {
+ tooltipLabel: `Sort ${config.aggregateType} in ascending order`,
+ icon: dvSort,
+ color: 'dark',
+ nextSortState: { ...fallbackSortState, y: EBarSortState.ASCENDING },
+ };
+ }
+ }
+ }
+ }
+ return {
+ tooltipLabel: `Sort ${sort === EBarSortParameters.CATEGORIES ? config.catColumnSelected?.name : sort === EBarSortParameters.AGGREGATION ? config.aggregateType : 'column'} in ascending order`,
+ icon: dvSort,
+ color: 'dark',
+ nextSortState:
+ config.direction === EBarDirection.HORIZONTAL
+ ? { ...fallbackSortState, x: EBarSortState.ASCENDING }
+ : config.direction === EBarDirection.VERTICAL
+ ? { ...fallbackSortState, y: EBarSortState.ASCENDING }
+ : { ...fallbackSortState },
+ };
+ },
+ [config.aggregateType, config.catColumnSelected?.name, config.direction, config.sortState],
+ );
+
+ return [getSortMetadata] as const;
+}
diff --git a/src/vis/bar/hooks/index.ts b/src/vis/bar/hooks/index.ts
new file mode 100644
index 000000000..ae75c211d
--- /dev/null
+++ b/src/vis/bar/hooks/index.ts
@@ -0,0 +1 @@
+export * from './BarSortHooks';
diff --git a/src/vis/bar/hooks/useGetBarScales.ts b/src/vis/bar/hooks/useGetBarScales.ts
deleted file mode 100644
index 5b14fa6b9..000000000
--- a/src/vis/bar/hooks/useGetBarScales.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-/* eslint-disable @typescript-eslint/ban-ts-comment */
-import ColumnTable from 'arquero/dist/types/table/column-table';
-import { desc, op, table, addFunction } from 'arquero';
-import { useMemo } from 'react';
-import * as d3 from 'd3v7';
-import { getBarData, sortTableBySortType } from '../utils';
-import { SortTypes } from '../interfaces';
-import { EAggregateTypes } from '../../interfaces';
-
-export function useGetBarScales(
- allColumns: Awaited>,
- height: number,
- width: number,
- margin: { top: number; left: number; bottom: number; right: number },
- categoryFilter: string | null,
- isVertical: boolean,
- selectedMap: Record,
- sortType: SortTypes,
- aggregateType: EAggregateTypes,
-): { aggregatedTable: ColumnTable; baseTable: ColumnTable; countScale: d3.ScaleLinear; categoryScale: d3.ScaleBand } {
- const baseTable = useMemo(() => {
- if (allColumns?.catColVals) {
- return table({
- category: allColumns.catColVals.resolvedValues.map((val) => val.val),
- group: allColumns?.groupColVals?.resolvedValues.map((val) => val.val),
- facets: allColumns?.facetsColVals?.resolvedValues.map((val) => val.val) || [],
- selected: allColumns.catColVals.resolvedValues.map((val) => (selectedMap[val.id] ? 1 : 0)),
- aggregateVal: allColumns?.aggregateColVals?.resolvedValues.map((val) => val.val) || [],
- id: allColumns.catColVals.resolvedValues.map((val) => val.id),
- });
- }
-
- return null;
- }, [allColumns, selectedMap]);
-
- const aggregateFunc = useMemo(() => {
- switch (aggregateType) {
- case EAggregateTypes.COUNT:
- return (d) => op.count();
- case EAggregateTypes.AVG:
- return (d) => op.average(d.aggregateVal);
- case EAggregateTypes.MIN:
- return (d) => op.min(d.aggregateVal);
- case EAggregateTypes.MED:
- return (d) => op.median(d.aggregateVal);
- case EAggregateTypes.MAX:
- return (d) => op.max(d.aggregateVal);
- default:
- return (d) => op.count();
- }
- }, [aggregateType]);
-
- const aggregatedTable = useMemo(() => {
- if (allColumns?.catColVals) {
- let myTable = baseTable;
-
- if (categoryFilter && allColumns?.facetsColVals) {
- myTable = baseTable.params({ categoryFilter }).filter((d, $) => d.facets === $.categoryFilter);
- }
-
- addFunction('aggregateFunc', aggregateFunc, { override: true });
-
- return myTable
- .groupby('category')
- .rollup({
- aggregateVal: aggregateFunc,
- count: op.count(),
- selectedCount: (d) => op.sum(d.selected),
- ids: (d) => op.array_agg(d.id),
- })
- .orderby('category');
- }
-
- return null;
- }, [aggregateFunc, allColumns?.catColVals, allColumns?.facetsColVals, baseTable, categoryFilter]);
-
- const countScale = useMemo(() => {
- if (!aggregatedTable) {
- return null;
- }
- return d3
- .scaleLinear()
- .range(isVertical ? [height - margin.bottom, margin.top] : [width - margin.right, margin.left])
- .domain([0, +d3.max(aggregatedTable.array('aggregateVal')) + +d3.max(aggregatedTable.array('aggregateVal')) / 25]);
- }, [aggregatedTable, height, isVertical, margin, width]);
-
- const categoryScale = useMemo(() => {
- if (!aggregatedTable) {
- return null;
- }
- return d3
- .scaleBand()
- .range(isVertical ? [width - margin.right, margin.left] : [height - margin.bottom, margin.top])
- .domain(sortTableBySortType(aggregatedTable, sortType).array('category'))
- .padding(0.2);
- }, [aggregatedTable, height, isVertical, margin.bottom, margin.left, margin.right, margin.top, sortType, width]);
-
- return { aggregatedTable, baseTable, countScale, categoryScale };
-}
diff --git a/src/vis/bar/hooks/useGetGroupedBarScales.ts b/src/vis/bar/hooks/useGetGroupedBarScales.ts
deleted file mode 100644
index 3dc5cd623..000000000
--- a/src/vis/bar/hooks/useGetGroupedBarScales.ts
+++ /dev/null
@@ -1,169 +0,0 @@
-/* eslint-disable @typescript-eslint/ban-ts-comment */
-import { escape, op } from 'arquero';
-import ColumnTable from 'arquero/dist/types/table/column-table';
-import * as d3 from 'd3v7';
-import { useMemo } from 'react';
-import { EAggregateTypes, EColumnTypes } from '../../interfaces';
-import { binByAggregateType, getBarData, groupByAggregateType, rollupByAggregateType } from '../utils';
-import { EBarGroupingType, SortTypes } from '../interfaces';
-import { useGetBarScales } from './useGetBarScales';
-import { categoricalColors as colorScale } from '../../../utils/colors';
-import { assignColorToNullValues } from '../../general/utils';
-
-export function useGetGroupedBarScales(
- allColumns: Awaited>,
- height: number,
- width: number,
- margin: { top: number; left: number; bottom: number; right: number },
- categoryFilter: string | null,
- isVertical: boolean,
- selectedMap: Record,
- groupType: EBarGroupingType,
- sortType: SortTypes,
- aggregateType: EAggregateTypes,
-): {
- aggregatedTable: ColumnTable;
- countScale: d3.ScaleLinear;
- categoryScale: d3.ScaleBand;
- groupedTable: ColumnTable;
- groupColorScale: d3.ScaleOrdinal;
- groupScale: d3.ScaleBand;
-} {
- const { aggregatedTable, categoryScale, countScale, baseTable } = useGetBarScales(
- allColumns,
- height,
- width,
- margin,
- categoryFilter,
- isVertical,
- selectedMap,
- sortType,
- aggregateType,
- );
-
- const groupedTable = useMemo(() => {
- if (!allColumns) {
- return null;
- }
-
- if (allColumns.groupColVals) {
- let filteredTable = baseTable;
-
- if (categoryFilter && allColumns.facetsColVals) {
- filteredTable = baseTable.filter(escape((d) => d.facets === categoryFilter));
- }
-
- return allColumns.groupColVals.type === EColumnTypes.NUMERICAL
- ? binByAggregateType(filteredTable, aggregateType)
- : groupByAggregateType(filteredTable, aggregateType);
- }
-
- return null;
- }, [aggregateType, allColumns, baseTable, categoryFilter]);
-
- const groupColorScale = useMemo(() => {
- if (!groupedTable) {
- return null;
- }
-
- let i = -1;
-
- const newGroup = groupedTable.ungroup().groupby('group').count();
- const domainFromColumn = allColumns?.groupColVals?.domain;
- const domainFromData = newGroup.array('group').sort();
- const domain = domainFromColumn ? [...new Set([...domainFromColumn, ...domainFromData])] : domainFromData;
-
- const categoricalColors = allColumns.groupColVals.color
- ? domain.map((value) => {
- i += 1;
- return allColumns.groupColVals.color[value] || colorScale[i % colorScale.length];
- })
- : assignColorToNullValues(
- Array.from(
- new Set(
- allColumns.groupColVals.resolvedValues.map((val) => {
- return String(val.val); // need to have a string, even if it's 'undefined' or 'null'
- }),
- ),
- ),
- colorScale,
- );
-
- const range =
- allColumns.groupColVals.type === EColumnTypes.NUMERICAL
- ? d3.schemeBlues[newGroup.array('group').length > 3 ? newGroup.array('group').length : 3]
- : categoricalColors;
- return d3.scaleOrdinal().domain(domain).range(range);
- }, [groupedTable, allColumns]);
-
- const groupScale = useMemo(() => {
- if (!groupedTable) {
- return null;
- }
- const newGroup = groupedTable.ungroup().groupby('category', 'group').count();
-
- return d3.scaleBand().range([0, categoryScale.bandwidth()]).domain(newGroup.array('group').sort()).padding(0.1);
- }, [categoryScale, groupedTable]);
-
- const newCountScale = useMemo(() => {
- if (!allColumns) {
- return null;
- }
-
- // No facets, only group
- if (!allColumns.facetsColVals) {
- // No group or group is a stack of count, dont need to change scale
- if (!groupedTable || (groupType === EBarGroupingType.STACK && aggregateType === EAggregateTypes.COUNT)) {
- return countScale;
- }
-
- // Group is a stack of something other than count, change max.
- if (groupType === EBarGroupingType.STACK) {
- const max = +d3.max(
- groupedTable
- .groupby('category')
- .rollup({ sum: (d) => op.sum(d.aggregateVal) })
- .array('sum'),
- );
- return countScale.copy().domain([0, max + max / 25]);
- }
-
- // Group is not stacked, change max.
- const max = +d3.max(groupedTable.array('aggregateVal'));
- return countScale.copy().domain([0, max + max / 25]);
- }
-
- // facets only, or facets and stacked.
- if (!groupedTable || (groupType === EBarGroupingType.STACK && aggregateType === EAggregateTypes.COUNT)) {
- const max = +d3.max(rollupByAggregateType(baseTable.groupby('category', 'facets'), aggregateType).array('aggregateVal'));
- return countScale.copy().domain([0, max + max / 25]);
- }
-
- // facets + stacking with something other than count. Tricky one. Change max
- if (groupType === EBarGroupingType.STACK) {
- const max = +d3.max(
- rollupByAggregateType(baseTable.groupby('category', 'group', 'facets'), aggregateType)
- .groupby('category', 'facets')
- .rollup({ sum: (d) => op.sum(d.aggregateVal) })
- .array('sum'),
- );
- return countScale.copy().domain([0, max + max / 25]);
- }
-
- // facets + grouped but not stacked. Change max.
- const max = +d3.max(rollupByAggregateType(baseTable.groupby('group', 'category', 'facets'), aggregateType).array('aggregateVal'));
-
- const tempScale = countScale.copy().domain([0, max + max / 25]);
-
- return tempScale;
- }, [aggregateType, allColumns, baseTable, countScale, groupType, groupedTable]);
-
- return {
- aggregatedTable,
- countScale: newCountScale,
- categoryScale,
- groupColorScale,
- groupScale,
- groupedTable,
- };
-}
diff --git a/src/vis/bar/index.ts b/src/vis/bar/index.ts
new file mode 100644
index 000000000..870cb6aee
--- /dev/null
+++ b/src/vis/bar/index.ts
@@ -0,0 +1,9 @@
+export * from './components';
+export * from './hooks';
+export * from './interfaces';
+export * from './utils';
+
+export * from './BarChart';
+export * from './BarVis';
+export * from './BarVisSidebar';
+export * from './SingleEChartsBarChart';
diff --git a/src/vis/bar/interfaces.ts b/src/vis/bar/interfaces.ts
deleted file mode 100644
index 38487e167..000000000
--- a/src/vis/bar/interfaces.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { BaseVisConfig, ColumnInfo, EAggregateTypes, ESupportedPlotlyVis } from '../interfaces';
-
-export enum SortTypes {
- NONE = 'NONE',
- CAT_ASC = 'CAT_ASC',
- CAT_DESC = 'CAT_DESC',
- COUNT_ASC = 'COUNT_ASC',
- COUNT_DESC = 'COUNT_DESC',
-}
-
-export enum EBarGroupingType {
- STACK = 'Stacked',
- GROUP = 'Grouped',
-}
-
-export enum EBarDisplayType {
- ABSOLUTE = 'Absolute',
- NORMALIZED = 'Normalized',
-}
-export enum EBarDirection {
- VERTICAL = 'Vertical',
- HORIZONTAL = 'Horizontal',
-}
-
-export interface IBarConfig extends BaseVisConfig {
- type: ESupportedPlotlyVis.BAR;
- facets: ColumnInfo | null;
- focusFacetIndex?: number | null;
- group: ColumnInfo | null;
- direction: EBarDirection;
- display: EBarDisplayType;
- groupType: EBarGroupingType;
- numColumnsSelected: ColumnInfo[];
- catColumnSelected: ColumnInfo;
- aggregateType: EAggregateTypes;
- aggregateColumn: ColumnInfo | null;
- showFocusFacetSelector?: boolean;
- sortType?: SortTypes;
-}
-
-export const defaultConfig: IBarConfig = {
- type: ESupportedPlotlyVis.BAR,
- numColumnsSelected: [],
- catColumnSelected: null,
- group: null,
- groupType: EBarGroupingType.STACK,
- facets: null,
- focusFacetIndex: null,
- display: EBarDisplayType.ABSOLUTE,
- direction: EBarDirection.HORIZONTAL,
- aggregateColumn: null,
- aggregateType: EAggregateTypes.COUNT,
- showFocusFacetSelector: false,
- sortType: SortTypes.NONE,
-};
-
-export function isBarConfig(s: BaseVisConfig): s is IBarConfig {
- return s.type === ESupportedPlotlyVis.BAR;
-}
diff --git a/src/vis/bar/interfaces/constants.ts b/src/vis/bar/interfaces/constants.ts
new file mode 100644
index 000000000..ecfdd7d7a
--- /dev/null
+++ b/src/vis/bar/interfaces/constants.ts
@@ -0,0 +1,21 @@
+import { EAggregateTypes, ESupportedPlotlyVis } from '../../interfaces';
+import { EBarDirection, EBarDisplayType, EBarGroupingType, EBarSortState } from './enums';
+import { IBarConfig } from './interfaces';
+
+export const defaultConfig: IBarConfig = {
+ type: ESupportedPlotlyVis.BAR,
+ numColumnsSelected: [],
+ catColumnSelected: null,
+ group: null,
+ groupType: EBarGroupingType.STACK,
+ facets: null,
+ focusFacetIndex: null,
+ display: EBarDisplayType.ABSOLUTE,
+ direction: EBarDirection.HORIZONTAL,
+ aggregateColumn: null,
+ aggregateType: EAggregateTypes.COUNT,
+ showFocusFacetSelector: false,
+ sortState: { x: EBarSortState.NONE, y: EBarSortState.NONE },
+ useFullHeight: true,
+ useResponsiveBarWidth: false,
+};
diff --git a/src/vis/bar/interfaces/enums.ts b/src/vis/bar/interfaces/enums.ts
new file mode 100644
index 000000000..bfa728bc9
--- /dev/null
+++ b/src/vis/bar/interfaces/enums.ts
@@ -0,0 +1,25 @@
+export enum EBarGroupingType {
+ STACK = 'Stacked',
+ GROUP = 'Grouped',
+}
+
+export enum EBarDisplayType {
+ ABSOLUTE = 'Absolute',
+ NORMALIZED = 'Normalized',
+}
+
+export enum EBarDirection {
+ VERTICAL = 'Vertical',
+ HORIZONTAL = 'Horizontal',
+}
+
+export enum EBarSortState {
+ NONE = 'None',
+ ASCENDING = 'Ascending',
+ DESCENDING = 'Descending',
+}
+
+export enum EBarSortParameters {
+ AGGREGATION = 'Aggregation',
+ CATEGORIES = 'Categories',
+}
diff --git a/src/vis/bar/interfaces/helpers.ts b/src/vis/bar/interfaces/helpers.ts
new file mode 100644
index 000000000..036144ea9
--- /dev/null
+++ b/src/vis/bar/interfaces/helpers.ts
@@ -0,0 +1,6 @@
+import { BaseVisConfig, ESupportedPlotlyVis } from '../../interfaces';
+import { IBarConfig } from './interfaces';
+
+export function isBarConfig(s: BaseVisConfig): s is IBarConfig {
+ return s.type === ESupportedPlotlyVis.BAR;
+}
diff --git a/src/vis/bar/interfaces/index.ts b/src/vis/bar/interfaces/index.ts
new file mode 100644
index 000000000..681297407
--- /dev/null
+++ b/src/vis/bar/interfaces/index.ts
@@ -0,0 +1,6 @@
+export * from './constants';
+export * from './enums';
+export * from './helpers';
+export * from './interfaces';
+export * from './maps';
+export * from './types';
diff --git a/src/vis/bar/interfaces/interfaces.ts b/src/vis/bar/interfaces/interfaces.ts
new file mode 100644
index 000000000..6163370f0
--- /dev/null
+++ b/src/vis/bar/interfaces/interfaces.ts
@@ -0,0 +1,32 @@
+import type { BaseVisConfig, ColumnInfo, EAggregateTypes, ESupportedPlotlyVis } from '../../interfaces';
+import type { EBarDirection, EBarDisplayType, EBarGroupingType, EBarSortState } from './enums';
+
+export interface IBarConfig extends BaseVisConfig {
+ type: ESupportedPlotlyVis.BAR;
+ facets: ColumnInfo | null;
+ focusFacetIndex?: number | null;
+ group: ColumnInfo | null;
+ direction: EBarDirection;
+ display: EBarDisplayType;
+ groupType: EBarGroupingType;
+ numColumnsSelected: ColumnInfo[];
+ catColumnSelected: ColumnInfo | null;
+ aggregateType: EAggregateTypes;
+ aggregateColumn: ColumnInfo | null;
+ showFocusFacetSelector?: boolean;
+ sortState?: { x: EBarSortState; y: EBarSortState };
+ useFullHeight?: boolean;
+ useResponsiveBarWidth?: boolean;
+}
+
+/**
+ * Interface for the data table used in the bar chart.
+ * @internal
+ */
+export interface IBarDataTableRow {
+ id: string;
+ category: string;
+ agg: number;
+ group: string;
+ facet: string;
+}
diff --git a/src/vis/bar/interfaces/internal/constants/bar-chart-container.ts b/src/vis/bar/interfaces/internal/constants/bar-chart-container.ts
new file mode 100644
index 000000000..a60ad8afa
--- /dev/null
+++ b/src/vis/bar/interfaces/internal/constants/bar-chart-container.ts
@@ -0,0 +1,14 @@
+/**
+ * Height margin for the chart to avoid cutting off bars, legend, title, axis labels, etc.
+ */
+export const CHART_HEIGHT_MARGIN = 100;
+
+/**
+ * Fallback height of a bar chart
+ */
+export const DEFAULT_BAR_CHART_HEIGHT = 300;
+
+/**
+ * Fallback height of a bar chart
+ */
+export const DEFAULT_BAR_CHART_MIN_WIDTH = 300;
diff --git a/src/vis/bar/interfaces/internal/constants/bar-plot.ts b/src/vis/bar/interfaces/internal/constants/bar-plot.ts
new file mode 100644
index 000000000..e50fc4cf7
--- /dev/null
+++ b/src/vis/bar/interfaces/internal/constants/bar-plot.ts
@@ -0,0 +1,9 @@
+/**
+ * Width of a single bar
+ */
+export const BAR_WIDTH = 25;
+
+/**
+ * Spacing between bars in a category
+ */
+export const BAR_SPACING = 10;
diff --git a/src/vis/bar/interfaces/internal/constants/facet.ts b/src/vis/bar/interfaces/internal/constants/facet.ts
new file mode 100644
index 000000000..722d96b6f
--- /dev/null
+++ b/src/vis/bar/interfaces/internal/constants/facet.ts
@@ -0,0 +1,4 @@
+/**
+ * Name of default facet when no facet is selected. This value is not displayed to the user.
+ */
+export const DEFAULT_FACET_NAME = '$default$';
diff --git a/src/vis/bar/interfaces/internal/constants/group.ts b/src/vis/bar/interfaces/internal/constants/group.ts
new file mode 100644
index 000000000..3a9b8b54f
--- /dev/null
+++ b/src/vis/bar/interfaces/internal/constants/group.ts
@@ -0,0 +1,4 @@
+/**
+ * The group name for the zero series.
+ */
+export const SERIES_ZERO = 'series0';
diff --git a/src/vis/bar/interfaces/internal/constants/index.ts b/src/vis/bar/interfaces/internal/constants/index.ts
new file mode 100644
index 000000000..329ad80c2
--- /dev/null
+++ b/src/vis/bar/interfaces/internal/constants/index.ts
@@ -0,0 +1,4 @@
+export * from './bar-chart-container';
+export * from './bar-plot';
+export * from './facet';
+export * from './group';
diff --git a/src/vis/bar/interfaces/internal/helpers/calculate-chart-dimensions.test.ts b/src/vis/bar/interfaces/internal/helpers/calculate-chart-dimensions.test.ts
new file mode 100644
index 000000000..3b8e949a8
--- /dev/null
+++ b/src/vis/bar/interfaces/internal/helpers/calculate-chart-dimensions.test.ts
@@ -0,0 +1,214 @@
+import { defaultConfig } from '../../constants';
+import { EBarDirection, EBarGroupingType } from '../../enums';
+import { calculateChartHeight, calculateChartMinWidth } from './calculate-chart-dimensions';
+
+const config = {
+ ...defaultConfig,
+};
+
+describe('Calculate chart height', () => {
+ // TODO: @dv-usama-ansari: Add more tests for different combinations of the following:
+ // - direction
+ // - useResponsiveBarWidth
+ // - groupType
+ // - containerWidth
+ // - useFullHeight
+ // - containerHeight
+ // - aggregatedData small
+ // - aggregatedData large
+
+ it('should return a number', () => {
+ expect(
+ Number.isNaN(
+ Number(
+ calculateChartHeight({
+ config,
+ containerHeight: 150,
+ aggregatedData: { categories: {}, categoriesList: [], groupingsList: [] },
+ }),
+ ),
+ ),
+ ).toBe(false);
+ });
+
+ it('should return a constant value when not using full height in vertical orientation', () => {
+ expect(
+ calculateChartHeight({
+ config: { ...config, useFullHeight: false, direction: EBarDirection.VERTICAL },
+ containerHeight: 150,
+ aggregatedData: {
+ categoriesList: ['Category 1', 'Category 2', 'Unknown'],
+ groupingsList: ['Unknown'],
+ categories: {}, // data not needed for this test
+ },
+ }),
+ ).toBe(300);
+ });
+
+ it('should return a constant value equal to container height minus margins when not using full height', () => {
+ expect(
+ calculateChartHeight({
+ config: { ...config, useFullHeight: true, direction: EBarDirection.VERTICAL },
+ containerHeight: 700,
+ aggregatedData: {
+ categoriesList: ['Category 1', 'Category 2', 'Unknown'],
+ groupingsList: ['Unknown'],
+ categories: {}, // data not needed for this test
+ },
+ }),
+ ).toBe(600);
+ });
+
+ it('should return calculated height for horizontal bars', () => {
+ expect(
+ calculateChartHeight({
+ config,
+ containerHeight: 150,
+ aggregatedData: {
+ categoriesList: ['Category 1', 'Category 2', 'Unknown'],
+ groupingsList: ['Unknown'],
+ categories: {}, // data not needed for this test
+ },
+ }),
+ ).toBe(125);
+ });
+
+ it('should return calculated height for a lot of horizontal bars', () => {
+ expect(
+ calculateChartHeight({
+ config,
+ containerHeight: 150,
+ aggregatedData: {
+ categoriesList: Array.from({ length: 100 }, (_, i) => `Category ${i + 1}`).concat(['Unknown']),
+ groupingsList: ['Unknown'],
+ categories: {}, // data not needed for this test
+ },
+ }),
+ ).toBe(3555);
+ });
+
+ it('should return calculated height for stacked bars', () => {
+ expect(
+ calculateChartHeight({
+ config: { ...config, group: { id: 'group', name: 'Group column', description: '' } },
+ containerHeight: 150,
+ aggregatedData: {
+ categoriesList: ['Category 1', 'Category 2', 'Unknown'],
+ groupingsList: ['Group 1', 'Group 2', 'Group 3', 'Unknown'],
+ categories: {}, // data not needed for this test
+ },
+ }),
+ ).toBe(125);
+ });
+
+ it('should return calculated height for grouped bars', () => {
+ expect(
+ calculateChartHeight({
+ config: { ...config, group: { id: 'group', name: 'Group column', description: '' }, groupType: EBarGroupingType.GROUP },
+ containerHeight: 150,
+ aggregatedData: {
+ categoriesList: ['Category 1', 'Category 2', 'Unknown'],
+ groupingsList: ['Group 1', 'Group 2', 'Group 3', 'Unknown'],
+ categories: {}, // data not needed for this test
+ },
+ }),
+ ).toBe(440);
+ });
+});
+
+describe('Calculate chart min width', () => {
+ it('should return a number', () => {
+ expect(
+ Number.isNaN(
+ Number(
+ calculateChartMinWidth({
+ config,
+ aggregatedData: {
+ categories: {},
+ categoriesList: [],
+ groupingsList: [],
+ },
+ }),
+ ),
+ ),
+ ).toBe(false);
+ });
+
+ it('should return a constant value when not using responsive bar width in vertical orientation', () => {
+ expect(
+ calculateChartMinWidth({
+ config: { ...config, useResponsiveBarWidth: false, direction: EBarDirection.VERTICAL },
+ aggregatedData: {
+ categoriesList: ['Category 1', 'Category 2', 'Unknown'],
+ groupingsList: ['Unknown'],
+ categories: {}, // data not needed for this test
+ },
+ }),
+ ).toBe(125);
+ });
+
+ it('should return a constant value equal to container height minus margins when not using full height', () => {
+ expect(
+ calculateChartMinWidth({
+ config: { ...config, useFullHeight: true, direction: EBarDirection.VERTICAL },
+ aggregatedData: {
+ categoriesList: ['Category 1', 'Category 2', 'Unknown'],
+ groupingsList: ['Unknown'],
+ categories: {}, // data not needed for this test
+ },
+ }),
+ ).toBe(125);
+ });
+
+ it('should return calculated height for horizontal bars', () => {
+ expect(
+ calculateChartMinWidth({
+ config,
+ aggregatedData: {
+ categoriesList: ['Category 1', 'Category 2', 'Unknown'],
+ groupingsList: ['Unknown'],
+ categories: {}, // data not needed for this test
+ },
+ }),
+ ).toBe(300);
+ });
+
+ it('should return calculated height for a lot of horizontal bars', () => {
+ expect(
+ calculateChartMinWidth({
+ config,
+ aggregatedData: {
+ categoriesList: Array.from({ length: 100 }, (_, i) => `Category ${i + 1}`).concat(['Unknown']),
+ groupingsList: ['Unknown'],
+ categories: {}, // data not needed for this test
+ },
+ }),
+ ).toBe(300);
+ });
+
+ it('should return calculated height for stacked bars', () => {
+ expect(
+ calculateChartMinWidth({
+ config: { ...config, group: { id: 'group', name: 'Group column', description: '' } },
+ aggregatedData: {
+ categoriesList: ['Category 1', 'Category 2', 'Unknown'],
+ groupingsList: ['Group 1', 'Group 2', 'Group 3', 'Unknown'],
+ categories: {}, // data not needed for this test
+ },
+ }),
+ ).toBe(300);
+ });
+
+ it('should return calculated height for grouped bars', () => {
+ expect(
+ calculateChartMinWidth({
+ config: { ...config, group: { id: 'group', name: 'Group column', description: '' }, groupType: EBarGroupingType.GROUP },
+ aggregatedData: {
+ categoriesList: ['Category 1', 'Category 2', 'Unknown'],
+ groupingsList: ['Group 1', 'Group 2', 'Group 3', 'Unknown'],
+ categories: {}, // data not needed for this test
+ },
+ }),
+ ).toBe(300);
+ });
+});
diff --git a/src/vis/bar/interfaces/internal/helpers/calculate-chart-dimensions.ts b/src/vis/bar/interfaces/internal/helpers/calculate-chart-dimensions.ts
new file mode 100644
index 000000000..d9797e5b3
--- /dev/null
+++ b/src/vis/bar/interfaces/internal/helpers/calculate-chart-dimensions.ts
@@ -0,0 +1,44 @@
+import { EBarDirection, EBarGroupingType } from '../../enums';
+import { IBarConfig } from '../../interfaces';
+import { BAR_WIDTH, BAR_SPACING, DEFAULT_BAR_CHART_MIN_WIDTH, CHART_HEIGHT_MARGIN, DEFAULT_BAR_CHART_HEIGHT } from '../constants';
+import { AggregatedDataType } from '../types';
+
+export function calculateChartMinWidth({ config, aggregatedData }: { config?: IBarConfig; aggregatedData?: AggregatedDataType }): number {
+ if (config?.direction === EBarDirection.VERTICAL) {
+ // calculate height for horizontal bars
+ const multiplicationFactor = !config?.group ? 1 : config?.groupType === EBarGroupingType.STACK ? 1 : (aggregatedData?.groupingsList ?? []).length;
+ const categoryWidth = ((config?.useResponsiveBarWidth ? 1 : BAR_WIDTH) + BAR_SPACING) * multiplicationFactor;
+ return (aggregatedData?.categoriesList ?? []).length * categoryWidth + 2 * BAR_SPACING;
+ }
+ if (config?.direction === EBarDirection.HORIZONTAL) {
+ // use fixed height for vertical bars
+
+ return DEFAULT_BAR_CHART_MIN_WIDTH;
+ }
+ return DEFAULT_BAR_CHART_MIN_WIDTH;
+}
+
+export function calculateChartHeight({
+ config,
+ aggregatedData,
+ containerHeight,
+}: {
+ config?: IBarConfig;
+ aggregatedData?: AggregatedDataType;
+ containerHeight: number;
+}): number {
+ if (config?.direction === EBarDirection.HORIZONTAL) {
+ // calculate height for horizontal bars
+ const multiplicationFactor = !config?.group ? 1 : config?.groupType === EBarGroupingType.STACK ? 1 : (aggregatedData?.groupingsList ?? []).length;
+ const categoryWidth = (BAR_WIDTH + BAR_SPACING) * multiplicationFactor;
+ return (aggregatedData?.categoriesList ?? []).length * categoryWidth + 2 * BAR_SPACING;
+ }
+ if (config?.direction === EBarDirection.VERTICAL) {
+ // use fixed height for vertical bars
+ if (!config?.facets && config?.useFullHeight) {
+ return containerHeight - CHART_HEIGHT_MARGIN;
+ }
+ return DEFAULT_BAR_CHART_HEIGHT;
+ }
+ return DEFAULT_BAR_CHART_HEIGHT;
+}
diff --git a/src/vis/bar/interfaces/internal/helpers/create-bin-lookup.test.ts b/src/vis/bar/interfaces/internal/helpers/create-bin-lookup.test.ts
new file mode 100644
index 000000000..75a6d9a99
--- /dev/null
+++ b/src/vis/bar/interfaces/internal/helpers/create-bin-lookup.test.ts
@@ -0,0 +1,85 @@
+import { createBinLookup } from './create-bin-lookup';
+
+// NOTE: @dv-usama-ansari: Copied from `BarRandom.stories.tsx`
+function RNG(seed: number, sign: 'positive' | 'negative' | 'mixed' = 'positive') {
+ const m = 2 ** 35 - 31;
+ const a = 185852;
+ let s = seed % m;
+ return () => {
+ let value = ((s = (s * a) % m) / m) * 2 - 1; // Generate values between -1 and 1
+ if (sign === 'positive') {
+ value = Math.abs(value);
+ } else if (sign === 'negative') {
+ value = -Math.abs(value);
+ }
+ return value;
+ };
+}
+
+describe('Create bin lookup', () => {
+ it('should return a map', () => {
+ expect(createBinLookup([])).toBeInstanceOf(Map);
+ });
+
+ it('should return a map with the correct number of bins', () => {
+ const binLookup = createBinLookup(
+ Array.from({ length: 10 }, (_, i) => ({ id: String(i), val: i })),
+ 5,
+ );
+ const bins = Array.from(new Set([...binLookup.values()]));
+
+ expect(bins.length).toBe(5);
+ });
+
+ it('should return fewer bins irrespective of maxBins for small data', () => {
+ const binLookup = createBinLookup(
+ Array.from({ length: 3 }, (_, i) => ({ id: String(i), val: i })),
+ 8,
+ );
+ const bins = Array.from(new Set([...binLookup.values()]));
+
+ expect(bins.length).toBe(2);
+ });
+
+ it('should return correct bins for a single element', () => {
+ const binLookup = createBinLookup([{ id: '1', val: 1 }]);
+ const bins = Array.from(new Set([...binLookup.values()]));
+ expect(bins).toEqual(['1']);
+ });
+
+ it('should return correct bins for null values', () => {
+ const binLookup = createBinLookup(Array.from({ length: 3 }, (_, i) => ({ id: String(i), val: null })));
+ const bins = Array.from(new Set([...binLookup.values()]));
+ expect(bins).toEqual(['Unknown']);
+ });
+
+ it('should return maximum of 8 bins for very large data', () => {
+ const binLookup = createBinLookup(Array.from({ length: 1001 }, (_, i) => ({ id: String(i), val: i })));
+ const bins = Array.from(new Set([...binLookup.values()]));
+ expect(bins.length).toBe(8);
+ expect(bins).toEqual(['0 to 125', '125 to 250', '250 to 375', '375 to 500', '500 to 625', '625 to 750', '750 to 875', '875 to 1000']);
+ });
+
+ it('should return correct number bins for high precision floating point numbers', () => {
+ const binLookup = createBinLookup(
+ Array.from({ length: 10 }, (_, i) => {
+ const val = RNG(i * 100, 'mixed')() * 100;
+ return {
+ id: String(i),
+ val,
+ };
+ }),
+ 5,
+ );
+ const bins = Array.from(new Set([...binLookup.values()]));
+
+ expect(new Set([...binLookup.values()]).size).toBe(5);
+ expect(bins).toEqual([
+ '-100 to -99.8052758162947',
+ '-99.8052758162947 to -99.61055163258939',
+ '-99.6105516325894 to -99.4158274488841',
+ '-99.4158274488841 to -99.2211032651788',
+ '-99.22110326517881 to -99.02637908147351',
+ ]);
+ });
+});
diff --git a/src/vis/bar/interfaces/internal/helpers/create-bin-lookup.ts b/src/vis/bar/interfaces/internal/helpers/create-bin-lookup.ts
new file mode 100644
index 000000000..652be8896
--- /dev/null
+++ b/src/vis/bar/interfaces/internal/helpers/create-bin-lookup.ts
@@ -0,0 +1,70 @@
+import lodashMax from 'lodash/max';
+import lodashMin from 'lodash/min';
+import range from 'lodash/range';
+import { NAN_REPLACEMENT } from '../../../../general';
+import { VisNumericalValue } from '../../../../interfaces';
+
+function binValues(values: number[], numberOfBins: number) {
+ const min = lodashMin(values) || 0;
+ const max = lodashMax(values) || 1;
+ const binSize = (max - min) / numberOfBins;
+
+ if (min === max) {
+ return [{ range: [min, max], values }];
+ }
+
+ // Create bins
+ const bins = range(0, numberOfBins).map((i) => {
+ const lowerBound = min + i * binSize;
+ const upperBound = lowerBound + binSize;
+ return {
+ range: [lowerBound, upperBound],
+ values: values.filter((value) => value >= lowerBound && value < upperBound),
+ };
+ });
+
+ return bins;
+}
+
+/**
+ * Creates a bin lookup map based on the provided data and maximum number of bins.
+ *
+ * @param data - The array of VisNumericalValue objects.
+ * @param maxBins - The maximum number of bins (default: 8).
+ * @returns A Map object with VisNumericalValue keys and string values representing the bin names.
+ */
+export const createBinLookup = (data: VisNumericalValue[], maxBins: number = 8): Map => {
+ // Separate null values from the data
+ const nonNullData = data.filter((row) => row.val !== null);
+ const nullData = data.filter((row) => row.val === null);
+
+ // Extract the numerical values from non-null data
+ const values = nonNullData.map((row) => row.val as number);
+
+ // Create the bins using custom lodash function
+ const bins = binValues(values, maxBins);
+
+ // Create a map to hold the bin names
+ const binMap = new Map();
+
+ // Map bins to our desired structure with names and filter out empty bins
+ bins
+ .filter((bin) => bin.values.length > 0) // Filter out empty bins
+ .forEach((bin) => {
+ const [min, max] = bin.range;
+ const binName = min === max ? `${min || max}` : `${bin.range[0]} to ${bin.range[1]}`;
+ const binRows = nonNullData.filter((row) => bin.values.includes(row.val as number));
+ binRows.forEach((row) => {
+ binMap.set(row, binName);
+ });
+ });
+
+ // Add a separate bin for null values
+ if (nullData.length > 0) {
+ nullData.forEach((row) => {
+ binMap.set(row, NAN_REPLACEMENT);
+ });
+ }
+
+ return binMap;
+};
diff --git a/src/vis/bar/interfaces/internal/helpers/generate-aggregated-data-lookup.test.ts b/src/vis/bar/interfaces/internal/helpers/generate-aggregated-data-lookup.test.ts
new file mode 100644
index 000000000..0d1bdd74a
--- /dev/null
+++ b/src/vis/bar/interfaces/internal/helpers/generate-aggregated-data-lookup.test.ts
@@ -0,0 +1,472 @@
+/* eslint-disable @typescript-eslint/dot-notation */
+import zipWith from 'lodash/zipWith';
+import { getLabelOrUnknown } from '../../../../general/utils';
+import { EAggregateTypes, EColumnTypes, VisNumericalValue } from '../../../../interfaces';
+import { fetchBreastCancerData } from '../../../../stories/fetchBreastCancerData';
+import { defaultConfig } from '../../constants';
+import { EBarGroupingType } from '../../enums';
+import { IBarConfig } from '../../interfaces';
+import { DEFAULT_FACET_NAME } from '../constants';
+import { createBinLookup } from './create-bin-lookup';
+import { generateAggregatedDataLookup } from './generate-aggregated-data-lookup';
+import { getBarData } from './get-bar-data';
+
+async function fetchMockDataTable(config: IBarConfig) {
+ const data = await getBarData(fetchBreastCancerData(), config.catColumnSelected!, config.group, config.facets, config.aggregateColumn);
+ const binLookup = data.groupColVals?.type === EColumnTypes.NUMERICAL ? createBinLookup(data.groupColVals?.resolvedValues as VisNumericalValue[]) : null;
+ return zipWith(
+ data.catColVals?.resolvedValues ?? [], // add array as fallback value to prevent zipWith from dropping the column
+ data.aggregateColVals?.resolvedValues ?? [], // add array as fallback value to prevent zipWith from dropping the column
+ data.groupColVals?.resolvedValues ?? [], // add array as fallback value to prevent zipWith from dropping the column
+ data.facetsColVals?.resolvedValues ?? [], // add array as fallback value to prevent zipWith from dropping the column
+ (cat, agg, group, facet) => {
+ return {
+ id: cat.id,
+ category: getLabelOrUnknown(cat?.val),
+ agg: agg?.val as number,
+ // if the group column is numerical, use the bin lookup to get the bin name, otherwise use the label or 'unknown'
+ group: typeof group?.val === 'number' ? (binLookup?.get(group as VisNumericalValue) as string) : getLabelOrUnknown(group?.val),
+ facet: getLabelOrUnknown(facet?.val),
+ };
+ },
+ );
+}
+
+const config = { ...defaultConfig };
+
+describe('Generate aggregated data lookup', () => {
+ // TODO: @dv-usama-ansari: Add tests for generateAggregatedDataLookup:
+ // - dataTable: non-faceted data
+ // - dataTable: faceted data
+ // - groupingsList and categoriesList are calculated correctly
+ // - globalMin and globalMax are calculated correctly
+ // - data: sum, count, nums and ids are populated correctly
+ // - **Good to have** check if the function uses multiple threads
+ it('should return an instance of object', async () => {
+ const lookupParams: Parameters['0'] = {
+ isFaceted: !!config.facets,
+ isGrouped: !!config.group,
+ aggregateType: config.aggregateType,
+ display: config.display,
+ groupType: config.groupType,
+ };
+ const dataTable: Parameters['1'] = [];
+ const selectedMap: Parameters['2'] = {};
+ const aggregatedDataLookup = generateAggregatedDataLookup(lookupParams, dataTable, selectedMap);
+ expect(aggregatedDataLookup).toBeInstanceOf(Object);
+ });
+
+ it('should return aggregated lookup of breast cancer data', async () => {
+ const lookupParams: Parameters['0'] = {
+ isFaceted: !!config.facets,
+ isGrouped: !!config.group,
+ aggregateType: config.aggregateType,
+ display: config.display,
+ groupType: config.groupType,
+ };
+ const dataTable: Parameters['1'] = await fetchMockDataTable({
+ ...config,
+ catColumnSelected: { id: 'breastSurgeryType', name: 'Breast surgery type', description: '' },
+ aggregateType: EAggregateTypes.COUNT,
+ });
+ const selectedMap: Parameters['2'] = {};
+ const aggregatedDataLookup = generateAggregatedDataLookup(lookupParams, dataTable, selectedMap);
+ expect(Object.keys(aggregatedDataLookup.facets)[0]).toBe(DEFAULT_FACET_NAME);
+ expect(aggregatedDataLookup.facetsList).toEqual([DEFAULT_FACET_NAME]);
+ expect(aggregatedDataLookup.globalDomain.min).toEqual(0);
+ expect(aggregatedDataLookup.globalDomain.max).toEqual(1010);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categoriesList).toEqual(['BREAST CONSERVING', 'MASTECTOMY', 'Unknown']);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.groupingsList).toEqual(['Unknown']);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['BREAST CONSERVING']?.total).toEqual(674);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['BREAST CONSERVING']?.groups['Unknown']?.selected.count).toEqual(0);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['BREAST CONSERVING']?.groups['Unknown']?.selected.nums).toEqual([]);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['BREAST CONSERVING']?.groups['Unknown']?.unselected.count).toEqual(674);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['BREAST CONSERVING']?.groups['Unknown']?.unselected.nums.length).toEqual(674);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['MASTECTOMY']?.total).toEqual(1010);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['Unknown']?.total).toEqual(220);
+ });
+
+ describe('Global Domain based on Aggregate Types', () => {
+ it('should return the correct aggregate values and global domain for a column with AVERAGE aggregate type', async () => {
+ const lookupParams: Parameters['0'] = {
+ isFaceted: !!config.facets,
+ isGrouped: !!config.group,
+ aggregateType: EAggregateTypes.AVG,
+ display: config.display,
+ groupType: config.groupType,
+ };
+ const dataTable: Parameters['1'] = await fetchMockDataTable({
+ ...config,
+ catColumnSelected: { id: 'breastSurgeryType', name: 'Breast surgery type', description: '' },
+ aggregateType: EAggregateTypes.AVG,
+ aggregateColumn: { id: 'tumorSize', name: 'Tumor size', description: '' },
+ });
+ const selectedMap: Parameters['2'] = {};
+ const aggregatedDataLookup = generateAggregatedDataLookup(lookupParams, dataTable, selectedMap);
+ expect(aggregatedDataLookup.globalDomain.min).toEqual(0);
+ expect(aggregatedDataLookup.globalDomain.max).toEqual(28.7782);
+ });
+
+ it('should return the correct aggregate values and global domain for a column with MINIMUM aggregate type', async () => {
+ const lookupParams: Parameters['0'] = {
+ isFaceted: !!config.facets,
+ isGrouped: !!config.group,
+ aggregateType: EAggregateTypes.MIN,
+ display: config.display,
+ groupType: config.groupType,
+ };
+ const dataTable: Parameters['1'] = await fetchMockDataTable({
+ ...config,
+ catColumnSelected: { id: 'breastSurgeryType', name: 'Breast surgery type', description: '' },
+ aggregateType: EAggregateTypes.MIN,
+ aggregateColumn: { id: 'tumorSize', name: 'Tumor size', description: '' },
+ });
+ const selectedMap: Parameters['2'] = {};
+ const aggregatedDataLookup = generateAggregatedDataLookup(lookupParams, dataTable, selectedMap);
+ expect(aggregatedDataLookup.globalDomain.min).toEqual(0);
+ expect(aggregatedDataLookup.globalDomain.max).toEqual(8);
+ });
+
+ it('should return the correct aggregate values and global domain for a column with MAXIMUM aggregate type', async () => {
+ const lookupParams: Parameters['0'] = {
+ isFaceted: !!config.facets,
+ isGrouped: !!config.group,
+ aggregateType: EAggregateTypes.MAX,
+ display: config.display,
+ groupType: config.groupType,
+ };
+ const dataTable: Parameters['1'] = await fetchMockDataTable({
+ ...config,
+ catColumnSelected: { id: 'breastSurgeryType', name: 'Breast surgery type', description: '' },
+ aggregateType: EAggregateTypes.MAX,
+ aggregateColumn: { id: 'tumorSize', name: 'Tumor size', description: '' },
+ });
+ const selectedMap: Parameters['2'] = {};
+ const aggregatedDataLookup = generateAggregatedDataLookup(lookupParams, dataTable, selectedMap);
+ expect(aggregatedDataLookup.globalDomain.min).toEqual(0);
+ expect(aggregatedDataLookup.globalDomain.max).toEqual(182);
+ });
+
+ it('should return the correct aggregate values and global domain for a column with MEDIAN aggregate type', async () => {
+ const lookupParams: Parameters['0'] = {
+ isFaceted: !!config.facets,
+ isGrouped: !!config.group,
+ aggregateType: EAggregateTypes.MED,
+ display: config.display,
+ groupType: config.groupType,
+ };
+ const dataTable: Parameters['1'] = await fetchMockDataTable({
+ ...config,
+ catColumnSelected: { id: 'breastSurgeryType', name: 'Breast surgery type', description: '' },
+ aggregateType: EAggregateTypes.MED,
+ aggregateColumn: { id: 'tumorSize', name: 'Tumor size', description: '' },
+ });
+ const selectedMap: Parameters['2'] = {};
+ const aggregatedDataLookup = generateAggregatedDataLookup(lookupParams, dataTable, selectedMap);
+ expect(aggregatedDataLookup.globalDomain.min).toEqual(0);
+ expect(aggregatedDataLookup.globalDomain.max).toEqual(25);
+ });
+ });
+
+ describe('Grouped data', () => {
+ it('should return the correct aggregate values and global domain for a column with COUNT aggregate type and stacked data', async () => {
+ const lookupParams: Parameters['0'] = {
+ isFaceted: !!config.facets,
+ isGrouped: true,
+ aggregateType: config.aggregateType,
+ display: config.display,
+ groupType: config.groupType,
+ };
+ const dataTable: Parameters['1'] = await fetchMockDataTable({
+ ...config,
+ catColumnSelected: { id: 'breastSurgeryType', name: 'Breast surgery type', description: '' },
+ group: { id: 'cellularity', name: 'Cellularity', description: '' },
+ });
+ const selectedMap: Parameters['2'] = {};
+ const aggregatedDataLookup = generateAggregatedDataLookup(lookupParams, dataTable, selectedMap);
+ expect(aggregatedDataLookup.globalDomain.min).toEqual(0);
+ expect(aggregatedDataLookup.globalDomain.max).toEqual(1010);
+ expect(aggregatedDataLookup.facetsList).toEqual([DEFAULT_FACET_NAME]);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categoriesList).toEqual(['BREAST CONSERVING', 'MASTECTOMY', 'Unknown']);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.groupingsList).toEqual(['High', 'Low', 'Moderate', 'Unknown']);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['BREAST CONSERVING']?.total).toEqual(674);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['BREAST CONSERVING']?.groups['High']?.unselected.count).toEqual(344);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['BREAST CONSERVING']?.groups['Low']?.unselected.count).toEqual(69);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['BREAST CONSERVING']?.groups['Moderate']?.unselected.count).toEqual(239);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['BREAST CONSERVING']?.groups['Unknown']?.unselected.count).toEqual(22);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['MASTECTOMY']?.total).toEqual(1010);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['MASTECTOMY']?.groups['High']?.unselected.count).toEqual(484);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['MASTECTOMY']?.groups['Low']?.unselected.count).toEqual(111);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['MASTECTOMY']?.groups['Moderate']?.unselected.count).toEqual(389);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['MASTECTOMY']?.groups['Unknown']?.unselected.count).toEqual(26);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['Unknown']?.total).toEqual(220);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['Unknown']?.groups['High']?.unselected.count).toEqual(111);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['Unknown']?.groups['Low']?.unselected.count).toEqual(20);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['Unknown']?.groups['Moderate']?.unselected.count).toEqual(83);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['Unknown']?.groups['Unknown']?.unselected.count).toEqual(6);
+ });
+
+ it('should return the correct aggregate values and global domain for a column with COUNT aggregate type and grouped data', async () => {
+ const lookupParams: Parameters['0'] = {
+ isFaceted: !!config.facets,
+ isGrouped: true,
+ aggregateType: config.aggregateType,
+ display: config.display,
+ groupType: config.groupType,
+ };
+ const dataTable: Parameters['1'] = await fetchMockDataTable({
+ ...config,
+ catColumnSelected: { id: 'breastSurgeryType', name: 'Breast surgery type', description: '' },
+ group: { id: 'cellularity', name: 'Cellularity', description: '' },
+ groupType: EBarGroupingType.GROUP,
+ });
+ const selectedMap: Parameters['2'] = {};
+ const aggregatedDataLookup = generateAggregatedDataLookup(lookupParams, dataTable, selectedMap);
+ expect(aggregatedDataLookup.globalDomain.min).toEqual(0);
+ expect(aggregatedDataLookup.globalDomain.max).toEqual(1010);
+ expect(aggregatedDataLookup.facetsList).toEqual([DEFAULT_FACET_NAME]);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categoriesList).toEqual(['BREAST CONSERVING', 'MASTECTOMY', 'Unknown']);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.groupingsList).toEqual(['High', 'Low', 'Moderate', 'Unknown']);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['BREAST CONSERVING']?.total).toEqual(674);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['BREAST CONSERVING']?.groups['High']?.unselected.count).toEqual(344);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['BREAST CONSERVING']?.groups['Low']?.unselected.count).toEqual(69);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['BREAST CONSERVING']?.groups['Moderate']?.unselected.count).toEqual(239);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['BREAST CONSERVING']?.groups['Unknown']?.unselected.count).toEqual(22);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['MASTECTOMY']?.total).toEqual(1010);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['MASTECTOMY']?.groups['High']?.unselected.count).toEqual(484);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['MASTECTOMY']?.groups['Low']?.unselected.count).toEqual(111);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['MASTECTOMY']?.groups['Moderate']?.unselected.count).toEqual(389);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['MASTECTOMY']?.groups['Unknown']?.unselected.count).toEqual(26);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['Unknown']?.total).toEqual(220);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['Unknown']?.groups['High']?.unselected.count).toEqual(111);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['Unknown']?.groups['Low']?.unselected.count).toEqual(20);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['Unknown']?.groups['Moderate']?.unselected.count).toEqual(83);
+ expect(aggregatedDataLookup.facets[DEFAULT_FACET_NAME]?.categories['Unknown']?.groups['Unknown']?.unselected.count).toEqual(6);
+ });
+
+ it('should return the correct aggregate values and global domain for a column with AVERAGE aggregate type and stacked data', async () => {
+ const lookupParams: Parameters['0'] = {
+ isFaceted: !!config.facets,
+ isGrouped: true,
+ aggregateType: EAggregateTypes.AVG,
+ display: config.display,
+ groupType: config.groupType,
+ };
+ const dataTable: Parameters['1'] = await fetchMockDataTable({
+ ...config,
+ catColumnSelected: { id: 'breastSurgeryType', name: 'Breast surgery type', description: '' },
+ aggregateType: EAggregateTypes.AVG,
+ aggregateColumn: { id: 'tumorSize', name: 'Tumor size', description: '' },
+ group: { id: 'cellularity', name: 'Cellularity', description: '' },
+ });
+ const selectedMap: Parameters['2'] = {};
+ const aggregatedDataLookup = generateAggregatedDataLookup(lookupParams, dataTable, selectedMap);
+ expect(aggregatedDataLookup.globalDomain.min).toEqual(0);
+ expect(aggregatedDataLookup.globalDomain.max).toEqual(106.8865);
+ });
+
+ it('should return the correct aggregate values and global domain for a column with AVERAGE aggregate type and grouped data', async () => {
+ const lookupParams: Parameters['0'] = {
+ isFaceted: !!config.facets,
+ isGrouped: true,
+ aggregateType: EAggregateTypes.AVG,
+ display: config.display,
+ groupType: EBarGroupingType.GROUP,
+ };
+ const dataTable: Parameters['1'] = await fetchMockDataTable({
+ ...config,
+ catColumnSelected: { id: 'breastSurgeryType', name: 'Breast surgery type', description: '' },
+ aggregateType: EAggregateTypes.AVG,
+ aggregateColumn: { id: 'tumorSize', name: 'Tumor size', description: '' },
+ group: { id: 'cellularity', name: 'Cellularity', description: '' },
+ groupType: EBarGroupingType.GROUP,
+ });
+ const selectedMap: Parameters['2'] = {};
+ const aggregatedDataLookup = generateAggregatedDataLookup(lookupParams, dataTable, selectedMap);
+ expect(aggregatedDataLookup.globalDomain.min).toEqual(0);
+ expect(aggregatedDataLookup.globalDomain.max).toEqual(30.3023);
+ });
+
+ it('should return the correct aggregate values and global domain for a column with MINIMUM aggregate type and stacked data', async () => {
+ const lookupParams: Parameters['0'] = {
+ isFaceted: !!config.facets,
+ isGrouped: true,
+ aggregateType: EAggregateTypes.MIN,
+ display: config.display,
+ groupType: config.groupType,
+ };
+ const dataTable: Parameters['1'] = await fetchMockDataTable({
+ ...config,
+ catColumnSelected: { id: 'breastSurgeryType', name: 'Breast surgery type', description: '' },
+ aggregateType: EAggregateTypes.MIN,
+ aggregateColumn: { id: 'tumorSize', name: 'Tumor size', description: '' },
+ group: { id: 'cellularity', name: 'Cellularity', description: '' },
+ });
+ const selectedMap: Parameters['2'] = {};
+ const aggregatedDataLookup = generateAggregatedDataLookup(lookupParams, dataTable, selectedMap);
+ expect(aggregatedDataLookup.globalDomain.min).toEqual(0);
+ expect(aggregatedDataLookup.globalDomain.max).toEqual(37);
+ });
+
+ it('should return the correct aggregate values and global domain for a column with MINIMUM aggregate type and grouped data', async () => {
+ const lookupParams: Parameters['0'] = {
+ isFaceted: !!config.facets,
+ isGrouped: true,
+ aggregateType: EAggregateTypes.MIN,
+ display: config.display,
+ groupType: EBarGroupingType.GROUP,
+ };
+ const dataTable: Parameters['1'] = await fetchMockDataTable({
+ ...config,
+ catColumnSelected: { id: 'breastSurgeryType', name: 'Breast surgery type', description: '' },
+ aggregateType: EAggregateTypes.MIN,
+ aggregateColumn: { id: 'tumorSize', name: 'Tumor size', description: '' },
+ group: { id: 'cellularity', name: 'Cellularity', description: '' },
+ groupType: EBarGroupingType.GROUP,
+ });
+ const selectedMap: Parameters['2'] = {};
+ const aggregatedDataLookup = generateAggregatedDataLookup(lookupParams, dataTable, selectedMap);
+ expect(aggregatedDataLookup.globalDomain.min).toEqual(0);
+ expect(aggregatedDataLookup.globalDomain.max).toEqual(10);
+ });
+
+ it('should return the correct aggregate values and global domain for a column with MAXIMUM aggregate type and stacked data', async () => {
+ const lookupParams: Parameters['0'] = {
+ isFaceted: !!config.facets,
+ isGrouped: true,
+ aggregateType: EAggregateTypes.MAX,
+ display: config.display,
+ groupType: config.groupType,
+ };
+ const dataTable: Parameters['1'] = await fetchMockDataTable({
+ ...config,
+ catColumnSelected: { id: 'breastSurgeryType', name: 'Breast surgery type', description: '' },
+ aggregateType: EAggregateTypes.MAX,
+ aggregateColumn: { id: 'tumorSize', name: 'Tumor size', description: '' },
+ group: { id: 'cellularity', name: 'Cellularity', description: '' },
+ });
+ const selectedMap: Parameters['2'] = {};
+ const aggregatedDataLookup = generateAggregatedDataLookup(lookupParams, dataTable, selectedMap);
+ expect(aggregatedDataLookup.globalDomain.min).toEqual(0);
+ expect(aggregatedDataLookup.globalDomain.max).toEqual(466);
+ });
+
+ it('should return the correct aggregate values and global domain for a column with MAXIMUM aggregate type and grouped data', async () => {
+ const lookupParams: Parameters['0'] = {
+ isFaceted: !!config.facets,
+ isGrouped: true,
+ aggregateType: EAggregateTypes.MAX,
+ display: config.display,
+ groupType: EBarGroupingType.GROUP,
+ };
+ const dataTable: Parameters['1'] = await fetchMockDataTable({
+ ...config,
+ catColumnSelected: { id: 'breastSurgeryType', name: 'Breast surgery type', description: '' },
+ aggregateType: EAggregateTypes.MAX,
+ aggregateColumn: { id: 'tumorSize', name: 'Tumor size', description: '' },
+ group: { id: 'cellularity', name: 'Cellularity', description: '' },
+ groupType: EBarGroupingType.GROUP,
+ });
+ const selectedMap: Parameters['2'] = {};
+ const aggregatedDataLookup = generateAggregatedDataLookup(lookupParams, dataTable, selectedMap);
+ expect(aggregatedDataLookup.globalDomain.min).toEqual(0);
+ expect(aggregatedDataLookup.globalDomain.max).toEqual(182);
+ });
+
+ it('should return the correct aggregate values and global domain for a column with MEDIAN aggregate type and stacked data', async () => {
+ const lookupParams: Parameters['0'] = {
+ isFaceted: !!config.facets,
+ isGrouped: true,
+ aggregateType: EAggregateTypes.MED,
+ display: config.display,
+ groupType: config.groupType,
+ };
+ const dataTable: Parameters['1'] = await fetchMockDataTable({
+ ...config,
+ catColumnSelected: { id: 'breastSurgeryType', name: 'Breast surgery type', description: '' },
+ aggregateType: EAggregateTypes.MED,
+ aggregateColumn: { id: 'tumorSize', name: 'Tumor size', description: '' },
+ group: { id: 'cellularity', name: 'Cellularity', description: '' },
+ });
+ const selectedMap: Parameters['2'] = {};
+ const aggregatedDataLookup = generateAggregatedDataLookup(lookupParams, dataTable, selectedMap);
+ expect(aggregatedDataLookup.globalDomain.min).toEqual(0);
+ expect(aggregatedDataLookup.globalDomain.max).toEqual(97.5);
+ });
+
+ it('should return the correct aggregate values and global domain for a column with MEDIAN aggregate type and grouped data', async () => {
+ const lookupParams: Parameters['0'] = {
+ isFaceted: !!config.facets,
+ isGrouped: true,
+ aggregateType: EAggregateTypes.MED,
+ display: config.display,
+ groupType: EBarGroupingType.GROUP,
+ };
+ const dataTable: Parameters['1'] = await fetchMockDataTable({
+ ...config,
+ catColumnSelected: { id: 'breastSurgeryType', name: 'Breast surgery type', description: '' },
+ aggregateType: EAggregateTypes.MED,
+ aggregateColumn: { id: 'tumorSize', name: 'Tumor size', description: '' },
+ group: { id: 'cellularity', name: 'Cellularity', description: '' },
+ groupType: EBarGroupingType.GROUP,
+ });
+ const selectedMap: Parameters['2'] = {};
+ const aggregatedDataLookup = generateAggregatedDataLookup(lookupParams, dataTable, selectedMap);
+ expect(aggregatedDataLookup.globalDomain.min).toEqual(0);
+ expect(aggregatedDataLookup.globalDomain.max).toEqual(25.5);
+ });
+
+ it('should return the correct aggregate values and global domain for SAME group and facet columns', async () => {
+ const lookupParams: Parameters['0'] = {
+ isFaceted: true,
+ isGrouped: true,
+ aggregateType: config.aggregateType,
+ display: config.display,
+ groupType: EBarGroupingType.GROUP,
+ };
+ const dataTable: Parameters['1'] = await fetchMockDataTable({
+ ...config,
+ catColumnSelected: { id: 'breastSurgeryType', name: 'Breast surgery type', description: '' },
+ aggregateType: EAggregateTypes.COUNT,
+ group: { id: 'cellularity', name: 'Cellularity', description: '' },
+ });
+ const selectedMap: Parameters['2'] = {};
+ const aggregatedDataLookup = generateAggregatedDataLookup(lookupParams, dataTable, selectedMap);
+ expect(aggregatedDataLookup.globalDomain.min).toEqual(0);
+ expect(aggregatedDataLookup.globalDomain.max).toEqual(484);
+ expect(aggregatedDataLookup.facetsList).toEqual(['Unknown']);
+ expect(aggregatedDataLookup.facets['Unknown']?.categoriesList).toEqual(['BREAST CONSERVING', 'MASTECTOMY', 'Unknown']);
+ expect(aggregatedDataLookup.facets['Unknown']?.groupingsList).toEqual(['High', 'Low', 'Moderate', 'Unknown']);
+ });
+ });
+
+ describe('Faceted data', () => {
+ it('should return the correct aggregate values and global domain for grouped and faceted data', async () => {
+ const lookupParams: Parameters['0'] = {
+ isFaceted: true,
+ isGrouped: true,
+ aggregateType: config.aggregateType,
+ display: config.display,
+ groupType: config.groupType,
+ };
+ const dataTable: Parameters['1'] = await fetchMockDataTable({
+ ...config,
+ catColumnSelected: { id: 'breastSurgeryType', name: 'Breast surgery type', description: '' },
+ aggregateType: EAggregateTypes.COUNT,
+ group: { id: 'cellularity', name: 'Cellularity', description: '' },
+ facets: { id: 'deathFromCancer', name: 'Death from cancer', description: '' },
+ });
+ const selectedMap: Parameters['2'] = {};
+ const aggregatedDataLookup = generateAggregatedDataLookup(lookupParams, dataTable, selectedMap);
+ expect(aggregatedDataLookup.globalDomain.min).toEqual(0);
+ expect(aggregatedDataLookup.globalDomain.max).toEqual(372);
+ expect(aggregatedDataLookup.facetsList).toEqual(['Living', 'Died of Disease', 'Died of Other Causes', 'Unknown']);
+ expect(aggregatedDataLookup.facets['Living']?.categoriesList).toEqual(['BREAST CONSERVING', 'MASTECTOMY', 'Unknown']);
+ expect(aggregatedDataLookup.facets['Died of Disease']?.categoriesList).toEqual(['BREAST CONSERVING', 'MASTECTOMY', 'Unknown']);
+ expect(aggregatedDataLookup.facets['Died of Other Causes']?.categoriesList).toEqual(['BREAST CONSERVING', 'MASTECTOMY', 'Unknown']);
+ expect(aggregatedDataLookup.facets['Unknown']?.categoriesList).toEqual(['BREAST CONSERVING']);
+ expect(aggregatedDataLookup.facets['Living']?.groupingsList).toEqual(['High', 'Low', 'Moderate', 'Unknown']);
+ expect(aggregatedDataLookup.facets['Unknown']?.groupingsList).toEqual(['Low']);
+ });
+ });
+});
diff --git a/src/vis/bar/interfaces/internal/helpers/generate-aggregated-data-lookup.ts b/src/vis/bar/interfaces/internal/helpers/generate-aggregated-data-lookup.ts
new file mode 100644
index 000000000..3f70f8f2a
--- /dev/null
+++ b/src/vis/bar/interfaces/internal/helpers/generate-aggregated-data-lookup.ts
@@ -0,0 +1,312 @@
+import groupBy from 'lodash/groupBy';
+import round from 'lodash/round';
+import sort from 'lodash/sortBy';
+import sortedUniq from 'lodash/sortedUniq';
+import { NAN_REPLACEMENT } from '../../../../general';
+import { EAggregateTypes, ICommonVisProps } from '../../../../interfaces';
+import { EBarDisplayType, EBarGroupingType } from '../../enums';
+import { IBarConfig, IBarDataTableRow } from '../../interfaces';
+import { DEFAULT_FACET_NAME } from '../constants';
+import { AggregatedDataType } from '../types';
+import { median } from './median';
+
+export function generateAggregatedDataLookup(
+ config: { isFaceted: boolean; isGrouped: boolean; groupType: EBarGroupingType; display: EBarDisplayType; aggregateType: EAggregateTypes },
+ dataTable: IBarDataTableRow[],
+ selectedMap: ICommonVisProps['selectedMap'],
+) {
+ const facetGrouped = config.isFaceted ? groupBy(dataTable, 'facet') : { [DEFAULT_FACET_NAME]: dataTable };
+ const aggregated: { facets: { [facet: string]: AggregatedDataType }; globalDomain: { min: number; max: number }; facetsList: string[] } = {
+ facets: {},
+ globalDomain: { min: Infinity, max: -Infinity },
+ facetsList: Object.keys(facetGrouped),
+ };
+ const minMax: { facets: { [facet: string]: AggregatedDataType } } = { facets: {} };
+
+ Object.keys(facetGrouped).forEach((facet) => {
+ const values = facetGrouped[facet];
+ const facetSensitiveDataTable = facet === DEFAULT_FACET_NAME ? dataTable : dataTable.filter((item) => item.facet === facet);
+ const categoriesList = sortedUniq(sort(facetSensitiveDataTable.map((item) => item.category) ?? []));
+ const groupingsList = sortedUniq(sort(facetSensitiveDataTable.map((item) => item.group ?? NAN_REPLACEMENT) ?? []));
+ (values ?? []).forEach((item) => {
+ const { category = NAN_REPLACEMENT, agg, group = NAN_REPLACEMENT } = item;
+ const selected = selectedMap?.[item.id] || false;
+ if (!aggregated.facets[facet]) {
+ aggregated.facets[facet] = { categoriesList, groupingsList, categories: {} };
+ }
+ if (!aggregated.facets[facet].categories[category]) {
+ aggregated.facets[facet].categories[category] = { total: 0, ids: [], groups: {} };
+ }
+ if (!aggregated.facets[facet].categories[category].groups[group]) {
+ aggregated.facets[facet].categories[category].groups[group] = {
+ total: 0,
+ ids: [],
+ selected: { count: 0, sum: 0, min: Infinity, max: -Infinity, nums: [], ids: [] },
+ unselected: { count: 0, sum: 0, min: Infinity, max: -Infinity, nums: [], ids: [] },
+ };
+ }
+
+ // update category values
+ aggregated.facets[facet].categories[category].total++;
+ aggregated.facets[facet].categories[category].ids.push(item.id);
+ aggregated.facets[facet].categories[category].groups[group].total++;
+ aggregated.facets[facet].categories[category].groups[group].ids.push(item.id);
+
+ // update group values
+ if (selected) {
+ aggregated.facets[facet].categories[category].groups[group].selected.count++;
+ aggregated.facets[facet].categories[category].groups[group].selected.sum += agg || 0;
+ aggregated.facets[facet].categories[category].groups[group].selected.nums.push(agg || 0);
+ aggregated.facets[facet].categories[category].groups[group].selected.ids.push(item.id);
+ } else {
+ aggregated.facets[facet].categories[category].groups[group].unselected.count++;
+ aggregated.facets[facet].categories[category].groups[group].unselected.sum += agg || 0;
+ aggregated.facets[facet].categories[category].groups[group].unselected.nums.push(agg || 0);
+ aggregated.facets[facet].categories[category].groups[group].unselected.ids.push(item.id);
+ }
+
+ if (!minMax.facets[facet]) {
+ minMax.facets[facet] = { categoriesList: [], groupingsList: [], categories: {} };
+ }
+ if (!minMax.facets[facet].categories[category]) {
+ minMax.facets[facet].categories[category] = { total: 0, ids: [], groups: {} };
+ }
+ if (!minMax.facets[facet].categories[category].groups[group]) {
+ minMax.facets[facet].categories[category].groups[group] = {
+ total: 0,
+ ids: [],
+ selected: { count: 0, sum: 0, nums: [], ids: [], min: Infinity, max: -Infinity },
+ unselected: { count: 0, sum: 0, nums: [], ids: [], min: Infinity, max: -Infinity },
+ };
+ }
+
+ if (selected) {
+ minMax.facets[facet].categories[category].groups[group].selected.min = Math.min(
+ minMax.facets[facet].categories[category].groups[group].selected.min,
+ agg || Infinity,
+ );
+ minMax.facets[facet].categories[category].groups[group].selected.max = Math.max(
+ minMax.facets[facet].categories[category].groups[group].selected.max,
+ agg || -Infinity,
+ );
+ } else {
+ minMax.facets[facet].categories[category].groups[group].unselected.min = Math.min(
+ minMax.facets[facet].categories[category].groups[group].unselected.min,
+ agg || Infinity,
+ );
+ minMax.facets[facet].categories[category].groups[group].unselected.max = Math.max(
+ minMax.facets[facet].categories[category].groups[group].unselected.max,
+ agg || -Infinity,
+ );
+ }
+ });
+ (values ?? []).forEach((item) => {
+ const { category, group } = item;
+ if (aggregated.facets[facet]?.categories[category]?.groups[group] && minMax.facets[facet]?.categories[category]?.groups[group]) {
+ aggregated.facets[facet].categories[category].groups[group].selected.min = minMax.facets[facet].categories[category].groups[group].selected.min;
+ aggregated.facets[facet].categories[category].groups[group].selected.max = minMax.facets[facet].categories[category].groups[group].selected.max;
+ aggregated.facets[facet].categories[category].groups[group].unselected.min = minMax.facets[facet].categories[category].groups[group].unselected.min;
+ aggregated.facets[facet].categories[category].groups[group].unselected.max = minMax.facets[facet].categories[category].groups[group].unselected.max;
+ }
+ });
+ });
+
+ Object.values(aggregated.facets).forEach((facet) => {
+ Object.values(facet?.categories ?? {}).forEach((category) => {
+ Object.values(category?.groups ?? {}).forEach((group) => {
+ if (config.groupType === EBarGroupingType.STACK && config.display === EBarDisplayType.NORMALIZED) {
+ aggregated.globalDomain.min = 0;
+ aggregated.globalDomain.max = 100;
+ } else {
+ switch (config.aggregateType) {
+ case EAggregateTypes.COUNT: {
+ const max =
+ config.groupType === EBarGroupingType.STACK
+ ? Math.max(category?.total ?? -Infinity, aggregated.globalDomain.max)
+ : Math.max(group?.total ?? -Infinity, aggregated.globalDomain.max);
+ const min =
+ config.groupType === EBarGroupingType.STACK
+ ? Math.min(category?.total ?? Infinity, aggregated.globalDomain.min, 0)
+ : Math.min(group?.total ?? Infinity, aggregated.globalDomain.min, 0);
+ aggregated.globalDomain.max = Math.max(max, aggregated.globalDomain.max, 0);
+ aggregated.globalDomain.min = Math.min(min, aggregated.globalDomain.min, 0);
+ break;
+ }
+
+ case EAggregateTypes.AVG: {
+ const max = round(
+ config.groupType === EBarGroupingType.STACK
+ ? Math.max(
+ Math.max(
+ Object.values(category?.groups ?? {}).reduce(
+ (acc, g) => Math.max(acc + (g?.selected.sum ?? -Infinity) / (g?.selected.count || 1), acc),
+ 0,
+ ),
+ Object.values(category?.groups ?? {}).reduce(
+ (acc, g) => Math.max(acc + (g?.unselected.sum ?? -Infinity) / (g?.unselected.count || 1), acc),
+ 0,
+ ),
+ ),
+ aggregated.globalDomain.max,
+ )
+ : Math.max(
+ Math.max(
+ (group?.selected.sum ?? -Infinity) / (group?.selected.count || 1),
+ (group?.unselected.sum ?? -Infinity) / (group?.unselected.count || 1),
+ ),
+ aggregated.globalDomain.max,
+ ),
+ 4,
+ );
+ const min = round(
+ config.groupType === EBarGroupingType.STACK
+ ? Math.min(
+ Math.min(
+ Object.values(category?.groups ?? {}).reduce(
+ (acc, g) => Math.min(acc + (g?.selected.sum ?? -Infinity) / (g?.selected.count || 1), acc),
+ 0,
+ ),
+ Object.values(category?.groups ?? {}).reduce(
+ (acc, g) => Math.min(acc + (g?.unselected.sum ?? -Infinity) / (g?.unselected.count || 1), acc),
+ 0,
+ ),
+ ),
+ aggregated.globalDomain.min,
+ )
+ : Math.min(
+ Math.min(
+ (group?.selected.sum ?? Infinity) / (group?.selected.count || 1),
+ (group?.unselected.sum ?? Infinity) / (group?.unselected.count || 1),
+ ),
+ aggregated.globalDomain.min,
+ 0,
+ ),
+ 4,
+ );
+ aggregated.globalDomain.max = Math.max(max, aggregated.globalDomain.max, 0);
+ aggregated.globalDomain.min = Math.min(min, aggregated.globalDomain.min, 0);
+ break;
+ }
+
+ case EAggregateTypes.MIN: {
+ const max = round(
+ config.groupType === EBarGroupingType.STACK
+ ? Math.max(
+ Object.values(category?.groups ?? {}).reduce((acc, g) => {
+ const selectedMin = g?.selected.min ?? 0;
+ const infiniteSafeSelectedMin = selectedMin === Infinity ? 0 : selectedMin;
+ const unselectedMin = g?.unselected.min ?? 0;
+ const infiniteSafeUnselectedMin = unselectedMin === Infinity ? 0 : unselectedMin;
+ return Math.max(acc + infiniteSafeSelectedMin + infiniteSafeUnselectedMin, acc);
+ }, 0),
+
+ aggregated.globalDomain.max,
+ )
+ : Math.max(Math.min(group?.selected.min ?? Infinity, group?.unselected.min ?? Infinity), aggregated.globalDomain.max),
+ 4,
+ );
+ const min = round(
+ config.groupType === EBarGroupingType.STACK
+ ? Math.min(
+ Object.values(category?.groups ?? {}).reduce((acc, g) => {
+ const selectedMin = g?.selected.min ?? 0;
+ const infiniteSafeSelectedMin = selectedMin === Infinity ? 0 : selectedMin;
+ const unselectedMin = g?.unselected.min ?? 0;
+ const infiniteSafeUnselectedMin = unselectedMin === Infinity ? 0 : unselectedMin;
+ return Math.min(acc + infiniteSafeSelectedMin + infiniteSafeUnselectedMin, acc);
+ }, 0),
+
+ aggregated.globalDomain.min,
+ )
+ : Math.min(Math.min(group?.selected.min ?? Infinity, group?.unselected.min ?? Infinity), aggregated.globalDomain.min, 0),
+ 4,
+ );
+ aggregated.globalDomain.max = Math.max(max, aggregated.globalDomain.max, 0);
+ aggregated.globalDomain.min = Math.min(min, aggregated.globalDomain.min, 0);
+ break;
+ }
+
+ case EAggregateTypes.MAX: {
+ const max = round(
+ config.groupType === EBarGroupingType.STACK
+ ? Math.max(
+ Object.values(category?.groups ?? {}).reduce((acc, g) => {
+ const selectedMax = g?.selected.max ?? 0;
+ const infiniteSafeSelectedMax = selectedMax === -Infinity ? 0 : selectedMax;
+ const unselectedMax = g?.unselected.max ?? 0;
+ const infiniteSafeUnselectedMax = unselectedMax === -Infinity ? 0 : unselectedMax;
+ return Math.max(acc + infiniteSafeSelectedMax + infiniteSafeUnselectedMax, acc);
+ }, 0),
+ aggregated.globalDomain.max,
+ )
+ : Math.max(Math.max(group?.selected.max ?? -Infinity, group?.unselected.max ?? -Infinity), aggregated.globalDomain.max),
+ 4,
+ );
+ const min = round(
+ config.groupType === EBarGroupingType.STACK
+ ? Math.min(
+ Object.values(category?.groups ?? {}).reduce((acc, g) => {
+ const selectedMax = g?.selected.max ?? 0;
+ const infiniteSafeSelectedMax = selectedMax === -Infinity ? 0 : selectedMax;
+ const unselectedMax = g?.unselected.max ?? 0;
+ const infiniteSafeUnselectedMax = unselectedMax === -Infinity ? 0 : unselectedMax;
+ return Math.min(acc + infiniteSafeSelectedMax + infiniteSafeUnselectedMax, acc);
+ }, 0),
+ aggregated.globalDomain.min,
+ )
+ : Math.min(Math.max(group?.selected.max ?? -Infinity, group?.unselected.max ?? -Infinity), aggregated.globalDomain.min, 0),
+ 4,
+ );
+ aggregated.globalDomain.max = Math.max(max, aggregated.globalDomain.max, 0);
+ aggregated.globalDomain.min = Math.min(min, aggregated.globalDomain.min, 0);
+ break;
+ }
+
+ case EAggregateTypes.MED: {
+ const selectedMedian = median(group?.selected.nums ?? []) ?? 0;
+ const unselectedMedian = median(group?.unselected.nums ?? []) ?? 0;
+ if (config.isGrouped) {
+ if (config.groupType === EBarGroupingType.STACK) {
+ const { max, min } = Object.values(category?.groups ?? {}).reduce(
+ (acc, g) => {
+ const selectedStackMedian = median(g?.selected.nums ?? []) ?? 0;
+ const unselectedStackMedian = median(g?.unselected.nums ?? []) ?? 0;
+ return {
+ ...acc,
+ max: Math.max(acc.max + selectedStackMedian + unselectedStackMedian, acc.max),
+ min: Math.min(acc.min + selectedStackMedian + unselectedStackMedian, acc.min),
+ };
+ },
+ { max: 0, min: 0 },
+ );
+ aggregated.globalDomain.max = Math.max(round(max, 4), aggregated.globalDomain.max, 0);
+ aggregated.globalDomain.min = Math.min(round(min, 4), aggregated.globalDomain.min, 0);
+ break;
+ } else if (config.groupType === EBarGroupingType.GROUP) {
+ const max = round(Math.max(Math.max(selectedMedian, unselectedMedian), aggregated.globalDomain.max), 4);
+ const min = round(Math.min(Math.min(selectedMedian, unselectedMedian), aggregated.globalDomain.min, 0), 4);
+ aggregated.globalDomain.max = Math.max(max, aggregated.globalDomain.max, 0);
+ aggregated.globalDomain.min = Math.min(min, aggregated.globalDomain.min, 0);
+ break;
+ }
+ } else {
+ const max = round(Math.max(Math.max(selectedMedian, unselectedMedian), aggregated.globalDomain.max), 4);
+ const min = round(Math.min(Math.min(selectedMedian, unselectedMedian), aggregated.globalDomain.min), 4);
+ aggregated.globalDomain.max = Math.max(max, aggregated.globalDomain.max, 0);
+ aggregated.globalDomain.min = Math.min(min, aggregated.globalDomain.min, 0);
+ break;
+ }
+ break;
+ }
+
+ default:
+ console.warn(`Aggregation type ${config.aggregateType} is not supported by bar chart.`);
+ break;
+ }
+ }
+ });
+ });
+ });
+
+ return aggregated;
+}
diff --git a/src/vis/bar/interfaces/internal/helpers/get-bar-data.test.ts b/src/vis/bar/interfaces/internal/helpers/get-bar-data.test.ts
new file mode 100644
index 000000000..191ddd847
--- /dev/null
+++ b/src/vis/bar/interfaces/internal/helpers/get-bar-data.test.ts
@@ -0,0 +1,64 @@
+import { ColumnInfo } from '../../../../interfaces';
+import { fetchBreastCancerData } from '../../../../stories/fetchBreastCancerData';
+import { defaultConfig } from '../../constants';
+import { getBarData } from './get-bar-data';
+
+const config = { ...defaultConfig };
+
+describe('getBarData', () => {
+ it('should resolve the promise for getting the bar data and return an object', async () => {
+ const columns: Parameters['0'] = [];
+ const catColumns: Parameters['1'] = { id: 'id', name: 'name', description: 'description' };
+ const groupColumns: Parameters['2'] = { id: 'id', name: 'name', description: 'description' };
+ const facetsColumns: Parameters['3'] = { id: 'id', name: 'name', description: 'description' };
+ const aggregateColumns: Parameters['4'] = { id: 'id', name: 'name', description: 'description' };
+ const barData = await getBarData(columns, catColumns, groupColumns, facetsColumns, aggregateColumns);
+ expect(barData).toBeInstanceOf(Object);
+ });
+
+ it('should return breast cancer data', async () => {
+ const configPayload = { ...config, catColumnSelected: { id: 'breastSurgeryType', name: 'Breast Surgery Type', description: 'some very long description' } };
+ const data = await getBarData(
+ fetchBreastCancerData(),
+ configPayload.catColumnSelected,
+ configPayload.group,
+ configPayload.facets,
+ configPayload.aggregateColumn,
+ );
+ expect(data.catColVals.info).toEqual({ id: 'breastSurgeryType', name: 'Breast Surgery Type', description: 'some very long description' });
+ expect(data.catColVals.resolvedValues.length).toBe(1904);
+ expect(data.catColVals.type.toLowerCase()).toBe('categorical');
+ expect(data.groupColVals).toBe(null);
+ expect(data.aggregateColVals).toBe(null);
+ expect(data.facetsColVals).toBe(null);
+ });
+
+ it('should return breast cancer data with group, aggregate and facet column', async () => {
+ const configPayload = {
+ ...config,
+ catColumnSelected: { id: 'breastSurgeryType' },
+ group: { id: 'tumorSize' },
+ facets: { id: 'cellularity' },
+ };
+ const data = await getBarData(
+ fetchBreastCancerData(),
+ configPayload.catColumnSelected as ColumnInfo,
+ configPayload.group as ColumnInfo,
+ configPayload.facets as ColumnInfo,
+ configPayload.aggregateColumn as ColumnInfo,
+ );
+ expect(data.catColVals.info).toEqual({ id: 'breastSurgeryType', name: 'Breast Surgery Type', description: 'some very long description' });
+ expect(data.catColVals.resolvedValues.length).toBe(1904);
+ expect(data.catColVals.type.toLowerCase()).toBe('categorical');
+
+ expect(data.groupColVals.info).toEqual({ description: 'some very long description', id: 'tumorSize', name: 'Tumor Size' });
+ expect(data.groupColVals.resolvedValues.length).toBe(1904);
+ expect(data.groupColVals.type.toLowerCase()).toBe('numerical');
+
+ expect(data.aggregateColVals).toBe(null);
+
+ expect(data.facetsColVals.info).toEqual({ description: null, id: 'cellularity', name: 'Cellularity' });
+ expect(data.facetsColVals.resolvedValues.length).toBe(1904);
+ expect(data.facetsColVals.type.toLowerCase()).toBe('categorical');
+ });
+});
diff --git a/src/vis/bar/interfaces/internal/helpers/get-bar-data.ts b/src/vis/bar/interfaces/internal/helpers/get-bar-data.ts
new file mode 100644
index 000000000..ea48a4249
--- /dev/null
+++ b/src/vis/bar/interfaces/internal/helpers/get-bar-data.ts
@@ -0,0 +1,45 @@
+import { resolveSingleColumn } from '../../../../general';
+import { ColumnInfo, EColumnTypes, VisCategoricalValue, VisColumn, VisNumericalValue } from '../../../../interfaces';
+import { VisColumnWithResolvedValues } from '../../types';
+
+export async function getBarData(
+ columns: VisColumn[],
+ catColumn: ColumnInfo,
+ groupColumn: ColumnInfo | null,
+ facetsColumn: ColumnInfo | null,
+ aggregateColumn: ColumnInfo | null,
+): Promise<{
+ catColVals: {
+ resolvedValues: (VisNumericalValue | VisCategoricalValue)[];
+ type: EColumnTypes.NUMERICAL | EColumnTypes.CATEGORICAL;
+ info: ColumnInfo;
+ };
+ groupColVals: {
+ resolvedValues: (VisNumericalValue | VisCategoricalValue)[];
+ type: EColumnTypes.NUMERICAL | EColumnTypes.CATEGORICAL;
+ info: ColumnInfo;
+ color?: Record;
+ };
+ facetsColVals: {
+ resolvedValues: (VisNumericalValue | VisCategoricalValue)[];
+ type: EColumnTypes.NUMERICAL | EColumnTypes.CATEGORICAL;
+ info: ColumnInfo;
+ };
+ aggregateColVals: {
+ resolvedValues: (VisNumericalValue | VisCategoricalValue)[];
+ type: EColumnTypes.NUMERICAL | EColumnTypes.CATEGORICAL;
+ info: ColumnInfo;
+ };
+}> {
+ const catColVals = (await resolveSingleColumn(columns.find((col) => col.info.id === catColumn.id)!)) as VisColumnWithResolvedValues;
+
+ const groupColVals = (await resolveSingleColumn(groupColumn ? columns.find((col) => col.info.id === groupColumn.id)! : null)) as VisColumnWithResolvedValues;
+ const facetsColVals = (await resolveSingleColumn(
+ facetsColumn ? columns.find((col) => col.info.id === facetsColumn.id)! : null,
+ )) as VisColumnWithResolvedValues;
+ const aggregateColVals = (await resolveSingleColumn(
+ aggregateColumn ? columns.find((col) => col.info.id === aggregateColumn.id)! : null,
+ )) as VisColumnWithResolvedValues;
+
+ return { catColVals, groupColVals, facetsColVals, aggregateColVals };
+}
diff --git a/src/vis/bar/interfaces/internal/helpers/index.ts b/src/vis/bar/interfaces/internal/helpers/index.ts
new file mode 100644
index 000000000..1d5aad182
--- /dev/null
+++ b/src/vis/bar/interfaces/internal/helpers/index.ts
@@ -0,0 +1,7 @@
+export * from './calculate-chart-dimensions';
+export * from './create-bin-lookup';
+export * from './generate-aggregated-data-lookup';
+export * from './get-bar-data';
+export * from './median';
+export * from './normalized-value';
+export * from './sort-series';
diff --git a/src/vis/bar/interfaces/internal/helpers/median.test.ts b/src/vis/bar/interfaces/internal/helpers/median.test.ts
new file mode 100644
index 000000000..59d97b645
--- /dev/null
+++ b/src/vis/bar/interfaces/internal/helpers/median.test.ts
@@ -0,0 +1,35 @@
+import { median } from './median';
+
+describe('median', () => {
+ it('should return the median value of a given array', () => {
+ expect(median([1, 2, 3, 4, 5])).toBe(3);
+ expect(median([1, 2, 3, 4, 5, 6])).toBe(3.5);
+ expect(median([1, 2, 3, 4, 5, 6, 7])).toBe(4);
+ expect(median([1, 2, 3, 4, 5, 6, 7, 8])).toBe(4.5);
+ });
+
+ it('should return the median value of a given array having negative values', () => {
+ expect(median([-1, -2, -3, -4, -5])).toBe(-3);
+ });
+
+ it('should return the median value of a given array having negative and positive values', () => {
+ expect(median([-1, -2, 3, 4, 5])).toBe(3);
+ });
+
+ it('should return the median value of a given array having duplicate values', () => {
+ expect(median([1, 2, 3, 3, 4, 5])).toBe(3);
+ });
+
+ it('should return the median value of a given array having null values', () => {
+ expect(median([1, 2, 3, 4, 5, null] as number[])).toBe(3.5);
+ });
+
+ it('should return null if the array is empty', () => {
+ expect(median([])).toBe(null);
+ });
+
+ it('should filter out Infinity and -Infinity', () => {
+ expect(median([1, 2, 3, 4, 5, Infinity])).toBe(3.5);
+ expect(median([1, 2, 3, 4, 5, -Infinity])).toBe(3.5);
+ });
+});
diff --git a/src/vis/bar/interfaces/internal/helpers/median.ts b/src/vis/bar/interfaces/internal/helpers/median.ts
new file mode 100644
index 000000000..5b4f8d545
--- /dev/null
+++ b/src/vis/bar/interfaces/internal/helpers/median.ts
@@ -0,0 +1,9 @@
+export function median(arr: number[]) {
+ if (arr.length === 0) {
+ return null;
+ }
+ const mid = Math.floor(arr.length / 2);
+ const nums = [...arr].filter((n) => ![Infinity, -Infinity, null, undefined].includes(n)).sort((a, b) => a - b) as number[];
+ const medianVal = arr.length % 2 !== 0 ? nums[mid] : (nums[mid - 1]! + nums[mid]!) / 2;
+ return medianVal;
+}
diff --git a/src/vis/bar/interfaces/internal/helpers/normalized-value.test.ts b/src/vis/bar/interfaces/internal/helpers/normalized-value.test.ts
new file mode 100644
index 000000000..a2fedb9a4
--- /dev/null
+++ b/src/vis/bar/interfaces/internal/helpers/normalized-value.test.ts
@@ -0,0 +1,29 @@
+import { defaultConfig } from '../../constants';
+import { EBarDisplayType, EBarGroupingType } from '../../enums';
+import { normalizedValue } from './normalized-value';
+
+const config = { ...defaultConfig };
+describe('normalizedValue', () => {
+ it('should check if normalized value returns a number for the given config and value', () => {
+ expect(Number.isNaN(Number(normalizedValue({ config, value: 10, total: 100 })))).toBe(false);
+ });
+
+ it('should return the normalized value for the given config and value with no grouping configuration', () => {
+ expect(normalizedValue({ config, value: 10, total: 100 })).toBe(10);
+ });
+
+ it('should return the normalized value for the given config and value with a grouping configuration', () => {
+ expect(
+ normalizedValue({
+ config: { ...config, group: { id: '', name: '', description: '' }, groupType: EBarGroupingType.STACK, display: EBarDisplayType.NORMALIZED },
+ value: 10,
+ total: 200,
+ }),
+ ).toBe(5);
+ });
+
+ it('should return null for Infinity and -Infinity values', () => {
+ expect(normalizedValue({ config, value: Infinity, total: 100 })).toBe(null);
+ expect(normalizedValue({ config, value: -Infinity, total: 100 })).toBe(null);
+ });
+});
diff --git a/src/vis/bar/interfaces/internal/helpers/normalized-value.ts b/src/vis/bar/interfaces/internal/helpers/normalized-value.ts
new file mode 100644
index 000000000..a2676aea2
--- /dev/null
+++ b/src/vis/bar/interfaces/internal/helpers/normalized-value.ts
@@ -0,0 +1,21 @@
+import round from 'lodash/round';
+import { EBarGroupingType, EBarDisplayType } from '../../enums';
+import { IBarConfig } from '../../interfaces';
+
+/**
+ * Calculates and returns the rounded absolute or normalized value, dependending on the config value.
+ * Enabled grouping always returns the absolute value. The normalized value is only calculated for stacked bars.
+ * @param config Bar chart configuration
+ * @param value Absolute value
+ * @param total Number of values in the category
+ * @returns Returns the rounded absolute value. Otherwise returns the rounded normalized value.
+ */
+export function normalizedValue({ config, value, total }: { config: IBarConfig; value: number; total: number }) {
+ // NOTE: @dv-usama-ansari: Filter out Infinity and -Infinity values. This is required for proper display of minimum and maximum aggregations.
+ if ([Infinity, -Infinity].includes(value)) {
+ return null;
+ }
+ return config?.group && config?.groupType === EBarGroupingType.STACK && config?.display === EBarDisplayType.NORMALIZED
+ ? round((value / total) * 100, 2)
+ : round(value, 4);
+}
diff --git a/src/vis/bar/interfaces/internal/helpers/sort-series.test.ts b/src/vis/bar/interfaces/internal/helpers/sort-series.test.ts
new file mode 100644
index 000000000..126f2dd75
--- /dev/null
+++ b/src/vis/bar/interfaces/internal/helpers/sort-series.test.ts
@@ -0,0 +1,365 @@
+import { EBarDirection, EBarSortState } from '../../enums';
+import { sortSeries } from './sort-series';
+
+describe('sortSeries', () => {
+ // TODO: @dv-usama-ansari: Add tests for sortSeries for different combinations of data:
+ // - series: empty array
+ // - series: very large number of elements
+ // - series: null values
+ // - test for all configurations of sortMetadata
+
+ // NOTE: @dv-usama-ansari: This test might be obsolete when the dataset of echarts is used.
+ it('should return an array of sorted series', () => {
+ const series: Parameters['0'] = [];
+ const sortMetadata: Parameters['1'] = {
+ direction: EBarDirection.HORIZONTAL,
+ sortState: { x: EBarSortState.NONE, y: EBarSortState.NONE },
+ };
+ const sortedSeries = sortSeries(series, sortMetadata);
+ expect(sortedSeries).toBeInstanceOf(Array);
+ expect(sortedSeries.length).toBe(series.length);
+ });
+
+ it('should return an array of sorted series with the same length as the input series', () => {
+ const series: Parameters['0'] = [
+ {
+ data: [1022, 1017, 1027, 976, 1032, 985, 1022, 985, 957, 977],
+ categories: [
+ 'CATEGORY_0',
+ 'CATEGORY_1',
+ 'CATEGORY_2',
+ 'CATEGORY_3',
+ 'CATEGORY_4',
+ 'CATEGORY_5',
+ 'CATEGORY_6',
+ 'CATEGORY_7',
+ 'CATEGORY_8',
+ 'CATEGORY_9',
+ ],
+ },
+ ];
+ const sortMetadata: Parameters['1'] = {
+ direction: EBarDirection.HORIZONTAL,
+ sortState: { x: EBarSortState.ASCENDING, y: EBarSortState.NONE },
+ };
+ const sortedSeries = sortSeries(series, sortMetadata);
+ expect(sortedSeries.length).toBe(series.length);
+ expect(sortedSeries[0]).toEqual({
+ categories: ['CATEGORY_8', 'CATEGORY_3', 'CATEGORY_9', 'CATEGORY_5', 'CATEGORY_7', 'CATEGORY_1', 'CATEGORY_0', 'CATEGORY_6', 'CATEGORY_2', 'CATEGORY_4'],
+ data: [957, 976, 977, 985, 985, 1017, 1022, 1022, 1027, 1032],
+ });
+ });
+
+ it('should sort the series correctly with a large data', () => {
+ const series: Parameters['0'] = [
+ {
+ data: [104, 106, 111, 99, 117, 105, 93, 95, 96, 104],
+ categories: [
+ 'CATEGORY_0',
+ 'CATEGORY_1',
+ 'CATEGORY_2',
+ 'CATEGORY_3',
+ 'CATEGORY_4',
+ 'CATEGORY_5',
+ 'CATEGORY_6',
+ 'CATEGORY_7',
+ 'CATEGORY_8',
+ 'CATEGORY_9',
+ ],
+ },
+ {
+ data: [84, 98, 107, 85, 119, 97, 91, 106, 97, 97],
+ categories: [
+ 'CATEGORY_0',
+ 'CATEGORY_1',
+ 'CATEGORY_2',
+ 'CATEGORY_3',
+ 'CATEGORY_4',
+ 'CATEGORY_5',
+ 'CATEGORY_6',
+ 'CATEGORY_7',
+ 'CATEGORY_8',
+ 'CATEGORY_9',
+ ],
+ },
+ {
+ data: [87, 96, 96, 96, 81, 90, 111, 102, 104, 96],
+ categories: [
+ 'CATEGORY_0',
+ 'CATEGORY_1',
+ 'CATEGORY_2',
+ 'CATEGORY_3',
+ 'CATEGORY_4',
+ 'CATEGORY_5',
+ 'CATEGORY_6',
+ 'CATEGORY_7',
+ 'CATEGORY_8',
+ 'CATEGORY_9',
+ ],
+ },
+ {
+ data: [97, 107, 109, 110, 110, 98, 86, 96, 98, 103],
+ categories: [
+ 'CATEGORY_0',
+ 'CATEGORY_1',
+ 'CATEGORY_2',
+ 'CATEGORY_3',
+ 'CATEGORY_4',
+ 'CATEGORY_5',
+ 'CATEGORY_6',
+ 'CATEGORY_7',
+ 'CATEGORY_8',
+ 'CATEGORY_9',
+ ],
+ },
+ {
+ data: [98, 112, 106, 99, 93, 92, 100, 100, 81, 86],
+ categories: [
+ 'CATEGORY_0',
+ 'CATEGORY_1',
+ 'CATEGORY_2',
+ 'CATEGORY_3',
+ 'CATEGORY_4',
+ 'CATEGORY_5',
+ 'CATEGORY_6',
+ 'CATEGORY_7',
+ 'CATEGORY_8',
+ 'CATEGORY_9',
+ ],
+ },
+ {
+ data: [110, 108, 96, 91, 98, 100, 108, 97, 89, 90],
+ categories: [
+ 'CATEGORY_0',
+ 'CATEGORY_1',
+ 'CATEGORY_2',
+ 'CATEGORY_3',
+ 'CATEGORY_4',
+ 'CATEGORY_5',
+ 'CATEGORY_6',
+ 'CATEGORY_7',
+ 'CATEGORY_8',
+ 'CATEGORY_9',
+ ],
+ },
+ {
+ data: [116, 100, 115, 85, 102, 104, 99, 93, 111, 114],
+ categories: [
+ 'CATEGORY_0',
+ 'CATEGORY_1',
+ 'CATEGORY_2',
+ 'CATEGORY_3',
+ 'CATEGORY_4',
+ 'CATEGORY_5',
+ 'CATEGORY_6',
+ 'CATEGORY_7',
+ 'CATEGORY_8',
+ 'CATEGORY_9',
+ ],
+ },
+ {
+ data: [102, 90, 104, 116, 88, 88, 115, 91, 90, 86],
+ categories: [
+ 'CATEGORY_0',
+ 'CATEGORY_1',
+ 'CATEGORY_2',
+ 'CATEGORY_3',
+ 'CATEGORY_4',
+ 'CATEGORY_5',
+ 'CATEGORY_6',
+ 'CATEGORY_7',
+ 'CATEGORY_8',
+ 'CATEGORY_9',
+ ],
+ },
+ {
+ data: [114, 102, 90, 101, 112, 96, 111, 112, 80, 102],
+ categories: [
+ 'CATEGORY_0',
+ 'CATEGORY_1',
+ 'CATEGORY_2',
+ 'CATEGORY_3',
+ 'CATEGORY_4',
+ 'CATEGORY_5',
+ 'CATEGORY_6',
+ 'CATEGORY_7',
+ 'CATEGORY_8',
+ 'CATEGORY_9',
+ ],
+ },
+ {
+ data: [110, 98, 93, 94, 112, 115, 108, 93, 111, 99],
+ categories: [
+ 'CATEGORY_0',
+ 'CATEGORY_1',
+ 'CATEGORY_2',
+ 'CATEGORY_3',
+ 'CATEGORY_4',
+ 'CATEGORY_5',
+ 'CATEGORY_6',
+ 'CATEGORY_7',
+ 'CATEGORY_8',
+ 'CATEGORY_9',
+ ],
+ },
+ ];
+ const sortMetadata: Parameters['1'] = {
+ direction: EBarDirection.HORIZONTAL,
+ sortState: { x: EBarSortState.DESCENDING, y: EBarSortState.NONE },
+ };
+ const sortedSeries = sortSeries(series, sortMetadata);
+ expect(sortedSeries.length).toBe(series.length);
+ expect(sortedSeries).toEqual([
+ {
+ categories: [
+ 'CATEGORY_4',
+ 'CATEGORY_2',
+ 'CATEGORY_0',
+ 'CATEGORY_6',
+ 'CATEGORY_1',
+ 'CATEGORY_5',
+ 'CATEGORY_7',
+ 'CATEGORY_9',
+ 'CATEGORY_3',
+ 'CATEGORY_8',
+ ],
+ data: [117, 111, 104, 93, 106, 105, 95, 104, 99, 96],
+ },
+ {
+ categories: [
+ 'CATEGORY_4',
+ 'CATEGORY_2',
+ 'CATEGORY_0',
+ 'CATEGORY_6',
+ 'CATEGORY_1',
+ 'CATEGORY_5',
+ 'CATEGORY_7',
+ 'CATEGORY_9',
+ 'CATEGORY_3',
+ 'CATEGORY_8',
+ ],
+ data: [119, 107, 84, 91, 98, 97, 106, 97, 85, 97],
+ },
+ {
+ categories: [
+ 'CATEGORY_4',
+ 'CATEGORY_2',
+ 'CATEGORY_0',
+ 'CATEGORY_6',
+ 'CATEGORY_1',
+ 'CATEGORY_5',
+ 'CATEGORY_7',
+ 'CATEGORY_9',
+ 'CATEGORY_3',
+ 'CATEGORY_8',
+ ],
+ data: [81, 96, 87, 111, 96, 90, 102, 96, 96, 104],
+ },
+ {
+ categories: [
+ 'CATEGORY_4',
+ 'CATEGORY_2',
+ 'CATEGORY_0',
+ 'CATEGORY_6',
+ 'CATEGORY_1',
+ 'CATEGORY_5',
+ 'CATEGORY_7',
+ 'CATEGORY_9',
+ 'CATEGORY_3',
+ 'CATEGORY_8',
+ ],
+ data: [110, 109, 97, 86, 107, 98, 96, 103, 110, 98],
+ },
+ {
+ categories: [
+ 'CATEGORY_4',
+ 'CATEGORY_2',
+ 'CATEGORY_0',
+ 'CATEGORY_6',
+ 'CATEGORY_1',
+ 'CATEGORY_5',
+ 'CATEGORY_7',
+ 'CATEGORY_9',
+ 'CATEGORY_3',
+ 'CATEGORY_8',
+ ],
+ data: [93, 106, 98, 100, 112, 92, 100, 86, 99, 81],
+ },
+ {
+ categories: [
+ 'CATEGORY_4',
+ 'CATEGORY_2',
+ 'CATEGORY_0',
+ 'CATEGORY_6',
+ 'CATEGORY_1',
+ 'CATEGORY_5',
+ 'CATEGORY_7',
+ 'CATEGORY_9',
+ 'CATEGORY_3',
+ 'CATEGORY_8',
+ ],
+ data: [98, 96, 110, 108, 108, 100, 97, 90, 91, 89],
+ },
+ {
+ categories: [
+ 'CATEGORY_4',
+ 'CATEGORY_2',
+ 'CATEGORY_0',
+ 'CATEGORY_6',
+ 'CATEGORY_1',
+ 'CATEGORY_5',
+ 'CATEGORY_7',
+ 'CATEGORY_9',
+ 'CATEGORY_3',
+ 'CATEGORY_8',
+ ],
+ data: [102, 115, 116, 99, 100, 104, 93, 114, 85, 111],
+ },
+ {
+ categories: [
+ 'CATEGORY_4',
+ 'CATEGORY_2',
+ 'CATEGORY_0',
+ 'CATEGORY_6',
+ 'CATEGORY_1',
+ 'CATEGORY_5',
+ 'CATEGORY_7',
+ 'CATEGORY_9',
+ 'CATEGORY_3',
+ 'CATEGORY_8',
+ ],
+ data: [88, 104, 102, 115, 90, 88, 91, 86, 116, 90],
+ },
+ {
+ categories: [
+ 'CATEGORY_4',
+ 'CATEGORY_2',
+ 'CATEGORY_0',
+ 'CATEGORY_6',
+ 'CATEGORY_1',
+ 'CATEGORY_5',
+ 'CATEGORY_7',
+ 'CATEGORY_9',
+ 'CATEGORY_3',
+ 'CATEGORY_8',
+ ],
+ data: [112, 90, 114, 111, 102, 96, 112, 102, 101, 80],
+ },
+ {
+ categories: [
+ 'CATEGORY_4',
+ 'CATEGORY_2',
+ 'CATEGORY_0',
+ 'CATEGORY_6',
+ 'CATEGORY_1',
+ 'CATEGORY_5',
+ 'CATEGORY_7',
+ 'CATEGORY_9',
+ 'CATEGORY_3',
+ 'CATEGORY_8',
+ ],
+ data: [112, 93, 110, 108, 98, 115, 93, 99, 94, 111],
+ },
+ ]);
+ });
+});
diff --git a/src/vis/bar/interfaces/internal/helpers/sort-series.ts b/src/vis/bar/interfaces/internal/helpers/sort-series.ts
new file mode 100644
index 000000000..b9001e4a6
--- /dev/null
+++ b/src/vis/bar/interfaces/internal/helpers/sort-series.ts
@@ -0,0 +1,163 @@
+import type { BarSeriesOption } from 'echarts/charts';
+import { NAN_REPLACEMENT } from '../../../../general';
+import { EBarSortState, EBarDirection } from '../../enums';
+
+/**
+ * Sorts the series data based on the specified order.
+ *
+ * For input data like below:
+ * ```ts
+ * const series = [{
+ * categories: ["Unknown", "High", "Moderate", "Low"],
+ * data: [26, 484, 389, 111],
+ * },{
+ * categories: ["Unknown", "High", "Moderate", "Low"],
+ * data: [22, 344, 239, 69],
+ * },{
+ * categories: ["Unknown", "High", "Moderate", "Low"],
+ * data: [6, 111, 83, 20],
+ * }];
+ * ```
+ *
+ * This function would return an output like below:
+ * ```ts
+ * const sortedSeries = [{ // The total of `Moderate` is the highest, sorted in descending order and `Unknown` is placed last no matter what.
+ * categories: ["Moderate", "Low", "High", "Unknown"],
+ * data: [111, 20, 83, 6],
+ * },{
+ * categories: ["Moderate", "Low", "High", "Unknown"],
+ * data: [239, 69, 344, 22],
+ * },{
+ * categories: ["Moderate", "Low", "High", "Unknown"],
+ * data: [389, 484, 111, 26],
+ * }]
+ * ```
+ *
+ * This function uses `for` loop for maximum performance and readability.
+ *
+ * @param series
+ * @param sortMetadata
+ * @returns
+ */
+export function sortSeries(
+ series: { categories: string[]; data: BarSeriesOption['data'] }[],
+ sortMetadata: { sortState: { x: EBarSortState; y: EBarSortState }; direction: EBarDirection } = {
+ sortState: { x: EBarSortState.NONE, y: EBarSortState.NONE },
+ direction: EBarDirection.HORIZONTAL,
+ },
+): { categories: string[]; data: BarSeriesOption['data'] }[] {
+ // Step 1: Aggregate the data
+ const aggregatedData: { [key: string]: number } = {};
+ let unknownCategorySum = 0;
+ for (const s of series) {
+ for (let i = 0; i < s.categories.length; i++) {
+ const category = s.categories[i] as string;
+ const value = (s.data?.[i] as number) || 0;
+ if (category === 'Unknown') {
+ unknownCategorySum += value;
+ } else {
+ if (!aggregatedData[category]) {
+ aggregatedData[category] = 0;
+ }
+ aggregatedData[category] += value;
+ }
+ }
+ }
+
+ // Add the 'Unknown' category at the end
+ aggregatedData[NAN_REPLACEMENT] = unknownCategorySum;
+
+ // NOTE: @dv-usama-ansari: filter out keys with 0 values
+ for (const key in aggregatedData) {
+ if (aggregatedData[key] === 0) {
+ delete aggregatedData[key];
+ }
+ }
+
+ // Step 2: Sort the aggregated data
+ // NOTE: @dv-usama-ansari: Code optimized for readability.
+ const sortedCategories = Object.keys(aggregatedData).sort((a, b) => {
+ if (a === NAN_REPLACEMENT) {
+ return 1;
+ }
+ if (b === NAN_REPLACEMENT) {
+ return -1;
+ }
+ if (sortMetadata.direction === EBarDirection.HORIZONTAL) {
+ if (sortMetadata.sortState.x === EBarSortState.ASCENDING) {
+ return (aggregatedData[a] as number) - (aggregatedData[b] as number);
+ }
+ if (sortMetadata.sortState.x === EBarSortState.DESCENDING) {
+ return (aggregatedData[b] as number) - (aggregatedData[a] as number);
+ }
+ if (sortMetadata.sortState.y === EBarSortState.ASCENDING) {
+ return a.localeCompare(b);
+ }
+ if (sortMetadata.sortState.y === EBarSortState.DESCENDING) {
+ return b.localeCompare(a);
+ }
+ if (sortMetadata.sortState.x === EBarSortState.NONE) {
+ // NOTE: @dv-usama-ansari: Sort according to the original order
+ // SLOW CODE because of using `indexOf`!
+ // return originalOrder.indexOf(a) - originalOrder.indexOf(b);
+ return 0;
+ }
+ if (sortMetadata.sortState.y === EBarSortState.NONE) {
+ // NOTE: @dv-usama-ansari: Sort according to the original order
+ // SLOW CODE because of using `indexOf`!
+ // return originalOrder.indexOf(a) - originalOrder.indexOf(b);
+ return 0;
+ }
+ }
+ if (sortMetadata.direction === EBarDirection.VERTICAL) {
+ if (sortMetadata.sortState.x === EBarSortState.ASCENDING) {
+ return a.localeCompare(b);
+ }
+ if (sortMetadata.sortState.x === EBarSortState.DESCENDING) {
+ return b.localeCompare(a);
+ }
+ if (sortMetadata.sortState.y === EBarSortState.ASCENDING) {
+ return (aggregatedData[a] as number) - (aggregatedData[b] as number);
+ }
+ if (sortMetadata.sortState.y === EBarSortState.DESCENDING) {
+ return (aggregatedData[b] as number) - (aggregatedData[a] as number);
+ }
+ if (sortMetadata.sortState.x === EBarSortState.NONE) {
+ // NOTE: @dv-usama-ansari: Sort according to the original order
+ // SLOW CODE because of using `indexOf`!
+ // return originalOrder.indexOf(a) - originalOrder.indexOf(b);
+ return 0;
+ }
+ if (sortMetadata.sortState.y === EBarSortState.NONE) {
+ // NOTE: @dv-usama-ansari: Sort according to the original order
+ // SLOW CODE because of using `indexOf`!
+ // return originalOrder.indexOf(a) - originalOrder.indexOf(b);
+ return 0;
+ }
+ }
+ return 0;
+ });
+
+ // Create a mapping of categories to their sorted indices
+ const categoryIndexMap: { [key: string]: number } = {};
+ for (let i = 0; i < sortedCategories.length; i++) {
+ categoryIndexMap[sortedCategories[i] as string] = i;
+ }
+
+ // Step 3: Sort each series according to the sorted categories
+ const sortedSeries: typeof series = [];
+ for (const s of series) {
+ const sortedData = new Array(sortedCategories.length).fill(null);
+ for (let i = 0; i < s.categories.length; i++) {
+ // NOTE: @dv-usama-ansari: index of the category in the sorted array
+ sortedData[categoryIndexMap[s.categories?.[i] as string] as number] = s.data?.[i];
+ }
+ sortedSeries.push({
+ ...s,
+ categories: sortedCategories,
+ data: sortedData,
+ });
+ }
+
+ return sortedSeries;
+}
diff --git a/src/vis/bar/interfaces/internal/index.ts b/src/vis/bar/interfaces/internal/index.ts
new file mode 100644
index 000000000..95f3dabd1
--- /dev/null
+++ b/src/vis/bar/interfaces/internal/index.ts
@@ -0,0 +1,3 @@
+export * from './constants';
+export * from './helpers';
+export * from './types';
diff --git a/src/vis/bar/interfaces/internal/types/aggregated-data.type.ts b/src/vis/bar/interfaces/internal/types/aggregated-data.type.ts
new file mode 100644
index 000000000..9659b517e
--- /dev/null
+++ b/src/vis/bar/interfaces/internal/types/aggregated-data.type.ts
@@ -0,0 +1,18 @@
+export type AggregatedDataType = {
+ categoriesList: string[];
+ groupingsList: string[];
+ categories: {
+ [category: string]: {
+ total: number;
+ ids: string[];
+ groups: {
+ [group: string]: {
+ total: number;
+ ids: string[];
+ selected: { count: number; sum: number; min: number; max: number; nums: number[]; ids: string[] };
+ unselected: { count: number; sum: number; min: number; max: number; nums: number[]; ids: string[] };
+ };
+ };
+ };
+ };
+};
diff --git a/src/vis/bar/interfaces/internal/types/index.ts b/src/vis/bar/interfaces/internal/types/index.ts
new file mode 100644
index 000000000..7263bcd8d
--- /dev/null
+++ b/src/vis/bar/interfaces/internal/types/index.ts
@@ -0,0 +1 @@
+export * from './aggregated-data.type';
diff --git a/src/vis/bar/interfaces/maps.ts b/src/vis/bar/interfaces/maps.ts
new file mode 100644
index 000000000..375750fb8
--- /dev/null
+++ b/src/vis/bar/interfaces/maps.ts
@@ -0,0 +1,7 @@
+import { EBarSortState } from './enums';
+
+export const SortDirectionMap: Record = {
+ [EBarSortState.NONE]: 'Unsorted',
+ [EBarSortState.ASCENDING]: 'Ascending',
+ [EBarSortState.DESCENDING]: 'Descending',
+};
diff --git a/src/vis/bar/interfaces/types.ts b/src/vis/bar/interfaces/types.ts
new file mode 100644
index 000000000..d4ee1a14f
--- /dev/null
+++ b/src/vis/bar/interfaces/types.ts
@@ -0,0 +1,3 @@
+import type { VisColumn, VisNumericalValue, VisCategoricalValue } from '../../interfaces';
+
+export type VisColumnWithResolvedValues = VisColumn & { resolvedValues: (VisNumericalValue | VisCategoricalValue)[] };
diff --git a/src/vis/bar/utils.ts b/src/vis/bar/utils.ts
deleted file mode 100644
index aa4862a3d..000000000
--- a/src/vis/bar/utils.ts
+++ /dev/null
@@ -1,208 +0,0 @@
-import { bin, desc, op } from 'arquero';
-import ColumnTable from 'arquero/dist/types/table/column-table';
-import merge from 'lodash/merge';
-import { resolveSingleColumn } from '../general/layoutUtils';
-import { ColumnInfo, EAggregateTypes, EColumnTypes, VisCategoricalValue, VisColumn, VisNumericalValue } from '../interfaces';
-import { IBarConfig, defaultConfig, SortTypes } from './interfaces';
-
-export function barMergeDefaultConfig(columns: VisColumn[], config: IBarConfig): IBarConfig {
- const merged = merge({}, defaultConfig, config);
-
- const catCols = columns.filter((c) => c.type === EColumnTypes.CATEGORICAL);
- const numCols = columns.filter((c) => c.type === EColumnTypes.NUMERICAL);
-
- if (!merged.catColumnSelected && catCols.length > 0) {
- merged.catColumnSelected = catCols[catCols.length - 1].info;
- }
-
- if (!merged.aggregateColumn && numCols.length > 0) {
- merged.aggregateColumn = numCols[numCols.length - 1].info;
- }
-
- return merged;
-}
-
-// Helper function for the bar chart which sorts the data depending on the sort type.
-export function sortTableBySortType(tempTable: ColumnTable, sortType: SortTypes) {
- switch (sortType) {
- case SortTypes.CAT_ASC:
- return tempTable.orderby(desc('category'));
- case SortTypes.CAT_DESC:
- return tempTable.orderby('category');
- case SortTypes.COUNT_ASC:
- return tempTable.orderby(desc('count'));
- case SortTypes.COUNT_DESC:
- return tempTable.orderby('count');
- default:
- return tempTable;
- }
-}
-
-// Helper function for the bar chart which bins the data depending on the aggregate type. Used for numerical column grouping
-export function binByAggregateType(tempTable: ColumnTable, aggregateType: EAggregateTypes) {
- switch (aggregateType) {
- case EAggregateTypes.COUNT:
- return tempTable
- .groupby('category', { group: bin('group', { maxbins: 9 }), group_max: bin('group', { maxbins: 9, offset: 1 }) })
- .rollup({ aggregateVal: () => op.count(), count: op.count(), selectedCount: (d) => op.sum(d.selected), ids: (d) => op.array_agg(d.id) })
- .orderby('group')
- .groupby('category')
- .derive({ categoryCount: (d) => op.sum(d.count) });
- case EAggregateTypes.AVG:
- return tempTable
- .groupby('category', { group: bin('group', { maxbins: 9 }), group_max: bin('group', { maxbins: 9, offset: 1 }) })
- .rollup({
- aggregateVal: (d) => op.average(d.aggregateVal),
- count: op.count(),
- selectedCount: (d) => op.sum(d.selected),
- ids: (d) => op.array_agg(d.id),
- })
- .orderby('group')
- .groupby('category')
- .derive({ categoryCount: (d) => op.sum(d.count) });
- case EAggregateTypes.MIN:
- return tempTable
- .groupby('category', { group: bin('group', { maxbins: 9 }), group_max: bin('group', { maxbins: 9, offset: 1 }) })
- .rollup({ aggregateVal: (d) => op.min(d.aggregateVal), count: op.count(), selectedCount: (d) => op.sum(d.selected), ids: (d) => op.array_agg(d.id) })
- .orderby('group')
- .groupby('category')
- .derive({ categoryCount: (d) => op.sum(d.count) });
- case EAggregateTypes.MED:
- return tempTable
- .groupby('category', { group: bin('group', { maxbins: 9 }), group_max: bin('group', { maxbins: 9, offset: 1 }) })
- .rollup({
- aggregateVal: (d) => op.median(d.aggregateVal),
- count: op.count(),
- selectedCount: (d) => op.sum(d.selected),
- ids: (d) => op.array_agg(d.id),
- })
- .orderby('group')
- .groupby('category')
- .derive({ categoryCount: (d) => op.sum(d.count) });
-
- case EAggregateTypes.MAX:
- return tempTable
- .groupby('category', { group: bin('group', { maxbins: 9 }), group_max: bin('group', { maxbins: 9, offset: 1 }) })
- .rollup({ aggregateVal: (d) => op.max(d.aggregateVal), count: op.count(), selectedCount: (d) => op.sum(d.selected), ids: (d) => op.array_agg(d.id) })
- .orderby('group')
- .groupby('category')
- .derive({ categoryCount: (d) => op.sum(d.count) });
- default:
- return null;
- }
-}
-// Helper function for the bar chart which aggregates the data based on the aggregate type.
-// Mostly just code duplication with the different aggregate types.
-export function groupByAggregateType(tempTable: ColumnTable, aggregateType: EAggregateTypes) {
- switch (aggregateType) {
- case EAggregateTypes.COUNT:
- return tempTable
- .groupby('category', 'group')
- .rollup({ aggregateVal: () => op.count(), count: op.count(), selectedCount: (d) => op.sum(d.selected), ids: (d) => op.array_agg(d.id) })
- .orderby('category')
- .groupby('category')
- .derive({ categoryCount: (d) => op.sum(d.count) });
- case EAggregateTypes.AVG:
- return tempTable
- .groupby('category', 'group')
- .rollup({
- aggregateVal: (d) => op.average(d.aggregateVal),
- count: op.count(),
- selectedCount: (d) => op.sum(d.selected),
- ids: (d) => op.array_agg(d.id),
- })
- .orderby('category')
- .groupby('category')
- .derive({ categoryCount: (d) => op.sum(d.count) });
- case EAggregateTypes.MIN:
- return tempTable
- .groupby('category', 'group')
- .rollup({ aggregateVal: (d) => op.min(d.aggregateVal), count: op.count(), selectedCount: (d) => op.sum(d.selected), ids: (d) => op.array_agg(d.id) })
- .orderby('category')
- .groupby('category')
- .derive({ categoryCount: (d) => op.sum(d.count) });
- case EAggregateTypes.MED:
- return tempTable
- .groupby('category', 'group')
- .rollup({
- aggregateVal: (d) => op.median(d.aggregateVal),
- count: op.count(),
- selectedCount: (d) => op.sum(d.selected),
- ids: (d) => op.array_agg(d.id),
- })
- .orderby('category')
- .groupby('category')
- .derive({ categoryCount: (d) => op.sum(d.count) });
-
- case EAggregateTypes.MAX:
- return tempTable
- .groupby('category', 'group')
- .rollup({ aggregateVal: (d) => op.max(d.aggregateVal), count: op.count(), selectedCount: (d) => op.sum(d.selected), ids: (d) => op.array_agg(d.id) })
- .orderby('category')
- .groupby('category')
- .derive({ categoryCount: (d) => op.sum(d.count) });
- default:
- return null;
- }
-}
-
-// Helper function for the bar chart which rolls up the data depending on the aggregate type.
-// Mostly just code duplication with the different aggregate types.
-export function rollupByAggregateType(tempTable: ColumnTable, aggregateType: EAggregateTypes) {
- switch (aggregateType) {
- case EAggregateTypes.COUNT:
- return tempTable.rollup({ aggregateVal: () => op.count() });
- case EAggregateTypes.AVG:
- return tempTable.rollup({ aggregateVal: (d) => op.average(d.aggregateVal) });
-
- case EAggregateTypes.MIN:
- return tempTable.rollup({ aggregateVal: (d) => op.min(d.aggregateVal) });
-
- case EAggregateTypes.MED:
- return tempTable.rollup({ aggregateVal: (d) => op.median(d.aggregateVal) });
- case EAggregateTypes.MAX:
- return tempTable.rollup({ aggregateVal: (d) => op.max(d.aggregateVal) });
-
- default:
- return null;
- }
-}
-
-export async function getBarData(
- columns: VisColumn[],
- catColumn: ColumnInfo,
- groupColumn: ColumnInfo | null,
- facetsColumn: ColumnInfo | null,
- aggregateColumn: ColumnInfo | null,
-): Promise<{
- catColVals: {
- resolvedValues: (VisNumericalValue | VisCategoricalValue)[];
- type: EColumnTypes.NUMERICAL | EColumnTypes.CATEGORICAL;
- info: ColumnInfo;
- };
- groupColVals: {
- resolvedValues: (VisNumericalValue | VisCategoricalValue)[];
- type: EColumnTypes.NUMERICAL | EColumnTypes.CATEGORICAL;
- info: ColumnInfo;
- color?: Record;
- domain?: string[] | [number | undefined, number | undefined];
- };
- facetsColVals: {
- resolvedValues: (VisNumericalValue | VisCategoricalValue)[];
- type: EColumnTypes.NUMERICAL | EColumnTypes.CATEGORICAL;
- info: ColumnInfo;
- };
- aggregateColVals: {
- resolvedValues: (VisNumericalValue | VisCategoricalValue)[];
- type: EColumnTypes.NUMERICAL | EColumnTypes.CATEGORICAL;
- info: ColumnInfo;
- };
-}> {
- const catColVals = await resolveSingleColumn(columns.find((col) => col.info.id === catColumn.id));
-
- const groupColVals = await resolveSingleColumn(groupColumn ? columns.find((col) => col.info.id === groupColumn.id) : null);
- const facetsColVals = await resolveSingleColumn(facetsColumn ? columns.find((col) => col.info.id === facetsColumn.id) : null);
- const aggregateColVals = await resolveSingleColumn(aggregateColumn ? columns.find((col) => col.info.id === aggregateColumn.id) : null);
-
- return { catColVals, groupColVals, facetsColVals, aggregateColVals };
-}
diff --git a/src/vis/bar/utils/index.ts b/src/vis/bar/utils/index.ts
new file mode 100644
index 000000000..04bca77e0
--- /dev/null
+++ b/src/vis/bar/utils/index.ts
@@ -0,0 +1 @@
+export * from './utils';
diff --git a/src/vis/bar/utils/utils.ts b/src/vis/bar/utils/utils.ts
new file mode 100644
index 000000000..bbb17f778
--- /dev/null
+++ b/src/vis/bar/utils/utils.ts
@@ -0,0 +1,20 @@
+import merge from 'lodash/merge';
+import { ColumnInfo, EColumnTypes, VisColumn } from '../../interfaces';
+import { defaultConfig, IBarConfig } from '../interfaces';
+
+export function barMergeDefaultConfig(columns: VisColumn[], config: IBarConfig): IBarConfig {
+ const merged = merge({}, defaultConfig, config);
+
+ const catCols = columns.filter((c) => c.type === EColumnTypes.CATEGORICAL);
+ const numCols = columns.filter((c) => c.type === EColumnTypes.NUMERICAL);
+
+ if (!merged.catColumnSelected && catCols.length > 0) {
+ merged.catColumnSelected = catCols[catCols.length - 1]?.info as ColumnInfo;
+ }
+
+ if (!merged.aggregateColumn && numCols.length > 0) {
+ merged.aggregateColumn = numCols[numCols.length - 1]?.info as ColumnInfo;
+ }
+
+ return merged;
+}
diff --git a/src/vis/general/DownloadPlotButton.tsx b/src/vis/general/DownloadPlotButton.tsx
index 92acba794..425e7bdc3 100644
--- a/src/vis/general/DownloadPlotButton.tsx
+++ b/src/vis/general/DownloadPlotButton.tsx
@@ -2,11 +2,11 @@ import { Tooltip, ActionIcon } from '@mantine/core';
import * as React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { BaseVisConfig } from '../interfaces';
-import { useCaptureVisScreenshot } from '../useCaptureVisScreenshot';
+import { DownloadPlotOptions, useCaptureVisScreenshot } from '../useCaptureVisScreenshot';
import { dvDownloadVisualization } from '../../icons';
-export function DownloadPlotButton({ uniquePlotId, config }: { config: BaseVisConfig; uniquePlotId: string }) {
- const [{ isLoading }, captureScreenshot] = useCaptureVisScreenshot(uniquePlotId, config);
+export function DownloadPlotButton({ uniquePlotId, config, options }: { config: BaseVisConfig; uniquePlotId: string; options?: DownloadPlotOptions }) {
+ const [{ isLoading }, captureScreenshot] = useCaptureVisScreenshot(uniquePlotId, config, options);
return (
diff --git a/src/vis/general/constants.ts b/src/vis/general/constants.ts
index 2c2e8fc5e..5077509b8 100644
--- a/src/vis/general/constants.ts
+++ b/src/vis/general/constants.ts
@@ -26,7 +26,7 @@ export const VIS_LABEL_COLOR = '#99A1A9';
export const VIS_GRID_COLOR = '#E9ECEF';
/**
- * Neutral color (e.g., histogram in scatterplot matrix)
+ * Neutral color (e.g., histogram in scatterplot matrix and should be also used for "Unknown" categorical values)
*/
export const VIS_NEUTRAL_COLOR = '#71787E';
diff --git a/src/vis/general/layoutUtils.ts b/src/vis/general/layoutUtils.ts
index c5c3610ef..010ff913c 100644
--- a/src/vis/general/layoutUtils.ts
+++ b/src/vis/general/layoutUtils.ts
@@ -50,7 +50,7 @@ export function beautifyLayout(
traces: PlotlyInfo,
layout: Partial,
oldLayout: Partial,
- categoryOrder: Map = null,
+ categoryOrder: Map | null = null,
automargin = true,
autorange = true,
) {
@@ -74,7 +74,7 @@ export function beautifyLayout(
titleTraces.forEach((t) => {
if (t.title) {
- layout.annotations.push({
+ layout.annotations?.push({
text: truncateText(t.title, true, 30),
showarrow: false,
x: 0.5,
@@ -91,10 +91,12 @@ export function beautifyLayout(
});
sharedAxisTraces.forEach((t, i) => {
- const axisX = t.data.xaxis?.replace('x', 'xaxis') || 'xaxis';
- layout[axisX] = {
- ...oldLayout?.[`xaxis${i > 0 ? i + 1 : ''}`],
- range: t.xDomain ? t.xDomain : null,
+ const xAxis = (t.data.xaxis?.replace('x', 'xaxis') || 'xaxis') as 'xaxis';
+ const indexedXAxis = `${xAxis}${i > 0 ? i + 1 : ''}` as `xaxis${2 | 3 | 4 | 5 | 6 | 7 | 8 | 9}`;
+
+ layout[xAxis] = {
+ ...oldLayout?.[indexedXAxis],
+ range: t.xDomain ? t.xDomain : undefined,
color: VIS_LABEL_COLOR,
gridcolor: VIS_GRID_COLOR,
zerolinecolor: VIS_GRID_COLOR,
@@ -102,31 +104,32 @@ export function beautifyLayout(
tickvals: t.xTicks,
ticktext: t.xTickLabels,
tickfont: {
- size: sharedAxisTraces.length > 1 ? VIS_TICK_LABEL_SIZE_SMALL : VIS_TICK_LABEL_SIZE,
+ size: sharedAxisTraces.length > 1 ? +VIS_TICK_LABEL_SIZE_SMALL : +VIS_TICK_LABEL_SIZE,
},
- type: typeof t.data.x?.[0] === 'string' ? 'category' : null,
- ticks: 'none',
- text: t.xTicks,
+ type: typeof t.data.x?.[0] === 'string' ? 'category' : undefined,
+ ticks: undefined,
showspikes: false,
spikedash: 'dash',
- categoryarray: categoryOrder?.get(i + 1) || null,
- categoryorder: categoryOrder?.get(i + 1) ? 'array' : null,
+ categoryarray: categoryOrder?.get(i + 1) || undefined,
+ categoryorder: categoryOrder?.get(i + 1) ? 'array' : undefined,
title: {
standoff: 5,
text: sharedAxisTraces.length > 1 ? truncateText(t.xLabel, false, 20) : truncateText(t.xLabel, true, 55),
font: {
family: 'Roboto, sans-serif',
- size: sharedAxisTraces.length > 1 ? VIS_AXIS_LABEL_SIZE_SMALL : VIS_AXIS_LABEL_SIZE,
+ size: sharedAxisTraces.length > 1 ? +VIS_AXIS_LABEL_SIZE_SMALL : +VIS_AXIS_LABEL_SIZE,
color: VIS_LABEL_COLOR,
},
},
};
- const axisY = t.data.yaxis?.replace('y', 'yaxis') || 'yaxis';
- layout[axisY] = {
- ...oldLayout?.[`yaxis${i > 0 ? i + 1 : ''}`],
- range: t.yDomain ? t.yDomain : null,
+ const yAxis = (t.data.yaxis?.replace('y', 'yaxis') || 'yaxis') as 'yaxis';
+ const indexedYAxis = `${yAxis}${i > 0 ? i + 1 : ''}` as `yaxis${2 | 3 | 4 | 5 | 6 | 7 | 8 | 9}`;
+
+ layout[yAxis] = {
+ ...oldLayout?.[indexedYAxis],
+ range: t.yDomain ? t.yDomain : undefined,
automargin,
autorange,
color: VIS_LABEL_COLOR,
@@ -135,11 +138,10 @@ export function beautifyLayout(
tickvals: t.yTicks,
ticktext: t.yTickLabels,
tickfont: {
- size: sharedAxisTraces.length > 1 ? VIS_TICK_LABEL_SIZE_SMALL : VIS_TICK_LABEL_SIZE,
+ size: sharedAxisTraces.length > 1 ? +VIS_TICK_LABEL_SIZE_SMALL : +VIS_TICK_LABEL_SIZE,
},
- type: typeof t.data.y?.[0] === 'string' ? 'category' : null,
- ticks: 'none',
- text: t.yTicks,
+ type: typeof t.data.y?.[0] === 'string' ? 'category' : undefined,
+ ticks: undefined,
showspikes: false,
spikedash: 'dash',
title: {
@@ -147,7 +149,7 @@ export function beautifyLayout(
text: sharedAxisTraces.length > 1 ? truncateText(t.yLabel, false, 20) : truncateText(t.yLabel, true, 55),
font: {
family: 'Roboto, sans-serif',
- size: sharedAxisTraces.length > 1 ? VIS_AXIS_LABEL_SIZE_SMALL : VIS_AXIS_LABEL_SIZE,
+ size: sharedAxisTraces.length > 1 ? +VIS_AXIS_LABEL_SIZE_SMALL : +VIS_AXIS_LABEL_SIZE,
color: VIS_LABEL_COLOR,
},
},
@@ -179,13 +181,17 @@ export async function resolveSingleColumn(column: VisColumn | null) {
*/
export async function createIdToLabelMapper(columns: VisColumn[]): Promise<(id: string) => string> {
const labelColumns = (await resolveColumnValues(columns.filter((c) => c.isLabel))).map((c) => c.resolvedValues);
- const labelsMap = labelColumns.reduce((acc, curr) => {
- curr.forEach((obj) => {
- if (acc[obj.id] == null) {
- acc[obj.id] = obj.val;
- }
- });
- return acc;
- }, {});
+ const labelsMap = labelColumns.reduce(
+ (acc, curr) => {
+ curr.forEach((obj) => {
+ if (acc[obj.id as string] == null) {
+ acc[obj.id as string] = obj.val as string;
+ }
+ });
+ return acc;
+ },
+ {} as { [key: string]: string },
+ );
+
return (id: string) => labelsMap[id] ?? id;
}
diff --git a/src/vis/general/utils.ts b/src/vis/general/utils.ts
index 68012f9b5..424ad10f5 100644
--- a/src/vis/general/utils.ts
+++ b/src/vis/general/utils.ts
@@ -7,10 +7,16 @@ import { NAN_REPLACEMENT, VIS_NEUTRAL_COLOR } from './constants';
* @returns the label if it is not undefined, null or empty, otherwise NAN_REPLACEMENT (Unknown)
*/
export function getLabelOrUnknown(label: string | number | null | undefined, unknownLabel: string = NAN_REPLACEMENT): string {
+ const formatter = new Intl.NumberFormat('en-US', {
+ maximumFractionDigits: 4,
+ maximumSignificantDigits: 4,
+ notation: 'compact',
+ compactDisplay: 'short',
+ });
return label === null || label === 'null' || label === undefined || label === 'undefined' || label === ''
? unknownLabel
- : Number(label) && !Number.isInteger(label) // if it is a number, but not an integer, round to 3 decimal places
- ? Number(label).toFixed(3)
+ : Number(label) && !Number.isInteger(label) // if it is a number, but not an integer, apply NumberFormat
+ ? formatter.format(label as number)
: label.toString();
}
diff --git a/src/vis/heatmap/Heatmap.tsx b/src/vis/heatmap/Heatmap.tsx
index 3978def87..0f51ab14e 100644
--- a/src/vis/heatmap/Heatmap.tsx
+++ b/src/vis/heatmap/Heatmap.tsx
@@ -4,7 +4,7 @@ import { desc, op, table } from 'arquero';
import * as d3 from 'd3v7';
import * as React from 'react';
import { useMemo } from 'react';
-import { rollupByAggregateType } from '../bar/utils';
+import { rollupByAggregateType } from './utils';
import { ColumnInfo, EAggregateTypes, EColumnTypes, ENumericalColorScaleType, VisCategoricalValue, VisNumericalValue } from '../interfaces';
import { ColorLegendVert } from '../legend/ColorLegendVert';
import { HeatmapRect } from './HeatmapRect';
diff --git a/src/vis/heatmap/utils.ts b/src/vis/heatmap/utils.ts
index 16f6aa7a5..c979324f2 100644
--- a/src/vis/heatmap/utils.ts
+++ b/src/vis/heatmap/utils.ts
@@ -1,3 +1,5 @@
+import { op } from 'arquero';
+import ColumnTable from 'arquero/dist/types/table/column-table';
import merge from 'lodash/merge';
import { resolveColumnValues, resolveSingleColumn } from '../general/layoutUtils';
import {
@@ -29,6 +31,28 @@ export function heatmapMergeDefaultConfig(columns: VisColumn[], config: IHeatmap
return merged;
}
+// Helper function for the bar chart which rolls up the data depending on the aggregate type.
+// Mostly just code duplication with the different aggregate types.
+export function rollupByAggregateType(tempTable: ColumnTable, aggregateType: EAggregateTypes) {
+ switch (aggregateType) {
+ case EAggregateTypes.COUNT:
+ return tempTable.rollup({ aggregateVal: () => op.count() });
+ case EAggregateTypes.AVG:
+ return tempTable.rollup({ aggregateVal: (d) => op.average(d.aggregateVal) });
+
+ case EAggregateTypes.MIN:
+ return tempTable.rollup({ aggregateVal: (d) => op.min(d.aggregateVal) });
+
+ case EAggregateTypes.MED:
+ return tempTable.rollup({ aggregateVal: (d) => op.median(d.aggregateVal) });
+ case EAggregateTypes.MAX:
+ return tempTable.rollup({ aggregateVal: (d) => op.max(d.aggregateVal) });
+
+ default:
+ return null;
+ }
+}
+
export async function getHeatmapData(
columns: VisColumn[],
catColumnDesc: ColumnInfo[],
diff --git a/src/vis/interfaces.ts b/src/vis/interfaces.ts
index 3354de266..d9065c2f1 100644
--- a/src/vis/interfaces.ts
+++ b/src/vis/interfaces.ts
@@ -17,6 +17,10 @@ export function isESupportedPlotlyVis(value: string): value is ESupportedPlotlyV
export interface BaseVisConfig {
type: string;
+ /**
+ * Merge the config with the default values once or if the vis type changes.
+ * @default false
+ */
merged?: boolean;
}
@@ -66,7 +70,7 @@ export interface IVisCommonValue {
/**
* Value of a vis column.
*/
- val: Type;
+ val: Type | null;
}
export type VisNumericalValue = IVisCommonValue;
diff --git a/src/vis/sidebar/index.ts b/src/vis/sidebar/index.ts
index 115e0c657..9d326a84b 100644
--- a/src/vis/sidebar/index.ts
+++ b/src/vis/sidebar/index.ts
@@ -1,10 +1,7 @@
-export * from '../bar/BarDirectionButtons';
-export * from '../bar/BarDisplayTypeButtons';
-export * from '../bar/BarGroupTypeButtons';
+export * from '../bar/components';
export * from './BrushOptionButtons';
export * from '../scatter/ColorSelect';
export * from './FilterButtons';
-export * from '../bar/GroupSelect';
export * from './NumericalColorButtons';
export * from './MultiSelect';
export * from '../scatter/OpacitySlider';
diff --git a/src/vis/stories/Iris.stories.tsx b/src/vis/stories/Iris.stories.tsx
index d62524202..d5f58ef3a 100644
--- a/src/vis/stories/Iris.stories.tsx
+++ b/src/vis/stories/Iris.stories.tsx
@@ -1,8 +1,8 @@
import { ComponentStory } from '@storybook/react';
import React, { useState } from 'react';
-import { Vis } from '../LazyVis';
import { EBarDirection, EBarDisplayType, EBarGroupingType } from '../bar/interfaces';
-import { BaseVisConfig, EAggregateTypes, ENumericalColorScaleType, EScatterSelectSettings, ESupportedPlotlyVis } from '../interfaces';
+import { ESupportedPlotlyVis, ENumericalColorScaleType, EScatterSelectSettings, BaseVisConfig, EAggregateTypes } from '../interfaces';
+import { Vis } from '../LazyVis';
import { EViolinOverlay } from '../violin/interfaces';
import { fetchIrisData } from './fetchIrisData';
@@ -69,11 +69,7 @@ BarChart.args = {
display: EBarDisplayType.ABSOLUTE,
groupType: EBarGroupingType.GROUP,
numColumnsSelected: [],
- catColumnSelected: {
- description: '',
- id: 'randomThing',
- name: 'Random Thing',
- },
+ catColumnSelected: null,
aggregateColumn: null,
aggregateType: EAggregateTypes.COUNT,
} as BaseVisConfig,
diff --git a/src/vis/stories/Vis/Bar/BarRandom.stories.tsx b/src/vis/stories/Vis/Bar/BarRandom.stories.tsx
index a8c83a6ec..b29c0866c 100644
--- a/src/vis/stories/Vis/Bar/BarRandom.stories.tsx
+++ b/src/vis/stories/Vis/Bar/BarRandom.stories.tsx
@@ -1,40 +1,58 @@
import { ComponentStory } from '@storybook/react';
import React from 'react';
+import { EBarDirection, EBarDisplayType, EBarGroupingType, EBarSortState } from '../../../bar/interfaces';
+import { BaseVisConfig, EAggregateTypes, EColumnTypes, ESupportedPlotlyVis, VisColumn } from '../../../interfaces';
import { Vis } from '../../../LazyVis';
import { VisProvider } from '../../../Provider';
-import { EBarDirection, EBarDisplayType, EBarGroupingType } from '../../../bar/interfaces';
-import { BaseVisConfig, EAggregateTypes, EColumnTypes, ESupportedPlotlyVis, VisColumn } from '../../../interfaces';
-function RNG(seed) {
+function RNG(seed: number, sign: 'positive' | 'negative' | 'mixed' = 'positive') {
const m = 2 ** 35 - 31;
const a = 185852;
let s = seed % m;
- return function () {
- return (s = (s * a) % m) / m;
+ return () => {
+ let value = ((s = (s * a) % m) / m) * 2 - 1; // Generate values between -1 and 1
+ if (sign === 'positive') {
+ value = Math.abs(value);
+ } else if (sign === 'negative') {
+ value = -Math.abs(value);
+ }
+ return value;
};
}
function fetchData(numberOfPoints: number): VisColumn[] {
- const rng = RNG(10);
+ const positiveRNG = RNG(10, 'positive');
+ const negativeRNG = RNG(10, 'negative');
+ const mixedRNG = RNG(10, 'mixed');
+
const dataGetter = async () => ({
- value: Array(numberOfPoints)
+ positiveNumbers: Array(numberOfPoints)
+ .fill(null)
+ .map(() => positiveRNG() * numberOfPoints),
+ negativeNumbers: Array(numberOfPoints)
+ .fill(null)
+ .map(() => negativeRNG() * numberOfPoints),
+ randomNumbers: Array(numberOfPoints)
.fill(null)
- .map(() => rng() * 100),
- pca_x: Array(numberOfPoints)
+ .map(() => mixedRNG() * numberOfPoints),
+ singleNumber: Array(numberOfPoints)
.fill(null)
- .map(() => rng() * 100),
- pca_y: Array(numberOfPoints)
+ .map(() => RNG(numberOfPoints, 'mixed')()),
+ categories: Array(numberOfPoints)
.fill(null)
- .map(() => rng() * 100),
- category: Array(numberOfPoints)
+ .map(() => `CATEGORY_${parseInt((positiveRNG() * 10).toString(), 10).toString()}`),
+ manyCategories: Array(numberOfPoints)
.fill(null)
- .map(() => parseInt((rng() * 10).toString(), 10).toString()),
- category2: Array(numberOfPoints)
+ .map(() => `MANY_CATEGORIES_${parseInt((positiveRNG() * 100).toString(), 10).toString()}`),
+ twoCategories: Array(numberOfPoints)
.fill(null)
- .map(() => parseInt((rng() * 5).toString(), 5).toString()),
- category3: Array(numberOfPoints)
+ .map((_, i) => `${parseInt((RNG(i)() * numberOfPoints).toString(), 10) % 3 ? 'EVEN' : 'ODD'}_CATEGORY`),
+ categoriesAsNumberOfPoints: Array(numberOfPoints)
.fill(null)
- .map(() => parseInt((rng() * 2).toString(), 2).toString()),
+ .map((_, i) => `DATA_CATEGORY_${i}`),
+ singleCategory: Array(numberOfPoints)
+ .fill(null)
+ .map(() => `ONE_CATEGORY`),
});
const dataPromise = dataGetter();
@@ -42,61 +60,117 @@ function fetchData(numberOfPoints: number): VisColumn[] {
return [
{
info: {
- description: '',
- id: 'pca_x',
- name: 'pca_x',
+ description: 'Positive numerical value of a data point',
+ id: 'positiveNumbers',
+ name: 'Positive numbers',
},
+ domain: [undefined, undefined],
+
type: EColumnTypes.NUMERICAL,
- domain: [0, undefined],
- values: () => dataPromise.then((data) => data.pca_x.map((val, i) => ({ id: i.toString(), val }))),
+ values: async () => {
+ const data = await dataPromise;
+ return data.positiveNumbers.map((val, i) => ({ id: i.toString(), val }));
+ },
},
{
info: {
- description: '',
- id: 'pca_y',
- name: 'pca_y',
+ description: 'Negative numerical value of a data point',
+ id: 'negativeNumbers',
+ name: 'Negative numbers',
},
+ domain: [undefined, undefined],
+
type: EColumnTypes.NUMERICAL,
- domain: [0, undefined],
- values: () => dataPromise.then((data) => data.pca_y.map((val, i) => ({ id: i.toString(), val }))),
+ values: async () => {
+ const data = await dataPromise;
+ return data.negativeNumbers.map((val, i) => ({ id: i.toString(), val }));
+ },
},
{
info: {
- description: '',
- id: 'value',
- name: 'value',
+ description: 'Random numbers generated for the data point. May be positive or negative or zero',
+ id: 'randomNumbers',
+ name: 'Random numbers',
},
- domain: [0, 100],
-
type: EColumnTypes.NUMERICAL,
- values: () => dataPromise.then((data) => data.value.map((val, i) => ({ id: i.toString(), val }))),
+ domain: [undefined, undefined],
+ values: async () => {
+ const data = await dataPromise;
+ return data.randomNumbers.map((val, i) => ({ id: i.toString(), val }));
+ },
},
{
info: {
- description: '',
- id: 'category',
- name: 'category',
+ description: 'Single number value',
+ id: 'singleNumber',
+ name: 'Single number',
+ },
+ type: EColumnTypes.NUMERICAL,
+ domain: [undefined, undefined],
+ values: async () => {
+ const data = await dataPromise;
+ return data.singleNumber.map((val, i) => ({ id: i.toString(), val }));
+ },
+ },
+ {
+ info: {
+ description: 'Categories for the data',
+ id: 'categories',
+ name: 'Categories',
+ },
+ type: EColumnTypes.CATEGORICAL,
+ values: async () => {
+ const data = await dataPromise;
+ return data.categories.map((val, i) => ({ id: i.toString(), val }));
+ },
+ },
+ {
+ info: {
+ description: 'Many categories for the data',
+ id: 'manyCategories',
+ name: 'Many categories',
+ },
+ type: EColumnTypes.CATEGORICAL,
+ values: async () => {
+ const data = await dataPromise;
+ return data.manyCategories.map((val, i) => ({ id: i.toString(), val }));
+ },
+ },
+ {
+ info: {
+ description: 'Two specific categories for the data',
+ id: 'twoCategories',
+ name: 'Two categories',
},
type: EColumnTypes.CATEGORICAL,
- values: () => dataPromise.then((data) => data.category.map((val, i) => ({ id: i.toString(), val }))),
+ values: async () => {
+ const data = await dataPromise;
+ return data.twoCategories.map((val, i) => ({ id: i.toString(), val }));
+ },
},
{
info: {
- description: '',
- id: 'category2',
- name: 'category2',
+ description: 'Categories as much as the number of points',
+ id: 'categoriesAsNumberOfPoints',
+ name: 'Categories as number of points',
},
type: EColumnTypes.CATEGORICAL,
- values: () => dataPromise.then((data) => data.category2.map((val, i) => ({ id: i.toString(), val }))),
+ values: async () => {
+ const data = await dataPromise;
+ return data.categoriesAsNumberOfPoints.map((val, i) => ({ id: i.toString(), val }));
+ },
},
{
info: {
- description: '',
- id: 'category3',
- name: 'category3',
+ description: 'One category for the data',
+ id: 'oneCategory',
+ name: 'Single category',
},
type: EColumnTypes.CATEGORICAL,
- values: () => dataPromise.then((data) => data.category3.map((val, i) => ({ id: i.toString(), val }))),
+ values: async () => {
+ const data = await dataPromise;
+ return data.singleCategory.map((val, i) => ({ id: i.toString(), val }));
+ },
},
];
}
@@ -136,18 +210,19 @@ Basic.args = {
externalConfig: {
type: ESupportedPlotlyVis.BAR,
catColumnSelected: {
- description: '',
- id: 'category',
- name: 'category',
+ description: 'Categories for the data',
+ id: 'categories',
+ name: 'Categories',
},
facets: null,
group: null,
- groupType: EBarGroupingType.GROUP,
+ groupType: EBarGroupingType.STACK,
direction: EBarDirection.HORIZONTAL,
display: EBarDisplayType.ABSOLUTE,
aggregateType: EAggregateTypes.COUNT,
aggregateColumn: null,
numColumnsSelected: [],
+ showSidebar: true,
} as BaseVisConfig,
};
@@ -156,9 +231,30 @@ Vertical.args = {
externalConfig: {
type: ESupportedPlotlyVis.BAR,
catColumnSelected: {
- description: '',
- id: 'category',
- name: 'category',
+ description: 'Categories for the data',
+ id: 'categories',
+ name: 'Categories',
+ },
+ facets: null,
+ group: null,
+ groupType: EBarGroupingType.GROUP,
+ direction: EBarDirection.VERTICAL,
+ display: EBarDisplayType.ABSOLUTE,
+ aggregateType: EAggregateTypes.COUNT,
+ aggregateColumn: null,
+ numColumnsSelected: [],
+ useFullHeight: false,
+ } as BaseVisConfig,
+};
+
+export const VerticalFullHeight: typeof Template = Template.bind({}) as typeof Template;
+VerticalFullHeight.args = {
+ externalConfig: {
+ type: ESupportedPlotlyVis.BAR,
+ catColumnSelected: {
+ description: 'Categories for the data',
+ id: 'categories',
+ name: 'Categories',
},
facets: null,
group: null,
@@ -176,15 +272,15 @@ Grouped.args = {
externalConfig: {
type: ESupportedPlotlyVis.BAR,
catColumnSelected: {
- description: '',
- id: 'category',
- name: 'category',
+ description: 'Categories for the data',
+ id: 'categories',
+ name: 'Categories',
},
facets: null,
group: {
- description: '',
- id: 'category2',
- name: 'category2',
+ description: 'Many categories for the data',
+ id: 'manyCategories',
+ name: 'Many categories',
},
groupType: EBarGroupingType.GROUP,
direction: EBarDirection.HORIZONTAL,
@@ -200,15 +296,15 @@ GroupedStack.args = {
externalConfig: {
type: ESupportedPlotlyVis.BAR,
catColumnSelected: {
- description: '',
- id: 'category',
- name: 'category',
+ description: 'Categories for the data',
+ id: 'categories',
+ name: 'Categories',
},
facets: null,
group: {
- description: '',
- id: 'category2',
- name: 'category2',
+ description: 'Many categories for the data',
+ id: 'manyCategories',
+ name: 'Many categories',
},
groupType: EBarGroupingType.STACK,
direction: EBarDirection.HORIZONTAL,
@@ -224,15 +320,15 @@ GroupedNumerical.args = {
externalConfig: {
type: ESupportedPlotlyVis.BAR,
catColumnSelected: {
- description: '',
- id: 'category',
- name: 'category',
+ description: 'Categories for the data',
+ id: 'categories',
+ name: 'Categories',
},
facets: null,
group: {
- description: '',
- id: 'pca_y',
- name: 'pca_y',
+ description: 'Positive numerical value of a data point',
+ id: 'positiveNumbers',
+ name: 'Positive numbers',
},
groupType: EBarGroupingType.GROUP,
direction: EBarDirection.HORIZONTAL,
@@ -248,15 +344,15 @@ GroupedNumericalStack.args = {
externalConfig: {
type: ESupportedPlotlyVis.BAR,
catColumnSelected: {
- description: '',
- id: 'category',
- name: 'category',
+ description: 'Categories for the data',
+ id: 'categories',
+ name: 'Categories',
},
facets: null,
group: {
- description: '',
- id: 'pca_y',
- name: 'pca_y',
+ description: 'Positive numerical value of a data point',
+ id: 'positiveNumbers',
+ name: 'Positive numbers',
},
groupType: EBarGroupingType.STACK,
direction: EBarDirection.HORIZONTAL,
@@ -267,19 +363,43 @@ GroupedNumericalStack.args = {
} as BaseVisConfig,
};
+export const GroupedNumericalStackNormalized: typeof Template = Template.bind({}) as typeof Template;
+GroupedNumericalStackNormalized.args = {
+ externalConfig: {
+ type: ESupportedPlotlyVis.BAR,
+ catColumnSelected: {
+ description: 'Categories for the data',
+ id: 'categories',
+ name: 'Categories',
+ },
+ facets: null,
+ group: {
+ description: 'Positive numerical value of a data point',
+ id: 'positiveNumbers',
+ name: 'Positive numbers',
+ },
+ groupType: EBarGroupingType.STACK,
+ direction: EBarDirection.HORIZONTAL,
+ display: EBarDisplayType.NORMALIZED,
+ aggregateType: EAggregateTypes.COUNT,
+ aggregateColumn: null,
+ numColumnsSelected: [],
+ } as BaseVisConfig,
+};
+
export const facets: typeof Template = Template.bind({}) as typeof Template;
facets.args = {
externalConfig: {
type: ESupportedPlotlyVis.BAR,
catColumnSelected: {
- description: '',
- id: 'category',
- name: 'category',
+ description: 'Categories for the data',
+ id: 'categories',
+ name: 'Categories',
},
facets: {
- description: '',
- id: 'category2',
- name: 'category2',
+ description: 'Many categories for the data',
+ id: 'manyCategories',
+ name: 'Many categories',
},
group: null,
groupType: EBarGroupingType.GROUP,
@@ -296,19 +416,19 @@ facetsAndGrouped.args = {
externalConfig: {
type: ESupportedPlotlyVis.BAR,
catColumnSelected: {
- description: '',
- id: 'category',
- name: 'category',
+ description: 'Categories for the data',
+ id: 'categories',
+ name: 'Categories',
},
facets: {
- description: '',
- id: 'category2',
- name: 'category2',
+ description: 'Many categories for the data',
+ id: 'manyCategories',
+ name: 'Many categories',
},
group: {
- description: '',
- id: 'category3',
- name: 'category3',
+ description: 'Random numbers generated for the data point. May be positive or negative or zero',
+ id: 'randomNumbers',
+ name: 'Random numbers',
},
groupType: EBarGroupingType.GROUP,
direction: EBarDirection.HORIZONTAL,
@@ -324,19 +444,19 @@ facetsAndGroupedStack.args = {
externalConfig: {
type: ESupportedPlotlyVis.BAR,
catColumnSelected: {
- description: '',
- id: 'category',
- name: 'category',
+ description: 'Categories for the data',
+ id: 'categories',
+ name: 'Categories',
},
facets: {
- description: '',
- id: 'category2',
- name: 'category2',
+ description: 'Many categories for the data',
+ id: 'manyCategories',
+ name: 'Many categories',
},
group: {
- description: '',
- id: 'category3',
- name: 'category3',
+ description: 'Random numbers generated for the data point. May be positive or negative or zero',
+ id: 'randomNumbers',
+ name: 'Random numbers',
},
groupType: EBarGroupingType.STACK,
direction: EBarDirection.HORIZONTAL,
@@ -352,9 +472,9 @@ AggregateAverage.args = {
externalConfig: {
type: ESupportedPlotlyVis.BAR,
catColumnSelected: {
- description: '',
- id: 'category',
- name: 'category',
+ description: 'Categories for the data',
+ id: 'categories',
+ name: 'Categories',
},
facets: null,
group: null,
@@ -363,10 +483,119 @@ AggregateAverage.args = {
display: EBarDisplayType.ABSOLUTE,
aggregateType: EAggregateTypes.AVG,
aggregateColumn: {
- description: '',
- id: 'value',
- name: 'value',
+ description: 'Positive numerical value of a data point',
+ id: 'positiveNumbers',
+ name: 'Positive numbers',
},
numColumnsSelected: [],
} as BaseVisConfig,
};
+
+export const AggregateMedianWithMixedValues: typeof Template = Template.bind({}) as typeof Template;
+AggregateMedianWithMixedValues.args = {
+ externalConfig: {
+ type: ESupportedPlotlyVis.BAR,
+ catColumnSelected: {
+ description: 'Categories for the data',
+ id: 'categories',
+ name: 'Categories',
+ },
+ facets: null,
+ group: null,
+ groupType: EBarGroupingType.GROUP,
+ direction: EBarDirection.HORIZONTAL,
+ display: EBarDisplayType.ABSOLUTE,
+ aggregateType: EAggregateTypes.MED,
+ aggregateColumn: {
+ description: 'Random numbers generated for the data point. May be positive or negative or zero',
+ id: 'randomNumbers',
+ name: 'Random numbers',
+ },
+ numColumnsSelected: [],
+ } as BaseVisConfig,
+};
+
+export const AggregateMedianWithGroupedMixedValues: typeof Template = Template.bind({}) as typeof Template;
+AggregateMedianWithGroupedMixedValues.args = {
+ externalConfig: {
+ type: ESupportedPlotlyVis.BAR,
+ catColumnSelected: {
+ description: 'Categories for the data',
+ id: 'categories',
+ name: 'Categories',
+ },
+ facets: null,
+ group: {
+ description: 'Random numbers generated for the data point. May be positive or negative or zero',
+ id: 'randomNumbers',
+ name: 'Random numbers',
+ },
+ groupType: EBarGroupingType.GROUP,
+ direction: EBarDirection.HORIZONTAL,
+ display: EBarDisplayType.ABSOLUTE,
+ aggregateType: EAggregateTypes.MED,
+ aggregateColumn: {
+ description: 'Random numbers generated for the data point. May be positive or negative or zero',
+ id: 'randomNumbers',
+ name: 'Random numbers',
+ },
+ numColumnsSelected: [],
+ } as BaseVisConfig,
+};
+
+export const AggregateMedianWithGroupedAndFacetedMixedValues: typeof Template = Template.bind({}) as typeof Template;
+AggregateMedianWithGroupedAndFacetedMixedValues.args = {
+ externalConfig: {
+ type: ESupportedPlotlyVis.BAR,
+ catColumnSelected: {
+ description: 'Categories for the data',
+ id: 'categories',
+ name: 'Categories',
+ },
+ facets: {
+ description: 'Many categories for the data',
+ id: 'manyCategories',
+ name: 'Many categories',
+ },
+ group: {
+ description: 'Random numbers generated for the data point. May be positive or negative or zero',
+ id: 'randomNumbers',
+ name: 'Random numbers',
+ },
+ groupType: EBarGroupingType.GROUP,
+ direction: EBarDirection.HORIZONTAL,
+ display: EBarDisplayType.ABSOLUTE,
+ aggregateType: EAggregateTypes.MED,
+ aggregateColumn: {
+ description: 'Random numbers generated for the data point. May be positive or negative or zero',
+ id: 'randomNumbers',
+ name: 'Random numbers',
+ },
+ numColumnsSelected: [],
+ } as BaseVisConfig,
+};
+
+export const PreconfiguredSorted: typeof Template = Template.bind({}) as typeof Template;
+PreconfiguredSorted.args = {
+ externalConfig: {
+ type: ESupportedPlotlyVis.BAR,
+ catColumnSelected: {
+ description: 'Categories for the data',
+ id: 'categories',
+ name: 'Categories',
+ },
+ facets: null,
+ group: {
+ description: 'Two specific categories for the data',
+ id: 'twoCategories',
+ name: 'Two categories',
+ },
+ groupType: EBarGroupingType.STACK,
+ direction: EBarDirection.HORIZONTAL,
+ display: EBarDisplayType.ABSOLUTE,
+ aggregateType: EAggregateTypes.COUNT,
+ aggregateColumn: null,
+ numColumnsSelected: [],
+ sortState: { x: EBarSortState.DESCENDING, y: EBarSortState.NONE },
+ } as BaseVisConfig,
+};
diff --git a/src/vis/stories/explodedData.ts b/src/vis/stories/explodedData.ts
new file mode 100644
index 000000000..baedac65b
--- /dev/null
+++ b/src/vis/stories/explodedData.ts
@@ -0,0 +1,222 @@
+import { NAN_REPLACEMENT } from '../general';
+import { EColumnTypes, VisColumn } from '../interfaces';
+
+export interface TestItem {
+ name: string | null | undefined;
+ age: number | null;
+ numerical1: number;
+ numerical2: number | null;
+ categorical1: string;
+ categorical2: string | null;
+ singleCategory: string;
+ singleNumerical: number;
+ manyCategories1: string;
+ manyCategories2: string | null;
+ statusFlag: 'ACTIVE' | 'INACTIVE';
+ type1: 'TYPE_A' | 'TYPE_B' | 'TYPE_C' | 'TYPE_D' | 'TYPE_E';
+ type2: 'TYPE_A' | 'TYPE_B' | 'TYPE_C' | 'TYPE_D' | 'TYPE_E' | null;
+}
+
+const POSSIBLE_NAMES = [
+ 'Alice Marie Johnson',
+ 'AMJ',
+ 'Bob James Smith',
+ 'Bo Ja Sm',
+ 'Charlie David Brown',
+ 'David Michael Williams',
+ 'Eve Elizabeth Jones',
+ 'Frank Thomas Miller',
+ 'Grace Patricia Wilson',
+ 'Hannah Barbara Moore',
+ 'Ivan Christopher Taylor',
+ 'Jack Daniel Anderson',
+ 'Alexander Jonathan Christopher William Smith',
+ 'Elizabeth Alexandra Catherine Victoria Johnson',
+ 'Maximilian Alexander Benjamin Theodore Brown',
+ 'Isabella Sophia Olivia Charlotte Williams',
+ 'Nathaniel Sebastian Alexander Harrison Jones',
+];
+
+/**
+ * Artificially exploded test dataset to check for performance issues.
+ */
+export function generateTestData(amount: number) {
+ const randomlyDividedAmount = Math.floor((amount / Math.random()) * 10);
+ return Array.from({ length: amount }).map(() => {
+ return {
+ // Random possible name
+ name: Math.random() > 0.97 ? null : POSSIBLE_NAMES[Math.floor(Math.random() * POSSIBLE_NAMES.length)],
+
+ // Random age
+ age: Math.random() > 0.97 ? null : Math.floor(Math.random() * 100),
+
+ // 4 numerical values
+ numerical1: Math.floor(Math.random() * 4 * Math.log10(amount)),
+
+ // Random numerical value with random sign or 0
+ numerical2: Math.random() > 0.97 ? null : Math.random() * amount * (Math.random() > 0.5 ? 1 : Math.random() < 0.1 ? 0 : -1),
+
+ // 10 categories
+ categorical1: `CATEGORY_${Math.floor(Math.random() * 10)}`,
+
+ // Random category or null
+ categorical2: Math.random() > 0.97 ? null : `CATEGORY_${Math.floor(Math.random() * 10 * Math.log10(amount))}`,
+
+ // Single category
+ singleCategory: `SINGLE_UNIQUE_CATEGORY`,
+
+ // Single numerical value
+ singleNumerical: randomlyDividedAmount,
+
+ // 100 unique categories
+ manyCategories1: `MANY_${Math.floor(Math.random() * 100)}`,
+
+ // 3000 unique categories or null
+ manyCategories2: Math.random() > 0.999 ? null : `FAR_TOO_MANY_${Math.floor(Math.random() * 3000)}`,
+
+ // 2 categories
+ statusFlag: Math.random() > 0.5 ? ('ACTIVE' as const) : ('INACTIVE' as const),
+
+ // 5 types
+ type1: `TYPE_${String.fromCharCode(65 + Math.floor(Math.random() * 5))}` as TestItem['type1'],
+
+ // Random type or null
+ type2: Math.random() > 0.97 ? null : (`TYPE_${String.fromCharCode(65 + Math.floor(Math.random() * 5))}` as TestItem['type2']),
+ };
+ });
+}
+
+export function fetchTestData(testData: TestItem[]): VisColumn[] {
+ return [
+ {
+ info: {
+ description: 'Name of the patient',
+ id: 'name',
+ name: 'Name',
+ },
+ type: EColumnTypes.CATEGORICAL,
+ values: () => testData.map((r) => r.name).map((val, i) => ({ id: i.toString(), val: val || NAN_REPLACEMENT })),
+ domain: POSSIBLE_NAMES,
+ },
+ {
+ info: {
+ description: 'Age of the patient',
+ id: 'age',
+ name: 'Age',
+ },
+ type: EColumnTypes.NUMERICAL,
+ values: () => testData.map((r) => r.age).map((val, i) => ({ id: i.toString(), val })),
+ domain: [0, 100],
+ },
+ {
+ info: {
+ description: 'One of 4 random numerical value',
+ id: 'numerical1',
+ name: 'Numerical 1',
+ },
+ type: EColumnTypes.NUMERICAL,
+ values: () => testData.map((r) => r.numerical1).map((val, i) => ({ id: i.toString(), val })),
+ domain: [0, 100],
+ },
+ {
+ info: {
+ description: 'Random numerical value (positive, negative or zero)',
+ id: 'numerical2',
+ name: 'Numerical 2',
+ },
+ type: EColumnTypes.NUMERICAL,
+ values: () => testData.map((r) => r.numerical2).map((val, i) => ({ id: i.toString(), val })),
+ domain: [0, 100],
+ },
+ {
+ info: {
+ description: 'Ten categories',
+ id: 'categorical1',
+ name: 'Categorical 1',
+ },
+ type: EColumnTypes.CATEGORICAL,
+ values: () => testData.map((r) => r.categorical1).map((val, i) => ({ id: i.toString(), val })),
+ domain: Array.from(new Set(testData.map((r) => r.categorical1))),
+ },
+ {
+ info: {
+ description: 'Random category or null',
+ id: 'categorical2',
+ name: 'Categorical 2',
+ },
+ type: EColumnTypes.CATEGORICAL,
+ values: () => testData.map((r) => r.categorical2).map((val, i) => ({ id: i.toString(), val })),
+ domain: Array.from(new Set(testData.map((r) => r.categorical2))).filter(Boolean) as string[],
+ },
+ {
+ info: {
+ description: 'Single category',
+ id: 'singleCategory',
+ name: 'Single Category',
+ },
+ type: EColumnTypes.CATEGORICAL,
+ values: () => testData.map((r) => r.singleCategory).map((val, i) => ({ id: i.toString(), val })),
+ domain: ['SINGLE_UNIQUE_CATEGORY'],
+ },
+ {
+ info: {
+ description: 'Single numerical value',
+ id: 'singleNumerical',
+ name: 'Single Numerical',
+ },
+ type: EColumnTypes.NUMERICAL,
+ values: () => testData.map((r) => r.singleNumerical).map((val, i) => ({ id: i.toString(), val })),
+ domain: [testData.length, testData.length],
+ },
+ {
+ info: {
+ description: 'One hundred unique categories',
+ id: 'manyCategories1',
+ name: 'Many Categories 1',
+ },
+ type: EColumnTypes.CATEGORICAL,
+ values: () => testData.map((r) => r.manyCategories1).map((val, i) => ({ id: i.toString(), val })),
+ domain: Array.from(new Set(testData.flatMap((r) => r.manyCategories1))),
+ },
+ {
+ info: {
+ description: 'Three thousand unique categories or null',
+ id: 'manyCategories2',
+ name: 'Many Categories 2',
+ },
+ type: EColumnTypes.CATEGORICAL,
+ values: () => testData.map((r) => r.manyCategories2).map((val, i) => ({ id: i.toString(), val })),
+ domain: Array.from(new Set(testData.flatMap((r) => r.manyCategories2))).filter(Boolean) as string[],
+ },
+ {
+ info: {
+ description: 'The status flag',
+ id: 'statusFlag',
+ name: 'Status Flag',
+ },
+ type: EColumnTypes.CATEGORICAL,
+ values: () => testData.map((r) => r.statusFlag).map((val, i) => ({ id: i.toString(), val })),
+ domain: ['active', 'inactive'],
+ },
+ {
+ info: {
+ description: 'The first type value',
+ id: 'type1',
+ name: 'Type 1',
+ },
+ type: EColumnTypes.CATEGORICAL,
+ values: () => testData.map((r) => r.type1).map((val, i) => ({ id: i.toString(), val })),
+ domain: ['TYPE_A', 'TYPE_B', 'TYPE_C', 'TYPE_D', 'TYPE_E'],
+ },
+ {
+ info: {
+ description: 'The second type value',
+ id: 'type2',
+ name: 'Type 2',
+ },
+ type: EColumnTypes.CATEGORICAL,
+ values: () => testData.map((r) => r.type2).map((val, i) => ({ id: i.toString(), val })),
+ domain: ['TYPE_A', 'TYPE_B', 'TYPE_C', 'TYPE_D', 'TYPE_E'],
+ },
+ ];
+}
diff --git a/src/vis/stories/fetchIrisData.tsx b/src/vis/stories/fetchIrisData.tsx
index 5376a827a..3e19c7663 100644
--- a/src/vis/stories/fetchIrisData.tsx
+++ b/src/vis/stories/fetchIrisData.tsx
@@ -46,7 +46,7 @@ export function fetchIrisData(): VisColumn[] {
name: 'Species',
},
type: EColumnTypes.CATEGORICAL,
- values: () => dataPromise.map((r) => r.species).map((val, i) => ({ id: i.toString(), val })),
+ values: () => dataPromise.map((r) => r.species).map((val, i) => ({ id: i.toString(), val: val ?? null })),
// color: {
// Setosa: 'red',
// Virginica: 'blue',
@@ -62,7 +62,7 @@ export function fetchIrisData(): VisColumn[] {
name: 'Random category',
},
type: EColumnTypes.CATEGORICAL,
- values: () => dataPromise.map((r) => r.species).map((val, i) => ({ id: i.toString(), val })),
+ values: () => dataPromise.map((r) => r.species).map((val, i) => ({ id: i.toString(), val: val ?? null })),
},
{
info: {
@@ -71,7 +71,7 @@ export function fetchIrisData(): VisColumn[] {
name: 'Random category2',
},
type: EColumnTypes.CATEGORICAL,
- values: () => dataPromise.map((r) => r.species).map((val, i) => ({ id: i.toString(), val })),
+ values: () => dataPromise.map((r) => r.species).map((val, i) => ({ id: i.toString(), val: val ?? null })),
},
];
}
diff --git a/src/vis/useCaptureVisScreenshot.ts b/src/vis/useCaptureVisScreenshot.ts
index d66bd5edc..6dfa8a2a7 100644
--- a/src/vis/useCaptureVisScreenshot.ts
+++ b/src/vis/useCaptureVisScreenshot.ts
@@ -1,8 +1,15 @@
import * as htmlToImage from 'html-to-image';
+import JSZip from 'jszip';
import * as React from 'react';
-import { BaseVisConfig, ESupportedPlotlyVis } from './interfaces';
+import { BaseVisConfig, EAggregateTypes, ESupportedPlotlyVis } from './interfaces';
+import { IBarConfig } from './bar/interfaces';
+import { sanitize } from '../utils';
-export function useCaptureVisScreenshot(uniquePlotId: string, visConfig: BaseVisConfig) {
+export type DownloadPlotOptions = {
+ fileName?: string;
+};
+
+export function useCaptureVisScreenshot(uniquePlotId: string, visConfig: BaseVisConfig, options?: DownloadPlotOptions) {
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState(null);
@@ -17,25 +24,79 @@ export function useCaptureVisScreenshot(uniquePlotId: string, visConfig: BaseVis
const Plotly = await import('plotly.js-dist-min');
await Plotly.downloadImage(plotElement, {
format: 'png',
- filename: `${visConfig.type}`,
+ filename: `${options?.fileName ?? visConfig.type}`,
height: plotElement.offsetHeight,
width: plotElement.offsetWidth,
});
- } else {
- await htmlToImage.toPng(plotElement, { backgroundColor: 'white' }).then((dataUrl) => {
+ } else if (visConfig.type === ESupportedPlotlyVis.BAR) {
+ const config = visConfig as IBarConfig;
+ const viewingSingleBarChart =
+ !config.facets ||
+ (config.facets && typeof config.focusFacetIndex === 'number') ||
+ (config.facets && plotElement.querySelectorAll('[data-in-viewport="true"] canvas').length === 1);
+ if (viewingSingleBarChart) {
+ const dataUrl = await htmlToImage.toPng(plotElement.querySelector('canvas')!, {
+ backgroundColor: 'white',
+ width: plotElement.querySelector('canvas')?.width,
+ height: plotElement.querySelector('canvas')?.height,
+ canvasWidth: plotElement.querySelector('canvas')?.width,
+ canvasHeight: plotElement.querySelector('canvas')?.height,
+ cacheBust: true,
+ });
+
const link = document.createElement('a');
- link.download = `${visConfig.type}.png`;
+ link.download = `${options?.fileName ?? visConfig.type}.png`;
link.href = dataUrl;
link.click();
+ link.remove();
+ } else {
+ const zip = new JSZip();
+ const boxList = plotElement.querySelectorAll('[data-facet]') as NodeListOf;
+ const canvasList = plotElement.querySelectorAll('[data-facet] canvas') as NodeListOf;
+ const blobList = await Promise.all(
+ Array.from(canvasList).map(async (canvas) => {
+ const blob = await htmlToImage.toBlob(canvas, {
+ backgroundColor: 'white',
+ width: canvas.width,
+ height: canvas.height,
+ canvasWidth: canvas.width,
+ canvasHeight: canvas.height,
+ cacheBust: true,
+ });
+ return blob;
+ }),
+ );
+ blobList.forEach((blob, i) => {
+ if (blob) {
+ const fileName = `${sanitize(config?.facets?.name as string)} - ${sanitize(boxList[i]?.dataset?.facet as string)} -- ${config?.aggregateType === EAggregateTypes.COUNT ? sanitize(config?.aggregateType as string) : sanitize(`${config?.aggregateType} of ${config?.aggregateColumn?.name}`)} - ${sanitize(config?.catColumnSelected?.name as string)}`;
+ zip.file(`${fileName}.png`, blob);
+ }
+ });
+ const content = await zip.generateAsync({ type: 'blob', mimeType: 'application/zip' });
+ const link = document.createElement('a');
+ link.download = `${options?.fileName ?? visConfig.type}.zip`;
+ link.href = URL.createObjectURL(content);
+ link.click();
+ link.remove();
+ }
+ } else {
+ const dataUrl = await htmlToImage.toPng(plotElement.querySelector('canvas')!, {
+ backgroundColor: 'white',
+ cacheBust: true,
});
+ const link = document.createElement('a');
+ link.download = `${options?.fileName ?? visConfig.type}.png`;
+ link.href = dataUrl;
+ link.click();
+ link.remove();
}
} catch (e) {
setIsLoading(false);
- setError(e.message);
+ setError((e as { message: string }).message);
}
setIsLoading(false);
- }, [uniquePlotId, visConfig.type]);
+ }, [options?.fileName, uniquePlotId, visConfig]);
return [{ isLoading, error }, captureScreenshot] as const;
}
diff --git a/src/vis/vishooks/hooks/useChart.ts b/src/vis/vishooks/hooks/useChart.ts
new file mode 100644
index 000000000..220733ab8
--- /dev/null
+++ b/src/vis/vishooks/hooks/useChart.ts
@@ -0,0 +1,167 @@
+/* eslint-disable react-compiler/react-compiler */
+import * as React from 'react';
+import { useSetState } from '@mantine/hooks';
+import type { ECElementEvent, ECharts, ComposeOption } from 'echarts/core';
+import { use, init } from 'echarts/core';
+import { BarChart, LineChart } from 'echarts/charts';
+import { DataZoomComponent, GridComponent, LegendComponent, TitleComponent, ToolboxComponent, TooltipComponent } from 'echarts/components';
+import { CanvasRenderer } from 'echarts/renderers';
+import type {
+ // The series option types are defined with the SeriesOption suffix
+ BarSeriesOption,
+ LineSeriesOption,
+} from 'echarts/charts';
+import type {
+ // The component option types are defined with the ComponentOption suffix
+ TitleComponentOption,
+ TooltipComponentOption,
+ GridComponentOption,
+ DatasetComponentOption,
+} from 'echarts/components';
+import { useSetRef } from '../../../hooks';
+
+export type ECOption = ComposeOption<
+ BarSeriesOption | LineSeriesOption | TitleComponentOption | TooltipComponentOption | GridComponentOption | DatasetComponentOption
+>;
+
+// Original code from https://dev.to/manufac/using-apache-echarts-with-react-and-typescript-optimizing-bundle-size-29l8
+// Register the required components
+use([
+ LegendComponent,
+ LineChart,
+ BarChart,
+ GridComponent,
+ TooltipComponent,
+ TitleComponent,
+ ToolboxComponent, // A group of utility tools, which includes export, data view, dynamic type switching, data area zooming, and reset.
+ DataZoomComponent, // Used in Line Graph Charts
+ CanvasRenderer, // If you only need to use the canvas rendering mode, the bundle will not include the SVGRenderer module, which is not needed.
+]);
+
+type ElementEventName =
+ | 'click'
+ | 'dblclick'
+ | 'mousewheel'
+ | 'mouseout'
+ | 'mouseover'
+ | 'mouseup'
+ | 'mousedown'
+ | 'mousemove'
+ | 'contextmenu'
+ | 'drag'
+ | 'dragstart'
+ | 'dragend'
+ | 'dragenter'
+ | 'dragleave'
+ | 'dragover'
+ | 'drop'
+ | 'globalout';
+
+// Type for mouse handlers in function form
+export type CallbackFunction = (event: ECElementEvent) => void;
+
+// Type for mouse handlers in object form
+export type CallbackObject = {
+ query?: string | object;
+ handler: CallbackFunction;
+};
+
+// Array of mouse handlers
+export type CallbackArray = (CallbackFunction | CallbackObject)[];
+
+export function useChart({
+ options,
+ settings,
+ mouseEvents,
+}: {
+ options?: ECOption;
+ settings?: Parameters[1];
+ mouseEvents?: Partial<{ [K in ElementEventName]: CallbackArray | CallbackFunction | CallbackObject }>;
+}) {
+ const [state, setState] = useSetState({
+ width: 0,
+ height: 0,
+ internalObserver: null as ResizeObserver | null,
+ instance: null as ECharts | null,
+ });
+
+ const mouseEventsRef = React.useRef(mouseEvents);
+ mouseEventsRef.current = mouseEvents;
+
+ const syncEvents = (instance: ECharts) => {
+ // Remove old events
+ Object.keys(mouseEventsRef.current ?? {}).forEach((eventName) => {
+ instance.off(eventName);
+ });
+
+ // Readd new events -> this is necessary when adding options for instance
+ Object.keys(mouseEventsRef.current ?? {}).forEach((e) => {
+ const eventName = e as ElementEventName;
+ const value = mouseEventsRef.current?.[eventName as ElementEventName];
+
+ // Either the value is a handler like () => ..., an object with a query or an array containing both types
+
+ if (Array.isArray(value)) {
+ value.forEach((handler, index) => {
+ if (typeof handler === 'function') {
+ instance.on(eventName, (params: ECElementEvent) => ((mouseEventsRef.current?.[eventName] as CallbackArray)[index] as CallbackFunction)(params));
+ } else if (!handler.query) {
+ instance.on(eventName, (params: ECElementEvent) =>
+ ((mouseEventsRef.current?.[eventName] as CallbackArray)[index] as CallbackObject).handler(params),
+ );
+ } else {
+ instance.on(eventName, handler.query, (params: ECElementEvent) =>
+ ((mouseEventsRef.current?.[eventName] as CallbackArray)[index] as CallbackObject).handler(params),
+ );
+ }
+ });
+ return;
+ }
+
+ if (typeof value === 'function') {
+ instance.on(eventName, (...args) => (mouseEventsRef.current?.[eventName] as CallbackFunction)(...args));
+ } else if (typeof value === 'object') {
+ if (!value.query) {
+ instance.on(eventName, (...args) => (mouseEventsRef.current?.[eventName] as CallbackObject).handler(...args));
+ } else {
+ instance.on(eventName, value.query, (...args) => (mouseEventsRef.current?.[eventName] as CallbackObject).handler(...args));
+ }
+ }
+ });
+ };
+
+ const { ref, setRef } = useSetRef({
+ register: (element) => {
+ const observer = new ResizeObserver((entries) => {
+ const newDimensions = entries[0]?.contentRect;
+ setState({ width: newDimensions?.width, height: newDimensions?.height });
+ });
+ // create the instance
+ const instance = init(element);
+ // Save the mouse events
+ syncEvents(instance);
+ setState({ instance, internalObserver: observer });
+ observer.observe(element);
+ },
+ cleanup() {
+ state.instance?.dispose();
+ },
+ });
+
+ React.useEffect(() => {
+ if (state.instance) {
+ state.instance.resize();
+ }
+ }, [state]);
+
+ React.useEffect(() => {
+ if (state.instance && state.width > 0 && state.height > 0) {
+ // This should be the last use effect since a resize stops the animation
+ state.instance.setOption(options!, settings);
+ // Sync events
+ syncEvents(state.instance);
+ }
+ }, [state, options, settings]);
+
+ return { ref, setRef, instance: state.instance };
+}