From 7b990ad05bb45043f20340f059b4bafa868a507b Mon Sep 17 00:00:00 2001 From: mukayevolzhas Date: Fri, 2 Aug 2024 00:10:49 +0300 Subject: [PATCH] feat: add bar chart zoom preview --- src/pages/studyView/StudyViewConfig.ts | 6 + src/pages/studyView/StudyViewPageStore.ts | 49 +++- .../studyView/chartHeader/ChartHeader.tsx | 29 ++ src/pages/studyView/charts/ChartContainer.tsx | 12 + .../studyView/charts/barChart/BarChart.tsx | 248 +++++++++++++----- src/pages/studyView/tabs/SummaryTab.tsx | 8 + 6 files changed, 287 insertions(+), 65 deletions(-) diff --git a/src/pages/studyView/StudyViewConfig.ts b/src/pages/studyView/StudyViewConfig.ts index 166ef974a1f..b66788e3b6e 100644 --- a/src/pages/studyView/StudyViewConfig.ts +++ b/src/pages/studyView/StudyViewConfig.ts @@ -64,6 +64,7 @@ export type StudyViewConfig = StudyView & StudyViewFrontEndConfig; export enum ChartTypeEnum { PIE_CHART = 'PIE_CHART', BAR_CHART = 'BAR_CHART', + BAR_PREVIEW_CHART = 'BAR_PREVIEW_CHART', SURVIVAL = 'SURVIVAL', TABLE = 'TABLE', SCATTER = 'SCATTER', @@ -88,6 +89,7 @@ export enum ChartTypeEnum { export enum ChartTypeNameEnum { PIE_CHART = 'pie chart', BAR_CHART = 'bar chart', + BAR_PREVIEW_CHART = 'bar preview chart', SURVIVAL = 'survival plot', TABLE = 'table', SCATTER = 'density plot', @@ -186,6 +188,10 @@ const studyViewFrontEnd = { w: 2, h: 1, }, + [ChartTypeEnum.BAR_PREVIEW_CHART]: { + w: 2, + h: 2, + }, [ChartTypeEnum.SCATTER]: { w: 2, h: 2, diff --git a/src/pages/studyView/StudyViewPageStore.ts b/src/pages/studyView/StudyViewPageStore.ts index 98d9da7d788..c17a1616a34 100644 --- a/src/pages/studyView/StudyViewPageStore.ts +++ b/src/pages/studyView/StudyViewPageStore.ts @@ -599,6 +599,47 @@ export class StudyViewPageStore ); } + @action.bound + changePreviewDimension(uniqueKey: string, previewShown: boolean): void { + const dimension = previewShown + ? STUDY_VIEW_CONFIG.layout.dimensions[ + ChartTypeEnum.BAR_PREVIEW_CHART + ] + : STUDY_VIEW_CONFIG.layout.dimensions[ChartTypeEnum.BAR_CHART]; + this.chartsDimension.set(uniqueKey, dimension); + } + + @action + togglePreview = (uniqueKey: string): void => { + if (this._customDataBinFilterSet.has(uniqueKey)) { + const filterSet = this._customDataBinFilterSet.get(uniqueKey); + const newFilter = _.clone(filterSet!); + newFilter.showPreview = !this.isPreviewShown(uniqueKey); + this.changePreviewDimension(uniqueKey, newFilter.showPreview); + this._customDataBinFilterSet.set(uniqueKey, newFilter as any); + } else { + const filterSet = this.getDataBinFilterSet(uniqueKey); + const newFilter = _.clone(filterSet.get(uniqueKey)!); + newFilter.showPreview = !this.isPreviewShown(uniqueKey); + this.changePreviewDimension(uniqueKey, newFilter.showPreview); + filterSet.set(uniqueKey, newFilter as any); + } + }; + + public isPreviewShown = (uniqueKey: string): boolean => { + if (this._customDataBinFilterSet.has(uniqueKey)) { + const filter = this._customDataBinFilterSet.get(uniqueKey); + return filter === undefined + ? false + : (filter!.showPreview as boolean); + } else { + const filter = this.getDataBinFilterSet(uniqueKey).get(uniqueKey)!; + return filter?.showPreview === undefined + ? false + : (filter!.showPreview as boolean); + } + }; + constructor( public appStore: AppStore, private sessionServiceIsEnabled: boolean, @@ -2114,19 +2155,19 @@ export class StudyViewPageStore >({}, { deep: false }); @observable private _clinicalDataBinFilterSet = observable.map< ChartUniqueKey, - ClinicalDataBinFilter & { showNA?: boolean } + ClinicalDataBinFilter & { showNA?: boolean; showPreview?: boolean } >(); @observable private _genomicDataBinFilterSet = observable.map< ChartUniqueKey, - GenomicDataBinFilter & { showNA?: boolean } + GenomicDataBinFilter & { showNA?: boolean; showPreview?: boolean } >(); @observable private _genericAssayDataBinFilterSet = observable.map< ChartUniqueKey, - GenericAssayDataBinFilter & { showNA?: boolean } + GenericAssayDataBinFilter & { showNA?: boolean; showPreview?: boolean } >(); @observable private _customDataBinFilterSet = observable.map< ChartUniqueKey, - ClinicalDataBinFilter & { showNA?: boolean } + ClinicalDataBinFilter & { showNA?: boolean; showPreview?: boolean } >(); @observable.ref private _geneFilterSet = observable.map< string, diff --git a/src/pages/studyView/chartHeader/ChartHeader.tsx b/src/pages/studyView/chartHeader/ChartHeader.tsx index 83a273aa57d..a9c695d5f8e 100644 --- a/src/pages/studyView/chartHeader/ChartHeader.tsx +++ b/src/pages/studyView/chartHeader/ChartHeader.tsx @@ -42,6 +42,7 @@ export interface IChartHeaderProps { toggleBoxPlot?: () => void; toggleViolinPlot?: () => void; toggleNAValue?: () => void; + togglePreview?: () => void; isLeftTruncationAvailable?: boolean; toggleSurvivalPlotLeftTruncation?: () => void; swapAxes?: () => void; @@ -79,6 +80,9 @@ export interface ChartControls { violinPlotChecked?: boolean; isShowNAChecked?: boolean; showNAToggle?: boolean; + isShowPreviewChecked?: boolean; + showPreviewToggle?: boolean; + showPreview?: boolean; showSwapAxes?: boolean; showSurvivalPlotLeftTruncationToggle?: boolean; survivalPlotLeftTruncationChecked?: boolean; @@ -602,6 +606,31 @@ export class ChartHeader extends React.Component { ); } + if (this.props.chartControls && this.props.togglePreview) { + items.push( +
  • + + + Show Zoom Preview + + } + style={{ marginTop: 1, marginBottom: -3 }} + /> + +
  • + ); + } } if (this.showDownload) { diff --git a/src/pages/studyView/charts/ChartContainer.tsx b/src/pages/studyView/charts/ChartContainer.tsx index ab3c4ea2c8f..d71de9f1366 100644 --- a/src/pages/studyView/charts/ChartContainer.tsx +++ b/src/pages/studyView/charts/ChartContainer.tsx @@ -145,6 +145,7 @@ export interface IChartContainerProps { onToggleViolinPlot?: (chartMeta: ChartMeta) => void; onToggleBoxPlot?: (chartMeta: ChartMeta) => void; onToggleNAValue?: (chartMeta: ChartMeta) => void; + onTogglePreview?: (chartMeta: ChartMeta) => void; onSwapAxes?: (chartMeta: ChartMeta) => void; logScaleChecked?: boolean; showLogScaleToggle?: boolean; @@ -157,6 +158,8 @@ export interface IChartContainerProps { showViolinPlotToggle?: boolean; violinPlotChecked?: boolean; isShowNAChecked?: boolean; + isShowPreviewChecked?: boolean; + isPreviewShown?: boolean; showNAToggle?: boolean; selectedCategories?: string[]; selectedGenes?: any; @@ -264,6 +267,9 @@ export class ChartContainer extends React.Component { onToggleNAValue: action(() => { this.props.onToggleNAValue?.(this.props.chartMeta); }), + onTogglePreview: action(() => { + this.props.onTogglePreview?.(this.props.chartMeta); + }), onToggleSurvivalPlotLeftTruncation: action(() => { this.props.onToggleSurvivalPlotLeftTruncation?.( this.props.chartMeta @@ -334,7 +340,9 @@ export class ChartContainer extends React.Component { showLogScaleToggle: this.props.showLogScaleToggle, logScaleChecked: this.props.logScaleChecked, isShowNAChecked: this.props.isShowNAChecked, + isShowPreviewChecked: this.props.isShowPreviewChecked, showNAToggle: this.props.showNAToggle, + showPreview: this.props.isPreviewShown, }; break; } @@ -564,6 +572,9 @@ export class ChartContainer extends React.Component { showNAChecked={this.props.store.isShowNAChecked( this.props.chartMeta.uniqueKey )} + showPreviewChecked={this.props.store.isPreviewShown( + this.props.chartMeta.uniqueKey + )} /> ); } @@ -1505,6 +1516,7 @@ export class ChartContainer extends React.Component { } swapAxes={this.handlers.onSwapAxes} toggleNAValue={this.handlers.onToggleNAValue} + togglePreview={this.handlers.onTogglePreview} chartControls={this.chartControls} changeChartType={this.changeChartType} getSVG={() => Promise.resolve(this.toSVGDOMNode())} diff --git a/src/pages/studyView/charts/barChart/BarChart.tsx b/src/pages/studyView/charts/barChart/BarChart.tsx index 5ac0382c8a0..612cb8e5799 100644 --- a/src/pages/studyView/charts/barChart/BarChart.tsx +++ b/src/pages/studyView/charts/barChart/BarChart.tsx @@ -5,7 +5,9 @@ import { VictoryBar, VictoryChart, VictoryLabel, + VictoryBrushContainer, VictorySelectionContainer, + createContainer, } from 'victory'; import { computed, observable, makeObservable } from 'mobx'; import _ from 'lodash'; @@ -40,6 +42,7 @@ export interface IBarChartProps { filters: DataFilterValue[]; onUserSelection: (dataBins: DataBin[]) => void; showNAChecked: boolean; + showPreviewChecked: boolean; } export type BarDatum = { @@ -48,6 +51,11 @@ export type BarDatum = { dataBin: DataBin; }; +export interface IBarChartState { + zoomDomain: any; + selectedDomain: any; +} + function generateTheme() { const theme = _.cloneDeep(CBIOPORTAL_VICTORY_THEME); theme.axis.style.tickLabels.fontSize *= 0.85; @@ -55,11 +63,13 @@ function generateTheme() { return theme; } +const VictoryZoomSelectionContainer = createContainer('zoom', 'selection'); const VICTORY_THEME = generateTheme(); const TILT_ANGLE = 50; @observer -export default class BarChart extends React.Component +export default class BarChart + extends React.Component implements AbstractChart { private svgContainer: any; @@ -74,7 +84,37 @@ export default class BarChart extends React.Component constructor(props: IBarChartProps) { super(props); + this.state = { + zoomDomain: null, + selectedDomain: null, + }; makeObservable(this); + this.handleBrush = this.handleBrush.bind(this); + } + + componentDidUpdate(prevProps: IBarChartProps) { + if (prevProps.showPreviewChecked && !this.props.showPreviewChecked) { + this.setState({ zoomDomain: null }); + } + } + + private handleBrush(domain: any) { + if (!domain || !domain.x) { + console.error('Brush domain is not defined.'); + return; + } + + const filteredData = this.barDataBasedOnNA.filter( + d => d.x >= domain.x[0] && d.x <= domain.x[1] + ); + const maxY = Math.max(...filteredData.map(d => d.y), 0); + + this.setState({ + zoomDomain: { + x: domain.x, + y: [0, maxY], + }, + }); } @autobind @@ -205,6 +245,14 @@ export default class BarChart extends React.Component return additionRatio * STUDY_VIEW_CONFIG.thresholds.barRatio; } + @computed + get chartHeight(): number { + if (this.props.showPreviewChecked) { + return this.props.height / 2; + } + return this.props.height; + } + @autobind private onMouseMove(event: React.MouseEvent): void { this.mousePosition = { x: event.pageX, y: event.pageY }; @@ -298,70 +346,148 @@ export default class BarChart extends React.Component return (
    {this.barData.length > 0 && ( - - (this.svgContainer = ref) - } - selectionDimension="x" - onSelection={this.onSelection} - /> - } - style={{ - parent: { - width: this.props.width, - height: this.props.height, - }, - }} - height={this.props.height - this.bottomPadding} - width={this.props.width} - padding={{ - left: 40, - right: 20, - top: 10, - bottom: this.bottomPadding, - }} - theme={VICTORY_THEME} - > - this.tickFormat[t - 1]} - domain={[0, this.maximumX]} - tickLabelComponent={} - style={{ - tickLabels: { - angle: TILT_ANGLE, - verticalAnchor: 'start', - textAnchor: 'start', - }, - }} - /> - - Number.isInteger(t) ? t.toFixed(0) : '' +
    + + (this.svgContainer = ref) + } + allowPan={false} + allowZoom={false} + zoomDimension="x" + selectionDimension="x" + onSelection={this.onSelection} + zoomDomain={this.state.zoomDomain} + /> } - /> - - isDataBinSelected( - d.dataBin, - this.props.filters - ) || this.props.filters.length === 0 - ? STUDY_VIEW_CONFIG.colors.theme - .primary - : DEFAULT_NA_COLOR, + parent: { + width: this.props.width, + height: this.chartHeight, }, }} - data={this.barDataBasedOnNA} - events={this.barPlotEvents} - /> - {this.labelShowingNA} - + height={this.chartHeight - this.bottomPadding} + width={this.props.width} + padding={{ + left: 40, + right: 20, + top: 10, + bottom: this.bottomPadding, + }} + theme={VICTORY_THEME} + > + + this.tickFormat[t - 1] + } + domain={[0, this.maximumX]} + tickLabelComponent={} + style={{ + tickLabels: { + angle: TILT_ANGLE, + verticalAnchor: 'start', + textAnchor: 'start', + }, + }} + /> + + Number.isInteger(t) ? t.toFixed(0) : '' + } + /> + + isDataBinSelected( + d.dataBin, + this.props.filters + ) || this.props.filters.length === 0 + ? STUDY_VIEW_CONFIG.colors.theme + .primary + : DEFAULT_NA_COLOR, + }, + }} + data={this.barDataBasedOnNA} + events={this.barPlotEvents} + /> + {this.labelShowingNA} + + {this.props.showPreviewChecked && ( + + } + > + + this.tickFormat[t - 1] + } + domain={[0, this.maximumX]} + tickLabelComponent={} + style={{ + tickLabels: { + angle: TILT_ANGLE, + verticalAnchor: 'start', + textAnchor: 'start', + }, + }} + /> + + Number.isInteger(t) ? t.toFixed(0) : '' + } + /> + + isDataBinSelected( + d.dataBin, + this.props.filters + ) || + this.props.filters.length === 0 + ? STUDY_VIEW_CONFIG.colors + .theme.primary + : DEFAULT_NA_COLOR, + }, + }} + data={this.barDataBasedOnNA} + /> + + )} +
    )} {ReactDOM.createPortal( { this.store.toggleNAValue(chartMeta.uniqueKey); }, + onTogglePreview: (chartMeta: ChartMeta) => { + this.store.togglePreview(chartMeta.uniqueKey); + }, onDeleteChart: (chartMeta: ChartMeta) => { this.store.resetFilterAndChangeChartVisibility( chartMeta.uniqueKey, @@ -277,6 +280,11 @@ export class StudySummaryTab extends React.Component< isShowNAChecked: this.store.isShowNAChecked( chartMeta.uniqueKey ), + onTogglePreview: this.handlers.onTogglePreview, + isPreviewShown: this.store.isPreviewShown( + chartMeta.uniqueKey + ), + previewShown: false, downloadTypes: ['Data', 'SVG', 'PDF'], }, [ChartMetaDataTypeEnum.GENE_SPECIFIC]: () => ({