diff --git a/package.json b/package.json index efaa09019..8ee8398cc 100644 --- a/package.json +++ b/package.json @@ -118,12 +118,11 @@ "react-highlight-words": "^0.20.0", "react-plotly.js": "^2.6.0", "react-spring": "^9.7.4", + "react-window": "^1.8.10", "use-deep-compare-effect": "^1.8.1", - "visyn_scripts": "^11.1.0", - "react-window": "^1.8.10" + "visyn_scripts": "^11.1.0" }, "devDependencies": { - "@types/react-window": "^1.8.8", "@chromatic-com/playwright": "^0.6.18", "@chromatic-com/storybook": "^1.8.0", "@playwright/test": "^1.45.2", @@ -137,6 +136,7 @@ "@storybook/react": "^7.6.20", "@storybook/react-webpack5": "^7.6.20", "@storybook/testing-library": "0.2.2", + "@types/react-window": "^1.8.8", "chromatic": "^11.7.1", "storybook": "^7.6.20", "storybook-addon-swc": "^1.2.0" diff --git a/src/demo/MainApp.tsx b/src/demo/MainApp.tsx index 4e2f94362..8922e1446 100644 --- a/src/demo/MainApp.tsx +++ b/src/demo/MainApp.tsx @@ -15,16 +15,17 @@ import { IBarConfig, Vis, } from '../vis'; -import { breastCancerData } from '../vis/stories/breastCancerData'; -import { fetchBreastCancerData } from '../vis/stories/fetchBreastCancerData'; import { MyCategoricalScore, MyLinkScore, MyNumberScore, MySMILESScore, MyStringScore } from './scoresUtils'; +import { explodedData, fetchExplodedData } from '../vis/stories/explodedData'; export function MainApp() { const { user } = useVisynAppContext(); - const [visConfig, setVisConfig] = React.useState({ - type: ESupportedPlotlyVis.BAR, - numColumnsSelected: [], - catColumnSelected: { + const [visConfig, setVisConfig] = React.useState( + () => + ({ + type: ESupportedPlotlyVis.BAR, + numColumnsSelected: [], + /* catColumnSelected: { description: null, id: 'cellularity', name: 'Cellularity', @@ -33,34 +34,47 @@ export function MainApp() { description: 'some very long description', id: 'breastSurgeryType', name: 'Breast Surgery Type', - }, - groupType: EBarGroupingType.STACK, - facets: { + }, */ + catColumnSelected: { + description: 'some very long description', + id: 'categorical2', + name: 'Categorical 2', + }, + group: null, + groupType: EBarGroupingType.STACK, + /* facets: { description: 'some very long description', id: 'tumorOtherHistologicSubtype', name: 'Tumor Other Histologic Subtype', - }, - focusFacetIndex: null, - display: EBarDisplayType.ABSOLUTE, - direction: EBarDirection.HORIZONTAL, - aggregateColumn: { + }, */ + facets: { + description: 'some very long description', + id: 'categorical1', + name: 'Categorical 1', + }, + focusFacetIndex: null, + display: EBarDisplayType.ABSOLUTE, + direction: EBarDirection.HORIZONTAL, + /* aggregateColumn: { description: 'some very long description', id: 'tumorStage', name: 'Tumor Stage', - }, - aggregateType: EAggregateTypes.COUNT, - showFocusFacetSelector: false, - sortState: { - x: EBarSortState.DESCENDING, - y: EBarSortState.NONE, - }, - numColorScaleType: ENumericalColorScaleType.SEQUENTIAL, - merged: true, - } as IBarConfig); - const columns = React.useMemo(() => (user ? fetchBreastCancerData() : []), [user]); - const [selection, setSelection] = React.useState([]); + }, */ + aggregateColumn: null, + aggregateType: EAggregateTypes.COUNT, + showFocusFacetSelector: false, + sortState: { + x: EBarSortState.DESCENDING, + y: EBarSortState.NONE, + }, + numColorScaleType: ENumericalColorScaleType.SEQUENTIAL, + merged: true, + }) as IBarConfig, + ); + const columns = React.useMemo(() => (user ? fetchExplodedData() : []), [user]); + const [selection, setSelection] = React.useState([]); - const visSelection = React.useMemo(() => selection.map((s) => `${breastCancerData.indexOf(s)}`), [selection]); + const visSelection = React.useMemo(() => selection.map((s) => `${explodedData.indexOf(s)}`), [selection]); const [loading, setLoading] = React.useState(false); const lineupRef = React.useRef(); @@ -119,7 +133,7 @@ export function MainApp() { /> defaultBuilder({ data, smilesOptions: { setDynamicHeight: true } })} @@ -138,7 +152,7 @@ export function MainApp() { selected={visSelection} selectionCallback={(s) => { if (s) { - setSelection(s.map((i) => breastCancerData[+i]!)); + setSelection(s.map((i) => explodedData[+i]!)); } }} filterCallback={(f) => { diff --git a/src/vis/bar/BarChart.tsx b/src/vis/bar/BarChart.tsx index a5fbac290..a0ca7839e 100644 --- a/src/vis/bar/BarChart.tsx +++ b/src/vis/bar/BarChart.tsx @@ -1,5 +1,5 @@ import { Center, Group, Loader, ScrollArea, Stack } from '@mantine/core'; -import { VariableSizeList as List } from 'react-window'; +import { VariableSizeList as List, ListChildComponentProps } from 'react-window'; import { useElementSize } from '@mantine/hooks'; import { scaleOrdinal, schemeBlues } from 'd3v7'; import { uniqueId, zipWith, sortBy } from 'lodash'; @@ -116,6 +116,41 @@ export function BarChart({ const isToolbarVisible = config?.showFocusFacetSelector || showDownloadScreenshot || config?.display !== EBarDisplayType.NORMALIZED; const innerHeight = containerHeight - (isToolbarVisible ? 40 : 0); + const itemData = React.useMemo( + () => ({ + dataTable, + selectedList, + selectedMap, + groupColorScale, + filteredUniqueFacetVals, + config, + setConfig, + allUniqueFacetVals, + customSelectionCallback, + }), + [dataTable, selectedList, selectedMap, customSelectionCallback, setConfig, groupColorScale, filteredUniqueFacetVals, config, allUniqueFacetVals], + ); + + const renderer = React.useCallback((props: ListChildComponentProps) => { + const multiplesVal = props.data.filteredUniqueFacetVals[props.index]; + + return ( +
+ +
+ ); + }, []); + return ( {showDownloadScreenshot || config.showFocusFacetSelector === true ? ( @@ -144,36 +179,19 @@ export function BarChart({ ) : null} - {colsStatus === 'success' && config?.facets && allColumns?.facetsColVals && ( + {colsStatus === 'success' && config?.facets && allColumns?.facetsColVals ? ( { return calculateChartHeight(config, dataTable, filteredUniqueFacetVals[index]); }} + itemData={itemData} width="100%" > - {({ index, style }) => { - const multiplesVal = filteredUniqueFacetVals[index]; - return ( -
- -
- ); - }} + {renderer}
- )} + ) : null}
); diff --git a/src/vis/stories/explodedData.ts b/src/vis/stories/explodedData.ts new file mode 100644 index 000000000..5323180fd --- /dev/null +++ b/src/vis/stories/explodedData.ts @@ -0,0 +1,111 @@ +import { EColumnTypes, VisColumn } from '../interfaces'; + +export interface ExplodedItem { + name: string; + age: number; + numerical1: number; + numerical2: number; + categorical1: string; + categorical2: string; + statusFlag: 'active' | 'inactive'; + type1: 'TYPE_A' | 'TYPE_B' | 'TYPE_C' | 'TYPE_D' | 'TYPE_E'; + type2: 'TYPE_A' | 'TYPE_B' | 'TYPE_C' | 'TYPE_D' | 'TYPE_E'; +} + +const POSSIBLE_NAMES = ['Alice', 'Bob', 'Charlie', 'David', 'Eve', 'Frank', 'Grace', 'Hannah', 'Ivan', 'Jack']; + +/** + * Artificially exploded test dataset to check for performance issues. + */ +function generate(amount: number) { + return Array.from({ length: amount }).map(() => { + return { + name: POSSIBLE_NAMES[Math.floor(Math.random() * POSSIBLE_NAMES.length)], + age: Math.floor(Math.random() * 100), + numerical1: Math.random() * 100, + numerical2: Math.random() * 100, + categorical1: `Category ${Math.floor(Math.random() * 10)}`, + categorical2: `Category ${Math.floor(Math.random() * 10)}`, + statusFlag: Math.random() > 0.5 ? 'active' : 'inactive', + type1: `TYPE_${String.fromCharCode(65 + Math.floor(Math.random() * 5))}` as any, + type2: `TYPE_${String.fromCharCode(65 + Math.floor(Math.random() * 5))}` as any, + }; + }); +} + +export const explodedData = generate(100000); + +export function fetchExplodedData(): VisColumn[] { + return [ + { + info: { + description: 'The name of the patient', + id: 'name', + name: 'Name', + }, + type: EColumnTypes.CATEGORICAL, + values: () => explodedData.map((r) => r.name).map((val, i) => ({ id: i.toString(), val })), + domain: POSSIBLE_NAMES, + }, + { + info: { + description: 'The age of the patient', + id: 'age', + name: 'Age', + }, + type: EColumnTypes.NUMERICAL, + values: () => explodedData.map((r) => r.age).map((val, i) => ({ id: i.toString(), val })), + domain: [0, 100], + }, + { + info: { + description: 'The first numerical value', + id: 'numerical1', + name: 'Numerical 1', + }, + type: EColumnTypes.NUMERICAL, + values: () => explodedData.map((r) => r.numerical1).map((val, i) => ({ id: i.toString(), val })), + domain: [0, 100], + }, + { + info: { + description: 'The second numerical value', + id: 'numerical2', + name: 'Numerical 2', + }, + type: EColumnTypes.NUMERICAL, + values: () => explodedData.map((r) => r.numerical2).map((val, i) => ({ id: i.toString(), val })), + domain: [0, 100], + }, + { + info: { + description: 'The first categorical value', + id: 'categorical1', + name: 'Categorical 1', + }, + type: EColumnTypes.CATEGORICAL, + values: () => explodedData.map((r) => r.categorical1).map((val, i) => ({ id: i.toString(), val })), + domain: Array.from(new Set(explodedData.map((r) => r.categorical1))), + }, + { + info: { + description: 'The second categorical value', + id: 'categorical2', + name: 'Categorical 2', + }, + type: EColumnTypes.CATEGORICAL, + values: () => explodedData.map((r) => r.categorical2).map((val, i) => ({ id: i.toString(), val })), + domain: Array.from(new Set(explodedData.map((r) => r.categorical2))), + }, + { + info: { + description: 'The status flag', + id: 'statusFlag', + name: 'Status Flag', + }, + type: EColumnTypes.CATEGORICAL, + values: () => explodedData.map((r) => r.statusFlag).map((val, i) => ({ id: i.toString(), val })), + domain: ['active', 'inactive'], + }, + ]; +} diff --git a/tsconfig.json b/tsconfig.json index 801c29aa0..9b30d4c4e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "jsx": "react", "outDir": "./dist", + "strict": false, }, "include": [ "src/**/*.ts",