diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d6094cc11..9fade9609 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,4 +13,4 @@ jobs: uses: datavisyn/github-workflows/.github/workflows/release-source.yml@main secrets: inherit with: - release_version: ${{ inputs.release_version }} + release_version: ${{ inputs.release_version }} \ No newline at end of file diff --git a/package.json b/package.json index 5aef0ef90..b0028930c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "visyn_core", "description": "Core repository for datavisyn applications.", - "version": "14.0.2", + "version": "14.1.0", "author": { "name": "datavisyn GmbH", "email": "contact@datavisyn.io", @@ -26,6 +26,7 @@ "./hooks": "./src/hooks/index.ts", "./i18n": "./src/i18n/index.ts", "./idtype": "./src/idtype/index.ts", + "./echarts": "./src/echarts/index.ts", "./plotly/full": "./src/plotly/full/index.ts", "./plotly": "./src/plotly/index.tsx", "./plugin": "./src/plugin/index.ts", @@ -103,6 +104,7 @@ "@types/react-dom": "^18.3.0", "@types/react-plotly.js": "^2.6.3", "arquero": "5.4.0", + "comlink": "^4.4.1", "d3-force-boundary": "^0.0.3", "d3-hexbin": "^0.2.2", "d3v7": "npm:d3@^7.9.0", diff --git a/playwright/boxPlot/04_subcategory.spec.ts b/playwright/boxPlot/04_subcategory.spec.ts index bc3beda60..e7a706a8f 100644 --- a/playwright/boxPlot/04_subcategory.spec.ts +++ b/playwright/boxPlot/04_subcategory.spec.ts @@ -15,7 +15,7 @@ test('subcategory selected', async ({ page }) => { await expect(page.locator('g[class="subplot xy"]').locator('g[class="trace boxes"]').nth(1).locator('path[class="box"]')).toHaveCSS( 'fill', - 'rgb(236, 104, 54)', + 'rgb(247, 90, 30)', ); await expect(page.locator('g[class="subplot xy"]').locator('g[class="trace boxes"]').nth(2).locator('path[class="box"]')).toHaveCSS( diff --git a/src/echarts/index.ts b/src/echarts/index.ts new file mode 100644 index 000000000..28a256fc2 --- /dev/null +++ b/src/echarts/index.ts @@ -0,0 +1 @@ +export * from './useChart'; diff --git a/src/vis/vishooks/hooks/useChart.ts b/src/echarts/useChart.ts similarity index 72% rename from src/vis/vishooks/hooks/useChart.ts rename to src/echarts/useChart.ts index 8d5e677d7..96a406846 100644 --- a/src/vis/vishooks/hooks/useChart.ts +++ b/src/echarts/useChart.ts @@ -1,16 +1,27 @@ /* eslint-disable react-compiler/react-compiler */ import * as React from 'react'; + import { useDebouncedCallback, 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 { BarChart, FunnelChart, LineChart, PieChart, SankeyChart, ScatterChart } from 'echarts/charts'; import type { // The series option types are defined with the SeriesOption suffix BarSeriesOption, LineSeriesOption, + PieSeriesOption, + SankeySeriesOption, + FunnelSeriesOption, + ScatterSeriesOption, } from 'echarts/charts'; +import { + DataZoomComponent, + GridComponent, + LegendComponent, + SingleAxisComponent, + TitleComponent, + ToolboxComponent, + TooltipComponent, + BrushComponent, +} from 'echarts/components'; import type { // The component option types are defined with the ComponentOption suffix TitleComponentOption, @@ -18,18 +29,27 @@ import type { GridComponentOption, DatasetComponentOption, } from 'echarts/components'; -import { useSetRef } from '../../../hooks'; +import type { ComposeOption, ECElementEvent, ECharts } from 'echarts/core'; +import { init, use } from 'echarts/core'; +import { CanvasRenderer } from 'echarts/renderers'; +import { useSetRef } from '../hooks/useSetRef'; + +export type ECSeries = BarSeriesOption | LineSeriesOption | SankeySeriesOption | FunnelSeriesOption | ScatterSeriesOption | PieSeriesOption; -export type ECOption = ComposeOption< - BarSeriesOption | LineSeriesOption | TitleComponentOption | TooltipComponentOption | GridComponentOption | DatasetComponentOption ->; +export type ECOption = ComposeOption; // Original code from https://dev.to/manufac/using-apache-echarts-with-react-and-typescript-optimizing-bundle-size-29l8 // Register the required components use([ + SingleAxisComponent, + BrushComponent, LegendComponent, LineChart, BarChart, + PieChart, + FunnelChart, + SankeyChart, + ScatterChart, GridComponent, TooltipComponent, TitleComponent, @@ -57,18 +77,29 @@ type ElementEventName = | 'drop' | 'globalout'; +type ExoticEventName = 'brush' | 'brushEnd' | 'brushselected' | 'highlight' | 'downplay'; + // Type for mouse handlers in function form export type CallbackFunction = (event: ECElementEvent) => void; +export type ExoticCallbackFunction = (event: unknown) => void; + // Type for mouse handlers in object form export type CallbackObject = { query?: string | object; handler: CallbackFunction; }; +export type ExoticCallbackObject = { + query?: string | object; + handler: ExoticCallbackFunction; +}; + // Array of mouse handlers export type CallbackArray = (CallbackFunction | CallbackObject)[]; +export type ExoticCallbackArray = (ExoticCallbackFunction | ExoticCallbackObject)[]; + export function useChart({ options, settings, @@ -76,7 +107,11 @@ export function useChart({ }: { options?: ECOption; settings?: Parameters[1]; - mouseEvents?: Partial<{ [K in ElementEventName]: CallbackArray | CallbackFunction | CallbackObject }>; + mouseEvents?: Partial< + { [K in ElementEventName]: CallbackArray | CallbackFunction | CallbackObject } & { + [K in ExoticEventName]: ExoticCallbackArray | ExoticCallbackFunction | ExoticCallbackObject; + } + >; }) { const [state, setState] = useSetState({ width: 0, @@ -85,11 +120,6 @@ export function useChart({ instance: null as ECharts | null, }); - const debouncedResizeObserver = useDebouncedCallback((entries: ResizeObserverEntry[]) => { - const newDimensions = entries[0]?.contentRect; - setState({ width: newDimensions?.width, height: newDimensions?.height }); - }, 250); - const mouseEventsRef = React.useRef(mouseEvents); mouseEventsRef.current = mouseEvents; @@ -137,7 +167,13 @@ export function useChart({ const { ref, setRef } = useSetRef({ register: (element) => { - const observer = new ResizeObserver(debouncedResizeObserver); + const observer = new ResizeObserver( + // NOTE: @dv-usama-ansari: This callback can be debounced for performance reasons + (entries: ResizeObserverEntry[]) => { + const newDimensions = entries[0]?.contentRect; + setState({ width: newDimensions?.width, height: newDimensions?.height }); + }, + ); // create the instance const instance = init(element); // Save the mouse events diff --git a/src/icons/Icons.tsx b/src/icons/Icons.tsx index df85b272a..c638eabfa 100644 --- a/src/icons/Icons.tsx +++ b/src/icons/Icons.tsx @@ -309,3 +309,66 @@ export const dvMouse: IconDefinition = { `M433.2 93.5c-19.7 13.3-21.6 41.5-4.9 74.9 4.3 8.7 11.7 20.3 22.8 35.8 17 23.6 23.1 35.3 27.3 52.3 5.1 20.5 1.6 41.3-10 60.6-4.6 7.6-19.1 24.3-18 20.7 3.1-10.2 3.9-32.8 1.7-45.8-6-34.1-27.2-63.3-58.3-80-22.5-12-47.1-15.4-84.5-11.5-23.3 2.5-53.6 9.1-69.8 15.4-4.5 1.8-5 2.2-4.7 4.8.2 1.5.4 9.5.5 17.8.1 14.4 0 15.4-2.9 23.2-7.4 19.9-22 35.8-40.8 44.3-13.5 6.2-14.5 6.3-59.7 6.9l-41.7.6-9.3 10c-10.7 11.6-24.8 28.4-33.8 40.3-8.1 10.7-11.7 13.1-21.8 14.3-9.2 1.2-15.8 4.1-17.7 7.8-2 3.8-6.5 18.9-7.2 24.3-.8 5.7.9 10.6 4 11.3 1.1.3 91.9.4 201.7.3l199.6-.3 7.9-4c21-10.6 45.9-28.3 61.3-43.3 36.4-35.8 45.7-75.1 28.6-121-7.8-20.7-17.9-37.7-43.5-73.2-18-25.1-22.8-36.7-23.7-58-.5-11.7.8-24.2 3.2-30.3 1-2.5-.4-2.1-6.3 1.8zM109.4 343.9c9.9 6.1 10.2 22.4.5 28.8-10.8 7.1-25.3 1.1-26.6-11.1-.3-2.5-.3-6 .1-7.9.8-4.2 6.5-10.3 10.9-11.6 4.4-1.4 11.3-.5 15.1 1.8M154 189.1c-17.6 2.5-30.7 13.3-40.6 33.4-6.5 13.2-8.5 22.1-9.1 40.5-.4 10-.1 18.1.7 22.8l1.2 7.2h29.1c33.2 0 38.7-.8 50.6-6.6 8.8-4.3 19.1-14.1 23.4-22.2 9.1-16.9 8.9-37.4-.6-51.1-11.6-16.9-34.6-27-54.7-24z`, ], }; + +/* + * Protein structure representation + */ +export const dvProteinBallAndStick: IconDefinition = { + prefix: 'dv' as IconPrefix, + iconName: 'proteinBallAndStick' as IconName, + icon: [ + 23, + 24, + [], + null, + `M6.85714 2.28571C6.85714 2.64748 6.94119 2.98961 7.09081 3.29365L4.40099 4.84662C4.05931 4.01453 3.24094 3.42857 2.28571 3.42857C1.02335 3.42857 0 4.45192 0 5.71429C0 6.90101 0.904379 7.8765 2.06152 7.98914V12.5823C0.904379 12.6949 0 13.6704 0 14.8571C0 16.1195 1.02335 17.1429 2.28571 17.1429C3.24094 17.1429 4.05932 16.5569 4.40099 15.7248L8 17.8027V19.7344C7.3168 20.1296 6.85714 20.8683 6.85714 21.7143C6.85714 22.9767 7.88049 24 9.14286 24C10.4052 24 11.4286 22.9767 11.4286 21.7143C11.4286 20.8683 10.9689 20.1296 10.2857 19.7344V17.8027L13.8847 15.7248C14.2264 16.5569 15.0448 17.1429 16 17.1429C17.2624 17.1429 18.2857 16.1195 18.2857 14.8571C18.2857 13.6704 17.3813 12.6949 16.2242 12.5823V7.38756L19.5439 5.47088C19.8528 5.62659 20.2019 5.71429 20.5714 5.71429C21.8338 5.71429 22.8571 4.69094 22.8571 3.42857C22.8571 2.16621 21.8338 1.14286 20.5714 1.14286C19.3091 1.14286 18.2857 2.16621 18.2857 3.42857C18.2857 3.47133 18.2869 3.51381 18.2892 3.55598L14.9692 5.47278L11.1949 3.29367C11.3445 2.98962 11.4286 2.64748 11.4286 2.28571C11.4286 1.02335 10.4052 0 9.14286 0C7.88049 0 6.85714 1.02335 6.85714 2.28571ZM13.7143 13.1839L9.14284 15.8232L4.34724 13.0545V7.51697L9.14284 4.74823L13.7143 7.38756V13.1839Z`, + ], +}; + +export const dvProteinCartoon: IconDefinition = { + prefix: 'dv' as IconPrefix, + iconName: 'proteinCartoon' as IconName, + icon: [ + 20, + 18, + [], + null, + `M18.1132 9.15125C18.3735 9.42009 18.5179 9.62296 18.5878 9.76977C18.6066 9.80933 18.6164 9.83736 18.6215 9.85566C18.6079 9.87081 18.5836 9.89448 18.5424 9.92614C18.4351 10.0086 18.2779 10.0958 18.0565 10.2019C17.9892 10.2341 17.9071 10.2721 17.819 10.3127C17.6639 10.3843 17.49 10.4646 17.3467 10.5372C17.1061 10.659 16.8254 10.8182 16.591 11.0342C16.3476 11.2586 16.1128 11.5835 16.0601 12.0276C15.9926 12.5962 16.1226 13.1341 16.2587 13.5726C16.3145 13.7524 16.3779 13.9348 16.4362 14.1024L16.4753 14.215C16.546 14.4197 16.6074 14.6042 16.6549 14.782C16.7508 15.1406 16.7712 15.406 16.7184 15.6304C16.6701 15.8361 16.5387 16.0978 16.1531 16.4064C15.8189 16.6739 15.76 17.1677 16.0216 17.5095C16.2831 17.8513 16.7661 17.9115 17.1003 17.644C17.7039 17.161 18.0682 16.6122 18.2127 15.998C18.3527 15.4025 18.2633 14.8385 18.1374 14.3676C18.074 14.1307 17.9965 13.9003 17.9243 13.6913L17.8806 13.5653C17.823 13.3996 17.7706 13.2488 17.7235 13.0972C17.6089 12.7278 17.5623 12.4598 17.5834 12.2396C17.5897 12.2316 17.6011 12.219 17.62 12.2015C17.6946 12.1327 17.8226 12.0498 18.0285 11.9456C18.157 11.8806 18.2732 11.8272 18.4016 11.7683C18.4945 11.7256 18.5937 11.68 18.7086 11.625C18.9466 11.511 19.2244 11.3678 19.4652 11.1829C19.7063 10.9978 19.975 10.7221 20.0981 10.3196C20.2272 9.89762 20.1556 9.47255 19.9696 9.08183C19.7261 8.57046 19.2381 8.01647 18.4895 7.38197L17.9164 3.55115L17.8614 3.40956C17.825 3.31594 17.7914 3.22927 17.7572 3.14282C17.7128 3.03052 17.6705 2.91712 17.6281 2.80372C17.5317 2.54542 17.4353 2.2871 17.314 2.04172C17.0612 1.53088 16.6595 1.19629 16.0844 1.11798C15.62 1.05475 15.1557 0.990978 14.6914 0.927202L14.0882 0.844383C14.0175 0.85027 13.9469 0.856157 13.8387 0.853573C13.7789 0.841084 13.7566 0.837727 13.7342 0.835027C13.6634 0.841052 13.5927 0.847077 13.4814 0.843359C13.3878 0.826091 13.3348 0.818562 13.2818 0.811035C13.2108 0.817083 13.1397 0.823126 13.0476 0.824246C13.0265 0.819319 13.0265 0.819562 13.0265 0.819562L12.2869 1.00615C12.5836 0.931309 12.805 0.875354 13.0039 0.81197C12.8983 0.817061 12.7936 0.820225 12.6899 0.823358C12.3756 0.832857 12.0702 0.842086 11.7754 0.9039C11.6022 0.940207 11.449 1.07634 11.3041 1.20512C11.2679 1.23732 11.2322 1.26907 11.1968 1.29868C11.2325 1.35098 11.2681 1.4019 11.3032 1.452C11.4254 1.62645 11.5407 1.79113 11.6263 1.97063C11.6934 2.1112 11.7481 2.25854 11.8027 2.40566C11.8774 2.60678 11.9519 2.80754 12.0576 2.98996C12.1813 3.20353 12.3961 3.37901 12.6032 3.52122C12.9681 3.77182 13.3583 3.98397 13.7442 4.18947L13.7488 4.1736C13.824 3.91223 13.8931 3.67171 13.9565 3.40461C13.9099 3.27453 13.8602 3.14721 13.807 3.02267C13.8638 3.15473 13.917 3.29024 13.967 3.42926C13.9908 3.50422 14.0118 3.57366 14.0315 3.63918C14.0706 3.76865 14.1053 3.88366 14.1474 3.99475C14.4808 4.87365 14.8163 5.75173 15.1518 6.6298L15.1522 6.63106L15.1527 6.63232L15.4758 7.47811C15.5011 7.48345 15.5286 7.48616 15.5561 7.48888C15.6051 7.49371 15.6541 7.49855 15.6912 7.51825C15.6334 7.48783 15.5479 7.49616 15.4887 7.50572C15.8564 8.35808 16.3978 8.94294 17.331 9.03114L17.5312 9.06345C17.6354 9.08023 17.6914 9.08924 17.7473 9.09859C17.8121 9.10942 17.8769 9.1207 18.0165 9.14502C18.0489 9.15064 18.0816 9.15263 18.1132 9.15125ZM17.5761 12.2499C17.576 12.2502 17.5761 12.2499 17.5761 12.2499ZM13.7464 4.83233C13.7543 5.33418 13.7623 5.83603 13.7508 6.33742C13.7448 6.59547 13.6983 6.85255 13.6486 7.12676C13.6319 7.21891 13.6149 7.31299 13.599 7.40969C13.5655 7.48637 13.5478 7.5224 13.5302 7.55843C13.4604 7.63638 13.3907 7.71438 13.278 7.81121C13.2263 7.81217 13.1754 7.81505 13.126 7.81786C13.0322 7.82318 12.9435 7.8282 12.8643 7.81911C12.9297 7.82728 13.0007 7.82645 13.0741 7.8256C13.1348 7.82489 13.197 7.82417 13.2589 7.8285C13.1978 7.88074 13.1365 7.93499 13.0746 7.9898C12.8618 8.17828 12.6412 8.37356 12.3924 8.51743C12.0275 8.72838 11.6472 8.91156 11.267 9.09474C11.1057 9.17243 10.9445 9.25012 10.7844 9.32993C10.1533 9.64451 9.53403 9.71192 8.92139 9.2706C8.80896 9.1896 8.68345 9.12758 8.55154 9.06239C8.48282 9.02843 8.41237 8.99361 8.34112 8.9548C8.38045 8.92775 8.43115 8.893 8.48295 8.87672C8.45422 8.88494 8.42491 8.89798 8.3956 8.91103C8.37045 8.92222 8.3453 8.93341 8.32052 8.94155C8.26565 8.92021 8.211 8.89807 8.15636 8.87594C8.0151 8.81871 7.87372 8.76144 7.72851 8.71803C7.68494 8.70501 7.63178 8.72549 7.57864 8.74597C7.55431 8.75534 7.52999 8.76471 7.50658 8.77087C7.51349 8.79432 7.51823 8.81993 7.52296 8.84554C7.53315 8.90064 7.54334 8.95576 7.57521 8.98933C8.18832 9.6351 8.50498 10.4496 8.79717 11.2705C8.86189 11.4523 8.85422 11.661 8.84595 11.8863C8.84348 11.9537 8.84095 12.0225 8.84029 12.0926C8.45943 12.3915 8.16471 12.7213 7.96506 13.1178C8.16467 12.7222 8.45927 12.3966 8.83946 12.1163C8.8373 12.1512 8.83618 12.1879 8.83503 12.2256C8.83064 12.3693 8.8258 12.5276 8.76143 12.6542C8.26891 13.6228 7.75819 14.5819 7.24576 15.5398C7.08715 15.8364 6.82483 15.9446 6.47593 15.8917C7.10161 15.3386 7.43202 14.6029 7.69921 13.8293C7.43165 14.603 7.09859 15.3384 6.45196 15.8874C6.17569 15.8108 5.89919 15.7351 5.6227 15.6594L5.62165 15.6591C4.96991 15.4805 4.31818 15.302 3.66944 15.1128C3.06526 14.9365 2.46346 14.9743 1.80779 15.0722C1.71109 15.0958 1.6171 15.1175 1.52541 15.1386C1.25882 15.2002 1.0112 15.2574 0.773103 15.3415C0.178908 15.5512 0.103148 15.4956 0.197411 14.8509C0.199517 14.8365 0.20145 14.8221 0.203384 14.8076C0.209424 14.7624 0.215474 14.7171 0.226855 14.6733C0.39855 14.013 0.571134 13.3529 0.743718 12.6928L0.868742 12.2146C1.61314 11.4488 2.56716 11.3526 3.54337 11.3635C3.78834 11.3663 4.03283 11.4033 4.27746 11.4403C4.40554 11.4597 4.53368 11.4791 4.66192 11.4936C4.72815 11.5011 4.79536 11.4996 4.86763 11.4979C4.90224 11.4971 4.93865 11.4962 4.97604 11.4964C4.52884 10.8514 4.10642 10.2421 3.6782 9.58314C3.87524 9.30484 4.06433 9.06107 4.28527 8.85259C4.12095 9.00764 3.97427 9.18197 3.82094 9.36443C3.76814 9.42727 3.71446 9.49114 3.65909 9.55537C3.42706 9.29639 3.1938 9.02538 2.9937 8.73081C2.80533 8.45352 2.78233 8.14262 2.96719 7.84763C3.30568 7.3075 3.65152 6.77219 4.008 6.22041L4.11908 6.04846C4.06298 6.25478 3.99595 6.48374 3.9149 6.7074C3.97129 6.55178 4.02087 6.39384 4.07048 6.23565C4.09217 6.16649 4.11386 6.09733 4.13612 6.02839C4.22976 5.90468 4.32302 5.78066 4.41629 5.65665C4.63199 5.36984 4.84768 5.08302 5.06807 4.80003C5.32637 4.46834 5.69067 4.40032 6.06265 4.48279L6.06636 4.48362C6.98673 4.68766 7.90905 4.89213 8.81362 5.157C9.14834 5.25501 9.46681 5.41102 9.80005 5.57427C9.95483 5.65009 10.1128 5.72747 10.277 5.80133C10.2789 5.76349 10.2813 5.72549 10.2838 5.68737C10.2892 5.60418 10.2947 5.52001 10.2936 5.43639C10.2857 4.79755 10.2778 4.15867 10.2633 3.51996C10.2438 2.65627 10.6419 1.96944 11.1799 1.31973C11.3566 1.5335 11.5161 1.73957 11.6263 1.97063C11.6934 2.1112 11.7481 2.25854 11.8027 2.40566C11.8774 2.60678 11.9519 2.80754 12.0576 2.98996C12.1813 3.20353 12.3961 3.37901 12.6032 3.52122C12.8604 3.69787 13.1302 3.85584 13.4 4.01338C13.5129 4.07933 13.6259 4.14528 13.7379 4.21263C13.7398 4.41917 13.7431 4.62576 13.7464 4.83233Z`, + ], +}; + +export const dvProteinStick: IconDefinition = { + prefix: 'dv' as IconPrefix, + iconName: 'proteinStick' as IconName, + icon: [ + 22, + 24, + [], + null, + `M8.64145 25C9.33999 25 9.90636 24.4344 9.90636 23.7368V18.1504L15.2141 15.0903L15.2198 15.0871L15.2373 15.0767L15.8466 14.7255C16.4791 14.1875 16.4791 13.8555 16.4791 13.3359V6.78208C16.4791 6.73489 16.4778 6.68771 16.4752 6.64114L22.1712 3.35714C22.7761 3.00835 22.9835 2.23584 22.6341 1.63171C22.2848 1.02759 21.5112 0.820658 20.9063 1.16944L15.0903 4.52253L9.90636 1.53396C9.12366 1.08279 8.15923 1.08279 7.37653 1.53396L2.06875 4.59407C1.28605 5.04555 0.803833 5.87943 0.803833 6.78208V12.9023C0.803833 13.805 1.28605 14.6388 2.06875 15.0903L7.37653 18.1504V23.7368C7.37653 24.4344 7.9429 25 8.64145 25ZM13.7011 13.0454L8.64145 15.9624L3.33366 12.9023V6.78208L8.64145 3.72196L13.7011 6.63898V13.0454Z`, + ], +}; + +export const dvProteinSurface: IconDefinition = { + prefix: 'dv' as IconPrefix, + iconName: 'proteinSurface' as IconName, + icon: [ + 24, + 22, + [], + null, + `M16.8103 0.468166C14.917 -0.614518 12.4949 0.25773 11.7476 2.29133L11.6499 2.55724C11.1567 3.89944 9.91213 4.82923 8.47256 4.93099L6.04224 5.10277C4.12786 5.23809 2.64464 6.81442 2.64464 8.71365V9.4774C2.64464 10.5233 2.15666 11.5106 1.32237 12.1528C-0.577428 13.615 -0.400827 16.5091 1.66297 17.7345L2.19765 18.0519C2.86182 18.4463 3.35332 19.0718 3.57588 19.8059C4.23128 21.9678 6.93794 22.7185 8.63329 21.2086L8.87829 20.9904C10.0262 19.9681 11.7351 19.8647 13.0002 20.7411L13.2207 20.8938C15.0497 22.1607 17.589 21.5239 18.5874 19.5478L19.5841 17.5753C19.9823 16.7872 20.6706 16.1802 21.5074 15.879L21.8696 15.7487C24.0936 14.9484 24.7248 12.1332 23.0514 10.4773C22.0534 9.4897 21.8178 7.97576 22.4692 6.73673L22.8133 6.0821C24.17 3.50166 21.9811 0.487916 19.0712 0.899278C18.2916 1.0095 17.4922 0.858122 16.8103 0.468166Z`, + ], +}; + +export const dvProteinBackbone: IconDefinition = { + prefix: 'dv' as IconPrefix, + iconName: 'proteinBackbone' as IconName, + icon: [ + 24, + 18, + [], + null, + `M3.47472 11.5486C2.62747 10.7018 2.41571 10.0262 2.44306 9.54479C2.47063 9.05963 2.75193 8.57865 3.29892 8.14683C3.84567 7.7152 4.60047 7.38394 5.40483 7.21366C6.21113 7.04297 7.0041 7.04727 7.62078 7.22213L7.65794 7.23267L7.69597 7.23933C8.24462 7.33544 8.74122 7.38558 9.19858 7.29628C9.69694 7.19899 10.0717 6.9537 10.4099 6.61702L10.525 6.50246L10.5825 6.35054C11.3769 4.24813 11.9875 3.29079 12.4131 2.92133C12.5994 2.75959 12.69 2.76332 12.7078 2.76406C12.7452 2.76561 12.8379 2.78536 13.0066 2.91768C13.1757 3.05024 13.3594 3.24646 13.5761 3.50371C13.6473 3.58832 13.728 3.68688 13.8127 3.79046C13.9562 3.96582 14.1115 4.15576 14.253 4.31661C14.4894 4.58535 14.7715 4.87491 15.097 5.08606C15.4326 5.30372 15.8615 5.46768 16.3586 5.39711C17.0488 5.29912 17.6507 4.97154 18.1571 4.64755C18.3641 4.51516 18.5711 4.3725 18.7659 4.23821L18.8988 4.14675C19.1377 3.98274 19.3608 3.83328 19.5827 3.70369C20.0289 3.44305 20.4137 3.29738 20.7927 3.28763C21.1532 3.27835 21.5998 3.39018 22.175 3.82289C23.3411 4.70013 23.8911 5.48283 24.0885 6.11246C24.2756 6.70958 24.1706 7.2447 23.8392 7.74065C23.1271 8.80659 21.3568 9.67002 19.3561 9.86394L19.2984 9.86954L19.2422 9.88394C18.9717 9.95328 18.6096 10.0819 18.3846 10.4208C18.1748 10.7368 18.1938 11.087 18.2017 11.2321L18.2025 11.2474C18.2368 11.9069 18.4727 12.5978 18.6982 13.203C18.753 13.35 18.8075 13.4929 18.8607 13.6324C19.0417 14.1068 19.2077 14.5421 19.3195 14.9695C19.4632 15.519 19.4753 15.9164 19.3697 16.2109C19.2775 16.4678 19.049 16.7732 18.3989 17.0408C18.2555 17.0998 18.1139 17.1589 17.9739 17.2173C17.5033 17.4137 17.0515 17.6022 16.6169 17.7533C16.0424 17.953 15.6061 18.0463 15.3047 18.0298C15.166 18.0222 15.0912 17.9926 15.0514 17.9683C15.0187 17.9483 14.9746 17.9113 14.9304 17.8142C14.8246 17.5818 14.7493 17.07 14.9172 16.063C14.9853 15.6545 14.7093 15.268 14.3007 15.1999C13.8921 15.1318 13.5057 15.4078 13.4376 15.8164C13.2568 16.9011 13.2686 17.7838 13.5651 18.4355C13.7221 18.7803 13.958 19.0578 14.2688 19.248C14.5727 19.4338 14.9053 19.5102 15.2226 19.5276C15.8332 19.5611 16.5035 19.3808 17.1095 19.1701C17.5915 19.0025 18.1011 18.7898 18.5788 18.5904C18.712 18.5348 18.8429 18.4801 18.9699 18.4279C19.9008 18.0446 20.51 17.4743 20.7816 16.7175C21.0397 15.9981 20.9417 15.2436 20.7707 14.5899C20.6361 14.0753 20.4303 13.5367 20.2446 13.0507C20.1957 12.9228 20.1482 12.7985 20.1038 12.6792C19.9052 12.1462 19.7633 11.7043 19.7156 11.3338C21.8682 11.0785 24.0545 10.1186 25.0865 8.57393C25.6444 7.73888 25.8559 6.73628 25.5198 5.66386C25.1939 4.62394 24.3808 3.60527 23.0767 2.6242C22.2856 2.02908 21.5156 1.76852 20.7541 1.78812C20.011 1.80726 19.3683 2.09177 18.8261 2.40847C18.5538 2.56755 18.2925 2.74354 18.05 2.91002L17.9056 3.00933C17.7125 3.14244 17.5319 3.26685 17.3488 3.38398C16.8899 3.67754 16.5104 3.86052 16.1478 3.912C16.1185 3.91616 16.0527 3.91801 15.9133 3.8276C15.7639 3.73068 15.5907 3.56623 15.3793 3.32591C15.2485 3.17716 15.1322 3.03467 15.0073 2.88151C14.9197 2.77404 14.8277 2.66125 14.7234 2.53744C14.4952 2.26646 14.2294 1.97031 13.9322 1.73727C13.6347 1.50401 13.2443 1.28494 12.7697 1.26534C12.2756 1.24493 11.8253 1.44523 11.4298 1.78858C10.7112 2.41238 10.0122 3.64886 9.24229 5.65488C9.11183 5.76461 9.01491 5.80382 8.91115 5.82408C8.74333 5.85684 8.48463 5.8527 7.99039 5.768C7.09318 5.52254 6.06121 5.54146 5.09417 5.74618C4.11094 5.95432 3.13167 6.36778 2.36947 6.96949C1.60752 7.57101 1.00501 8.41198 0.945478 9.4597C0.885737 10.5112 1.38361 11.5794 2.41432 12.6096C2.73824 12.9333 3.24866 13.2865 3.73219 13.621C3.85865 13.7085 3.98327 13.7947 4.10224 13.8788C4.73391 14.3253 5.30217 14.7701 5.66196 15.2379C6.00378 15.6823 6.09269 16.0547 5.96 16.4468C5.80425 16.907 5.28789 17.5739 3.94155 18.4154C3.5903 18.6349 3.48352 19.0976 3.70306 19.4489C3.9226 19.8001 4.38531 19.9069 4.73656 19.6873C6.17912 18.7857 7.05722 17.8838 7.38083 16.9276C7.72748 15.9034 7.38063 15.012 6.85091 14.3233C6.33917 13.6581 5.60012 13.1007 4.96804 12.6539C4.80139 12.5361 4.64497 12.4276 4.49948 12.3266C4.04264 12.0096 3.69365 11.7675 3.47472 11.5486Z`, + ], +}; diff --git a/src/locales/en/visyn.json b/src/locales/en/visyn.json index d8950805b..862532d94 100644 --- a/src/locales/en/visyn.json +++ b/src/locales/en/visyn.json @@ -1,11 +1,16 @@ { "vis": { - "violinError": "To create a violin plot, please select at least 1 numerical column.", - "scatterError": "To create a scatter plot, please select at least 2 numerical columns.", - "hexbinError": "To create a hexbin plot, please select at least 2 numerical columns.", - "stripError": "To create a strip plot, please select at least 1 numerical column.", - "barError": "To create a bar chart, please select 1 categorical column.", - "errorHeader": "Invalid settings", + "missingColumn": { + "errorHeader": "Missing column selection", + "violinError": "Select at least one numerical column to display the violin plot.", + "boxplotError": "Select at least one numerical column to display the box plot.", + "scatterError": "Select at least two numerical columns to display the scatter plot.", + "hexbinError": "Select at least two numerical columns to display the hexbin plot.", + "barError": "Select one categorical column to display the bar chart.", + "correlationError": "Select at least two numerical columns to display the correlation plot.", + "heatmapError": "Select at least two categorical columns to display the heatmap chart.", + "sankeyError": "Select at least two categorical columns to display the sankey chart." + }, "openSettings": "Open settings", "closeSettings": "Close settings", "hexbinOptions": "Hexbin options", diff --git a/src/vis/EagerVis.tsx b/src/vis/EagerVis.tsx index 23008d758..dded63b7c 100644 --- a/src/vis/EagerVis.tsx +++ b/src/vis/EagerVis.tsx @@ -1,10 +1,6 @@ -import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Alert, Group, Stack } from '@mantine/core'; +import { Group, Stack } from '@mantine/core'; import { useResizeObserver, useUncontrolled } from '@mantine/hooks'; -import * as d3v7 from 'd3v7'; import * as React from 'react'; -import { getCssValue } from '../utils'; import { createVis, useVisProvider } from './Provider'; import { VisSidebarWrapper } from './VisSidebarWrapper'; import { @@ -16,7 +12,6 @@ import { EScatterSelectSettings, ESupportedPlotlyVis, IPlotStats, - Scales, VisColumn, isESupportedPlotlyVis, } from './interfaces'; @@ -28,6 +23,7 @@ import { correlationMergeDefaultConfig } from './correlation'; import { CorrelationVis } from './correlation/CorrelationVis'; import { CorrelationVisSidebar } from './correlation/CorrelationVisSidebar'; import { ICorrelationConfig } from './correlation/interfaces'; +import { WarningMessage } from './general/WarningMessage'; import { HeatmapVis } from './heatmap/HeatmapVis'; import { HeatmapVisSidebar } from './heatmap/HeatmapVisSidebar'; import { IHeatmapConfig } from './heatmap/interfaces'; @@ -41,12 +37,12 @@ import { SankeyVisSidebar } from './sankey/SankeyVisSidebar'; import { ISankeyConfig } from './sankey/interfaces'; import { sankeyMergeDefaultConfig } from './sankey/utils'; import { scatterMergeDefaultConfig } from './scatter'; +import { ScatterVis } from './scatter/ScatterVis'; import { ScatterVisSidebar } from './scatter/ScatterVisSidebar'; import { IScatterConfig } from './scatter/interfaces'; import { ViolinVis, violinBoxMergeDefaultConfig } from './violin'; import { ViolinVisSidebar } from './violin/ViolinVisSidebar'; import { IViolinConfig } from './violin/interfaces'; -import { ScatterVis } from './scatter/ScatterVis'; const DEFAULT_SHAPES = ['circle', 'square', 'triangle-up', 'star']; @@ -312,13 +308,13 @@ export function EagerVis({ {enableSidebar && !showSidebar ? setShowSidebar(!showSidebar)} /> : null} {visTypeNotSupported ? ( - }> + The visualization type "{visConfig?.type}" is not supported. Please open the sidebar and select a different type. - + ) : visHasError || !Renderer ? ( - }> + An error occured in the visualization. Please try to select something different in the sidebar. - + ) : ( visConfig?.merged && ( ; + aggregatedDataMap: Awaited>; allUniqueFacetVals: string[]; chartHeightMap: Record; chartMinWidthMap: Record; @@ -61,77 +65,70 @@ export function BarChart({ '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 listRef = React.useRef(null); - const { value: allColumns, status: colsStatus } = useAsync(getBarData, [ + const { value: barData, status: barDataStatus } = useAsync(getBarData, [ columns, config?.catColumnSelected as ColumnInfo, config?.group as ColumnInfo, config?.facets as ColumnInfo, config?.aggregateColumn as ColumnInfo, ]); + const generateDataTableWorker = React.useCallback(async (...args: Parameters) => WorkerWrapper.generateDataTable(...args), []); + const { execute: generateDataTableTrigger, status: dataTableStatus } = useAsync(generateDataTableWorker); - const [gridLeft, setGridLeft] = React.useState(containerWidth / 3); + const generateAggregateDataLookupWorker = React.useCallback( + async (...args: Parameters) => WorkerWrapper.generateAggregatedDataLookup(...args), + [], + ); + const { execute: generateAggregatedDataLookupTrigger, status: dataLookupStatus } = useAsync(generateAggregateDataLookupWorker); - const truncatedTextRef = React.useRef<{ labels: { [value: string]: string }; longestLabelWidth: number; containerWidth: number }>({ - labels: {}, - longestLabelWidth: 0, - containerWidth, - }); + const getTruncatedTextMapWorker = React.useCallback( + async (...args: Parameters) => WorkerWrapper.getTruncatedTextMap(...args), + [], + ); + const { execute: getTruncatedTextMapTrigger, status: truncatedTextStatus } = useAsync(getTruncatedTextMapWorker); + + const [itemData, setItemData] = React.useState(null); + const [dataTable, setDataTable] = React.useState>([]); + const [aggregatedDataMap, setAggregatedDataMap] = React.useState> | null>(null); + const [gridLeft, setGridLeft] = React.useState(containerWidth / 3); const [labelsMap, setLabelsMap] = React.useState>({}); + const [longestLabelWidth, setLongestLabelWidth] = React.useState(0); - const dataTable = React.useMemo(() => { - if (!allColumns) { - return []; - } + const listRef = React.useRef(null); - // 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 id = React.useMemo(() => uniquePlotId || uniqueId('BarChartVis'), [uniquePlotId]); - 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 isLoading = React.useMemo(() => barDataStatus === 'pending' || dataTableStatus === 'pending', [barDataStatus, dataTableStatus]); + + const isError = React.useMemo( + () => barDataStatus === 'error' || dataTableStatus === 'error' || dataLookupStatus === 'error' || truncatedTextStatus === 'error', + [barDataStatus, dataLookupStatus, dataTableStatus, truncatedTextStatus], ); + const isSuccess = React.useMemo(() => barDataStatus === 'success' && dataTableStatus === 'success', [barDataStatus, dataTableStatus]); + + const allUniqueFacetVals = React.useMemo(() => { + const set = new Set(); + barData?.facetsColVals?.resolvedValues.forEach((v) => set.add(getLabelOrUnknown(v.val))); + return [...set] as string[]; + }, [barData?.facetsColVals?.resolvedValues]); + + const filteredUniqueFacetVals = React.useMemo(() => { + const unsorted = + typeof config?.focusFacetIndex === 'number' && config?.focusFacetIndex < allUniqueFacetVals.length + ? ([allUniqueFacetVals[config?.focusFacetIndex]] as string[]) + : allUniqueFacetVals; + return unsorted.sort((a, b) => (a === NAN_REPLACEMENT || b === NAN_REPLACEMENT ? 1 : a && b ? a.localeCompare(b) : 0)); + }, [allUniqueFacetVals, config?.focusFacetIndex]); + const groupColorScale = React.useMemo(() => { - if (!allColumns?.groupColVals) { + if (!barData?.groupColVals) { return null; } const groups = - allColumns.groupColVals.type === EColumnTypes.NUMERICAL + barData.groupColVals.type === EColumnTypes.NUMERICAL ? [ ...new Set( Object.values(aggregatedDataMap?.facets ?? {}) @@ -161,43 +158,16 @@ export function BarChart({ const maxGroupings = Object.values(aggregatedDataMap?.facets ?? {}).reduce((acc: number, facet) => Math.max(acc, facet.groupingsList.length), 0); const range = - allColumns.groupColVals.type === EColumnTypes.NUMERICAL + barData.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 + (group, i) => (barData?.groupColVals?.color?.[group] || categoricalColors10[i % categoricalColors10.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 = React.useMemo(() => { - return typeof config?.focusFacetIndex === 'number' && config?.focusFacetIndex < allUniqueFacetVals.length - ? [allUniqueFacetVals[config?.focusFacetIndex]] - : allUniqueFacetVals; - }, [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], - ); + }, [aggregatedDataMap, barData, config]); const chartHeightMap = React.useMemo(() => { const map: Record = {}; @@ -219,52 +189,34 @@ export function BarChart({ 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 shouldRenderFacets = React.useMemo( + () => Boolean(config?.facets && barData?.facetsColVals && filteredUniqueFacetVals.length === Object.keys(chartHeightMap).length), + [config?.facets, barData?.facetsColVals, filteredUniqueFacetVals.length, chartHeightMap], ); - const handleScroll = React.useCallback(({ y }: { y: number }) => { - listRef.current?.scrollTo(y); - }, []); + const isGroupedByNumerical = React.useMemo(() => barData?.groupColVals?.type === EColumnTypes.NUMERICAL, [barData?.groupColVals?.type]); + + 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 Row = React.useCallback((props: ListChildComponentProps) => { + if (!props.data) { + return null; + } const facet = props.data.filteredUniqueFacetVals?.[props.index] as string; return ( @@ -291,123 +243,202 @@ export function BarChart({ ); }, []); - 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]; - } - - 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); + const handleScroll = React.useCallback(({ y }: { y: number }) => { + listRef.current?.scrollTo(y); + }, []); - truncatedTextRef.current.labels[value] = truncatedText; - return truncatedText; - }, - [config?.direction, containerWidth, gridLeft], + const calculateItemHeight = React.useCallback( + (index: number) => (chartHeightMap[filteredUniqueFacetVals[index] as string] ?? DEFAULT_BAR_CHART_HEIGHT) + CHART_HEIGHT_MARGIN, + [chartHeightMap, filteredUniqueFacetVals], ); - // 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 ? ( - - {config?.showFocusFacetSelector === true ? : null} - {showDownloadScreenshot ? : null} - - ) : null} - - {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} - > - {Row} - - + useShallowEffect(() => { + if (barDataStatus === 'success' && barData) { + const fetchDataTable = async () => { + const table = await generateDataTableTrigger({ + aggregateColVals: { + info: barData.aggregateColVals?.info, + resolvedValues: barData.aggregateColVals?.resolvedValues, + type: barData.aggregateColVals?.type, + }, + catColVals: { + info: barData.catColVals?.info, + resolvedValues: barData.catColVals?.resolvedValues, + type: barData.catColVals?.type, + }, + facetsColVals: { + info: barData.facetsColVals?.info, + resolvedValues: barData.facetsColVals?.resolvedValues, + type: barData.facetsColVals?.type, + }, + groupColVals: { + info: barData.groupColVals?.info, + resolvedValues: barData.groupColVals?.resolvedValues, + type: barData.groupColVals?.type, + }, + }); + setDataTable(table); + }; + fetchDataTable(); + } + }, [barData, barDataStatus, generateDataTableTrigger]); + + useShallowEffect(() => { + const fetchLookup = async () => { + const lookup = await generateAggregatedDataLookupTrigger( + { + 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, + ); + setAggregatedDataMap(lookup); + }; + fetchLookup(); + }, [ + config?.aggregateType, + config?.display, + config?.facets?.id, + config?.group?.id, + config?.groupType, + dataTable, + generateAggregatedDataLookupTrigger, + selectedMap, + ]); + + useShallowEffect(() => { + const fetchTruncatedTextMap = async () => { + const truncatedTextMap = await getTruncatedTextMapTrigger( + Object.values(aggregatedDataMap?.facets ?? {}) + .map((value) => value?.categoriesList ?? []) + .flat(), + config?.direction === EBarDirection.HORIZONTAL ? Math.max(gridLeft, containerWidth / 3) - 20 : 70, + ); + setLabelsMap(truncatedTextMap.map); + setLongestLabelWidth(truncatedTextMap.longestLabelWidth); + setGridLeft(Math.min(containerWidth / 3, Math.max(longestLabelWidth + 20, 60))); + }; + fetchTruncatedTextMap(); + }, [aggregatedDataMap?.facets, aggregatedDataMap?.facetsList, config?.direction, containerWidth, getTruncatedTextMapTrigger, gridLeft, longestLabelWidth]); + + React.useEffect(() => { + setItemData({ + aggregatedDataMap: aggregatedDataMap!, + allUniqueFacetVals, + chartHeightMap, + chartMinWidthMap, + config: config!, + containerHeight, + containerWidth, + filteredUniqueFacetVals, + groupColorScale: groupColorScale!, + isGroupedByNumerical, + labelsMap, + longestLabelWidth, + selectedList: selectedList!, + selectedMap: selectedMap!, + selectionCallback: customSelectionCallback, + setConfig: setConfig!, + } satisfies VirtualizedBarChartProps); + }, [ + aggregatedDataMap, + allUniqueFacetVals, + chartHeightMap, + chartMinWidthMap, + config, + containerHeight, + containerWidth, + customSelectionCallback, + filteredUniqueFacetVals, + groupColorScale, + isGroupedByNumerical, + labelsMap, + longestLabelWidth, + selectedList, + selectedMap, + setConfig, + ]); + + return isLoading ? ( + + ) : isError ? ( + + + Something went wrong while loading and processing the data. Please try again. + + + ) : ( + isSuccess && ( + + {showDownloadScreenshot || config?.showFocusFacetSelector === true ? ( + + {config?.showFocusFacetSelector === true ? : null} + {showDownloadScreenshot ? : null} + ) : null} + + {!config?.facets || !barData?.facetsColVals ? ( + + + + ) : config?.facets && barData?.facetsColVals ? ( + // NOTE: @dv-usama-ansari: Referenced from https://codesandbox.io/p/sandbox/react-window-with-scrollarea-g9dg6d?file=%2Fsrc%2FApp.tsx%3A40%2C8 + shouldRenderFacets && ( + + + {Row} + + + ) + ) : null} + -
+ ) ); } diff --git a/src/vis/bar/BarVis.tsx b/src/vis/bar/BarVis.tsx index 7ac94a296..1983ea6df 100644 --- a/src/vis/bar/BarVis.tsx +++ b/src/vis/bar/BarVis.tsx @@ -1,9 +1,10 @@ import { Stack } from '@mantine/core'; import * as React from 'react'; -import { InvalidCols } from '../general'; import { ICommonVisProps } from '../interfaces'; import { IBarConfig } from './interfaces'; import { BarChart } from './BarChart'; +import { i18n } from '../../i18n'; +import { WarningMessage } from '../general/WarningMessage'; export function BarVis({ config, @@ -29,7 +30,9 @@ export function BarVis({ showDownloadScreenshot={showDownloadScreenshot} /> ) : ( - + + {i18n.t('visyn:vis.missingColumn.barError')} + )}
); diff --git a/src/vis/bar/SingleEChartsBarChart.tsx b/src/vis/bar/SingleEChartsBarChart.tsx index bbceb1a7e..e71debca9 100644 --- a/src/vis/bar/SingleEChartsBarChart.tsx +++ b/src/vis/bar/SingleEChartsBarChart.tsx @@ -1,16 +1,28 @@ -import { Box } from '@mantine/core'; +import { Box, Stack, Text } 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 { BlurredOverlay } from '../../components'; +import { type ECOption, useChart } from '../../echarts'; +import { useAsync } from '../../hooks'; 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 { ErrorMessage } from '../general/ErrorMessage'; +import { WarningMessage } from '../general/WarningMessage'; +import { ColumnInfo, EAggregateTypes, ICommonVisProps } from '../interfaces'; 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'; +import { + AggregatedDataType, + BAR_WIDTH, + CHART_HEIGHT_MARGIN, + DEFAULT_BAR_CHART_HEIGHT, + GenerateAggregatedDataLookup, + SERIES_ZERO, + sortSeries, + WorkerWrapper, +} from './interfaces/internal'; function generateHTMLString({ label, value, color }: { label: string; value: string; color?: string }): string { return `
@@ -22,6 +34,13 @@ function generateHTMLString({ label, value, color }: { label: string; value: str
`; } +const numberFormatter = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 4, + maximumSignificantDigits: 4, + notation: 'compact', + compactDisplay: 'short', +}); + function EagerSingleEChartsBarChart({ aggregatedData, chartHeight, @@ -55,6 +74,12 @@ function EagerSingleEChartsBarChart({ selectedFacetValue?: string; selectionCallback: (e: React.MouseEvent, ids: string[]) => void; }) { + const generateBarSeriesWorker = React.useCallback( + async (...args: Parameters) => WorkerWrapper.generateBarSeries(...args), + [], + ); + const { execute: generateBarSeriesTrigger, status: generateBarSeriesStatus } = useAsync(generateBarSeriesWorker); + const [getSortMetadata] = useBarSortHelper({ config: config! }); const [visState, setVisState] = useSetState({ series: [] as BarSeriesOption[], xAxis: null as ECOption['xAxis'] | null, @@ -62,85 +87,8 @@ function EagerSingleEChartsBarChart({ }); 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( @@ -181,8 +129,8 @@ function EagerSingleEChartsBarChart({ return [...knownSeries, ...unknownSeries]; }, [groupColorScale, isGroupedByNumerical, visState.series]); - // prepare data - const barSeriesBase = React.useMemo( + // NOTE: @dv-usama-ansari: Prepare the base series options for the bar chart. + const seriesBase = React.useMemo( () => ({ type: 'bar', @@ -203,18 +151,21 @@ function EagerSingleEChartsBarChart({ axisPointer: { type: 'shadow', }, + // NOTE: @dv-usama-ansari: This function is a performance bottleneck. 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 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 + : aggregatedData?.groupingsList.length === 1 + ? (aggregatedData?.groupingsList[0] as string) + : params.name : sanitizedSeriesName; const color = sanitizedSeriesName === NAN_REPLACEMENT @@ -234,18 +185,8 @@ function EagerSingleEChartsBarChart({ } 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)); + const formattedMin = numberFormatter.format(Number(min)); + const formattedMax = numberFormatter.format(Number(max)); return generateHTMLString({ label, value: `${formattedMin} to ${formattedMax}`, color }); } return generateHTMLString({ label, value: params.value as string, color }); @@ -256,19 +197,40 @@ function EagerSingleEChartsBarChart({ })(); const aggregateString = generateHTMLString({ - label: config?.aggregateType === EAggregateTypes.COUNT ? config?.aggregateType : `${config?.aggregateType} of ${config?.aggregateColumn?.name}`, - value: params.value as string, + label: + config?.aggregateType === EAggregateTypes.COUNT + ? config?.display === EBarDisplayType.NORMALIZED + ? `Normalized ${config?.aggregateType}` + : config?.aggregateType + : `${config?.aggregateType} of ${config?.aggregateColumn?.name}`, + value: config?.display === EBarDisplayType.NORMALIZED ? `${params.value}%` : (params.value as string), }); + const nonNormalizedString = + config?.display === EBarDisplayType.NORMALIZED + ? generateHTMLString({ + label: config?.aggregateType, + value: + // NOTE: @dv-usama-ansari: Count is undefined for 100% bars, therefore we need to use a different approach + params?.value === 100 + ? String( + aggregatedData?.categories[params.name]?.groups[Object.keys(aggregatedData?.categories[params.name]?.groups ?? {})[0] as string] + ?.total, + ) + : (String(aggregatedData?.categories[params.name]?.groups[params.seriesName as string]?.total) ?? ''), + }) + : ''; + const categoryString = generateHTMLString({ label: config?.catColumnSelected?.name as string, value: params.name }); - const tooltipGrid = `
${categoryString}${aggregateString}${facetString}${groupString}
`; + const tooltipGrid = `
${categoryString}${nonNormalizedString}${aggregateString}${facetString}${groupString}
`; return tooltipGrid; }, }, label: { show: true, + // NOTE: @dv-usama-ansari: This function is a performance bottleneck. formatter: (params) => config?.group && config?.groupType === EBarGroupingType.STACK && config?.display === EBarDisplayType.NORMALIZED ? `${params.value}%` @@ -296,374 +258,111 @@ function EagerSingleEChartsBarChart({ config?.facets?.name, config?.facets?.id, config?.aggregateType, + config?.display, config?.aggregateColumn?.name, config?.groupType, - config?.display, selectedFacetValue, + aggregatedData?.categories, + aggregatedData?.groupingsList, 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, - }, + const optionBase = React.useMemo( + () => + ({ + animation: false, - 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, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow', }, - 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; + 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', }, - rotate: 45, }, - nameTextStyle: { - color: categoricalAxisSortText !== SortDirectionMap[EBarSortState.NONE] ? selectionColorDark : VIS_NEUTRAL_COLOR, - }, - triggerEvent: true, + ], + + 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! }, - 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, + 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) }, + })) + : [], + // NOTE: @dv-usama-ansari: This function is a performance bottleneck. + formatter: (name: string) => { + if (isGroupedByNumerical) { + if (name === NAN_REPLACEMENT && !name.includes(' to ')) { + return name; + } + const [min, max] = name.split(' to '); + const formattedMin = numberFormatter.format(Number(min)); + if (max) { + const formattedMax = numberFormatter.format(Number(max)); + return `${formattedMin} to ${formattedMax}`; + } + return formattedMin; + } + return name; }, - 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]); + }) as ECOption, + [ + config?.aggregateColumn?.name, + config?.aggregateType, + config?.catColumnSelected?.name, + config?.direction, + config?.facets, + config?.group, + containerWidth, + gridLeft, + groupColorScale, + groupSortedSeries, + isGroupedByNumerical, + selectedFacetValue, + ], + ); - // NOTE: @dv-usama-ansari: This effect is used to update the series data when the selected categorical column changes. - React.useEffect(() => { - updateCategoriesSideEffect(); - }, [updateCategoriesSideEffect]); + const options = React.useMemo( + () => + ({ + ...optionBase, + series: groupSortedSeries, + xAxis: visState.xAxis, + yAxis: visState.yAxis, + }) as ECOption, + [groupSortedSeries, optionBase, visState.xAxis, visState.yAxis], + ); const settings = React.useMemo( () => ({ @@ -695,8 +394,6 @@ function EagerSingleEChartsBarChart({ return { dom, content }; }, []); - const [getSortMetadata] = useBarSortHelper({ config: config! }); - const { setRef, instance } = useChart({ options, settings, @@ -867,21 +564,300 @@ function EagerSingleEChartsBarChart({ }, }); + const isLoading = React.useMemo( + () => (visState.series.length === 0 ? generateBarSeriesStatus === 'pending' : false), + [generateBarSeriesStatus, visState.series.length], + ); + const isError = React.useMemo(() => generateBarSeriesStatus === 'error', [generateBarSeriesStatus]); + const isSuccess = React.useMemo(() => visState.series.length > 0, [visState.series.length]); + + const updateSortSideEffect = React.useCallback( + ({ barSeries = [] }: { barSeries: (BarSeriesOption & { categories: string[] })[] }) => { + if (barSeries.length > 0 || !aggregatedData) { + if (config?.direction === EBarDirection.HORIZONTAL) { + const sortedSeries = sortSeries( + barSeries.map((item) => (item ? { categories: item.categories, data: item.data } : null)), + { 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 as NonNullable)].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) => (item ? { categories: item.categories, data: item.data } : null)), + { 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 }, + })); + } + } + }, + [aggregatedData, config?.direction, config?.sortState, setVisState], + ); + + const updateDirectionSideEffect = React.useCallback(() => { + if (visState.series.length === 0 || !aggregatedData) { + return; + } + 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) => numberFormatter.format(value), + }, + 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) => labelsMap[value], + }, + 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) => labelsMap[value], + 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) => numberFormatter.format(value), + }, + nameTextStyle: { + color: aggregationAxisSortText !== SortDirectionMap[EBarSortState.NONE] ? selectionColorDark : VIS_NEUTRAL_COLOR, + }, + triggerEvent: true, + }, + })); + } + }, [ + aggregatedData, + 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, + visState.series.length, + ]); + + const updateCategoriesSideEffect = React.useCallback(async () => { + if (aggregatedData) { + const result = await generateBarSeriesTrigger(aggregatedData, { + aggregateType: config?.aggregateType as EAggregateTypes, + display: config?.display as EBarDisplayType, + facets: config?.facets as ColumnInfo, + group: config?.group as ColumnInfo, + groupType: config?.groupType as EBarGroupingType, + }); + + const barSeries = result.map((series) => { + if (!series) { + return series; + } + const r = series as typeof series & { selected: 'selected' | 'unselected'; group: string }; + const isGrouped = config?.group && groupColorScale != null; + const isSelected = r.selected === 'selected'; + const shouldLowerOpacity = hasSelected && isGrouped && !isSelected; + const lowerBarOpacity = shouldLowerOpacity ? { opacity: VIS_UNSELECTED_OPACITY } : {}; + const fixLabelColor = shouldLowerOpacity ? { opacity: 0.5, color: DEFAULT_COLOR } : {}; + return { + ...seriesBase, + ...r, + label: { + ...seriesBase.label, + ...r.label, + ...fixLabelColor, + }, + large: true, + itemStyle: { + ...seriesBase.itemStyle, + ...r.itemStyle, + ...lowerBarOpacity, + color: + r.group === NAN_REPLACEMENT + ? isSelected + ? SELECT_COLOR + : VIS_NEUTRAL_COLOR + : isGrouped + ? groupColorScale(r.group) || VIS_NEUTRAL_COLOR + : VIS_NEUTRAL_COLOR, + }, + }; + }); + + updateSortSideEffect({ barSeries }); + updateDirectionSideEffect(); + } + }, [ + aggregatedData, + seriesBase, + config?.aggregateType, + config?.display, + config?.facets, + config?.group, + config?.groupType, + generateBarSeriesTrigger, + groupColorScale, + hasSelected, + updateDirectionSideEffect, + updateSortSideEffect, + ]); + + // 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(); + }, [config?.catColumnSelected?.id, selectedMap, updateCategoriesSideEffect]); + React.useEffect(() => { if (instance && instance.getDom() && !instance?.getDom()?.querySelector('#axis-tooltip')) { instance.getDom().appendChild(axisLabelTooltip.dom); } }, [axisLabelTooltip.dom, instance]); - return options ? ( - - ) : null; + ) : isError ? ( + + {config?.facets && selectedFacetValue ? {selectedFacetValue} : null} + Something went wrong setting up your chart. + + ) : ( + isSuccess && + (groupSortedSeries.length === 0 ? ( + config?.facets && selectedFacetValue ? ( + + {selectedFacetValue} + No data available for this facet. + + ) : ( + + No data available for this chart. Try a different configuration. + + ) + ) : !(visState.xAxis && visState.yAxis) ? null : ( + options && ( + + ) + )) + ); } export const SingleEChartsBarChart = React.memo(EagerSingleEChartsBarChart); diff --git a/src/vis/bar/interfaces/internal/helpers/bar-vis.worker-helper.ts b/src/vis/bar/interfaces/internal/helpers/bar-vis.worker-helper.ts new file mode 100644 index 000000000..13f84a63f --- /dev/null +++ b/src/vis/bar/interfaces/internal/helpers/bar-vis.worker-helper.ts @@ -0,0 +1,5 @@ +import * as ComLink from 'comlink'; +import type { GenerateAggregatedDataLookup } from './bar-vis.worker'; + +const worker = new Worker(new URL('./bar-vis.worker.ts', import.meta.url)); +export const WorkerWrapper = ComLink.wrap(worker); diff --git a/src/vis/bar/interfaces/internal/helpers/bar-vis.worker.ts b/src/vis/bar/interfaces/internal/helpers/bar-vis.worker.ts new file mode 100644 index 000000000..8b087bfa3 --- /dev/null +++ b/src/vis/bar/interfaces/internal/helpers/bar-vis.worker.ts @@ -0,0 +1,11 @@ +import * as ComLink from 'comlink'; +import { generateAggregatedDataLookup } from './generate-aggregated-data-lookup'; +import { generateBarSeries } from './generate-bar-series'; +import { generateDataTable } from './generate-data-table'; +import { getTruncatedTextMap } from './get-truncated-text-map'; + +const exposed = { generateAggregatedDataLookup, generateBarSeries, generateDataTable, getTruncatedTextMap }; + +export type GenerateAggregatedDataLookup = typeof exposed; + +ComLink.expose(exposed); diff --git a/src/vis/bar/interfaces/internal/helpers/create-bin-lookup.ts b/src/vis/bar/interfaces/internal/helpers/create-bin-lookup.ts index 652be8896..9aae1ea3d 100644 --- a/src/vis/bar/interfaces/internal/helpers/create-bin-lookup.ts +++ b/src/vis/bar/interfaces/internal/helpers/create-bin-lookup.ts @@ -1,7 +1,7 @@ import lodashMax from 'lodash/max'; import lodashMin from 'lodash/min'; import range from 'lodash/range'; -import { NAN_REPLACEMENT } from '../../../../general'; +import { NAN_REPLACEMENT } from '../../../../general/constants'; import { VisNumericalValue } from '../../../../interfaces'; function binValues(values: number[], numberOfBins: number) { @@ -30,10 +30,10 @@ function binValues(values: number[], numberOfBins: number) { * 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). + * @param binCount - 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 => { +export const createBinLookup = (data: VisNumericalValue[], binCount: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 = 8): Map => { // Separate null values from the data const nonNullData = data.filter((row) => row.val !== null); const nullData = data.filter((row) => row.val === null); @@ -42,7 +42,7 @@ export const createBinLookup = (data: VisNumericalValue[], maxBins: number = 8): const values = nonNullData.map((row) => row.val as number); // Create the bins using custom lodash function - const bins = binValues(values, maxBins); + const bins = binValues(values, Math.min(binCount, 8)); // Create a map to hold the bin names const binMap = new Map(); 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 index 3f70f8f2a..de0c0b2eb 100644 --- a/src/vis/bar/interfaces/internal/helpers/generate-aggregated-data-lookup.ts +++ b/src/vis/bar/interfaces/internal/helpers/generate-aggregated-data-lookup.ts @@ -2,7 +2,7 @@ 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 { NAN_REPLACEMENT } from '../../../../general/constants'; import { EAggregateTypes, ICommonVisProps } from '../../../../interfaces'; import { EBarDisplayType, EBarGroupingType } from '../../enums'; import { IBarConfig, IBarDataTableRow } from '../../interfaces'; @@ -30,6 +30,7 @@ export function generateAggregatedDataLookup( const groupingsList = sortedUniq(sort(facetSensitiveDataTable.map((item) => item.group ?? NAN_REPLACEMENT) ?? [])); (values ?? []).forEach((item) => { const { category = NAN_REPLACEMENT, agg, group = NAN_REPLACEMENT } = item; + const aggregate = [null, undefined, Infinity, -Infinity, NaN].includes(agg) ? 0 : agg; const selected = selectedMap?.[item.id] || false; if (!aggregated.facets[facet]) { aggregated.facets[facet] = { categoriesList, groupingsList, categories: {} }; @@ -55,13 +56,13 @@ export function generateAggregatedDataLookup( // 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.sum += aggregate || 0; + aggregated.facets[facet].categories[category].groups[group].selected.nums.push(aggregate || 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.sum += aggregate || 0; + aggregated.facets[facet].categories[category].groups[group].unselected.nums.push(aggregate || 0); aggregated.facets[facet].categories[category].groups[group].unselected.ids.push(item.id); } @@ -83,20 +84,20 @@ export function generateAggregatedDataLookup( if (selected) { minMax.facets[facet].categories[category].groups[group].selected.min = Math.min( minMax.facets[facet].categories[category].groups[group].selected.min, - agg || Infinity, + aggregate || Infinity, ); minMax.facets[facet].categories[category].groups[group].selected.max = Math.max( minMax.facets[facet].categories[category].groups[group].selected.max, - agg || -Infinity, + aggregate || -Infinity, ); } else { minMax.facets[facet].categories[category].groups[group].unselected.min = Math.min( minMax.facets[facet].categories[category].groups[group].unselected.min, - agg || Infinity, + aggregate || Infinity, ); minMax.facets[facet].categories[category].groups[group].unselected.max = Math.max( minMax.facets[facet].categories[category].groups[group].unselected.max, - agg || -Infinity, + aggregate || -Infinity, ); } }); diff --git a/src/vis/bar/interfaces/internal/helpers/generate-bar-series.test.ts b/src/vis/bar/interfaces/internal/helpers/generate-bar-series.test.ts new file mode 100644 index 000000000..1ce3d6b21 --- /dev/null +++ b/src/vis/bar/interfaces/internal/helpers/generate-bar-series.test.ts @@ -0,0 +1,23 @@ +import { defaultConfig } from '../../constants'; +import { generateBarSeries } from './generate-bar-series'; + +const config = { ...defaultConfig }; + +// TODO: @dv-usama-ansari: Add more test cases +describe('generateBarSeries', () => { + it('should return a series object for the given data', () => { + const data: Parameters['0'] = { + categories: {}, + categoriesList: [], + groupingsList: [], + }; + const series = generateBarSeries(data, { + aggregateType: config.aggregateType, + display: config.display, + facets: config.facets, + group: config.group, + groupType: config.groupType, + }); + expect(series).toBeInstanceOf(Array); + }); +}); diff --git a/src/vis/bar/interfaces/internal/helpers/generate-bar-series.ts b/src/vis/bar/interfaces/internal/helpers/generate-bar-series.ts new file mode 100644 index 000000000..30e48087a --- /dev/null +++ b/src/vis/bar/interfaces/internal/helpers/generate-bar-series.ts @@ -0,0 +1,55 @@ +import { BarSeriesOption } from 'echarts/charts'; +import { ColumnInfo, EAggregateTypes } from '../../../../interfaces'; +import { EBarDisplayType, EBarGroupingType } from '../../enums'; +import { AggregatedDataType } from '../types'; +import { getDataForAggregationType } from './get-data-for-aggregate-type'; + +export function generateBarSeries( + aggregatedData: AggregatedDataType, + config: { aggregateType: EAggregateTypes; display: EBarDisplayType; facets: ColumnInfo | null; group: ColumnInfo | null; groupType: EBarGroupingType }, +) { + return (aggregatedData?.groupingsList ?? []) + .map((g) => + (['selected', 'unselected'] as const).map((s) => { + const data = getDataForAggregationType( + aggregatedData, + { + aggregateType: config?.aggregateType as EAggregateTypes, + display: config?.display as EBarDisplayType, + group: config?.group as ColumnInfo, + groupType: config?.groupType as EBarGroupingType, + }, + 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, null, undefined].includes(d.value as number))) { + return null; + } + + return { + name: aggregatedData?.groupingsList.length > 1 ? g : null, + label: { + show: config?.group?.id === config?.facets?.id ? true : !(config?.group && config?.groupType === EBarGroupingType.STACK), + }, + emphasis: { + label: { + show: true, + }, + }, + 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() as (BarSeriesOption & { categories: string[]; group: string; selected: 'selected' | 'unselected' })[]; +} diff --git a/src/vis/bar/interfaces/internal/helpers/generate-data-table.ts b/src/vis/bar/interfaces/internal/helpers/generate-data-table.ts new file mode 100644 index 000000000..ea64ee76b --- /dev/null +++ b/src/vis/bar/interfaces/internal/helpers/generate-data-table.ts @@ -0,0 +1,28 @@ +import zipWith from 'lodash/zipWith'; +import { getLabelOrUnknown } from '../../../../general/utils'; +import { EColumnTypes, type VisNumericalValue } from '../../../../interfaces'; +import { createBinLookup } from './create-bin-lookup'; +import type { getBarData } from './get-bar-data'; + +export function generateDataTable(allColumns: Awaited>) { + // 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) => ({ + 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), + }), + ); +} diff --git a/src/vis/bar/interfaces/internal/helpers/get-data-for-aggregate-type.test.ts b/src/vis/bar/interfaces/internal/helpers/get-data-for-aggregate-type.test.ts new file mode 100644 index 000000000..13d72b6ad --- /dev/null +++ b/src/vis/bar/interfaces/internal/helpers/get-data-for-aggregate-type.test.ts @@ -0,0 +1,18 @@ +import { NAN_REPLACEMENT } from '../../../../general'; +import { defaultConfig } from '../../constants'; +import { getDataForAggregationType } from './get-data-for-aggregate-type'; + +const config = { ...defaultConfig }; + +// TODO: @dv-usama-ansari: Add more test cases +describe('getDataForAggregationType', () => { + it('should return an instance of an array', () => { + const aggregatedData: Parameters['0'] = { + categories: {}, + categoriesList: [], + groupingsList: [], + }; + const data = getDataForAggregationType(aggregatedData, config, NAN_REPLACEMENT, 'selected'); + expect(data).toBeInstanceOf(Array); + }); +}); diff --git a/src/vis/bar/interfaces/internal/helpers/get-data-for-aggregate-type.ts b/src/vis/bar/interfaces/internal/helpers/get-data-for-aggregate-type.ts new file mode 100644 index 000000000..f50e79be0 --- /dev/null +++ b/src/vis/bar/interfaces/internal/helpers/get-data-for-aggregate-type.ts @@ -0,0 +1,85 @@ +import { ColumnInfo, EAggregateTypes } from '../../../../interfaces'; +import { EBarGroupingType, EBarDisplayType } from '../../enums'; +import { AggregatedDataType } from '../types'; +import { median } from './median'; +import { normalizedValue } from './normalized-value'; + +/** + * Retrieves data for a specified aggregation type from the aggregated data. + * + * @param aggregatedData - The aggregated data object containing categories and their respective groups. + * @param config - Configuration object containing the aggregation type, display type, group information, and grouping type. + * @param config.aggregateType - The type of aggregation to be performed (e.g., Count, Average, Minimum, Maximum or Median). + * @param config.display - The display type for the bar chart (Absolute or Normalized). + * @param config.group - Information about the group column. + * @param config.groupType - The type of grouping to be applied (Group or Stack). + * @param group - The specific group to retrieve data for. + * @param selected - Indicates whether to retrieve data for 'selected' or 'unselected' items. + * @returns An array of objects containing the category and its corresponding value based on the specified aggregation type. + * Returns `null` if no aggregated data is available. + * @throws Will log a warning if the aggregation type is not supported or if no data is available. + */ +export function getDataForAggregationType( + aggregatedData: AggregatedDataType, + config: { aggregateType: EAggregateTypes; display: EBarDisplayType; group: ColumnInfo | null; groupType: EBarGroupingType }, + 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, aggregatedData.categories[category].groups[group][selected].count, aggregatedData.categories[category].total) + : 0, + category, + })); + + case EAggregateTypes.AVG: + return (aggregatedData.categoriesList ?? []).map((category) => ({ + value: aggregatedData.categories[category]?.groups[group]?.[selected] + ? normalizedValue( + config, + aggregatedData.categories[category].groups[group][selected].sum / (aggregatedData.categories[category].groups[group][selected].count || 1), + aggregatedData.categories[category].total, + ) + : 0, + category, + })); + + case EAggregateTypes.MIN: + return (aggregatedData.categoriesList ?? []).map((category) => ({ + value: aggregatedData.categories[category]?.groups[group]?.[selected] + ? normalizedValue(config, aggregatedData.categories[category].groups[group][selected].min, aggregatedData.categories[category].total) + : 0, + category, + })); + + case EAggregateTypes.MAX: + return (aggregatedData.categoriesList ?? []).map((category) => ({ + value: aggregatedData.categories[category]?.groups[group]?.[selected] + ? normalizedValue(config, aggregatedData.categories[category].groups[group][selected].max, aggregatedData.categories[category].total) + : 0, + category, + })); + + case EAggregateTypes.MED: + return (aggregatedData.categoriesList ?? []).map((category) => ({ + value: aggregatedData.categories[category]?.groups[group]?.[selected] + ? normalizedValue( + config, + median(aggregatedData.categories[category].groups[group][selected].nums) as number, + 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; +} diff --git a/src/vis/bar/interfaces/internal/helpers/get-truncated-text-map.ts b/src/vis/bar/interfaces/internal/helpers/get-truncated-text-map.ts new file mode 100644 index 000000000..f085c5f2d --- /dev/null +++ b/src/vis/bar/interfaces/internal/helpers/get-truncated-text-map.ts @@ -0,0 +1,38 @@ +export function getTruncatedTextMap(labels: string[], maxWidth: number): { map: Record; longestLabelWidth: number } { + const map: Record = {}; + let longestLabelWidth = 0; + + const canvas = new OffscreenCanvas(0, 0); + const ctx = canvas.getContext('2d'); + + if (ctx) { + ctx.font = '16px Arial'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + + const ellipsis = '...'; + const ellipsisWidth = ctx.measureText(ellipsis).width; + + labels.forEach((value) => { + const renderedTextWidth = ctx.measureText(value).width; + longestLabelWidth = Math.max(longestLabelWidth, renderedTextWidth); + let truncatedText = ''; + let currentWidth = 0; + for (let i = 0; i < value.length; i++) { + const char = value[i]; + const charWidth = ctx.measureText(char as string).width; + + if (currentWidth + charWidth + ellipsisWidth > maxWidth) { + truncatedText += ellipsis; + break; + } else { + truncatedText += char; + currentWidth += charWidth; + } + } + map[value] = truncatedText; + }); + } + + return { map, longestLabelWidth }; +} diff --git a/src/vis/bar/interfaces/internal/helpers/index.ts b/src/vis/bar/interfaces/internal/helpers/index.ts index 1d5aad182..e115047e9 100644 --- a/src/vis/bar/interfaces/internal/helpers/index.ts +++ b/src/vis/bar/interfaces/internal/helpers/index.ts @@ -1,7 +1,13 @@ +export * from './bar-vis.worker'; +export * from './bar-vis.worker-helper'; export * from './calculate-chart-dimensions'; export * from './create-bin-lookup'; export * from './generate-aggregated-data-lookup'; +export * from './generate-bar-series'; +export * from './generate-data-table'; +export * from './get-truncated-text-map'; export * from './get-bar-data'; +export * from './get-data-for-aggregate-type'; export * from './median'; export * from './normalized-value'; export * from './sort-series'; diff --git a/src/vis/bar/interfaces/internal/helpers/normalized-value.test.ts b/src/vis/bar/interfaces/internal/helpers/normalized-value.test.ts index a2fedb9a4..556689e89 100644 --- a/src/vis/bar/interfaces/internal/helpers/normalized-value.test.ts +++ b/src/vis/bar/interfaces/internal/helpers/normalized-value.test.ts @@ -5,25 +5,25 @@ 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); + expect(Number.isNaN(Number(normalizedValue(config, 10, 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); + expect(normalizedValue(config, 10, 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, - }), + normalizedValue( + { ...config, group: { id: '', name: '', description: '' }, groupType: EBarGroupingType.STACK, display: EBarDisplayType.NORMALIZED }, + 10, + 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); + expect(normalizedValue(config, Infinity, 100)).toBe(null); + expect(normalizedValue(config, -Infinity, 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 index a2676aea2..1724b05df 100644 --- a/src/vis/bar/interfaces/internal/helpers/normalized-value.ts +++ b/src/vis/bar/interfaces/internal/helpers/normalized-value.ts @@ -1,6 +1,6 @@ import round from 'lodash/round'; -import { EBarGroupingType, EBarDisplayType } from '../../enums'; -import { IBarConfig } from '../../interfaces'; +import { ColumnInfo } from '../../../../interfaces'; +import { EBarDisplayType, EBarGroupingType } from '../../enums'; /** * Calculates and returns the rounded absolute or normalized value, dependending on the config value. @@ -10,12 +10,16 @@ import { IBarConfig } from '../../interfaces'; * @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 }) { +export function normalizedValue( + config: { group: ColumnInfo | null; groupType: EBarGroupingType; display: EBarDisplayType }, + value: number, + total: number, +): number | null { // 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)) { + if ([Infinity, -Infinity, NaN, null, undefined].includes(value)) { return null; } - return config?.group && config?.groupType === EBarGroupingType.STACK && config?.display === EBarDisplayType.NORMALIZED + 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.ts b/src/vis/bar/interfaces/internal/helpers/sort-series.ts index b9001e4a6..394e536d6 100644 --- a/src/vis/bar/interfaces/internal/helpers/sort-series.ts +++ b/src/vis/bar/interfaces/internal/helpers/sort-series.ts @@ -40,19 +40,22 @@ import { EBarSortState, EBarDirection } from '../../enums'; * @returns */ export function sortSeries( - series: { categories: string[]; data: BarSeriesOption['data'] }[], + series: ({ categories: string[]; data: BarSeriesOption['data'] } | null)[], sortMetadata: { sortState: { x: EBarSortState; y: EBarSortState }; direction: EBarDirection } = { sortState: { x: EBarSortState.NONE, y: EBarSortState.NONE }, direction: EBarDirection.HORIZONTAL, }, -): { categories: string[]; data: BarSeriesOption['data'] }[] { +): ({ categories: string[]; data: BarSeriesOption['data'] } | null)[] { + if (!series) { + return []; + } // 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; + 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 { @@ -148,9 +151,9 @@ export function sortSeries( 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++) { + 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]; + sortedData[categoryIndexMap[s?.categories?.[i] as string] as number] = s?.data?.[i]; } sortedSeries.push({ ...s, diff --git a/src/vis/correlation/CorrelationVis.tsx b/src/vis/correlation/CorrelationVis.tsx index 59adfe6e6..a558d86ba 100644 --- a/src/vis/correlation/CorrelationVis.tsx +++ b/src/vis/correlation/CorrelationVis.tsx @@ -1,13 +1,16 @@ import * as React from 'react'; -import { InvalidCols } from '../general/InvalidCols'; import { ICommonVisProps } from '../interfaces'; import { CorrelationMatrix } from './CorrelationMatrix'; import { ICorrelationConfig } from './interfaces'; +import { i18n } from '../../i18n'; +import { WarningMessage } from '../general/WarningMessage'; export function CorrelationVis({ config, columns, showDownloadScreenshot, uniquePlotId }: ICommonVisProps) { return config.numColumnsSelected.length > 1 ? ( ) : ( - + + {i18n.t('visyn:vis.missingColumn.correlationError')} + ); } diff --git a/src/vis/general/ErrorMessage.tsx b/src/vis/general/ErrorMessage.tsx new file mode 100644 index 000000000..47a7f86fe --- /dev/null +++ b/src/vis/general/ErrorMessage.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Alert, Center, Stack } from '@mantine/core'; + +function Message({ + title, + children, + alertProps, + dataTestId, +}: { + title?: React.ReactNode; + children: React.ReactNode; + alertProps?: React.ComponentProps; + dataTestId?: string; +}) { + return ( + } {...alertProps} data-test-id={dataTestId}> + {children} + + ); +} + +export function ErrorMessage({ + title, + children, + alertProps, + dataTestId, + centered, + style, +}: { + /** + * Optional title for the message. + */ + title?: React.ReactNode; + /** + * The content of the message. + */ + children: React.ReactNode; + /** + * Props for the Mantine Alert component. + */ + alertProps?: React.ComponentProps; + /** + * data-testid attribute for testing. + */ + dataTestId?: string; + /** + * If true, the message will be centered in the parent container. + */ + centered?: boolean; + /** + * If centered is true, style object for the container. + */ + style?: React.CSSProperties; +}) { + return centered ? ( + +
+ + {children} + +
+
+ ) : ( + + {children} + + ); +} diff --git a/src/vis/general/InvalidCols.tsx b/src/vis/general/InvalidCols.tsx deleted file mode 100644 index 41b185c2a..000000000 --- a/src/vis/general/InvalidCols.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Alert, Center, Stack } from '@mantine/core'; -import * as React from 'react'; - -export function InvalidCols({ headerMessage, bodyMessage, style }: { headerMessage: string; bodyMessage: string; style?: React.CSSProperties }) { - return ( - -
- - {bodyMessage} - -
-
- ); -} diff --git a/src/vis/general/WarningMessage.tsx b/src/vis/general/WarningMessage.tsx new file mode 100644 index 000000000..447c2f885 --- /dev/null +++ b/src/vis/general/WarningMessage.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Alert, Center, Stack } from '@mantine/core'; + +function Message({ + title, + children, + alertProps, + dataTestId, +}: { + title?: React.ReactNode; + children: React.ReactNode; + alertProps?: React.ComponentProps; + dataTestId?: string; +}) { + return ( + } {...alertProps} data-test-id={dataTestId}> + {children} + + ); +} + +export function WarningMessage({ + title, + children, + alertProps, + dataTestId, + centered, + style, +}: { + /** + * Optional title for the message. + */ + title?: React.ReactNode; + /** + * The content of the message. + */ + children: React.ReactNode; + /** + * Props for the Mantine Alert component. + */ + alertProps?: React.ComponentProps; + /** + * data-testid attribute for testing. + */ + dataTestId?: string; + /** + * If true, the message will be centered in the parent container. + */ + centered?: boolean; + /** + * If centered is true, style object for the container. + */ + style?: React.CSSProperties; +}) { + return centered ? ( + +
+ + {children} + +
+
+ ) : ( + + {children} + + ); +} diff --git a/src/vis/general/index.ts b/src/vis/general/index.ts index 83a4f3f0c..1719e64f0 100644 --- a/src/vis/general/index.ts +++ b/src/vis/general/index.ts @@ -1,4 +1,5 @@ -export * from './InvalidCols'; +export * from './WarningMessage'; +export * from './ErrorMessage'; export * from './layoutUtils'; export * from './constants'; export * from './SortIcon'; diff --git a/src/vis/heatmap/HeatmapGrid.tsx b/src/vis/heatmap/HeatmapGrid.tsx index e31b297cc..9a7987f11 100644 --- a/src/vis/heatmap/HeatmapGrid.tsx +++ b/src/vis/heatmap/HeatmapGrid.tsx @@ -2,13 +2,14 @@ import { Center, Loader, Stack } from '@mantine/core'; import * as React from 'react'; import { uniqueId } from 'lodash'; import { useAsync } from '../../hooks/useAsync'; -import { InvalidCols } from '../general/InvalidCols'; import { VisColumn } from '../interfaces'; import { Heatmap } from './Heatmap'; import { IHeatmapConfig } from './interfaces'; import { getHeatmapData } from './utils'; import { DownloadPlotButton } from '../general/DownloadPlotButton'; +import { i18n } from '../../i18n'; +import { WarningMessage } from '../general/WarningMessage'; export function HeatmapGrid({ config, @@ -64,7 +65,9 @@ export function HeatmapGrid({ )} {status === 'success' && !hasAtLeast2CatCols && ( - + + {i18n.t('visyn:vis.missingColumn.heatmapError')} + )}
); diff --git a/src/vis/hexbin/HexbinVis.tsx b/src/vis/hexbin/HexbinVis.tsx index 99c31c86d..203e27f87 100644 --- a/src/vis/hexbin/HexbinVis.tsx +++ b/src/vis/hexbin/HexbinVis.tsx @@ -5,15 +5,14 @@ import * as React from 'react'; import { css } from '@emotion/css'; import { useAsync } from '../../hooks/useAsync'; import { i18n } from '../../i18n'; -import { InvalidCols } from '../general'; import { EScatterSelectSettings, ICommonVisProps } from '../interfaces'; import { BrushOptionButtons } from '../sidebar'; import { Hexplot } from './Hexplot'; import { IHexbinConfig } from './interfaces'; import { getHexData } from './utils'; import { LegendItem } from '../general/LegendItem'; -import { DownloadPlotButton } from '../general/DownloadPlotButton'; import { assignColorToNullValues } from '../general/utils'; +import { WarningMessage } from '../general/WarningMessage'; function Legend({ categories, @@ -152,7 +151,9 @@ export function HexbinVis({ }} > {config.numColumnsSelected.length < 2 ? ( - + + {i18n.t('visyn:vis.missingColumn.hexbinError')} + ) : null} {config.numColumnsSelected.length === 2 && allColumns?.numColVals.length === config.numColumnsSelected.length && colsStatus === 'success' ? ( diff --git a/src/vis/interfaces.ts b/src/vis/interfaces.ts index d9065c2f1..766798bb1 100644 --- a/src/vis/interfaces.ts +++ b/src/vis/interfaces.ts @@ -70,7 +70,7 @@ export interface IVisCommonValue { /** * Value of a vis column. */ - val: Type | null; + val: Type | null | undefined; } export type VisNumericalValue = IVisCommonValue; diff --git a/src/vis/sankey/SankeyVis.tsx b/src/vis/sankey/SankeyVis.tsx index 9112ccaf0..0bc505995 100644 --- a/src/vis/sankey/SankeyVis.tsx +++ b/src/vis/sankey/SankeyVis.tsx @@ -6,11 +6,12 @@ import { useAsync } from '../../hooks/useAsync'; import { PlotlyComponent } from '../../plotly'; import { selectionColorDark } from '../../utils'; import { DownloadPlotButton } from '../general/DownloadPlotButton'; -import { InvalidCols } from '../general/InvalidCols'; import { NAN_REPLACEMENT, VIS_NEUTRAL_COLOR, VIS_UNSELECTED_COLOR } from '../general/constants'; import { resolveColumnValues } from '../general/layoutUtils'; import { ICommonVisProps, VisCategoricalColumn, VisColumn } from '../interfaces'; import { ISankeyConfig } from './interfaces'; +import { i18n } from '../../i18n'; +import { WarningMessage } from '../general/WarningMessage'; /** * Performs the data transformation that maps the fetched data to @@ -252,7 +253,9 @@ export function SankeyVis({ /> ) : (
- + + {i18n.t('visyn:vis.missingColumn.sankeyError')} +
)} diff --git a/src/vis/scatter/ScatterVis.tsx b/src/vis/scatter/ScatterVis.tsx index 95934404c..c7b53220b 100644 --- a/src/vis/scatter/ScatterVis.tsx +++ b/src/vis/scatter/ScatterVis.tsx @@ -1,27 +1,27 @@ /* eslint-disable react-compiler/react-compiler */ +import { css } from '@emotion/css'; +import { Center, Group, ScrollArea, Stack, Switch, Tooltip } from '@mantine/core'; import { useElementSize, useWindowEvent } from '@mantine/hooks'; -import { Center, Group, Stack, Switch, Tooltip, ScrollArea } from '@mantine/core'; -import * as React from 'react'; +import * as d3v7 from 'd3v7'; import cloneDeep from 'lodash/cloneDeep'; import uniq from 'lodash/uniq'; -import * as d3v7 from 'd3v7'; -import { css } from '@emotion/css'; +import * as React from 'react'; import { useAsync } from '../../hooks'; +import { i18n } from '../../i18n/I18nextManager'; import { PlotlyComponent, PlotlyTypes } from '../../plotly'; +import { categoricalColors10, getCssValue } from '../../utils'; import { DownloadPlotButton } from '../general/DownloadPlotButton'; +import { LegendItem } from '../general/LegendItem'; +import { WarningMessage } from '../general/WarningMessage'; import { VIS_NEUTRAL_COLOR } from '../general/constants'; import { EColumnTypes, ENumericalColorScaleType, EScatterSelectSettings, ICommonVisProps } from '../interfaces'; import { BrushOptionButtons } from '../sidebar/BrushOptionButtons'; -import { ERegressionLineType, IInternalScatterConfig, IRegressionResult } from './interfaces'; -import { defaultRegressionLineStyle, fetchColumnData, regressionToAnnotation } from './utils'; import { fitRegressionLine } from './Regression'; +import { ERegressionLineType, IInternalScatterConfig, IRegressionResult } from './interfaces'; +import { useData } from './useData'; import { useDataPreparation } from './useDataPreparation'; -import { InvalidCols } from '../general/InvalidCols'; -import { i18n } from '../../i18n/I18nextManager'; -import { LegendItem } from '../general/LegendItem'; import { useLayout } from './useLayout'; -import { useData } from './useData'; -import { categoricalColors, getCssValue } from '../../utils'; +import { defaultRegressionLineStyle, fetchColumnData, regressionToAnnotation } from './utils'; function Legend({ categories, colorMap, onClick }: { categories: string[]; colorMap: (v: number | string) => string; onClick: (string) => void }) { return ( @@ -348,7 +348,7 @@ export function ScatterVis({ } else { // Create d3 color scale valuesWithoutUnknown.forEach((v, i) => { - mapping[v] = categoricalColors[i % categoricalColors.length]!; + mapping[v] = categoricalColors10[i % categoricalColors10.length]!; }); mapping.Unknown = VIS_NEUTRAL_COLOR; } @@ -547,13 +547,16 @@ export function ScatterVis({ config={{ scrollZoom, displayModeBar: false }} /> ) : status !== 'idle' && status !== 'pending' ? ( - + > + {error?.message || i18n.t('visyn:vis.missingColumn.scatterError')} + ) : null} diff --git a/src/vis/scatter/useData.tsx b/src/vis/scatter/useData.tsx index dd238d43e..fe1219fae 100644 --- a/src/vis/scatter/useData.tsx +++ b/src/vis/scatter/useData.tsx @@ -196,12 +196,6 @@ export function useData({ } if (splom && value.validColumns[0]) { - // SPLOM case - const plotlyDimensions = value.validColumns.map((col) => ({ - label: col.info.name, - values: col.resolvedValues.map((v) => v.val), - })); - const traces = [ { ...BASE_DATA, @@ -212,7 +206,7 @@ export function useData({ }, showupperhalf: false, // @ts-ignore - dimensions: plotlyDimensions, + dimensions: splom.dimensions, hovertext: value.validColumns[0].resolvedValues.map((v, i) => `${value.idToLabelMapper(v.id)} ${(value.resolvedLabelColumns ?? []).map((l) => `
${columnNameWithDescription(l.info)}: ${getLabelOrUnknown(l.resolvedValues[i]?.val)}`)} diff --git a/src/vis/scatter/useDataPreparation.ts b/src/vis/scatter/useDataPreparation.ts index 33c6eb918..112259ba0 100644 --- a/src/vis/scatter/useDataPreparation.ts +++ b/src/vis/scatter/useDataPreparation.ts @@ -116,7 +116,7 @@ export function useDataPreparation({ } const plotlyDimensions = value.validColumns.map((col) => ({ - label: col.info.name, + label: columnNameWithDescription(col.info), values: col.resolvedValues.map((v) => v.val), })); diff --git a/src/vis/stories/Vis/Bar/BarRandom.stories.tsx b/src/vis/stories/Vis/Bar/BarRandom.stories.tsx index b29c0866c..a6a70676b 100644 --- a/src/vis/stories/Vis/Bar/BarRandom.stories.tsx +++ b/src/vis/stories/Vis/Bar/BarRandom.stories.tsx @@ -1,7 +1,7 @@ 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 { BaseVisConfig, EAggregateTypes, EColumnTypes, ESupportedPlotlyVis, VisCategoricalColumn, VisColumn } from '../../../interfaces'; import { Vis } from '../../../LazyVis'; import { VisProvider } from '../../../Provider'; @@ -38,15 +38,22 @@ function fetchData(numberOfPoints: number): VisColumn[] { singleNumber: Array(numberOfPoints) .fill(null) .map(() => RNG(numberOfPoints, 'mixed')()), + badNumbers: Array(numberOfPoints) + .fill(null) + .map(() => [null, undefined, Infinity, -Infinity, NaN][parseInt((positiveRNG() * numberOfPoints).toString(), 10) % 5]), categories: Array(numberOfPoints) .fill(null) .map(() => `CATEGORY_${parseInt((positiveRNG() * 10).toString(), 10).toString()}`), - manyCategories: Array(numberOfPoints) + manyCategoriesWithBadValues: Array(numberOfPoints) .fill(null) - .map(() => `MANY_CATEGORIES_${parseInt((positiveRNG() * 100).toString(), 10).toString()}`), + .map((_, i) => + parseInt((RNG(i)() * numberOfPoints).toString(), 10) % 135 + ? `MANY_CATEGORIES_${parseInt((positiveRNG() * 150).toString(), 10).toString()}` + : [null, undefined][parseInt((RNG(i)() * numberOfPoints).toString(), 10) % 2], + ), twoCategories: Array(numberOfPoints) .fill(null) - .map((_, i) => `${parseInt((RNG(i)() * numberOfPoints).toString(), 10) % 3 ? 'EVEN' : 'ODD'}_CATEGORY`), + .map((_, i) => `${parseInt((RNG(i)() * numberOfPoints).toString(), 10) % 3 ? 'EVEN' : 'ODD'}_CATEGORY_WITH_LONG_LABELS_WHICH_SHOULD_BE_TRUNCATED`), categoriesAsNumberOfPoints: Array(numberOfPoints) .fill(null) .map((_, i) => `DATA_CATEGORY_${i}`), @@ -112,6 +119,19 @@ function fetchData(numberOfPoints: number): VisColumn[] { return data.singleNumber.map((val, i) => ({ id: i.toString(), val })); }, }, + { + info: { + description: 'Bad numbers like null, undefined, Infinity, -Infinity, NaN', + id: 'badNumbers', + name: 'Bad numbers', + }, + type: EColumnTypes.NUMERICAL, + domain: [undefined, undefined], + values: async () => { + const data = await dataPromise; + return data.badNumbers.map((val, i) => ({ id: i.toString(), val })); + }, + }, { info: { description: 'Categories for the data', @@ -126,14 +146,14 @@ function fetchData(numberOfPoints: number): VisColumn[] { }, { info: { - description: 'Many categories for the data', - id: 'manyCategories', - name: 'Many categories', + description: 'Many categories for the data having some bad values', + id: 'manyCategoriesWithBadValues', + name: 'Many categories with bad values', }, type: EColumnTypes.CATEGORICAL, values: async () => { const data = await dataPromise; - return data.manyCategories.map((val, i) => ({ id: i.toString(), val })); + return data.manyCategoriesWithBadValues.map((val, i) => ({ id: i.toString(), val })); }, }, { @@ -278,9 +298,9 @@ Grouped.args = { }, facets: null, group: { - description: 'Many categories for the data', - id: 'manyCategories', - name: 'Many categories', + description: 'Many categories for the data having some bad values', + id: 'manyCategoriesWithBadValues', + name: 'Many categories with bad values', }, groupType: EBarGroupingType.GROUP, direction: EBarDirection.HORIZONTAL, @@ -302,9 +322,9 @@ GroupedStack.args = { }, facets: null, group: { - description: 'Many categories for the data', - id: 'manyCategories', - name: 'Many categories', + description: 'Many categories for the data having some bad values', + id: 'manyCategoriesWithBadValues', + name: 'Many categories with bad values', }, groupType: EBarGroupingType.STACK, direction: EBarDirection.HORIZONTAL, @@ -315,6 +335,30 @@ GroupedStack.args = { } as BaseVisConfig, }; +export const GroupedStackNormalized: typeof Template = Template.bind({}) as typeof Template; +GroupedStackNormalized.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.NORMALIZED, + aggregateType: EAggregateTypes.COUNT, + aggregateColumn: null, + numColumnsSelected: [], + } as BaseVisConfig, +}; + export const GroupedNumerical: typeof Template = Template.bind({}) as typeof Template; GroupedNumerical.args = { externalConfig: { @@ -397,9 +441,9 @@ facets.args = { name: 'Categories', }, facets: { - description: 'Many categories for the data', - id: 'manyCategories', - name: 'Many categories', + description: 'Many categories for the data having some bad values', + id: 'manyCategoriesWithBadValues', + name: 'Many categories with bad values', }, group: null, groupType: EBarGroupingType.GROUP, @@ -421,9 +465,9 @@ facetsAndGrouped.args = { name: 'Categories', }, facets: { - description: 'Many categories for the data', - id: 'manyCategories', - name: 'Many categories', + description: 'Many categories for the data having some bad values', + id: 'manyCategoriesWithBadValues', + name: 'Many categories with bad values', }, group: { description: 'Random numbers generated for the data point. May be positive or negative or zero', @@ -449,9 +493,9 @@ facetsAndGroupedStack.args = { name: 'Categories', }, facets: { - description: 'Many categories for the data', - id: 'manyCategories', - name: 'Many categories', + description: 'Many categories for the data having some bad values', + id: 'manyCategoriesWithBadValues', + name: 'Many categories with bad values', }, group: { description: 'Random numbers generated for the data point. May be positive or negative or zero', @@ -553,9 +597,9 @@ AggregateMedianWithGroupedAndFacetedMixedValues.args = { name: 'Categories', }, facets: { - description: 'Many categories for the data', - id: 'manyCategories', - name: 'Many categories', + description: 'Many categories for the data having some bad values', + id: 'manyCategoriesWithBadValues', + name: 'Many categories with bad values', }, group: { description: 'Random numbers generated for the data point. May be positive or negative or zero', diff --git a/src/vis/violin/ViolinVis.tsx b/src/vis/violin/ViolinVis.tsx index b9094d69e..b32f8b777 100644 --- a/src/vis/violin/ViolinVis.tsx +++ b/src/vis/violin/ViolinVis.tsx @@ -3,7 +3,6 @@ import uniqueId from 'lodash/uniqueId'; import React, { useEffect, useMemo, useState } from 'react'; import { useAsync } from '../../hooks'; import { PlotlyComponent, PlotlyTypes } from '../../plotly'; -import { InvalidCols } from '../general'; import { DownloadPlotButton } from '../general/DownloadPlotButton'; import { ESortStates, createPlotlySortIcon } from '../general/SortIcon'; import { beautifyLayout } from '../general/layoutUtils'; @@ -11,6 +10,7 @@ import { ESupportedPlotlyVis, ICommonVisProps } from '../interfaces'; import { EViolinOverlay, EYAxisMode, IViolinConfig } from './interfaces'; import { createViolinTraces } from './utils'; import { IBoxplotConfig } from '../boxplot'; +import { WarningMessage } from '../general/WarningMessage'; export function ViolinVis({ config, @@ -187,7 +187,9 @@ export function ViolinVis({ /> ) : traceStatus !== 'pending' && traceStatus !== 'idle' && layout ? ( - + + {traceError?.message || traces?.errorMessage} + ) : null} ); diff --git a/src/vis/violin/utils.ts b/src/vis/violin/utils.ts index c1f26ddbf..442fa897e 100644 --- a/src/vis/violin/utils.ts +++ b/src/vis/violin/utils.ts @@ -1,6 +1,6 @@ import { cloneDeep, groupBy, mean, merge, orderBy, sumBy } from 'lodash'; import { i18n } from '../../i18n'; -import { categoricalColors } from '../../utils'; +import { categoricalColors10 } from '../../utils'; import { IBoxplotConfig } from '../boxplot'; import { ESortStates } from '../general/SortIcon'; import { NAN_REPLACEMENT, SELECT_COLOR, VIS_NEUTRAL_COLOR, VIS_UNSELECTED_OPACITY } from '../general/constants'; @@ -89,8 +89,8 @@ export async function createViolinTraces( violinMode: 'overlay', hasSplit: false, categoryOrder: null, - errorMessage: i18n.t('visyn:vis.violinError'), - errorMessageHeader: i18n.t('visyn:vis.errorHeader'), + errorMessage: i18n.t('visyn:vis.missingColumn.violinError'), + errorMessageHeader: i18n.t('visyn:vis.missingColumn.errorHeader'), }; } @@ -154,7 +154,7 @@ export async function createViolinTraces( if (!subCatMap[subCatVal]) { subCatMap[subCatVal] = { - color: subCatVal === NAN_REPLACEMENT ? VIS_NEUTRAL_COLOR : categoricalColors[Object.keys(subCatMap).length % categoricalColors.length], + color: subCatVal === NAN_REPLACEMENT ? VIS_NEUTRAL_COLOR : categoricalColors10[Object.keys(subCatMap).length % categoricalColors10.length], idx: Object.keys(subCatMap).length, }; } @@ -186,7 +186,7 @@ export async function createViolinTraces( if (!subCatMap[subCatVal]) { subCatMap[subCatVal] = { - color: subCatVal === NAN_REPLACEMENT ? VIS_NEUTRAL_COLOR : categoricalColors[Object.keys(subCatMap).length % categoricalColors.length], + color: subCatVal === NAN_REPLACEMENT ? VIS_NEUTRAL_COLOR : categoricalColors10[Object.keys(subCatMap).length % categoricalColors10.length], idx: Object.keys(subCatMap).length, }; } @@ -229,7 +229,7 @@ export async function createViolinTraces( // Create subcategory map for coloring and legend [...subCatOrder].forEach((v, i) => { - subCatMap[v] = { color: v === NAN_REPLACEMENT ? VIS_NEUTRAL_COLOR : categoricalColors[i % categoricalColors.length], idx: i }; + subCatMap[v] = { color: v === NAN_REPLACEMENT ? VIS_NEUTRAL_COLOR : categoricalColors10[i % categoricalColors10.length], idx: i }; }); // Only allow split mode if there are exactly two subcategories and the plot type is violin @@ -451,8 +451,8 @@ export async function createViolinTraces( rows, cols, categoryOrder: categoriesPerPlot, - errorMessage: i18n.t('visyn:vis.violinError'), - errorMessageHeader: i18n.t('visyn:vis.errorHeader'), + errorMessage: isViolinConfig(config) ? i18n.t('visyn:vis.missingColumn.violinError') : i18n.t('visyn:vis.missingColumn.boxplotError'), + errorMessageHeader: i18n.t('visyn:vis.missingColumn.errorHeader'), violinMode: (numColsAsSubcat || subCatCol) && !hasSplit ? 'group' : 'overlay', hasSplit, };