From 77e5a0a3acf53c24a9fe131ab014cdfd99999d7b Mon Sep 17 00:00:00 2001 From: Usama Ansari <115616380+dv-usama-ansari@users.noreply.github.com> Date: Wed, 16 Oct 2024 17:19:37 +0200 Subject: [PATCH] feat(vis-type: bar): switch to ECharts bar chart (#497) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: bar chart with echarts (wip) * feat: support all bar chart config options * feat: compute data table for each facet * perf: compose bar data table once and filter them for each facet * feat: make focus facet selector working * style: calculate chart height * feat: scrollable facet views * feat: select items with click on a bar * feat: add tooltip on hover * feat: assign color scale * feat: binned color scale for numerical group columns * fix: selection of items on bar click * feat: highlight incoming selection * fix: bar height for non-grouped bars * style: truncate axis labels after 50 chars * fix: add scrollbar * fix: if agg column or cat column is undefined * fix: account for null values in the config - make group value default - harmonize bar width/height * !feat: don't exclude already selected columns in sidebar * fix: scatter vis for selected points (cherry picked from commit 8e4a0ea4e738c4d36eab38757526ca1b4d95f494) * fix: check width and height of bar chart container Fixes https://github.com/datavisyn/communication_xaira_ordino/issues/26 The ReactECharts component will check the resize of the parent element and update accordingly * fix: bar chart facets horizontal scrollbar (#480) * fix: make resizing work for facets * feat: add a loading overlay when the viewport resizes * fix: scroll flickering * fix: remove pos relative * chore: remove unnecessary overflow hidden * chore: address PR review * fix: storybook error * feat: implement bar plot sorting (#482) * feat: add sorting controls wip: add sorting for y axis wip: add property in config to preload sorting wip: use in reprovisyn * feat: improvements in sorting * feat: handle categorical column change * feat: implement initial sorting * feat: move sort into the header * fix: sort for normalized mode * chore: sort facets by name * fix: add null check for sortState * fix: add null checks for sort button * fix: fallback value * fix: remove circular dependency * fix: address PR feedback * chore: use color for unknown values * fix: storybook error * fix: title of bar plots overlaps with legend when in 5050 view with volcano (#486) * feat: add sorting controls wip: add sorting for y axis wip: add property in config to preload sorting wip: use in reprovisyn * feat: improvements in sorting * feat: handle categorical column change * feat: implement initial sorting * feat: move sort into the header * fix: sort for normalized mode * chore: sort facets by name * fix: add null check for sortState * fix: add null checks for sort button * fix: fallback value * fix: remove circular dependency * fix: address PR feedback * chore: use color for unknown values * fix: storybook error * fix: overlapping legends in bar chart * chore: update SingleEChartsBarChart.tsx * fix: aggregations in bar chart * fix(wip): select bars in sorted chart wip: the current config in main app has a bug which needs to be resolved * feat: add shift + select to add to the selection * fix: temporarily disable bar chart selection * fix: wip show categories for all the facets * chore: update config * fix: add padding top and bottom to bar chart height * chore: rename variables * fix: update bars on sorting wip: currently only ascending sort works for horizontal bars wip: grouping with the same column does not work wip: ⚠️ major performance issue * fix: display same grouped bars in sorted order correctly wip: colors do not match wip: selection in sorting does not work as expected * fix: sorting using copilot powered development * chore: cleanup after feature * feat(wip): add condition to sort unknown at the end of the list wip: the behavior is inconsistent for vertical and horizontal orientation * chore: minor improvements * fix: add react-window virtualizer * docs: add documentation for matrixSort method * fix: handle NaN values in matrix aggregation * feat: improve shift + click to select usability * fix: remove control key event listener * chore: remove internal sorting state * chore: remove redundant code * Fixed virtualization remount by having a stable renderer and using the itemData property of react-window * fix: adjust max label length * fix: bar label overlap + add optimization for large data * fix: rotate x-axis labels by 45 degrees * fix: emphasize and blur options * fix: make barWidth responsive when there are a lot of bars * fix: horizontal scrollbar issue * refactor: optimize sorting for performance * Fixed rerenders caused by new settings object, incorrect height of facets, added hook with setRef function to use instead of react echarts component * added useChart to exported hooks in visyn_core * linted hook * removed option and converted it to a usememo * Combined state of vis to one state object * cleaned up * chore: cleanup + minor fixes * fix: build error * fix: build errors * chore: rename variables * fix: infinite loop * feat: add axis label * fix: min and max aggregations * fix: lint error * chore: cleanup * chore: move function to proper utils file * chore: cleanup * fix: battle with strict mode for affected files * chore: move calculate chart height function to bar chart * feat: use mantine scrollarea for variable size list * feat: use mantine scrollarea for variable size list * chore: continuing battle with strict mode * fix: empty bar plot on facets * fix: color and selection * fix: use higher opacity for unselected * fix: set opacity back to 0.5 * fix: selection broke * fix: selection again * fix: add temp xAxis domain * fix: selection for same group and facet combination * fix: modify domain for aggregation axis * fix: add higher opacity to to unselected bar label items in grouped mode * fix: reverse condition * fix: adapt opacity and colors * fix: reaggregate bar chart data + add global min and max wip: selection does not work * fix: selection * feat: sort categories * fix: tooltip clipping * fix: follow up fix for selection * feat: add useFullHeight flag to use full height if available * fix: labels when direction is changed * fix: aggregation type sensitive global min and max * feat: implement sorting along both axes * feat: add DOM sensitive label truncate * fix: recalculate height * fix: single value in facet * fix: minor improvements * style: make chart responsive to the container * feat: add responsive labels for y axis for horizontal bar chart * chore: cleanup unnecessary ref * fix: click same bar to deselect * fix: labels width be min of max label width or container / 3 * refactor: add selection information in the header * fix: categoriesList null check * fix: invalidate item size cache on config change * fix: invalidate item size cache on datatable change as well * feat: sync grid left across facets * feat(wip): show axis label tooltip wip: position the tooltip correctly wip: add a flag to enable / disable the tooltip * chore: sync colors * fix: tooltip position + refactoring * fix: storybook * chore: select bars when label clicked * fix: tooltip position for horizontal as well as vertical bars * fix: strict mode issues in useChart.ts * fix: add min-width to the bars and make the chart scrollable * style: make title in sync with other charts * style: add descriptive title for facets * style: change axis title for normalized * style: fix cursor pointer for non interactive elements * style: round to 4 digits in legend for numerical group * fix: add custom binning * fix: always show tooltip for vertical bars * fix: axis label distance * chore: restructure bar plot files + remove legacy bar plot components * feat: add axis label click for sort * fix: color scale for faceted plots * chore: expose isBarConfig function * chore: restructure files again * fix: do not compute legend when no grouping is available * fix: min max calculation * chore: deprecated bar chart sort buttons * chore: make useFullHeight as default * fix: build error * chore: add comments * fix: label of aggregation axis * fix: reduce unnecessary recalculation of aggregatedData * fix: numerical color scale * chore: remove optional check * chore: improve tooltip * style: improve tooltip * style: sync tooltip appearance * style: make axis well spaced * fix: make loader appear only once * chore: cleanup * fix: scrollbar overlap on legend * fix: tooltip rounding for numerical scales * chore: update label helper * Adjusted imports for echarts * feat: implement responsive width flag * chore: update title as per the feedback * chore: minor improvements * fix: hide tooltip labels in stacked mode and show only when hovered * fix: display facets in the tooltip first * fix: htmlize tooltip * fix: bug when same facet and category selected for numerical group * fix: color scale for numerical grouping and faceting * fix: color of legend and tooltip * fix: dimensions * chore: remove bar chart sort button from the header * chore: add placeholder tests * fix: improve median logic * fix: bar chart color scale for facet and group combinations * chore: display labels for grouped bars faceted by same column * fix: bar chart story * fix: tooltip label for group having only one bar * fix: selection when only one bar is present in grouped mode * chore: remove group.ts * chore: add group.ts * chore: improve diversity of test data * fix: group legend and tooltip for single numerical value grouping * fix: iris story * fix: generalize the fix * fix: generalize the fix 2 * chore: optimizations on previous fix * fix: limit min and max to 0 * refactor: updates to aggregation types + reduce complexity * fix: global domain calculation of min max and median * fix: global domain calculation of average * fix: use the correct median function import * fix: label visibility * fix: median logic * chore: reduce number of looping for median calculation * fix: tooltip content for same group and facet column * test: add tests for bar dimensions * test: add tests for bin lookup * chore: cleanup in singleechartbarchart * test: add tests for generated-aggregated-data-lookup * docs: add comment about `merge` flag in BaseVisConfig * chore: cleanup main app * docs: update comment * fix: median aggregation * fix: storybook * feat(vis-type: bar): download chart is clipped for long charts (#561) Co-authored-by: Holger Stitz * chore: extend bar tests * chore: extend bar tests * chore: add comment * chore: extend bar tests * chore: update storybook * feat: add tooltip for disabled segmented control * fix: color scale order for numerical groupings * fix: main app for the PW tests to pass --------- Co-authored-by: Holger Stitz Co-authored-by: oltionchampari Co-authored-by: Moritz Heckmann Co-authored-by: Champari Oltion <51322092+oltionchampari@users.noreply.github.com> --- package.json | 4 + src/demo/MainApp.tsx | 7 +- src/utils/index.ts | 1 + src/utils/sanitize-filename.test.ts | Bin 0 -> 1444 bytes src/utils/sanitize-filename.ts | 69 ++ src/vis/EagerVis.tsx | 7 +- src/vis/bar/BarChart.tsx | 469 ++++++--- src/vis/bar/BarDisplayTypeButtons.tsx | 27 - src/vis/bar/BarVis.tsx | 9 +- src/vis/bar/BarVisSidebar.tsx | 29 +- src/vis/bar/SingleBarChart.tsx | 290 ------ src/vis/bar/SingleEChartsBarChart.tsx | 887 ++++++++++++++++++ .../bar/barComponents/FocusFacetSelector.tsx | 51 - src/vis/bar/barComponents/Legend.tsx | 40 - src/vis/bar/barComponents/SingleBar.tsx | 72 -- src/vis/bar/barComponents/XAxis.tsx | 171 ---- src/vis/bar/barComponents/YAxis.tsx | 98 -- src/vis/bar/barTypes/GroupedBars.tsx | 99 -- src/vis/bar/barTypes/SimpleBars.tsx | 82 -- src/vis/bar/barTypes/StackedBars.tsx | 114 --- .../{ => components}/BarDirectionButtons.tsx | 6 +- .../bar/components/BarDisplayTypeButtons.tsx | 31 + .../{ => components}/BarGroupTypeButtons.tsx | 6 +- src/vis/bar/components/FocusFacetSelector.tsx | 53 ++ src/vis/bar/{ => components}/GroupSelect.tsx | 12 +- src/vis/bar/components/index.ts | 5 + src/vis/bar/hooks/BarSortHooks.tsx | 137 +++ src/vis/bar/hooks/index.ts | 1 + src/vis/bar/hooks/useGetBarScales.ts | 99 -- src/vis/bar/hooks/useGetGroupedBarScales.ts | 169 ---- src/vis/bar/index.ts | 9 + src/vis/bar/interfaces.ts | 59 -- src/vis/bar/interfaces/constants.ts | 21 + src/vis/bar/interfaces/enums.ts | 25 + src/vis/bar/interfaces/helpers.ts | 6 + src/vis/bar/interfaces/index.ts | 6 + src/vis/bar/interfaces/interfaces.ts | 32 + .../internal/constants/bar-chart-container.ts | 14 + .../interfaces/internal/constants/bar-plot.ts | 9 + .../interfaces/internal/constants/facet.ts | 4 + .../interfaces/internal/constants/group.ts | 4 + .../interfaces/internal/constants/index.ts | 4 + .../calculate-chart-dimensions.test.ts | 214 +++++ .../helpers/calculate-chart-dimensions.ts | 44 + .../helpers/create-bin-lookup.test.ts | 85 ++ .../internal/helpers/create-bin-lookup.ts | 70 ++ .../generate-aggregated-data-lookup.test.ts | 472 ++++++++++ .../generate-aggregated-data-lookup.ts | 312 ++++++ .../internal/helpers/get-bar-data.test.ts | 64 ++ .../internal/helpers/get-bar-data.ts | 45 + .../bar/interfaces/internal/helpers/index.ts | 7 + .../internal/helpers/median.test.ts | 35 + .../bar/interfaces/internal/helpers/median.ts | 9 + .../internal/helpers/normalized-value.test.ts | 29 + .../internal/helpers/normalized-value.ts | 21 + .../internal/helpers/sort-series.test.ts | 365 +++++++ .../internal/helpers/sort-series.ts | 163 ++++ src/vis/bar/interfaces/internal/index.ts | 3 + .../internal/types/aggregated-data.type.ts | 18 + .../bar/interfaces/internal/types/index.ts | 1 + src/vis/bar/interfaces/maps.ts | 7 + src/vis/bar/interfaces/types.ts | 3 + src/vis/bar/utils.ts | 208 ---- src/vis/bar/utils/index.ts | 1 + src/vis/bar/utils/utils.ts | 20 + src/vis/general/DownloadPlotButton.tsx | 6 +- src/vis/general/constants.ts | 2 +- src/vis/general/layoutUtils.ts | 68 +- src/vis/general/utils.ts | 12 +- src/vis/heatmap/Heatmap.tsx | 2 +- src/vis/heatmap/utils.ts | 24 + src/vis/interfaces.ts | 6 +- src/vis/sidebar/index.ts | 5 +- src/vis/stories/Iris.stories.tsx | 10 +- src/vis/stories/Vis/Bar/BarRandom.stories.tsx | 443 ++++++--- src/vis/stories/explodedData.ts | 222 +++++ src/vis/stories/fetchIrisData.tsx | 6 +- src/vis/useCaptureVisScreenshot.ts | 77 +- src/vis/vishooks/hooks/useChart.ts | 167 ++++ 79 files changed, 4595 insertions(+), 1889 deletions(-) create mode 100644 src/utils/sanitize-filename.test.ts create mode 100644 src/utils/sanitize-filename.ts delete mode 100644 src/vis/bar/BarDisplayTypeButtons.tsx delete mode 100644 src/vis/bar/SingleBarChart.tsx create mode 100644 src/vis/bar/SingleEChartsBarChart.tsx delete mode 100644 src/vis/bar/barComponents/FocusFacetSelector.tsx delete mode 100644 src/vis/bar/barComponents/Legend.tsx delete mode 100644 src/vis/bar/barComponents/SingleBar.tsx delete mode 100644 src/vis/bar/barComponents/XAxis.tsx delete mode 100644 src/vis/bar/barComponents/YAxis.tsx delete mode 100644 src/vis/bar/barTypes/GroupedBars.tsx delete mode 100644 src/vis/bar/barTypes/SimpleBars.tsx delete mode 100644 src/vis/bar/barTypes/StackedBars.tsx rename src/vis/bar/{ => components}/BarDirectionButtons.tsx (83%) create mode 100644 src/vis/bar/components/BarDisplayTypeButtons.tsx rename src/vis/bar/{ => components}/BarGroupTypeButtons.tsx (83%) create mode 100644 src/vis/bar/components/FocusFacetSelector.tsx rename src/vis/bar/{ => components}/GroupSelect.tsx (75%) create mode 100644 src/vis/bar/components/index.ts create mode 100644 src/vis/bar/hooks/BarSortHooks.tsx create mode 100644 src/vis/bar/hooks/index.ts delete mode 100644 src/vis/bar/hooks/useGetBarScales.ts delete mode 100644 src/vis/bar/hooks/useGetGroupedBarScales.ts create mode 100644 src/vis/bar/index.ts delete mode 100644 src/vis/bar/interfaces.ts create mode 100644 src/vis/bar/interfaces/constants.ts create mode 100644 src/vis/bar/interfaces/enums.ts create mode 100644 src/vis/bar/interfaces/helpers.ts create mode 100644 src/vis/bar/interfaces/index.ts create mode 100644 src/vis/bar/interfaces/interfaces.ts create mode 100644 src/vis/bar/interfaces/internal/constants/bar-chart-container.ts create mode 100644 src/vis/bar/interfaces/internal/constants/bar-plot.ts create mode 100644 src/vis/bar/interfaces/internal/constants/facet.ts create mode 100644 src/vis/bar/interfaces/internal/constants/group.ts create mode 100644 src/vis/bar/interfaces/internal/constants/index.ts create mode 100644 src/vis/bar/interfaces/internal/helpers/calculate-chart-dimensions.test.ts create mode 100644 src/vis/bar/interfaces/internal/helpers/calculate-chart-dimensions.ts create mode 100644 src/vis/bar/interfaces/internal/helpers/create-bin-lookup.test.ts create mode 100644 src/vis/bar/interfaces/internal/helpers/create-bin-lookup.ts create mode 100644 src/vis/bar/interfaces/internal/helpers/generate-aggregated-data-lookup.test.ts create mode 100644 src/vis/bar/interfaces/internal/helpers/generate-aggregated-data-lookup.ts create mode 100644 src/vis/bar/interfaces/internal/helpers/get-bar-data.test.ts create mode 100644 src/vis/bar/interfaces/internal/helpers/get-bar-data.ts create mode 100644 src/vis/bar/interfaces/internal/helpers/index.ts create mode 100644 src/vis/bar/interfaces/internal/helpers/median.test.ts create mode 100644 src/vis/bar/interfaces/internal/helpers/median.ts create mode 100644 src/vis/bar/interfaces/internal/helpers/normalized-value.test.ts create mode 100644 src/vis/bar/interfaces/internal/helpers/normalized-value.ts create mode 100644 src/vis/bar/interfaces/internal/helpers/sort-series.test.ts create mode 100644 src/vis/bar/interfaces/internal/helpers/sort-series.ts create mode 100644 src/vis/bar/interfaces/internal/index.ts create mode 100644 src/vis/bar/interfaces/internal/types/aggregated-data.type.ts create mode 100644 src/vis/bar/interfaces/internal/types/index.ts create mode 100644 src/vis/bar/interfaces/maps.ts create mode 100644 src/vis/bar/interfaces/types.ts delete mode 100644 src/vis/bar/utils.ts create mode 100644 src/vis/bar/utils/index.ts create mode 100644 src/vis/bar/utils/utils.ts create mode 100644 src/vis/stories/explodedData.ts create mode 100644 src/vis/vishooks/hooks/useChart.ts diff --git a/package.json b/package.json index 82bcff36a..3674d3396 100644 --- a/package.json +++ b/package.json @@ -106,10 +106,12 @@ "d3-force-boundary": "^0.0.3", "d3-hexbin": "^0.2.2", "d3v7": "npm:d3@^7.9.0", + "echarts": "^5.5.1", "fit-curve": "^0.2.0", "html-to-image": "^1.11.11", "i18next": "^23.14.0", "jstat": "^1.9.6", + "jszip": "^3.10.1", "lineupjs": "4.11.0", "lodash": "~4.17.21", "plotly.js-dist-min": "~2.12.0", @@ -118,6 +120,7 @@ "react-highlight-words": "^0.20.0", "react-plotly.js": "^2.6.0", "react-spring": "^9.7.4", + "react-window": "^1.8.10", "use-deep-compare-effect": "^1.8.1", "visyn_scripts": "^11.1.0" }, @@ -135,6 +138,7 @@ "@storybook/react": "^7.6.20", "@storybook/react-webpack5": "^7.6.20", "@storybook/testing-library": "0.2.2", + "@types/react-window": "^1.8.8", "chromatic": "^11.7.1", "storybook": "^7.6.20", "storybook-addon-swc": "^1.2.0" diff --git a/src/demo/MainApp.tsx b/src/demo/MainApp.tsx index 35e2793ea..fc9045058 100644 --- a/src/demo/MainApp.tsx +++ b/src/demo/MainApp.tsx @@ -34,7 +34,7 @@ export function MainApp() { }, ], color: { - description: null, + description: '', id: 'cellularity', name: 'Cellularity', }, @@ -66,6 +66,11 @@ export function MainApp() { aboutAppModal: { content: This is the demo app for visyn core., }, + center: ( + + {breastCancerData.length} data points / {selection.length} points selected + + ), }} /> } diff --git a/src/utils/index.ts b/src/utils/index.ts index 5f57c6233..e717b937c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,3 +3,4 @@ export * from './getCssValue'; export * from './initializeLibrary'; export * from './fromNow'; export * from './colors'; +export * from './sanitize-filename'; diff --git a/src/utils/sanitize-filename.test.ts b/src/utils/sanitize-filename.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3d44e29b519b5a54bb8581f2acab91c0cdcff79e GIT binary patch literal 1444 zcmbW1!A^uQ6h&F{6)&?Xn6XoL>gbsG9c&opk!b>yl#&p~`FE>?)>x*=bHResd+sHH zAlqJP0~Z*|P8#`vaD`f{78u_x=)RT>c4dofpQa}qDlLzgvE`U;AajA}QkAhw4pZmQnU(df0^Do5wftW7}qVP=2 z-x2el#QcVsFD4<#%0o?79_cGj)D_P8-{)G;?Jp22)diY|5wX`RqJt5!9H(&6!HC$6 zO(;4T5i8Y-(DXE-HoaL= 0xd800 && codePoint <= 0xdbff; +} + +function isLowSurrogate(codePoint: number) { + return codePoint >= 0xdc00 && codePoint <= 0xdfff; +} + +// Truncate string by size in bytes +function truncate(getLength: (str: string) => number, input: string, byteLength: number) { + let curByteLength = 0; + let codePoint = 0; + let segment = ''; + + for (let i = 0; i < input.length; i += 1) { + codePoint = input.charCodeAt(i); + segment = input[i] as string; + + if (isHighSurrogate(codePoint) && isLowSurrogate(input.charCodeAt(i + 1))) { + i += 1; + segment += input[i]; + } + + curByteLength += getLength(segment); + + if (curByteLength === byteLength) { + return input.slice(0, i + 1); + } + if (curByteLength > byteLength) { + return input.slice(0, i - segment.length + 1); + } + } + + return input; +} + +// NOTE: @dv-usama-ansari: Referenced from https://github.com/parshap/truncate-utf8-bytes/blob/master/index.js +const getLength = Buffer.byteLength.bind(Buffer); +const boundTruncate = truncate.bind(null, getLength); + +// NOTE: @dv-usama-ansari: Referenced from https://github.com/parshap/node-sanitize-filename/blob/master/index.js +const illegalRe = /[/?<>\\:*|"]/g; +// eslint-disable-next-line no-control-regex +const controlRe = /[\x00-\x1f\x80-\x9f]/g; +const reservedRe = /^\.+$/; +const windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i; +const windowsTrailingRe = /[. ]+$/; + +function sanitizeHelper(input: string, replacement: string) { + const sanitized = input + .replace(illegalRe, replacement) + .replace(controlRe, replacement) + .replace(reservedRe, replacement) + .replace(windowsReservedRe, replacement) + .replace(windowsTrailingRe, replacement); + return boundTruncate(sanitized, 255); +} + +export function sanitize(input: string, options?: { replacement?: '' }) { + const replacement = options?.replacement ?? ''; + const output = sanitizeHelper(input, replacement); + if (replacement === '') { + return output; + } + return sanitizeHelper(output, ''); +} diff --git a/src/vis/EagerVis.tsx b/src/vis/EagerVis.tsx index ef17b6269..9d9704a06 100644 --- a/src/vis/EagerVis.tsx +++ b/src/vis/EagerVis.tsx @@ -23,10 +23,7 @@ import { import { VisSidebar } from './VisSidebar'; import { VisSidebarOpenButton } from './VisSidebarOpenButton'; -import { BarVis } from './bar/BarVis'; -import { BarVisSidebar } from './bar/BarVisSidebar'; -import { EBarDirection, EBarDisplayType, EBarGroupingType, IBarConfig } from './bar/interfaces'; -import { barMergeDefaultConfig } from './bar/utils'; +import { BarVis, BarVisSidebar, EBarDirection, EBarDisplayType, EBarGroupingType, IBarConfig, barMergeDefaultConfig } from './bar'; import { correlationMergeDefaultConfig } from './correlation'; import { CorrelationVis } from './correlation/CorrelationVis'; import { CorrelationVisSidebar } from './correlation/CorrelationVisSidebar'; @@ -256,7 +253,7 @@ export function EagerVis({ if (isSelectedVisTypeRegistered && (!visConfig?.merged || prevVisConfig?.type !== visConfig?.type)) { // TODO: I would prefer this to be not in a useEffect, as then we wouldn't have the render-flicker: https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes setPrevVisConfig(visConfig); - _setVisConfig?.(getVisByType(visConfig.type)?.mergeConfig(columns, { ...visConfig, merged: true })); + _setVisConfig?.(getVisByType(visConfig.type)?.mergeConfig(columns, { ...visConfig, merged: true }) as BaseVisConfig); } }, [_setVisConfig, columns, getVisByType, isSelectedVisTypeRegistered, prevVisConfig?.type, visConfig]); diff --git a/src/vis/bar/BarChart.tsx b/src/vis/bar/BarChart.tsx index f788c1a8f..1a66c5dc9 100644 --- a/src/vis/bar/BarChart.tsx +++ b/src/vis/bar/BarChart.tsx @@ -1,18 +1,51 @@ -import { Box, Center, Group, Loader, Stack } from '@mantine/core'; -import { useResizeObserver } from '@mantine/hooks'; -import { uniqueId } from 'lodash'; -import React, { useCallback, useMemo } from 'react'; +import { Box, Center, Group, Loader, ScrollArea, Stack } from '@mantine/core'; +import { useElementSize } from '@mantine/hooks'; +import { scaleOrdinal, schemeBlues, type ScaleOrdinal } from 'd3v7'; +import uniqueId from 'lodash/uniqueId'; +import zipWith from 'lodash/zipWith'; +import * as React from 'react'; +import { ListChildComponentProps, VariableSizeList } from 'react-window'; import { useAsync } from '../../hooks/useAsync'; -import { NAN_REPLACEMENT } from '../general'; +import { categoricalColors as colorScale } from '../../utils/colors'; import { DownloadPlotButton } from '../general/DownloadPlotButton'; import { getLabelOrUnknown } from '../general/utils'; -import { EColumnTypes, ICommonVisProps } from '../interfaces'; -import { SingleBarChart } from './SingleBarChart'; -import { FocusFacetSelector } from './barComponents/FocusFacetSelector'; -import { Legend } from './barComponents/Legend'; -import { useGetGroupedBarScales } from './hooks/useGetGroupedBarScales'; -import { IBarConfig } from './interfaces'; -import { getBarData } from './utils'; +import { ColumnInfo, EAggregateTypes, EColumnTypes, ICommonVisProps, VisNumericalValue } from '../interfaces'; +import { FocusFacetSelector } from './components'; +import { EBarDirection, EBarDisplayType, EBarGroupingType, IBarConfig } from './interfaces'; +import { + AggregatedDataType, + calculateChartHeight, + calculateChartMinWidth, + CHART_HEIGHT_MARGIN, + createBinLookup, + DEFAULT_BAR_CHART_HEIGHT, + DEFAULT_BAR_CHART_MIN_WIDTH, + DEFAULT_FACET_NAME, + generateAggregatedDataLookup, + getBarData, +} from './interfaces/internal'; +import { SingleEChartsBarChart } from './SingleEChartsBarChart'; + +type VirtualizedBarChartProps = { + aggregatedDataMap: ReturnType; + allUniqueFacetVals: string[]; + chartHeightMap: Record; + chartMinWidthMap: Record; + containerHeight: number; + containerWidth: number; + config: IBarConfig; + filteredUniqueFacetVals: string[]; + groupColorScale: ScaleOrdinal; + isGroupedByNumerical: boolean; + labelsMap: Record; + longestLabelWidth: number; + selectedFacetIndex?: number; + selectedFacetValue?: string; + selectedList: string[]; + selectedMap: Record; + selectionCallback: (e: React.MouseEvent, ids: string[]) => void; + setConfig: (config: IBarConfig) => void; +}; export function BarChart({ config, @@ -27,131 +60,353 @@ export function BarChart({ ICommonVisProps, 'config' | 'setConfig' | 'columns' | 'selectedMap' | 'selectedList' | 'selectionCallback' | 'uniquePlotId' | 'showDownloadScreenshot' >) { + const { ref: resizeObserverRef, width: containerWidth, height: containerHeight } = useElementSize(); const id = React.useMemo(() => uniquePlotId || uniqueId('BarChartVis'), [uniquePlotId]); - const [filteredOut, setFilteredOut] = React.useState([]); + + const listRef = React.useRef(null); const { value: allColumns, status: colsStatus } = useAsync(getBarData, [ columns, - config.catColumnSelected, - config.group, - config.facets, - config.aggregateColumn, + config?.catColumnSelected as ColumnInfo, + config?.group as ColumnInfo, + config?.facets as ColumnInfo, + config?.aggregateColumn as ColumnInfo, ]); - const allUniqueFacetVals = useMemo(() => { + const [gridLeft, setGridLeft] = React.useState(containerWidth / 3); + + const truncatedTextRef = React.useRef<{ labels: { [value: string]: string }; longestLabelWidth: number; containerWidth: number }>({ + labels: {}, + longestLabelWidth: 0, + containerWidth, + }); + const [labelsMap, setLabelsMap] = React.useState>({}); + + const dataTable = React.useMemo(() => { + if (!allColumns) { + return []; + } + + // bin the `group` column values if a numerical column is selected + const binLookup: Map | null = + allColumns.groupColVals?.type === EColumnTypes.NUMERICAL ? createBinLookup(allColumns.groupColVals?.resolvedValues as VisNumericalValue[]) : null; + + return zipWith( + allColumns.catColVals?.resolvedValues ?? [], // add array as fallback value to prevent zipWith from dropping the column + allColumns.aggregateColVals?.resolvedValues ?? [], // add array as fallback value to prevent zipWith from dropping the column + allColumns.groupColVals?.resolvedValues ?? [], // add array as fallback value to prevent zipWith from dropping the column + allColumns.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), + }; + }, + ); + }, [allColumns]); + + const aggregatedDataMap = React.useMemo( + () => + generateAggregatedDataLookup( + { + isFaceted: !!config?.facets?.id, + isGrouped: !!config?.group?.id, + groupType: config?.groupType as EBarGroupingType, + display: config?.display as EBarDisplayType, + aggregateType: config?.aggregateType as EAggregateTypes, + }, + dataTable, + selectedMap, + ), + [config?.aggregateType, config?.display, config?.facets?.id, config?.group?.id, config?.groupType, dataTable, selectedMap], + ); + + const groupColorScale = React.useMemo(() => { + if (!allColumns?.groupColVals) { + return null; + } + + const groups = + allColumns.groupColVals.type === EColumnTypes.NUMERICAL + ? [ + ...new Set( + Object.values(aggregatedDataMap?.facets ?? {}) + .flatMap((facet) => facet.groupingsList) + .sort((a, b) => { + const [minA] = a.split(' to '); + const [minB] = b.split(' to '); + if (minA && minB) { + return Number(minA) - Number(minB); + } + return 0; + }), + ), + ] + : aggregatedDataMap?.facetsList[0] === DEFAULT_FACET_NAME + ? (aggregatedDataMap?.facets[DEFAULT_FACET_NAME]?.groupingsList ?? []) + : config?.group?.id === config?.facets?.id + ? (aggregatedDataMap?.facetsList ?? []) + : [ + ...new Set( + Object.values(aggregatedDataMap?.facets ?? {}).flatMap((facet) => { + return facet.groupingsList; + }), + ), + ]; + + const maxGroupings = Object.values(aggregatedDataMap?.facets ?? {}).reduce((acc: number, facet) => Math.max(acc, facet.groupingsList.length), 0); + + const range = + allColumns.groupColVals.type === EColumnTypes.NUMERICAL + ? config?.catColumnSelected?.id === config?.facets?.id + ? (schemeBlues[Math.max(Math.min(groups.length - 1, maxGroupings), 3)] as string[]).slice(0, maxGroupings) + : (schemeBlues[Math.max(Math.min(groups.length - 1, 9), 3)] as string[]) // use at least 3 colors for numerical values + : groups.map( + (group, i) => (allColumns?.groupColVals?.color?.[group] || colorScale[i % colorScale.length]) as string, // use the custom color from the column if available, otherwise use the default color scale + ); + + return scaleOrdinal().domain(groups).range(range); + }, [aggregatedDataMap, allColumns, config]); + + const allUniqueFacetVals = React.useMemo(() => { return [...new Set(allColumns?.facetsColVals?.resolvedValues.map((v) => getLabelOrUnknown(v.val)))] as string[]; }, [allColumns?.facetsColVals?.resolvedValues]); - const filteredUniqueFacetVals = useMemo(() => { - return typeof config.focusFacetIndex === 'number' && config.focusFacetIndex < allUniqueFacetVals.length - ? [allUniqueFacetVals[config.focusFacetIndex]] + const filteredUniqueFacetVals = React.useMemo(() => { + return typeof config?.focusFacetIndex === 'number' && config?.focusFacetIndex < allUniqueFacetVals.length + ? [allUniqueFacetVals[config?.focusFacetIndex]] : allUniqueFacetVals; - }, [allUniqueFacetVals, config.focusFacetIndex]); - - const { groupColorScale, groupedTable } = useGetGroupedBarScales( - allColumns, - 0, - 0, - { left: 0, top: 0, right: 0, bottom: 0 }, - null, - true, - selectedMap, - config.groupType, - config.sortType, - config.aggregateType, + }, [allUniqueFacetVals, config?.focusFacetIndex]); + + const customSelectionCallback = React.useCallback( + (e: React.MouseEvent, ids: string[]) => { + if (selectionCallback) { + if (e.ctrlKey) { + selectionCallback([...new Set([...(selectedList ?? []), ...ids])]); + return; + } + if ((selectedList ?? []).length === ids.length && (selectedList ?? []).every((value, index) => value === ids[index])) { + selectionCallback([]); + } else { + selectionCallback(ids); + } + } + }, + [selectedList, selectionCallback], ); - const [legendBoxRef] = useResizeObserver(); + const chartHeightMap = React.useMemo(() => { + const map: Record = {}; + Object.entries(aggregatedDataMap?.facets ?? {}).forEach(([facet, value]) => { + if (facet) { + map[facet] = calculateChartHeight({ config, aggregatedData: value, containerHeight }); + } + }); + return map; + }, [aggregatedDataMap?.facets, config, containerHeight]); - const customSelectionCallback = useCallback( - (e: React.MouseEvent, ids: string[], label?: string) => { - // If a label is passed we are selecting all ids with that label - if (label) { - allColumns?.catColVals?.resolvedValues.filter((v) => getLabelOrUnknown(v.val) === label).forEach((v) => ids.push(v.id)); + const chartMinWidthMap = React.useMemo(() => { + const map: Record = {}; + Object.entries(aggregatedDataMap?.facets ?? {}).forEach(([facet, value]) => { + if (facet) { + map[facet] = calculateChartMinWidth({ config, aggregatedData: value }); } + }); + return map; + }, [aggregatedDataMap?.facets, config]); + + const isGroupedByNumerical = React.useMemo(() => allColumns?.groupColVals?.type === EColumnTypes.NUMERICAL, [allColumns?.groupColVals?.type]); + + const itemData = React.useMemo( + () => + ({ + aggregatedDataMap, + allUniqueFacetVals, + chartHeightMap, + chartMinWidthMap, + config: config!, + containerHeight, + containerWidth, + filteredUniqueFacetVals: filteredUniqueFacetVals as string[], + groupColorScale: groupColorScale!, + isGroupedByNumerical, + labelsMap, + longestLabelWidth: truncatedTextRef.current.longestLabelWidth, + selectedList: selectedList!, + selectedMap: selectedMap!, + selectionCallback: customSelectionCallback, + setConfig: setConfig!, + }) satisfies VirtualizedBarChartProps, + [ + aggregatedDataMap, + allUniqueFacetVals, + chartHeightMap, + chartMinWidthMap, + config, + containerHeight, + containerWidth, + customSelectionCallback, + filteredUniqueFacetVals, + groupColorScale, + isGroupedByNumerical, + labelsMap, + selectedList, + selectedMap, + setConfig, + ], + ); + + const handleScroll = React.useCallback(({ y }: { y: number }) => { + listRef.current?.scrollTo(y); + }, []); + + const Row = React.useCallback((props: ListChildComponentProps) => { + const facet = props.data.filteredUniqueFacetVals?.[props.index] as string; + return ( + + + + ); + }, []); - if (e.ctrlKey) { - selectionCallback([...new Set([...selectedList, ...ids])]); - return; + const getTruncatedText = React.useCallback( + (value: string) => { + // NOTE: @dv-usama-ansari: This might be a performance bottleneck if the number of labels is very high and/or the parentWidth changes frequently (when the viewport is resized). + if (containerWidth === truncatedTextRef.current.containerWidth && truncatedTextRef.current.labels[value] !== undefined) { + return truncatedTextRef.current.labels[value]; } - if (selectionCallback) { - if (selectedList.length === ids.length && selectedList.every((value, index) => value === ids[index])) { - selectionCallback([]); - } else { - selectionCallback(ids); + + const textEl = document.createElement('p'); + textEl.style.position = 'absolute'; + textEl.style.visibility = 'hidden'; + textEl.style.whiteSpace = 'nowrap'; + textEl.style.maxWidth = config?.direction === EBarDirection.HORIZONTAL ? `${Math.max(gridLeft, containerWidth / 3) - 20}px` : '70px'; + textEl.innerText = value; + + document.body.appendChild(textEl); + const longestLabelWidth = Math.max(truncatedTextRef.current.longestLabelWidth, textEl.scrollWidth); + truncatedTextRef.current.longestLabelWidth = longestLabelWidth; + + let truncatedText = ''; + for (let i = 0; i < value.length; i++) { + textEl.innerText = `${truncatedText + value[i]}...`; + if (textEl.scrollWidth > textEl.clientWidth) { + truncatedText += '...'; + break; } + truncatedText += value[i]; } + + document.body.removeChild(textEl); + + truncatedTextRef.current.labels[value] = truncatedText; + return truncatedText; }, - [allColumns?.catColVals?.resolvedValues, selectedList, selectionCallback], + [config?.direction, containerWidth, gridLeft], ); + // NOTE: @dv-usama-ansari: We might need an optimization here. + React.useEffect(() => { + setLabelsMap({}); + Object.values(aggregatedDataMap?.facets ?? {}).forEach((value) => { + (value?.categoriesList ?? []).forEach((category) => { + const truncatedText = getTruncatedText(category); + truncatedTextRef.current.labels[category] = truncatedText; + setLabelsMap((prev) => ({ ...prev, [category]: truncatedText })); + }); + }); + setGridLeft(Math.min(containerWidth / 3, Math.max(truncatedTextRef.current.longestLabelWidth + 20, 60))); + }, [containerWidth, getTruncatedText, config?.catColumnSelected?.id, aggregatedDataMap?.facets]); + + React.useEffect(() => { + listRef.current?.resetAfterIndex(0); + }, [config, dataTable]); + return ( - - {showDownloadScreenshot || config.showFocusFacetSelector === true ? ( + + {showDownloadScreenshot || config?.showFocusFacetSelector === true ? ( - {config.showFocusFacetSelector === true ? : null} - {showDownloadScreenshot ? : null} + {config?.showFocusFacetSelector === true ? : null} + {showDownloadScreenshot ? : null} ) : null} - - - {groupColorScale ? ( - {}} // disable legend click for now - /> - ) : null} - - - - {colsStatus !== 'success' ? ( -
- -
- ) : !config.facets || !allColumns.facetsColVals ? ( - + {colsStatus !== 'success' ? ( +
+ +
+ ) : !config?.facets || !allColumns?.facetsColVals ? ( + + - ) : ( - + ) : config?.facets && allColumns?.facetsColVals ? ( + // NOTE: @dv-usama-ansari: Referenced from https://codesandbox.io/p/sandbox/react-window-with-scrollarea-g9dg6d?file=%2Fsrc%2FApp.tsx%3A40%2C8 + + (chartHeightMap[filteredUniqueFacetVals[index] as string] ?? DEFAULT_BAR_CHART_HEIGHT) + CHART_HEIGHT_MARGIN} + width="100%" + style={{ overflow: 'visible' }} + ref={listRef} > - {filteredUniqueFacetVals.map((multiplesVal) => ( - - ))} - - )} -
+ {Row} +
+ + ) : null} ); diff --git a/src/vis/bar/BarDisplayTypeButtons.tsx b/src/vis/bar/BarDisplayTypeButtons.tsx deleted file mode 100644 index 19e6dcac8..000000000 --- a/src/vis/bar/BarDisplayTypeButtons.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Container, SegmentedControl, Stack } 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 ( - - - - - - ); -} diff --git a/src/vis/bar/BarVis.tsx b/src/vis/bar/BarVis.tsx index ad48774ce..7ac94a296 100644 --- a/src/vis/bar/BarVis.tsx +++ b/src/vis/bar/BarVis.tsx @@ -1,10 +1,9 @@ import { Stack } from '@mantine/core'; -import React from 'react'; - -import { InvalidCols } from '../general/InvalidCols'; +import * as React from 'react'; +import { InvalidCols } from '../general'; import { ICommonVisProps } from '../interfaces'; -import { BarChart } from './BarChart'; import { IBarConfig } from './interfaces'; +import { BarChart } from './BarChart'; export function BarVis({ config, @@ -18,7 +17,7 @@ export function BarVis({ }: ICommonVisProps) { return ( - {config.catColumnSelected ? ( + {config?.catColumnSelected ? ( @@ -53,7 +52,7 @@ export function BarVisSidebar({ setConfig({ ...config, aggregateType, - aggregateColumn: columns.find((col) => col.type === EColumnTypes.NUMERICAL).info, + aggregateColumn: (columns ?? []).find((col) => col.type === EColumnTypes.NUMERICAL)?.info as ColumnInfo, display: aggregateType === EAggregateTypes.COUNT ? config.display : EBarDisplayType.ABSOLUTE, }); } else { @@ -70,12 +69,12 @@ export function BarVisSidebar({ ? mergedOptionsConfig.group.customComponent || ( setConfig({ ...config, group })} + groupColumnSelectCallback={(group: ColumnInfo | null) => setConfig({ ...config, group })} groupTypeSelectCallback={(groupType: EBarGroupingType) => setConfig({ ...config, groupType })} groupDisplaySelectCallback={(display: EBarDisplayType) => setConfig({ ...config, display })} displayType={config.display} groupType={config.groupType} - columns={columns.filter((c) => config.catColumnSelected && c.info.id !== config.catColumnSelected.id)} + columns={columns} currentSelected={config.group} /> ) @@ -84,8 +83,8 @@ export function BarVisSidebar({ ? mergedOptionsConfig.facets.customComponent || ( setConfig({ ...config, facets })} - columns={columns.filter((c) => config.catColumnSelected && c.info.id !== config.catColumnSelected.id)} - currentSelected={config.facets} + columns={columns} + currentSelected={config.facets!} label="Facets" columnType={[EColumnTypes.CATEGORICAL]} /> diff --git a/src/vis/bar/SingleBarChart.tsx b/src/vis/bar/SingleBarChart.tsx deleted file mode 100644 index acaacc122..000000000 --- a/src/vis/bar/SingleBarChart.tsx +++ /dev/null @@ -1,290 +0,0 @@ -import { Box, Container } from '@mantine/core'; -import { useResizeObserver } from '@mantine/hooks'; -import React, { useCallback, useMemo } from 'react'; -import { EAggregateTypes } from '../interfaces'; -import { XAxis } from './barComponents/XAxis'; -import { YAxis } from './barComponents/YAxis'; -import { GroupedBars } from './barTypes/GroupedBars'; -import { SimpleBars } from './barTypes/SimpleBars'; -import { StackedBars } from './barTypes/StackedBars'; -import { useGetGroupedBarScales } from './hooks/useGetGroupedBarScales'; -import { getBarData } from './utils'; -import { EBarDirection, EBarDisplayType, EBarGroupingType, IBarConfig, SortTypes } from './interfaces'; -import { ESortStates } from '../general/SortIcon'; -import { getLabelOrUnknown } from '../general/utils'; - -/** - * Return the margin object and adjust the bottom offset which also defines the lenght of the rotated labels - * @param rotatAxisLabel if set to true, the labels on the x-axis have 80 px offset from the bottom. Otherwise, 60 px. - * @returns the margin object with the top, bottom, left and right offsets - */ -const getMargin = (rotatAxisLabel: boolean) => ({ - top: 30, - bottom: rotatAxisLabel ? 80 : 60, - left: 60, - right: 25, -}); - -export function SingleBarChart({ - index, - allColumns, - config, - setConfig, - categoryFilter, - title, - selectedMap, - selectedList, - selectionCallback, - isSmall = false, - legendHeight, -}: { - index?: number; - allColumns: Awaited>; - config: IBarConfig; - setConfig: (config: IBarConfig) => void; - selectedMap: Record; - selectedList: string[]; - categoryFilter?: string; - title?: string; - selectionCallback?: (e: React.MouseEvent, ids: string[], label?: string) => void; - isSmall?: boolean; - legendHeight: number; -}) { - const [ref, { height, width }] = useResizeObserver(); - const [rotateXAxisTicks, setRotateXAxisTicks] = React.useState(false); - - const { aggregatedTable, categoryScale, countScale, groupColorScale, groupScale, groupedTable } = useGetGroupedBarScales( - allColumns, - height - legendHeight, - width, - getMargin(rotateXAxisTicks), - categoryFilter, - config.direction === EBarDirection.VERTICAL, - selectedMap, - config.groupType, - config.sortType, - config.aggregateType, - ); - - const categoryTicks = useMemo(() => { - return categoryScale?.domain().map((value) => ({ - value, - offset: categoryScale(value) + categoryScale.bandwidth() / 2, - })); - }, [categoryScale]); - - const normalizedCountScale = useMemo(() => { - if (config.display === EBarDisplayType.NORMALIZED && config.groupType === EBarGroupingType.STACK && config.group) { - return countScale.copy().domain([0, 1]); - } - return countScale; - }, [config.display, config.group, config.groupType, countScale]); - - const countTicks = useMemo(() => { - if (!normalizedCountScale) { - return null; - } - if (config.direction !== EBarDirection.VERTICAL) { - const newScale = normalizedCountScale.copy().domain([normalizedCountScale.domain()[1], normalizedCountScale.domain()[0]]); - return newScale.ticks(5).map((value) => ({ - value, - offset: newScale(value), - })); - } - return normalizedCountScale.ticks(5).map((value) => ({ - value, - offset: normalizedCountScale(value), - })); - }, [config.direction, normalizedCountScale]); - - const sortTypeCallback = useCallback( - (label: string, nextSortState: ESortStates) => { - if (label === config.catColumnSelected.name) { - if (nextSortState === ESortStates.ASC) { - setConfig({ ...config, sortType: SortTypes.CAT_ASC }); - } else if (nextSortState === ESortStates.DESC) { - setConfig({ ...config, sortType: SortTypes.CAT_DESC }); - } else { - setConfig({ ...config, sortType: SortTypes.NONE }); - } - } else if (nextSortState === ESortStates.ASC) { - setConfig({ ...config, sortType: SortTypes.COUNT_ASC }); - } else if (nextSortState === ESortStates.DESC) { - setConfig({ ...config, sortType: SortTypes.COUNT_DESC }); - } else { - setConfig({ ...config, sortType: SortTypes.NONE }); - } - }, - [config, setConfig], - ); - - return ( - - - - - {countScale && categoryScale && title !== undefined ? ( - { - setConfig({ ...config, focusFacetIndex: config.focusFacetIndex === index ? null : index }); - }} - > - {getLabelOrUnknown(title)} - - ) : null} - selectionCallback(e, [])} - /> - - {countScale && categoryScale ? ( - config.direction === EBarDirection.VERTICAL ? ( - - ) : ( - - ) - ) : null} - {categoryScale && countScale ? ( - config.direction === EBarDirection.VERTICAL ? ( - - ) : ( - - ) - ) : null} - {config.group ? ( - config.groupType === EBarGroupingType.GROUP ? ( - 0} - groupedTable={groupedTable} - groupScale={groupScale} - categoryScale={categoryScale} - countScale={countScale} - groupColorScale={groupColorScale} - width={width} - height={height - legendHeight} - margin={getMargin(rotateXAxisTicks)} - aggregateType={config.aggregateType} - isVertical={config.direction === EBarDirection.VERTICAL} - aggregateColumnName={config.aggregateColumn?.name} - /> - ) : ( - 0} - groupedTable={groupedTable} - categoryScale={categoryScale} - countScale={countScale} - groupColorScale={groupColorScale} - height={height - legendHeight} - margin={getMargin(rotateXAxisTicks)} - width={width} - isVertical={config.direction === EBarDirection.VERTICAL} - normalized={config.display === EBarDisplayType.NORMALIZED} - aggregateType={config.aggregateType} - aggregateColumnName={config.aggregateColumn?.name} - /> - ) - ) : ( - 0} - selectionCallback={selectionCallback} - aggregatedTable={aggregatedTable} - categoryScale={categoryScale} - countScale={countScale} - height={height} - margin={getMargin(rotateXAxisTicks)} - width={width} - aggregateType={config.aggregateType} - isVertical={config.direction === EBarDirection.VERTICAL} - aggregateColumnName={config.aggregateColumn?.name} - /> - )} - - - - - ); -} diff --git a/src/vis/bar/SingleEChartsBarChart.tsx b/src/vis/bar/SingleEChartsBarChart.tsx new file mode 100644 index 000000000..bbceb1a7e --- /dev/null +++ b/src/vis/bar/SingleEChartsBarChart.tsx @@ -0,0 +1,887 @@ +import { Box } from '@mantine/core'; +import { useSetState } from '@mantine/hooks'; +import type { ScaleOrdinal } from 'd3v7'; +import type { BarSeriesOption } from 'echarts/charts'; +import * as React from 'react'; +import { sanitize, selectionColorDark } from '../../utils'; +import { DEFAULT_COLOR, NAN_REPLACEMENT, SELECT_COLOR, VIS_NEUTRAL_COLOR, VIS_UNSELECTED_OPACITY } from '../general'; +import { EAggregateTypes, ICommonVisProps } from '../interfaces'; +import { useChart } from '../vishooks/hooks/useChart'; +import type { ECOption } from '../vishooks/hooks/useChart'; +import { useBarSortHelper } from './hooks'; +import { EBarDirection, EBarDisplayType, EBarGroupingType, EBarSortParameters, EBarSortState, IBarConfig, SortDirectionMap } from './interfaces'; +import { AggregatedDataType, BAR_WIDTH, CHART_HEIGHT_MARGIN, median, normalizedValue, SERIES_ZERO, sortSeries } from './interfaces/internal'; + +function generateHTMLString({ label, value, color }: { label: string; value: string; color?: string }): string { + return `
+
${label}:
+
+
${value}
+ ${color ? `
` : ''} +
+
`; +} + +function EagerSingleEChartsBarChart({ + aggregatedData, + chartHeight, + chartMinWidth, + config, + containerWidth, + globalMax, + globalMin, + groupColorScale, + isGroupedByNumerical, + labelsMap, + longestLabelWidth, + selectedFacetIndex, + selectedFacetValue, + selectedList, + selectedMap, + selectionCallback, + setConfig, +}: Pick, 'config' | 'setConfig' | 'selectedMap' | 'selectedList'> & { + aggregatedData: AggregatedDataType; + chartHeight: number; + chartMinWidth: number; + containerWidth: number; + globalMax?: number; + globalMin?: number; + groupColorScale: ScaleOrdinal; + isGroupedByNumerical: boolean; + labelsMap: Record; + longestLabelWidth: number; + selectedFacetIndex?: number; + selectedFacetValue?: string; + selectionCallback: (e: React.MouseEvent, 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 ( - - { + setConfig?.({ ...config, focusFacetIndex: typeof value === 'string' ? facets.indexOf(value) : value }); + }} + clearable + /> + + { + setConfig?.({ ...config, focusFacetIndex: ((config.focusFacetIndex ?? 0) - 1 + facets.length) % facets.length }); + }} + > + + + + + { + setConfig?.({ ...config, focusFacetIndex: ((config.focusFacetIndex ?? 0) + 1) % facets.length }); + }} + > + + + + + ) + ); +} 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 d30788c89..687cbf386 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, }, }, @@ -161,7 +163,7 @@ export function resolveColumnValues(columns: VisColumn[]) { return Promise.all(columns.map(async (col) => ({ ...col, resolvedValues: (await col?.values()) || [] }))); } -export async function resolveSingleColumn(column: VisColumn) { +export async function resolveSingleColumn(column: VisColumn | null) { if (!column) { return null; } @@ -178,13 +180,17 @@ export async function resolveSingleColumn(column: VisColumn) { */ 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 b181ed675..e62480c93 100644 --- a/src/vis/general/utils.ts +++ b/src/vis/general/utils.ts @@ -7,7 +7,17 @@ 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 { - return label === null || label === 'null' || label === undefined || label === 'undefined' || label === '' ? unknownLabel : label.toString(); + const formatter = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 4, + maximumSignificantDigits: 4, + notation: 'compact', + compactDisplay: 'short', + }); + return [null, 'null', undefined, 'undefined', ''].includes(label as string) + ? unknownLabel + : Number.isNaN(Number(label)) + ? (label as string) + : formatter.format(label as number); } /** 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 82c8a885d..e405cb187 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 }; +}