From 84a62aa799ce1c36e2e9aed4946a33b5b90d0ee6 Mon Sep 17 00:00:00 2001 From: Dave Falke Date: Tue, 13 Feb 2024 15:34:02 -0500 Subject: [PATCH] SAM mega study details (#834) Co-authored-by: Bob MacCallum --- .../components/src/map/BoundsDriftMarker.tsx | 7 +- .../containers/DraggablePanel/index.tsx | 52 +++-- .../plugins/correlationAssayMetadata.tsx | 2 +- .../libs/eda/src/lib/core/hooks/debouncing.ts | 36 +++- .../eda/src/lib/map/analysis/MapAnalysis.tsx | 2 + .../eda/src/lib/map/analysis/SubStudies.tsx | 143 ++++++++++++++ .../libs/eda/src/lib/map/analysis/appState.ts | 89 +++++++-- .../eda/src/lib/map/analysis/littleFilters.ts | 29 ++- .../mapTypes/MapTypeHeaderStudyDetails.tsx | 178 ++++++++++++++++++ .../mapTypes/plugins/BarMarkerMapType.tsx | 88 +++++---- .../mapTypes/plugins/BubbleMarkerMapType.tsx | 77 +++++--- .../mapTypes/plugins/DonutMarkerMapType.tsx | 83 +++++--- .../src/lib/map/analysis/mapTypes/shared.tsx | 18 +- .../src/lib/map/analysis/mapTypes/types.ts | 6 +- packages/libs/eda/src/lib/map/constants.ts | 3 + 15 files changed, 678 insertions(+), 135 deletions(-) create mode 100644 packages/libs/eda/src/lib/map/analysis/SubStudies.tsx create mode 100644 packages/libs/eda/src/lib/map/analysis/mapTypes/MapTypeHeaderStudyDetails.tsx diff --git a/packages/libs/components/src/map/BoundsDriftMarker.tsx b/packages/libs/components/src/map/BoundsDriftMarker.tsx index 455a35e925..e9ef9d8c80 100755 --- a/packages/libs/components/src/map/BoundsDriftMarker.tsx +++ b/packages/libs/components/src/map/BoundsDriftMarker.tsx @@ -309,8 +309,11 @@ export default function BoundsDriftMarker({ [setSelectedMarkers, selectedMarkers, props.id] ); - const handleDoubleClick = () => { - if (map) { + const handleDoubleClick = (e: LeafletMouseEvent) => { + // If SHIFT is pressed, ignore double-click event + // so users can quickly select multiple markers without + // triggering a zoom + if (map && !e.originalEvent.shiftKey) { map.fitBounds(boundingBox); } }; diff --git a/packages/libs/coreui/src/components/containers/DraggablePanel/index.tsx b/packages/libs/coreui/src/components/containers/DraggablePanel/index.tsx index 7a3f95536c..d3b7964da4 100644 --- a/packages/libs/coreui/src/components/containers/DraggablePanel/index.tsx +++ b/packages/libs/coreui/src/components/containers/DraggablePanel/index.tsx @@ -109,10 +109,9 @@ export default function DraggablePanel(props: DraggablePanelProps) { } } - const { ref, height, width } = useResizeObserver(); - - useEffect( - function invokeOnPanelResize() { + const { ref } = useResizeObserver({ + box: 'border-box', + onResize: ({ height, width }) => { if (!onPanelResize || !height || !width) return; onPanelResize({ @@ -120,11 +119,23 @@ export default function DraggablePanel(props: DraggablePanelProps) { width: width, }); }, - [height, width] - ); + }); + + const { + ref: containerRef, + height: conainerHeight, + width: containerWidth, + } = useResizeObserver({ + box: 'border-box', + }); const finalPosition = confineToParentContainer - ? constrainPositionOnScreen(panelPosition, width, height, window) + ? constrainPositionOnScreen( + panelPosition, + containerWidth, + conainerHeight, + window + ) : panelPosition; // set maximum text length for the panel title @@ -141,7 +152,7 @@ export default function DraggablePanel(props: DraggablePanelProps) { position={finalPosition} >
}
{children} @@ -305,3 +318,12 @@ export const truncateWithEllipsis = (label: string, maxLabelLength: number) => { ? (label || '').substring(0, maxLabelLength - 2) + '...' : label; }; + +function cssLengthToString(value?: string | number): string | undefined { + switch (typeof value) { + case 'number': + return value + 'px'; + default: + return value; + } +} diff --git a/packages/libs/eda/src/lib/core/components/computations/plugins/correlationAssayMetadata.tsx b/packages/libs/eda/src/lib/core/components/computations/plugins/correlationAssayMetadata.tsx index cf4502c7d8..acc6ef5801 100644 --- a/packages/libs/eda/src/lib/core/components/computations/plugins/correlationAssayMetadata.tsx +++ b/packages/libs/eda/src/lib/core/components/computations/plugins/correlationAssayMetadata.tsx @@ -1,6 +1,6 @@ import { - VariableTreeNode, FeaturePrefilterThresholds, + VariableTreeNode, useFindEntityAndVariableCollection, } from '../../..'; import { VariableCollectionDescriptor } from '../../../types/variable'; diff --git a/packages/libs/eda/src/lib/core/hooks/debouncing.ts b/packages/libs/eda/src/lib/core/hooks/debouncing.ts index 1162496e3c..0983cef41c 100644 --- a/packages/libs/eda/src/lib/core/hooks/debouncing.ts +++ b/packages/libs/eda/src/lib/core/hooks/debouncing.ts @@ -1,4 +1,5 @@ -import { useEffect, useState } from 'react'; +import { debounce } from 'lodash'; +import { useEffect, useRef, useState } from 'react'; // Courtesy of https://usehooks.com/useDebounce/ export function useDebounce(value: T, delay: number) { @@ -21,3 +22,36 @@ export function useDebounce(value: T, delay: number) { ); return debouncedValue; } + +/** + * Returns a stable function that calls the input function after `delayMs` time in milliseconds. + * If the returned function is called multiple times with the `delayMs` time window, previoius + * calls will be cancelled. Furthermore, when the component is unmounted, any queued function + * calls will be cancelled. + * + * Note that this hook does not require any dependencies, and does not support cancellation + * based on dependency values changing. + */ +export function useDebouncedCallback( + fn: (...args: T) => void, + delayMs: number +) { + // TODO Consider supporting cancellation based on dependency values changing. + // This could be done using a "queryKey", similar to `useQuery`. + // We would need a good use case for this behavior, before implementing. + const fnRef = useRef(fn); + fnRef.current = fn; + const debouncedFn = useRef( + debounce(function (...args: T) { + fnRef.current(...args); + }, delayMs) + ).current; + + useEffect(() => { + return function cancel() { + debouncedFn.cancel(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return debouncedFn; +} diff --git a/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx b/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx index d747ff9b72..17c43fe6b2 100755 --- a/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx @@ -168,6 +168,7 @@ function MapAnalysisImpl(props: ImplProps) { setIsSidePanelExpanded, setMarkerConfigurations, setActiveMarkerConfigurationType, + setStudyDetailsPanelConfig, geoConfigs, setTimeSliderConfig, showLinkToEda = false, @@ -764,6 +765,7 @@ function MapAnalysisImpl(props: ImplProps) { filteredCounts, hideVizInputsAndControls, setHideVizInputsAndControls, + setStudyDetailsPanelConfig, headerButtons: HeaderButtons, }; diff --git a/packages/libs/eda/src/lib/map/analysis/SubStudies.tsx b/packages/libs/eda/src/lib/map/analysis/SubStudies.tsx new file mode 100644 index 0000000000..97356dc20d --- /dev/null +++ b/packages/libs/eda/src/lib/map/analysis/SubStudies.tsx @@ -0,0 +1,143 @@ +import { useQuery } from '@tanstack/react-query'; +import { Filter, useSubsettingClient } from '../../core'; +import { usePermissions } from '@veupathdb/study-data-access/lib/data-restriction/permissionsHooks'; +import { useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { DraggablePanel } from '@veupathdb/coreui/lib/components/containers'; +import { PanelConfig } from './appState'; +import { useDebouncedCallback } from '../../core/hooks/debouncing'; +import Spinner from '@veupathdb/components/lib/components/Spinner'; +import Banner from '@veupathdb/coreui/lib/components/banners/Banner'; + +interface Props { + studyId: string; + /** ID for Studies entity */ + entityId: string; + /** ID for StudyID variable */ + variableId: string; + filters?: Filter[]; + panelConfig: PanelConfig; + updatePanelConfig: (config: PanelConfig) => void; + hasSelectedMarkers: boolean; +} + +export function SubStudies(props: Props) { + // get tabular data for studies + const { + entityId, + studyId, + variableId, + filters = [], + panelConfig, + updatePanelConfig, + hasSelectedMarkers, + } = props; + const subsettingClient = useSubsettingClient(); + const permissions = usePermissions(); + const result = useQuery({ + queryKey: ['map', 'studies', entityId, filters], + queryFn: async () => { + return await subsettingClient.getTabularData(studyId, entityId, { + filters, + outputVariableIds: [variableId], + reportConfig: { + headerFormat: 'standard', + }, + }); + }, + }); + + const datasetIdByStudyId = useMemo(() => { + if (permissions.loading) return {}; + return Object.fromEntries( + Object.entries(permissions.permissions.perDataset) + .map(([datasetId, value]) => [value?.studyId, datasetId]) + .filter((entry): entry is [string, string] => entry[0] != null) + ); + }, [permissions]); + + const updatePosition = useDebouncedCallback( + (position: PanelConfig['position']) => { + updatePanelConfig({ ...panelConfig, position }); + }, + 250 + ); + + const updateDimensions = useDebouncedCallback( + (dimensions: PanelConfig['dimensions']) => { + updatePanelConfig({ ...panelConfig, dimensions }); + }, + 250 + ); + + return ( + + updatePanelConfig({ ...panelConfig, isVisble: false }) + } + > +
+ {result.error ? ( + + ) : result.data == null || result.isFetching ? ( + + ) : ( +
+

+ There {studyCountPhrase(result.data.length - 1)} for the{' '} + {hasSelectedMarkers ? 'selected' : 'visible'} markers on the map. +

+
    + {result.data.slice(1).map(([id, display]) => ( +
  • + + {display} + +
  • + ))} +
+
+ )} +
+
+ ); +} + +function studyCountPhrase(numStudies: number) { + switch (numStudies) { + case 0: + return 'are no studies'; + case 1: + return 'is 1 study'; + default: + return `are ${numStudies} studies`; + } +} diff --git a/packages/libs/eda/src/lib/map/analysis/appState.ts b/packages/libs/eda/src/lib/map/analysis/appState.ts index 90787a1a43..aff52b5a25 100644 --- a/packages/libs/eda/src/lib/map/analysis/appState.ts +++ b/packages/libs/eda/src/lib/map/analysis/appState.ts @@ -3,10 +3,15 @@ import { pipe } from 'fp-ts/lib/function'; import * as t from 'io-ts'; import { isEqual } from 'lodash'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useAnalysis, useGetDefaultVariableDescriptor } from '../../core'; +import { + useAnalysis, + useGetDefaultVariableDescriptor, + useStudyMetadata, +} from '../../core'; import { VariableDescriptor } from '../../core/types/variable'; import { useGetDefaultTimeVariableDescriptor } from './hooks/eztimeslider'; import { defaultViewport } from '@veupathdb/components/lib/map/config/map'; +import { STUDIES_ENTITY_ID, STUDY_ID_VARIABLE_ID } from '../constants'; const LatLngLiteral = t.type({ lat: t.number, lng: t.number }); @@ -91,6 +96,18 @@ export const MarkerConfiguration = t.intersection([ ]), ]); +const PanelConfig = t.type({ + isVisble: t.boolean, + position: t.type({ x: t.number, y: t.number }), + dimensions: t.type({ + height: t.union([t.number, t.string]), + width: t.union([t.number, t.string]), + }), +}); + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export type PanelConfig = t.TypeOf; + export const AppState = t.intersection([ t.type({ viewport: t.type({ @@ -102,6 +119,7 @@ export const AppState = t.intersection([ isSidePanelExpanded: t.boolean, }), t.partial({ + studyDetailsPanelConfig: PanelConfig, boundsZoomLevel: t.type({ zoomLevel: t.number, bounds: t.type({ @@ -154,6 +172,7 @@ export function useAppState( getOrElseW(() => undefined) ); + const studyMetadata = useStudyMetadata(); const getDefaultVariableDescriptor = useGetDefaultVariableDescriptor(); const defaultVariable = getDefaultVariableDescriptor(); @@ -161,6 +180,12 @@ export function useAppState( useGetDefaultTimeVariableDescriptor(); const defaultTimeVariable = getDefaultTimeVariableDescriptor(); + const isMegaStudy = + studyMetadata.rootEntity.id === STUDIES_ENTITY_ID && + studyMetadata.rootEntity.variables.find( + (variable) => variable.id === STUDY_ID_VARIABLE_ID + ) != null; + const defaultAppState: AppState = useMemo( () => ({ viewport: defaultViewport, @@ -172,6 +197,15 @@ export function useAppState( active: true, selectedRange: undefined, }, + ...(isMegaStudy + ? { + studyDetailsPanelConfig: { + isVisble: false, + position: { x: 650, y: 225 }, + dimensions: { height: '70vh', width: 1000 }, + }, + } + : {}), markerConfigurations: [ { type: 'pie', @@ -198,7 +232,7 @@ export function useAppState( }, ], }), - [defaultVariable, defaultTimeVariable] + [defaultTimeVariable, isMegaStudy, defaultVariable] ); useEffect(() => { @@ -219,27 +253,40 @@ export function useAppState( ) ); - // refactored these into two calls to setVariableUISettings to be more readable and future-proof - if (missingMarkerConfigs.length > 0) - setVariableUISettings((prev) => ({ - ...prev, - [uiStateKey]: { - ...appState, - markerConfigurations: [ - ...appState.markerConfigurations, - ...missingMarkerConfigs, - ], - }, - })); + // Used to track if appState needs to be updated with + // missing pieces of configuration. + let nextAppState = appState; - const timeSliderConfigIsMissing = appState.timeSliderConfig == null; - if (timeSliderConfigIsMissing) + if (missingMarkerConfigs.length > 0) { + nextAppState = { + ...nextAppState, + markerConfigurations: [ + ...nextAppState.markerConfigurations, + ...missingMarkerConfigs, + ], + }; + } + + if (appState.timeSliderConfig == null) { + nextAppState = { + ...nextAppState, + timeSliderConfig: defaultAppState.timeSliderConfig, + }; + } + + if (isMegaStudy && appState.studyDetailsPanelConfig == null) { + nextAppState = { + ...nextAppState, + studyDetailsPanelConfig: defaultAppState.studyDetailsPanelConfig, + }; + } + + // If nextAppState has a new value, then we need to update + // the analysis object + if (nextAppState !== appState) setVariableUISettings((prev) => ({ ...prev, - [uiStateKey]: { - ...appState, - timeSliderConfig: defaultAppState.timeSliderConfig, - }, + [uiStateKey]: nextAppState, })); } setAppStateChecked(true); @@ -247,6 +294,7 @@ export function useAppState( }, [ analysis, appState, + isMegaStudy, setVariableUISettings, uiStateKey, defaultAppState, @@ -290,5 +338,6 @@ export function useAppState( setSubsetVariableAndEntity: useSetter('subsetVariableAndEntity'), setViewport: useSetter('viewport'), setTimeSliderConfig: useSetter('timeSliderConfig', true), + setStudyDetailsPanelConfig: useSetter('studyDetailsPanelConfig'), }; } diff --git a/packages/libs/eda/src/lib/map/analysis/littleFilters.ts b/packages/libs/eda/src/lib/map/analysis/littleFilters.ts index 5dd0ba8861..87a0adb5b2 100644 --- a/packages/libs/eda/src/lib/map/analysis/littleFilters.ts +++ b/packages/libs/eda/src/lib/map/analysis/littleFilters.ts @@ -1,4 +1,9 @@ -import { Filter, StudyEntity, Variable } from '../../core'; +import { + Filter, + StudyEntity, + Variable, + useFindEntityAndVariable, +} from '../../core'; import { useMemo } from 'react'; import { AppState } from './appState'; import { GeoConfig } from '../../core/types/geoConfig'; @@ -9,9 +14,15 @@ export interface UseLittleFiltersProps { filters: Filter[] | undefined; appState: AppState; geoConfigs: GeoConfig[]; - findEntityAndVariable?: ( - vd: VariableDescriptor - ) => { entity: StudyEntity; variable: Variable } | undefined; +} + +export interface UseLittleFiltersFuncProps extends UseLittleFiltersProps { + findEntityAndVariable: (variableDescriptor?: VariableDescriptor) => + | { + entity: StudyEntity; + variable: Variable; + } + | undefined; } // @@ -25,16 +36,20 @@ export interface UseLittleFiltersProps { // export function useLittleFilters( props: UseLittleFiltersProps, - funcs: ((props: UseLittleFiltersProps) => Filter[])[] + funcs: ((props: UseLittleFiltersFuncProps) => Filter[])[] ) { + const findEntityAndVariable = useFindEntityAndVariable(props.filters); const littleFilters = useDeepValue( useMemo( () => funcs.reduce( - (filters, func) => [...filters, ...func(props)], + (filters, func) => [ + ...filters, + ...func({ ...props, findEntityAndVariable }), + ], [] as Filter[] ), - [props, funcs] + [props, funcs, findEntityAndVariable] ) ); diff --git a/packages/libs/eda/src/lib/map/analysis/mapTypes/MapTypeHeaderStudyDetails.tsx b/packages/libs/eda/src/lib/map/analysis/mapTypes/MapTypeHeaderStudyDetails.tsx new file mode 100644 index 0000000000..bdf9dc45bb --- /dev/null +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/MapTypeHeaderStudyDetails.tsx @@ -0,0 +1,178 @@ +import { Info } from '@material-ui/icons'; +import { useQuery } from '@tanstack/react-query'; +import { Tooltip } from '@veupathdb/components/lib/components/widgets/Tooltip'; +import { + Filter, + useStudyEntities, + useStudyMetadata, + useSubsettingClient, +} from '../../../core'; +import { STUDIES_ENTITY_ID } from '../../constants'; + +interface Props { + includesTimeSliderFilter: boolean; + /** All filters applied to the map, including: + * - subset + * - marker config + * - timeline filters + * - viewport + */ + filtersForVisibleData: Filter[]; + /** Entity space of map markers, overlay, etc */ + outputEntityId: string; + /** + * If omitted, do not show studies link + */ + onShowStudies?: (showStudies: boolean) => void; +} + +const { format } = new Intl.NumberFormat('en-us'); + +export function MapTypeHeaderStudyDetails(props: Props) { + const { + filtersForVisibleData: filterForVisibleData, + includesTimeSliderFilter, + outputEntityId, + onShowStudies, + } = props; + const entities = useStudyEntities(); + const studyEntity = entities.find( + (entity) => entity.id === STUDIES_ENTITY_ID + ); + const outputEntity = entities.find((entity) => entity.id === outputEntityId); + + const studyEntityCount = useEntityCount( + studyEntity?.id, // Study entity + filterForVisibleData // Should be subset, map type, timeline, and viewport filters + ); + + const outputEntityCount = useEntityCount( + outputEntity?.id, + filterForVisibleData + ); + + // Should not happen. Throw error? + if ( + outputEntity == null || + outputEntityCount.error || + studyEntityCount.error + ) { + outputEntity == null && + console.error('Could not find an entity with the ID ' + outputEntityId); + outputEntityCount.error && console.error(outputEntityCount.error); + studyEntityCount.error && console.error(studyEntityCount.error); + return ( +
+ Could not load counts for map data +
+ ); + } + + if ( + studyEntityCount.data == null || + studyEntityCount.isFetching || + outputEntityCount.data == null || + outputEntityCount.isFetching + ) + return ( +
+ Loading counts… +
+ ); + + const totalCountFormatted = format(outputEntityCount.data.count); + const totalStudiesFormatted = format(studyEntityCount.data.count); + + const tooltipContent = ( +
+

+ {totalCountFormatted}{' '} + {outputEntityCount.data.count === 1 + ? outputEntity.displayName + ' is' + : outputEntity.displayNamePlural + ' are'}{' '} + currently visualized on the map using markers. These are the{' '} + {outputEntity.displayNamePlural} that +

    +
  • satisfy all your filters
  • + {includesTimeSliderFilter && ( +
  • satisfy the time range you have selected
  • + )} +
  • + have data for the variable that is currently displayed on the + visible or selected markers +
  • +
  • + have appropriate values, if the marker has been custom-configured +
  • +
+

+ {onShowStudies && studyEntity && ( +

+ The visualized data comes from {totalStudiesFormatted}{' '} + {studyEntityCount.data.count === 1 + ? studyEntity.displayName + : studyEntity.displayNamePlural}{' '} + +

+ )} +
+ ); + + return ( + <> +
+ {totalCountFormatted}{' '} + {outputEntityCount.data.count === 1 + ? outputEntity.displayName + : outputEntity.displayNamePlural} + {onShowStudies && studyEntity && ( + <> + {' '} + from {format(studyEntityCount.data.count)}{' '} + {studyEntity.displayNamePlural} + + )} + + + +
+ + ); +} + +function useEntityCount(entityId?: string, filters?: Filter[]) { + const studyMetadata = useStudyMetadata(); + const subsettingClient = useSubsettingClient(); + return useQuery({ + queryKey: ['entityCount', entityId, filters], + queryFn: async function () { + if (entityId == null) + return { + count: 0, + }; + return subsettingClient.getEntityCount( + studyMetadata.id, + entityId, + filters ?? [] + ); + }, + }); +} 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 index 70f81ae0d2..a6ce55a43c 100644 --- a/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/BarMarkerMapType.tsx +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/BarMarkerMapType.tsx @@ -31,7 +31,12 @@ import { ColorPaletteDefault, gradientSequentialColorscaleMap, } from '@veupathdb/components/lib/types/plots/addOns'; -import { UNSELECTED_DISPLAY_TEXT, UNSELECTED_TOKEN } from '../../../constants'; +import { + STUDIES_ENTITY_ID, + STUDY_ID_VARIABLE_ID, + UNSELECTED_DISPLAY_TEXT, + UNSELECTED_TOKEN, +} from '../../../constants'; import SemanticMarkers from '@veupathdb/components/lib/map/SemanticMarkers'; import { DistributionMarkerDataProps, @@ -40,14 +45,17 @@ import { isApproxSameViewport, markerDataFilterFuncs, pieOrBarMarkerConfigLittleFilter, + timeSliderLittleFilter, useCategoricalValues, useCommonData, useDistributionMarkerData, useDistributionOverlayConfig, + viewportLittleFilters, useSelectedMarkerSnackbars, visibleOptionFilterFuncs, getErrorOverlayComponent, getLegendErrorMessage, + selectedMarkersLittleFilter, } from '../shared'; import { useFindEntityAndVariable, @@ -67,9 +75,10 @@ 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 { MapTypeHeaderCounts } from '../MapTypeHeaderCounts'; import { useLittleFilters } from '../../littleFilters'; import TimeSliderQuickFilter from '../../TimeSliderQuickFilter'; +import { MapTypeHeaderStudyDetails } from '../MapTypeHeaderStudyDetails'; +import { SubStudies } from '../../SubStudies'; const displayName = 'Bar plots'; export const plugin: MapTypePlugin = { @@ -362,6 +371,7 @@ function MapLayerComponent(props: MapTypeMapLayerProps) { }); const handleSelectedMarkerSnackbars = useSelectedMarkerSnackbars( + appState.studyDetailsPanelConfig != null, activeVisualizationId ); @@ -419,6 +429,7 @@ function MapOverlayComponent(props: MapTypeMapLayerProps) { appState: { markerConfigurations, activeMarkerConfigurationType }, updateConfiguration, headerButtons, + setStudyDetailsPanelConfig, } = props; const configuration = props.configuration as BarPlotMarkerConfiguration; @@ -470,8 +481,28 @@ function MapOverlayComponent(props: MapTypeMapLayerProps) { floaterFilterFuncs ); + const { filters: filtersForSubStudies } = useLittleFilters( + { + filters, + appState, + geoConfigs, + }, + substudyFilterFuncs + ); + return ( <> + {appState.studyDetailsPanelConfig?.isVisble && ( + + )} + props.setStudyDetailsPanelConfig({ + ...studyDetailsPanelConfig, + isVisble, + })) + } /> ) : null; } const timeSliderFilterFuncs = [pieOrBarMarkerConfigLittleFilter]; +const substudyFilterFuncs = [ + viewportLittleFilters, + timeSliderLittleFilter, + pieOrBarMarkerConfigLittleFilter, + selectedMarkersLittleFilter, +]; + export function TimeSliderComponent(props: MapTypeMapLayerProps) { const { studyId, @@ -577,14 +603,12 @@ export function TimeSliderComponent(props: MapTypeMapLayerProps) { } = props; const toggleStarredVariable = useToggleStarredVariable(analysisState); - const findEntityAndVariable = useFindEntityAndVariable(filters); const { filters: filtersForTimeSlider } = useLittleFilters( { filters, appState, geoConfigs, - findEntityAndVariable, }, timeSliderFilterFuncs ); 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 index 8a947048e5..54b22c3f06 100644 --- a/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/BubbleMarkerMapType.tsx +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/BubbleMarkerMapType.tsx @@ -42,8 +42,11 @@ import { isApproxSameViewport, markerDataFilterFuncs, useCommonData, + timeSliderLittleFilter, + viewportLittleFilters, getErrorOverlayComponent, useSelectedMarkerSnackbars, + selectedMarkersLittleFilter, } from '../shared'; import { MapTypeConfigPanelProps, @@ -56,9 +59,14 @@ 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 { MapTypeHeaderCounts } from '../MapTypeHeaderCounts'; -import { useLittleFilters, UseLittleFiltersProps } from '../../littleFilters'; +import { + useLittleFilters, + UseLittleFiltersFuncProps, +} from '../../littleFilters'; import TimeSliderQuickFilter from '../../TimeSliderQuickFilter'; +import { SubStudies } from '../../SubStudies'; +import { MapTypeHeaderStudyDetails } from '../MapTypeHeaderStudyDetails'; +import { STUDIES_ENTITY_ID, STUDY_ID_VARIABLE_ID } from '../../../constants'; const displayName = 'Bubbles'; @@ -222,6 +230,7 @@ function BubbleMapLayer(props: MapTypeMapLayerProps) { }); const handleSelectedMarkerSnackbars = useSelectedMarkerSnackbars( + appState.studyDetailsPanelConfig != null, configuration.activeVisualizationId ); @@ -233,7 +242,7 @@ function BubbleMapLayer(props: MapTypeMapLayerProps) { selectedMarkers, }); }, - [props.configuration, updateConfiguration] + [handleSelectedMarkerSnackbars, props.configuration, updateConfiguration] ); if (markersData.error && !markersData.isFetching) @@ -277,6 +286,7 @@ function BubbleLegendsAndFloater(props: MapTypeMapLayerProps) { appState: { markerConfigurations, activeMarkerConfigurationType }, updateConfiguration, headerButtons, + setStudyDetailsPanelConfig, } = props; const configuration = props.configuration as BubbleMarkerConfiguration; @@ -336,8 +346,28 @@ function BubbleLegendsAndFloater(props: MapTypeMapLayerProps) { floaterFilterFuncs ); + const { filters: filtersForSubStudies } = useLittleFilters( + { + filters, + appState, + geoConfigs, + }, + substudyFilterFuncs + ); + return ( <> + {appState.studyDetailsPanelConfig?.isVisble && ( + + )}
{invalidProportionMessage ?? ( @@ -400,44 +430,40 @@ function BubbleLegendsAndFloater(props: MapTypeMapLayerProps) { function MapTypeHeaderDetails(props: MapTypeMapLayerProps) { const { - studyId, studyEntities, filters, geoConfigs, appState, - appState: { boundsZoomLevel }, + appState: { timeSliderConfig, studyDetailsPanelConfig }, } = props; const configuration = props.configuration as BubbleMarkerConfiguration; - const { filters: filtersForMarkerData } = useLittleFilters( + const { filters: filtersForSubStudies } = useLittleFilters( { filters, appState, geoConfigs, }, - markerDataFilterFuncs + substudyFilterFuncs ); - const markerDataResponse = useMarkerData({ - studyId, - filters: filtersForMarkerData, - geoConfigs, - boundsZoomLevel, - configuration, - }); - const { outputEntity: { id: outputEntityId }, } = useCommonData(configuration.selectedVariable, geoConfigs, studyEntities); return outputEntityId != null ? ( - + props.setStudyDetailsPanelConfig({ + ...studyDetailsPanelConfig, + isVisble, + })) } /> ) : null; @@ -445,6 +471,13 @@ function MapTypeHeaderDetails(props: MapTypeMapLayerProps) { const timeSliderFilterFuncs = [markerConfigLittleFilter]; +const substudyFilterFuncs = [ + viewportLittleFilters, + timeSliderLittleFilter, + markerConfigLittleFilter, + selectedMarkersLittleFilter, +]; + export function TimeSliderComponent(props: MapTypeMapLayerProps) { const { studyId, @@ -459,14 +492,12 @@ export function TimeSliderComponent(props: MapTypeMapLayerProps) { } = props; const toggleStarredVariable = useToggleStarredVariable(analysisState); - const findEntityAndVariable = useFindEntityAndVariable(filters); const { filters: filtersForTimeSlider } = useLittleFilters( { filters, appState, geoConfigs, - findEntityAndVariable, }, timeSliderFilterFuncs ); @@ -909,7 +940,7 @@ function useMarkerData(props: DataProps) { // calculates little filters related to // marker variable selection and custom checked values // -function markerConfigLittleFilter(props: UseLittleFiltersProps): Filter[] { +function markerConfigLittleFilter(props: UseLittleFiltersFuncProps): Filter[] { const { appState: { markerConfigurations, activeMarkerConfigurationType }, findEntityAndVariable, 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 index 1b0921c28b..e32ff5af03 100644 --- a/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/DonutMarkerMapType.tsx +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/DonutMarkerMapType.tsx @@ -12,7 +12,12 @@ import { gradientSequentialColorscaleMap, } from '@veupathdb/components/lib/types/plots/addOns'; import { useCallback, useMemo } from 'react'; -import { UNSELECTED_DISPLAY_TEXT, UNSELECTED_TOKEN } from '../../../constants'; +import { + STUDIES_ENTITY_ID, + STUDY_ID_VARIABLE_ID, + UNSELECTED_DISPLAY_TEXT, + UNSELECTED_TOKEN, +} from '../../../constants'; import { StandaloneMapMarkersResponse, Variable, @@ -41,9 +46,12 @@ import { markerDataFilterFuncs, floaterFilterFuncs, pieOrBarMarkerConfigLittleFilter, + viewportLittleFilters, + timeSliderLittleFilter, getErrorOverlayComponent, getLegendErrorMessage, useSelectedMarkerSnackbars, + selectedMarkersLittleFilter, } from '../shared'; import { MapTypeConfigPanelProps, @@ -60,7 +68,8 @@ 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 { MapTypeHeaderCounts } from '../MapTypeHeaderCounts'; +import { MapTypeHeaderStudyDetails } from '../MapTypeHeaderStudyDetails'; +import { SubStudies } from '../../SubStudies'; import { useLittleFilters } from '../../littleFilters'; import TimeSliderQuickFilter from '../../TimeSliderQuickFilter'; @@ -322,6 +331,7 @@ function MapLayerComponent(props: MapTypeMapLayerProps) { }); const handleSelectedMarkerSnackbars = useSelectedMarkerSnackbars( + appState.studyDetailsPanelConfig != null, activeVisualizationId ); @@ -380,6 +390,7 @@ function MapOverlayComponent(props: MapTypeMapLayerProps) { appState: { markerConfigurations, activeMarkerConfigurationType }, filters, headerButtons, + setStudyDetailsPanelConfig, } = props; const { selectedVariable, @@ -387,7 +398,7 @@ function MapOverlayComponent(props: MapTypeMapLayerProps) { binningMethod, activeVisualizationId, } = props.configuration as PieMarkerConfiguration; - const findEntityAndVariable = useFindEntityAndVariable(); + const findEntityAndVariable = useFindEntityAndVariable(filters); const { variable: overlayVariable } = findEntityAndVariable(selectedVariable) ?? {}; const setActiveVisualizationId = useCallback( @@ -409,6 +420,15 @@ function MapOverlayComponent(props: MapTypeMapLayerProps) { floaterFilterFuncs ); + const { filters: filtersForSubStudies } = useLittleFilters( + { + filters, + appState, + geoConfigs, + }, + substudyFilterFuncs + ); + const data = useMarkerData({ studyId, filters, @@ -435,6 +455,18 @@ function MapOverlayComponent(props: MapTypeMapLayerProps) { return ( <> + {appState.studyDetailsPanelConfig?.isVisble && ( + + )} + + props.setStudyDetailsPanelConfig({ + ...studyDetailsPanelConfig, + isVisble, + })) + } /> ) : null; } const timeSliderFilterFuncs = [pieOrBarMarkerConfigLittleFilter]; +const substudyFilterFuncs = [ + viewportLittleFilters, + timeSliderLittleFilter, + pieOrBarMarkerConfigLittleFilter, + selectedMarkersLittleFilter, +]; export function TimeSliderComponent(props: MapTypeMapLayerProps) { const { @@ -535,14 +566,12 @@ export function TimeSliderComponent(props: MapTypeMapLayerProps) { } = props; const toggleStarredVariable = useToggleStarredVariable(analysisState); - const findEntityAndVariable = useFindEntityAndVariable(filters); const { filters: filtersForTimeSlider } = useLittleFilters( { filters, appState, geoConfigs, - findEntityAndVariable, }, timeSliderFilterFuncs ); diff --git a/packages/libs/eda/src/lib/map/analysis/mapTypes/shared.tsx b/packages/libs/eda/src/lib/map/analysis/mapTypes/shared.tsx index 9e5d1cbede..4e8fd1354f 100644 --- a/packages/libs/eda/src/lib/map/analysis/mapTypes/shared.tsx +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/shared.tsx @@ -30,7 +30,10 @@ import { } from '@veupathdb/components/lib/types/plots'; import { getCategoricalValues } from '../utils/categoricalValues'; import { Viewport } from '@veupathdb/components/lib/map/MapVEuMap'; -import { UseLittleFiltersProps } from '../littleFilters'; +import { + UseLittleFiltersFuncProps, + UseLittleFiltersProps, +} from '../littleFilters'; import { filtersFromBoundingBox } from '../../../core/utils/visualization'; import { MapFloatingErrorDiv } from '../MapFloatingErrorDiv'; import Banner from '@veupathdb/coreui/lib/components/banners/Banner'; @@ -382,11 +385,12 @@ export function isApproxSameViewport(v1: Viewport, v2: Viewport) { // returns a function (selectedMarkers?) => voi export function useSelectedMarkerSnackbars( + isMegaStudy: boolean, activeVisualizationId: string | undefined ) { const { enqueueSnackbar } = useSnackbar(); const [shownSelectedMarkersSnackbar, setShownSelectedMarkersSnackbar] = - useState(false); + useState(isMegaStudy); const [shownShiftKeySnackbar, setShownShiftKeySnackbar] = useState(false); return useCallback( @@ -434,7 +438,7 @@ export function useSelectedMarkerSnackbars( * little filter helpers */ -function timeSliderLittleFilter(props: UseLittleFiltersProps): Filter[] { +export function timeSliderLittleFilter(props: UseLittleFiltersProps): Filter[] { const { timeSliderConfig } = props.appState; if (timeSliderConfig != null) { @@ -452,7 +456,7 @@ function timeSliderLittleFilter(props: UseLittleFiltersProps): Filter[] { return []; } -function viewportLittleFilters(props: UseLittleFiltersProps): Filter[] { +export function viewportLittleFilters(props: UseLittleFiltersProps): Filter[] { const { appState: { boundsZoomLevel }, geoConfigs, @@ -478,7 +482,7 @@ function viewportLittleFilters(props: UseLittleFiltersProps): Filter[] { // marker variable selection and custom checked values // export function pieOrBarMarkerConfigLittleFilter( - props: UseLittleFiltersProps + props: UseLittleFiltersFuncProps ): Filter[] { const { appState: { markerConfigurations, activeMarkerConfigurationType }, @@ -587,7 +591,9 @@ export function pieOrBarMarkerConfigLittleFilter( // to the current zoom level and creates a little filter // on that variable using `selectedMarkers` // -function selectedMarkersLittleFilter(props: UseLittleFiltersProps): Filter[] { +export function selectedMarkersLittleFilter( + props: UseLittleFiltersProps +): Filter[] { const { appState: { markerConfigurations, diff --git a/packages/libs/eda/src/lib/map/analysis/mapTypes/types.ts b/packages/libs/eda/src/lib/map/analysis/mapTypes/types.ts index 49e6c05b70..03beff0e2c 100644 --- a/packages/libs/eda/src/lib/map/analysis/mapTypes/types.ts +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/types.ts @@ -7,7 +7,7 @@ import { } from '../../../core'; import { GeoConfig } from '../../../core/types/geoConfig'; import { ComputationAppOverview } from '../../../core/types/visualization'; -import { AppState } from '../appState'; +import { AppState, PanelConfig } from '../appState'; import { EntityCounts } from '../../../core/hooks/entityCounts'; import { SiteInformationProps } from '../Types'; @@ -43,6 +43,10 @@ export interface MapTypeMapLayerProps { // and sent to plugin components that don't need it - we should also address this hideVizInputsAndControls: boolean; setHideVizInputsAndControls: (hide: boolean) => void; + // selectedMarkers and its state function + selectedMarkers?: string[]; + setSelectedMarkers?: React.Dispatch>; + setStudyDetailsPanelConfig: (config: PanelConfig) => void; setTimeSliderConfig?: ( newConfig: NonNullable ) => void; diff --git a/packages/libs/eda/src/lib/map/constants.ts b/packages/libs/eda/src/lib/map/constants.ts index 60f53379cb..d226844f7e 100644 --- a/packages/libs/eda/src/lib/map/constants.ts +++ b/packages/libs/eda/src/lib/map/constants.ts @@ -7,3 +7,6 @@ export const mapSidePanelBorder: CSSProperties['border'] = '1px solid #D9D9D9'; export const UNSELECTED_TOKEN = '__UNSELECTED__'; // This is what is displayed to the user instead: export const UNSELECTED_DISPLAY_TEXT = 'All other values'; + +export const STUDIES_ENTITY_ID = 'EUPATH_0000605'; +export const STUDY_ID_VARIABLE_ID = 'OBI_0001622';