diff --git a/packages/libs/components/src/map/BoundsDriftMarker.tsx b/packages/libs/components/src/map/BoundsDriftMarker.tsx index 6fbccc4cc2..455a35e925 100755 --- a/packages/libs/components/src/map/BoundsDriftMarker.tsx +++ b/packages/libs/components/src/map/BoundsDriftMarker.tsx @@ -1,5 +1,5 @@ import { useMap, Popup } from 'react-leaflet'; -import { useRef, useEffect, useState } from 'react'; +import { useRef, useEffect, useCallback } from 'react'; // use new ReactLeafletDriftMarker instead of DriftMarker import ReactLeafletDriftMarker from 'react-leaflet-drift-marker'; import { MarkerProps, Bounds } from './Types'; @@ -15,7 +15,7 @@ export interface BoundsDriftMarkerProps extends MarkerProps { // selectedMarkers state selectedMarkers?: string[]; // selectedMarkers setState - setSelectedMarkers?: React.Dispatch>; + setSelectedMarkers?: (selectedMarkers: string[] | undefined) => void; } /** @@ -281,30 +281,33 @@ export default function BoundsDriftMarker({ }; // add click events for highlighting markers - const handleClick = (e: LeafletMouseEvent) => { - // check the number of mouse click and enable function for single click only - if (e.originalEvent.detail === 1) { - if (setSelectedMarkers) { - if (selectedMarkers?.find((id) => id === props.id)) { - setSelectedMarkers((prevSelectedMarkers: string[]) => - prevSelectedMarkers.filter((id: string) => id !== props.id) - ); - } else { - // select - setSelectedMarkers((prevSelectedMarkers: string[]) => [ - ...prevSelectedMarkers, - props.id, - ]); + const handleClick = useCallback( + (e: LeafletMouseEvent) => { + // check the number of mouse click and enable function for single click only + if (e.originalEvent.detail === 1) { + if (setSelectedMarkers) { + if (selectedMarkers?.find((id) => id === props.id)) { + setSelectedMarkers( + selectedMarkers.filter((id: string) => id !== props.id) + ); + } else if (e.originalEvent.shiftKey) { + // add to selection if SHIFT key pressed + setSelectedMarkers([...(selectedMarkers ?? []), props.id]); + } else { + // replace selection + setSelectedMarkers([props.id]); + } } - } - // Sometimes clicking throws off the popup's orientation, so reorient it - orientPopup(popupOrientationRef.current); - // Default popup behavior is to open on marker click - // Prevent by immediately closing it - e.target.closePopup(); - } - }; + // Sometimes clicking throws off the popup's orientation, so reorient it + orientPopup(popupOrientationRef.current); + // Default popup behavior is to open on marker click + // Prevent by immediately closing it + e.target.closePopup(); + } + }, + [setSelectedMarkers, selectedMarkers, props.id] + ); const handleDoubleClick = () => { if (map) { diff --git a/packages/libs/components/src/map/MapVEuMap.tsx b/packages/libs/components/src/map/MapVEuMap.tsx index d6014121b1..3696d76b06 100755 --- a/packages/libs/components/src/map/MapVEuMap.tsx +++ b/packages/libs/components/src/map/MapVEuMap.tsx @@ -151,8 +151,6 @@ export interface MapVEuMapProps { scrollingEnabled?: boolean; /** pass default viewport */ defaultViewport?: Viewport; - /* selectedMarkers setState (for on-click reset) **/ - setSelectedMarkers?: React.Dispatch>; children?: React.ReactNode; onMapClick?: () => void; onMapDrag?: () => void; @@ -179,7 +177,6 @@ function MapVEuMap(props: MapVEuMapProps, ref: Ref) { scrollingEnabled = true, interactive = true, defaultViewport, - setSelectedMarkers, onMapClick, onMapDrag, onMapZoom, @@ -253,7 +250,7 @@ function MapVEuMap(props: MapVEuMapProps, ref: Ref) { zoom={viewport.zoom} minZoom={1} zoomSnap={zoomSnap} - wheelPxPerZoomLevel={1000} + wheelPxPerZoomLevel={100} // We add our own custom zoom control below zoomControl={false} style={{ height, width, ...style }} @@ -296,7 +293,6 @@ function MapVEuMap(props: MapVEuMapProps, ref: Ref) { void; onBoundsChanged: (bondsViewport: BoundsViewport) => void; onBaseLayerChanged?: (newBaseLayer: BaseLayerChoice) => void; - setSelectedMarkers?: React.Dispatch>; onMapClick?: () => void; onMapDrag?: () => void; onMapZoom?: () => void; } -const EMPTY_MARKERS: string[] = []; - // function to handle map events such as onViewportChanged and baselayerchange function MapVEuMapEvents(props: MapVEuMapEventsProps) { const { onViewportChanged, onBaseLayerChanged, onBoundsChanged, - setSelectedMarkers, onMapClick, onMapDrag, onMapZoom, @@ -376,8 +368,6 @@ function MapVEuMapEvents(props: MapVEuMapEventsProps) { }, // map click event: remove selected highlight markers click: () => { - if (setSelectedMarkers != null) setSelectedMarkers(EMPTY_MARKERS); - if (onMapClick != null) onMapClick(); }, }); diff --git a/packages/libs/components/src/map/SemanticMarkers.tsx b/packages/libs/components/src/map/SemanticMarkers.tsx index 74cbea3392..b623d770f5 100644 --- a/packages/libs/components/src/map/SemanticMarkers.tsx +++ b/packages/libs/components/src/map/SemanticMarkers.tsx @@ -8,7 +8,7 @@ import { } from 'react'; import { AnimationFunction, Bounds } from './Types'; import { BoundsDriftMarkerProps } from './BoundsDriftMarker'; -import { useMap } from 'react-leaflet'; +import { useMap, useMapEvents } from 'react-leaflet'; import { LatLngBounds } from 'leaflet'; import { debounce, isEqual } from 'lodash'; @@ -27,7 +27,7 @@ export interface SemanticMarkersProps { /* selectedMarkers state **/ selectedMarkers?: string[]; /* selectedMarkers setState **/ - setSelectedMarkers?: React.Dispatch>; + setSelectedMarkers?: (selectedMarkers: string[] | undefined) => void; } /** @@ -48,6 +48,13 @@ export default function SemanticMarkers({ // react-leaflet v3 const map = useMap(); + // cancel marker selection with a single click on the map + useMapEvents({ + click: () => { + if (setSelectedMarkers != null) setSelectedMarkers(undefined); + }, + }); + const [prevRecenteredMarkers, setPrevRecenteredMarkers] = useState[]>(markers); @@ -128,7 +135,13 @@ export default function SemanticMarkers({ }); // set them as current // any marker that already existed will move to the modified position - setConsolidatedMarkers(animationValues.markers); + if ( + !isEqual( + animationValues.markers.map(({ props }) => props), + consolidatedMarkers.map(({ props }) => props) + ) + ) + setConsolidatedMarkers(animationValues.markers); // then set a timer to remove the old markers when zooming out // or if zooming in, switch to just the new markers straight away // (their starting position was set by `animationFunction`) @@ -139,7 +152,13 @@ export default function SemanticMarkers({ ); } else { /** First render of markers **/ - setConsolidatedMarkers(recenteredMarkers); + if ( + !isEqual( + recenteredMarkers.map(({ props }) => props), + consolidatedMarkers.map(({ props }) => props) + ) + ) + setConsolidatedMarkers(recenteredMarkers); } // To prevent infinite loops, especially when in "other worlds", @@ -176,7 +195,14 @@ export default function SemanticMarkers({ ); } } - }, [animation, map, markers, prevRecenteredMarkers, recenterMarkers]); + }, [ + animation, + map, + markers, + prevRecenteredMarkers, + recenterMarkers, + consolidatedMarkers, + ]); // remove any selectedMarkers that no longer exist in the current markers useEffect(() => { @@ -188,7 +214,7 @@ export default function SemanticMarkers({ if (prunedSelectedMarkers.length < selectedMarkers.length) setSelectedMarkers(prunedSelectedMarkers); } - }, [consolidatedMarkers, selectedMarkers]); + }, [consolidatedMarkers, selectedMarkers, setSelectedMarkers]); // add the selectedMarkers props and callback // (and the scheduled-for-removal showPopup prop) @@ -201,7 +227,7 @@ export default function SemanticMarkers({ setSelectedMarkers, }) ), - [consolidatedMarkers, selectedMarkers] + [consolidatedMarkers, selectedMarkers, setSelectedMarkers] ); // this should use the unadulterated markers (which are always in the "main world") diff --git a/packages/libs/components/src/map/animation_functions/geohash.tsx b/packages/libs/components/src/map/animation_functions/geohash.tsx index bad29af056..d0b0ed10d0 100644 --- a/packages/libs/components/src/map/animation_functions/geohash.tsx +++ b/packages/libs/components/src/map/animation_functions/geohash.tsx @@ -40,7 +40,7 @@ export default function geohashAnimation({ } else { /** No difference in geohashes - Render markers as they are **/ zoomType = null; - consolidatedMarkers = [...markers]; + consolidatedMarkers = markers; } return { zoomType: zoomType, markers: consolidatedMarkers }; diff --git a/packages/libs/components/src/stories/MarkerSelection.stories.tsx b/packages/libs/components/src/stories/MarkerSelection.stories.tsx index 030fdd6407..0bd786ec15 100755 --- a/packages/libs/components/src/stories/MarkerSelection.stories.tsx +++ b/packages/libs/components/src/stories/MarkerSelection.stories.tsx @@ -45,7 +45,8 @@ export const DonutMarkers: Story = (args) => { }); // make an array of objects state to list highlighted markers - const [selectedMarkers, setSelectedMarkers] = useState([]); + const [selectedMarkers, setSelectedMarkers] = + useState(); console.log('selectedMarkers =', selectedMarkers); @@ -72,7 +73,6 @@ export const DonutMarkers: Story = (args) => { onViewportChanged={setViewport} onBoundsChanged={handleViewportChanged} zoomLevelToGeohashLevel={leafletZoomLevelToGeohashLevel} - setSelectedMarkers={setSelectedMarkers} > = (args) => { const duration = defaultAnimationDuration; // make an array of objects state to list highlighted markers - const [selectedMarkers, setSelectedMarkers] = useState([]); + const [selectedMarkers, setSelectedMarkers] = + useState(); console.log('selectedMarkers =', selectedMarkers); @@ -142,7 +143,6 @@ export const ChartMarkers: Story = (args) => { onBoundsChanged={handleViewportChanged} showGrid={true} zoomLevelToGeohashLevel={leafletZoomLevelToGeohashLevel} - setSelectedMarkers={setSelectedMarkers} > {} function FullscreenComponent(props: VisualizationProps) { @@ -902,6 +903,7 @@ function BarplotViz(props: VisualizationProps) { .every((reqdVar) => !!(vizConfig as any)[reqdVar[0]]); const LayoutComponent = options?.layoutComponent ?? PlotLayout; + const plotSubtitle = options?.getPlotSubtitle?.(); return (
@@ -935,7 +937,11 @@ function BarplotViz(props: VisualizationProps) { {!hideInputsAndControls && ( - + )} ) { ); - // plot subtitle const plotSubtitle = options?.getPlotSubtitle?.( computation.descriptor.configuration ); diff --git a/packages/libs/eda/src/lib/core/components/visualizations/implementations/HistogramVisualization.tsx b/packages/libs/eda/src/lib/core/components/visualizations/implementations/HistogramVisualization.tsx index c9f668b4cd..c5aae9365c 100755 --- a/packages/libs/eda/src/lib/core/components/visualizations/implementations/HistogramVisualization.tsx +++ b/packages/libs/eda/src/lib/core/components/visualizations/implementations/HistogramVisualization.tsx @@ -114,21 +114,16 @@ import { histogramDefaultIndependentAxisMinMax, histogramDefaultDependentAxisMinMax, } from '../../../utils/axis-range-calculations'; -import { LayoutOptions } from '../../layouts/types'; +import { LayoutOptions, TitleOptions } from '../../layouts/types'; import { OverlayOptions, RequestOptionProps, RequestOptions, } from '../options/types'; import { useDeepValue } from '../../../hooks/immutability'; - -// reset to defaults button import { ResetButtonCoreUI } from '../../ResetButton'; import { FloatingHistogramExtraProps } from '../../../../map/analysis/hooks/plugins/histogram'; import { useFindOutputEntity } from '../../../hooks/findOutputEntity'; - -import { getDistribution } from '../../filter/util'; -import { DistributionResponse } from '../../../api/SubsettingClient'; import { useSubsettingClient } from '../../../hooks/workspace'; import { red } from '../../filter/colors'; import { min, max } from 'lodash'; @@ -203,6 +198,7 @@ export const HistogramConfig = t.intersection([ interface Options extends LayoutOptions, OverlayOptions, + TitleOptions, RequestOptions< HistogramConfig, FloatingHistogramExtraProps, @@ -463,73 +459,70 @@ function HistogramViz(props: VisualizationProps) { // get distribution data const subsettingClient = useSubsettingClient(); - const getDistributionData = useCallback(async () => { - if (vizConfig.xAxisVariable != null && xAxisVariable != null) { - const [displayRangeMin, displayRangeMax, binWidth, binUnits] = - NumberVariable.is(xAxisVariable) - ? [ - xAxisVariable.distributionDefaults.displayRangeMin ?? - xAxisVariable.distributionDefaults.rangeMin, - xAxisVariable.distributionDefaults.displayRangeMax ?? - xAxisVariable.distributionDefaults.rangeMax, - xAxisVariable.distributionDefaults.binWidth, - undefined, - ] - : [ - (xAxisVariable as DateVariable).distributionDefaults - .displayRangeMin ?? - (xAxisVariable as DateVariable).distributionDefaults.rangeMin, - (xAxisVariable as DateVariable).distributionDefaults - .displayRangeMax ?? - (xAxisVariable as DateVariable).distributionDefaults.rangeMax, - (xAxisVariable as DateVariable).distributionDefaults.binWidth, - (xAxisVariable as DateVariable).distributionDefaults.binUnits, - ]; - - // try to call once - const distribution = await subsettingClient.getDistribution( - studyMetadata.id, - vizConfig.xAxisVariable?.entityId ?? '', - vizConfig.xAxisVariable?.variableId ?? '', - { - valueSpec: 'count', - filters, - binSpec: { - // Note: technically any arbitrary values can be used here for displayRangeMin/Max - // but used more accurate value anyway - displayRangeMin: DateVariable.is(xAxisVariable) - ? displayRangeMin + 'T00:00:00Z' - : displayRangeMin, - displayRangeMax: DateVariable.is(xAxisVariable) - ? displayRangeMax + 'T00:00:00Z' - : displayRangeMax, - binWidth: binWidth ?? 1, - binUnits: binUnits, - }, - } - ); - - // return series using foreground response - const series = { - series: [ - distributionResponseToDataSeries( - 'Subset', - distribution, - red, - NumberVariable.is(xAxisVariable) ? 'number' : 'date' - ), - ], - }; + const distributionDataPromise = usePromise( + useCallback(async () => { + if (vizConfig.xAxisVariable != null && xAxisVariable != null) { + const [displayRangeMin, displayRangeMax, binWidth, binUnits] = + NumberVariable.is(xAxisVariable) + ? [ + xAxisVariable.distributionDefaults.displayRangeMin ?? + xAxisVariable.distributionDefaults.rangeMin, + xAxisVariable.distributionDefaults.displayRangeMax ?? + xAxisVariable.distributionDefaults.rangeMax, + xAxisVariable.distributionDefaults.binWidth, + undefined, + ] + : [ + (xAxisVariable as DateVariable).distributionDefaults + .displayRangeMin ?? + (xAxisVariable as DateVariable).distributionDefaults.rangeMin, + (xAxisVariable as DateVariable).distributionDefaults + .displayRangeMax ?? + (xAxisVariable as DateVariable).distributionDefaults.rangeMax, + (xAxisVariable as DateVariable).distributionDefaults.binWidth, + (xAxisVariable as DateVariable).distributionDefaults.binUnits, + ]; + + // try to call once + const distribution = await subsettingClient.getDistribution( + studyMetadata.id, + vizConfig.xAxisVariable?.entityId ?? '', + vizConfig.xAxisVariable?.variableId ?? '', + { + valueSpec: 'count', + filters, + binSpec: { + // Note: technically any arbitrary values can be used here for displayRangeMin/Max + // but used more accurate value anyway + displayRangeMin: DateVariable.is(xAxisVariable) + ? displayRangeMin + 'T00:00:00Z' + : displayRangeMin, + displayRangeMax: DateVariable.is(xAxisVariable) + ? displayRangeMax + 'T00:00:00Z' + : displayRangeMax, + binWidth: binWidth ?? 1, + binUnits: binUnits, + }, + } + ); - return series; - } + // return series using foreground response + const series = { + series: [ + distributionResponseToDataSeries( + 'Subset', + distribution, + red, + NumberVariable.is(xAxisVariable) ? 'number' : 'date' + ), + ], + }; - return undefined; - }, [filters, xAxisVariable, vizConfig.xAxisVariable, subsettingClient]); + return series; + } - // need useCallback to avoid infinite loop - const distributionDataPromise = usePromise( - useCallback(() => getDistributionData(), [getDistributionData]) + return undefined; + }, [filters, xAxisVariable, vizConfig.xAxisVariable, subsettingClient]) ); const dataRequestConfig: DataRequestConfig = useDeepValue( @@ -560,13 +553,6 @@ function HistogramViz(props: VisualizationProps) { ) return undefined; - // wait till distributionDataPromise is ready - if ( - distributionDataPromise.pending || - distributionDataPromise.value == null - ) - return undefined; - if ( !variablesAreUnique([ xAxisVariable, @@ -662,8 +648,6 @@ function HistogramViz(props: VisualizationProps) { computation.descriptor.type, overlayEntity, facetEntity, - distributionDataPromise.pending, - distributionDataPromise.value, ]) ); @@ -682,21 +666,22 @@ function HistogramViz(props: VisualizationProps) { if ( !distributionDataPromise.pending && distributionDataPromise.value != null - ) - return { - min: DateVariable.is(xAxisVariable) - ? ( - (distributionDataPromise?.value?.series[0]?.summary - ?.min as string) ?? '' - ).split('T')[0] - : distributionDataPromise?.value?.series[0]?.summary?.min, - max: DateVariable.is(xAxisVariable) - ? ( - (distributionDataPromise?.value?.series[0]?.summary - ?.max as string) ?? '' - ).split('T')[0] - : distributionDataPromise?.value?.series[0]?.summary?.max, - }; + ) { + const min = distributionDataPromise.value.series[0]?.summary?.min; + const max = distributionDataPromise.value.series[0]?.summary?.max; + + if (min != null && max != null) { + if (DateVariable.is(xAxisVariable)) { + return { + min: (min as string).split('T')[0], + max: (max as string).split('T')[0], + }; + } else { + return { min, max }; + } + } + } + return undefined; }, [distributionDataPromise]); const independentAxisMinMax = useMemo(() => { @@ -1358,6 +1343,7 @@ function HistogramViz(props: VisualizationProps) { ); const LayoutComponent = options?.layoutComponent ?? PlotLayout; + const plotSubtitle = options?.getPlotSubtitle?.(); return (
@@ -1393,7 +1379,11 @@ function HistogramViz(props: VisualizationProps) {
{!hideInputsAndControls && ( - + )} ) { ); const LayoutComponent = options?.layoutComponent ?? PlotLayout; - + const plotSubtitle = options?.getPlotSubtitle?.(); return (
@@ -1825,7 +1826,11 @@ function LineplotViz(props: VisualizationProps) { {!hideInputsAndControls && ( - + )} ) { return ; @@ -736,7 +736,7 @@ function MosaicViz(props: Props) { }, [dataElementConstraints, isTwoByTwo, vizConfig]); const LayoutComponent = options?.layoutComponent ?? PlotLayout; - + const plotSubtitle = options?.getPlotSubtitle?.(); const classes = useInputStyles(); /** @@ -1022,7 +1022,11 @@ function MosaicViz(props: Props) { {!hideInputsAndControls && ( - + )} ([]); - const sidePanelMenuEntries: SidePanelMenuEntry[] = [ { type: 'heading', @@ -700,6 +697,7 @@ function MapAnalysisImpl(props: ImplProps) { if (appState.isSidePanelExpanded && activeSideMenuId === nextSideMenuId) enqueueSnackbar('Marker configuration panel is already open', { variant: 'info', + anchorOrigin: { vertical: 'top', horizontal: 'center' }, }); if (!appState.isSidePanelExpanded) setIsSidePanelExpanded(true); @@ -766,10 +764,6 @@ function MapAnalysisImpl(props: ImplProps) { filteredCounts, hideVizInputsAndControls, setHideVizInputsAndControls, - /* disabled until wired in fully: - selectedMarkers, - setSelectedMarkers, - */ headerButtons: HeaderButtons, }; @@ -855,8 +849,6 @@ function MapAnalysisImpl(props: ImplProps) { } // pass defaultViewport & isStandAloneMap props for custom zoom control defaultViewport={defaultViewport} - // for multiple markers cancelation of selection only - setSelectedMarkers={setSelectedMarkers} // close left-side panel when map events happen onMapClick={closePanel} onMapDrag={closePanel} diff --git a/packages/libs/eda/src/lib/map/analysis/appState.ts b/packages/libs/eda/src/lib/map/analysis/appState.ts index 9ba12b8a00..90787a1a43 100644 --- a/packages/libs/eda/src/lib/map/analysis/appState.ts +++ b/packages/libs/eda/src/lib/map/analysis/appState.ts @@ -49,20 +49,34 @@ export const MarkerConfiguration = t.intersection([ activeVisualizationId: t.string, }), t.union([ - t.type({ - type: t.literal('barplot'), - selectedValues: SelectedValues, - selectedPlotMode: t.union([t.literal('count'), t.literal('proportion')]), - binningMethod: BinningMethod, - dependentAxisLogScale: t.boolean, - selectedCountsOption: SelectedCountsOption, - }), - t.type({ - type: t.literal('pie'), - selectedValues: SelectedValues, - binningMethod: BinningMethod, - selectedCountsOption: SelectedCountsOption, - }), + t.intersection([ + t.type({ + type: t.literal('barplot'), + selectedValues: SelectedValues, + selectedPlotMode: t.union([ + t.literal('count'), + t.literal('proportion'), + ]), + binningMethod: BinningMethod, + dependentAxisLogScale: t.boolean, + selectedCountsOption: SelectedCountsOption, + }), + t.partial({ + // yes all the modes have selectedMarkers but maybe in the future one won't + selectedMarkers: t.array(t.string), + }), + ]), + t.intersection([ + t.type({ + type: t.literal('pie'), + selectedValues: SelectedValues, + binningMethod: BinningMethod, + selectedCountsOption: SelectedCountsOption, + }), + t.partial({ + selectedMarkers: t.array(t.string), + }), + ]), t.intersection([ t.type({ type: t.literal('bubble'), @@ -71,6 +85,7 @@ export const MarkerConfiguration = t.intersection([ aggregator: t.union([t.literal('mean'), t.literal('median')]), numeratorValues: t.union([t.array(t.string), t.undefined]), denominatorValues: t.union([t.array(t.string), t.undefined]), + selectedMarkers: t.array(t.string), }), ]), ]), diff --git a/packages/libs/eda/src/lib/map/analysis/hooks/standaloneVizPlugins.ts b/packages/libs/eda/src/lib/map/analysis/hooks/standaloneVizPlugins.ts index ab9997a313..b488df56d3 100644 --- a/packages/libs/eda/src/lib/map/analysis/hooks/standaloneVizPlugins.ts +++ b/packages/libs/eda/src/lib/map/analysis/hooks/standaloneVizPlugins.ts @@ -3,7 +3,10 @@ import * as t from 'io-ts'; import { ComputationPlugin } from '../../../core/components/computations/Types'; import { ZeroConfiguration } from '../../../core/components/computations/ZeroConfiguration'; import { FloatingLayout } from '../../../core/components/layouts/FloatingLayout'; -import { LayoutOptions } from '../../../core/components/layouts/types'; +import { + LayoutOptions, + TitleOptions, +} from '../../../core/components/layouts/types'; import { OverlayOptions, RequestOptionProps, @@ -30,17 +33,20 @@ import { scatterplotRequest } from './plugins/scatterplot'; import TimeSeriesSVG from '../../../core/components/visualizations/implementations/selectorIcons/TimeSeriesSVG'; import _ from 'lodash'; +import { EntitySubtitleForViz } from '../mapTypes/shared'; interface Props { selectedOverlayConfig?: OverlayConfig | BubbleOverlayConfig; overlayHelp?: ReactNode; + selectedMarkers?: string[] | undefined; } -type StandaloneVizOptions = LayoutOptions & OverlayOptions; +type StandaloneVizOptions = LayoutOptions & OverlayOptions & TitleOptions; export function useStandaloneVizPlugins({ selectedOverlayConfig, overlayHelp = 'The overlay variable can be selected via the top-right panel.', + selectedMarkers, }: Props): Record { return useMemo(() => { function vizWithOptions( @@ -70,6 +76,17 @@ export function useStandaloneVizPlugins({ } }, getOverlayVariableHelp: () => overlayHelp, + getPlotSubtitle: () => + selectedMarkers && selectedMarkers.length > 0 + ? selectedMarkers.length > 1 + ? EntitySubtitleForViz({ subtitle: 'for selected markers' }) + : EntitySubtitleForViz({ + subtitle: 'for selected marker - shift-click to add more', + }) + : EntitySubtitleForViz({ + subtitle: + 'for all visible data on map - click markers to refine', + }), }); } @@ -154,5 +171,5 @@ export function useStandaloneVizPlugins({ }, }, }; - }, [overlayHelp, selectedOverlayConfig]); + }, [overlayHelp, selectedOverlayConfig, selectedMarkers]); } 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 72004e973d..7cd8313134 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 @@ -46,6 +46,7 @@ import { useCommonData, useDistributionMarkerData, useDistributionOverlayConfig, + useSelectedMarkerSnackbars, visibleOptionFilterFuncs, } from '../shared'; import { @@ -322,8 +323,12 @@ function MapLayerComponent(props: MapTypeMapLayerProps) { filters, geoConfigs, appState, - selectedMarkers, - setSelectedMarkers, + appState: { + boundsZoomLevel, + markerConfigurations, + activeMarkerConfigurationType, + }, + updateConfiguration, } = props; const { @@ -332,6 +337,7 @@ function MapLayerComponent(props: MapTypeMapLayerProps) { binningMethod, dependentAxisLogScale, selectedPlotMode, + activeVisualizationId, } = props.configuration as BarPlotMarkerConfiguration; const { filters: filtersForMarkerData } = useLittleFilters( @@ -348,7 +354,7 @@ function MapLayerComponent(props: MapTypeMapLayerProps) { studyId, filters: filtersForMarkerData, geoConfigs, - boundsZoomLevel: appState.boundsZoomLevel, + boundsZoomLevel, selectedVariable, selectedValues, binningMethod, @@ -356,16 +362,36 @@ function MapLayerComponent(props: MapTypeMapLayerProps) { valueSpec: selectedPlotMode, }); + const handleSelectedMarkerSnackbars = useSelectedMarkerSnackbars( + activeVisualizationId + ); + + const setSelectedMarkers = useCallback( + (selectedMarkers?: string[]) => { + handleSelectedMarkerSnackbars(selectedMarkers); + updateConfiguration({ + ...(props.configuration as BarPlotMarkerConfiguration), + selectedMarkers, + }); + }, + [props.configuration, updateConfiguration, handleSelectedMarkerSnackbars] + ); + if (markerData.error && !markerData.isFetching) return isNoDataError(markerData.error) ? null : ( ); - // pass selectedMarkers and its state function + // convert marker data to markers const markers = markerData.markerProps?.map((markerProps) => ( )); + const selectedMarkers = markerConfigurations.find( + (markerConfiguration) => + markerConfiguration.type === activeMarkerConfigurationType + )?.selectedMarkers; + return ( <> {markerData.isFetching && } @@ -393,6 +419,7 @@ function MapOverlayComponent(props: MapTypeMapLayerProps) { filters, geoConfigs, appState, + appState: { markerConfigurations, activeMarkerConfigurationType }, updateConfiguration, headerButtons, } = props; @@ -424,9 +451,15 @@ function MapOverlayComponent(props: MapTypeMapLayerProps) { valueSpec: configuration.selectedPlotMode, }); + const selectedMarkers = markerConfigurations.find( + (markerConfiguration) => + markerConfiguration.type === activeMarkerConfigurationType + )?.selectedMarkers; + const legendItems = markerData.legendItems; const plugins = useStandaloneVizPlugins({ selectedOverlayConfig: markerData.overlayConfig, + selectedMarkers, }); const toggleStarredVariable = useToggleStarredVariable(props.analysisState); const noDataError = isNoDataError(markerData.error) 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 bb09e71a25..8c89963c63 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,6 +42,7 @@ import { isApproxSameViewport, markerDataFilterFuncs, useCommonData, + useSelectedMarkerSnackbars, } from '../shared'; import { MapTypeConfigPanelProps, @@ -65,7 +66,7 @@ export const plugin: MapTypePlugin = { displayName, ConfigPanelComponent: BubbleMapConfigurationPanel, MapLayerComponent: BubbleMapLayer, - MapOverlayComponent: BubbleLegends, + MapOverlayComponent: BubbleLegendsAndFloater, MapTypeHeaderDetails, TimeSliderComponent, }; @@ -186,10 +187,13 @@ function BubbleMapLayer(props: MapTypeMapLayerProps) { studyId, filters, appState, - appState: { boundsZoomLevel }, + appState: { + boundsZoomLevel, + markerConfigurations, + activeMarkerConfigurationType, + }, + updateConfiguration, geoConfigs, - selectedMarkers, - setSelectedMarkers, } = props; const configuration = props.configuration as BubbleMarkerConfiguration; @@ -216,6 +220,22 @@ function BubbleMapLayer(props: MapTypeMapLayerProps) { studyId, filters: filtersForMarkerData, }); + + const handleSelectedMarkerSnackbars = useSelectedMarkerSnackbars( + configuration.activeVisualizationId + ); + + const setSelectedMarkers = useCallback( + (selectedMarkers?: string[]) => { + handleSelectedMarkerSnackbars(selectedMarkers); + updateConfiguration({ + ...(props.configuration as BubbleMarkerConfiguration), + selectedMarkers, + }); + }, + [props.configuration, updateConfiguration] + ); + if (markersData.error && !markersData.isFetching) return ; @@ -223,6 +243,11 @@ function BubbleMapLayer(props: MapTypeMapLayerProps) { )); + const selectedMarkers = markerConfigurations.find( + (markerConfiguration) => + markerConfiguration.type === activeMarkerConfigurationType + )?.selectedMarkers; + return ( <> {markersData.isFetching && } @@ -243,12 +268,13 @@ function BubbleMapLayer(props: MapTypeMapLayerProps) { ); } -function BubbleLegends(props: MapTypeMapLayerProps) { +function BubbleLegendsAndFloater(props: MapTypeMapLayerProps) { const { studyId, filters, geoConfigs, appState, + appState: { markerConfigurations, activeMarkerConfigurationType }, updateConfiguration, headerButtons, } = props; @@ -281,8 +307,14 @@ function BubbleLegends(props: MapTypeMapLayerProps) { [configuration, updateConfiguration] ); + const selectedMarkers = markerConfigurations.find( + (markerConfiguration) => + markerConfiguration.type === activeMarkerConfigurationType + )?.selectedMarkers; + const plugins = useStandaloneVizPlugins({ overlayHelp: 'Overlay variables are not available for this map type', + selectedMarkers, }); const toggleStarredVariable = useToggleStarredVariable(props.analysisState); 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 ebf69ec42c..74107a837c 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 @@ -43,6 +43,7 @@ import { markerDataFilterFuncs, floaterFilterFuncs, pieOrBarMarkerConfigLittleFilter, + useSelectedMarkerSnackbars, } from '../shared'; import { MapTypeConfigPanelProps, @@ -279,20 +280,26 @@ function ConfigPanelComponent(props: MapTypeConfigPanelProps) { } function MapLayerComponent(props: MapTypeMapLayerProps) { - // selectedMarkers and its state function const { studyId, studyEntities, - selectedMarkers, - setSelectedMarkers, appState, - appState: { boundsZoomLevel }, + appState: { + boundsZoomLevel, + markerConfigurations, + activeMarkerConfigurationType, + }, geoConfigs, filters, + updateConfiguration, } = props; - const { selectedVariable, binningMethod, selectedValues } = - props.configuration as PieMarkerConfiguration; + const { + selectedVariable, + binningMethod, + selectedValues, + activeVisualizationId, + } = props.configuration as PieMarkerConfiguration; const { filters: filtersForMarkerData } = useLittleFilters( { @@ -315,16 +322,37 @@ function MapLayerComponent(props: MapTypeMapLayerProps) { valueSpec: 'count', }); + const handleSelectedMarkerSnackbars = useSelectedMarkerSnackbars( + activeVisualizationId + ); + + const setSelectedMarkers = useCallback( + (selectedMarkers?: string[]) => { + handleSelectedMarkerSnackbars(selectedMarkers); + updateConfiguration({ + ...(props.configuration as PieMarkerConfiguration), + selectedMarkers, + }); + }, + [props.configuration, updateConfiguration, handleSelectedMarkerSnackbars] + ); + // no markers and no error div for certain known error strings if (markerDataResponse.error && !markerDataResponse.isFetching) return isNoDataError(markerDataResponse.error) ? null : ( ); - // pass selectedMarkers and its state function + // convert marker data into markers const markers = markerDataResponse.markerProps?.map((markerProps) => ( )); + + const selectedMarkers = markerConfigurations.find( + (markerConfiguration) => + markerConfiguration.type === activeMarkerConfigurationType + )?.selectedMarkers; + return ( <> {markerDataResponse.isFetching && } @@ -352,6 +380,7 @@ function MapOverlayComponent(props: MapTypeMapLayerProps) { geoConfigs, updateConfiguration, appState, + appState: { markerConfigurations, activeMarkerConfigurationType }, filters, headerButtons, } = props; @@ -394,9 +423,16 @@ function MapOverlayComponent(props: MapTypeMapLayerProps) { valueSpec: 'count', }); + const selectedMarkers = markerConfigurations.find( + (markerConfiguration) => + markerConfiguration.type === activeMarkerConfigurationType + )?.selectedMarkers; + const plugins = useStandaloneVizPlugins({ selectedOverlayConfig: data.overlayConfig, + selectedMarkers, }); + const toggleStarredVariable = useToggleStarredVariable(props.analysisState); const noDataError = isNoDataError(data.error) ? noDataErrorMessage 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 71016c40c6..0b75b28cd0 100644 --- a/packages/libs/eda/src/lib/map/analysis/mapTypes/shared.tsx +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/shared.tsx @@ -32,6 +32,8 @@ import { getCategoricalValues } from '../utils/categoricalValues'; import { Viewport } from '@veupathdb/components/lib/map/MapVEuMap'; import { UseLittleFiltersProps } from '../littleFilters'; import { filtersFromBoundingBox } from '../../../core/utils/visualization'; +import { useCallback, useState } from 'react'; +import useSnackbar from '@veupathdb/coreui/lib/components/notifications/useSnackbar'; export const defaultAnimation = { method: 'geohash', @@ -43,6 +45,7 @@ export const markerDataFilterFuncs = [timeSliderLittleFilter]; export const floaterFilterFuncs = [ timeSliderLittleFilter, viewportLittleFilters, + selectedMarkersLittleFilter, ]; export const visibleOptionFilterFuncs = [ timeSliderLittleFilter, @@ -374,6 +377,56 @@ export function isApproxSameViewport(v1: Viewport, v2: Viewport) { ); } +// returns a function (selectedMarkers?) => voi +export function useSelectedMarkerSnackbars( + activeVisualizationId: string | undefined +) { + const { enqueueSnackbar } = useSnackbar(); + const [shownSelectedMarkersSnackbar, setShownSelectedMarkersSnackbar] = + useState(false); + const [shownShiftKeySnackbar, setShownShiftKeySnackbar] = useState(false); + + return useCallback( + (selectedMarkers: string[] | undefined) => { + if ( + !shownSelectedMarkersSnackbar && + selectedMarkers != null && + activeVisualizationId == null + ) { + enqueueSnackbar( + `Marker selections currently only apply to supporting plots`, + { + variant: 'info', + anchorOrigin: { vertical: 'top', horizontal: 'center' }, + } + ); + setShownSelectedMarkersSnackbar(true); + } + if ( + (shownSelectedMarkersSnackbar || activeVisualizationId != null) && + !shownShiftKeySnackbar && + selectedMarkers != null && + selectedMarkers.length === 1 + ) { + enqueueSnackbar(`Use shift-click to select multiple markers`, { + variant: 'info', + anchorOrigin: { vertical: 'top', horizontal: 'center' }, + }); + setShownShiftKeySnackbar(true); + } + // if the user has managed to select more than one marker, then they don't need help + if (selectedMarkers != null && selectedMarkers.length > 1) + setShownShiftKeySnackbar(true); + }, + [ + shownSelectedMarkersSnackbar, + shownShiftKeySnackbar, + enqueueSnackbar, + activeVisualizationId, + ] + ); +} + /** * little filter helpers */ @@ -526,6 +579,47 @@ export function pieOrBarMarkerConfigLittleFilter( return []; } +// +// figures out which geoaggregator variable corresponds +// to the current zoom level and creates a little filter +// on that variable using `selectedMarkers` +// +function selectedMarkersLittleFilter(props: UseLittleFiltersProps): Filter[] { + const { + appState: { + markerConfigurations, + activeMarkerConfigurationType, + viewport: { zoom }, + }, + geoConfigs, + } = props; + + const activeMarkerConfiguration = markerConfigurations.find( + (markerConfig) => markerConfig.type === activeMarkerConfigurationType + ); + + const selectedMarkers = activeMarkerConfiguration?.selectedMarkers; + + // only return a filter if there are selectedMarkers + if (selectedMarkers && selectedMarkers.length > 0) { + const { entity, zoomLevelToAggregationLevel, aggregationVariableIds } = + geoConfigs[0]; + const geoAggregationVariableId = + aggregationVariableIds?.[zoomLevelToAggregationLevel(zoom) - 1]; + if (entity && geoAggregationVariableId) + // sanity check due to array indexing + return [ + { + type: 'stringSet' as const, + entityId: entity.id, + variableId: geoAggregationVariableId, + stringSet: selectedMarkers, + }, + ]; + } + return []; +} + /** * We can use this viewport to request all available data */ @@ -561,3 +655,11 @@ export const noDataErrorMessage = (

Please check your filters or choose another variable.

); + +/** + * Simple styling for the optional visualization subtitle as used in + * standaloneVizPlugins.ts (Bob didn't want to convert it to tsx) + */ +export function EntitySubtitleForViz({ subtitle }: { subtitle: string }) { + return
({subtitle})
; +} 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 518a06e761..49e6c05b70 100644 --- a/packages/libs/eda/src/lib/map/analysis/mapTypes/types.ts +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/types.ts @@ -39,11 +39,10 @@ export interface MapTypeMapLayerProps { updateConfiguration: (configuration: unknown) => void; totalCounts: PromiseHookState; filteredCounts: PromiseHookState; + // TO DO: the hideVizInputsAndControls props are currently required + // 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>; setTimeSliderConfig?: ( newConfig: NonNullable ) => void;