diff --git a/packages/libs/components/src/map/MapVEuMap.tsx b/packages/libs/components/src/map/MapVEuMap.tsx index d4661c21ca..e31dc46c9e 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, 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'; @@ -113,6 +105,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']; @@ -121,18 +115,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 **/ @@ -157,10 +141,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 */ @@ -183,6 +163,7 @@ export interface MapVEuMapProps { prevGeohashLevel?: number; /* prevGeohashLevel setState **/ setPrevGeohashLevel?: React.Dispatch>; + children?: React.ReactNode; } function MapVEuMap(props: MapVEuMapProps, ref: Ref) { @@ -193,22 +174,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, @@ -232,8 +206,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(() => { @@ -243,7 +221,7 @@ function MapVEuMap(props: MapVEuMapProps, ref: Ref) { if (gitterBtn) { gitterBtn.style.display = 'none'; } - () => { + return () => { if (gitterBtn) { gitterBtn.style.display = 'inline'; } @@ -263,13 +241,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, @@ -296,12 +270,7 @@ function MapVEuMap(props: MapVEuMapProps, ref: Ref) { attribution='© OpenStreetMap contributors' /> - + {props.children} {showGrid && zoomLevelToGeohashLevel ? ( @@ -326,14 +295,6 @@ function MapVEuMap(props: MapVEuMapProps, ref: Ref) { {/* add Scale in the map */} {showScale && } - {/* PerformFlyToMarkers component for flyTo functionality */} - {flyToMarkers && ( - - )} {/* component for map events */} ) { zoomLevelToGeohashLevel={zoomLevelToGeohashLevel} prevGeohashLevel={prevGeohashLevel} setPrevGeohashLevel={setPrevGeohashLevel} + onBoundsChanged={onBoundsChanged} /> {/* set ScrollWheelZoom */} @@ -355,49 +317,9 @@ 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; selectedMarkers?: markerDataProp[]; setSelectedMarkers?: React.Dispatch>; @@ -412,6 +334,7 @@ function MapVEuMapEvents(props: MapVEuMapEventsProps) { const { onViewportChanged, onBaseLayerChanged, + onBoundsChanged, selectedMarkers, setSelectedMarkers, setIsPanning, @@ -464,6 +387,14 @@ function MapVEuMapEvents(props: MapVEuMapEventsProps) { ) { setPrevGeohashLevel(zoomLevelToGeohashLevel(mapEvents.getZoom())); } + + const boundsViewport: BoundsViewport = { + bounds: constrainLongitudeToMainWorld( + boundsToGeoBBox(mapEvents.getBounds()) + ), + zoomLevel: mapEvents.getZoom(), + }; + onBoundsChanged(boundsViewport); }, moveend: () => { onViewportChanged({ @@ -497,6 +428,14 @@ function MapVEuMapEvents(props: MapVEuMapEventsProps) { } ); } + + const boundsViewport: BoundsViewport = { + bounds: constrainLongitudeToMainWorld( + boundsToGeoBBox(mapEvents.getBounds()) + ), + zoomLevel: mapEvents.getZoom(), + }; + onBoundsChanged(boundsViewport); }, baselayerchange: (e: { name: string }) => { onBaseLayerChanged && onBaseLayerChanged(e.name as BaseLayerChoice); @@ -612,57 +551,60 @@ 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 }, + }; } // remove marker's highlight class 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/plots/VolcanoPlot.tsx b/packages/libs/components/src/plots/VolcanoPlot.tsx index 2cf096f82c..45c463c2a8 100755 --- a/packages/libs/components/src/plots/VolcanoPlot.tsx +++ b/packages/libs/components/src/plots/VolcanoPlot.tsx @@ -46,6 +46,17 @@ export interface RawDataMinMaxValues { y: NumberRange; } +export interface StatisticsFloors { + /** The minimum allowed p value. Useful for protecting the plot against taking the log of pvalue=0. Points with true pvalue <= pValueFloor will get plotted at -log10(pValueFloor). + * Any points with pvalue <= the pValueFloor will show "P Value <= {pValueFloor}" in the tooltip. + */ + pValueFloor: number; + /** The minimum allowed adjusted p value. Ideally should be calculated in conjunction with the pValueFloor. Currently used + * only to update the tooltip with this information, but later will be used to control the y axis location, similar to pValueFloor. + */ + adjustedPValueFloor?: number; +} + export interface VolcanoPlotProps { /** Data for the plot. An effectSizeLabel and an array of VolcanoPlotDataPoints */ data: VolcanoPlotData | undefined; @@ -80,8 +91,10 @@ export interface VolcanoPlotProps { showSpinner?: boolean; /** used to determine truncation logic */ rawDataMinMaxValues: RawDataMinMaxValues; - /** The maximum possible y axis value. Points with pValue=0 will get plotted at -log10(minPValueCap). */ - minPValueCap?: number; + /** Minimum (floor) values for p values and adjusted p values. Will set a cap on the maximum y axis values + * at which points can be plotted. This information will also be shown in tooltips for floored points. + */ + statisticsFloors?: StatisticsFloors; } const EmptyVolcanoPlotStats: VolcanoPlotStats = [ @@ -93,6 +106,10 @@ const EmptyVolcanoPlotData: VolcanoPlotData = { statistics: EmptyVolcanoPlotStats, }; +export const DefaultStatisticsFloors: StatisticsFloors = { + pValueFloor: 0, // Do not floor by default +}; + const MARGIN_DEFAULT = 50; interface TruncationRectangleProps { @@ -144,7 +161,7 @@ function VolcanoPlot(props: VolcanoPlotProps, ref: Ref) { truncationBarFill, showSpinner = false, rawDataMinMaxValues, - minPValueCap = 2e-300, + statisticsFloors = DefaultStatisticsFloors, } = props; // Use ref forwarding to enable screenshotting of the plot for thumbnail versions. @@ -167,34 +184,44 @@ function VolcanoPlot(props: VolcanoPlotProps, ref: Ref) { const { min: dataXMin, max: dataXMax } = rawDataMinMaxValues.x; const { min: dataYMin, max: dataYMax } = rawDataMinMaxValues.y; + // When dataYMin = 0, there must be a point with pvalue = 0, which means the plot will try in vain to draw a point at -log10(0) = Inf. + // When this issue arises, one should set a pValueFloor >= 0 so that the point with pValue = 0 + // will be able to be plotted sensibly. + if (dataYMin === 0 && statisticsFloors.pValueFloor <= 0) { + throw new Error( + 'Found data point with pValue = 0. Cannot create a volcano plot with a point at -log10(0) = Inf. Please use the statisticsFloors prop to set a pValueFloor >= 0.' + ); + } + // Set mins, maxes of axes in the plot using axis range props - // The y axis max should not be allowed to exceed -log10(minPValueCap) + // The y axis max should not be allowed to exceed -log10(pValueFloor) const xAxisMin = independentAxisRange?.min ?? 0; const xAxisMax = independentAxisRange?.max ?? 0; const yAxisMin = dependentAxisRange?.min ?? 0; const yAxisMax = dependentAxisRange?.max - ? dependentAxisRange.max > -Math.log10(minPValueCap) - ? -Math.log10(minPValueCap) + ? dependentAxisRange.max > -Math.log10(statisticsFloors.pValueFloor) + ? -Math.log10(statisticsFloors.pValueFloor) : dependentAxisRange.max : 0; // Do we need to show the special annotation for the case when the y axis is maxxed out? - const showCappedDataAnnotation = yAxisMax === -Math.log10(minPValueCap); + const showFlooredDataAnnotation = + yAxisMax === -Math.log10(statisticsFloors.pValueFloor); // Truncation indicators // If we have truncation indicators, we'll need to expand the plot range just a tad to // ensure the truncation bars appear. The folowing showTruncationBar variables will // be either 0 (do not show bar) or 1 (show bar). - // The y axis has special logic because it gets capped at -log10(minPValueCap) + // The y axis has special logic because it gets capped at -log10(pValueFloor) and we dont want to + // show the truncation bar if the annotation will be shown! const showXMinTruncationBar = Number(dataXMin < xAxisMin); const showXMaxTruncationBar = Number(dataXMax > xAxisMax); const xTruncationBarWidth = 0.02 * (xAxisMax - xAxisMin); const showYMinTruncationBar = Number(-Math.log10(dataYMax) < yAxisMin); - const showYMaxTruncationBar = - dataYMin === 0 - ? Number(-Math.log10(minPValueCap) > yAxisMax) - : Number(-Math.log10(dataYMin) > yAxisMax); + const showYMaxTruncationBar = Number( + -Math.log10(dataYMin) > yAxisMax && !showFlooredDataAnnotation + ); const yTruncationBarHeight = 0.02 * (yAxisMax - yAxisMin); /** @@ -211,12 +238,12 @@ function VolcanoPlot(props: VolcanoPlotProps, ref: Ref) { * Accessors - tell visx which value of the data point we should use and where. */ - // For the actual volcano plot data. Y axis points are capped at -Math.log10(minPValueCap) + // For the actual volcano plot data. Y axis points are capped at -Math.log10(pValueFloor) const dataAccessors = { xAccessor: (d: VolcanoPlotDataPoint) => Number(d?.effectSize), yAccessor: (d: VolcanoPlotDataPoint) => - d.pValue === '0' - ? -Math.log10(minPValueCap) + Number(d.pValue) <= statisticsFloors.pValueFloor + ? -Math.log10(statisticsFloors.pValueFloor) : -Math.log10(Number(d?.pValue)), }; @@ -266,7 +293,7 @@ function VolcanoPlot(props: VolcanoPlotProps, ref: Ref) { findNearestDatumOverride={findNearestDatumXY} margin={{ top: MARGIN_DEFAULT, - right: showCappedDataAnnotation ? 150 : MARGIN_DEFAULT, + right: showFlooredDataAnnotation ? 150 : MARGIN_DEFAULT, left: MARGIN_DEFAULT, bottom: MARGIN_DEFAULT, }} @@ -351,7 +378,7 @@ function VolcanoPlot(props: VolcanoPlotProps, ref: Ref) { )} {/* infinity y data annotation line */} - {showCappedDataAnnotation && ( + {showFlooredDataAnnotation && ( ) { {effectSizeLabel}: {data?.effectSize}
  • - P Value: {data?.pValue} + P Value:{' '} + {data?.pValue + ? Number(data.pValue) <= statisticsFloors.pValueFloor + ? '<= ' + statisticsFloors.pValueFloor + : data?.pValue + : 'n/a'}
  • Adjusted P Value:{' '} - {data?.adjustedPValue ?? 'n/a'} + {data?.adjustedPValue + ? statisticsFloors.adjustedPValueFloor && + Number(data.adjustedPValue) <= + statisticsFloors.adjustedPValueFloor && + Number(data.pValue) <= statisticsFloors.pValueFloor + ? '<= ' + statisticsFloors.adjustedPValueFloor + : data?.adjustedPValue + : 'n/a'}
  • 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 ff2de26ae4..b10575a164 100755 --- 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 774a1c36fd..f82fd12d12 100755 --- 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 a36f4f2ef6..3c1e44648e 100755 --- 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) => { @@ -301,11 +319,14 @@ export const Tiny: Story = (args) => { + onBoundsChanged={handleViewportChanged} + > + + ); }; @@ -387,12 +408,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/MarkerSelection.stories.tsx b/packages/libs/components/src/stories/MarkerSelection.stories.tsx index 62013057e2..7253d55ce0 100755 --- a/packages/libs/components/src/stories/MarkerSelection.stories.tsx +++ b/packages/libs/components/src/stories/MarkerSelection.stories.tsx @@ -23,6 +23,8 @@ import geohashAnimation from '../map/animation_functions/geohash'; import { markerDataProp } from '../map/BoundsDriftMarker'; +import SemanticMarkers from '../map/SemanticMarkers'; + export default { title: 'Map/Marker Selection', component: MapVEuMapSidebar, @@ -84,8 +86,6 @@ export const DonutMarkers: Story = (args) => { viewport={viewport} onViewportChanged={setViewport} onBoundsChanged={handleViewportChanged} - markers={markerElements} - animation={defaultAnimation} zoomLevelToGeohashLevel={leafletZoomLevelToGeohashLevel} // pass selectedMarkers and its setState selectedMarkers={selectedMarkers} @@ -95,7 +95,12 @@ export const DonutMarkers: Story = (args) => { // pass geohash level and setState prevGeohashLevel={prevGeohashLevel} setPrevGeohashLevel={setPrevGeohashLevel} - /> + > + + ); }; @@ -168,9 +173,7 @@ export const ChartMarkers: Story = (args) => { viewport={viewport} onViewportChanged={setViewport} onBoundsChanged={handleViewportChanged} - markers={markerElements} showGrid={true} - animation={defaultAnimation} zoomLevelToGeohashLevel={leafletZoomLevelToGeohashLevel} // pass selectedMarkers and its setState selectedMarkers={selectedMarkers} @@ -180,7 +183,12 @@ export const ChartMarkers: Story = (args) => { // pass geohash level and setState prevGeohashLevel={prevGeohashLevel} setPrevGeohashLevel={setPrevGeohashLevel} - /> + > + + ); }; 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/components/src/stories/plots/VolcanoPlot.stories.tsx b/packages/libs/components/src/stories/plots/VolcanoPlot.stories.tsx index 5010f1eb8c..3d87c8f3d6 100755 --- a/packages/libs/components/src/stories/plots/VolcanoPlot.stories.tsx +++ b/packages/libs/components/src/stories/plots/VolcanoPlot.stories.tsx @@ -1,4 +1,7 @@ -import VolcanoPlot, { VolcanoPlotProps } from '../../plots/VolcanoPlot'; +import VolcanoPlot, { + StatisticsFloors, + VolcanoPlotProps, +} from '../../plots/VolcanoPlot'; import { Story, Meta } from '@storybook/react/types-6-0'; import { range } from 'lodash'; import { getNormallyDistributedRandomNumber } from './ScatterPlot.storyData'; @@ -70,8 +73,8 @@ const dataSetVolcano: VEuPathDBVolcanoPlotData = { '0.001', '0.0001', '0.002', - '0', - '0', + '1e-90', + '0.00000002', ], adjustedPValue: ['0.01', '0.001', '0.01', '0.001', '0.02', '0', '0'], pointID: [ @@ -123,6 +126,7 @@ interface TemplateProps { comparisonLabels?: string[]; truncationBarFill?: string; showSpinner?: boolean; + statisticsFloors?: StatisticsFloors; } const Template: Story = (args) => { @@ -131,22 +135,27 @@ const Template: Story = (args) => { const volcanoDataPoints: VolcanoPlotData | undefined = { effectSizeLabel: args.data?.volcanoplot.effectSizeLabel ?? '', statistics: - args.data?.volcanoplot.statistics.effectSize.map((effectSize, index) => { - return { - effectSize: effectSize, - pValue: args.data?.volcanoplot.statistics.pValue[index], - adjustedPValue: - args.data?.volcanoplot.statistics.adjustedPValue[index], - pointID: args.data?.volcanoplot.statistics.pointID[index], + args.data?.volcanoplot.statistics.effectSize + .map((effectSize, index) => { + return { + effectSize: effectSize, + pValue: args.data?.volcanoplot.statistics.pValue[index], + adjustedPValue: + args.data?.volcanoplot.statistics.adjustedPValue[index], + pointID: args.data?.volcanoplot.statistics.pointID[index], + }; + }) + .map((d) => ({ + ...d, + pointIDs: d.pointID ? [d.pointID] : undefined, significanceColor: assignSignificanceColor( - Number(effectSize), - Number(args.data?.volcanoplot.statistics.pValue[index]), + Number(d.effectSize), + Number(d.pValue), args.significanceThreshold, args.effectSizeThreshold, significanceColors ), - }; - }) ?? [], + })) ?? [], }; const rawDataMinMaxValues = { @@ -191,6 +200,7 @@ const Template: Story = (args) => { truncationBarFill: args.truncationBarFill, showSpinner: args.showSpinner, rawDataMinMaxValues, + statisticsFloors: args.statisticsFloors, }; return ( @@ -268,3 +278,21 @@ Empty.args = { independentAxisRange: { min: -9, max: 9 }, dependentAxisRange: { min: -1, max: 9 }, }; + +// With a pvalue floor +const testStatisticsFloors: StatisticsFloors = { + pValueFloor: 0.006, + adjustedPValueFloor: 0.01, +}; +export const FlooredPValues = Template.bind({}); +FlooredPValues.args = { + data: dataSetVolcano, + markerBodyOpacity: 0.8, + effectSizeThreshold: 1, + significanceThreshold: 0.01, + comparisonLabels: ['up in group a', 'up in group b'], + independentAxisRange: { min: -9, max: 9 }, + dependentAxisRange: { min: -1, max: 9 }, + showSpinner: false, + statisticsFloors: testStatisticsFloors, +}; diff --git a/packages/libs/coreui/src/components/icons/Plus.tsx b/packages/libs/coreui/src/components/icons/Plus.tsx new file mode 100644 index 0000000000..3e901ba980 --- /dev/null +++ b/packages/libs/coreui/src/components/icons/Plus.tsx @@ -0,0 +1,19 @@ +import { SVGProps } from 'react'; +import { Add } from '@material-ui/icons'; + +const Plus = (props: SVGProps) => { + const { height = '1em', width = '1em' } = props; + return ( + + + + ); +}; + +export default Plus; diff --git a/packages/libs/coreui/src/components/icons/index.tsx b/packages/libs/coreui/src/components/icons/index.tsx index 86a67ed044..0d9d9a45b7 100644 --- a/packages/libs/coreui/src/components/icons/index.tsx +++ b/packages/libs/coreui/src/components/icons/index.tsx @@ -14,6 +14,7 @@ export { default as Filter } from './Filter'; export { default as Loading } from './Loading'; export { default as NoEdit } from './NoEdit'; export { default as Pencil } from './Pencil'; +export { default as Plus } from './Plus'; export { default as SampleDetailsDark } from './SampleDetailsDark'; export { default as SampleDetailsLight } from './SampleDetailsLight'; export { default as Share } from './Share'; diff --git a/packages/libs/eda/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/api/DataClient/types.ts b/packages/libs/eda/src/lib/core/api/DataClient/types.ts index f11ccf8a8c..f20f96deba 100755 --- a/packages/libs/eda/src/lib/core/api/DataClient/types.ts +++ b/packages/libs/eda/src/lib/core/api/DataClient/types.ts @@ -366,10 +366,16 @@ export const VolcanoPlotStatistics = array( }) ); -export const VolcanoPlotResponse = type({ - effectSizeLabel: string, - statistics: VolcanoPlotStatistics, -}); +export const VolcanoPlotResponse = intersection([ + type({ + effectSizeLabel: string, + statistics: VolcanoPlotStatistics, + }), + partial({ + pValueFloor: string, + adjustedPValueFloor: string, + }), +]); export interface VolcanoPlotRequestParams { studyId: string; @@ -831,7 +837,7 @@ export const BubbleOverlayConfig = type({ denominatorValues: array(string), }), type({ - overlayType: literal('continuous'), + overlayType: literal('continuous'), // TO DO for dates: probably redefine as 'number' | 'date' aggregator: keyof({ mean: null, median: null }), }), ]), @@ -889,7 +895,7 @@ export const StandaloneMapBubblesResponse = type({ intersection([ MapElement, type({ - overlayValue: number, + overlayValue: string, }), ]) ), @@ -914,8 +920,8 @@ export type StandaloneMapBubblesLegendResponse = TypeOf< typeof StandaloneMapBubblesLegendResponse >; export const StandaloneMapBubblesLegendResponse = type({ - minColorValue: number, - maxColorValue: number, + minColorValue: string, + maxColorValue: string, minSizeValue: number, maxSizeValue: number, }); diff --git a/packages/libs/eda/src/lib/core/components/computations/plugins/differentialabundance.tsx b/packages/libs/eda/src/lib/core/components/computations/plugins/differentialabundance.tsx index 313b8d0e95..a35148bc35 100644 --- a/packages/libs/eda/src/lib/core/components/computations/plugins/differentialabundance.tsx +++ b/packages/libs/eda/src/lib/core/components/computations/plugins/differentialabundance.tsx @@ -73,6 +73,7 @@ export const DifferentialAbundanceConfig = t.type({ collectionVariable: VariableCollectionDescriptor, comparator: Comparator, differentialAbundanceMethod: t.string, + pValueFloor: t.string, }); // Check to ensure the entirety of the configuration is filled out before enabling the @@ -188,6 +189,11 @@ export function DifferentialAbundanceConfiguration( configuration.differentialAbundanceMethod = DIFFERENTIAL_ABUNDANCE_METHODS[0]; + // Set the pValueFloor here. May change for other apps. + // Note this is intentionally different than the default pValueFloor used in the Volcano component. By default + // that component does not floor the data, but we know we want the diff abund computation to use a floor. + if (configuration) configuration.pValueFloor = '1e-200'; + // Include known collection variables in this array. const collections = useCollectionVariables(studyMetadata.rootEntity); if (collections.length === 0) 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/components/visualizations/implementations/VolcanoPlotVisualization.tsx b/packages/libs/eda/src/lib/core/components/visualizations/implementations/VolcanoPlotVisualization.tsx index 3200e9d4e8..e9799de0b6 100755 --- a/packages/libs/eda/src/lib/core/components/visualizations/implementations/VolcanoPlotVisualization.tsx +++ b/packages/libs/eda/src/lib/core/components/visualizations/implementations/VolcanoPlotVisualization.tsx @@ -3,6 +3,8 @@ import VolcanoPlot, { VolcanoPlotProps, assignSignificanceColor, RawDataMinMaxValues, + StatisticsFloors, + DefaultStatisticsFloors, } from '@veupathdb/components/lib/plots/VolcanoPlot'; import * as t from 'io-ts'; @@ -415,6 +417,15 @@ function VolcanoPlotViz(props: VisualizationProps) { ] : []; + // Record any floors for the p value and adjusted p value sent to us from the backend. + const statisticsFloors: StatisticsFloors = + data.value && data.value.pValueFloor + ? { + pValueFloor: Number(data.value.pValueFloor), + adjustedPValueFloor: Number(data.value.adjustedPValueFloor), + } + : DefaultStatisticsFloors; + const volcanoPlotProps: VolcanoPlotProps = { /** * VolcanoPlot defines an EmptyVolcanoPlotData variable that will be assigned when data is undefined. @@ -437,6 +448,7 @@ function VolcanoPlotViz(props: VisualizationProps) { * confusing behavior where selecting group values displays on the empty viz placeholder. */ comparisonLabels: data.value ? comparisonLabels : [], + statisticsFloors, showSpinner: data.pending, truncationBarFill: yellow[300], independentAxisRange, 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 5aca96df75..28cf340aa0 100755 --- a/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx @@ -1,19 +1,14 @@ -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, - useAnalysis, useAnalysisClient, useDataClient, useDownloadClient, @@ -31,95 +26,63 @@ import { DocumentationContainer } from '../../core/components/docs/Documentation import { CheckIcon, Download, + Plus, FilledButton, Filter as FilterIcon, + FloatingButton, H5, Table, } 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'; import NameAnalysis from '../../workspace/sharing/NameAnalysis'; import NotesTab from '../../workspace/NotesTab'; import ConfirmShareAnalysis from '../../workspace/sharing/ConfirmShareAnalysis'; -import { useHistory } from 'react-router'; +import { useHistory, useRouteMatch } from 'react-router'; import { uniq } from 'lodash'; +import Path from 'path'; import DownloadTab from '../../workspace/DownloadTab'; import { RecordController } from '@veupathdb/wdk-client/lib/Controllers'; -import { - 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'; import { markerDataProp } from '@veupathdb/components/lib/map/BoundsDriftMarker'; @@ -132,81 +95,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; @@ -215,8 +112,7 @@ interface Props { } export function MapAnalysis(props: Props) { - const analysisState = useAnalysis(props.analysisId, 'pass'); - const appStateAndSetters = useAppState('@@mapApp@@', analysisState); + const appStateAndSetters = useAppState('@@mapApp@@', props.analysisId); const geoConfigs = useGeoConfig(useStudyEntities()); if (geoConfigs == null || geoConfigs.length === 0) @@ -233,7 +129,6 @@ export function MapAnalysis(props: Props) { ); @@ -254,13 +149,12 @@ function MapAnalysisImpl(props: ImplProps) { analysisState, analysisId, setViewport, - setActiveVisualizationId, setBoundsZoomLevel, setSubsetVariableAndEntity, // sharingUrl, - setIsSubsetPanelOpen = () => {}, - setActiveMarkerConfigurationType, + setIsSidePanelExpanded, setMarkerConfigurations, + setActiveMarkerConfigurationType, geoConfigs, setTimeSliderConfig, } = props; @@ -291,17 +185,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( @@ -389,275 +272,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'; - } - })(); - - // a series of useStates for multiple markers selection - // make an array of objects state to list highlighted markers - const [selectedMarkers, setSelectedMarkers] = useState([]); - - console.log('selectedMarkers =', selectedMarkers); - - // check if map panning occured for multiple markers selection - const [isPanning, setIsPanning] = useState(false); - - // set initial prevGeohashLevel state for multiple markers selection - const [prevGeohashLevel, setPrevGeohashLevel] = useState( - geoConfig?.zoomLevelToAggregationLevel(appState.viewport.zoom) - ); - - 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; @@ -687,33 +301,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 = () => { @@ -742,8 +336,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" @@ -780,510 +373,421 @@ function MapAnalysisImpl(props: ImplProps) { const filteredEntities = uniq(filters?.map((f) => f.entityId)); - 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 [isAnalysisNameDialogOpen, setIsAnalysisNameDialogOpen] = + useState(false); + const { url: urlRouteMatch } = useRouteMatch(); + const redirectURL = studyId + ? urlRouteMatch.endsWith(studyId) + ? `/workspace/${urlRouteMatch}/new` + : Path.resolve(urlRouteMatch, '../new') + : null; + const redirectToNewAnalysis = useCallback(() => { + if (redirectURL) history.push(redirectURL); + }, [history, redirectURL]); + + // make an array of objects state to list highlighted markers + const [selectedMarkers, setSelectedMarkers] = useState([]); + + console.log('selectedMarkers =', selectedMarkers); - const markerConfigurationObjects: MarkerConfigurationOption[] = [ + // set initial prevGeohashLevel state + const [prevGeohashLevel, setPrevGeohashLevel] = useState( + geoConfig?.zoomLevelToAggregationLevel(appState.viewport.zoom) + ); + + 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 ( -
    - , + 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, + // selectedMarkers and its state function + selectedMarkers, + setSelectedMarkers, + }; return ( @@ -1299,18 +803,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 */} @@ -1346,36 +849,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} @@ -1384,124 +879,26 @@ function MapAnalysisImpl(props: ImplProps) { } // pass defaultViewport & isStandAloneMap props for custom zoom control defaultViewport={defaultViewport} - // pass selectedMarkers and its setState for multiple markers selection + // multiple markers selection + // pass selectedMarkers and its setState selectedMarkers={selectedMarkers} setSelectedMarkers={setSelectedMarkers} - // pass setIsPanning to check if map panning occurred for multiple markers selection - setIsPanning={setIsPanning} - // pass geohash level and setState for multiple markers selection + // pass geohash level and setState prevGeohashLevel={prevGeohashLevel} setPrevGeohashLevel={setPrevGeohashLevel} - /> - - - {markerType !== 'bubble' ? ( - -
    - -
    -
    - ) : ( - <> - -
    - -
    -
    - -
    - 'white'), - }} - /> -
    -
    - - )} - - {/* )} */} - {/* -
    - {safeHtml(studyRecord.displayName)} ({totalEntityCount}) -
    -
    - Showing {entity?.displayName} variable {variable?.displayName} -
    -
    - setIsSubsetPanelOpen(true)} - /> -
    - */} - - {activeSideMenuId && isMapTypeSubMenuItemSelected() && ( - - )} +
    + - {error && ( - -
    {String(error)}
    -
    + {activeMapTypePlugin?.MapOverlayComponent && ( + )} @@ -1511,175 +908,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 84885d2f0a..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'; @@ -13,7 +12,8 @@ import { aggregationHelp, AggregationInputs, } from '../../../core/components/visualizations/implementations/LineplotVisualization'; -import { DataElementConstraint } from '../../../core/types/visualization'; +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 af57d76ede..32003b8408 100644 --- a/packages/libs/eda/src/lib/map/analysis/appState.ts +++ b/packages/libs/eda/src/lib/map/analysis/appState.ts @@ -2,14 +2,11 @@ import { getOrElseW } from 'fp-ts/lib/Either'; import { pipe } from 'fp-ts/lib/function'; import * as t from 'io-ts'; import { isEqual } from 'lodash'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { - AnalysisState, - useGetDefaultVariableDescriptor, - useStudyMetadata, -} from '../../core'; +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 }); @@ -48,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'), @@ -84,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({ @@ -116,13 +116,17 @@ 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); + + // make some backwards compatability updates to the appstate retrieved from the back end + const [appStateChecked, setAppStateChecked] = useState(false); + + useEffect(() => { + // flip bit when analysis id changes + setAppStateChecked(false); + }, [analysisId]); -export function useAppState(uiStateKey: string, analysisState: AnalysisState) { const { analysis, setVariableUISettings } = analysisState; const appState = pipe( AppState.decode( @@ -143,6 +147,7 @@ export function useAppState(uiStateKey: string, analysisState: AnalysisState) { viewport: defaultViewport, mouseMode: 'default', activeMarkerConfigurationType: 'pie', + isSidePanelExpanded: true, timeSliderConfig: { variable: defaultTimeVariable, active: true, @@ -177,11 +182,8 @@ export function useAppState(uiStateKey: string, analysisState: AnalysisState) { [defaultVariable, defaultTimeVariable] ); - // make some backwards compatability updates to the appstate retrieved from the back end - const appStateCheckedRef = useRef(false); - useEffect(() => { - if (appStateCheckedRef.current) return; + if (appStateChecked) return; if (analysis) { if (!appState) { setVariableUISettings((prev) => ({ @@ -216,9 +218,16 @@ export function useAppState(uiStateKey: string, analysisState: AnalysisState) { })); } } - appStateCheckedRef.current = true; + setAppStateChecked(true); } - }, [analysis, appState, setVariableUISettings, uiStateKey, defaultAppState]); + }, [ + analysis, + appState, + setVariableUISettings, + uiStateKey, + defaultAppState, + appStateChecked, + ]); function useSetter(key: T) { return useCallback( @@ -243,13 +252,13 @@ export function useAppState(uiStateKey: string, analysisState: AnalysisState) { return { appState, + analysisState, setActiveMarkerConfigurationType: useSetter( '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 2c18312d45..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, @@ -117,7 +117,13 @@ interface MapMarkers { // vocabulary: string[] | undefined; /** data for creating a legend */ legendItems: LegendItemsProps[]; - bubbleLegendData?: StandaloneMapBubblesLegendResponse; + bubbleLegendData?: // TO DO for dates: use StandaloneMapBubblesLegendResponse instead; + { + minColorValue: number; + maxColorValue: number; + minSizeValue: number; + maxSizeValue: number; + }; bubbleValueToDiameterMapper?: (value: number) => number; bubbleValueToColorMapper?: (value: number) => string; /** is the request pending? */ @@ -200,12 +206,7 @@ export function useStandaloneMapMarkers( | StandaloneMapMarkersResponse | StandaloneMapBubblesResponse; vocabulary: string[] | undefined; - bubbleLegendData?: { - minColorValue: number; - maxColorValue: number; - minSizeValue: number; - maxSizeValue: number; - }; + bubbleLegendData?: StandaloneMapBubblesLegendResponse; } | undefined >( @@ -414,7 +415,18 @@ export function useStandaloneMapMarkers( ) as NumberRange; const vocabulary = rawPromise.value?.vocabulary; - const bubbleLegendData = rawPromise.value?.bubbleLegendData; + + // temporarily convert potentially date-strings to numbers + // but don't worry - we are also temporarily disabling date variables from bubble mode + const temp = rawPromise.value?.bubbleLegendData; + const bubbleLegendData = temp + ? { + minColorValue: Number(temp.minColorValue), + maxColorValue: Number(temp.maxColorValue), + minSizeValue: temp.minSizeValue, + maxSizeValue: temp.maxSizeValue, + } + : undefined; const adjustedSizeData = useMemo( () => @@ -727,13 +739,13 @@ const processRawBubblesData = ( const bubbleData = { value: entityCount, diameter: bubbleValueToDiameterMapper?.(entityCount) ?? 0, - colorValue: overlayValue, + colorValue: Number(overlayValue), // TO DO for dates: handle dates! colorLabel: aggregationConfig ? aggregationConfig.overlayType === 'continuous' ? _.capitalize(aggregationConfig.aggregator) : 'Proportion' : undefined, - color: bubbleValueToColorMapper?.(overlayValue), + color: bubbleValueToColorMapper?.(Number(overlayValue)), }; return { diff --git a/packages/libs/eda/src/lib/map/analysis/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..b153fcb0f5 --- /dev/null +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/BarMarkerMapType.tsx @@ -0,0 +1,680 @@ +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) { + // selectedMarkers and its state function + const selectedMarkers = props.selectedMarkers; + const setSelectedMarkers = props.setSelectedMarkers; + + 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 ; + + // pass selectedMarkers and its state function + 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..78f63c6211 --- /dev/null +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/BubbleMarkerMapType.tsx @@ -0,0 +1,695 @@ +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) { + // selectedMarkers and its state function + const selectedMarkers = props.selectedMarkers; + const setSelectedMarkers = props.setSelectedMarkers; + + const { studyId, filters, appState, configuration, geoConfigs } = props; + const markersData = useMarkerData({ + boundsZoomLevel: appState.boundsZoomLevel, + configuration: configuration as BubbleMarkerConfiguration, + geoConfigs, + studyId, + filters, + }); + if (markersData.error) + return ; + + // pass selectedMarkers and its state function + 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..efdff446a5 --- /dev/null +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/DonutMarkerMapType.tsx @@ -0,0 +1,598 @@ +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) { + // selectedMarkers and its state function + const selectedMarkers = props.selectedMarkers; + const setSelectedMarkers = props.setSelectedMarkers; + + 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 ; + + // pass selectedMarkers and its state function + 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..fb521e2db5 --- /dev/null +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/types.ts @@ -0,0 +1,73 @@ +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'; +import { markerDataProp } from '@veupathdb/components/lib/map/BoundsDriftMarker'; + +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; + // selectedMarkers and its state function + selectedMarkers?: markerDataProp[]; + setSelectedMarkers?: React.Dispatch>; +} + +/** + * 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 b60f795c2b..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, @@ -9,9 +9,10 @@ import { OverlayConfig, StudyEntity, Variable, + 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. @@ -21,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; @@ -28,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, @@ -45,11 +91,7 @@ export async function getDefaultOverlayConfig( overlayEntity, dataClient, subsettingClient, - markerType, binningMethod = 'equalInterval', - aggregator = 'mean', - numeratorValues, - denominatorValues, } = props; if (overlayVariable != null && overlayEntity != null) { @@ -60,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', - 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/libs/eda/src/lib/workspace/AnalysisNameDialog.tsx b/packages/libs/eda/src/lib/workspace/AnalysisNameDialog.tsx index dc8cb5581a..98aa809a64 100644 --- a/packages/libs/eda/src/lib/workspace/AnalysisNameDialog.tsx +++ b/packages/libs/eda/src/lib/workspace/AnalysisNameDialog.tsx @@ -22,7 +22,7 @@ interface AnalysisNameDialogProps { redirectToNewAnalysis: () => void; } -export function AnalysisNameDialog({ +export default function AnalysisNameDialog({ isOpen, setIsOpen, initialAnalysisName, @@ -30,11 +30,17 @@ export function AnalysisNameDialog({ redirectToNewAnalysis, }: AnalysisNameDialogProps) { const [inputText, setInputText] = useState(initialAnalysisName); + const [continueText, setContinueText] = + useState<'Continue' | 'Rename and continue'>('Continue'); const [nameIsValid, setNameIsValid] = useState(true); + const [disableButtons, setDisableButtons] = useState(false); const handleTextChange = (event: React.ChangeEvent) => { const newText = event.target.value; setInputText(newText); + setContinueText( + newText === initialAnalysisName ? 'Continue' : 'Rename and continue' + ); // Currently the only requirement is no empty name newText.length > 0 ? setNameIsValid(true) : setNameIsValid(false); }; @@ -44,11 +50,12 @@ export function AnalysisNameDialog({ setInputText(initialAnalysisName); }; - const handleContinue = async () => { - // TypeScript says this `await` has no effect, but it seems to be required - // for this function to finish before the page redirect - await setAnalysisName(inputText); - redirectToNewAnalysis(); + const handleContinue = () => { + setDisableButtons(true); + setAnalysisName(inputText); + // The timeout for saving an analysis is 1 second, + // so wait a bit longer than that + setTimeout(redirectToNewAnalysis, 1200); }; return ( @@ -74,20 +81,23 @@ export function AnalysisNameDialog({ error={!nameIsValid} // Currently the only requirement is no empty name helperText={nameIsValid ? ' ' : 'Name must not be blank'} + disabled={disableButtons} /> - diff --git a/packages/libs/eda/src/lib/workspace/EDAWorkspaceHeading.tsx b/packages/libs/eda/src/lib/workspace/EDAWorkspaceHeading.tsx index 1b5e191a51..11daf56faa 100644 --- a/packages/libs/eda/src/lib/workspace/EDAWorkspaceHeading.tsx +++ b/packages/libs/eda/src/lib/workspace/EDAWorkspaceHeading.tsx @@ -6,7 +6,7 @@ import Path from 'path'; import { H3, Table, FloatingButton } from '@veupathdb/coreui'; import { safeHtml } from '@veupathdb/wdk-client/lib/Utils/ComponentUtils'; -import { AnalysisNameDialog } from './AnalysisNameDialog'; +import AnalysisNameDialog from './AnalysisNameDialog'; import AddIcon from '@material-ui/icons/Add'; // Hooks diff --git a/packages/libs/wdk-client/src/Service/Decoders/QuestionDecoders.ts b/packages/libs/wdk-client/src/Service/Decoders/QuestionDecoders.ts new file mode 100644 index 0000000000..373faaea95 --- /dev/null +++ b/packages/libs/wdk-client/src/Service/Decoders/QuestionDecoders.ts @@ -0,0 +1,389 @@ +import { + Question, + QuestionWithParameters, + TreeBoxVocabNode, + Parameter, + ParameterGroup, + SummaryViewPluginField, + DatasetParam, + TimestampParam, + StringParam, + FilterParamNew, + NumberParam, + NumberRangeParam, + DateParam, + DateRangeParam, + AnswerParam, + EnumParam, + CheckBoxEnumParam, + SinglePickCheckBoxEnumParam, + MultiPickCheckBoxEnumParam, + SinglePickSelectEnumParam, + MultiPickSelectEnumParam, + SelectEnumParam, + SinglePickTypeAheadEnumParam, + MultiPickTypeAheadEnumParam, + TypeAheadEnumParam, + SinglePickTreeBoxEnumParam, + MultiPickTreeBoxEnumParam, + TreeBoxEnumParam, +} from '../../Utils/WdkModel'; +import * as Decode from '../../Utils/Json'; +import { attributeFieldDecoder } from '../Decoders/RecordClassDecoders'; + +const summaryViewPluginFieldDecoder: Decode.Decoder = + Decode.combine( + Decode.field('name', Decode.string), + Decode.field('displayName', Decode.string), + Decode.field('description', Decode.string) + ); + +const questionFilterDecoder = Decode.combine( + Decode.field('name', Decode.string), + Decode.field('displayName', Decode.optional(Decode.string)), + Decode.field('description', Decode.optional(Decode.string)), + Decode.field('isViewOnly', Decode.boolean) +); + +const paramSharedDecoder = + /* Common properties */ + Decode.combine( + Decode.combine( + Decode.field('name', Decode.string), + Decode.field('displayName', Decode.string), + Decode.field( + 'properties', + Decode.optional(Decode.objectOf(Decode.arrayOf(Decode.string))) + ), + Decode.field('help', Decode.string), + Decode.field('isVisible', Decode.boolean), + Decode.field('group', Decode.string), + Decode.field('isReadOnly', Decode.boolean), + Decode.field('initialDisplayValue', Decode.optional(Decode.string)), + Decode.field('dependentParams', Decode.arrayOf(Decode.string)) + ), + Decode.combine( + Decode.field('allowEmptyValue', Decode.boolean), + Decode.field('visibleHelp', Decode.optional(Decode.string)) + ) + ); + +/* DatasetParam */ +const datasetParamDecoder: Decode.Decoder = Decode.combine( + paramSharedDecoder, + Decode.field('type', Decode.constant('input-dataset')), + Decode.field('defaultIdList', Decode.optional(Decode.string)), + Decode.field( + 'parsers', + Decode.arrayOf( + Decode.combine( + Decode.field('name', Decode.string), + Decode.field('displayName', Decode.string), + Decode.field('description', Decode.string) + ) + ) + ) +); + +/* TimestampParam */ +const timestampParamDecoder: Decode.Decoder = Decode.combine( + paramSharedDecoder, + Decode.field('type', Decode.constant('timestamp')) +); + +/* StringParam */ +const stringParamDecoder: Decode.Decoder = Decode.combine( + paramSharedDecoder, + Decode.field('type', Decode.constant('string')), + Decode.field('length', Decode.number) +); + +/* FilterParamNew */ +const filterParamDecoder: Decode.Decoder = Decode.combine( + paramSharedDecoder, + Decode.field('type', Decode.constant('filter')), + Decode.field('filterDataTypeDisplayName', Decode.optional(Decode.string)), + Decode.field('minSelectedCount', Decode.number), + Decode.field('hideEmptyOntologyNodes', Decode.optional(Decode.boolean)), + Decode.field('values', Decode.objectOf(Decode.arrayOf(Decode.string))), + Decode.field( + 'ontology', + Decode.arrayOf( + Decode.combine( + Decode.field('term', Decode.string), + Decode.field('parent', Decode.optional(Decode.string)), + Decode.field('display', Decode.string), + Decode.field('description', Decode.optional(Decode.string)), + Decode.field( + 'type', + Decode.optional( + Decode.oneOf( + Decode.constant('date'), + Decode.constant('string'), + Decode.constant('number'), + Decode.constant('multiFilter') + ) + ) + ), + // Decode.field('units', Decode.string), + Decode.field('precision', Decode.number), + Decode.field('isRange', Decode.boolean) + ) + ) + ) +); + +/* NumberParam */ +const numberParamDecoder: Decode.Decoder = Decode.combine( + paramSharedDecoder, + Decode.field('type', Decode.constant('number')), + Decode.field('min', Decode.number), + Decode.field('max', Decode.number), + Decode.field('increment', Decode.number) +); + +/* NumberRangeParam */ +const numberRangeParamDecoder: Decode.Decoder = + Decode.combine( + paramSharedDecoder, + Decode.field('type', Decode.constant('number-range')), + Decode.field('min', Decode.number), + Decode.field('max', Decode.number), + Decode.field('increment', Decode.number) + ); + +/* DateParam */ +const dateParamDecoder: Decode.Decoder = Decode.combine( + paramSharedDecoder, + Decode.field('type', Decode.constant('date')), + Decode.field('minDate', Decode.string), + Decode.field('maxDate', Decode.string) +); + +/* DateRangeParam */ +const dateRangeParamDecoder: Decode.Decoder = Decode.combine( + paramSharedDecoder, + Decode.field('type', Decode.constant('date-range')), + Decode.field('minDate', Decode.string), + Decode.field('maxDate', Decode.string) +); + +/* AnswerParam */ +const answerParamDecoder: Decode.Decoder = Decode.combine( + paramSharedDecoder, + Decode.field('type', Decode.constant('input-step')) +); + +/* Base decoders for enum types */ +const enumParamSharedDecoder = Decode.combine( + paramSharedDecoder, + Decode.field('maxSelectedCount', Decode.number), + Decode.field('minSelectedCount', Decode.number) +); + +const singlePickEnumParamDecoder = Decode.field( + 'type', + Decode.constant('single-pick-vocabulary') +); +const multiPickEnumParamDecoder = Decode.field( + 'type', + Decode.constant('multi-pick-vocabulary') +); + +const standardVocabularyEnumParamDecoder = Decode.combine( + enumParamSharedDecoder, + Decode.field( + 'vocabulary', + Decode.arrayOf(Decode.tuple(Decode.string, Decode.string, Decode.nullValue)) + ) +); + +/* CheckBoxEnumParam */ +const checkBoxEnumParamBaseDecoder = Decode.combine( + standardVocabularyEnumParamDecoder, + Decode.field('displayType', Decode.constant('checkBox')) +); +export const singlePickCheckBoxEnumParamDecoder: Decode.Decoder = + Decode.combine(checkBoxEnumParamBaseDecoder, singlePickEnumParamDecoder); +export const multiPickCheckBoxEnumParamDecoder: Decode.Decoder = + Decode.combine(checkBoxEnumParamBaseDecoder, multiPickEnumParamDecoder); +export const checkBoxEnumParamDecoder: Decode.Decoder = + Decode.oneOf( + singlePickCheckBoxEnumParamDecoder, + multiPickCheckBoxEnumParamDecoder + ); + +/* SelectEnumParam */ +const selectEnumParamBaseDecoder = Decode.combine( + standardVocabularyEnumParamDecoder, + Decode.field('displayType', Decode.constant('select')) +); +export const singlePickSelectEnumParamDecoder: Decode.Decoder = + Decode.combine(selectEnumParamBaseDecoder, singlePickEnumParamDecoder); +export const multiPickSelectEnumParamDecoder: Decode.Decoder = + Decode.combine(selectEnumParamBaseDecoder, multiPickEnumParamDecoder); +export const selectEnumParamDecoder: Decode.Decoder = + Decode.oneOf( + singlePickSelectEnumParamDecoder, + multiPickSelectEnumParamDecoder + ); + +/* TypeAheadEnumParam */ +const typeAheadEnumParamBaseDecoder = Decode.combine( + standardVocabularyEnumParamDecoder, + Decode.field('displayType', Decode.constant('typeAhead')) +); +export const singlePickTypeAheadEnumParamDecoder: Decode.Decoder = + Decode.combine(typeAheadEnumParamBaseDecoder, singlePickEnumParamDecoder); +export const multiPickTypeAheadEnumParamDecoder: Decode.Decoder = + Decode.combine(typeAheadEnumParamBaseDecoder, multiPickEnumParamDecoder); +export const typeAheadEnumParamDecoder: Decode.Decoder = + Decode.oneOf( + singlePickTypeAheadEnumParamDecoder, + multiPickTypeAheadEnumParamDecoder + ); + +/* TreeboxEnumParam */ +const treeBoxVocabDecoder: Decode.Decoder = Decode.combine( + Decode.field( + 'data', + Decode.combine( + Decode.field('term', Decode.string), + Decode.field('display', Decode.string) + ) + ), + Decode.field( + 'children', + Decode.lazy(() => Decode.arrayOf(treeBoxVocabDecoder)) + ) +); +const treeBoxEnumParamBaseDecoder = Decode.combine( + enumParamSharedDecoder, + Decode.field('displayType', Decode.constant('treeBox')), + Decode.field('countOnlyLeaves', Decode.boolean), + Decode.field('depthExpanded', Decode.number), + Decode.field('vocabulary', treeBoxVocabDecoder) +); +export const singlePickTreeBoxEnumParamDecoder: Decode.Decoder = + Decode.combine(treeBoxEnumParamBaseDecoder, singlePickEnumParamDecoder); +export const multiPickTreeBoxEnumParamDecoder: Decode.Decoder = + Decode.combine(treeBoxEnumParamBaseDecoder, multiPickEnumParamDecoder); +export const treeBoxEnumParamDecoder: Decode.Decoder = + Decode.oneOf( + singlePickTreeBoxEnumParamDecoder, + multiPickTreeBoxEnumParamDecoder + ); + +/* EnumParam */ +const enumParamDecoder: Decode.Decoder = Decode.oneOf( + checkBoxEnumParamDecoder, + selectEnumParamDecoder, + typeAheadEnumParamDecoder, + treeBoxEnumParamDecoder +); + +const parameterDecoder: Decode.Decoder = Decode.oneOf( + datasetParamDecoder, + timestampParamDecoder, + stringParamDecoder, + filterParamDecoder, + enumParamDecoder, + numberParamDecoder, + numberRangeParamDecoder, + dateParamDecoder, + dateRangeParamDecoder, + answerParamDecoder +); + +export const parametersDecoder: Decode.Decoder = + Decode.arrayOf(parameterDecoder); + +export const paramGroupDecoder: Decode.Decoder = Decode.combine( + Decode.field('description', Decode.string), + Decode.field('displayName', Decode.string), + Decode.field('displayType', Decode.string), + Decode.field('isVisible', Decode.boolean), + Decode.field('name', Decode.string), + Decode.field('parameters', Decode.arrayOf(Decode.string)) +); + +const questionSharedDecoder = Decode.combine( + Decode.combine( + Decode.field('fullName', Decode.string), + Decode.field('displayName', Decode.string), + Decode.field( + 'properties', + Decode.optional(Decode.objectOf(Decode.arrayOf(Decode.string))) + ), + Decode.field('summary', Decode.optional(Decode.string)), + Decode.field('description', Decode.optional(Decode.string)), + Decode.field('shortDisplayName', Decode.string), + Decode.field('outputRecordClassName', Decode.string), + Decode.field('help', Decode.optional(Decode.string)), + Decode.field('newBuild', Decode.optional(Decode.string)), + Decode.field('reviseBuild', Decode.optional(Decode.string)) + ), + Decode.combine( + Decode.field('urlSegment', Decode.string), + Decode.field('groups', Decode.arrayOf(paramGroupDecoder)), + Decode.field('defaultAttributes', Decode.arrayOf(Decode.string)), + Decode.field('paramNames', Decode.arrayOf(Decode.string)) + ), + Decode.field( + 'defaultSorting', + Decode.arrayOf( + Decode.combine( + Decode.field('attributeName', Decode.string), + Decode.field( + 'direction', + Decode.oneOf(Decode.constant('ASC'), Decode.constant('DESC')) + ) + ) + ) + ), + Decode.field('dynamicAttributes', Decode.arrayOf(attributeFieldDecoder)), + Decode.combine( + Decode.field('defaultSummaryView', Decode.string), + Decode.field('noSummaryOnSingleRecord', Decode.boolean), + Decode.field( + 'summaryViewPlugins', + Decode.arrayOf(summaryViewPluginFieldDecoder) + ) + ), + Decode.field('filters', Decode.arrayOf(questionFilterDecoder)), + Decode.field( + 'allowedPrimaryInputRecordClassNames', + Decode.optional(Decode.arrayOf(Decode.string)) + ), + Decode.field( + 'allowedSecondaryInputRecordClassNames', + Decode.optional(Decode.arrayOf(Decode.string)) + ), + Decode.field('isAnalyzable', Decode.boolean), + Decode.field('isCacheable', Decode.boolean) +); + +export type QuestionWithValidatedParameters = { + searchData: QuestionWithParameters; + validation: any; // FIXME: use actual type here +}; + +export const questionWithParametersDecoder: Decode.Decoder = + Decode.combine( + Decode.field( + 'searchData', + Decode.combine( + questionSharedDecoder, + Decode.field('parameters', parametersDecoder) + ) + ), + Decode.field('validation', Decode.ok) + ); + +const questionDecoder: Decode.Decoder = Decode.combine( + questionSharedDecoder, + Decode.field('parameters', Decode.arrayOf(Decode.string)) +); + +const questionsDecoder: Decode.Decoder = + Decode.arrayOf(questionDecoder); diff --git a/packages/libs/wdk-client/src/Service/Mixins/SearchesService.ts b/packages/libs/wdk-client/src/Service/Mixins/SearchesService.ts index 067e32d9e8..d768d73449 100644 --- a/packages/libs/wdk-client/src/Service/Mixins/SearchesService.ts +++ b/packages/libs/wdk-client/src/Service/Mixins/SearchesService.ts @@ -2,426 +2,13 @@ import { ServiceBase } from '../../Service/ServiceBase'; import { ParameterValue, ParameterValues, - Question, QuestionWithParameters, - TreeBoxVocabNode, - Parameter, - ParameterGroup, - AttributeField, - Reporter, - SummaryViewPluginField, - DatasetParam, - TimestampParam, - StringParam, - FilterParamNew, - NumberParam, - NumberRangeParam, - DateParam, - DateRangeParam, - AnswerParam, - EnumParam, - CheckBoxEnumParam, - SinglePickCheckBoxEnumParam, - MultiPickCheckBoxEnumParam, - SinglePickSelectEnumParam, - MultiPickSelectEnumParam, - SelectEnumParam, - SinglePickTypeAheadEnumParam, - MultiPickTypeAheadEnumParam, - TypeAheadEnumParam, - SinglePickTreeBoxEnumParam, - MultiPickTreeBoxEnumParam, - TreeBoxEnumParam, } from '../../Utils/WdkModel'; import { OntologyTermSummary } from '../../Components/AttributeFilter/Types'; -import { ServiceError } from '../../Service/ServiceError'; -import * as Decode from '../../Utils/Json'; - -const reporterDecoder: Decode.Decoder = Decode.combine( - Decode.field('name', Decode.string), - Decode.field('type', Decode.string), - Decode.field('displayName', Decode.string), - Decode.field('description', Decode.string), - Decode.field('isInReport', Decode.boolean), - // TODO Replace with list of known scopes - Decode.field('scopes', Decode.arrayOf(Decode.string)) -); - -const attributeFieldDecoder: Decode.Decoder = Decode.combine( - Decode.field('name', Decode.string), - Decode.field('displayName', Decode.string), - Decode.field('formats', Decode.arrayOf(reporterDecoder)), - Decode.field( - 'properties', - Decode.optional(Decode.objectOf(Decode.arrayOf(Decode.string))) - ), - Decode.field('help', Decode.optional(Decode.string)), - Decode.field('align', Decode.optional(Decode.string)), - Decode.field('type', Decode.optional(Decode.string)), - Decode.field('truncateTo', Decode.number), - Decode.combine( - Decode.field('isSortable', Decode.boolean), - Decode.field('isRemovable', Decode.boolean), - Decode.field('isDisplayable', Decode.boolean) - ) -); - -const summaryViewPluginFieldDecoder: Decode.Decoder = - Decode.combine( - Decode.field('name', Decode.string), - Decode.field('displayName', Decode.string), - Decode.field('description', Decode.string) - ); - -const questionFilterDecoder = Decode.combine( - Decode.field('name', Decode.string), - Decode.field('displayName', Decode.optional(Decode.string)), - Decode.field('description', Decode.optional(Decode.string)), - Decode.field('isViewOnly', Decode.boolean) -); - -const paramSharedDecoder = - /* Common properties */ - Decode.combine( - Decode.combine( - Decode.field('name', Decode.string), - Decode.field('displayName', Decode.string), - Decode.field( - 'properties', - Decode.optional(Decode.objectOf(Decode.arrayOf(Decode.string))) - ), - Decode.field('help', Decode.string), - Decode.field('isVisible', Decode.boolean), - Decode.field('group', Decode.string), - Decode.field('isReadOnly', Decode.boolean), - Decode.field('initialDisplayValue', Decode.optional(Decode.string)), - Decode.field('dependentParams', Decode.arrayOf(Decode.string)) - ), - Decode.combine( - Decode.field('allowEmptyValue', Decode.boolean), - Decode.field('visibleHelp', Decode.optional(Decode.string)) - ) - ); - -/* DatasetParam */ -const datasetParamDecoder: Decode.Decoder = Decode.combine( - paramSharedDecoder, - Decode.field('type', Decode.constant('input-dataset')), - Decode.field('defaultIdList', Decode.optional(Decode.string)), - Decode.field( - 'parsers', - Decode.arrayOf( - Decode.combine( - Decode.field('name', Decode.string), - Decode.field('displayName', Decode.string), - Decode.field('description', Decode.string) - ) - ) - ) -); - -/* TimestampParam */ -const timestampParamDecoder: Decode.Decoder = Decode.combine( - paramSharedDecoder, - Decode.field('type', Decode.constant('timestamp')) -); - -/* StringParam */ -const stringParamDecoder: Decode.Decoder = Decode.combine( - paramSharedDecoder, - Decode.field('type', Decode.constant('string')), - Decode.field('length', Decode.number) -); - -/* FilterParamNew */ -const filterParamDecoder: Decode.Decoder = Decode.combine( - paramSharedDecoder, - Decode.field('type', Decode.constant('filter')), - Decode.field('filterDataTypeDisplayName', Decode.optional(Decode.string)), - Decode.field('minSelectedCount', Decode.number), - Decode.field('hideEmptyOntologyNodes', Decode.optional(Decode.boolean)), - Decode.field('values', Decode.objectOf(Decode.arrayOf(Decode.string))), - Decode.field( - 'ontology', - Decode.arrayOf( - Decode.combine( - Decode.field('term', Decode.string), - Decode.field('parent', Decode.optional(Decode.string)), - Decode.field('display', Decode.string), - Decode.field('description', Decode.optional(Decode.string)), - Decode.field( - 'type', - Decode.optional( - Decode.oneOf( - Decode.constant('date'), - Decode.constant('string'), - Decode.constant('number'), - Decode.constant('multiFilter') - ) - ) - ), - // Decode.field('units', Decode.string), - Decode.field('precision', Decode.number), - Decode.field('isRange', Decode.boolean) - ) - ) - ) -); - -/* NumberParam */ -const numberParamDecoder: Decode.Decoder = Decode.combine( - paramSharedDecoder, - Decode.field('type', Decode.constant('number')), - Decode.field('min', Decode.number), - Decode.field('max', Decode.number), - Decode.field('increment', Decode.number) -); - -/* NumberRangeParam */ -const numberRangeParamDecoder: Decode.Decoder = - Decode.combine( - paramSharedDecoder, - Decode.field('type', Decode.constant('number-range')), - Decode.field('min', Decode.number), - Decode.field('max', Decode.number), - Decode.field('increment', Decode.number) - ); - -/* DateParam */ -const dateParamDecoder: Decode.Decoder = Decode.combine( - paramSharedDecoder, - Decode.field('type', Decode.constant('date')), - Decode.field('minDate', Decode.string), - Decode.field('maxDate', Decode.string) -); - -/* DateRangeParam */ -const dateRangeParamDecoder: Decode.Decoder = Decode.combine( - paramSharedDecoder, - Decode.field('type', Decode.constant('date-range')), - Decode.field('minDate', Decode.string), - Decode.field('maxDate', Decode.string) -); - -/* AnswerParam */ -const answerParamDecoder: Decode.Decoder = Decode.combine( - paramSharedDecoder, - Decode.field('type', Decode.constant('input-step')) -); - -/* Base decoders for enum types */ -const enumParamSharedDecoder = Decode.combine( - paramSharedDecoder, - Decode.field('maxSelectedCount', Decode.number), - Decode.field('minSelectedCount', Decode.number) -); - -const singlePickEnumParamDecoder = Decode.field( - 'type', - Decode.constant('single-pick-vocabulary') -); -const multiPickEnumParamDecoder = Decode.field( - 'type', - Decode.constant('multi-pick-vocabulary') -); - -const standardVocabularyEnumParamDecoder = Decode.combine( - enumParamSharedDecoder, - Decode.field( - 'vocabulary', - Decode.arrayOf(Decode.tuple(Decode.string, Decode.string, Decode.nullValue)) - ) -); - -/* CheckBoxEnumParam */ -const checkBoxEnumParamBaseDecoder = Decode.combine( - standardVocabularyEnumParamDecoder, - Decode.field('displayType', Decode.constant('checkBox')) -); -export const singlePickCheckBoxEnumParamDecoder: Decode.Decoder = - Decode.combine(checkBoxEnumParamBaseDecoder, singlePickEnumParamDecoder); -export const multiPickCheckBoxEnumParamDecoder: Decode.Decoder = - Decode.combine(checkBoxEnumParamBaseDecoder, multiPickEnumParamDecoder); -export const checkBoxEnumParamDecoder: Decode.Decoder = - Decode.oneOf( - singlePickCheckBoxEnumParamDecoder, - multiPickCheckBoxEnumParamDecoder - ); - -/* SelectEnumParam */ -const selectEnumParamBaseDecoder = Decode.combine( - standardVocabularyEnumParamDecoder, - Decode.field('displayType', Decode.constant('select')) -); -export const singlePickSelectEnumParamDecoder: Decode.Decoder = - Decode.combine(selectEnumParamBaseDecoder, singlePickEnumParamDecoder); -export const multiPickSelectEnumParamDecoder: Decode.Decoder = - Decode.combine(selectEnumParamBaseDecoder, multiPickEnumParamDecoder); -export const selectEnumParamDecoder: Decode.Decoder = - Decode.oneOf( - singlePickSelectEnumParamDecoder, - multiPickSelectEnumParamDecoder - ); - -/* TypeAheadEnumParam */ -const typeAheadEnumParamBaseDecoder = Decode.combine( - standardVocabularyEnumParamDecoder, - Decode.field('displayType', Decode.constant('typeAhead')) -); -export const singlePickTypeAheadEnumParamDecoder: Decode.Decoder = - Decode.combine(typeAheadEnumParamBaseDecoder, singlePickEnumParamDecoder); -export const multiPickTypeAheadEnumParamDecoder: Decode.Decoder = - Decode.combine(typeAheadEnumParamBaseDecoder, multiPickEnumParamDecoder); -export const typeAheadEnumParamDecoder: Decode.Decoder = - Decode.oneOf( - singlePickTypeAheadEnumParamDecoder, - multiPickTypeAheadEnumParamDecoder - ); - -/* TreeboxEnumParam */ -const treeBoxVocabDecoder: Decode.Decoder = Decode.combine( - Decode.field( - 'data', - Decode.combine( - Decode.field('term', Decode.string), - Decode.field('display', Decode.string) - ) - ), - Decode.field( - 'children', - Decode.lazy(() => Decode.arrayOf(treeBoxVocabDecoder)) - ) -); -const treeBoxEnumParamBaseDecoder = Decode.combine( - enumParamSharedDecoder, - Decode.field('displayType', Decode.constant('treeBox')), - Decode.field('countOnlyLeaves', Decode.boolean), - Decode.field('depthExpanded', Decode.number), - Decode.field('vocabulary', treeBoxVocabDecoder) -); -export const singlePickTreeBoxEnumParamDecoder: Decode.Decoder = - Decode.combine(treeBoxEnumParamBaseDecoder, singlePickEnumParamDecoder); -export const multiPickTreeBoxEnumParamDecoder: Decode.Decoder = - Decode.combine(treeBoxEnumParamBaseDecoder, multiPickEnumParamDecoder); -export const treeBoxEnumParamDecoder: Decode.Decoder = - Decode.oneOf( - singlePickTreeBoxEnumParamDecoder, - multiPickTreeBoxEnumParamDecoder - ); - -/* EnumParam */ -const enumParamDecoder: Decode.Decoder = Decode.oneOf( - checkBoxEnumParamDecoder, - selectEnumParamDecoder, - typeAheadEnumParamDecoder, - treeBoxEnumParamDecoder -); - -const parameterDecoder: Decode.Decoder = Decode.oneOf( - datasetParamDecoder, - timestampParamDecoder, - stringParamDecoder, - filterParamDecoder, - enumParamDecoder, - numberParamDecoder, - numberRangeParamDecoder, - dateParamDecoder, - dateRangeParamDecoder, - answerParamDecoder -); - -export const parametersDecoder: Decode.Decoder = - Decode.arrayOf(parameterDecoder); - -export const paramGroupDecoder: Decode.Decoder = Decode.combine( - Decode.field('description', Decode.string), - Decode.field('displayName', Decode.string), - Decode.field('displayType', Decode.string), - Decode.field('isVisible', Decode.boolean), - Decode.field('name', Decode.string), - Decode.field('parameters', Decode.arrayOf(Decode.string)) -); - -const questionSharedDecoder = Decode.combine( - Decode.combine( - Decode.field('fullName', Decode.string), - Decode.field('displayName', Decode.string), - Decode.field( - 'properties', - Decode.optional(Decode.objectOf(Decode.arrayOf(Decode.string))) - ), - Decode.field('summary', Decode.optional(Decode.string)), - Decode.field('description', Decode.optional(Decode.string)), - Decode.field('shortDisplayName', Decode.string), - Decode.field('outputRecordClassName', Decode.string), - Decode.field('help', Decode.optional(Decode.string)), - Decode.field('newBuild', Decode.optional(Decode.string)), - Decode.field('reviseBuild', Decode.optional(Decode.string)) - ), - Decode.combine( - Decode.field('urlSegment', Decode.string), - Decode.field('groups', Decode.arrayOf(paramGroupDecoder)), - Decode.field('defaultAttributes', Decode.arrayOf(Decode.string)), - Decode.field('paramNames', Decode.arrayOf(Decode.string)) - ), - Decode.field( - 'defaultSorting', - Decode.arrayOf( - Decode.combine( - Decode.field('attributeName', Decode.string), - Decode.field( - 'direction', - Decode.oneOf(Decode.constant('ASC'), Decode.constant('DESC')) - ) - ) - ) - ), - Decode.field('dynamicAttributes', Decode.arrayOf(attributeFieldDecoder)), - Decode.combine( - Decode.field('defaultSummaryView', Decode.string), - Decode.field('noSummaryOnSingleRecord', Decode.boolean), - Decode.field( - 'summaryViewPlugins', - Decode.arrayOf(summaryViewPluginFieldDecoder) - ) - ), - Decode.field('filters', Decode.arrayOf(questionFilterDecoder)), - Decode.field( - 'allowedPrimaryInputRecordClassNames', - Decode.optional(Decode.arrayOf(Decode.string)) - ), - Decode.field( - 'allowedSecondaryInputRecordClassNames', - Decode.optional(Decode.arrayOf(Decode.string)) - ), - Decode.field('isAnalyzable', Decode.boolean), - Decode.field('isCacheable', Decode.boolean) -); - -export type QuestionWithValidatedParameters = { - searchData: QuestionWithParameters; - validation: any; // FIXME: use actual type here -}; - -const questionWithParametersDecoder: Decode.Decoder = - Decode.combine( - Decode.field( - 'searchData', - Decode.combine( - questionSharedDecoder, - Decode.field('parameters', parametersDecoder) - ) - ), - Decode.field('validation', Decode.ok) - ); - -const questionDecoder: Decode.Decoder = Decode.combine( - questionSharedDecoder, - Decode.field('parameters', Decode.arrayOf(Decode.string)) -); - -const questionsDecoder: Decode.Decoder = - Decode.arrayOf(questionDecoder); +import { + questionWithParametersDecoder, + parametersDecoder, +} from '../Decoders/QuestionDecoders'; export default (base: ServiceBase) => { async function getQuestionAndParameters( diff --git a/packages/libs/wdk-client/src/Service/Mixins/StepAnalysisService.ts b/packages/libs/wdk-client/src/Service/Mixins/StepAnalysisService.ts index 897939892e..62c50c98c4 100644 --- a/packages/libs/wdk-client/src/Service/Mixins/StepAnalysisService.ts +++ b/packages/libs/wdk-client/src/Service/Mixins/StepAnalysisService.ts @@ -12,7 +12,7 @@ import { StepAnalysisType, StepAnalysisConfig, } from '../../Utils/StepAnalysisUtils'; -import { parametersDecoder } from '../../Service/Mixins/SearchesService'; +import { parametersDecoder } from '../../Service/Decoders/QuestionDecoders'; import { Parameter, ParameterValues } from '../../Utils/WdkModel'; import { extractParamValues } from '../../Utils/WdkUser'; import { makeTraceid } from '../ServiceUtils'; diff --git a/packages/libs/wdk-client/src/Utils/StepAnalysisUtils.ts b/packages/libs/wdk-client/src/Utils/StepAnalysisUtils.ts index c224ffff58..319e34c729 100644 --- a/packages/libs/wdk-client/src/Utils/StepAnalysisUtils.ts +++ b/packages/libs/wdk-client/src/Utils/StepAnalysisUtils.ts @@ -14,7 +14,7 @@ import { string, } from './Json'; import { ParameterGroup, ParameterValues } from './WdkModel'; -import { paramGroupDecoder } from '../Service/Mixins/SearchesService'; +import { paramGroupDecoder } from '../Service/Decoders/QuestionDecoders'; import { ValidStepValidation, InvalidStepValidation } from '../Utils/WdkUser'; export interface StepAnalysis { 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/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/homepage/VEuPathDBHomePage.tsx b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/homepage/VEuPathDBHomePage.tsx index 482eacf7a7..08687804ef 100644 --- a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/homepage/VEuPathDBHomePage.tsx +++ b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/homepage/VEuPathDBHomePage.tsx @@ -331,9 +331,7 @@ const VEuPathDB = 'VEuPathDB'; const UniDB = 'UniDB'; const DB = 'DB'; -// TODO Update this const once we know the question name to use. -// const QUESTION_FOR_MAP_DATASETS = 'DatasetsForMapMenu'; -const QUESTION_FOR_MAP_DATASETS = 'AllDatasets'; +const QUESTION_FOR_MAP_DATASETS = 'MapStudiesForToolbar'; function makeStaticPageRoute(subPath: string) { return `${STATIC_ROUTE_PATH}${subPath}`; @@ -369,16 +367,8 @@ const useHeaderMenuItems = ( (q) => q.urlSegment === QUESTION_FOR_MAP_DATASETS ) ); - const mapStudy = useWdkService( - (wdkService) => - wdkService - .getRecord('dataset', [{ name: 'dataset_id', value: 'DS_480c976ef9' }]) - .catch(() => {}), - [] - ); - // const showInteractiveMaps = mapMenuItemsQuestion != null; - // const mapMenuItems = useMapMenuItems(mapMenuItemsQuestion); - const showInteractiveMaps = projectId === VectorBase && !!useEda; + const showInteractiveMaps = mapMenuItemsQuestion != null; + const mapMenuItems = useMapMenuItems(mapMenuItemsQuestion); // type: reactRoute, webAppRoute, externalLink, subMenu, custom const fullMenuItemEntries: HeaderMenuItemEntry[] = [ @@ -586,40 +576,56 @@ const useHeaderMenuItems = ( include: [EuPathDB, UniDB], }, }, - // { - // key: 'maps-alpha', - // display: ( - // <> - // Interactive maps BETA - // - // ), - // type: 'subMenu', - // metadata: { - // test: () => showInteractiveMaps, - // }, - // items: mapMenuItems ?? [ - // { - // key: 'maps-loading', - // type: 'custom', - // display: , - // }, - // ], - // }, - { - type: 'reactRoute', - display: ( - <> - MapVEu - {safeHtml(mapStudy?.displayName ?? '')}{' '} - BETA - - ), - key: 'map--mega-study', - url: '/workspace/maps/DS_480c976ef9/new', - target: '_blank', - metadata: { - test: () => showInteractiveMaps && mapStudy != null, - }, - }, + !showInteractiveMaps + ? { + type: 'custom', + key: 'maps-alpha', + display: null, + metadata: { + test: () => showInteractiveMaps, + }, + } + : mapMenuItems == null + ? { + key: 'maps-alpha', + type: 'custom', + display: ( + <> + MapVEu — Interactive maps{' '} + BETA{' '} + + + ), + } + : mapMenuItems.length === 1 + ? { + ...mapMenuItems[0], + display: ( + <> + MapVEu — {mapMenuItems[0].display}{' '} + BETA + + ), + } + : { + key: 'maps-alpha', + type: 'subMenu', + display: ( + <> + MapVEu — Interactive maps{' '} + BETA + + ), + items: mapMenuItems, + }, { key: 'pubcrawler', display: 'PubMed and Entrez', @@ -1196,21 +1202,21 @@ export const VEuPathDBHomePage = connect(mapStateToProps)( function useMapMenuItems(question?: Question) { const { wdkService } = useNonNullableContext(WdkDependenciesContext); - const studyAccessApi = useStudyAccessApi(); + const studyAccessApi = useStudyAccessApi_tryCatch(); const subsettingClient = useMemo( () => new SubsettingClient({ baseUrl: edaServiceUrl }, wdkService), [wdkService] ); - const [mapMenuItems, setMapMenuItems] = useState(); + const [mapMenuItems, setMapMenuItems] = useState(); useEffect(() => { - if (question == null) return; + if (question == null || studyAccessApi == null) return; getWdkStudyRecords( { studyAccessApi, subsettingClient, wdkService }, { searchName: question.urlSegment } ).then( (records) => { const menuItems = records.map( - (record): HeaderMenuItem => ({ + (record): HeaderMenuItemEntry => ({ key: `map-${record.id[0].value}`, display: record.displayName, type: 'reactRoute', @@ -1237,3 +1243,14 @@ function useMapMenuItems(question?: Question) { }, [question, studyAccessApi, subsettingClient, wdkService]); return mapMenuItems; } + +function useStudyAccessApi_tryCatch() { + // useStudyAccessApi() will throw if WdkService isn't configured for study + // access. We can ignore the error and return `undefined` to allow the + // application to handle the absence of the configuration. + try { + return useStudyAccessApi(); + } catch { + return; + } +} diff --git a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/questions/GenesByOrthologPattern.scss b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/questions/GenesByOrthologPattern.scss index a4c7ec38fc..3cafd58973 100644 --- a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/questions/GenesByOrthologPattern.scss +++ b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/questions/GenesByOrthologPattern.scss @@ -1,42 +1,44 @@ .GenesByOrthologPattern { &ProfileParameter { - .wdk-CheckboxTree { - width: 50%; + &TreeWrapper { + max-width: 650px; position: relative; - > .wdk-CheckboxTreeList - > .wdk-CheckboxTreeItem - > .wdk-CheckboxTreeNodeWrapper - > i { - visibility: hidden; - } + [class$='-CheckboxTree'] { + > [class$='-CheckboxTreeList'] + > [class$='-CheckboxTreeItem'] + > [class$='-CheckboxTreeNodeWrapper'] + > i { + visibility: hidden; + } - .GenesByOrthologPatternConstraintIcon { - cursor: pointer; + .GenesByOrthologPatternConstraintIcon { + cursor: pointer; + } } - } - .wdk-CheckboxTreeList { - .wdk-CheckboxTreeToggle { - &__collapsed { - margin-left: 0.1em; + [class$='-CheckboxTreeList'] { + .wdk-CheckboxTreeToggle { + &__collapsed { + margin-left: 0.1em; + } } } - } - .wdk-CheckboxTreeNodeContent { - margin-left: 1em; - } + [class$='-CheckboxTreeNodeContent'] { + margin-left: 1em; + } - .wdk-CheckboxTreeLinks { - position: absolute; - top: 1.1em; - left: 9.5em; - z-index: 1; - margin: 0 0 0 1rem; + [class$='-CheckboxTreeLinks'] { + position: absolute; + top: 1.1em; + left: 9.5em; + z-index: 1; + margin: 0 0 0 1rem; - > div { - text-align: left; + > div { + text-align: left; + } } } } diff --git a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/questions/GenesByOrthologPattern.tsx b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/questions/GenesByOrthologPattern.tsx index 02cadc28f7..9ae1b775e3 100644 --- a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/questions/GenesByOrthologPattern.tsx +++ b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/questions/GenesByOrthologPattern.tsx @@ -15,6 +15,7 @@ import { EbrcDefaultQuestionForm } from '@veupathdb/web-common/lib/components/qu import { makeClassNameHelper } from '@veupathdb/wdk-client/lib/Utils/ComponentUtils'; import './GenesByOrthologPattern.scss'; +import { areTermsInString } from '@veupathdb/wdk-client/lib/Utils/SearchUtils'; const cx = makeClassNameHelper('GenesByOrthologPattern'); const cxDefaultQuestionForm = makeClassNameHelper('wdk-QuestionForm'); @@ -204,6 +205,15 @@ function ProfileParameter({ [constraints, nodeMap, profileTree, eventHandlers, questionState] ); + const [searchTerm, setSearchTerm] = useState(''); + + const searchPredicate = useCallback( + (node: ProfileNode, searchTerms: string[]) => { + return areTermsInString(searchTerms, node.display + ' ' + node.term); + }, + [] + ); + return (
    @@ -219,16 +229,22 @@ function ProfileParameter({ = mixture of constraints )
    - - tree={profileTree} - getNodeId={getNodeId} - getNodeChildren={getNodeChildren} - onExpansionChange={onExpansionChange} - renderNode={renderNode} - expandedList={expandedList} - showRoot - linksPosition={LinksPosition.Top} - /> +
    + + tree={profileTree} + getNodeId={getNodeId} + getNodeChildren={getNodeChildren} + onExpansionChange={onExpansionChange} + renderNode={renderNode} + expandedList={expandedList} + showRoot + linksPosition={LinksPosition.Top} + isSearchable + searchTerm={searchTerm} + onSearchTermChange={setSearchTerm} + searchPredicate={searchPredicate} + /> +
    ); } @@ -295,7 +311,7 @@ function makeRenderNode( {node.display} 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"