From ddf98a44e37ae5769f89d408589e3efa9d9a2818 Mon Sep 17 00:00:00 2001 From: Bob Date: Sun, 27 Aug 2023 15:40:57 +0100 Subject: [PATCH 01/27] WIP: bubble overlay type to number or date --- .../libs/eda/src/lib/core/api/DataClient/types.ts | 12 ++++++------ .../lib/map/analysis/hooks/standaloneMapMarkers.tsx | 8 ++++---- .../lib/map/analysis/utils/defaultOverlayConfig.ts | 3 ++- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/libs/eda/src/lib/core/api/DataClient/types.ts b/packages/libs/eda/src/lib/core/api/DataClient/types.ts index 4333667f77..a2001b5dab 100755 --- a/packages/libs/eda/src/lib/core/api/DataClient/types.ts +++ b/packages/libs/eda/src/lib/core/api/DataClient/types.ts @@ -797,7 +797,7 @@ export const BubbleOverlayConfig = type({ denominatorValues: array(string), }), type({ - overlayType: literal('continuous'), + overlayType: keyof({ number: null, date: null }), // was literal('continuous'), aggregator: keyof({ mean: null, median: null }), }), ]), @@ -855,7 +855,7 @@ export const StandaloneMapBubblesResponse = type({ intersection([ MapElement, type({ - overlayValue: number, + overlayValue: string, }), ]) ), @@ -880,10 +880,10 @@ export type StandaloneMapBubblesLegendResponse = TypeOf< typeof StandaloneMapBubblesLegendResponse >; export const StandaloneMapBubblesLegendResponse = type({ - minColorValue: number, - maxColorValue: number, - minSizeValue: number, - maxSizeValue: number, + minColorValue: string, + maxColorValue: string, + minSizeValue: string, + maxSizeValue: string, }); export interface ContinousVariableMetadataRequestParams { diff --git a/packages/libs/eda/src/lib/map/analysis/hooks/standaloneMapMarkers.tsx b/packages/libs/eda/src/lib/map/analysis/hooks/standaloneMapMarkers.tsx index 935b6160a1..f32e0aeb55 100644 --- a/packages/libs/eda/src/lib/map/analysis/hooks/standaloneMapMarkers.tsx +++ b/packages/libs/eda/src/lib/map/analysis/hooks/standaloneMapMarkers.tsx @@ -182,10 +182,10 @@ export function useStandaloneMapMarkers( | StandaloneMapBubblesResponse; vocabulary: string[] | undefined; bubbleLegendData?: { - minColorValue: number; - maxColorValue: number; - minSizeValue: number; - maxSizeValue: number; + minColorValue: string; + maxColorValue: string; + minSizeValue: string; + maxSizeValue: string; }; } | undefined diff --git a/packages/libs/eda/src/lib/map/analysis/utils/defaultOverlayConfig.ts b/packages/libs/eda/src/lib/map/analysis/utils/defaultOverlayConfig.ts index 9c44d8a44c..62ef5b5c3d 100644 --- a/packages/libs/eda/src/lib/map/analysis/utils/defaultOverlayConfig.ts +++ b/packages/libs/eda/src/lib/map/analysis/utils/defaultOverlayConfig.ts @@ -9,6 +9,7 @@ import { OverlayConfig, StudyEntity, Variable, + VariableType, } from '../../../core'; import { DataClient, SubsettingClient } from '../../../core/api'; import { BinningMethod, MarkerConfiguration } from '../appState'; @@ -92,7 +93,7 @@ export async function getDefaultOverlayConfig( return { overlayVariable: overlayVariableDescriptor, aggregationConfig: { - overlayType: 'continuous', + overlayType: overlayVariable.type === 'date' ? 'date' : 'number', aggregator, }, }; From f49c39f075b257dc908aad3966f1e3d78d923bd0 Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 29 Aug 2023 16:39:29 +0100 Subject: [PATCH 02/27] revert overzealous number to string conversion --- packages/libs/eda/src/lib/core/api/DataClient/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/libs/eda/src/lib/core/api/DataClient/types.ts b/packages/libs/eda/src/lib/core/api/DataClient/types.ts index a2001b5dab..d56558846e 100755 --- a/packages/libs/eda/src/lib/core/api/DataClient/types.ts +++ b/packages/libs/eda/src/lib/core/api/DataClient/types.ts @@ -882,8 +882,8 @@ export type StandaloneMapBubblesLegendResponse = TypeOf< export const StandaloneMapBubblesLegendResponse = type({ minColorValue: string, maxColorValue: string, - minSizeValue: string, - maxSizeValue: string, + minSizeValue: number, + maxSizeValue: number, }); export interface ContinousVariableMetadataRequestParams { From 1708efa3d7b0dd3b180d9e29a282b630c060d42e Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 29 Aug 2023 16:40:27 +0100 Subject: [PATCH 03/27] reuse StandaloneMapBubblesLegendResponse type --- .../src/lib/map/analysis/hooks/standaloneMapMarkers.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/libs/eda/src/lib/map/analysis/hooks/standaloneMapMarkers.tsx b/packages/libs/eda/src/lib/map/analysis/hooks/standaloneMapMarkers.tsx index f32e0aeb55..b18295b14e 100644 --- a/packages/libs/eda/src/lib/map/analysis/hooks/standaloneMapMarkers.tsx +++ b/packages/libs/eda/src/lib/map/analysis/hooks/standaloneMapMarkers.tsx @@ -181,12 +181,7 @@ export function useStandaloneMapMarkers( | StandaloneMapMarkersResponse | StandaloneMapBubblesResponse; vocabulary: string[] | undefined; - bubbleLegendData?: { - minColorValue: string; - maxColorValue: string; - minSizeValue: string; - maxSizeValue: string; - }; + bubbleLegendData?: StandaloneMapBubblesLegendResponse; } | undefined >( From bedddfb1f6798d919eebc3ecdf51a098171e1801 Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 29 Aug 2023 17:41:36 +0100 Subject: [PATCH 04/27] holding pattern - no date handling for now --- .../eda/src/lib/core/api/DataClient/types.ts | 2 +- .../analysis/hooks/standaloneMapMarkers.tsx | 25 ++++++++++++++++--- .../analysis/utils/defaultOverlayConfig.ts | 2 +- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/libs/eda/src/lib/core/api/DataClient/types.ts b/packages/libs/eda/src/lib/core/api/DataClient/types.ts index d56558846e..f03b852559 100755 --- a/packages/libs/eda/src/lib/core/api/DataClient/types.ts +++ b/packages/libs/eda/src/lib/core/api/DataClient/types.ts @@ -797,7 +797,7 @@ export const BubbleOverlayConfig = type({ denominatorValues: array(string), }), type({ - overlayType: keyof({ number: null, date: null }), // was literal('continuous'), + overlayType: literal('continuous'), // TO DO for dates: probably redefine as 'number' | 'date' aggregator: keyof({ mean: null, median: null }), }), ]), diff --git a/packages/libs/eda/src/lib/map/analysis/hooks/standaloneMapMarkers.tsx b/packages/libs/eda/src/lib/map/analysis/hooks/standaloneMapMarkers.tsx index b18295b14e..ce5071af78 100644 --- a/packages/libs/eda/src/lib/map/analysis/hooks/standaloneMapMarkers.tsx +++ b/packages/libs/eda/src/lib/map/analysis/hooks/standaloneMapMarkers.tsx @@ -98,7 +98,13 @@ interface MapMarkers { // vocabulary: string[] | undefined; /** data for creating a legend */ legendItems: LegendItemsProps[]; - bubbleLegendData?: StandaloneMapBubblesLegendResponse; + bubbleLegendData?: // TO DO for dates: use StandaloneMapBubblesLegendResponse instead; + { + minColorValue: number; + maxColorValue: number; + minSizeValue: number; + maxSizeValue: number; + }; bubbleValueToDiameterMapper?: (value: number) => number; bubbleValueToColorMapper?: (value: number) => string; /** is the request pending? */ @@ -390,7 +396,18 @@ export function useStandaloneMapMarkers( ) as NumberRange; const vocabulary = rawPromise.value?.vocabulary; - const bubbleLegendData = rawPromise.value?.bubbleLegendData; + + // temporarily convert potentially date-strings to numbers + // but don't worry - we are also temporarily disabling date variables from bubble mode + const temp = rawPromise.value?.bubbleLegendData; + const bubbleLegendData = temp + ? { + minColorValue: Number(temp.minColorValue), + maxColorValue: Number(temp.maxColorValue), + minSizeValue: temp.minSizeValue, + maxSizeValue: temp.maxSizeValue, + } + : undefined; const adjustedSizeData = useMemo( () => @@ -701,13 +718,13 @@ const processRawBubblesData = ( const bubbleData = { value: entityCount, diameter: bubbleValueToDiameterMapper?.(entityCount) ?? 0, - colorValue: overlayValue, + colorValue: Number(overlayValue), // TO DO for dates: handle dates! colorLabel: aggregationConfig ? aggregationConfig.overlayType === 'continuous' ? _.capitalize(aggregationConfig.aggregator) : 'Proportion' : undefined, - color: bubbleValueToColorMapper?.(overlayValue), + color: bubbleValueToColorMapper?.(Number(overlayValue)), }; return { diff --git a/packages/libs/eda/src/lib/map/analysis/utils/defaultOverlayConfig.ts b/packages/libs/eda/src/lib/map/analysis/utils/defaultOverlayConfig.ts index 62ef5b5c3d..a4efe936f7 100644 --- a/packages/libs/eda/src/lib/map/analysis/utils/defaultOverlayConfig.ts +++ b/packages/libs/eda/src/lib/map/analysis/utils/defaultOverlayConfig.ts @@ -93,7 +93,7 @@ export async function getDefaultOverlayConfig( return { overlayVariable: overlayVariableDescriptor, aggregationConfig: { - overlayType: overlayVariable.type === 'date' ? 'date' : 'number', + overlayType: 'continuous', // TO DO for dates: might do `overlayVariable.type === 'date' ? 'date' : 'number'` aggregator, }, }; From 3381a1fa6eb7838035776c587763aecd49849eab Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 29 Aug 2023 18:03:57 +0100 Subject: [PATCH 05/27] temporarily disable date vars from bubble --- .../BubbleMarkerConfigurationMenu.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BubbleMarkerConfigurationMenu.tsx b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BubbleMarkerConfigurationMenu.tsx index 99bacf8cad..da60656e61 100644 --- a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BubbleMarkerConfigurationMenu.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BubbleMarkerConfigurationMenu.tsx @@ -13,6 +13,7 @@ import { aggregationHelp, AggregationInputs, } from '../../../core/components/visualizations/implementations/LineplotVisualization'; +import { DataElementConstraint } from '../../../core/types/visualization'; // TO DO for dates: remove type AggregatorOption = typeof aggregatorOptions[number]; const aggregatorOptions = ['mean', 'median'] as const; @@ -189,7 +190,22 @@ export function BubbleMarkerConfigurationMenu({ onChange={handleInputVariablesOnChange} starredVariables={starredVariables} toggleStarredVariable={toggleStarredVariable} - constraints={constraints} + constraints={ + // TEMPORARILY disable date vars; TO DO for dates - remove! + constraints?.map((constraint) => { + return Object.fromEntries( + Object.keys(constraint).map((key) => [ + key, + { + ...constraint[key], + allowedTypes: constraint[key]?.allowedTypes?.filter( + (t) => t !== 'date' + ), + } as DataElementConstraint, // assertion seems required due to spread operator + ]) + ); + }) + } flexDirection="column" /> From 003ff2bdbb72958bfeb984ff5b62fe411387ffe8 Mon Sep 17 00:00:00 2001 From: Connor Howington Date: Wed, 30 Aug 2023 11:25:50 -0400 Subject: [PATCH 06/27] Add new analysis button to all analyses component in map --- .../eda/src/lib/core/api/AnalysisClient.ts | 2 + .../libs/eda/src/lib/core/hooks/analysis.ts | 18 +- .../eda/src/lib/map/analysis/MapAnalysis.tsx | 6 +- .../eda/src/lib/workspace/AllAnalyses.tsx | 198 ++++++++++++------ 4 files changed, 151 insertions(+), 73 deletions(-) diff --git a/packages/libs/eda/src/lib/core/api/AnalysisClient.ts b/packages/libs/eda/src/lib/core/api/AnalysisClient.ts index 7567204760..0c0d962996 100644 --- a/packages/libs/eda/src/lib/core/api/AnalysisClient.ts +++ b/packages/libs/eda/src/lib/core/api/AnalysisClient.ts @@ -136,6 +136,7 @@ export class AnalysisClient extends FetchClientWithCredentials { 'studyVersion', ]); + console.log('fetching in createAnalysis'); return this.fetchWithDetails((user, projectId) => createJsonRequest({ path: this.makeAnalysesPath(user.id, projectId), @@ -158,6 +159,7 @@ export class AnalysisClient extends FetchClientWithCredentials { 'isPublic', ]); + console.log('fetching in updateAnalysis'); return this.fetchWithDetails((user, projectId) => createJsonRequest({ path: `${this.makeAnalysesPath(user.id, projectId)}/${analysisId}`, diff --git a/packages/libs/eda/src/lib/core/hooks/analysis.ts b/packages/libs/eda/src/lib/core/hooks/analysis.ts index 31498d3e89..7df16cc094 100644 --- a/packages/libs/eda/src/lib/core/hooks/analysis.ts +++ b/packages/libs/eda/src/lib/core/hooks/analysis.ts @@ -178,11 +178,15 @@ export function useAnalysis( throw new Error("Attempt to save an analysis that hasn't been loaded."); if (!isSavedAnalysis(analysis)) { + console.log('creating analysis'); createAnalysis(analysis); + console.log('created analysis'); } // Only save if the analysis has changed else if (analysis !== analysisCache.get(analysis.analysisId)) { + console.log('updating analysis'); await analysisClient.updateAnalysis(analysis.analysisId, analysis); + console.log('updated analysis'); analysisCache.set(analysis.analysisId, analysis); } }, [analysisClient, createAnalysis]); @@ -228,14 +232,24 @@ export function useAnalysis( const scheduleUpdate = analysisId != null || createIfUnsaved; return useCallback( (nestedValue: T | ((nestedValue: T) => T)) => { + console.log({ scheduleUpdate }); + console.log({ nestedValue, nestedValueLens }); setAnalysis((analysis) => { if (analysis == null) throw new Error( "Cannot update an analysis before it's been loaded." ); - return updateAnalysis(analysis, nestedValueLens, nestedValue); + const localUpdateAnalysis = updateAnalysis( + analysis, + nestedValueLens, + nestedValue + ); + console.log({ localUpdateAnalysis }); + return localUpdateAnalysis; }); + console.log('before setUpdateScheduled'); setUpdateScheduled(scheduleUpdate); + console.log('after setUpdateScheduled'); }, [nestedValueLens, scheduleUpdate] ); @@ -375,7 +389,9 @@ export function useAnalysis( useEffect(() => { const id = setTimeout(function deferredSave() { if (updateScheduled) { + console.log('saving analysis'); saveAnalysis(); + console.log('saved analysis'); setUpdateScheduled(false); } }, 1000); diff --git a/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx b/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx index c750a27a1a..c42d5a2d01 100644 --- a/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx @@ -1014,13 +1014,13 @@ function MapAnalysisImpl(props: ImplProps) { > diff --git a/packages/libs/eda/src/lib/workspace/AllAnalyses.tsx b/packages/libs/eda/src/lib/workspace/AllAnalyses.tsx index d55c85c1ba..c42ac7f2ed 100644 --- a/packages/libs/eda/src/lib/workspace/AllAnalyses.tsx +++ b/packages/libs/eda/src/lib/workspace/AllAnalyses.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { orderBy } from 'lodash'; import Path from 'path'; -import { Link, useHistory, useLocation } from 'react-router-dom'; +import { Link, useHistory, useLocation, useRouteMatch } from 'react-router-dom'; import { Button, @@ -35,11 +35,15 @@ import { OverflowingTextCell } from '@veupathdb/wdk-client/lib/Views/Strategy/Ov import { AnalysisClient, + AnalysisState, AnalysisSummary, + DEFAULT_ANALYSIS_NAME, + StudyRecord, useAnalysisList, usePinnedAnalyses, } from '../core'; import SubsettingClient from '../core/api/SubsettingClient'; +import { getAnalysisId } from '../core/utils/analysis'; import { useDebounce } from '../core/hooks/debouncing'; import { useWdkStudyRecords } from '../core/hooks/study'; import { @@ -47,9 +51,11 @@ import { makeCurrentProvenanceString, makeOnImportProvenanceString, } from '../core/utils/analysis'; +import { AnalysisNameDialog } from './AnalysisNameDialog'; import { convertISOToDisplayFormat } from '../core/utils/date-conversion'; import ShareFromAnalysesList from './sharing/ShareFromAnalysesList'; import { Checkbox, Toggle, colors } from '@veupathdb/coreui'; +import { getStudyId } from '@veupathdb/study-data-access/lib/shared/studies'; interface AnalysisAndDataset { analysis: AnalysisSummary & { @@ -68,14 +74,15 @@ interface Props { exampleAnalysesAuthor?: number; /** * When provided, the table is filtered to the study, - * and the study column is not displayed. + * and the study column is not displayed. This, along with analysisState, is + * necessary for a new analysis button to be shown */ - studyId?: string | null; + studyRecord?: StudyRecord; /** - * If the analysis with this ID is displayed, - * indicate it is "active" + * If there is an active analysis, indicate the active analysis in the table + * and show a new analysis button if studyRecord is also provided */ - activeAnalysisId?: string; + analysisState?: AnalysisState; /** * Determines if the search term is stored as a query * param in the url @@ -111,15 +118,31 @@ export function AllAnalyses(props: Props) { analysisClient, exampleAnalysesAuthor, showLoginForm, - studyId, + studyRecord, synchronizeWithUrl, updateDocumentTitle, - activeAnalysisId, + analysisState, } = props; const user = useWdkService((wdkService) => wdkService.getCurrentUser(), []); const history = useHistory(); const location = useLocation(); const classes = useStyles(); + const [isAnalysisNameDialogOpen, setIsAnalysisNameDialogOpen] = + useState(false); + const analysis = analysisState?.analysis; + const activeAnalysisId = getAnalysisId(analysis); + const studyId = studyRecord ? getStudyId(studyRecord) : null; + const { url } = useRouteMatch(); + const redirectURL = studyId + ? url.endsWith(studyId) + ? `/workspace/${url}/new` + : Path.resolve(url, '../new') + : null; + const redirectToNewAnalysis = useCallback( + () => (redirectURL ? history.push(redirectURL) : null), + [history, redirectURL] + ); + console.log({ redirectURL }); const searchTextQueryParam = useMemo(() => { if (!synchronizeWithUrl) return ''; @@ -320,6 +343,25 @@ export function AllAnalyses(props: Props) { selectedAnalyses.has(analysis.analysisId), }, actions: [ + // A button to create a new analysis + { + element: + activeAnalysisId && redirectToNewAnalysis ? ( + + ) : ( + <> + ), + }, { element: ( From fb1d38546945af8848c0854cf4f03f03998f89e0 Mon Sep 17 00:00:00 2001 From: Connor Howington Date: Wed, 30 Aug 2023 17:36:18 -0400 Subject: [PATCH 09/27] Delete comment --- packages/libs/eda/src/lib/workspace/AllAnalyses.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/libs/eda/src/lib/workspace/AllAnalyses.tsx b/packages/libs/eda/src/lib/workspace/AllAnalyses.tsx index 9d45a0624c..f80cca90ff 100644 --- a/packages/libs/eda/src/lib/workspace/AllAnalyses.tsx +++ b/packages/libs/eda/src/lib/workspace/AllAnalyses.tsx @@ -342,7 +342,6 @@ export function AllAnalyses(props: Props) { selectedAnalyses.has(analysis.analysisId), }, actions: [ - // A button to create a new analysis { element: activeAnalysisId && redirectToNewAnalysis ? ( From 8e3e876ac553e003b4363ab40842ebb872c9b026 Mon Sep 17 00:00:00 2001 From: Connor Howington Date: Wed, 6 Sep 2023 21:03:41 -0400 Subject: [PATCH 10/27] Use subsettingclient from props instead of hook --- packages/libs/eda/src/lib/workspace/AllAnalyses.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/libs/eda/src/lib/workspace/AllAnalyses.tsx b/packages/libs/eda/src/lib/workspace/AllAnalyses.tsx index e93c9dc987..9dbf10f751 100644 --- a/packages/libs/eda/src/lib/workspace/AllAnalyses.tsx +++ b/packages/libs/eda/src/lib/workspace/AllAnalyses.tsx @@ -41,7 +41,6 @@ import { StudyRecord, useAnalysisList, usePinnedAnalyses, - useSubsettingClient, } from '../core'; import SubsettingClient from '../core/api/SubsettingClient'; import { getAnalysisId } from '../core/utils/analysis'; @@ -117,6 +116,7 @@ const WDK_STUDY_RECORD_ATTRIBUTES = ['study_access']; export function AllAnalyses(props: Props) { const { analysisClient, + subsettingClient, exampleAnalysesAuthor, showLoginForm, studyRecord, @@ -192,7 +192,6 @@ export function AllAnalyses(props: Props) { removePinnedAnalysis, } = usePinnedAnalyses(analysisClient); - const subsettingClient = useSubsettingClient(); const datasets = useWdkStudyRecords(subsettingClient, { attributes: WDK_STUDY_RECORD_ATTRIBUTES, }); From 31293ab78b71a2299e534f0fbab2bc09df3b5e70 Mon Sep 17 00:00:00 2001 From: Connor Howington Date: Tue, 12 Sep 2023 18:32:38 -0400 Subject: [PATCH 11/27] Render button in panel instead of AllAnalyses --- .../libs/coreui/src/components/icons/Plus.tsx | 19 +++++++ .../coreui/src/components/icons/index.tsx | 1 + .../eda/src/lib/map/analysis/MapAnalysis.tsx | 49 ++++++++++++++++++- .../eda/src/lib/workspace/AllAnalyses.tsx | 33 ------------- .../src/lib/workspace/AnalysisNameDialog.tsx | 2 +- .../src/lib/workspace/EDAWorkspaceHeading.tsx | 2 +- 6 files changed, 69 insertions(+), 37 deletions(-) create mode 100644 packages/libs/coreui/src/components/icons/Plus.tsx diff --git a/packages/libs/coreui/src/components/icons/Plus.tsx b/packages/libs/coreui/src/components/icons/Plus.tsx new file mode 100644 index 0000000000..2384d82867 --- /dev/null +++ b/packages/libs/coreui/src/components/icons/Plus.tsx @@ -0,0 +1,19 @@ +import { SVGProps } from 'react'; +import { Add } from '@material-ui/icons'; + +const Plus = (props: SVGProps) => { + const { height = '1em', width = '1em' } = props; + return ( + + + + ); +}; + +export default Plus; diff --git a/packages/libs/coreui/src/components/icons/index.tsx b/packages/libs/coreui/src/components/icons/index.tsx index 86a67ed044..0d9d9a45b7 100644 --- a/packages/libs/coreui/src/components/icons/index.tsx +++ b/packages/libs/coreui/src/components/icons/index.tsx @@ -14,6 +14,7 @@ export { default as Filter } from './Filter'; export { default as Loading } from './Loading'; export { default as NoEdit } from './NoEdit'; export { default as Pencil } from './Pencil'; +export { default as Plus } from './Plus'; export { default as SampleDetailsDark } from './SampleDetailsDark'; export { default as SampleDetailsLight } from './SampleDetailsLight'; export { default as Share } from './Share'; diff --git a/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx b/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx index c612cfb625..d4cf67d96c 100755 --- a/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx @@ -31,8 +31,10 @@ import { DocumentationContainer } from '../../core/components/docs/Documentation import { CheckIcon, Download, + Plus, FilledButton, Filter as FilterIcon, + FloatingButton, H5, Table, } from '@veupathdb/coreui'; @@ -69,9 +71,10 @@ import { useLoginCallbacks } from '../../workspace/sharing/hooks'; import NameAnalysis from '../../workspace/sharing/NameAnalysis'; import NotesTab from '../../workspace/NotesTab'; import ConfirmShareAnalysis from '../../workspace/sharing/ConfirmShareAnalysis'; -import { useHistory } from 'react-router'; +import { useHistory, useRouteMatch } from 'react-router'; import { uniq } from 'lodash'; +import Path from 'path'; import DownloadTab from '../../workspace/DownloadTab'; import { RecordController } from '@veupathdb/wdk-client/lib/Controllers'; import { @@ -87,7 +90,6 @@ import { import { leastAncestralEntity } from '../../core/utils/data-element-constraints'; import { getDefaultOverlayConfig } from './utils/defaultOverlayConfig'; import { AllAnalyses } from '../../workspace/AllAnalyses'; -import { getStudyId } from '@veupathdb/study-data-access/lib/shared/studies'; import { isSavedAnalysis } from '../../core/utils/analysis'; import { MapTypeConfigurationMenu, @@ -115,6 +117,7 @@ import { DraggablePanelCoordinatePair } from '@veupathdb/coreui/lib/components/c import _ from 'lodash'; import EZTimeFilter from './EZTimeFilter'; +import AnalysisNameDialog from '../../workspace/AnalysisNameDialog'; enum MapSideNavItemLabels { Download = 'Download', @@ -717,6 +720,23 @@ function MapAnalysisImpl(props: ImplProps) { const filteredEntities = uniq(filters?.map((f) => f.entityId)); + const [isAnalysisNameDialogOpen, setIsAnalysisNameDialogOpen] = + useState(false); + const { url: urlRouteMatch } = useRouteMatch(); + const redirectURL = studyId + ? urlRouteMatch.endsWith(studyId) + ? `/workspace/${urlRouteMatch}/new` + : Path.resolve(urlRouteMatch, '../new') + : null; + const redirectToNewAnalysis = useCallback(() => { + if (redirectURL) { + history.push(redirectURL); + // push() alone doesn't seem to work in this context; the URL changes, + // but the page doesn't load, so we force a refresh + history.go(0); + } + }, [history, redirectURL]); + const sideNavigationButtonConfigurationObjects: SideNavigationItemConfigurationObject[] = [ { @@ -1071,6 +1091,31 @@ function MapAnalysisImpl(props: ImplProps) { maxWidth: '1500px', }} > + {analysisId && redirectToNewAnalysis ? ( +
+ setIsAnalysisNameDialogOpen(true) + : redirectToNewAnalysis + } + /> +
+ ) : ( + <> + )} + {analysisState.analysis && ( + + )} } - onClick={ - analysis && analysis.displayName === DEFAULT_ANALYSIS_NAME - ? () => setIsAnalysisNameDialogOpen(true) - : redirectToNewAnalysis - } - > - Create new analysis - - ) : ( - <> - ), - }, { element: ( -
-
- {children} -
-
- backgroundColor: `rgba(0, 0, 0,0.15)`, - border: 0, - height: '2px', - marginBottom: '1.5rem', - width: '50%', - }} - /> -
- {/* For now these are more for demonstration purposes. */} - -
+ const iconCss = css({ + height: '1.5em', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + ':first-of-type': { + width: '1.5em', + }, + }); + + const labelCss = css({ + margin: '0 0.5em', + }); + + return ( +
+
{entry.leftIcon}
+
{entry.labelText}
+
{entry.rightIcon}
-
{ + switch (entry.type) { + case 'heading': + case 'subheading': + return ( +
  • +
    {formatEntry(entry)}
    + +
  • + ); + case 'item': { + const isActive = entry.id === activeSideMenuId; + return ( +
  • + +
  • + ); + } + } + return null; + }); + return ( +
    +
      - {activeNavigationMenu} -
    - + {sideNavigationItems} + +
    ); } diff --git a/packages/libs/eda/src/lib/map/analysis/MapSidePanel.tsx b/packages/libs/eda/src/lib/map/analysis/MapSidePanel.tsx new file mode 100644 index 0000000000..088be51f4f --- /dev/null +++ b/packages/libs/eda/src/lib/map/analysis/MapSidePanel.tsx @@ -0,0 +1,204 @@ +import { ChevronRight } from '@veupathdb/coreui'; +import { Launch, LockOpen, Person } from '@material-ui/icons'; +import { mapSidePanelBackgroundColor, mapSidePanelBorder } from '../constants'; +import { SiteInformationProps } from './Types'; + +import { Link } from 'react-router-dom'; + +export type MapSidePanelProps = { + isExpanded: boolean; + children: React.ReactNode; + /** This fires when the user expands/collapses the nav. */ + onToggleIsExpanded: () => void; + /** Content to render in sidePanel drawer */ + sidePanelDrawerContents?: React.ReactNode; + siteInformationProps: SiteInformationProps; + isUserLoggedIn: boolean | undefined; +}; + +const bottomLinkStyles: React.CSSProperties = { + // These are for formatting the links to the login + // and site URL. + display: 'flex', + justifyContent: 'flex-start', + alignItems: 'center', + fontSize: 15, + marginBottom: '1rem', +}; + +const mapSideNavTopOffset = '1.5rem'; + +export function MapSidePanel({ + sidePanelDrawerContents, + children, + isExpanded, + onToggleIsExpanded, + siteInformationProps, + isUserLoggedIn, +}: MapSidePanelProps) { + const sideMenuExpandButtonWidth = 20; + + return ( + + ); +} diff --git a/packages/libs/eda/src/lib/map/analysis/MapVizManagement.tsx b/packages/libs/eda/src/lib/map/analysis/MapVizManagement.tsx index caae4da354..aecc40eb9e 100644 --- a/packages/libs/eda/src/lib/map/analysis/MapVizManagement.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MapVizManagement.tsx @@ -6,14 +6,14 @@ import { Tooltip } from '@material-ui/core'; import { Add } from '@material-ui/icons'; import { useUITheme } from '@veupathdb/coreui/lib/components/theming'; import { makeClassNameHelper } from '@veupathdb/wdk-client/lib/Utils/ComponentUtils'; -import { mapNavigationBorder } from '..'; +import { mapSidePanelBorder } from '../constants'; import { AnalysisState } from '../../core'; import PlaceholderIcon from '../../core/components/visualizations/PlaceholderIcon'; import { useVizIconColors } from '../../core/components/visualizations/implementations/selectorIcons/types'; import { GeoConfig } from '../../core/types/geoConfig'; import { ComputationAppOverview } from '../../core/types/visualization'; import './MapVizManagement.scss'; -import { MarkerConfiguration, useAppState } from './appState'; +import { MarkerConfiguration } from './appState'; import { ComputationPlugin } from '../../core/components/computations/Types'; import { VisualizationPlugin } from '../../core/components/visualizations/VisualizationPlugin'; import { StartPage } from '../../core/components/computations/StartPage'; @@ -21,9 +21,7 @@ import { StartPage } from '../../core/components/computations/StartPage'; interface Props { activeVisualizationId: string | undefined; analysisState: AnalysisState; - setActiveVisualizationId: ReturnType< - typeof useAppState - >['setActiveVisualizationId']; + setActiveVisualizationId: (id?: string) => void; apps: ComputationAppOverview[]; plugins: Partial>; // visualizationPlugins: Partial>; @@ -131,7 +129,7 @@ export default function MapVizManagement({ {isVizSelectorVisible && totalVisualizationCount > 0 && (
    diff --git a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BarPlotMarkerConfigurationMenu.tsx b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BarPlotMarkerConfigurationMenu.tsx index 06febaeadd..e50d686716 100644 --- a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BarPlotMarkerConfigurationMenu.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BarPlotMarkerConfigurationMenu.tsx @@ -17,7 +17,6 @@ import { CategoricalMarkerPreview } from './CategoricalMarkerPreview'; import Barplot from '@veupathdb/components/lib/plots/Barplot'; import { SubsettingClient } from '../../../core/api'; import { Toggle } from '@veupathdb/coreui'; -import { SharedMarkerConfigurations } from './PieMarkerConfigurationMenu'; import { useUncontrolledSelections } from '../hooks/uncontrolledSelections'; import { BinningMethod, @@ -25,6 +24,7 @@ import { SelectedValues, } from '../appState'; import { gray } from '@veupathdb/coreui/lib/definitions/colors'; +import { SharedMarkerConfigurations } from '../mapTypes/shared'; interface MarkerConfiguration { type: T; diff --git a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BubbleMarkerConfigurationMenu.tsx b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BubbleMarkerConfigurationMenu.tsx index 954f997055..b8d6da4fb5 100644 --- a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BubbleMarkerConfigurationMenu.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BubbleMarkerConfigurationMenu.tsx @@ -5,7 +5,6 @@ import { import { VariableTreeNode } from '../../../core/types/study'; import { VariablesByInputName } from '../../../core/utils/data-element-constraints'; import { findEntityAndVariable } from '../../../core/utils/study-metadata'; -import { SharedMarkerConfigurations } from './PieMarkerConfigurationMenu'; import HelpIcon from '@veupathdb/wdk-client/lib/Components/Icon/HelpIcon'; import { BubbleOverlayConfig } from '../../../core'; import PluginError from '../../../core/components/visualizations/PluginError'; @@ -14,6 +13,7 @@ import { AggregationInputs, } from '../../../core/components/visualizations/implementations/LineplotVisualization'; import { DataElementConstraint } from '../../../core/types/visualization'; // TO DO for dates: remove +import { SharedMarkerConfigurations } from '../mapTypes/shared'; type AggregatorOption = typeof aggregatorOptions[number]; const aggregatorOptions = ['mean', 'median'] as const; diff --git a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/CategoricalMarkerConfigurationTable.tsx b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/CategoricalMarkerConfigurationTable.tsx index 07bce43224..8caef3d01f 100644 --- a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/CategoricalMarkerConfigurationTable.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/CategoricalMarkerConfigurationTable.tsx @@ -5,7 +5,7 @@ import { AllValuesDefinition } from '../../../core'; import { Tooltip } from '@veupathdb/components/lib/components/widgets/Tooltip'; import { ColorPaletteDefault } from '@veupathdb/components/lib/types/plots'; import RadioButtonGroup from '@veupathdb/components/lib/components/widgets/RadioButtonGroup'; -import { UNSELECTED_TOKEN } from '../../'; +import { UNSELECTED_TOKEN } from '../../constants'; import { orderBy } from 'lodash'; import { SelectedCountsOption } from '../appState'; diff --git a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/CategoricalMarkerPreview.tsx b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/CategoricalMarkerPreview.tsx index ab376f2a01..f8095eed43 100644 --- a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/CategoricalMarkerPreview.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/CategoricalMarkerPreview.tsx @@ -5,7 +5,7 @@ import { getChartMarkerDependentAxisRange, } from '@veupathdb/components/lib/map/ChartMarker'; import { DonutMarkerStandalone } from '@veupathdb/components/lib/map/DonutMarker'; -import { UNSELECTED_TOKEN } from '../..'; +import { UNSELECTED_TOKEN } from '../../constants'; import Banner from '@veupathdb/coreui/lib/components/banners/Banner'; import { kFormatter, diff --git a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/MapTypeConfigurationMenu.tsx b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/MapTypeConfigurationMenu.tsx index a521f21af9..327cbc9fee 100644 --- a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/MapTypeConfigurationMenu.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/MapTypeConfigurationMenu.tsx @@ -13,19 +13,14 @@ export interface MarkerConfigurationOption { } interface Props { - activeMarkerConfigurationType: MarkerConfiguration['type']; - markerConfigurations: MarkerConfigurationOption[]; + markerConfiguration: MarkerConfigurationOption; mapTypeConfigurationMenuTabs: TabbedDisplayProps<'markers' | 'plots'>['tabs']; } export function MapTypeConfigurationMenu({ - activeMarkerConfigurationType, - markerConfigurations, + markerConfiguration, mapTypeConfigurationMenuTabs, }: Props) { - const activeMarkerConfiguration = markerConfigurations.find( - ({ type }) => type === activeMarkerConfigurationType - ); const [activeTab, setActiveTab] = useState('markers'); return ( @@ -42,8 +37,8 @@ export function MapTypeConfigurationMenu({ alignItems: 'center', }} > - Configure {activeMarkerConfiguration?.displayName} - {activeMarkerConfiguration?.icon} + Configure {markerConfiguration.displayName} + {markerConfiguration.icon} { type: T; } -export interface SharedMarkerConfigurations { - selectedVariable: VariableDescriptor; -} export interface PieMarkerConfiguration extends MarkerConfiguration<'pie'>, SharedMarkerConfigurations { diff --git a/packages/libs/eda/src/lib/map/analysis/Types.ts b/packages/libs/eda/src/lib/map/analysis/Types.ts new file mode 100644 index 0000000000..80d375cc25 --- /dev/null +++ b/packages/libs/eda/src/lib/map/analysis/Types.ts @@ -0,0 +1,37 @@ +import { ReactNode } from 'react'; +import { ComputationAppOverview } from '../../core/types/visualization'; + +export type SidePanelMenuEntry = + | SidePanelItem + | SidePanelHeading + | SidePanelSubheading; + +export interface SidePanelMenuItemBase { + leftIcon?: ReactNode; + rightIcon?: ReactNode; + labelText: ReactNode; +} + +export interface SidePanelItem extends SidePanelMenuItemBase { + type: 'item'; + id: string; + renderSidePanelDrawer: (apps: ComputationAppOverview[]) => ReactNode; + onActive?: () => void; +} + +export interface SidePanelHeading extends SidePanelMenuItemBase { + type: 'heading'; + children: (SidePanelSubheading | SidePanelItem)[]; +} + +export interface SidePanelSubheading extends SidePanelMenuItemBase { + type: 'subheading'; + children: SidePanelItem[]; +} + +export interface SiteInformationProps { + siteHomeUrl: string; + loginUrl: string; + siteName: string; + siteLogoSrc: string; +} diff --git a/packages/libs/eda/src/lib/map/analysis/appState.ts b/packages/libs/eda/src/lib/map/analysis/appState.ts index fc08875a5d..32003b8408 100644 --- a/packages/libs/eda/src/lib/map/analysis/appState.ts +++ b/packages/libs/eda/src/lib/map/analysis/appState.ts @@ -6,6 +6,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useAnalysis, useGetDefaultVariableDescriptor } from '../../core'; import { VariableDescriptor } from '../../core/types/variable'; import { useGetDefaultTimeVariableDescriptor } from './hooks/eztimeslider'; +import { defaultViewport } from '@veupathdb/components/lib/map/config/map'; const LatLngLiteral = t.type({ lat: t.number, lng: t.number }); @@ -44,6 +45,9 @@ export const MarkerConfiguration = t.intersection([ type: MarkerType, selectedVariable: VariableDescriptor, }), + t.partial({ + activeVisualizationId: t.string, + }), t.union([ t.type({ type: t.literal('barplot'), @@ -80,9 +84,9 @@ export const AppState = t.intersection([ }), activeMarkerConfigurationType: MarkerType, markerConfigurations: t.array(MarkerConfiguration), + isSidePanelExpanded: t.boolean, }), t.partial({ - activeVisualizationId: t.string, boundsZoomLevel: t.type({ zoomLevel: t.number, bounds: t.type({ @@ -112,12 +116,6 @@ export const AppState = t.intersection([ // eslint-disable-next-line @typescript-eslint/no-redeclare export type AppState = t.TypeOf; -// export default viewport for custom zoom control -export const defaultViewport: AppState['viewport'] = { - center: [0, 0], - zoom: 1, -}; - export function useAppState(uiStateKey: string, analysisId?: string) { const analysisState = useAnalysis(analysisId); @@ -149,6 +147,7 @@ export function useAppState(uiStateKey: string, analysisId?: string) { viewport: defaultViewport, mouseMode: 'default', activeMarkerConfigurationType: 'pie', + isSidePanelExpanded: true, timeSliderConfig: { variable: defaultTimeVariable, active: true, @@ -258,9 +257,8 @@ export function useAppState(uiStateKey: string, analysisId?: string) { 'activeMarkerConfigurationType' ), setMarkerConfigurations: useSetter('markerConfigurations'), - setActiveVisualizationId: useSetter('activeVisualizationId'), setBoundsZoomLevel: useSetter('boundsZoomLevel'), - setIsSubsetPanelOpen: useSetter('isSubsetPanelOpen'), + setIsSidePanelExpanded: useSetter('isSidePanelExpanded'), setSubsetVariableAndEntity: useSetter('subsetVariableAndEntity'), setViewport: useSetter('viewport'), setTimeSliderConfig: useSetter('timeSliderConfig'), diff --git a/packages/libs/eda/src/lib/map/analysis/hooks/standaloneMapMarkers.tsx b/packages/libs/eda/src/lib/map/analysis/hooks/standaloneMapMarkers.tsx index ab39333ebf..d4cc26955c 100644 --- a/packages/libs/eda/src/lib/map/analysis/hooks/standaloneMapMarkers.tsx +++ b/packages/libs/eda/src/lib/map/analysis/hooks/standaloneMapMarkers.tsx @@ -30,7 +30,7 @@ import { defaultAnimationDuration } from '@veupathdb/components/lib/map/config/m import { LegendItemsProps } from '@veupathdb/components/lib/components/plotControls/PlotListLegend'; import { VariableDescriptor } from '../../../core/types/variable'; import { useDeepValue } from '../../../core/hooks/immutability'; -import { UNSELECTED_DISPLAY_TEXT, UNSELECTED_TOKEN } from '../..'; +import { UNSELECTED_DISPLAY_TEXT, UNSELECTED_TOKEN } from '../../constants'; import { DonutMarkerProps } from '@veupathdb/components/lib/map/DonutMarker'; import { ChartMarkerProps, diff --git a/packages/libs/eda/src/lib/map/analysis/mapTypes/MapTypeHeaderCounts.tsx b/packages/libs/eda/src/lib/map/analysis/mapTypes/MapTypeHeaderCounts.tsx new file mode 100644 index 0000000000..5435ecf048 --- /dev/null +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/MapTypeHeaderCounts.tsx @@ -0,0 +1,121 @@ +import { CSSProperties } from 'react'; +import { makeEntityDisplayName } from '../../../core/utils/study-metadata'; +import { useStudyEntities } from '../../../core'; + +interface Props { + outputEntityId: string; + totalEntityCount?: number; + totalEntityInSubsetCount?: number; + visibleEntityCount?: number; +} + +const { format } = new Intl.NumberFormat(); + +export function MapTypeHeaderCounts(props: Props) { + const { + outputEntityId, + totalEntityCount = 0, + totalEntityInSubsetCount = 0, + visibleEntityCount = 0, + } = props; + const entities = useStudyEntities(); + const outputEntity = entities.find((entity) => entity.id === outputEntityId); + if (outputEntity == null) return null; + return ( +
    +

    {makeEntityDisplayName(outputEntity, true)}

    + + + + {/* */} + + + 1 + )} in the dataset.`} + > + + + + 1 + )} in the subset.`} + > + + + + 1 + )} are in the current viewport, and have data for the painted variable.`} + > + + + + +
    {entityDisplayName}
    All{format(totalEntityCount)}
    Filtered{format(totalEntityInSubsetCount)}
    View{format(visibleEntityCount)}
    +
    + ); +} + +type LeftBracketProps = { + /** Should you need to adjust anything! */ + styles?: CSSProperties; +}; +function LeftBracket(props: LeftBracketProps) { + return ( +
    + ); +} diff --git a/packages/libs/eda/src/lib/map/analysis/mapTypes/index.ts b/packages/libs/eda/src/lib/map/analysis/mapTypes/index.ts new file mode 100644 index 0000000000..715596ebd1 --- /dev/null +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/index.ts @@ -0,0 +1,3 @@ +export { plugin as donutMarkerPlugin } from './plugins/DonutMarkerMapType'; +export { plugin as barMarkerPlugin } from './plugins/BarMarkerMapType'; +export { plugin as bubbleMarkerPlugin } from './plugins/BubbleMarkerMapType'; diff --git a/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/BarMarkerMapType.tsx b/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/BarMarkerMapType.tsx new file mode 100644 index 0000000000..1c3d52cd76 --- /dev/null +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/BarMarkerMapType.tsx @@ -0,0 +1,670 @@ +import React, { useCallback, useMemo } from 'react'; +import { Variable } from '../../../../core/types/study'; +import { findEntityAndVariable } from '../../../../core/utils/study-metadata'; +import { + BarPlotMarkerConfiguration, + BarPlotMarkerConfigurationMenu, +} from '../../MarkerConfiguration/BarPlotMarkerConfigurationMenu'; +import { + MapTypeConfigPanelProps, + MapTypeMapLayerProps, + MapTypePlugin, +} from '../types'; +import { + OverlayConfig, + StandaloneMapMarkersResponse, +} from '../../../../core/api/DataClient/types'; +import { getDefaultAxisRange } from '../../../../core/utils/computeDefaultAxisRange'; +import { NumberRange } from '@veupathdb/components/lib/types/general'; +import { mFormatter } from '../../../../core/utils/big-number-formatters'; +import ChartMarker, { + ChartMarkerStandalone, + getChartMarkerDependentAxisRange, +} from '@veupathdb/components/lib/map/ChartMarker'; +import { + defaultAnimationDuration, + defaultViewport, +} from '@veupathdb/components/lib/map/config/map'; +import { + ColorPaletteDefault, + gradientSequentialColorscaleMap, +} from '@veupathdb/components/lib/types/plots/addOns'; +import { UNSELECTED_DISPLAY_TEXT, UNSELECTED_TOKEN } from '../../../constants'; +import SemanticMarkers from '@veupathdb/components/lib/map/SemanticMarkers'; +import { + DistributionMarkerDataProps, + defaultAnimation, + isApproxSameViewport, + useCategoricalValues, + useDistributionMarkerData, + useDistributionOverlayConfig, +} from '../shared'; +import { + useFindEntityAndVariable, + useSubsettingClient, +} from '../../../../core/hooks/workspace'; +import { DraggableLegendPanel } from '../../DraggableLegendPanel'; +import { MapLegend } from '../../MapLegend'; +import { filtersFromBoundingBox } from '../../../../core/utils/visualization'; +import { sharedStandaloneMarkerProperties } from '../../MarkerConfiguration/CategoricalMarkerPreview'; +import { useToggleStarredVariable } from '../../../../core/hooks/starredVariables'; +import DraggableVisualization from '../../DraggableVisualization'; +import { useStandaloneVizPlugins } from '../../hooks/standaloneVizPlugins'; +import { + MapTypeConfigurationMenu, + MarkerConfigurationOption, +} from '../../MarkerConfiguration/MapTypeConfigurationMenu'; +import { BarPlotMarkerIcon } from '../../MarkerConfiguration/icons'; +import { TabbedDisplayProps } from '@veupathdb/coreui/lib/components/grids/TabbedDisplay'; +import MapVizManagement from '../../MapVizManagement'; +import Spinner from '@veupathdb/components/lib/components/Spinner'; +import { MapFloatingErrorDiv } from '../../MapFloatingErrorDiv'; +import { MapTypeHeaderCounts } from '../MapTypeHeaderCounts'; +import { ChartMarkerPropsWithCounts } from '../../hooks/standaloneMapMarkers'; + +const displayName = 'Bar plots'; + +export const plugin: MapTypePlugin = { + displayName, + ConfigPanelComponent, + MapLayerComponent, + MapOverlayComponent, + MapTypeHeaderDetails, +}; + +function ConfigPanelComponent(props: MapTypeConfigPanelProps) { + const { + apps, + analysisState, + appState, + geoConfigs, + updateConfiguration, + studyId, + studyEntities, + filters, + } = props; + + const geoConfig = geoConfigs[0]; + const subsettingClient = useSubsettingClient(); + const configuration = props.configuration as BarPlotMarkerConfiguration; + const { + selectedVariable, + selectedValues, + binningMethod, + dependentAxisLogScale, + selectedPlotMode, + } = configuration; + + const { entity: overlayEntity, variable: overlayVariable } = + findEntityAndVariable(studyEntities, selectedVariable) ?? {}; + + if ( + overlayEntity == null || + overlayVariable == null || + !Variable.is(overlayVariable) + ) { + throw new Error( + 'Could not find overlay variable: ' + JSON.stringify(selectedVariable) + ); + } + + const filtersIncludingViewport = useMemo(() => { + const viewportFilters = appState.boundsZoomLevel + ? filtersFromBoundingBox( + appState.boundsZoomLevel.bounds, + { + variableId: geoConfig.latitudeVariableId, + entityId: geoConfig.entity.id, + }, + { + variableId: geoConfig.longitudeVariableId, + entityId: geoConfig.entity.id, + } + ) + : []; + return [...(filters ?? []), ...viewportFilters]; + }, [ + appState.boundsZoomLevel, + geoConfig.entity.id, + geoConfig.latitudeVariableId, + geoConfig.longitudeVariableId, + filters, + ]); + + const allFilteredCategoricalValues = useCategoricalValues({ + overlayEntity, + studyId, + overlayVariable, + filters, + }); + + const allVisibleCategoricalValues = useCategoricalValues({ + overlayEntity, + studyId, + overlayVariable, + filters: filtersIncludingViewport, + enabled: configuration.selectedCountsOption === 'visible', + }); + + const previewMarkerData = useMarkerData({ + studyId, + filters, + studyEntities, + geoConfigs, + boundsZoomLevel: appState.boundsZoomLevel, + selectedVariable, + selectedValues, + binningMethod, + dependentAxisLogScale, + valueSpec: selectedPlotMode, + }); + + const continuousMarkerPreview = useMemo(() => { + if ( + !previewMarkerData || + !previewMarkerData.markerProps?.length || + !Array.isArray(previewMarkerData.markerProps[0].data) + ) + return; + const initialDataObject = previewMarkerData.markerProps[0].data.map( + (data) => ({ + label: data.label, + value: 0, + count: 0, + ...(data.color ? { color: data.color } : {}), + }) + ); + /** + * In the chart marker's proportion mode, the values are pre-calculated proportion values. Using these pre-calculated proportion values results + * in an erroneous totalCount summation and some off visualizations in the marker previews. Since no axes/numbers are displayed in the marker + * previews, let's just overwrite the value property with the count property. + * + * NOTE: the donut preview doesn't have proportion mode and was working just fine, but now it's going to receive count data that it neither + * needs nor consumes. + */ + const finalData = previewMarkerData.markerProps.reduce( + (prevData, currData) => + currData.data.map((data, index) => ({ + label: data.label, + // here's the overwrite mentioned in the above comment + value: data.count + prevData[index].count, + count: data.count + prevData[index].count, + ...('color' in prevData[index] + ? { color: prevData[index].color } + : 'color' in data + ? { color: data.color } + : {}), + })), + initialDataObject + ); + return ( + p + c.count, 0))} + dependentAxisLogScale={dependentAxisLogScale} + dependentAxisRange={getChartMarkerDependentAxisRange( + finalData, + dependentAxisLogScale + )} + {...sharedStandaloneMarkerProperties} + /> + ); + }, [dependentAxisLogScale, previewMarkerData]); + + const toggleStarredVariable = useToggleStarredVariable(analysisState); + + const overlayConfiguration = useDistributionOverlayConfig({ + studyId, + filters, + binningMethod, + overlayVariableDescriptor: selectedVariable, + selectedValues, + }); + + const markerVariableConstraints = apps + .find((app) => app.name === 'standalone-map') + ?.visualizations.find( + (viz) => viz.name === 'map-markers' + )?.dataElementConstraints; + + const configurationMenu = ( + + ); + + const markerConfigurationOption: MarkerConfigurationOption = { + type: 'bubble', + displayName, + icon: ( + + ), + configurationMenu, + }; + + const setActiveVisualizationId = useCallback( + (activeVisualizationId?: string) => { + if (configuration == null) return; + updateConfiguration({ + ...configuration, + activeVisualizationId, + }); + }, + [configuration, updateConfiguration] + ); + + const plugins = useStandaloneVizPlugins({ + selectedOverlayConfig: overlayConfiguration.data, + }); + + const mapTypeConfigurationMenuTabs: TabbedDisplayProps< + 'markers' | 'plots' + >['tabs'] = [ + { + key: 'markers', + displayName: 'Markers', + content: configurationMenu, + }, + { + key: 'plots', + displayName: 'Supporting Plots', + content: ( + + ), + }, + ]; + + return ( +
    + +
    + ); +} + +function MapLayerComponent(props: MapTypeMapLayerProps) { + const { + studyEntities, + studyId, + filters, + geoConfigs, + appState: { boundsZoomLevel }, + } = props; + const { + selectedVariable, + selectedValues, + binningMethod, + dependentAxisLogScale, + selectedPlotMode, + } = props.configuration as BarPlotMarkerConfiguration; + const markerData = useMarkerData({ + studyEntities, + studyId, + filters, + geoConfigs, + boundsZoomLevel, + selectedVariable, + selectedValues, + binningMethod, + dependentAxisLogScale, + valueSpec: selectedPlotMode, + }); + + if (markerData.error) return ; + + const markers = markerData.markerProps?.map((markerProps) => ( + + )); + + return ( + <> + {markerData.isFetching && } + {markers && ( + + )} + + ); +} + +function MapOverlayComponent(props: MapTypeMapLayerProps) { + const { + studyEntities, + studyId, + filters, + geoConfigs, + appState: { boundsZoomLevel }, + updateConfiguration, + } = props; + const configuration = props.configuration as BarPlotMarkerConfiguration; + const findEntityAndVariable = useFindEntityAndVariable(); + const { variable: overlayVariable } = + findEntityAndVariable(configuration.selectedVariable) ?? {}; + + const setActiveVisualizationId = useCallback( + (activeVisualizationId?: string) => { + updateConfiguration({ + ...configuration, + activeVisualizationId, + }); + }, + [configuration, updateConfiguration] + ); + + const markerData = useMarkerData({ + studyEntities, + studyId, + filters, + geoConfigs, + boundsZoomLevel, + selectedVariable: configuration.selectedVariable, + binningMethod: configuration.binningMethod, + dependentAxisLogScale: configuration.dependentAxisLogScale, + selectedValues: configuration.selectedValues, + valueSpec: configuration.selectedPlotMode, + }); + + const legendItems = markerData.legendItems; + + const plugins = useStandaloneVizPlugins({ + selectedOverlayConfig: markerData.overlayConfig, + }); + + const toggleStarredVariable = useToggleStarredVariable(props.analysisState); + + return ( + <> + +
    + +
    +
    + + + ); +} + +function MapTypeHeaderDetails(props: MapTypeMapLayerProps) { + const { + selectedVariable, + binningMethod, + selectedValues, + dependentAxisLogScale, + selectedPlotMode, + } = props.configuration as BarPlotMarkerConfiguration; + const markerDataResponse = useMarkerData({ + studyId: props.studyId, + filters: props.filters, + studyEntities: props.studyEntities, + geoConfigs: props.geoConfigs, + boundsZoomLevel: props.appState.boundsZoomLevel, + selectedVariable, + selectedValues, + binningMethod, + dependentAxisLogScale, + valueSpec: selectedPlotMode, + }); + return ( + + ); +} + +const processRawMarkersData = ( + mapElements: StandaloneMapMarkersResponse['mapElements'], + defaultDependentAxisRange: NumberRange, + dependentAxisLogScale: boolean, + vocabulary?: string[], + overlayType?: 'categorical' | 'continuous' +) => { + return mapElements.map( + ({ + geoAggregateValue, + entityCount, + avgLat, + avgLon, + minLat, + minLon, + maxLat, + maxLon, + overlayValues, + }) => { + const { bounds, position } = getBoundsAndPosition( + minLat, + minLon, + maxLat, + maxLon, + avgLat, + avgLon + ); + + const donutData = + vocabulary && overlayValues && overlayValues.length + ? overlayValues.map(({ binLabel, value, count }) => ({ + label: binLabel, + value, + count, + color: + overlayType === 'categorical' + ? ColorPaletteDefault[vocabulary.indexOf(binLabel)] + : gradientSequentialColorscaleMap( + vocabulary.length > 1 + ? vocabulary.indexOf(binLabel) / (vocabulary.length - 1) + : 0.5 + ), + })) + : []; + + // TO DO: address diverging colorscale (especially if there are use-cases) + + // now reorder the data, adding zeroes if necessary. + const reorderedData = + vocabulary != null + ? vocabulary.map( + ( + overlayLabel // overlay label can be 'female' or a bin label '(0,100]' + ) => + donutData.find(({ label }) => label === overlayLabel) ?? { + label: fixLabelForOtherValues(overlayLabel), + value: 0, + count: 0, + } + ) + : // however, if there is no overlay data + // provide a simple entity count marker in the palette's first colour + [ + { + label: 'unknown', + value: entityCount, + color: '#333', + }, + ]; + + const count = + vocabulary != null && overlayValues // if there's an overlay (all expected use cases) + ? overlayValues + .filter(({ binLabel }) => vocabulary.includes(binLabel)) + .reduce((sum, { count }) => (sum = sum + count), 0) + : entityCount; // fallback if not + + return { + data: reorderedData, + id: geoAggregateValue, + key: geoAggregateValue, + bounds, + position, + duration: defaultAnimationDuration, + markerLabel: mFormatter(count), + dependentAxisRange: defaultDependentAxisRange, + dependentAxisLogScale, + } as ChartMarkerPropsWithCounts; + } + ); +}; + +const getBoundsAndPosition = ( + minLat: number, + minLon: number, + maxLat: number, + maxLon: number, + avgLat: number, + avgLon: number +) => ({ + bounds: { + southWest: { lat: minLat, lng: minLon }, + northEast: { lat: maxLat, lng: maxLon }, + }, + position: { lat: avgLat, lng: avgLon }, +}); + +function fixLabelForOtherValues(input: string): string { + return input === UNSELECTED_TOKEN ? UNSELECTED_DISPLAY_TEXT : input; +} + +interface MarkerDataProps extends DistributionMarkerDataProps { + dependentAxisLogScale: boolean; +} + +function useMarkerData(props: MarkerDataProps) { + const { + data: markerData, + error, + isFetching, + } = useDistributionMarkerData(props); + if (markerData == null) return { error, isFetching }; + + const { + mapElements, + totalVisibleEntityCount, + totalVisibleWithOverlayEntityCount, + legendItems, + overlayConfig, + } = markerData; + + // calculate minPos, max and sum for chart marker dependent axis + // assumes the value is a count! (so never negative) + const { valueMax, valueMinPos } = mapElements + .flatMap((el) => ('overlayValues' in el ? el.overlayValues : [])) + .reduce( + ({ valueMax, valueMinPos }, elem) => ({ + valueMax: Math.max(elem.value, valueMax), + valueMinPos: + elem.value > 0 && (valueMinPos == null || elem.value < valueMinPos) + ? elem.value + : valueMinPos, + }), + { + valueMax: 0, + valueMinPos: Infinity, + } + ); + + const defaultDependentAxisRange = getDefaultAxisRange( + null, + 0, + valueMinPos, + valueMax, + props.dependentAxisLogScale + ) as NumberRange; + + /** + * Merge the overlay data into the basicMarkerData, if available, + * and create markers. + */ + const markerProps = processRawMarkersData( + mapElements, + defaultDependentAxisRange, + props.dependentAxisLogScale, + getVocabulary(overlayConfig), + overlayConfig.overlayType + ); + + return { + error, + isFetching, + markerProps, + totalVisibleWithOverlayEntityCount, + totalVisibleEntityCount, + legendItems, + overlayConfig, + boundsZoomLevel: props.boundsZoomLevel, + }; +} + +function getVocabulary(overlayConfig: OverlayConfig) { + switch (overlayConfig.overlayType) { + case 'categorical': + return overlayConfig.overlayValues; + case 'continuous': + return overlayConfig.overlayValues.map((v) => v.binLabel); + default: + return []; + } +} diff --git a/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/BubbleMarkerMapType.tsx b/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/BubbleMarkerMapType.tsx new file mode 100644 index 0000000000..f4c0f52bb0 --- /dev/null +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/BubbleMarkerMapType.tsx @@ -0,0 +1,685 @@ +import BubbleMarker, { + BubbleMarkerProps, +} from '@veupathdb/components/lib/map/BubbleMarker'; +import SemanticMarkers from '@veupathdb/components/lib/map/SemanticMarkers'; +import { + defaultAnimationDuration, + defaultViewport, +} from '@veupathdb/components/lib/map/config/map'; +import { getValueToGradientColorMapper } from '@veupathdb/components/lib/types/plots/addOns'; +import { TabbedDisplayProps } from '@veupathdb/coreui/lib/components/grids/TabbedDisplay'; +import { capitalize, sumBy } from 'lodash'; +import { useCallback, useMemo } from 'react'; +import { + useFindEntityAndVariable, + Filter, + useDataClient, + useStudyEntities, +} from '../../../../core'; +import { + BubbleOverlayConfig, + StandaloneMapBubblesLegendRequestParams, + StandaloneMapBubblesRequestParams, + StandaloneMapBubblesResponse, +} from '../../../../core/api/DataClient/types'; +import { useToggleStarredVariable } from '../../../../core/hooks/starredVariables'; +import { DraggableLegendPanel } from '../../DraggableLegendPanel'; +import { MapLegend } from '../../MapLegend'; +import MapVizManagement from '../../MapVizManagement'; +import { BubbleMarkerConfigurationMenu } from '../../MarkerConfiguration'; +import { + BubbleMarkerConfiguration, + validateProportionValues, +} from '../../MarkerConfiguration/BubbleMarkerConfigurationMenu'; +import { + MapTypeConfigurationMenu, + MarkerConfigurationOption, +} from '../../MarkerConfiguration/MapTypeConfigurationMenu'; +import { BubbleMarkerIcon } from '../../MarkerConfiguration/icons'; +import { useStandaloneVizPlugins } from '../../hooks/standaloneVizPlugins'; +import { getDefaultBubbleOverlayConfig } from '../../utils/defaultOverlayConfig'; +import { + defaultAnimation, + isApproxSameViewport, + useCommonData, +} from '../shared'; +import { + MapTypeConfigPanelProps, + MapTypeMapLayerProps, + MapTypePlugin, +} from '../types'; +import DraggableVisualization from '../../DraggableVisualization'; +import { VariableDescriptor } from '../../../../core/types/variable'; +import { useQuery } from '@tanstack/react-query'; +import { BoundsViewport } from '@veupathdb/components/lib/map/Types'; +import { GeoConfig } from '../../../../core/types/geoConfig'; +import Spinner from '@veupathdb/components/lib/components/Spinner'; +import { MapFloatingErrorDiv } from '../../MapFloatingErrorDiv'; +import { MapTypeHeaderCounts } from '../MapTypeHeaderCounts'; + +const displayName = 'Bubbles'; + +export const plugin: MapTypePlugin = { + displayName, + ConfigPanelComponent: BubbleMapConfigurationPanel, + MapLayerComponent: BubbleMapLayer, + MapOverlayComponent: BubbleLegends, + MapTypeHeaderDetails, +}; + +function BubbleMapConfigurationPanel(props: MapTypeConfigPanelProps) { + const { + apps, + analysisState, + studyEntities, + updateConfiguration, + studyId, + filters, + geoConfigs, + } = props; + + const toggleStarredVariable = useToggleStarredVariable(analysisState); + const markerConfiguration = props.configuration as BubbleMarkerConfiguration; + + const markerVariableConstraints = apps + .find((app) => app.name === 'standalone-map') + ?.visualizations.find( + (viz) => viz.name === 'map-markers' + )?.dataElementConstraints; + + const setActiveVisualizationId = useCallback( + (activeVisualizationId?: string) => { + if (markerConfiguration == null) return; + updateConfiguration({ + ...markerConfiguration, + activeVisualizationId, + }); + }, + [markerConfiguration, updateConfiguration] + ); + + // If the variable or filters have changed on the active marker config + // get the default overlay config. + const activeOverlayConfig = useOverlayConfig({ + studyId, + filters, + ...markerConfiguration, + }); + + const plugins = useStandaloneVizPlugins({ + selectedOverlayConfig: activeOverlayConfig, + }); + + const configurationMenu = ( + + ); + + const markerConfigurationOption: MarkerConfigurationOption = { + type: 'bubble', + displayName, + icon: ( + + ), + configurationMenu, + }; + + const mapTypeConfigurationMenuTabs: TabbedDisplayProps< + 'markers' | 'plots' + >['tabs'] = [ + { + key: 'markers', + displayName: 'Markers', + content: configurationMenu, + }, + { + key: 'plots', + displayName: 'Supporting Plots', + content: ( + + ), + }, + ]; + + return ( +
    + +
    + ); +} + +/** + * Renders marker and legend components + */ +function BubbleMapLayer(props: MapTypeMapLayerProps) { + const { studyId, filters, appState, configuration, geoConfigs } = props; + const markersData = useMarkerData({ + boundsZoomLevel: appState.boundsZoomLevel, + configuration: configuration as BubbleMarkerConfiguration, + geoConfigs, + studyId, + filters, + }); + if (markersData.error) + return ; + + const markers = markersData.data?.markersData.map((markerProps) => ( + + )); + + return ( + <> + {markersData.isFetching && } + {markers && ( + + )} + + ); +} + +function BubbleLegends(props: MapTypeMapLayerProps) { + const { studyId, filters, geoConfigs, appState, updateConfiguration } = props; + const configuration = props.configuration as BubbleMarkerConfiguration; + const findEntityAndVariable = useFindEntityAndVariable(); + const { variable: overlayVariable } = + findEntityAndVariable(configuration.selectedVariable) ?? {}; + + const legendData = useLegendData({ + studyId, + filters, + geoConfigs, + configuration, + boundsZoomLevel: appState.boundsZoomLevel, + }); + + const setActiveVisualizationId = useCallback( + (activeVisualizationId?: string) => { + updateConfiguration({ + ...configuration, + activeVisualizationId, + }); + }, + [configuration, updateConfiguration] + ); + + const plugins = useStandaloneVizPlugins({ + overlayHelp: 'Overlay variables are not available for this map type', + }); + + const toggleStarredVariable = useToggleStarredVariable(props.analysisState); + + return ( + <> + +
    + {legendData.error ? ( +
    +
    {String(legendData.error)}
    +
    + ) : ( + + )} +
    +
    + +
    + 'white'), + }} + /> +
    +
    + + + ); +} + +function MapTypeHeaderDetails(props: MapTypeMapLayerProps) { + const configuration = props.configuration as BubbleMarkerConfiguration; + const markerDataResponse = useMarkerData({ + studyId: props.studyId, + filters: props.filters, + geoConfigs: props.geoConfigs, + boundsZoomLevel: props.appState.boundsZoomLevel, + configuration, + }); + return ( + + ); +} + +const processRawBubblesData = ( + mapElements: StandaloneMapBubblesResponse['mapElements'], + aggregationConfig?: BubbleOverlayConfig['aggregationConfig'], + bubbleValueToDiameterMapper?: (value: number) => number, + bubbleValueToColorMapper?: (value: number) => string +) => { + return mapElements.map( + ({ + geoAggregateValue, + entityCount, + avgLat, + avgLon, + minLat, + minLon, + maxLat, + maxLon, + overlayValue, + }) => { + const { bounds, position } = getBoundsAndPosition( + minLat, + minLon, + maxLat, + maxLon, + avgLat, + avgLon + ); + + // TO DO: address diverging colorscale (especially if there are use-cases) + + const bubbleData = { + value: entityCount, + diameter: bubbleValueToDiameterMapper?.(entityCount) ?? 0, + colorValue: Number(overlayValue), + colorLabel: aggregationConfig + ? aggregationConfig.overlayType === 'continuous' + ? capitalize(aggregationConfig.aggregator) + : 'Proportion' + : undefined, + color: bubbleValueToColorMapper?.(Number(overlayValue)), + }; + + return { + id: geoAggregateValue, + key: geoAggregateValue, + bounds, + position, + duration: defaultAnimationDuration, + data: bubbleData, + markerLabel: String(entityCount), + } as BubbleMarkerProps; + } + ); +}; + +const getBoundsAndPosition = ( + minLat: number, + minLon: number, + maxLat: number, + maxLon: number, + avgLat: number, + avgLon: number +) => ({ + bounds: { + southWest: { lat: minLat, lng: minLon }, + northEast: { lat: maxLat, lng: maxLon }, + }, + position: { lat: avgLat, lng: avgLon }, +}); + +interface OverlayConfigProps { + selectedVariable?: VariableDescriptor; + studyId: string; + filters?: Filter[]; + aggregator?: BubbleMarkerConfiguration['aggregator']; + numeratorValues?: BubbleMarkerConfiguration['numeratorValues']; + denominatorValues?: BubbleMarkerConfiguration['denominatorValues']; +} + +function useOverlayConfig(props: OverlayConfigProps) { + const { + studyId, + filters = [], + aggregator, + numeratorValues, + denominatorValues, + selectedVariable, + } = props; + const findEntityAndVariable = useFindEntityAndVariable(); + const entityAndVariable = findEntityAndVariable(selectedVariable); + + if (entityAndVariable == null) + throw new Error( + 'Invalid selected variable: ' + JSON.stringify(selectedVariable) + ); + const { entity: overlayEntity, variable: overlayVariable } = + entityAndVariable; + // If the variable or filters have changed on the active marker config + // get the default overlay config. + return useMemo(() => { + return getDefaultBubbleOverlayConfig({ + studyId, + filters, + overlayVariable, + overlayEntity, + aggregator, + numeratorValues, + denominatorValues, + }); + }, [ + studyId, + filters, + overlayVariable, + overlayEntity, + aggregator, + numeratorValues, + denominatorValues, + ]); +} + +interface DataProps { + boundsZoomLevel?: BoundsViewport; + configuration: BubbleMarkerConfiguration; + geoConfigs: GeoConfig[]; + studyId: string; + filters?: Filter[]; +} + +function useLegendData(props: DataProps) { + const { boundsZoomLevel, configuration, geoConfigs, studyId, filters } = + props; + + const studyEntities = useStudyEntities(); + + const dataClient = useDataClient(); + + const { selectedVariable, numeratorValues, denominatorValues, aggregator } = + configuration as BubbleMarkerConfiguration; + + const { outputEntity, geoAggregateVariables } = useCommonData( + selectedVariable, + geoConfigs, + studyEntities, + boundsZoomLevel + ); + + const outputEntityId = outputEntity?.id; + + const overlayConfig = useOverlayConfig({ + studyId, + filters, + selectedVariable, + aggregator, + numeratorValues, + denominatorValues, + }); + + const disabled = + numeratorValues?.length === 0 || + denominatorValues?.length === 0 || + !validateProportionValues(numeratorValues, denominatorValues); + + const legendRequestParams: StandaloneMapBubblesLegendRequestParams = { + studyId, + filters: filters || [], + config: { + outputEntityId, + colorLegendConfig: { + geoAggregateVariable: geoAggregateVariables.at(-1)!, + quantitativeOverlayConfig: overlayConfig, + }, + sizeConfig: { + geoAggregateVariable: geoAggregateVariables[0], + }, + }, + }; + + return useQuery({ + queryKey: ['bubbleMarkers', 'legendData', legendRequestParams], + queryFn: async () => { + // temporarily convert potentially date-strings to numbers + // but don't worry - we are also temporarily disabling date variables from bubble mode + const temp = await dataClient.getStandaloneBubblesLegend( + 'standalone-map', + legendRequestParams + ); + + const bubbleLegendData = { + minColorValue: Number(temp.minColorValue), + maxColorValue: Number(temp.maxColorValue), + minSizeValue: temp.minSizeValue, + maxSizeValue: temp.maxSizeValue, + }; + + const adjustedSizeData = + bubbleLegendData.minSizeValue === bubbleLegendData.maxSizeValue + ? { + minSizeValue: 0, + maxSizeValue: bubbleLegendData.maxSizeValue || 1, + } + : undefined; + + const adjustedColorData = + bubbleLegendData.minColorValue === bubbleLegendData.maxColorValue + ? bubbleLegendData.maxColorValue >= 0 + ? { + minColorValue: 0, + maxColorValue: bubbleLegendData.maxColorValue || 1, + } + : { + minColorValue: bubbleLegendData.minColorValue, + maxColorValue: 0, + } + : undefined; + + const adjustedBubbleLegendData = { + ...bubbleLegendData, + ...adjustedSizeData, + ...adjustedColorData, + }; + + const bubbleValueToDiameterMapper = (value: number) => { + // const largestCircleArea = 9000; + const largestCircleDiameter = 90; + const smallestCircleDiameter = 10; + + // Area scales directly with value + // const constant = largestCircleArea / maxOverlayCount; + // const area = value * constant; + // const radius = Math.sqrt(area / Math.PI); + + // Radius scales with log_10 of value + // const constant = 20; + // const radius = Math.log10(value) * constant; + + // Radius scales directly with value + // y = mx + b, m = (y2 - y1) / (x2 - x1), b = y1 - m * x1 + const m = + (largestCircleDiameter - smallestCircleDiameter) / + (adjustedBubbleLegendData.maxSizeValue - + adjustedBubbleLegendData.minSizeValue); + const b = + smallestCircleDiameter - m * adjustedBubbleLegendData.minSizeValue; + const diameter = m * value + b; + + // return 2 * radius; + return diameter; + }; + + const bubbleValueToColorMapper = getValueToGradientColorMapper( + adjustedBubbleLegendData.minColorValue, + adjustedBubbleLegendData.maxColorValue + ); + + return { + bubbleLegendData: adjustedBubbleLegendData, + bubbleValueToDiameterMapper, + bubbleValueToColorMapper, + }; + }, + enabled: !disabled, + }); +} + +function useMarkerData(props: DataProps) { + const { boundsZoomLevel, configuration, geoConfigs, studyId, filters } = + props; + + const { numeratorValues, denominatorValues } = configuration; + + const disabled = + numeratorValues?.length === 0 || + denominatorValues?.length === 0 || + !validateProportionValues(numeratorValues, denominatorValues); + + const studyEntities = useStudyEntities(); + const dataClient = useDataClient(); + + const { + outputEntity, + latitudeVariable, + longitudeVariable, + geoAggregateVariable, + viewport, + } = useCommonData( + configuration.selectedVariable, + geoConfigs, + studyEntities, + boundsZoomLevel + ); + + const outputEntityId = outputEntity?.id; + + const overlayConfig = useOverlayConfig({ + studyId, + filters, + ...configuration, + }); + + const markerRequestParams: StandaloneMapBubblesRequestParams = { + studyId, + filters: filters || [], + config: { + geoAggregateVariable, + latitudeVariable, + longitudeVariable, + overlayConfig, + outputEntityId, + valueSpec: 'count', + viewport, + }, + }; + const { data: legendData } = useLegendData(props); + + // FIXME Don't make dependent on legend data + return useQuery({ + queryKey: ['bubbleMarkers', 'markerData', markerRequestParams], + queryFn: async () => { + const rawMarkersData = await dataClient.getStandaloneBubbles( + 'standalone-map', + markerRequestParams + ); + const { bubbleValueToColorMapper, bubbleValueToDiameterMapper } = + legendData ?? {}; + + const totalVisibleEntityCount = rawMarkersData.mapElements.reduce( + (acc, curr) => { + return acc + curr.entityCount; + }, + 0 + ); + + /** + * Merge the overlay data into the basicMarkerData, if available, + * and create markers. + */ + const finalMarkersData = processRawBubblesData( + rawMarkersData.mapElements, + overlayConfig.aggregationConfig, + bubbleValueToDiameterMapper, + bubbleValueToColorMapper + ); + + const totalVisibleWithOverlayEntityCount = sumBy( + rawMarkersData.mapElements, + 'entityCount' + ); + + return { + markersData: finalMarkersData, + totalVisibleWithOverlayEntityCount, + totalVisibleEntityCount, + boundsZoomLevel, + }; + }, + enabled: !disabled, + }); +} diff --git a/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/DonutMarkerMapType.tsx b/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/DonutMarkerMapType.tsx new file mode 100644 index 0000000000..9fae72f96e --- /dev/null +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/DonutMarkerMapType.tsx @@ -0,0 +1,588 @@ +import React from 'react'; +import DonutMarker, { + DonutMarkerProps, + DonutMarkerStandalone, +} from '@veupathdb/components/lib/map/DonutMarker'; +import SemanticMarkers from '@veupathdb/components/lib/map/SemanticMarkers'; +import { + defaultAnimationDuration, + defaultViewport, +} from '@veupathdb/components/lib/map/config/map'; +import { + ColorPaletteDefault, + gradientSequentialColorscaleMap, +} from '@veupathdb/components/lib/types/plots/addOns'; +import { useCallback, useMemo } from 'react'; +import { UNSELECTED_DISPLAY_TEXT, UNSELECTED_TOKEN } from '../../../constants'; +import { + StandaloneMapMarkersResponse, + Variable, + useFindEntityAndVariable, + useSubsettingClient, +} from '../../../../core'; +import { useToggleStarredVariable } from '../../../../core/hooks/starredVariables'; +import { kFormatter } from '../../../../core/utils/big-number-formatters'; +import { findEntityAndVariable } from '../../../../core/utils/study-metadata'; +import { filtersFromBoundingBox } from '../../../../core/utils/visualization'; +import { DraggableLegendPanel } from '../../DraggableLegendPanel'; +import { MapLegend } from '../../MapLegend'; +import { sharedStandaloneMarkerProperties } from '../../MarkerConfiguration/CategoricalMarkerPreview'; +import { + PieMarkerConfiguration, + PieMarkerConfigurationMenu, +} from '../../MarkerConfiguration/PieMarkerConfigurationMenu'; +import { + DistributionMarkerDataProps, + defaultAnimation, + isApproxSameViewport, + useCategoricalValues, + useDistributionMarkerData, + useDistributionOverlayConfig, +} from '../shared'; +import { + MapTypeConfigPanelProps, + MapTypeMapLayerProps, + MapTypePlugin, +} from '../types'; +import DraggableVisualization from '../../DraggableVisualization'; +import { useStandaloneVizPlugins } from '../../hooks/standaloneVizPlugins'; +import { + MapTypeConfigurationMenu, + MarkerConfigurationOption, +} from '../../MarkerConfiguration/MapTypeConfigurationMenu'; +import { DonutMarkersIcon } from '../../MarkerConfiguration/icons'; +import { TabbedDisplayProps } from '@veupathdb/coreui/lib/components/grids/TabbedDisplay'; +import MapVizManagement from '../../MapVizManagement'; +import Spinner from '@veupathdb/components/lib/components/Spinner'; +import { MapFloatingErrorDiv } from '../../MapFloatingErrorDiv'; +import { MapTypeHeaderCounts } from '../MapTypeHeaderCounts'; + +const displayName = 'Donuts'; + +export const plugin: MapTypePlugin = { + displayName, + ConfigPanelComponent, + MapLayerComponent, + MapOverlayComponent, + MapTypeHeaderDetails, +}; + +function ConfigPanelComponent(props: MapTypeConfigPanelProps) { + const { + apps, + analysisState, + appState, + geoConfigs, + updateConfiguration, + studyId, + studyEntities, + filters, + } = props; + + const geoConfig = geoConfigs[0]; + const subsettingClient = useSubsettingClient(); + const configuration = props.configuration as PieMarkerConfiguration; + const { selectedVariable, selectedValues, binningMethod } = configuration; + + const { entity: overlayEntity, variable: overlayVariable } = + findEntityAndVariable(studyEntities, selectedVariable) ?? {}; + + if ( + overlayEntity == null || + overlayVariable == null || + !Variable.is(overlayVariable) + ) { + throw new Error( + 'Could not find overlay variable: ' + JSON.stringify(selectedVariable) + ); + } + + const filtersIncludingViewport = useMemo(() => { + const viewportFilters = appState.boundsZoomLevel + ? filtersFromBoundingBox( + appState.boundsZoomLevel.bounds, + { + variableId: geoConfig.latitudeVariableId, + entityId: geoConfig.entity.id, + }, + { + variableId: geoConfig.longitudeVariableId, + entityId: geoConfig.entity.id, + } + ) + : []; + return [...(filters ?? []), ...viewportFilters]; + }, [ + appState.boundsZoomLevel, + geoConfig.entity.id, + geoConfig.latitudeVariableId, + geoConfig.longitudeVariableId, + filters, + ]); + + const allFilteredCategoricalValues = useCategoricalValues({ + overlayEntity, + studyId, + overlayVariable, + filters, + }); + + const allVisibleCategoricalValues = useCategoricalValues({ + overlayEntity, + studyId, + overlayVariable, + filters: filtersIncludingViewport, + enabled: configuration.selectedCountsOption === 'visible', + }); + + const previewMarkerResult = useMarkerData({ + studyId, + filters, + studyEntities, + geoConfigs, + boundsZoomLevel: appState.boundsZoomLevel, + selectedVariable: configuration.selectedVariable, + binningMethod: configuration.binningMethod, + selectedValues: configuration.selectedValues, + valueSpec: 'count', + }); + + const continuousMarkerPreview = useMemo(() => { + if ( + !previewMarkerResult || + !previewMarkerResult.markerProps?.length || + !Array.isArray(previewMarkerResult.markerProps[0].data) + ) + return; + const initialDataObject = previewMarkerResult.markerProps[0].data.map( + (data) => ({ + label: data.label, + value: 0, + ...(data.color ? { color: data.color } : {}), + }) + ); + const finalData = previewMarkerResult.markerProps.reduce( + (prevData, currData) => + currData.data.map((data, index) => ({ + label: data.label, + value: data.value + prevData[index].value, + ...('color' in prevData[index] + ? { color: prevData[index].color } + : 'color' in data + ? { color: data.color } + : {}), + })), + initialDataObject + ); + return ( + p + c.value, 0))} + {...sharedStandaloneMarkerProperties} + /> + ); + }, [previewMarkerResult]); + + const toggleStarredVariable = useToggleStarredVariable(analysisState); + + const overlayConfiguration = useDistributionOverlayConfig({ + studyId, + filters, + binningMethod, + overlayVariableDescriptor: selectedVariable, + selectedValues, + }); + + const markerVariableConstraints = apps + .find((app) => app.name === 'standalone-map') + ?.visualizations.find( + (viz) => viz.name === 'map-markers' + )?.dataElementConstraints; + + const configurationMenu = ( + + ); + + const markerConfigurationOption: MarkerConfigurationOption = { + type: 'pie', + displayName, + icon: ( + + ), + configurationMenu, + }; + + const plugins = useStandaloneVizPlugins({ + selectedOverlayConfig: overlayConfiguration.data, + }); + + const setActiveVisualizationId = useCallback( + (activeVisualizationId?: string) => { + if (configuration == null) return; + updateConfiguration({ + ...configuration, + activeVisualizationId, + }); + }, + [configuration, updateConfiguration] + ); + + const mapTypeConfigurationMenuTabs: TabbedDisplayProps< + 'markers' | 'plots' + >['tabs'] = [ + { + key: 'markers', + displayName: 'Markers', + content: configurationMenu, + }, + { + key: 'plots', + displayName: 'Supporting Plots', + content: ( + + ), + }, + ]; + + return ( +
    + +
    + ); +} + +function MapLayerComponent(props: MapTypeMapLayerProps) { + const { selectedVariable, binningMethod, selectedValues } = + props.configuration as PieMarkerConfiguration; + const markerDataResponse = useMarkerData({ + studyId: props.studyId, + filters: props.filters, + studyEntities: props.studyEntities, + geoConfigs: props.geoConfigs, + boundsZoomLevel: props.appState.boundsZoomLevel, + selectedVariable, + selectedValues, + binningMethod, + valueSpec: 'count', + }); + + if (markerDataResponse.error) + return ; + + const markers = markerDataResponse.markerProps?.map((markerProps) => ( + + )); + return ( + <> + {markerDataResponse.isFetching && } + {markers && ( + + )} + + ); +} + +function MapOverlayComponent(props: MapTypeMapLayerProps) { + const { + studyId, + filters, + studyEntities, + geoConfigs, + appState: { boundsZoomLevel }, + updateConfiguration, + } = props; + const { + selectedVariable, + selectedValues, + binningMethod, + activeVisualizationId, + } = props.configuration as PieMarkerConfiguration; + const findEntityAndVariable = useFindEntityAndVariable(); + const { variable: overlayVariable } = + findEntityAndVariable(selectedVariable) ?? {}; + const setActiveVisualizationId = useCallback( + (activeVisualizationId?: string) => { + updateConfiguration({ + ...(props.configuration as PieMarkerConfiguration), + activeVisualizationId, + }); + }, + [props.configuration, updateConfiguration] + ); + + const data = useMarkerData({ + studyId, + filters, + studyEntities, + geoConfigs, + boundsZoomLevel, + binningMethod, + selectedVariable, + selectedValues, + valueSpec: 'count', + }); + + const plugins = useStandaloneVizPlugins({ + selectedOverlayConfig: data.overlayConfig, + }); + + const toggleStarredVariable = useToggleStarredVariable(props.analysisState); + + return ( + <> + +
    + +
    +
    + + + ); +} + +function MapTypeHeaderDetails(props: MapTypeMapLayerProps) { + const { selectedVariable, binningMethod, selectedValues } = + props.configuration as PieMarkerConfiguration; + const markerDataResponse = useMarkerData({ + studyId: props.studyId, + filters: props.filters, + studyEntities: props.studyEntities, + geoConfigs: props.geoConfigs, + boundsZoomLevel: props.appState.boundsZoomLevel, + selectedVariable, + selectedValues, + binningMethod, + valueSpec: 'count', + }); + return ( + + ); +} + +function useMarkerData(props: DistributionMarkerDataProps) { + const { + data: markerData, + error, + isFetching, + } = useDistributionMarkerData(props); + + if (markerData == null) return { error, isFetching }; + + const { + mapElements, + totalVisibleEntityCount, + totalVisibleWithOverlayEntityCount, + legendItems, + overlayConfig, + boundsZoomLevel, + } = markerData; + + const vocabulary = + overlayConfig.overlayType === 'categorical' // switch statement style guide time!! + ? overlayConfig.overlayValues + : overlayConfig.overlayType === 'continuous' + ? overlayConfig.overlayValues.map((ov) => + typeof ov === 'object' ? ov.binLabel : '' + ) + : undefined; + + /** + * Merge the overlay data into the basicMarkerData, if available, + * and create markers. + */ + const markerProps = processRawMarkersData( + mapElements, + vocabulary, + overlayConfig.overlayType + ); + + return { + error, + isFetching, + markerProps, + totalVisibleWithOverlayEntityCount, + totalVisibleEntityCount, + legendItems, + overlayConfig, + boundsZoomLevel, + }; +} + +const processRawMarkersData = ( + mapElements: StandaloneMapMarkersResponse['mapElements'], + vocabulary?: string[], + overlayType?: 'categorical' | 'continuous' +): DonutMarkerProps[] => { + return mapElements.map( + ({ + geoAggregateValue, + entityCount, + avgLat, + avgLon, + minLat, + minLon, + maxLat, + maxLon, + overlayValues, + }) => { + const { bounds, position } = getBoundsAndPosition( + minLat, + minLon, + maxLat, + maxLon, + avgLat, + avgLon + ); + + const donutData = + vocabulary && overlayValues && overlayValues.length + ? overlayValues.map(({ binLabel, value }) => ({ + label: binLabel, + value: value, + color: + overlayType === 'categorical' + ? ColorPaletteDefault[vocabulary.indexOf(binLabel)] + : gradientSequentialColorscaleMap( + vocabulary.length > 1 + ? vocabulary.indexOf(binLabel) / (vocabulary.length - 1) + : 0.5 + ), + })) + : []; + + // TO DO: address diverging colorscale (especially if there are use-cases) + + // now reorder the data, adding zeroes if necessary. + const reorderedData = + vocabulary != null + ? vocabulary.map( + ( + overlayLabel // overlay label can be 'female' or a bin label '(0,100]' + ) => + donutData.find(({ label }) => label === overlayLabel) ?? { + label: fixLabelForOtherValues(overlayLabel), + value: 0, + } + ) + : // however, if there is no overlay data + // provide a simple entity count marker in the palette's first colour + [ + { + label: 'unknown', + value: entityCount, + color: '#333', + }, + ]; + + const count = + vocabulary != null && overlayValues // if there's an overlay (all expected use cases) + ? overlayValues + .filter(({ binLabel }) => vocabulary.includes(binLabel)) + .reduce((sum, { count }) => (sum = sum + count), 0) + : entityCount; // fallback if not + + return { + data: reorderedData, + id: geoAggregateValue, + key: geoAggregateValue, + bounds, + position, + duration: defaultAnimationDuration, + markerLabel: kFormatter(count), + }; + } + ); +}; + +const getBoundsAndPosition = ( + minLat: number, + minLon: number, + maxLat: number, + maxLon: number, + avgLat: number, + avgLon: number +) => ({ + bounds: { + southWest: { lat: minLat, lng: minLon }, + northEast: { lat: maxLat, lng: maxLon }, + }, + position: { lat: avgLat, lng: avgLon }, +}); + +function fixLabelForOtherValues(input: string): string { + return input === UNSELECTED_TOKEN ? UNSELECTED_DISPLAY_TEXT : input; +} diff --git a/packages/libs/eda/src/lib/map/analysis/mapTypes/shared.ts b/packages/libs/eda/src/lib/map/analysis/mapTypes/shared.ts new file mode 100644 index 0000000000..1b3733caa1 --- /dev/null +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/shared.ts @@ -0,0 +1,354 @@ +import geohashAnimation from '@veupathdb/components/lib/map/animation_functions/geohash'; +import { defaultAnimationDuration } from '@veupathdb/components/lib/map/config/map'; +import { VariableDescriptor } from '../../../core/types/variable'; +import { GeoConfig } from '../../../core/types/geoConfig'; +import { + CategoricalVariableDataShape, + Filter, + StandaloneMapMarkersRequestParams, + StudyEntity, + Variable, + useDataClient, + useFindEntityAndVariable, + useSubsettingClient, + OverlayConfig, +} from '../../../core'; +import { BoundsViewport } from '@veupathdb/components/lib/map/Types'; +import { findEntityAndVariable } from '../../../core/utils/study-metadata'; +import { leastAncestralEntity } from '../../../core/utils/data-element-constraints'; +import { GLOBAL_VIEWPORT } from '../hooks/standaloneMapMarkers'; +import { useQuery } from '@tanstack/react-query'; +import { + DefaultOverlayConfigProps, + getDefaultOverlayConfig, +} from '../utils/defaultOverlayConfig'; +import { sumBy } from 'lodash'; +import { LegendItemsProps } from '@veupathdb/components/lib/components/plotControls/PlotListLegend'; +import { UNSELECTED_DISPLAY_TEXT, UNSELECTED_TOKEN } from '../../constants'; +import { + ColorPaletteDefault, + gradientSequentialColorscaleMap, +} from '@veupathdb/components/lib/types/plots'; +import { getCategoricalValues } from '../utils/categoricalValues'; +import { Viewport } from '@veupathdb/components/lib/map/MapVEuMap'; + +export const defaultAnimation = { + method: 'geohash', + animationFunction: geohashAnimation, + duration: defaultAnimationDuration, +}; + +export interface SharedMarkerConfigurations { + selectedVariable: VariableDescriptor; + activeVisualizationId?: string; +} + +export function useCommonData( + selectedVariable: VariableDescriptor, + geoConfigs: GeoConfig[], + studyEntities: StudyEntity[], + boundsZoomLevel?: BoundsViewport +) { + const geoConfig = geoConfigs[0]; + + const { entity: overlayEntity, variable: overlayVariable } = + findEntityAndVariable(studyEntities, selectedVariable) ?? {}; + + if (overlayEntity == null || overlayVariable == null) { + throw new Error( + 'Could not find overlay variable: ' + JSON.stringify(selectedVariable) + ); + } + + if (!Variable.is(overlayVariable)) { + throw new Error('Not a variable'); + } + + const outputEntity = leastAncestralEntity( + [overlayEntity, geoConfig.entity], + studyEntities + ); + + if (outputEntity == null) { + throw new Error('Output entity not found.'); + } + + // prepare some info that the map-markers and overlay requests both need + const { latitudeVariable, longitudeVariable } = { + latitudeVariable: { + entityId: geoConfig.entity.id, + variableId: geoConfig.latitudeVariableId, + }, + longitudeVariable: { + entityId: geoConfig.entity.id, + variableId: geoConfig.longitudeVariableId, + }, + }; + + // handle the geoAggregateVariable separately because it changes with zoom level + // and we don't want that to change overlayVariableAndEntity etc because that invalidates + // the overlayConfigPromise + + const geoAggregateVariables = geoConfig.aggregationVariableIds.map( + (variableId) => ({ + entityId: geoConfig.entity.id, + variableId, + }) + ); + + const aggregrationLevel = boundsZoomLevel?.zoomLevel + ? geoConfig.zoomLevelToAggregationLevel(boundsZoomLevel?.zoomLevel) - 1 + : 0; + + const geoAggregateVariable = geoAggregateVariables[aggregrationLevel]; + + const viewport = boundsZoomLevel + ? { + latitude: { + xMin: boundsZoomLevel.bounds.southWest.lat, + xMax: boundsZoomLevel.bounds.northEast.lat, + }, + longitude: { + left: boundsZoomLevel.bounds.southWest.lng, + right: boundsZoomLevel.bounds.northEast.lng, + }, + } + : GLOBAL_VIEWPORT; + + return { + overlayEntity, + overlayVariable, + outputEntity, + latitudeVariable, + longitudeVariable, + geoAggregateVariable, + geoAggregateVariables, + viewport, + }; +} + +export interface DistributionOverlayConfigProps { + studyId: string; + filters?: Filter[]; + overlayVariableDescriptor: VariableDescriptor; + selectedValues: string[] | undefined; + binningMethod: DefaultOverlayConfigProps['binningMethod']; +} + +export function useDistributionOverlayConfig( + props: DistributionOverlayConfigProps +) { + const dataClient = useDataClient(); + const subsettingClient = useSubsettingClient(); + const findEntityAndVariable = useFindEntityAndVariable(); + return useQuery({ + keepPreviousData: true, + queryKey: ['distributionOverlayConfig', props], + queryFn: async function getOverlayConfig() { + if (props.selectedValues) { + const overlayConfig: OverlayConfig = { + overlayType: 'categorical', + overlayValues: props.selectedValues, + overlayVariable: props.overlayVariableDescriptor, + }; + return overlayConfig; + } + console.log('fetching data for distributionOverlayConfig'); + const { entity: overlayEntity, variable: overlayVariable } = + findEntityAndVariable(props.overlayVariableDescriptor) ?? {}; + return getDefaultOverlayConfig({ + studyId: props.studyId, + filters: props.filters ?? [], + overlayEntity, + overlayVariable, + dataClient, + subsettingClient, + binningMethod: props.binningMethod, + }); + }, + }); +} + +export interface DistributionMarkerDataProps { + studyId: string; + filters?: Filter[]; + studyEntities: StudyEntity[]; + geoConfigs: GeoConfig[]; + boundsZoomLevel?: BoundsViewport; + selectedVariable: VariableDescriptor; + selectedValues: string[] | undefined; + binningMethod: DefaultOverlayConfigProps['binningMethod']; + valueSpec: StandaloneMapMarkersRequestParams['config']['valueSpec']; +} + +export function useDistributionMarkerData(props: DistributionMarkerDataProps) { + const { + boundsZoomLevel, + selectedVariable, + binningMethod, + geoConfigs, + studyId, + filters, + studyEntities, + selectedValues, + valueSpec, + } = props; + + const dataClient = useDataClient(); + + const { + geoAggregateVariable, + outputEntity: { id: outputEntityId }, + latitudeVariable, + longitudeVariable, + viewport, + } = useCommonData( + selectedVariable, + geoConfigs, + studyEntities, + boundsZoomLevel + ); + + const overlayConfigResult = useDistributionOverlayConfig({ + studyId, + filters, + binningMethod, + overlayVariableDescriptor: selectedVariable, + selectedValues, + }); + + if (overlayConfigResult.error) { + throw new Error('Could not get overlay config'); + } + + const requestParams: StandaloneMapMarkersRequestParams = { + studyId, + filters: filters || [], + config: { + geoAggregateVariable, + latitudeVariable, + longitudeVariable, + overlayConfig: overlayConfigResult.data, + outputEntityId, + valueSpec, + viewport, + }, + }; + const overlayConfig = overlayConfigResult.data; + + return useQuery({ + keepPreviousData: true, + queryKey: ['mapMarkers', requestParams], + queryFn: async () => { + const markerData = await dataClient.getStandaloneMapMarkers( + 'standalone-map', + requestParams + ); + if (overlayConfig == null) return; + + const vocabulary = + overlayConfig.overlayType === 'categorical' // switch statement style guide time!! + ? overlayConfig.overlayValues + : overlayConfig.overlayType === 'continuous' + ? overlayConfig.overlayValues.map((ov) => + typeof ov === 'object' ? ov.binLabel : '' + ) + : undefined; + + const totalVisibleEntityCount = markerData?.mapElements.reduce( + (acc, curr) => { + return acc + curr.entityCount; + }, + 0 + ); + + const countSum = sumBy(markerData?.mapElements, 'entityCount'); + + /** + * create custom legend data + */ + const legendItems: LegendItemsProps[] = + vocabulary?.map((label) => ({ + label: fixLabelForOtherValues(label), + marker: 'square', + markerColor: + overlayConfig.overlayType === 'categorical' + ? ColorPaletteDefault[vocabulary.indexOf(label)] + : overlayConfig.overlayType === 'continuous' + ? gradientSequentialColorscaleMap( + vocabulary.length > 1 + ? vocabulary.indexOf(label) / (vocabulary.length - 1) + : 0.5 + ) + : undefined, + // has any geo-facet got an array of overlay data + // containing at least one element that satisfies label==label + hasData: markerData.mapElements.some( + (el) => + // TS says el could potentially be a number, and I don't know why + typeof el === 'object' && + 'overlayValues' in el && + el.overlayValues.some((ov) => ov.binLabel === label) + ), + group: 1, + rank: 1, + })) ?? []; + + return { + mapElements: markerData.mapElements, + totalVisibleWithOverlayEntityCount: countSum, + totalVisibleEntityCount, + legendItems, + overlayConfig, + boundsZoomLevel, + }; + }, + enabled: overlayConfig != null, + }); +} + +function fixLabelForOtherValues(input: string): string { + return input === UNSELECTED_TOKEN ? UNSELECTED_DISPLAY_TEXT : input; +} + +interface CategoricalValuesProps { + studyId: string; + filters?: Filter[]; + overlayEntity: StudyEntity; + overlayVariable: Variable; + enabled?: boolean; +} +export function useCategoricalValues(props: CategoricalValuesProps) { + const subsettingClient = useSubsettingClient(); + return useQuery({ + queryKey: [ + 'categoricalValues', + props.studyId, + props.filters, + props.overlayEntity.id, + props.overlayVariable.id, + ], + queryFn: () => { + if (!CategoricalVariableDataShape.is(props.overlayVariable.dataShape)) { + return undefined; + } + return getCategoricalValues({ + studyId: props.studyId, + filters: props.filters, + subsettingClient, + overlayEntity: props.overlayEntity, + overlayVariable: props.overlayVariable, + }); + }, + enabled: props.enabled ?? true, + }); +} + +export function isApproxSameViewport(v1: Viewport, v2: Viewport) { + const epsilon = 2.0; + return ( + v1.zoom === v2.zoom && + Math.abs(v1.center[0] - v2.center[0]) < epsilon && + Math.abs(v1.center[1] - v2.center[1]) < epsilon + ); +} diff --git a/packages/libs/eda/src/lib/map/analysis/mapTypes/types.ts b/packages/libs/eda/src/lib/map/analysis/mapTypes/types.ts new file mode 100644 index 0000000000..66fd2bd865 --- /dev/null +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/types.ts @@ -0,0 +1,69 @@ +import { ComponentType } from 'react'; +import { + AnalysisState, + Filter, + PromiseHookState, + StudyEntity, +} from '../../../core'; +import { GeoConfig } from '../../../core/types/geoConfig'; +import { ComputationAppOverview } from '../../../core/types/visualization'; +import { AppState } from '../appState'; +import { EntityCounts } from '../../../core/hooks/entityCounts'; + +export interface MapTypeConfigPanelProps { + apps: ComputationAppOverview[]; + analysisState: AnalysisState; + appState: AppState; + studyId: string; + filters: Filter[] | undefined; + studyEntities: StudyEntity[]; + geoConfigs: GeoConfig[]; + configuration: unknown; + updateConfiguration: (configuration: unknown) => void; + hideVizInputsAndControls: boolean; + setHideVizInputsAndControls: (hide: boolean) => void; +} + +export interface MapTypeMapLayerProps { + apps: ComputationAppOverview[]; + analysisState: AnalysisState; + appState: AppState; + studyId: string; + filters: Filter[] | undefined; + studyEntities: StudyEntity[]; + geoConfigs: GeoConfig[]; + configuration: unknown; + updateConfiguration: (configuration: unknown) => void; + totalCounts: PromiseHookState; + filteredCounts: PromiseHookState; + filtersIncludingViewport: Filter[]; + hideVizInputsAndControls: boolean; + setHideVizInputsAndControls: (hide: boolean) => void; +} + +/** + * A plugin containing the pieces needed to render + * and configure a map type + */ +export interface MapTypePlugin { + /** + * Display name of map type used for menu, etc. + */ + displayName: string; + /** + * Returns a ReactNode used for configuring the map type + */ + ConfigPanelComponent: ComponentType; + /** + * Returns a ReactNode that is rendered as a leaflet map layer + */ + MapLayerComponent?: ComponentType; + /** + * Returns a ReactNode that is rendered on top of the map + */ + MapOverlayComponent?: ComponentType; + /** + * Returns a ReactNode that is rendered in the map header + */ + MapTypeHeaderDetails?: ComponentType; +} diff --git a/packages/libs/eda/src/lib/map/analysis/utils/defaultOverlayConfig.ts b/packages/libs/eda/src/lib/map/analysis/utils/defaultOverlayConfig.ts index 8eeaedbe15..0fc95c5ff9 100644 --- a/packages/libs/eda/src/lib/map/analysis/utils/defaultOverlayConfig.ts +++ b/packages/libs/eda/src/lib/map/analysis/utils/defaultOverlayConfig.ts @@ -1,5 +1,5 @@ import { ColorPaletteDefault } from '@veupathdb/components/lib/types/plots'; -import { UNSELECTED_TOKEN } from '../..'; +import { UNSELECTED_TOKEN } from '../../constants'; import { BinRange, BubbleOverlayConfig, @@ -12,7 +12,7 @@ import { VariableType, } from '../../../core'; import { DataClient, SubsettingClient } from '../../../core/api'; -import { BinningMethod, MarkerConfiguration } from '../appState'; +import { BinningMethod } from '../appState'; import { BubbleMarkerConfiguration } from '../MarkerConfiguration/BubbleMarkerConfigurationMenu'; // This async function fetches the default overlay config. @@ -22,6 +22,55 @@ import { BubbleMarkerConfiguration } from '../MarkerConfiguration/BubbleMarkerCo // For categoricals it calls subsetting's distribution endpoint to get a list of values and their counts // +export interface DefaultBubbleOverlayConfigProps { + studyId: string; + filters: Filter[] | undefined; + overlayVariable: Variable; + overlayEntity: StudyEntity; + aggregator?: BubbleMarkerConfiguration['aggregator']; + numeratorValues?: BubbleMarkerConfiguration['numeratorValues']; + denominatorValues?: BubbleMarkerConfiguration['denominatorValues']; +} + +export function getDefaultBubbleOverlayConfig( + props: DefaultBubbleOverlayConfigProps +): BubbleOverlayConfig { + const { + overlayVariable, + overlayEntity, + aggregator = 'mean', + numeratorValues = overlayVariable?.vocabulary ?? [], + denominatorValues = overlayVariable?.vocabulary ?? [], + } = props; + + const overlayVariableDescriptor = { + variableId: overlayVariable.id, + entityId: overlayEntity.id, + }; + + if (CategoricalVariableDataShape.is(overlayVariable.dataShape)) { + // categorical + return { + overlayVariable: overlayVariableDescriptor, + aggregationConfig: { + overlayType: 'categorical', + numeratorValues, + denominatorValues, + }, + }; + } else if (ContinuousVariableDataShape.is(overlayVariable.dataShape)) { + // continuous + return { + overlayVariable: overlayVariableDescriptor, + aggregationConfig: { + overlayType: 'continuous', // TO DO for dates: might do `overlayVariable.type === 'date' ? 'date' : 'number'` + aggregator, + }, + }; + } + throw new Error('Unknown variable datashape: ' + overlayVariable.dataShape); +} + export interface DefaultOverlayConfigProps { studyId: string; filters: Filter[] | undefined; @@ -29,16 +78,12 @@ export interface DefaultOverlayConfigProps { overlayEntity: StudyEntity | undefined; dataClient: DataClient; subsettingClient: SubsettingClient; - markerType?: MarkerConfiguration['type']; binningMethod?: BinningMethod; - aggregator?: BubbleMarkerConfiguration['aggregator']; - numeratorValues?: BubbleMarkerConfiguration['numeratorValues']; - denominatorValues?: BubbleMarkerConfiguration['denominatorValues']; } export async function getDefaultOverlayConfig( props: DefaultOverlayConfigProps -): Promise { +): Promise { const { studyId, filters, @@ -46,11 +91,7 @@ export async function getDefaultOverlayConfig( overlayEntity, dataClient, subsettingClient, - markerType, binningMethod = 'equalInterval', - aggregator = 'mean', - numeratorValues, - denominatorValues, } = props; if (overlayVariable != null && overlayEntity != null) { @@ -61,59 +102,34 @@ export async function getDefaultOverlayConfig( if (CategoricalVariableDataShape.is(overlayVariable.dataShape)) { // categorical - if (markerType === 'bubble') { - return { - overlayVariable: overlayVariableDescriptor, - aggregationConfig: { - overlayType: 'categorical', - numeratorValues: - numeratorValues ?? overlayVariable.vocabulary ?? [], - denominatorValues: - denominatorValues ?? overlayVariable.vocabulary ?? [], - }, - }; - } else { - const overlayValues = await getMostFrequentValues({ - studyId: studyId, - ...overlayVariableDescriptor, - filters: filters ?? [], - numValues: ColorPaletteDefault.length - 1, - subsettingClient, - }); - - return { - overlayType: 'categorical', - overlayVariable: overlayVariableDescriptor, - overlayValues, - }; - } + const overlayValues = await getMostFrequentValues({ + studyId: studyId, + ...overlayVariableDescriptor, + filters: filters ?? [], + numValues: ColorPaletteDefault.length - 1, + subsettingClient, + }); + + return { + overlayType: 'categorical', + overlayVariable: overlayVariableDescriptor, + overlayValues, + }; } else if (ContinuousVariableDataShape.is(overlayVariable.dataShape)) { // continuous - if (markerType === 'bubble') { - return { - overlayVariable: overlayVariableDescriptor, - aggregationConfig: { - overlayType: 'continuous', // TO DO for dates: might do `overlayVariable.type === 'date' ? 'date' : 'number'` - aggregator, - }, - }; - } else { - const overlayBins = await getBinRanges({ - studyId, - ...overlayVariableDescriptor, - filters: filters ?? [], - dataClient, - binningMethod, - }); - - return { - overlayType: 'continuous', - overlayValues: overlayBins, - overlayVariable: overlayVariableDescriptor, - }; - } - } else { - return; + const overlayBins = await getBinRanges({ + studyId, + ...overlayVariableDescriptor, + filters: filters ?? [], + dataClient, + binningMethod, + }); + + return { + overlayType: 'continuous', + overlayValues: overlayBins, + overlayVariable: overlayVariableDescriptor, + }; } } } diff --git a/packages/libs/eda/src/lib/map/constants.ts b/packages/libs/eda/src/lib/map/constants.ts new file mode 100644 index 0000000000..60f53379cb --- /dev/null +++ b/packages/libs/eda/src/lib/map/constants.ts @@ -0,0 +1,9 @@ +import { CSSProperties } from 'react'; + +export const mapSidePanelBackgroundColor = 'white'; +export const mapSidePanelBorder: CSSProperties['border'] = '1px solid #D9D9D9'; + +// Back end overlay values contain a special token for the "Other" category: +export const UNSELECTED_TOKEN = '__UNSELECTED__'; +// This is what is displayed to the user instead: +export const UNSELECTED_DISPLAY_TEXT = 'All other values'; diff --git a/packages/libs/eda/src/lib/map/index.ts b/packages/libs/eda/src/lib/map/index.ts index 4d824ee45d..dcc9392093 100644 --- a/packages/libs/eda/src/lib/map/index.ts +++ b/packages/libs/eda/src/lib/map/index.ts @@ -1,18 +1 @@ -import { CSSProperties } from 'react'; - export { MapVeuContainer as default } from './MapVeuContainer'; - -export type SiteInformationProps = { - siteHomeUrl: string; - loginUrl: string; - siteName: string; - siteLogoSrc: string; -}; - -export const mapNavigationBackgroundColor = 'white'; -export const mapNavigationBorder: CSSProperties['border'] = '1px solid #D9D9D9'; - -// Back end overlay values contain a special token for the "Other" category: -export const UNSELECTED_TOKEN = '__UNSELECTED__'; -// This is what is displayed to the user instead: -export const UNSELECTED_DISPLAY_TEXT = 'All other values'; diff --git a/packages/sites/genomics-site/webapp/wdkCustomization/css/client.scss b/packages/sites/genomics-site/webapp/wdkCustomization/css/client.scss index 7bceca6ef0..8f20dcc8d0 100644 --- a/packages/sites/genomics-site/webapp/wdkCustomization/css/client.scss +++ b/packages/sites/genomics-site/webapp/wdkCustomization/css/client.scss @@ -45,12 +45,14 @@ } /* Hide default record ID printed by WDK for junctions */ -.wdk-RecordContainer__JunctionRecordClasses\.JunctionRecordClass .wdk-RecordHeading { +.wdk-RecordContainer__JunctionRecordClasses\.JunctionRecordClass + .wdk-RecordHeading { display: none; } /* Hide default record ID printed by WDK for long read transcripts */ -.wdk-RecordContainer__LongReadTranscriptRecordClasses\.LongReadTranscriptRecordClass .wdk-RecordHeading { +.wdk-RecordContainer__LongReadTranscriptRecordClasses\.LongReadTranscriptRecordClass + .wdk-RecordHeading { display: none; } diff --git a/yarn.lock b/yarn.lock index a73a767f59..0c662e5449 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5701,6 +5701,56 @@ __metadata: languageName: node linkType: hard +"@tanstack/match-sorter-utils@npm:^8.7.0": + version: 8.8.4 + resolution: "@tanstack/match-sorter-utils@npm:8.8.4" + dependencies: + remove-accents: 0.4.2 + checksum: d005f500754f52ef94966cbbe4217f26e7e3c07291faa2578b06bca9a5abe01689569994c37a1d01c6e783addf5ffbb28fa82eba7961d36eabf43ec43d1e496b + languageName: node + linkType: hard + +"@tanstack/query-core@npm:4.33.0": + version: 4.33.0 + resolution: "@tanstack/query-core@npm:4.33.0" + checksum: fae325f1d79b936435787797c32367331d5b8e9c5ced84852bf2085115e3aafef57a7ae530a6b0af46da4abafb4b0afaef885926b71715a0e6f166d74da61c7f + languageName: node + linkType: hard + +"@tanstack/react-query-devtools@npm:^4.35.3": + version: 4.35.3 + resolution: "@tanstack/react-query-devtools@npm:4.35.3" + dependencies: + "@tanstack/match-sorter-utils": ^8.7.0 + superjson: ^1.10.0 + use-sync-external-store: ^1.2.0 + peerDependencies: + "@tanstack/react-query": ^4.35.3 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: 59495f9dabdb13efa780444de9b4c89cc34528109da1fe993f2f710a63959a73acb250f50c6a120d11d0e34006582f9913a448c8f62a0a2d0e9f72a733129d7a + languageName: node + linkType: hard + +"@tanstack/react-query@npm:^4.33.0": + version: 4.33.0 + resolution: "@tanstack/react-query@npm:4.33.0" + dependencies: + "@tanstack/query-core": 4.33.0 + use-sync-external-store: ^1.2.0 + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-native: "*" + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + checksum: b3cf4afa427435e464e077b3f23c891e38e5f78873518f15c1d061ad55f1464d6241ecd92d796a5dbc9412b4fd7eb30b01f2a9cfc285ee9f30dfdd2ca0ecaf4b + languageName: node + linkType: hard + "@testing-library/dom@npm:8.11.2": version: 8.11.2 resolution: "@testing-library/dom@npm:8.11.2" @@ -8210,6 +8260,8 @@ __metadata: "@material-ui/core": ^4.12.4 "@material-ui/icons": ^4.11.3 "@material-ui/lab": ^4.0.0-alpha.61 + "@tanstack/react-query": ^4.33.0 + "@tanstack/react-query-devtools": ^4.35.3 "@testing-library/jest-dom": ^5.16.5 "@testing-library/react": ^11.1.0 "@testing-library/react-hooks": ^5.0.3 @@ -14529,6 +14581,15 @@ __metadata: languageName: node linkType: hard +"copy-anything@npm:^3.0.2": + version: 3.0.5 + resolution: "copy-anything@npm:3.0.5" + dependencies: + is-what: ^4.1.8 + checksum: d39f6601c16b7cbd81cdb1c1f40f2bf0f2ca0297601cf7bfbb4ef1d85374a6a89c559502329f5bada36604464df17623e111fe19a9bb0c3f6b1c92fe2cbe972f + languageName: node + linkType: hard + "copy-concurrently@npm:^1.0.0": version: 1.0.5 resolution: "copy-concurrently@npm:1.0.5" @@ -22258,6 +22319,13 @@ __metadata: languageName: node linkType: hard +"is-what@npm:^4.1.8": + version: 4.1.15 + resolution: "is-what@npm:4.1.15" + checksum: fe27f6cd4af41be59a60caf46ec09e3071bcc69b9b12a7c871c90f54360edb6d0bc7240cb944a251fb0afa3d35635d1cecea9e70709876b368a8285128d70a89 + languageName: node + linkType: hard + "is-whitespace-character@npm:^1.0.0": version: 1.0.4 resolution: "is-whitespace-character@npm:1.0.4" @@ -32305,6 +32373,13 @@ __metadata: languageName: node linkType: hard +"remove-accents@npm:0.4.2": + version: 0.4.2 + resolution: "remove-accents@npm:0.4.2" + checksum: 84a6988555dea24115e2d1954db99509588d43fe55a1590f0b5894802776f7b488b3151c37ceb9e4f4b646f26b80b7325dcea2fae58bc3865df146e1fa606711 + languageName: node + linkType: hard + "remove-trailing-separator@npm:^1.0.1": version: 1.1.0 resolution: "remove-trailing-separator@npm:1.1.0" @@ -34928,6 +35003,15 @@ __metadata: languageName: node linkType: hard +"superjson@npm:^1.10.0": + version: 1.13.1 + resolution: "superjson@npm:1.13.1" + dependencies: + copy-anything: ^3.0.2 + checksum: 9c8c664a924ce097250112428805ccc8b500018b31a91042e953d955108b8481c156005d836b413940c9fa5f124a3195f55f3a518fe76510a254a59f9151a204 + languageName: node + linkType: hard + "superscript-text@npm:^1.0.0": version: 1.0.0 resolution: "superscript-text@npm:1.0.0" @@ -36814,6 +36898,15 @@ __metadata: languageName: node linkType: hard +"use-sync-external-store@npm:^1.2.0": + version: 1.2.0 + resolution: "use-sync-external-store@npm:1.2.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: 5c639e0f8da3521d605f59ce5be9e094ca772bd44a4ce7322b055a6f58eeed8dda3c94cabd90c7a41fb6fa852210092008afe48f7038792fd47501f33299116a + languageName: node + linkType: hard + "use@npm:^3.1.0": version: 3.1.1 resolution: "use@npm:3.1.1" From 62f7c8ee337d452851e772ad436197d96cf729d6 Mon Sep 17 00:00:00 2001 From: asizemore Date: Fri, 27 Oct 2023 15:30:57 -0400 Subject: [PATCH 27/27] clarify role of defaultPValueFloor --- packages/libs/components/src/plots/VolcanoPlot.tsx | 14 +++++++++++--- .../src/stories/plots/VolcanoPlot.stories.tsx | 6 +++--- .../computations/plugins/differentialabundance.tsx | 2 ++ .../implementations/VolcanoPlotVisualization.tsx | 12 ++++++++---- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/packages/libs/components/src/plots/VolcanoPlot.tsx b/packages/libs/components/src/plots/VolcanoPlot.tsx index cc4840512f..45c463c2a8 100755 --- a/packages/libs/components/src/plots/VolcanoPlot.tsx +++ b/packages/libs/components/src/plots/VolcanoPlot.tsx @@ -106,9 +106,8 @@ const EmptyVolcanoPlotData: VolcanoPlotData = { statistics: EmptyVolcanoPlotStats, }; -const DefaultStatisticsFloors: StatisticsFloors = { - pValueFloor: 1e-200, // This default matches the default value in the backend. - adjustedPValueFloor: 0, +export const DefaultStatisticsFloors: StatisticsFloors = { + pValueFloor: 0, // Do not floor by default }; const MARGIN_DEFAULT = 50; @@ -185,6 +184,15 @@ function VolcanoPlot(props: VolcanoPlotProps, ref: Ref) { const { min: dataXMin, max: dataXMax } = rawDataMinMaxValues.x; const { min: dataYMin, max: dataYMax } = rawDataMinMaxValues.y; + // When dataYMin = 0, there must be a point with pvalue = 0, which means the plot will try in vain to draw a point at -log10(0) = Inf. + // When this issue arises, one should set a pValueFloor >= 0 so that the point with pValue = 0 + // will be able to be plotted sensibly. + if (dataYMin === 0 && statisticsFloors.pValueFloor <= 0) { + throw new Error( + 'Found data point with pValue = 0. Cannot create a volcano plot with a point at -log10(0) = Inf. Please use the statisticsFloors prop to set a pValueFloor >= 0.' + ); + } + // Set mins, maxes of axes in the plot using axis range props // The y axis max should not be allowed to exceed -log10(pValueFloor) const xAxisMin = independentAxisRange?.min ?? 0; diff --git a/packages/libs/components/src/stories/plots/VolcanoPlot.stories.tsx b/packages/libs/components/src/stories/plots/VolcanoPlot.stories.tsx index 637669df07..3d87c8f3d6 100755 --- a/packages/libs/components/src/stories/plots/VolcanoPlot.stories.tsx +++ b/packages/libs/components/src/stories/plots/VolcanoPlot.stories.tsx @@ -73,8 +73,8 @@ const dataSetVolcano: VEuPathDBVolcanoPlotData = { '0.001', '0.0001', '0.002', - '0', - '0', + '1e-90', + '0.00000002', ], adjustedPValue: ['0.01', '0.001', '0.01', '0.001', '0.02', '0', '0'], pointID: [ @@ -291,7 +291,7 @@ FlooredPValues.args = { effectSizeThreshold: 1, significanceThreshold: 0.01, comparisonLabels: ['up in group a', 'up in group b'], - independentAxisRange: { min: -8, max: 9 }, + independentAxisRange: { min: -9, max: 9 }, dependentAxisRange: { min: -1, max: 9 }, showSpinner: false, statisticsFloors: testStatisticsFloors, diff --git a/packages/libs/eda/src/lib/core/components/computations/plugins/differentialabundance.tsx b/packages/libs/eda/src/lib/core/components/computations/plugins/differentialabundance.tsx index 6054c7eab1..a35148bc35 100644 --- a/packages/libs/eda/src/lib/core/components/computations/plugins/differentialabundance.tsx +++ b/packages/libs/eda/src/lib/core/components/computations/plugins/differentialabundance.tsx @@ -190,6 +190,8 @@ export function DifferentialAbundanceConfiguration( DIFFERENTIAL_ABUNDANCE_METHODS[0]; // Set the pValueFloor here. May change for other apps. + // Note this is intentionally different than the default pValueFloor used in the Volcano component. By default + // that component does not floor the data, but we know we want the diff abund computation to use a floor. if (configuration) configuration.pValueFloor = '1e-200'; // Include known collection variables in this array. diff --git a/packages/libs/eda/src/lib/core/components/visualizations/implementations/VolcanoPlotVisualization.tsx b/packages/libs/eda/src/lib/core/components/visualizations/implementations/VolcanoPlotVisualization.tsx index 61a7084f22..e9799de0b6 100755 --- a/packages/libs/eda/src/lib/core/components/visualizations/implementations/VolcanoPlotVisualization.tsx +++ b/packages/libs/eda/src/lib/core/components/visualizations/implementations/VolcanoPlotVisualization.tsx @@ -4,6 +4,7 @@ import VolcanoPlot, { assignSignificanceColor, RawDataMinMaxValues, StatisticsFloors, + DefaultStatisticsFloors, } from '@veupathdb/components/lib/plots/VolcanoPlot'; import * as t from 'io-ts'; @@ -417,10 +418,13 @@ function VolcanoPlotViz(props: VisualizationProps) { : []; // Record any floors for the p value and adjusted p value sent to us from the backend. - const statisticsFloors: StatisticsFloors = { - pValueFloor: (data.value && Number(data.value.pValueFloor)) ?? 0, - adjustedPValueFloor: data.value && Number(data.value.adjustedPValueFloor), - }; + const statisticsFloors: StatisticsFloors = + data.value && data.value.pValueFloor + ? { + pValueFloor: Number(data.value.pValueFloor), + adjustedPValueFloor: Number(data.value.adjustedPValueFloor), + } + : DefaultStatisticsFloors; const volcanoPlotProps: VolcanoPlotProps = { /**