diff --git a/packages/libs/components/src/map/MapVEuMap.tsx b/packages/libs/components/src/map/MapVEuMap.tsx index c16acdc86a..13f086c326 100755 --- a/packages/libs/components/src/map/MapVEuMap.tsx +++ b/packages/libs/components/src/map/MapVEuMap.tsx @@ -1,8 +1,6 @@ import React, { useEffect, CSSProperties, - ReactElement, - cloneElement, Ref, useMemo, useImperativeHandle, @@ -10,12 +8,7 @@ import React, { useCallback, useRef, } from 'react'; -import { - BoundsViewport, - AnimationFunction, - Bounds as MapVEuBounds, -} from './Types'; -import { BoundsDriftMarkerProps } from './BoundsDriftMarker'; +import { BoundsViewport, AnimationFunction, Bounds } from './Types'; import { MapContainer, TileLayer, @@ -24,7 +17,6 @@ import { useMap, useMapEvents, } from 'react-leaflet'; -import SemanticMarkers from './SemanticMarkers'; import 'leaflet/dist/leaflet.css'; import './styles/map-styles.css'; import CustomGridLayer from './CustomGridLayer'; @@ -32,7 +24,7 @@ import { PlotRef } from '../types/plots'; import { ToImgopts } from 'plotly.js'; import Spinner from '../components/Spinner'; import NoDataOverlay from '../components/NoDataOverlay'; -import { LatLngBounds, Map, DomEvent } from 'leaflet'; +import { Map, DomEvent, LatLngBounds } from 'leaflet'; import domToImage from 'dom-to-image'; import { makeSharedPromise } from '../utils/promise-utils'; import { Undo } from '@veupathdb/coreui'; @@ -111,6 +103,8 @@ export interface MapVEuMapProps { /** update handler */ onViewportChanged: (viewport: Viewport) => void; + onBoundsChanged: (boundsViewport: BoundsViewport) => void; + /** Height and width of plot element */ height: CSSProperties['height']; width: CSSProperties['width']; @@ -119,18 +113,8 @@ export interface MapVEuMapProps { * which have their own dedicated props */ style?: Omit; - /** callback for when viewport has changed, giving access to the bounding box */ - onBoundsChanged: (bvp: BoundsViewport) => void; - - markers: ReactElement[]; - recenterMarkers?: boolean; // closing sidebar at MapVEuMap: passing setSidebarCollapsed() sidebarOnClose?: (value: React.SetStateAction) => void; - animation: { - method: string; - duration: number; - animationFunction: AnimationFunction; - } | null; /** Should a geohash-based grid be shown? * Optional. See also zoomLevelToGeohashLevel **/ @@ -155,10 +139,6 @@ export interface MapVEuMapProps { /** Show zoom control, default true */ showZoomControl?: boolean; - /** Whether to zoom and pan map to center on markers */ - flyToMarkers?: boolean; - /** How long (in ms) after rendering to wait before flying to markers */ - flyToMarkersDelay?: number; /** Whether to show a loading spinner */ showSpinner?: boolean; /** Whether to show the "No data" overlay */ @@ -171,6 +151,7 @@ export interface MapVEuMapProps { scrollingEnabled?: boolean; /** pass default viewport */ defaultViewport?: Viewport; + children?: React.ReactNode; } function MapVEuMap(props: MapVEuMapProps, ref: Ref) { @@ -181,22 +162,15 @@ function MapVEuMap(props: MapVEuMapProps, ref: Ref) { style, onViewportChanged, onBoundsChanged, - markers, - animation, - recenterMarkers = true, showGrid, zoomLevelToGeohashLevel, - minZoom = 1, baseLayer, onBaseLayerChanged, - flyToMarkers, - flyToMarkersDelay, showSpinner, showNoDataOverlay, showScale = true, showLayerSelector = true, showAttribution = true, - showZoomControl = true, scrollingEnabled = true, interactive = true, defaultViewport, @@ -215,8 +189,12 @@ function MapVEuMap(props: MapVEuMapProps, ref: Ref) { (map: Map) => { mapRef.current = map; sharedPlotCreation.run(); + onBoundsChanged({ + bounds: constrainLongitudeToMainWorld(boundsToGeoBBox(map.getBounds())), + zoomLevel: map.getZoom(), + }); }, - [sharedPlotCreation.run] + [onBoundsChanged, sharedPlotCreation] ); useEffect(() => { @@ -226,7 +204,7 @@ function MapVEuMap(props: MapVEuMapProps, ref: Ref) { if (gitterBtn) { gitterBtn.style.display = 'none'; } - () => { + return () => { if (gitterBtn) { gitterBtn.style.display = 'inline'; } @@ -246,13 +224,9 @@ function MapVEuMap(props: MapVEuMapProps, ref: Ref) { return domToImage.toPng(mapRef.current.getContainer(), imageOpts); }, }), - [domToImage, mapRef] + [sharedPlotCreation.promise] ); - const finalMarkers = useMemo(() => { - return markers.map((marker) => cloneElement(marker, { showPopup: true })); - }, [markers]); - const disabledInteractiveProps = { dragging: false, keyboard: false, @@ -279,12 +253,7 @@ function MapVEuMap(props: MapVEuMapProps, ref: Ref) { attribution='© OpenStreetMap contributors' /> - + {props.children} {showGrid && zoomLevelToGeohashLevel ? ( @@ -309,18 +278,11 @@ function MapVEuMap(props: MapVEuMapProps, ref: Ref) { {/* add Scale in the map */} {showScale && } - {/* PerformFlyToMarkers component for flyTo functionality */} - {flyToMarkers && ( - - )} {/* component for map events */} {/* set ScrollWheelZoom */} @@ -332,67 +294,41 @@ function MapVEuMap(props: MapVEuMapProps, ref: Ref) { export default forwardRef(MapVEuMap); -// for flyTo -interface PerformFlyToMarkersProps { - /* markers */ - markers: ReactElement[]; - /** Whether to zoom and pan map to center on markers */ - flyToMarkers?: boolean; - /** How long (in ms) after rendering to wait before flying to markers */ - flyToMarkersDelay?: number; -} - -// component to implement flyTo functionality -function PerformFlyToMarkers(props: PerformFlyToMarkersProps) { - const { markers, flyToMarkers, flyToMarkersDelay } = props; - - // instead of using useRef() to the map in v2, useMap() should be used instead in v3 - const map = useMap(); - - const markersBounds = useMemo(() => { - return computeMarkersBounds(markers); - }, [markers]); - - const performFlyToMarkers = useCallback(() => { - if (markersBounds) { - const boundingBox = computeBoundingBox(markersBounds); - if (boundingBox) map.fitBounds(boundingBox); - } - }, [markersBounds, map]); - - useEffect(() => { - const asyncEffect = async () => { - if (flyToMarkersDelay) - await new Promise((resolve) => setTimeout(resolve, flyToMarkersDelay)); - performFlyToMarkers(); - }; - - if (flyToMarkers && markers.length > 0) asyncEffect(); - }, [markers, flyToMarkers, flyToMarkersDelay, performFlyToMarkers]); - - return null; -} - interface MapVEuMapEventsProps { onViewportChanged: (viewport: Viewport) => void; + onBoundsChanged: (bondsViewport: BoundsViewport) => void; onBaseLayerChanged?: (newBaseLayer: BaseLayerChoice) => void; } // function to handle map events such as onViewportChanged and baselayerchange function MapVEuMapEvents(props: MapVEuMapEventsProps) { - const { onViewportChanged, onBaseLayerChanged } = props; + const { onViewportChanged, onBaseLayerChanged, onBoundsChanged } = props; const mapEvents = useMapEvents({ zoomend: () => { onViewportChanged({ center: [mapEvents.getCenter().lat, mapEvents.getCenter().lng], zoom: mapEvents.getZoom(), }); + const boundsViewport: BoundsViewport = { + bounds: constrainLongitudeToMainWorld( + boundsToGeoBBox(mapEvents.getBounds()) + ), + zoomLevel: mapEvents.getZoom(), + }; + onBoundsChanged(boundsViewport); }, moveend: () => { onViewportChanged({ center: [mapEvents.getCenter().lat, mapEvents.getCenter().lng], zoom: mapEvents.getZoom(), }); + const boundsViewport: BoundsViewport = { + bounds: constrainLongitudeToMainWorld( + boundsToGeoBBox(mapEvents.getBounds()) + ), + zoomLevel: mapEvents.getZoom(), + }; + onBoundsChanged(boundsViewport); }, baselayerchange: (e: { name: string }) => { onBaseLayerChanged && onBaseLayerChanged(e.name as BaseLayerChoice); @@ -501,55 +437,58 @@ function CustomZoomControl(props: CustomZoomControlProps) { ); } -// compute markers bounds -function computeMarkersBounds(markers: ReactElement[]) { - if (markers) { - let [minLat, maxLat, minLng, maxLng] = [90, -90, 180, -180]; - - for (const marker of markers) { - const bounds = marker.props.bounds; - const ne = bounds.northEast; - const sw = bounds.southWest; - - if (ne.lat > maxLat) maxLat = ne.lat; - if (ne.lat < minLat) minLat = ne.lat; - - if (ne.lng > maxLng) maxLng = ne.lng; - if (ne.lng < minLng) minLng = ne.lng; - - if (sw.lat > maxLat) maxLat = sw.lat; - if (sw.lat < minLat) minLat = sw.lat; - - if (sw.lng > maxLng) maxLng = sw.lng; - if (sw.lng < minLng) minLng = sw.lng; - } +function boundsToGeoBBox(bounds: LatLngBounds): Bounds { + var south = bounds.getSouth(); + if (south < -90) { + south = -90; + } + var north = bounds.getNorth(); + if (north > 90) { + north = 90; + } + var east = bounds.getEast(); + var west = bounds.getWest(); - return { - southWest: { lat: minLat, lng: minLng }, - northEast: { lat: maxLat, lng: maxLng }, - }; - } else { - return null; + if (east - west > 360) { + const center = (east + west) / 2; + west = center - 180; + east = center + 180; } -} -// compute bounding box -function computeBoundingBox(markersBounds: MapVEuBounds | null) { - if (markersBounds) { - const ne = markersBounds.northEast; - const sw = markersBounds.southWest; + return { + southWest: { lat: south, lng: west }, + northEast: { lat: north, lng: east }, + }; +} - const bufferFactor = 0.1; - const latBuffer = (ne.lat - sw.lat) * bufferFactor; - const lngBuffer = (ne.lng - sw.lng) * bufferFactor; +// put longitude bounds within normal -180 to 180 range +function constrainLongitudeToMainWorld({ + southWest: { lat: south, lng: west }, + northEast: { lat: north, lng: east }, +}: Bounds): Bounds { + let newEast = east; + let newWest = west; + while (newEast > 180) { + newEast -= 360; + } + while (newEast < -180) { + newEast += 360; + } + while (newWest < -180) { + newWest += 360; + } + while (newWest > 180) { + newWest -= 360; + } - const boundingBox = new LatLngBounds([ - [sw.lat - latBuffer, sw.lng - lngBuffer], - [ne.lat + latBuffer, ne.lng + lngBuffer], - ]); + // fully zoomed out, the longitude bounds are often the same + // but we need to make sure that west is slightly greater than east + // so that they "wrap around" the whole globe + // (if west was slightly less than east, it would represent a very tiny sliver) + if (Math.abs(newEast - newWest) < 1e-8) newWest = newEast + 1e-8; - return boundingBox; - } else { - return undefined; - } + return { + southWest: { lat: south, lng: newWest }, + northEast: { lat: north, lng: newEast }, + }; } diff --git a/packages/libs/components/src/map/MapVEuMapSidebar.tsx b/packages/libs/components/src/map/MapVEuMapSidebar.tsx index 7c4336d425..4c16d7a6eb 100644 --- a/packages/libs/components/src/map/MapVEuMapSidebar.tsx +++ b/packages/libs/components/src/map/MapVEuMapSidebar.tsx @@ -60,7 +60,7 @@ interface MapVEuMapPropsCutAndPasteCopy { animation: { method: string; duration: number; - animationFunction: AnimationFunction; + animationFunction: AnimationFunction; } | null; showGrid: boolean; } @@ -167,7 +167,6 @@ export default function MapVEuMapSidebarSibling({ void; +export interface SemanticMarkersProps { markers: Array>; recenterMarkers?: boolean; animation: { method: string; duration: number; - animationFunction: AnimationFunction; + animationFunction: AnimationFunction; } | null; + /** Whether to zoom and pan map to center on markers */ + flyToMarkers?: boolean; + /** How long (in ms) after rendering to wait before flying to markers */ + flyToMarkersDelay?: number; } /** @@ -28,40 +33,23 @@ interface SemanticMarkersProps { * @param props */ export default function SemanticMarkers({ - onBoundsChanged, markers, animation, recenterMarkers = true, + flyToMarkers, + flyToMarkersDelay, }: SemanticMarkersProps) { // react-leaflet v3 const map = useMap(); const [prevMarkers, setPrevMarkers] = useState[]>(markers); - // local bounds state needed for recentreing markers - const [bounds, setBounds] = useState(); - const [consolidatedMarkers, setConsolidatedMarkers] = useState< - ReactElement[] - >([]); - const [zoomType, setZoomType] = useState(null); + const [consolidatedMarkers, setConsolidatedMarkers] = + useState[]>(markers); - // call the prop callback to communicate bounds and zoomLevel to outside world useEffect(() => { - if (map == null) return; - - function updateBounds() { - if (map != null) { - const bounds = boundsToGeoBBox(map.getBounds()); - setBounds(bounds); - const zoomLevel = map.getZoom(); - onBoundsChanged({ - bounds: constrainLongitudeToMainWorld(bounds), - zoomLevel, - }); - } - } - + let timeoutVariable: number | undefined; // debounce needed to avoid cyclic in/out zooming behaviour const debouncedUpdateBounds = debounce(updateBounds, 1000); // call it at least once at the beginning of the life cycle @@ -74,93 +62,104 @@ export default function SemanticMarkers({ // detach from leaflet events handler map.off('resize moveend dragend zoomend', debouncedUpdateBounds); debouncedUpdateBounds.cancel(); + clearTimeout(timeoutVariable); }; - }, [map, onBoundsChanged]); - // handle recentering of markers (around +180/-180 longitude) and animation - useEffect(() => { - let recenteredMarkers = false; - if (recenterMarkers && bounds) { - markers = markers.map((marker) => { - let { lat, lng } = marker.props.position; - let { - southWest: { lat: ltMin, lng: lnMin }, - northEast: { lat: ltMax, lng: lnMax }, - } = marker.props.bounds; - let recentered: boolean = false; - while (lng > bounds.northEast.lng) { - lng -= 360; - lnMax -= 360; - lnMin -= 360; - recentered = true; - } - while (lng < bounds.southWest.lng) { - lng += 360; - lnMax += 360; - lnMin += 360; - recentered = true; - } - recenteredMarkers = recenteredMarkers || recentered; - return recentered - ? cloneElement(marker, { - position: { lat, lng }, - bounds: { + // function definitions + + function updateBounds() { + const bounds = boundsToGeoBBox(map.getBounds()); + // handle recentering of markers (around +180/-180 longitude) and animation + const recenteredMarkers = + recenterMarkers && bounds + ? markers.map((marker) => { + let { lat, lng } = marker.props.position; + let { southWest: { lat: ltMin, lng: lnMin }, northEast: { lat: ltMax, lng: lnMax }, - }, + } = marker.props.bounds; + let recentered: boolean = false; + while (lng > bounds.northEast.lng) { + lng -= 360; + lnMax -= 360; + lnMin -= 360; + recentered = true; + } + while (lng < bounds.southWest.lng) { + lng += 360; + lnMax += 360; + lnMin += 360; + recentered = true; + } + return recentered + ? cloneElement(marker, { + position: { lat, lng }, + bounds: { + southWest: { lat: ltMin, lng: lnMin }, + northEast: { lat: ltMax, lng: lnMax }, + }, + }) + : marker; }) - : marker; - }); - } + : markers; - // now handle animation - // but don't animate if we moved markers by 360 deg. longitude - // because the DriftMarker or Leaflet.Marker.SlideTo code seems to - // send everything back to the 'main' world. - if ( - markers.length > 0 && - prevMarkers.length > 0 && - animation && - !recenteredMarkers - ) { - const animationValues = animation.animationFunction({ - prevMarkers, - markers, - }); - setZoomType(animationValues.zoomType); - setConsolidatedMarkers(animationValues.markers); - } else { - /** First render of markers **/ - setConsolidatedMarkers([...markers]); - } + const didRecenterMarkers = !isShallowEqual(markers, recenteredMarkers); + + // now handle animation + // but don't animate if we moved markers by 360 deg. longitude + // because the DriftMarker or Leaflet.Marker.SlideTo code seems to + // send everything back to the 'main' world. + if ( + recenteredMarkers.length > 0 && + prevMarkers.length > 0 && + animation && + !didRecenterMarkers + ) { + const animationValues = animation.animationFunction({ + prevMarkers, + markers: recenteredMarkers, + }); + setConsolidatedMarkers(animationValues.markers); + timeoutVariable = enqueueZoom(animationValues.zoomType); + } else { + /** First render of markers **/ + setConsolidatedMarkers(markers); + } - // Update previous markers with the original markers array - setPrevMarkers(markers); - }, [markers, bounds]); + // Update previous markers with the original markers array + setPrevMarkers(markers); + } - useEffect(() => { - /** If we are zooming in then reset the marker elements. When initially rendered - * the new markers will start at the matching existing marker's location and here we will - * reset marker elements so they will animated to their final position - **/ - let timeoutVariable: NodeJS.Timeout; - - if (zoomType == 'in') { - setConsolidatedMarkers([...markers]); - } else if (zoomType == 'out') { - /** If we are zooming out then remove the old markers after they finish animating. **/ - timeoutVariable = setTimeout( - () => { - setConsolidatedMarkers([...markers]); - }, - animation ? animation.duration : 0 - ); + function enqueueZoom(zoomType: string | null) { + /** If we are zooming in then reset the marker elements. When initially rendered + * the new markers will start at the matching existing marker's location and here we will + * reset marker elements so they will animated to their final position + **/ + if (zoomType === 'in') { + setConsolidatedMarkers(markers); + } else if (zoomType === 'out') { + /** If we are zooming out then remove the old markers after they finish animating. **/ + return window.setTimeout( + () => { + setConsolidatedMarkers(markers); + }, + animation ? animation.duration : 0 + ); + } } + }, [animation, map, markers, prevMarkers, recenterMarkers]); + + const refinedMarkers = useMemo( + () => + consolidatedMarkers.map((marker) => + cloneElement(marker, { showPopup: true }) + ), + [consolidatedMarkers] + ); - return () => clearTimeout(timeoutVariable); - }, [zoomType, markers]); + useFlyToMarkers({ markers: refinedMarkers, flyToMarkers, flyToMarkersDelay }); - return <>{consolidatedMarkers}; + return <>{refinedMarkers}; } function boundsToGeoBBox(bounds: LatLngBounds): Bounds { @@ -187,34 +186,105 @@ function boundsToGeoBBox(bounds: LatLngBounds): Bounds { }; } -// put longitude bounds within normal -180 to 180 range -function constrainLongitudeToMainWorld({ - southWest: { lat: south, lng: west }, - northEast: { lat: north, lng: east }, -}: Bounds): Bounds { - let newEast = east; - let newWest = west; - while (newEast > 180) { - newEast -= 360; - } - while (newEast < -180) { - newEast += 360; - } - while (newWest < -180) { - newWest += 360; +// for flyTo +interface PerformFlyToMarkersProps { + /* markers */ + markers: ReactElement[]; + /** Whether to zoom and pan map to center on markers */ + flyToMarkers?: boolean; + /** How long (in ms) after rendering to wait before flying to markers */ + flyToMarkersDelay?: number; +} + +// component to implement flyTo functionality +function useFlyToMarkers(props: PerformFlyToMarkersProps) { + const { markers, flyToMarkers, flyToMarkersDelay } = props; + + // instead of using useRef() to the map in v2, useMap() should be used instead in v3 + const map = useMap(); + + const markersBounds = useMemo(() => { + return computeMarkersBounds(markers); + }, [markers]); + + const performFlyToMarkers = useCallback(() => { + if (markersBounds) { + const boundingBox = computeBoundingBox(markersBounds); + if (boundingBox) map.fitBounds(boundingBox); + } + }, [markersBounds, map]); + + useEffect(() => { + const asyncEffect = async () => { + if (flyToMarkersDelay) + await new Promise((resolve) => setTimeout(resolve, flyToMarkersDelay)); + performFlyToMarkers(); + }; + + if (flyToMarkers && markers.length > 0) asyncEffect(); + }, [markers, flyToMarkers, flyToMarkersDelay, performFlyToMarkers]); + + return null; +} + +// compute bounding box +function computeBoundingBox(markersBounds: Bounds | null) { + if (markersBounds) { + const ne = markersBounds.northEast; + const sw = markersBounds.southWest; + + const bufferFactor = 0.1; + const latBuffer = (ne.lat - sw.lat) * bufferFactor; + const lngBuffer = (ne.lng - sw.lng) * bufferFactor; + + const boundingBox = new LatLngBounds([ + [sw.lat - latBuffer, sw.lng - lngBuffer], + [ne.lat + latBuffer, ne.lng + lngBuffer], + ]); + + return boundingBox; + } else { + return undefined; } - while (newWest > 180) { - newWest -= 360; +} + +// compute markers bounds +function computeMarkersBounds(markers: ReactElement[]) { + if (markers) { + let [minLat, maxLat, minLng, maxLng] = [90, -90, 180, -180]; + + for (const marker of markers) { + const bounds = marker.props.bounds; + const ne = bounds.northEast; + const sw = bounds.southWest; + + if (ne.lat > maxLat) maxLat = ne.lat; + if (ne.lat < minLat) minLat = ne.lat; + + if (ne.lng > maxLng) maxLng = ne.lng; + if (ne.lng < minLng) minLng = ne.lng; + + if (sw.lat > maxLat) maxLat = sw.lat; + if (sw.lat < minLat) minLat = sw.lat; + + if (sw.lng > maxLng) maxLng = sw.lng; + if (sw.lng < minLng) minLng = sw.lng; + } + + return { + southWest: { lat: minLat, lng: minLng }, + northEast: { lat: maxLat, lng: maxLng }, + }; + } else { + return null; } +} - // fully zoomed out, the longitude bounds are often the same - // but we need to make sure that west is slightly greater than east - // so that they "wrap around" the whole globe - // (if west was slightly less than east, it would represent a very tiny sliver) - if (Math.abs(newEast - newWest) < 1e-8) newWest = newEast + 1e-8; +function isShallowEqual(array1: T[], array2: T[]) { + if (array1.length !== array2.length) return false; - return { - southWest: { lat: south, lng: newWest }, - northEast: { lat: north, lng: newEast }, - }; + for (let index = 0; index < array1.length; index++) { + if (array1[index] !== array2[index]) return false; + } + return true; } diff --git a/packages/libs/components/src/map/Types.ts b/packages/libs/components/src/map/Types.ts index 146f0912c2..2289cf679e 100644 --- a/packages/libs/components/src/map/Types.ts +++ b/packages/libs/components/src/map/Types.ts @@ -1,6 +1,5 @@ import { ReactElement } from 'react'; import { LatLngLiteral, Icon } from 'leaflet'; -import { PlotProps } from '../plots/PlotlyPlot'; export type LatLng = LatLngLiteral; // from leaflet: @@ -39,15 +38,15 @@ export interface MarkerProps { zIndexOffset?: number; } -export type AnimationFunction = ({ +export type AnimationFunction = ({ prevMarkers, markers, }: { - prevMarkers: ReactElement[]; - markers: ReactElement[]; + prevMarkers: ReactElement[]; + markers: ReactElement[]; }) => { zoomType: string | null; - markers: ReactElement[]; + markers: ReactElement[]; }; /** diff --git a/packages/libs/components/src/map/animation_functions/geohash.tsx b/packages/libs/components/src/map/animation_functions/geohash.tsx index 779434b55c..bad29af056 100644 --- a/packages/libs/components/src/map/animation_functions/geohash.tsx +++ b/packages/libs/components/src/map/animation_functions/geohash.tsx @@ -1,10 +1,10 @@ -import { MarkerProps } from '../Types'; import { ReactElement } from 'react'; import updateMarkers from './updateMarkers'; +import { BoundsDriftMarkerProps } from '../BoundsDriftMarker'; interface geoHashAnimation { - prevMarkers: Array>; - markers: Array>; + prevMarkers: Array>; + markers: Array>; } export default function geohashAnimation({ diff --git a/packages/libs/components/src/map/animation_functions/updateMarkers.tsx b/packages/libs/components/src/map/animation_functions/updateMarkers.tsx index 24a977b6cd..1673f1881f 100644 --- a/packages/libs/components/src/map/animation_functions/updateMarkers.tsx +++ b/packages/libs/components/src/map/animation_functions/updateMarkers.tsx @@ -1,9 +1,9 @@ import { cloneElement, ReactElement } from 'react'; -import { MarkerProps } from '../Types'; +import { BoundsDriftMarkerProps } from '../BoundsDriftMarker'; export default function updateMarkers( - toChangeMarkers: Array>, - sourceMarkers: Array>, + toChangeMarkers: Array>, + sourceMarkers: Array>, hashDif: number ) { return toChangeMarkers.map((markerObj) => { diff --git a/packages/libs/components/src/map/config/map.ts b/packages/libs/components/src/map/config/map.ts index a8886d565b..fe193e66b1 100644 --- a/packages/libs/components/src/map/config/map.ts +++ b/packages/libs/components/src/map/config/map.ts @@ -2,6 +2,8 @@ // which is deprecated. // (Context: https://github.com/VEuPathDB/web-components/issues/324) +import { Viewport } from '../MapVEuMap'; + export const defaultAnimationDuration = 300; export const allColorsHex = [ @@ -33,3 +35,9 @@ export const chartMarkerColorsHex = [ '#B21B45', '#ED1C23', ]; + +// export default viewport for custom zoom control +export const defaultViewport: Viewport = { + center: [0, 0], + zoom: 1, +}; diff --git a/packages/libs/components/src/stories/AnimatedMarkers.stories.tsx b/packages/libs/components/src/stories/AnimatedMarkers.stories.tsx index 7cbd11fd3a..2c8e4dcb8f 100644 --- a/packages/libs/components/src/stories/AnimatedMarkers.stories.tsx +++ b/packages/libs/components/src/stories/AnimatedMarkers.stories.tsx @@ -11,6 +11,7 @@ import geohashAnimation from '../map/animation_functions/geohash'; import { defaultAnimationDuration } from '../map/config/map'; import { leafletZoomLevelToGeohashLevel } from '../map/utils/leaflet-geohash'; import { Viewport } from '../map/MapVEuMap'; +import SemanticMarkers from '../map/SemanticMarkers'; export default { title: 'Map/Zoom animation', @@ -179,12 +180,12 @@ export const Default: Story = (args) => { viewport={viewport} // add onViewportChanged to test showScale onViewportChanged={onViewportChanged} - onBoundsChanged={handleViewportChanged} - markers={markerElements} - animation={defaultAnimation} // test showScale: currently set to show from zoom = 5 showScale={viewport.zoom != null && viewport.zoom > 4 ? true : false} - /> + onBoundsChanged={handleViewportChanged} + > + + ); }; Default.args = { @@ -221,13 +222,16 @@ export const DifferentSpeeds: Story = ( viewport={viewport} onViewportChanged={setViewport} onBoundsChanged={handleViewportChanged} - markers={markerElements} - animation={{ - method: 'geohash', - animationFunction: geohashAnimation, - duration: args.animationDuration, - }} - /> + > + + ); }; DifferentSpeeds.args = { @@ -259,9 +263,9 @@ export const NoAnimation: Story = (args) => { viewport={viewport} onViewportChanged={setViewport} onBoundsChanged={handleViewportChanged} - markers={markerElements} - animation={null} - /> + > + + ); }; NoAnimation.args = { diff --git a/packages/libs/components/src/stories/ChartMarkers.stories.tsx b/packages/libs/components/src/stories/ChartMarkers.stories.tsx index d53126881d..1c81c9496e 100644 --- a/packages/libs/components/src/stories/ChartMarkers.stories.tsx +++ b/packages/libs/components/src/stories/ChartMarkers.stories.tsx @@ -25,6 +25,7 @@ import { MouseMode } from '../map/MouseTools'; import LabelledGroup from '../components/widgets/LabelledGroup'; import { Toggle } from '@veupathdb/coreui'; +import SemanticMarkers from '../map/SemanticMarkers'; export default { title: 'Map/Chart Markers', @@ -100,12 +101,15 @@ export const AllInOneRequest: Story = (args) => { {...args} viewport={viewport} onViewportChanged={setViewport} - onBoundsChanged={handleViewportChanged} - markers={markerElements} showGrid={true} - animation={defaultAnimation} zoomLevelToGeohashLevel={leafletZoomLevelToGeohashLevel} - /> + onBoundsChanged={handleViewportChanged} + > + + = (args) => { {...args} viewport={viewport} onViewportChanged={setViewport} - onBoundsChanged={handleViewportChanged} - markers={markerElements} showGrid={true} - animation={defaultAnimation} zoomLevelToGeohashLevel={leafletZoomLevelToGeohashLevel} - /> + onBoundsChanged={handleViewportChanged} + > + + = (args) => { {...args} viewport={viewport} onViewportChanged={setViewport} - onBoundsChanged={handleViewportChanged} - markers={markerElements} showGrid={true} - animation={defaultAnimation} zoomLevelToGeohashLevel={leafletZoomLevelToGeohashLevel} - /> + onBoundsChanged={handleViewportChanged} + > + + {/* Y-axis range control */}
diff --git a/packages/libs/components/src/stories/DonutMarkers.stories.tsx b/packages/libs/components/src/stories/DonutMarkers.stories.tsx index 7894089740..f3ba84a4e1 100644 --- a/packages/libs/components/src/stories/DonutMarkers.stories.tsx +++ b/packages/libs/components/src/stories/DonutMarkers.stories.tsx @@ -27,6 +27,7 @@ import DonutMarker, { DonutMarkerProps, DonutMarkerStandalone, } from '../map/DonutMarker'; +import SemanticMarkers from '../map/SemanticMarkers'; export default { title: 'Map/Donut Markers', @@ -112,11 +113,14 @@ export const AllInOneRequest: Story = (args) => { {...args} viewport={viewport} onViewportChanged={setViewport} - onBoundsChanged={handleViewportChanged} - markers={markerElements} - animation={defaultAnimation} zoomLevelToGeohashLevel={leafletZoomLevelToGeohashLevel} - /> + onBoundsChanged={handleViewportChanged} + > + + = (args) => { {...args} viewport={viewport} onViewportChanged={setViewport} - onBoundsChanged={handleViewportChanged} - markers={markerElements} - animation={defaultAnimation} zoomLevelToGeohashLevel={leafletZoomLevelToGeohashLevel} - /> + onBoundsChanged={handleViewportChanged} + > + + = (args) => { {...args} viewport={viewport} onViewportChanged={setViewport} - onBoundsChanged={handleViewportChanged} - markers={markerElements} - animation={defaultAnimation} zoomLevelToGeohashLevel={leafletZoomLevelToGeohashLevel} - /> + onBoundsChanged={handleViewportChanged} + > + + = (args) => { {}} - animation={defaultAnimation} zoomLevelToGeohashLevel={leafletZoomLevelToGeohashLevel} - /> + onBoundsChanged={() => {}} + > + + ); }; diff --git a/packages/libs/components/src/stories/Map.stories.tsx b/packages/libs/components/src/stories/Map.stories.tsx index d7f7459783..8289771dbc 100644 --- a/packages/libs/components/src/stories/Map.stories.tsx +++ b/packages/libs/components/src/stories/Map.stories.tsx @@ -25,6 +25,7 @@ import { Checkbox } from '@material-ui/core'; import geohashAnimation from '../map/animation_functions/geohash'; import { MouseMode } from '../map/MouseTools'; import { PlotRef } from '../types/plots'; +import SemanticMarkers, { SemanticMarkersProps } from '../map/SemanticMarkers'; export default { title: 'Map/General', @@ -105,11 +106,14 @@ export const Spinner: Story = (args) => { + onBoundsChanged={handleViewportChanged} + > + + = (args) => { + onBoundsChanged={handleViewportChanged} + > + + = (args) => { + onBoundsChanged={handleViewportChanged} + > + + = function ScreenhotOnLoad( - args -) { +export const ScreenshotOnLoad: Story<{ + mapProps: MapVEuMapProps; + markerProps: SemanticMarkersProps; +}> = function ScreenhotOnLoad(args) { const mapRef = useRef(null); const [image, setImage] = useState(''); useEffect(() => { @@ -239,36 +250,43 @@ export const ScreenshotOnLoad: Story = function ScreenhotOnLoad( // because the size of the base64 encoding causes "too much recursion". mapRef.current ?.toImage({ - height: args.height as number, - width: args.width as number, + height: args.mapProps.height as number, + width: args.mapProps.width as number, format: 'png', }) .then(fetch) .then((res) => res.blob()) .then(URL.createObjectURL) .then(setImage); - }, []); + }, [args.mapProps.height, args.mapProps.width]); return (
- + > + + + Map screenshot
); }; ScreenshotOnLoad.args = { - height: 500, - width: 700, - showGrid: true, - markers: [], - viewport: { center: [13, 16], zoom: 4 }, - onBoundsChanged: () => {}, + mapProps: { + height: 500, + width: 700, + showGrid: true, + viewport: { center: [13, 16], zoom: 4 }, + onViewportChanged: () => {}, + onBoundsChanged: () => {}, + }, + markerProps: { + markers: [], + animation: null, + }, }; export const Tiny: Story = (args) => { @@ -298,11 +316,14 @@ export const Tiny: Story = (args) => { + onBoundsChanged={handleViewportChanged} + > + + ); }; @@ -384,12 +405,15 @@ export const ScrollAndZoom: Story = (args) => { {...args} viewport={viewport} onViewportChanged={setViewport} - onBoundsChanged={handleViewportChanged} - markers={markerElements} - animation={defaultAnimation} zoomLevelToGeohashLevel={leafletZoomLevelToGeohashLevel} scrollingEnabled={mapScroll} - /> + onBoundsChanged={handleViewportChanged} + > + +
); diff --git a/packages/libs/components/src/stories/MapRefRelated.stories.tsx b/packages/libs/components/src/stories/MapRefRelated.stories.tsx index cf7b410422..993e7503a2 100644 --- a/packages/libs/components/src/stories/MapRefRelated.stories.tsx +++ b/packages/libs/components/src/stories/MapRefRelated.stories.tsx @@ -28,6 +28,7 @@ import MapVEuLegendSampleList, { import geohashAnimation from '../map/animation_functions/geohash'; // import PlotRef import { PlotRef } from '../types/plots'; +import SemanticMarkers from '../map/SemanticMarkers'; export default { title: 'Map/Map ref related', @@ -112,15 +113,18 @@ export const MapFlyTo: Story = (args) => { {...args} viewport={viewport} onViewportChanged={setViewport} - onBoundsChanged={handleViewportChanged} - markers={markerElements} - animation={defaultAnimation} zoomLevelToGeohashLevel={leafletZoomLevelToGeohashLevel} - // pass FlyTo - flyToMarkers={true} - // set a bit longer delay for a demonstrate purpose at story - flyToMarkersDelay={2000} - /> + onBoundsChanged={handleViewportChanged} + > + + = (args) => { {...args} viewport={viewport} onViewportChanged={setViewport} - onBoundsChanged={handleViewportChanged} - markers={markerElements} - animation={defaultAnimation} zoomLevelToGeohashLevel={leafletZoomLevelToGeohashLevel} // pass ref ref={ref} - /> + onBoundsChanged={handleViewportChanged} + > + +

Partial screenshot

@@ -235,16 +242,19 @@ export const ChangeViewportAndBaseLayer: Story = (args) => { {...args} viewport={viewport} onViewportChanged={setViewport} - onBoundsChanged={handleViewportChanged} - markers={markerElements} - animation={defaultAnimation} zoomLevelToGeohashLevel={leafletZoomLevelToGeohashLevel} - flyToMarkers={true} - // set a bit longer delay for a demonstrate purpose at story - flyToMarkersDelay={2000} baseLayer={baseLayer} onBaseLayerChanged={setBaseLayer} - /> + onBoundsChanged={handleViewportChanged} + > + + ); }; diff --git a/packages/libs/components/src/stories/MultipleWorlds.stories.tsx b/packages/libs/components/src/stories/MultipleWorlds.stories.tsx index 48e010cc58..994c8533ce 100644 --- a/packages/libs/components/src/stories/MultipleWorlds.stories.tsx +++ b/packages/libs/components/src/stories/MultipleWorlds.stories.tsx @@ -65,7 +65,7 @@ const getMarkerElements = ( }); }; -const getDatelineArgs = () => { +const useDatelineArgs = () => { const [markerElements, setMarkerElements] = useState< ReactElement[] >([]); @@ -79,7 +79,7 @@ const getDatelineArgs = () => { (bvp: BoundsViewport) => { setMarkerElements(getMarkerElements(bvp, duration, testDataStraddling)); }, - [setMarkerElements] + [duration] ); return { @@ -100,6 +100,6 @@ const getDatelineArgs = () => { }; const Template = (args: MapVEuMapProps) => ( - + ); export const DatelineData: Story = Template.bind({}); diff --git a/packages/libs/components/src/stories/SidebarResize.stories.tsx b/packages/libs/components/src/stories/SidebarResize.stories.tsx index 67e5f4bf8d..70d2c9260e 100755 --- a/packages/libs/components/src/stories/SidebarResize.stories.tsx +++ b/packages/libs/components/src/stories/SidebarResize.stories.tsx @@ -33,6 +33,7 @@ import MapVEuMap, { MapVEuMapProps } from '../map/MapVEuMap'; // import Geohash from 'latlon-geohash'; // import {DriftMarker} from "leaflet-drift-marker"; import geohashAnimation from '../map/animation_functions/geohash'; +import SemanticMarkers from '../map/SemanticMarkers'; export default { title: 'Sidebar/Sidebar in map', @@ -231,9 +232,12 @@ export const SidebarResize: Story = (args) => { {...args} viewport={viewport} onBoundsChanged={handleViewportChanged} - markers={markerElements} - animation={defaultAnimation} - /> + > + + ); }; diff --git a/packages/libs/eda/package.json b/packages/libs/eda/package.json index ca405f7f1f..5759d3650c 100644 --- a/packages/libs/eda/package.json +++ b/packages/libs/eda/package.json @@ -2,6 +2,8 @@ "name": "@veupathdb/eda", "version": "4.1.12", "dependencies": { + "@tanstack/react-query": "^4.33.0", + "@tanstack/react-query-devtools": "^4.35.3", "@veupathdb/components": "workspace:^", "@veupathdb/coreui": "workspace:^", "@veupathdb/http-utils": "workspace:^", diff --git a/packages/libs/eda/src/lib/core/components/visualizations/implementations/MapVisualization.tsx b/packages/libs/eda/src/lib/core/components/visualizations/implementations/MapVisualization.tsx index a9a30d6f6a..65b5162ad2 100644 --- a/packages/libs/eda/src/lib/core/components/visualizations/implementations/MapVisualization.tsx +++ b/packages/libs/eda/src/lib/core/components/visualizations/implementations/MapVisualization.tsx @@ -42,6 +42,7 @@ import LabelledGroup from '@veupathdb/components/lib/components/widgets/Labelled import { Toggle } from '@veupathdb/coreui'; import { LayoutOptions } from '../../layouts/types'; import { useMapMarkers } from '../../../hooks/mapMarkers'; +import SemanticMarkers from '@veupathdb/components/lib/map/SemanticMarkers'; export const mapVisualization = createVisualizationPlugin({ selectorIcon: MapSVG, @@ -249,8 +250,6 @@ function MapViz(props: VisualizationProps) { viewport={{ center: [latitude, longitude], zoom: zoomLevel }} onViewportChanged={handleViewportChanged} onBoundsChanged={setBoundsZoomLevel} - markers={markers ?? []} - animation={defaultAnimation} height={height} width={width} showGrid={geoConfig?.zoomLevelToAggregationLevel != null} @@ -260,8 +259,6 @@ function MapViz(props: VisualizationProps) { onBaseLayerChanged={(newBaseLayer) => updateVizConfig({ baseLayer: newBaseLayer }) } - flyToMarkers={markers && markers.length > 0 && willFlyTo && !pending} - flyToMarkersDelay={500} showSpinner={pending} // whether to show scale at map showScale={zoomLevel != null && zoomLevel > 4 ? true : false} @@ -273,7 +270,14 @@ function MapViz(props: VisualizationProps) { ], zoom: defaultConfig.mapCenterAndZoom.zoomLevel, }} - /> + > + 0 && willFlyTo && !pending} + flyToMarkersDelay={500} + /> + ); diff --git a/packages/libs/eda/src/lib/core/hooks/computeDefaultAxisRange.ts b/packages/libs/eda/src/lib/core/hooks/computeDefaultAxisRange.ts index 5c9c0c9c27..97beb4a224 100755 --- a/packages/libs/eda/src/lib/core/hooks/computeDefaultAxisRange.ts +++ b/packages/libs/eda/src/lib/core/hooks/computeDefaultAxisRange.ts @@ -1,12 +1,8 @@ import { useMemo } from 'react'; import { Variable } from '../types/study'; -// for scatter plot -import { numberDateDefaultAxisRange } from '../utils/default-axis-range'; import { NumberOrDateRange } from '../types/general'; -// type of computedVariableMetadata for computation apps such as alphadiv and abundance import { VariableMapping } from '../api/DataClient/types'; -import { numberSignificantFigures } from '../utils/number-significant-figures'; -import { DateTime } from 'luxon'; +import { getDefaultAxisRange } from '../utils/computeDefaultAxisRange'; /** * A custom hook to compute default axis range from annotated and observed min/max values @@ -24,73 +20,9 @@ export function useDefaultAxisRange( logScale?: boolean, axisRangeSpec = 'Full' ): NumberOrDateRange | undefined { - const defaultAxisRange = useMemo(() => { - // Check here to make sure number ranges (min, minPos, max) came with number variables - // Originally from https://github.com/VEuPathDB/web-eda/pull/1004 - // Only checking min for brevity. - // use luxon library to check the validity of date string - if ( - (Variable.is(variable) && - (min == null || - ((variable.type === 'number' || variable.type === 'integer') && - typeof min === 'number') || - (variable.type === 'date' && - typeof min === 'string' && - isValidDateString(min)))) || - VariableMapping.is(variable) - ) { - const defaultRange = numberDateDefaultAxisRange( - variable, - min, - minPos, - max, - logScale, - axisRangeSpec - ); - - // 4 significant figures - if ( - // consider computed variable as well - (Variable.is(variable) && - (variable.type === 'number' || variable.type === 'integer') && - typeof defaultRange?.min === 'number' && - typeof defaultRange?.max === 'number') || - (VariableMapping.is(variable) && - typeof defaultRange?.min === 'number' && - typeof defaultRange?.max === 'number') - ) - // check non-zero baseline for continuous overlay variable - return { - min: numberSignificantFigures(defaultRange.min, 4, 'down'), - max: numberSignificantFigures(defaultRange.max, 4, 'up'), - }; - else return defaultRange; - } else if ( - variable == null && - typeof max === 'number' && - typeof minPos === 'number' - ) { - // if there's no variable, it's a count or proportion axis (barplot/histogram) - return logScale - ? { - min: numberSignificantFigures( - Math.min(minPos / 10, 0.1), - 4, - 'down' - ), // ensure the minimum-height bars will be visible - max: numberSignificantFigures(max, 4, 'up'), - } - : { - min: 0, - max: numberSignificantFigures(max, 4, 'up'), - }; - } else { - return undefined; - } - }, [variable, min, minPos, max, logScale, axisRangeSpec]); - return defaultAxisRange; -} - -function isValidDateString(value: string) { - return DateTime.fromISO(value).isValid; + return useMemo( + () => + getDefaultAxisRange(variable, min, minPos, max, logScale, axisRangeSpec), + [axisRangeSpec, logScale, max, min, minPos, variable] + ); } diff --git a/packages/libs/eda/src/lib/core/utils/computeDefaultAxisRange.ts b/packages/libs/eda/src/lib/core/utils/computeDefaultAxisRange.ts new file mode 100755 index 0000000000..03fc40405c --- /dev/null +++ b/packages/libs/eda/src/lib/core/utils/computeDefaultAxisRange.ts @@ -0,0 +1,88 @@ +import { Variable } from '../types/study'; +// for scatter plot +import { numberDateDefaultAxisRange } from '../utils/default-axis-range'; +import { NumberOrDateRange } from '../types/general'; +// type of computedVariableMetadata for computation apps such as alphadiv and abundance +import { VariableMapping } from '../api/DataClient/types'; +import { numberSignificantFigures } from '../utils/number-significant-figures'; +import { DateTime } from 'luxon'; + +/** + * A custom hook to compute default axis range from annotated and observed min/max values + * taking into account log scale, dates and computed variables + */ + +export function getDefaultAxisRange( + /** the variable (or computed variable) or null/undefined if no variable (e.g. histogram/barplot y) */ + variable: Variable | VariableMapping | undefined | null, + /** the min/minPos/max values observed in the data response */ + min?: number | string, + minPos?: number | string, + max?: number | string, + /** are we using a log scale */ + logScale?: boolean, + axisRangeSpec = 'Full' +): NumberOrDateRange | undefined { + // Check here to make sure number ranges (min, minPos, max) came with number variables + // Originally from https://github.com/VEuPathDB/web-eda/pull/1004 + // Only checking min for brevity. + // use luxon library to check the validity of date string + if ( + (Variable.is(variable) && + (min == null || + ((variable.type === 'number' || variable.type === 'integer') && + typeof min === 'number') || + (variable.type === 'date' && + typeof min === 'string' && + isValidDateString(min)))) || + VariableMapping.is(variable) + ) { + const defaultRange = numberDateDefaultAxisRange( + variable, + min, + minPos, + max, + logScale, + axisRangeSpec + ); + + // 4 significant figures + if ( + // consider computed variable as well + (Variable.is(variable) && + (variable.type === 'number' || variable.type === 'integer') && + typeof defaultRange?.min === 'number' && + typeof defaultRange?.max === 'number') || + (VariableMapping.is(variable) && + typeof defaultRange?.min === 'number' && + typeof defaultRange?.max === 'number') + ) + // check non-zero baseline for continuous overlay variable + return { + min: numberSignificantFigures(defaultRange.min, 4, 'down'), + max: numberSignificantFigures(defaultRange.max, 4, 'up'), + }; + else return defaultRange; + } else if ( + variable == null && + typeof max === 'number' && + typeof minPos === 'number' + ) { + // if there's no variable, it's a count or proportion axis (barplot/histogram) + return logScale + ? { + min: numberSignificantFigures(Math.min(minPos / 10, 0.1), 4, 'down'), // ensure the minimum-height bars will be visible + max: numberSignificantFigures(max, 4, 'up'), + } + : { + min: 0, + max: numberSignificantFigures(max, 4, 'up'), + }; + } else { + return undefined; + } +} + +function isValidDateString(value: string) { + return DateTime.fromISO(value).isValid; +} diff --git a/packages/libs/eda/src/lib/core/utils/visualization.ts b/packages/libs/eda/src/lib/core/utils/visualization.ts index 4a2d0750f9..2301cd309b 100644 --- a/packages/libs/eda/src/lib/core/utils/visualization.ts +++ b/packages/libs/eda/src/lib/core/utils/visualization.ts @@ -25,7 +25,7 @@ import { VariablesByInputName, } from './data-element-constraints'; import { isEqual } from 'lodash'; -import { UNSELECTED_DISPLAY_TEXT, UNSELECTED_TOKEN } from '../../map'; +import { UNSELECTED_DISPLAY_TEXT, UNSELECTED_TOKEN } from '../../map/constants'; // was: BarplotData | HistogramData | { series: BoxplotData }; type SeriesWithStatistics = T & CoverageStatistics; diff --git a/packages/libs/eda/src/lib/map/MapVeuContainer.tsx b/packages/libs/eda/src/lib/map/MapVeuContainer.tsx index acca2b5b88..dba712dd05 100644 --- a/packages/libs/eda/src/lib/map/MapVeuContainer.tsx +++ b/packages/libs/eda/src/lib/map/MapVeuContainer.tsx @@ -6,6 +6,9 @@ import { useHistory, } from 'react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; + import { EDAAnalysisListContainer, EDAWorkspaceContainer } from '../core'; import { AnalysisList } from './MapVeuAnalysisList'; @@ -21,7 +24,7 @@ import { } from '../core/hooks/client'; import './MapVEu.scss'; -import { SiteInformationProps } from '.'; +import { SiteInformationProps } from './analysis/Types'; import { StudyList } from './StudyList'; import { PublicAnalysesRoute } from '../workspace/PublicAnalysesRoute'; import { ImportAnalysis } from '../workspace/ImportAnalysis'; @@ -49,98 +52,113 @@ export function MapVeuContainer(mapVeuContainerProps: Props) { history.push(loginUrl); } + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // This is similar behavior to our custom usePromise hook. + // It can be overridden on an individual basis, if needed. + keepPreviousData: true, + // We presume data will not go stale during the lifecycle of an application. + staleTime: Infinity, + }, + }, + }); + // This will get the matched path of the active parent route. // This is useful so we don't have to hardcode the path root. const { path } = useRouteMatch(); return ( - - ( - - )} - /> - } - /> - ( - - )} - /> - - ) => { - return ( - + + ( + - ); - }} - /> - - ) => ( - - + } + /> + ( + - - )} - /> - ) => ( - - + + ) => { + return ( + + ); + }} + /> + + ) => ( + + + + )} + /> + ) => ( + - - )} - /> - + analysisClient={analysisClient} + subsettingClient={edaClient} + dataClient={dataClient} + downloadClient={downloadClient} + computeClient={computeClient} + className="MapVEu" + > + + + )} + /> + + + ); } diff --git a/packages/libs/eda/src/lib/map/analysis/DraggableLegendPanel.tsx b/packages/libs/eda/src/lib/map/analysis/DraggableLegendPanel.tsx new file mode 100644 index 0000000000..e55276fba0 --- /dev/null +++ b/packages/libs/eda/src/lib/map/analysis/DraggableLegendPanel.tsx @@ -0,0 +1,23 @@ +import DraggablePanel, { + DraggablePanelCoordinatePair, +} from '@veupathdb/coreui/lib/components/containers/DraggablePanel'; + +export const DraggableLegendPanel = (props: { + zIndex: number; + panelTitle?: string; + defaultPosition?: DraggablePanelCoordinatePair; + children: React.ReactNode; +}) => ( + + {props.children} + +); diff --git a/packages/libs/eda/src/lib/map/analysis/DraggableVisualization.tsx b/packages/libs/eda/src/lib/map/analysis/DraggableVisualization.tsx index edf62a7429..028b2e97ab 100644 --- a/packages/libs/eda/src/lib/map/analysis/DraggableVisualization.tsx +++ b/packages/libs/eda/src/lib/map/analysis/DraggableVisualization.tsx @@ -1,6 +1,5 @@ import { AnalysisState, PromiseHookState } from '../../core'; -import { AppState, useAppState } from './appState'; import { ComputationAppOverview, VisualizationOverview, @@ -16,10 +15,8 @@ import { ComputationPlugin } from '../../core/components/computations/Types'; interface Props { analysisState: AnalysisState; - setActiveVisualizationId: ReturnType< - typeof useAppState - >['setActiveVisualizationId']; - appState: AppState; + visualizationId?: string; + setActiveVisualizationId: (id?: string) => void; apps: ComputationAppOverview[]; plugins: Partial>; geoConfigs: GeoConfig[]; @@ -35,7 +32,7 @@ interface Props { export default function DraggableVisualization({ analysisState, - appState, + visualizationId, setActiveVisualizationId, geoConfigs, apps, @@ -50,9 +47,7 @@ export default function DraggableVisualization({ setHideInputsAndControls, }: Props) { const { computation: activeComputation, visualization: activeViz } = - analysisState.getVisualizationAndComputation( - appState.activeVisualizationId - ) ?? {}; + analysisState.getVisualizationAndComputation(visualizationId) ?? {}; const computationType = activeComputation?.descriptor.type; diff --git a/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx b/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx index f52d07bb8b..805b84f598 100755 --- a/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx @@ -1,17 +1,13 @@ -import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { - AllValuesDefinition, AnalysisState, - BubbleOverlayConfig, - CategoricalVariableDataShape, DEFAULT_ANALYSIS_NAME, DateRangeFilter, DateVariable, EntityDiagram, NumberRangeFilter, NumberVariable, - OverlayConfig, PromiseResult, useAnalysisClient, useDataClient, @@ -39,35 +35,15 @@ import { } from '@veupathdb/coreui'; import { useEntityCounts } from '../../core/hooks/entityCounts'; import ShowHideVariableContextProvider from '../../core/utils/show-hide-variable-context'; -import { MapLegend } from './MapLegend'; -import { - AppState, - MarkerConfiguration, - useAppState, - defaultViewport, -} from './appState'; -import { FloatingDiv } from './FloatingDiv'; +import { AppState, MarkerConfiguration, useAppState } from './appState'; import Subsetting from '../../workspace/Subsetting'; import { MapHeader } from './MapHeader'; import FilterChipList from '../../core/components/FilterChipList'; import { VariableLinkConfig } from '../../core/components/VariableLink'; -import { MapSideNavigation } from './MapSideNavigation'; -import { SiteInformationProps } from '..'; -import MapVizManagement from './MapVizManagement'; -import { useToggleStarredVariable } from '../../core/hooks/starredVariables'; +import { MapSidePanel } from './MapSidePanel'; import { filtersFromBoundingBox } from '../../core/utils/visualization'; import { EditLocation, InfoOutlined, Notes, Share } from '@material-ui/icons'; import { ComputationAppOverview } from '../../core/types/visualization'; -import { - ChartMarkerPropsWithCounts, - DonutMarkerPropsWithCounts, - useStandaloneMapMarkers, -} from './hooks/standaloneMapMarkers'; -import { useStandaloneVizPlugins } from './hooks/standaloneVizPlugins'; -import geohashAnimation from '@veupathdb/components/lib/map/animation_functions/geohash'; -import { defaultAnimationDuration } from '@veupathdb/components/lib/map/config/map'; -import DraggableVisualization from './DraggableVisualization'; -import { useUITheme } from '@veupathdb/coreui/lib/components/theming'; import { useWdkService } from '@veupathdb/wdk-client/lib/Hooks/WdkServiceHook'; import Login from '../../workspace/sharing/Login'; import { useLoginCallbacks } from '../../workspace/sharing/hooks'; @@ -80,48 +56,32 @@ import { uniq } from 'lodash'; import Path from 'path'; import DownloadTab from '../../workspace/DownloadTab'; import { RecordController } from '@veupathdb/wdk-client/lib/Controllers'; -import { - BarPlotMarkerConfigurationMenu, - PieMarkerConfigurationMenu, - BubbleMarkerConfigurationMenu, -} from './MarkerConfiguration'; import { BarPlotMarkerIcon, DonutMarkerIcon, BubbleMarkerIcon, } from './MarkerConfiguration/icons'; -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, - MarkerConfigurationOption, -} from './MarkerConfiguration/MapTypeConfigurationMenu'; -import { DraggablePanel } from '@veupathdb/coreui/lib/components/containers'; -import { TabbedDisplayProps } from '@veupathdb/coreui/lib/components/grids/TabbedDisplay'; import { GeoConfig } from '../../core/types/geoConfig'; import Banner from '@veupathdb/coreui/lib/components/banners/Banner'; -import BubbleMarker, { - BubbleMarkerProps, -} from '@veupathdb/components/lib/map/BubbleMarker'; -import DonutMarker, { - DonutMarkerProps, - DonutMarkerStandalone, -} from '@veupathdb/components/lib/map/DonutMarker'; -import ChartMarker, { - ChartMarkerProps, - ChartMarkerStandalone, - getChartMarkerDependentAxisRange, -} from '@veupathdb/components/lib/map/ChartMarker'; -import { sharedStandaloneMarkerProperties } from './MarkerConfiguration/CategoricalMarkerPreview'; -import { mFormatter, kFormatter } from '../../core/utils/big-number-formatters'; -import { getCategoricalValues } from './utils/categoricalValues'; -import { DraggablePanelCoordinatePair } from '@veupathdb/coreui/lib/components/containers/DraggablePanel'; -import _ from 'lodash'; +import { + SidePanelItem, + SidePanelMenuEntry, + SiteInformationProps, +} from './Types'; +import { SideNavigationItems } from './MapSideNavigation'; +import { + barMarkerPlugin, + bubbleMarkerPlugin, + donutMarkerPlugin, +} from './mapTypes'; import EZTimeFilter from './EZTimeFilter'; +import { useToggleStarredVariable } from '../../core/hooks/starredVariables'; +import { MapTypeMapLayerProps } from './mapTypes/types'; +import { defaultViewport } from '@veupathdb/components/lib/map/config/map'; import AnalysisNameDialog from '../../workspace/AnalysisNameDialog'; enum MapSideNavItemLabels { @@ -133,81 +93,15 @@ enum MapSideNavItemLabels { StudyDetails = 'View Study Details', MyAnalyses = 'My Analyses', ConfigureMap = 'Configure Map', + SingleVariableMaps = 'Single Variable Maps', + GroupedVariableMaps = 'Grouped Variable Maps', } -enum MarkerTypeLabels { - pie = 'Donuts', - barplot = 'Bar plots', - bubble = 'Bubbles', -} - -type SideNavigationItemConfigurationObject = { - href?: string; - labelText: MapSideNavItemLabels; - icon: ReactNode; - renderSideNavigationPanel: (apps: ComputationAppOverview[]) => ReactNode; - onToggleSideMenuItem?: (isActive: boolean) => void; - isExpandable?: boolean; - isExpanded?: boolean; - subMenuConfig?: SubMenuItems[]; -}; - -type SubMenuItems = { - /** - * id is derived by concatentating the parent and sub-menu labels, since: - * A) parent labels must be unique (makes no sense to offer the same label in a menu!) - * B) sub-menu labels must be unique - */ - id: string; - labelText: string; - icon?: ReactNode; - onClick: () => void; - isActive: boolean; -}; - const mapStyle: React.CSSProperties = { zIndex: 1, pointerEvents: 'auto', }; -/** - * The following code and styles are for demonstration purposes - * at this point. After #1671 is merged, we can implement these - * menu buttons and their associated panels for real. - */ -const buttonStyles: React.CSSProperties = { - alignItems: 'center', - background: 'transparent', - borderColor: 'transparent', - display: 'flex', - fontSize: '1.3em', - justifyContent: 'flex-start', - margin: 0, - padding: 0, - width: '100%', -}; -const iconStyles: React.CSSProperties = { - alignItems: 'center', - display: 'flex', - height: '1.5em', - width: '1.5em', - justifyContent: 'center', -}; -const labelStyles: React.CSSProperties = { - marginLeft: '0.5em', -}; - -export const defaultAnimation = { - method: 'geohash', - animationFunction: geohashAnimation, - duration: defaultAnimationDuration, -}; - -enum DraggablePanelIds { - LEGEND_PANEL = 'legend', - VIZ_PANEL = 'viz', -} - interface Props { analysisId?: string; sharingUrl: string; @@ -253,13 +147,12 @@ function MapAnalysisImpl(props: ImplProps) { analysisState, analysisId, setViewport, - setActiveVisualizationId, setBoundsZoomLevel, setSubsetVariableAndEntity, // sharingUrl, - setIsSubsetPanelOpen = () => {}, - setActiveMarkerConfigurationType, + setIsSidePanelExpanded, setMarkerConfigurations, + setActiveMarkerConfigurationType, geoConfigs, setTimeSliderConfig, } = props; @@ -290,17 +183,6 @@ function MapAnalysisImpl(props: ImplProps) { (markerConfig) => markerConfig.type === activeMarkerConfigurationType ); - const { variable: overlayVariable, entity: overlayEntity } = - findEntityAndVariable(activeMarkerConfiguration?.selectedVariable) ?? {}; - - const outputEntity = useMemo(() => { - if (geoConfig == null || geoConfig.entity.id == null) return; - - return overlayEntity - ? leastAncestralEntity([overlayEntity, geoConfig.entity], studyEntities) - : geoConfig.entity; - }, [geoConfig, overlayEntity, studyEntities]); - const updateMarkerConfigurations = useCallback( (updatedConfiguration: MarkerConfiguration) => { const nextMarkerConfigurations = markerConfigurations.map( @@ -388,246 +270,6 @@ function MapAnalysisImpl(props: ImplProps) { ]; }, [props.analysisState.analysis?.descriptor.subset.descriptor, timeFilter]); - const allFilteredCategoricalValues = usePromise( - useCallback(async (): Promise => { - /** - * We only need this data for categorical vars, so we can return early if var isn't categorical - */ - if ( - !overlayVariable || - !CategoricalVariableDataShape.is(overlayVariable.dataShape) - ) - return; - return getCategoricalValues({ - overlayEntity, - subsettingClient, - studyId, - overlayVariable, - filters, - }); - }, [overlayEntity, overlayVariable, subsettingClient, studyId, filters]) - ); - - const allVisibleCategoricalValues = usePromise( - useCallback(async (): Promise => { - /** - * Return early if: - * - overlay var isn't categorical - * - "Show counts for" toggle isn't set to 'visible' - */ - if ( - !overlayVariable || - !CategoricalVariableDataShape.is(overlayVariable.dataShape) || - (activeMarkerConfiguration && - 'selectedCountsOption' in activeMarkerConfiguration && - activeMarkerConfiguration.selectedCountsOption !== 'visible') - ) - return; - - return getCategoricalValues({ - overlayEntity, - subsettingClient, - studyId, - overlayVariable, - filters: filtersIncludingViewportAndTimeSlider, // TO DO: decide whether to filter on time slider here - }); - }, [ - overlayVariable, - activeMarkerConfiguration, - overlayEntity, - subsettingClient, - studyId, - filtersIncludingViewportAndTimeSlider, - ]) - ); - - // If the variable or filters have changed on the active marker config - // get the default overlay config. - const activeOverlayConfig = usePromise( - useCallback(async (): Promise< - OverlayConfig | BubbleOverlayConfig | undefined - > => { - // Use `selectedValues` to generate the overlay config for categorical variables - if ( - activeMarkerConfiguration && - 'selectedValues' in activeMarkerConfiguration && - activeMarkerConfiguration.selectedValues && - CategoricalVariableDataShape.is(overlayVariable?.dataShape) - ) { - return { - overlayType: 'categorical', - overlayVariable: { - variableId: overlayVariable?.id, - entityId: overlayEntity?.id, - }, - overlayValues: activeMarkerConfiguration.selectedValues, - } as OverlayConfig; - } - - return getDefaultOverlayConfig({ - studyId, - filters, - overlayVariable, - overlayEntity, - dataClient, - subsettingClient, - markerType: activeMarkerConfiguration?.type, - binningMethod: _.get(activeMarkerConfiguration, 'binningMethod'), - aggregator: _.get(activeMarkerConfiguration, 'aggregator'), - numeratorValues: _.get(activeMarkerConfiguration, 'numeratorValues'), - denominatorValues: _.get( - activeMarkerConfiguration, - 'denominatorValues' - ), - }); - }, [ - activeMarkerConfiguration, - overlayVariable, - studyId, - filters, - overlayEntity, - dataClient, - subsettingClient, - ]) - ); - - // needs to be pie, count or proportion - const markerType = (() => { - switch (activeMarkerConfiguration?.type) { - case 'barplot': { - return activeMarkerConfiguration?.selectedPlotMode; // count or proportion - } - case 'bubble': - return 'bubble'; - case 'pie': - default: - return 'pie'; - } - })(); - - const { - markersData, - pending, - error, - legendItems, - bubbleLegendData, - bubbleValueToDiameterMapper, - bubbleValueToColorMapper, - totalVisibleEntityCount, - totalVisibleWithOverlayEntityCount, - } = useStandaloneMapMarkers({ - boundsZoomLevel: appState.boundsZoomLevel, - geoConfig: geoConfig, - studyId, - filters: filtersIncludingTimeSlider, - markerType, - selectedOverlayVariable: activeMarkerConfiguration?.selectedVariable, - overlayConfig: activeOverlayConfig.value, - outputEntityId: outputEntity?.id, - dependentAxisLogScale: - activeMarkerConfiguration && - 'dependentAxisLogScale' in activeMarkerConfiguration - ? activeMarkerConfiguration.dependentAxisLogScale - : false, - }); - - const { markersData: previewMarkerData } = useStandaloneMapMarkers({ - boundsZoomLevel: undefined, - geoConfig: geoConfig, - studyId, - filters, - markerType, - selectedOverlayVariable: activeMarkerConfiguration?.selectedVariable, - overlayConfig: activeOverlayConfig.value, - outputEntityId: outputEntity?.id, - }); - - const continuousMarkerPreview = useMemo(() => { - if ( - !previewMarkerData || - !previewMarkerData.length || - !Array.isArray(previewMarkerData[0].data) - ) - return; - const typedData = - markerType === 'pie' - ? (previewMarkerData as DonutMarkerPropsWithCounts[]) - : (previewMarkerData as ChartMarkerPropsWithCounts[]); - const initialDataObject = typedData[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 dataWithCountsOnly = typedData.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 - ); - // NOTE: we could just as well reduce using c.value since we overwrite the value prop with the count data - const totalCount = dataWithCountsOnly.reduce((p, c) => p + c.count, 0); - if (markerType === 'pie') { - return ( - - ); - } else { - const dependentAxisLogScale = - activeMarkerConfiguration && - 'dependentAxisLogScale' in activeMarkerConfiguration - ? activeMarkerConfiguration.dependentAxisLogScale - : false; - return ( - - ); - } - }, [activeMarkerConfiguration, markerType, previewMarkerData]); - - const markers = useMemo( - () => - markersData?.map((markerProps) => - markerType === 'pie' ? ( - - ) : markerType === 'bubble' ? ( - - ) : ( - - ) - ) || [], - [markersData, markerType] - ); - const userLoggedIn = useWdkService(async (wdkService) => { const user = await wdkService.getCurrentUser(); return !user.isGuest; @@ -657,33 +299,13 @@ function MapAnalysisImpl(props: ImplProps) { analysisState.analysis?.descriptor.subset.descriptor ); - const plugins = useStandaloneVizPlugins({ - selectedOverlayConfig: - activeMarkerConfigurationType === 'bubble' - ? undefined - : activeOverlayConfig.value, - overlayHelp: - activeMarkerConfigurationType === 'bubble' - ? 'Overlay variables are not available for this map type' - : undefined, - }); - const subsetVariableAndEntity = useMemo(() => { return appState.subsetVariableAndEntity ?? getDefaultVariableDescriptor(); }, [appState.subsetVariableAndEntity, getDefaultVariableDescriptor]); - const outputEntityTotalCount = - totalCounts.value && outputEntity ? totalCounts.value[outputEntity.id] : 0; - - const outputEntityFilteredCount = - filteredCounts.value && outputEntity - ? filteredCounts.value[outputEntity.id] - : 0; - function openSubsetPanelFromControlOutsideOfNavigation() { - setIsSubsetPanelOpen(true); - setActiveSideMenuId(MapSideNavItemLabels.Filter); - setSideNavigationIsExpanded(true); + setActiveSideMenuId('filter'); + setIsSidePanelExpanded(true); } const FilterChipListForHeader = () => { @@ -712,8 +334,7 @@ function MapAnalysisImpl(props: ImplProps) { disabled={ // You don't need this button if whenever the filter // section is active and expanded. - sideNavigationIsExpanded && - activeSideMenuId === MapSideNavItemLabels.Filter + appState.isSidePanelExpanded && activeSideMenuId === 'filter' } themeRole="primary" text="Add filters" @@ -762,536 +383,396 @@ function MapAnalysisImpl(props: ImplProps) { if (redirectURL) history.push(redirectURL); }, [history, redirectURL]); - const sideNavigationButtonConfigurationObjects: SideNavigationItemConfigurationObject[] = - [ - { - labelText: MapSideNavItemLabels.ConfigureMap, - icon: , - isExpandable: true, - subMenuConfig: [ - { - // concatenating the parent and subMenu labels creates a unique ID - id: MapSideNavItemLabels.ConfigureMap + MarkerTypeLabels.pie, - labelText: MarkerTypeLabels.pie, - icon: , - onClick: () => setActiveMarkerConfigurationType('pie'), - isActive: activeMarkerConfigurationType === 'pie', - }, - { - // concatenating the parent and subMenu labels creates a unique ID - id: MapSideNavItemLabels.ConfigureMap + MarkerTypeLabels.barplot, - labelText: MarkerTypeLabels.barplot, - icon: , - onClick: () => setActiveMarkerConfigurationType('barplot'), - isActive: activeMarkerConfigurationType === 'barplot', - }, - { - // concatenating the parent and subMenu labels creates a unique ID - id: MapSideNavItemLabels.ConfigureMap + MarkerTypeLabels.bubble, - labelText: MarkerTypeLabels.bubble, - icon: , - onClick: () => setActiveMarkerConfigurationType('bubble'), - isActive: activeMarkerConfigurationType === 'bubble', - }, - ], - renderSideNavigationPanel: (apps) => { - const markerVariableConstraints = apps - .find((app) => app.name === 'standalone-map') - ?.visualizations.find( - (viz) => viz.name === 'map-markers' - )?.dataElementConstraints; - - const markerConfigurationObjects: MarkerConfigurationOption[] = [ + const sidePanelMenuEntries: SidePanelMenuEntry[] = [ + { + type: 'heading', + labelText: MapSideNavItemLabels.ConfigureMap, + leftIcon: , + children: [ + { + type: 'subheading', + labelText: MapSideNavItemLabels.SingleVariableMaps, + children: [ { - type: 'pie', - displayName: MarkerTypeLabels.pie, - icon: ( - - ), - configurationMenu: - activeMarkerConfiguration?.type === 'pie' ? ( - , + leftIcon: + activeMarkerConfigurationType === 'pie' ? : null, + onActive: () => { + setActiveMarkerConfigurationType('pie'); + }, + renderSidePanelDrawer(apps) { + return ( + - ) : ( - <> - ), + ); + }, }, { - type: 'barplot', - displayName: MarkerTypeLabels.barplot, - icon: ( - - ), - configurationMenu: - activeMarkerConfiguration?.type === 'barplot' ? ( - + ) : null, + rightIcon: , + onActive: () => { + setActiveMarkerConfigurationType('barplot'); + }, + renderSidePanelDrawer(apps) { + return ( + - ) : ( - <> - ), + ); + }, }, { - type: 'bubble', - displayName: MarkerTypeLabels.bubble, - icon: ( - - ), - configurationMenu: - activeMarkerConfiguration?.type === 'bubble' ? ( - , + leftIcon: + activeMarkerConfigurationType === 'bubble' ? ( + + ) : null, + onActive: () => setActiveMarkerConfigurationType('bubble'), + renderSidePanelDrawer(apps) { + return ( + - ) : ( - <> - ), - }, - ]; - - const mapTypeConfigurationMenuTabs: TabbedDisplayProps< - 'markers' | 'plots' - >['tabs'] = [ - { - key: 'markers', - displayName: 'Markers', - content: markerConfigurationObjects.find( - ({ type }) => type === activeMarkerConfigurationType - )?.configurationMenu, - }, - { - key: 'plots', - displayName: 'Supporting Plots', - content: ( - - ), + ); + }, }, - ]; - - return ( -
- -
- ); + ], }, - }, - { - labelText: MapSideNavItemLabels.Filter, - icon: , - renderSideNavigationPanel: () => { - return ( + ], + }, + { + type: 'item', + id: 'filter', + labelText: MapSideNavItemLabels.Filter, + leftIcon: , + renderSidePanelDrawer: () => { + return ( +
-
- { - setSubsetVariableAndEntity({ - entityId: variableValue?.entityId, - variableId: variableValue?.variableId - ? variableValue.variableId - : getDefaultVariableDescriptor( - variableValue?.entityId - ).variableId, - }); - }, - }} - /> -
- { + setSubsetVariableAndEntity({ + entityId: variableValue?.entityId, + variableId: variableValue?.variableId + ? variableValue.variableId + : getDefaultVariableDescriptor(variableValue?.entityId) + .variableId, + }); + }, }} - entityId={subsetVariableAndEntity?.entityId ?? ''} - variableId={subsetVariableAndEntity.variableId ?? ''} - analysisState={analysisState} - totalCounts={totalCounts.value} - filteredCounts={filteredCounts.value} - // gets passed to variable tree in order to disable scrollIntoView - scope="map" />
- ); - }, - onToggleSideMenuItem: (isActive) => { - setIsSubsetPanelOpen(!isActive); - }, - }, - { - labelText: MapSideNavItemLabels.Download, - icon: , - renderSideNavigationPanel: () => { - return ( -
- -
- ); - }, + entityId={subsetVariableAndEntity?.entityId ?? ''} + variableId={subsetVariableAndEntity.variableId ?? ''} + analysisState={analysisState} + totalCounts={totalCounts.value} + filteredCounts={filteredCounts.value} + // gets passed to variable tree in order to disable scrollIntoView + scope="map" + /> +
+ ); }, - { - labelText: MapSideNavItemLabels.Share, - icon: , - renderSideNavigationPanel: () => { - if (!analysisState.analysis) return null; - - function getShareMenuContent() { - if (!userLoggedIn) { - return ; - } - if ( - analysisState?.analysis?.displayName === DEFAULT_ANALYSIS_NAME - ) { - return ( - - ); - } - return ; + }, + { + type: 'item', + id: 'download', + labelText: MapSideNavItemLabels.Download, + leftIcon: , + renderSidePanelDrawer: () => { + return ( +
+ +
+ ); + }, + }, + { + type: 'item', + id: 'share', + labelText: MapSideNavItemLabels.Share, + leftIcon: , + renderSidePanelDrawer: () => { + if (!analysisState.analysis) return null; + + function getShareMenuContent() { + if (!userLoggedIn) { + return ; } + if (analysisState?.analysis?.displayName === DEFAULT_ANALYSIS_NAME) { + return ( + + ); + } + return ; + } - return ( -
- {getShareMenuContent()} -
- ); - }, + return ( +
+ {getShareMenuContent()} +
+ ); }, - { - labelText: MapSideNavItemLabels.Notes, - icon: , - renderSideNavigationPanel: () => { - return ( -
- -
- ); - }, + }, + { + type: 'item', + id: 'notes', + labelText: MapSideNavItemLabels.Notes, + leftIcon: , + renderSidePanelDrawer: () => { + return ( +
+ +
+ ); }, - { - labelText: MapSideNavItemLabels.MyAnalyses, - icon: , - renderSideNavigationPanel: () => { - return ( -
- {analysisId && redirectToNewAnalysis ? ( -
- setIsAnalysisNameDialogOpen(true) - : redirectToNewAnalysis - } - textTransform="none" - /> -
- ) : ( - <> - )} - {analysisState.analysis && ( - , + renderSidePanelDrawer: () => { + return ( +
+ {analysisId && redirectToNewAnalysis ? ( +
+ setIsAnalysisNameDialogOpen(true) + : redirectToNewAnalysis + } + textTransform="none" /> - )} - + ) : ( + <> + )} + {analysisState.analysis && ( + -
- ); - }, + )} + +
+ ); }, - { - labelText: MapSideNavItemLabels.StudyDetails, - icon: , - renderSideNavigationPanel: () => { - return ( -
-
Study Details
- p.value).join('/')} - /> -
- ); - }, + }, + { + type: 'item', + id: 'study-details', + labelText: MapSideNavItemLabels.StudyDetails, + leftIcon: , + renderSidePanelDrawer: () => { + return ( +
+
Study Details
+ p.value).join('/')} + /> +
+ ); }, - ]; - - function isMapTypeSubMenuItemSelected() { - const mapTypeSideNavObject = sideNavigationButtonConfigurationObjects.find( - (navObject) => navObject.labelText === MapSideNavItemLabels.ConfigureMap - ); - if ( - mapTypeSideNavObject && - 'subMenuConfig' in mapTypeSideNavObject && - mapTypeSideNavObject.subMenuConfig - ) { - return !!mapTypeSideNavObject.subMenuConfig.find( - (mapType) => mapType.id === activeSideMenuId - ); - } else { - return false; + }, + ]; + + function findActiveSidePanelItem( + entries: SidePanelMenuEntry[] = sidePanelMenuEntries + ): SidePanelItem | undefined { + for (const entry of entries) { + switch (entry.type) { + case 'heading': + case 'subheading': + const activeChild = findActiveSidePanelItem(entry.children); + if (activeChild) return activeChild; + break; + case 'item': + if (entry.id === activeSideMenuId) { + return entry; + } + break; + } } } - function areMapTypeAndActiveVizCompatible() { - if (!appState.activeVisualizationId) return false; - const visualization = analysisState.getVisualization( - appState.activeVisualizationId - ); - return ( - visualization?.descriptor.applicationContext === - activeMarkerConfigurationType - ); - } - - const intialActiveSideMenuId: string | undefined = (() => { - if ( - appState.activeVisualizationId && - appState.activeMarkerConfigurationType && - MarkerTypeLabels[appState.activeMarkerConfigurationType] - ) - return ( - MapSideNavItemLabels.ConfigureMap + - MarkerTypeLabels[appState.activeMarkerConfigurationType] - ); - - return undefined; - })(); - // activeSideMenuId is derived from the label text since labels must be unique in a navigation menu const [activeSideMenuId, setActiveSideMenuId] = useState( - intialActiveSideMenuId + 'single-variable-' + appState.activeMarkerConfigurationType ); const toggleStarredVariable = useToggleStarredVariable(analysisState); - const [sideNavigationIsExpanded, setSideNavigationIsExpanded] = - useState(true); - - // for flyTo functionality - const [willFlyTo, setWillFlyTo] = useState(false); - - // Only decide if we need to flyTo while we are waiting for marker data - // then only trigger the flyTo when no longer pending. - // This makes sure that the user sees the global location of the data before the flyTo happens. - useEffect(() => { - if (pending) { - // set a safe margin (epsilon) to perform flyTo correctly due to an issue of map resolution etc. - // not necessarily need to use defaultAppState.viewport.center [0, 0] here but used it just in case - const epsilon = 2.0; - const isWillFlyTo = - appState.viewport.zoom === defaultViewport.zoom && - Math.abs(appState.viewport.center[0] - defaultViewport.center[0]) <= - epsilon && - Math.abs(appState.viewport.center[1] - defaultViewport.center[1]) <= - epsilon; - setWillFlyTo(isWillFlyTo); - } - }, [pending, appState.viewport]); - - const [zIndicies /* setZIndicies */] = useState( - Object.values(DraggablePanelIds) - ); - - function getZIndexByPanelTitle( - requestedPanelTitle: DraggablePanelIds - ): number { - const index = zIndicies.findIndex( - (panelTitle) => panelTitle === requestedPanelTitle - ); - const zIndexFactor = sideNavigationIsExpanded ? 2 : 10; - return index + zIndexFactor; - } - - const legendZIndex = - getZIndexByPanelTitle(DraggablePanelIds.LEGEND_PANEL) + - getZIndexByPanelTitle(DraggablePanelIds.VIZ_PANEL); + const activeMapTypePlugin = + activeMarkerConfiguration?.type === 'barplot' + ? barMarkerPlugin + : activeMarkerConfiguration?.type === 'bubble' + ? bubbleMarkerPlugin + : activeMarkerConfiguration?.type === 'pie' + ? donutMarkerPlugin + : undefined; return ( {(apps: ComputationAppOverview[]) => { - const activeSideNavigationItemMenu = getSideNavigationItemMenu(); - - function getSideNavigationItemMenu() { - if (activeSideMenuId == null) return <>; - return sideNavigationButtonConfigurationObjects - .find((navItem) => { - if (navItem.labelText === activeSideMenuId) return true; - if ('subMenuConfig' in navItem && navItem.subMenuConfig) { - return navItem.subMenuConfig.find( - (subNavItem) => subNavItem.id === activeSideMenuId - ); - } - return false; - }) - ?.renderSideNavigationPanel(apps); - } + const activePanelItem = findActiveSidePanelItem(); + const activeSideNavigationItemMenu = + activePanelItem?.renderSidePanelDrawer(apps) ?? null; + + const mapTypeMapLayerProps: MapTypeMapLayerProps = { + apps, + analysisState, + appState, + studyId, + filters: filtersIncludingTimeSlider, + studyEntities, + geoConfigs, + configuration: activeMarkerConfiguration, + updateConfiguration: updateMarkerConfigurations as any, + filtersIncludingViewport: filtersIncludingViewportAndTimeSlider, + totalCounts, + filteredCounts, + hideVizInputsAndControls, + setHideVizInputsAndControls, + }; return ( @@ -1307,18 +788,17 @@ function MapAnalysisImpl(props: ImplProps) { > } siteInformation={props.siteInformationProps} onAnalysisNameEdit={analysisState.setName} studyName={studyRecord.displayName} - totalEntityCount={outputEntityTotalCount} - totalEntityInSubsetCount={outputEntityFilteredCount} - visibleEntityCount={ - totalVisibleWithOverlayEntityCount ?? - totalVisibleEntityCount + mapTypeDetails={ + activeMapTypePlugin?.MapTypeHeaderDetails && ( + + ) } - overlayActive={overlayVariable != null} > {/* child elements will be distributed across, 'hanging' below the header */} {/* Time slider component - only if prerequisite variable is available */} @@ -1354,36 +834,28 @@ function MapAnalysisImpl(props: ImplProps) { pointerEvents: 'none', }} > - - setSideNavigationIsExpanded((isExpanded) => !isExpanded) + setIsSidePanelExpanded(!appState.isSidePanelExpanded) } siteInformationProps={props.siteInformationProps} - activeNavigationMenu={activeSideNavigationItemMenu} + sidePanelDrawerContents={activeSideNavigationItemMenu} > - + 0 && willFlyTo && !pending - } - flyToMarkersDelay={500} onBoundsChanged={setBoundsZoomLevel} onViewportChanged={setViewport} showGrid={geoConfig?.zoomLevelToAggregationLevel !== null} @@ -1392,116 +864,19 @@ function MapAnalysisImpl(props: ImplProps) { } // pass defaultViewport & isStandAloneMap props for custom zoom control defaultViewport={defaultViewport} - /> -
- - {markerType !== 'bubble' ? ( - -
- -
-
- ) : ( - <> - -
- -
-
- -
- 'white'), - }} - /> -
-
- - )} - - {/* )} */} - {/* -
- {safeHtml(studyRecord.displayName)} ({totalEntityCount}) -
-
- Showing {entity?.displayName} variable {variable?.displayName} -
-
- setIsSubsetPanelOpen(true)} - /> -
- */} - - {activeSideMenuId && isMapTypeSubMenuItemSelected() && ( - - )} + + - {error && ( - -
{String(error)}
-
+ {activeMapTypePlugin?.MapOverlayComponent && ( + )} @@ -1511,175 +886,3 @@ function MapAnalysisImpl(props: ImplProps) { ); } - -type SideNavItemsProps = { - itemConfigObjects: SideNavigationItemConfigurationObject[]; - activeSideMenuId: string | undefined; - setActiveSideMenuId: React.Dispatch>; -}; - -function SideNavigationItems({ - itemConfigObjects, - activeSideMenuId, - setActiveSideMenuId, -}: SideNavItemsProps) { - const theme = useUITheme(); - const sideNavigationItems = itemConfigObjects.map( - ({ - labelText, - icon, - onToggleSideMenuItem = () => {}, - subMenuConfig = [], - }) => { - /** - * if subMenuConfig.length doesn't exist, we render menu items the same as before sub-menus were added - */ - if (!subMenuConfig.length) { - const isActive = activeSideMenuId === labelText; - return ( -
  • - -
  • - ); - } else { - /** - * If subMenuConfig has items, we nest a
      and map over the items. - * Note that the isActive style gets applied to the nested
        items, not the parent - */ - return ( -
      • - -
          - {subMenuConfig.map((item) => { - return ( -
        • - -
        • - ); - })} -
        -
      • - ); - } - } - ); - return ( -
        -
          {sideNavigationItems}
        -
        - ); -} - -const DraggableLegendPanel = (props: { - zIndex: number; - panelTitle?: string; - defaultPosition?: DraggablePanelCoordinatePair; - children: React.ReactNode; -}) => ( - - {props.children} - -); diff --git a/packages/libs/eda/src/lib/map/analysis/MapFloatingErrorDiv.tsx b/packages/libs/eda/src/lib/map/analysis/MapFloatingErrorDiv.tsx new file mode 100644 index 0000000000..4578875712 --- /dev/null +++ b/packages/libs/eda/src/lib/map/analysis/MapFloatingErrorDiv.tsx @@ -0,0 +1,20 @@ +import { FloatingDiv } from './FloatingDiv'; + +interface MapFloatingErrorDivProps { + error: unknown; +} + +export function MapFloatingErrorDiv(props: MapFloatingErrorDivProps) { + return ( + +
        {String(props.error)}
        +
        + ); +} diff --git a/packages/libs/eda/src/lib/map/analysis/MapHeader.scss b/packages/libs/eda/src/lib/map/analysis/MapHeader.scss index e77298a133..08cdfff5b4 100644 --- a/packages/libs/eda/src/lib/map/analysis/MapHeader.scss +++ b/packages/libs/eda/src/lib/map/analysis/MapHeader.scss @@ -23,37 +23,6 @@ width: 55px; } } - - &__SampleCounter { - display: flex; - height: 100%; - align-items: center; - justify-content: center; - margin-right: 1.5rem; - font-size: 15px; - - th { - border-width: 0; - padding: 0; - margin: 0; - } - td { - margin: 0; - padding: 0 0 0 10px; - } - tbody tr td:first-child { - text-align: left; - } - tbody tr td:nth-child(2) { - text-align: right; - } - - tbody tr td:first-child { - &::after { - content: ':'; - } - } - } } .HeaderContent { diff --git a/packages/libs/eda/src/lib/map/analysis/MapHeader.tsx b/packages/libs/eda/src/lib/map/analysis/MapHeader.tsx index 04e5d93959..474c074bc9 100644 --- a/packages/libs/eda/src/lib/map/analysis/MapHeader.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MapHeader.tsx @@ -1,4 +1,4 @@ -import { CSSProperties, ReactElement, ReactNode } from 'react'; +import { ReactElement, ReactNode } from 'react'; import { makeClassNameHelper, safeHtml, @@ -6,29 +6,20 @@ import { import { SaveableTextEditor } from '@veupathdb/wdk-client/lib/Components'; import { ANALYSIS_NAME_MAX_LENGTH } from '../../core/utils/analysis'; import './MapHeader.scss'; -import { - mapNavigationBackgroundColor, - mapNavigationBorder, - SiteInformationProps, -} from '..'; -import { StudyEntity } from '../../core'; -import { makeEntityDisplayName } from '../../core/utils/study-metadata'; +import { mapSidePanelBackgroundColor } from '../constants'; +import { SiteInformationProps } from './Types'; import { useUITheme } from '@veupathdb/coreui/lib/components/theming'; export type MapNavigationProps = { analysisName?: string; - outputEntity?: StudyEntity; filterList?: ReactElement; siteInformation: SiteInformationProps; onAnalysisNameEdit: (newName: string) => void; studyName: string; - totalEntityCount: number | undefined; - totalEntityInSubsetCount: number | undefined; - visibleEntityCount: number | undefined; - overlayActive: boolean; /** children of this component will be rendered in flex children distributed across the bottom edge of the header, hanging down like tabs */ children: ReactNode; + mapTypeDetails?: ReactNode; }; /** @@ -38,19 +29,14 @@ export type MapNavigationProps = { */ export function MapHeader({ analysisName, - outputEntity, filterList, siteInformation, onAnalysisNameEdit, studyName, - totalEntityCount = 0, - totalEntityInSubsetCount = 0, - visibleEntityCount = 0, - overlayActive, children, + mapTypeDetails, }: MapNavigationProps) { const mapHeader = makeClassNameHelper('MapHeader'); - const { format } = new Intl.NumberFormat(); const { siteName } = siteInformation; const theme = useUITheme(); @@ -65,7 +51,7 @@ export function MapHeader({ background: siteName === 'VectorBase' ? '#F5FAF1' - : theme?.palette.primary.hue[100] ?? mapNavigationBackgroundColor, + : theme?.palette.primary.hue[100] ?? mapSidePanelBackgroundColor, // Mimics shadow used in Google maps boxShadow: '0 1px 2px rgba(60,64,67,0.3), 0 2px 6px 2px rgba(60,64,67,0.15)', @@ -88,60 +74,7 @@ export function MapHeader({ onAnalysisNameEdit={onAnalysisNameEdit} /> - {outputEntity && ( -
        -

        {makeEntityDisplayName(outputEntity, true)}

        - -
    - - {/* */} - - - 1 - )} in the dataset.`} - > - - - - 1 - )} in the subset.`} - > - - - - 1 - )} are in the current viewport${ - overlayActive - ? ', and have data for the painted variable' - : '' - }.`} - > - - - - -
    {entityDisplayName}
    All{format(totalEntityCount)}
    Filtered{format(totalEntityInSubsetCount)}
    View{format(visibleEntityCount)}
    - - )} + {mapTypeDetails} {children} ); @@ -198,22 +131,3 @@ function HeaderContent({ ); } - -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/MapSideNavigation.tsx b/packages/libs/eda/src/lib/map/analysis/MapSideNavigation.tsx index f306faad9c..d4d1e09675 100644 --- a/packages/libs/eda/src/lib/map/analysis/MapSideNavigation.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MapSideNavigation.tsx @@ -1,206 +1,121 @@ -import { ChevronRight } from '@veupathdb/coreui'; -import { Launch, LockOpen, Person } from '@material-ui/icons'; -import { - mapNavigationBackgroundColor, - mapNavigationBorder, - SiteInformationProps, -} from '..'; -import { Link } from 'react-router-dom'; +import { css } from '@emotion/react'; +import { colors } from '@veupathdb/coreui'; +import useUITheme from '@veupathdb/coreui/lib/components/theming/useUITheme'; +import * as React from 'react'; +import { SidePanelMenuEntry } from './Types'; -export type MapSideNavigationProps = { - /** The navigation is stateless. */ - isExpanded: boolean; - children: React.ReactNode; - /** This fires when the user expands/collapses the nav. */ - onToggleIsExpanded: () => void; - activeNavigationMenu?: React.ReactNode; - siteInformationProps: SiteInformationProps; - isUserLoggedIn: boolean | undefined; +type SideNavItemsProps = { + menuEntries: SidePanelMenuEntry[]; + activeSideMenuId: string | undefined; + setActiveSideMenuId: React.Dispatch>; }; -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', -}; +export function SideNavigationItems(props: SideNavItemsProps) { + return ; +} -const mapSideNavTopOffset = '1.5rem'; +function SideNavigationItemsRecursion({ + menuEntries, + activeSideMenuId, + setActiveSideMenuId, + indentLevel, +}: SideNavItemsProps & { indentLevel: number }) { + const theme = useUITheme(); -export function MapSideNavigation({ - activeNavigationMenu, - children, - isExpanded, - onToggleIsExpanded, - siteInformationProps, - isUserLoggedIn, -}: MapSideNavigationProps) { - const sideMenuExpandButtonWidth = 20; + function formatEntry(entry: SidePanelMenuEntry, isActive = false) { + const style = { + paddingLeft: `${indentLevel * 1.3 + 0.5}em`, + }; + const entryCss = css({ + display: 'flex', + alignItems: 'end', + justifyContent: 'flex-start', + fontSize: entry.type === 'subheading' ? '1.2em' : '1.3em', + fontWeight: entry.type === 'subheading' ? 500 : isActive ? 'bold' : '', + color: entry.type === 'subheading' ? colors.gray[600] : colors.gray[900], + padding: '.4em 0', + backgroundColor: isActive ? theme?.palette.primary.hue[100] : 'inherit', + }); - return ( - + {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"