diff --git a/packages/libs/components/public/css/vb-popbio-maps.css b/packages/libs/components/public/css/vb-popbio-maps.css index 31cee0bace..bd7f116d77 100755 --- a/packages/libs/components/public/css/vb-popbio-maps.css +++ b/packages/libs/components/public/css/vb-popbio-maps.css @@ -95,16 +95,10 @@ body { text-align: left; line-height: 15px; color: #555555; - /* DKDK changed margin-bottom from 5 to 0 */ margin-bottom: 0px !important; border-radius: 0 !important; - /* DKDK change these for mapveu v2 - width : 280px; - min-height : 300px; */ width: 250px; min-height: 210px; - /* DKDK combine with other CSS defined */ - /* padding: 6px 8px 6px 8px; */ padding: 0px 0px 0px 0px; background: white; /*max-width : 230px; @@ -128,17 +122,14 @@ body { min-width: 50px; text-align: right; font-weight: 600; - /*DKDK VB-8650*/ width: 27%; } .summ-by-value { float: left; - /*DKDK VB-8650 */ - /*width: 79%;*/ } -/*DKDK VB-8650 add below class to handle active-legend and external link separately*/ +/* VB-8650 add below class to handle active-legend and external link separately*/ .active-legend-area { float: left; width: 73%; @@ -150,18 +141,11 @@ body { .legend { position: absolute; - /* DKDK set bottom to be smaller - bottom: 40px; */ bottom: 10px; right: 0px; z-index: 10000; } -/* .legend > div { - /* DKDK change padding from 0 8px to 0 4px - padding: 0 2px; -} */ - .legend p { font-size: smaller; word-wrap: break-word; @@ -212,7 +196,7 @@ body { .mapboxbottom .overlaybottom { position: absolute; - /*DKDK VB-8707 change bottom from 3 to 25 px*/ + /* VB-8707 change bottom from 3 to 25 px*/ bottom: 25px; left: 40%; width: 30%; @@ -278,12 +262,29 @@ path, text-shadow: -1px -1px 0 #ffffff, 1px -1px 0 #ffffff, -1px 1px 0 #ffffff, 1px 1px 0 #ffffff; } -.highlight-marker { - background-clip: padding-box; - border-radius: 30px; - box-shadow: 0px 0px 0px 4px rgba(255, 255, 0, 1); + +/* donut and bubble markers highlight */ +.highlight-donutmarker, +.highlight-bubblemarker { + /* background-clip: padding-box; */ + border-radius: 50%; + /* add inset to reduce a gap between the element and the box-shadow */ + box-shadow: 2.5px 2.5px 0px 6px rgba(255, 255, 0, 1), + 2.5px 2.5px 0px 2px rgba(255, 255, 0, 1) inset; z-index: 9999 !important; } + +/* chart marker highlight */ +.highlight-chartmarker { + /* background-clip: padding-box; */ + /* the rounded corner of the chart marker has 10px in radius */ + border-radius: 10px; + /* add inset to reduce a gap between the element and the box-shadow */ + box-shadow: -1px -0.5px 0px 6px rgba(255, 255, 0, 1), + -1px -0.5px 0px 2px rgba(255, 255, 0, 1) inset; + z-index: 9999 !important; +} + .top-marker { z-index: 3100 !important; } @@ -449,7 +450,7 @@ path, fill: none; stroke: #000000; stroke-width: 1px; - /* DKDK temporarily block below */ + /* temporarily block below */ /* color-rendering : optimizeQuality !important; */ shape-rendering: crispEdges !important; text-rendering: geometricPrecision !important; @@ -541,7 +542,7 @@ html { border-radius: 0px; box-shadow: none; } -/* x-jsrender active terms DKDK VB-8650 */ +/* x-jsrender active terms VB-8650 */ .active-term, .active-legend, .active-others, @@ -741,13 +742,13 @@ div.hint div { text-overflow: ellipsis; } -/* DKDK VB-8650 */ +/* VB-8650 */ .active-legend, .insertExternalLinkLegend { line-height: 17px; } -/* DKDK VB-8650 */ +/* VB-8650 */ /* .active-legend i, #Other-Terms-List i{ */ .active-legend .summ-by-value .summ-by-value-item, .insertExternalLinkLegend .summ-by-value .summ-by-value-item, @@ -1079,7 +1080,7 @@ div.hint div { text-align: center; } -/*DKDK add vectorbase-icon*/ +/* add vectorbase-icon*/ #vectorbase-icon { height: 40px; font-size: 12pt; @@ -1274,7 +1275,7 @@ div.hint div { text-align: center; } -/* DKDK VB-8112 disabling cursor change on pie chart legend */ +/* VB-8112 disabling cursor change on pie chart legend */ .nvd3 .nv-legend .nv-series { cursor: default !important; } @@ -1291,7 +1292,7 @@ div.hint div { cursor: ew-resize; } -/* DKDK VB-8650 related CSS */ +/* VB-8650 related CSS */ /*.insertExternalLinkLegend .insertExternalLink { float: initial; } diff --git a/packages/libs/components/src/map/BoundsDriftMarker.tsx b/packages/libs/components/src/map/BoundsDriftMarker.tsx old mode 100644 new mode 100755 index 7fa19c4c52..10aa193795 --- a/packages/libs/components/src/map/BoundsDriftMarker.tsx +++ b/packages/libs/components/src/map/BoundsDriftMarker.tsx @@ -1,15 +1,46 @@ import { useMap, Popup } from 'react-leaflet'; -import { useRef, useEffect } from 'react'; +import { useRef, useEffect, useState } from 'react'; // use new ReactLeafletDriftMarker instead of DriftMarker import ReactLeafletDriftMarker from 'react-leaflet-drift-marker'; import { MarkerProps, Bounds } from './Types'; import L, { LeafletMouseEvent, LatLngBounds } from 'leaflet'; +// define markerData type to have as many info as possible +export interface markerDataProp { + id: string; + latLng: { + lat: number; + lng: number; + }; + data?: { + value: number; + label: string; + color?: string; + }[]; + // bubble marker's data is not array, so define it as bubbleData + bubbleData?: { + value: number; + diameter?: number; + colorValue?: number; + colorLabel?: string; + color?: string; + }; + markerType: 'donut' | 'chart' | 'bubble'; +} + export interface BoundsDriftMarkerProps extends MarkerProps { bounds: Bounds; duration: number; // A class to add to the popup element popupClass?: string; + // selectedMarkers state + selectedMarkers?: markerDataProp[]; + // selectedMarkers setState + setSelectedMarkers?: React.Dispatch>; + // marker type to be used for highlighting markers + markerType?: 'donut' | 'chart' | 'bubble'; + // marker data + markerData?: markerDataProp; } /** @@ -39,8 +70,14 @@ export default function BoundsDriftMarker({ popupContent, popupClass, zIndexOffset, + selectedMarkers, + setSelectedMarkers, + markerType, + markerData, + ...props }: BoundsDriftMarkerProps) { const map = useMap(); + const boundingBox = new LatLngBounds([ [bounds.southWest.lat, bounds.southWest.lng], [bounds.northEast.lat, bounds.northEast.lng], @@ -270,7 +307,54 @@ export default function BoundsDriftMarker({ e.target.closePopup(); }; + // add click events for highlighting markers const handleClick = (e: LeafletMouseEvent) => { + // hightlight donutmarker and highlight chartmarker + if ( + e.target._icon.classList.contains('highlight-donutmarker') || + e.target._icon.classList.contains('highlight-chartmarker') || + e.target._icon.classList.contains('highlight-bubblemarker') + ) { + if (markerType === 'donut') + e.target._icon.classList.remove('highlight-donutmarker'); + else if (markerType === 'chart') + e.target._icon.classList.remove('highlight-chartmarker'); + else if (markerType === 'bubble') + e.target._icon.classList.remove('highlight-bubblemarker'); + + if ( + selectedMarkers != null && + setSelectedMarkers != null && + markerData != null + ) { + // functional updates + setSelectedMarkers((prevSelectedMarkers: markerDataProp[]) => + prevSelectedMarkers.filter( + (item: markerDataProp) => item.id !== props.id + ) + ); + } + } else { + if (markerType === 'donut') + e.target._icon.classList.add('highlight-donutmarker'); + else if (markerType === 'chart') + e.target._icon.classList.add('highlight-chartmarker'); + else if (markerType === 'bubble') + e.target._icon.classList.add('highlight-bubblemarker'); + + if ( + selectedMarkers != null && + setSelectedMarkers != null && + markerData != null + ) { + // functional updates + setSelectedMarkers((prevSelectedMarkers: markerDataProp[]) => [ + ...prevSelectedMarkers, + markerData, + ]); + } + } + // Sometimes clicking throws off the popup's orientation, so reorient it orientPopup(popupOrientationRef.current); // Default popup behavior is to open on marker click diff --git a/packages/libs/components/src/map/BubbleMarker.tsx b/packages/libs/components/src/map/BubbleMarker.tsx index 15776da395..efa21296f6 100755 --- a/packages/libs/components/src/map/BubbleMarker.tsx +++ b/packages/libs/components/src/map/BubbleMarker.tsx @@ -4,10 +4,13 @@ import BoundsDriftMarker, { BoundsDriftMarkerProps } from './BoundsDriftMarker'; import { ContainerStylesAddon } from '../types/plots'; +import { markerDataProp } from './BoundsDriftMarker'; + export interface BubbleMarkerProps extends BoundsDriftMarkerProps { data: { /* The size value */ value: number; + // make this undefined? diameter: number; /* The color value (shown in the popup) */ colorValue?: number; @@ -18,17 +21,48 @@ export interface BubbleMarkerProps extends BoundsDriftMarkerProps { // isAtomic: add a special thumbtack icon if this is true isAtomic?: boolean; onClick?: (event: L.LeafletMouseEvent) => void | undefined; + /* add selectedMarkers state and its setState props but these are not used for this BubbleMarker **/ + selectedMarkers?: markerDataProp[]; + setSelectedMarkers?: React.Dispatch>; } /** * this is a SVG bubble marker icon */ export default function BubbleMarker(props: BubbleMarkerProps) { + const selectedMarkers = props.selectedMarkers; + const setSelectedMarkers = props.setSelectedMarkers; + const { html: svgHTML, diameter: size } = bubbleMarkerSVGIcon(props); + // make a prop to pass to BoundsDriftMarker + const markerData: markerDataProp = { + id: props.id, + latLng: props.position, + // use bubbleData, not data for bubble marker + // bubbleData: props.data, + markerType: 'bubble', + }; + + // add class, highlight-chartmarker, for panning + // Note: map panning calls for new data request, resulting that marker elements are completely regenerated, which causes new className without highlighting + // Thus, it is necessary to add a highlight for a marker based on whether it is included in the selectedMarkers + // One inevitable disadvantage is that this possibly results in on & off of highlighting (may look like a blink) + const addHighlightClassName = + selectedMarkers != null && + selectedMarkers.length > 0 && + selectedMarkers.some((selectedMarker) => selectedMarker.id === props.id) + ? ' highlight-bubblemarker' + : ''; + // set icon as divIcon const SVGBubbleIcon = L.divIcon({ - className: 'leaflet-canvas-icon', // may need to change this className but just leave it as it for now + className: + 'leaflet-canvas-icon ' + + 'marker-id-' + + props.id + + ' bubble-marker' + + addHighlightClassName, iconSize: new L.Point(size, size), // this will make icon to cover up SVG area! iconAnchor: new L.Point(size / 2, size / 2), // location of topleft corner: this is used for centering of the icon like transform/translate in CSS html: svgHTML, // divIcon HTML svg code generated above @@ -70,6 +104,11 @@ export default function BubbleMarker(props: BubbleMarkerProps) { }, }} showPopup={props.showPopup} + // pass selectedMarkers state and setState + selectedMarkers={selectedMarkers} + setSelectedMarkers={setSelectedMarkers} + markerType={'bubble'} + markerData={markerData} /> ); } diff --git a/packages/libs/components/src/map/ChartMarker.tsx b/packages/libs/components/src/map/ChartMarker.tsx index 6c8f915e39..4f3b2632b0 100755 --- a/packages/libs/components/src/map/ChartMarker.tsx +++ b/packages/libs/components/src/map/ChartMarker.tsx @@ -15,6 +15,8 @@ import { MarkerScaleDefault, } from '../types/plots'; +import { markerDataProp } from './BoundsDriftMarker'; + export type BaseMarkerData = { value: number; label: string; @@ -37,6 +39,10 @@ export interface ChartMarkerProps /** cumulative mode: when true, the total count shown will be the last value, not the sum of the values. * See cumulative prop in DonutMarker.tsx for context. */ cumulative?: boolean; + /* selectedMarkers state **/ + selectedMarkers?: markerDataProp[]; + /* selectedMarkers setState **/ + setSelectedMarkers?: React.Dispatch>; } /** @@ -46,11 +52,36 @@ export interface ChartMarkerProps * - accordingly icon size could be reduced */ export default function ChartMarker(props: ChartMarkerProps) { + const selectedMarkers = props.selectedMarkers; + const setSelectedMarkers = props.setSelectedMarkers; + const { html: svgHTML, size, sumValuesString } = chartMarkerSVGIcon(props); + // make a prop to pass to BoundsDriftMarker + const markerData: markerDataProp = { + id: props.id, + latLng: props.position, + // data: props.data, + markerType: 'chart', + }; + + // add class, highlight-chartmarker, for panning + const addHighlightClassName = + selectedMarkers != null && + selectedMarkers.length > 0 && + selectedMarkers.some((selectedMarker) => selectedMarker.id === props.id) + ? ' highlight-chartmarker' + : ''; + // set icon let HistogramIcon: any = L.divIcon({ - className: 'leaflet-canvas-icon', + // add class, highlight-chartmarker, for panning + className: + 'leaflet-canvas-icon ' + + 'marker-id-' + + props.id + + ' chart-marker' + + addHighlightClassName, iconSize: new L.Point(size, size), iconAnchor: new L.Point(size / 2, size / 2), // location of topleft corner: this is used for centering of the icon like transform/translate in CSS html: svgHTML, // divIcon HTML svg code generated above @@ -116,6 +147,11 @@ export default function ChartMarker(props: ChartMarkerProps) { }} showPopup={props.showPopup} popupClass="histogram-popup" + // pass // selectedMarkers state and setState + selectedMarkers={selectedMarkers} + setSelectedMarkers={setSelectedMarkers} + markerType={'chart'} + markerData={markerData} /> ); } diff --git a/packages/libs/components/src/map/DonutMarker.tsx b/packages/libs/components/src/map/DonutMarker.tsx index 4359695b32..90d7d08089 100755 --- a/packages/libs/components/src/map/DonutMarker.tsx +++ b/packages/libs/components/src/map/DonutMarker.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import L from 'leaflet'; import BoundsDriftMarker, { BoundsDriftMarkerProps } from './BoundsDriftMarker'; @@ -13,6 +13,8 @@ import { import { last } from 'lodash'; import { BaseMarkerData } from './ChartMarker'; +import { markerDataProp } from './BoundsDriftMarker'; + // ts definition for HistogramMarkerSVGProps: need some adjustment but for now, just use Donut marker one export interface DonutMarkerProps extends BoundsDriftMarkerProps, @@ -28,6 +30,10 @@ export interface DonutMarkerProps * value does not have to be 100. 2,4,6,8,10 would produce the same donut * (but with different mouse-overs in the enlarged version.) */ cumulative?: boolean; + /* selectedMarkers state **/ + selectedMarkers?: markerDataProp[]; + /* selectedMarkers setState **/ + setSelectedMarkers?: React.Dispatch>; } // convert to Cartesian coord. toCartesian(centerX, centerY, Radius for arc to draw, arc (radian)) @@ -103,6 +109,9 @@ function makeArc( * this is a SVG donut marker icon */ export default function DonutMarker(props: DonutMarkerProps) { + const selectedMarkers = props.selectedMarkers; + const setSelectedMarkers = props.setSelectedMarkers; + const { html: svgHTML, size, @@ -110,9 +119,34 @@ export default function DonutMarker(props: DonutMarkerProps) { sliceTextOverrides, } = donutMarkerSVGIcon(props); + // make a prop to pass to BoundsDriftMarker + const markerData: markerDataProp = { + id: props.id, + latLng: props.position, + // data: props.data, + markerType: 'donut', + }; + + // add class, highlight-chartmarker, for panning + // Note: map panning calls for new data request, resulting that marker elements are completely regenerated, which causes new className without highlighting + // Thus, it is necessary to add a highlight for a marker based on whether it is included in the selectedMarkers + // One inevitable disadvantage is that this possibly results in on & off of highlighting (may look like a blink) + const addHighlightClassName = + selectedMarkers != null && + selectedMarkers.length > 0 && + selectedMarkers.some((selectedMarker) => selectedMarker.id === props.id) + ? ' highlight-donutmarker' + : ''; + // set icon as divIcon const SVGDonutIcon: any = L.divIcon({ - className: 'leaflet-canvas-icon', // may need to change this className but just leave it as it for now + // add class, highlight-chartmarker, for panning + className: + 'leaflet-canvas-icon ' + + 'marker-id-' + + props.id + + ' donut-marker' + + addHighlightClassName, iconSize: new L.Point(size, size), // this will make icon to cover up SVG area! iconAnchor: new L.Point(size / 2, size / 2), // location of topleft corner: this is used for centering of the icon like transform/translate in CSS html: svgHTML, // divIcon HTML svg code generated above @@ -169,6 +203,11 @@ export default function DonutMarker(props: DonutMarkerProps) { }} showPopup={props.showPopup} popupClass="donut-popup" + // pass selectedMarkers state and setState + selectedMarkers={selectedMarkers} + setSelectedMarkers={setSelectedMarkers} + markerType={'donut'} + markerData={markerData} /> ); } @@ -310,5 +349,11 @@ function donutMarkerSVGIcon(props: DonutMarkerStandaloneProps): { // closing svg tag svgHTML += ''; - return { html: svgHTML, size, sliceTextOverrides, markerLabel: sumLabel }; + + return { + html: svgHTML, + size, + sliceTextOverrides, + markerLabel: sumLabel, + }; } diff --git a/packages/libs/components/src/map/MapVEuMap.tsx b/packages/libs/components/src/map/MapVEuMap.tsx index 13f086c326..e31dc46c9e 100755 --- a/packages/libs/components/src/map/MapVEuMap.tsx +++ b/packages/libs/components/src/map/MapVEuMap.tsx @@ -8,7 +8,7 @@ import React, { useCallback, useRef, } from 'react'; -import { BoundsViewport, AnimationFunction, Bounds } from './Types'; +import { BoundsViewport, Bounds } from './Types'; import { MapContainer, TileLayer, @@ -29,6 +29,8 @@ import domToImage from 'dom-to-image'; import { makeSharedPromise } from '../utils/promise-utils'; import { Undo } from '@veupathdb/coreui'; +import { markerDataProp } from './BoundsDriftMarker'; + // define Viewport type export type Viewport = { center: [number, number]; @@ -151,6 +153,16 @@ export interface MapVEuMapProps { scrollingEnabled?: boolean; /** pass default viewport */ defaultViewport?: Viewport; + /* selectedMarkers state **/ + selectedMarkers?: markerDataProp[]; + /* selectedMarkers setState **/ + setSelectedMarkers?: React.Dispatch>; + /* setIsPanning that is used to check if map panning occurred */ + setIsPanning?: React.Dispatch>; + /* prevGeohashLevel state **/ + prevGeohashLevel?: number; + /* prevGeohashLevel setState **/ + setPrevGeohashLevel?: React.Dispatch>; children?: React.ReactNode; } @@ -174,6 +186,11 @@ function MapVEuMap(props: MapVEuMapProps, ref: Ref) { scrollingEnabled = true, interactive = true, defaultViewport, + selectedMarkers, + setSelectedMarkers, + setIsPanning, + prevGeohashLevel, + setPrevGeohashLevel, } = props; // use a ref to avoid unneeded renders @@ -282,6 +299,12 @@ function MapVEuMap(props: MapVEuMapProps, ref: Ref) { {/* set ScrollWheelZoom */} @@ -298,17 +321,73 @@ interface MapVEuMapEventsProps { onViewportChanged: (viewport: Viewport) => void; onBoundsChanged: (bondsViewport: BoundsViewport) => void; onBaseLayerChanged?: (newBaseLayer: BaseLayerChoice) => void; + selectedMarkers?: markerDataProp[]; + setSelectedMarkers?: React.Dispatch>; + setIsPanning?: React.Dispatch>; + zoomLevelToGeohashLevel?: (leafletZoomLevel: number) => number; + prevGeohashLevel?: number; + setPrevGeohashLevel?: React.Dispatch>; } // function to handle map events such as onViewportChanged and baselayerchange function MapVEuMapEvents(props: MapVEuMapEventsProps) { - const { onViewportChanged, onBaseLayerChanged, onBoundsChanged } = props; + const { + onViewportChanged, + onBaseLayerChanged, + onBoundsChanged, + selectedMarkers, + setSelectedMarkers, + setIsPanning, + zoomLevelToGeohashLevel, + prevGeohashLevel, + setPrevGeohashLevel, + } = props; const mapEvents = useMapEvents({ zoomend: () => { onViewportChanged({ center: [mapEvents.getCenter().lat, mapEvents.getCenter().lng], zoom: mapEvents.getZoom(), }); + + // check selected markers are within the map viewport/bounds + if ( + selectedMarkers != null && + selectedMarkers.length > 0 && + setSelectedMarkers != null && + zoomLevelToGeohashLevel != null && + setPrevGeohashLevel != null + ) { + if (prevGeohashLevel === zoomLevelToGeohashLevel(mapEvents.getZoom())) { + setSelectedMarkers( + (prevSelectedMarkers: markerDataProp[]): markerDataProp[] => { + const visibleMarkers = prevSelectedMarkers + .map((selectedMarker: markerDataProp) => { + if (mapEvents.getBounds().contains(selectedMarker.latLng)) { + return selectedMarker; + } + }) + .filter((item) => item != null); + + if (visibleMarkers != null) + return visibleMarkers as markerDataProp[]; + else return []; + } + ); + } else { + // when geohash level is changed + setSelectedMarkers([]); + } + } + + // update preGeohashLevel. Separate condition is made as this is not related to selectedMarkers + if ( + zoomLevelToGeohashLevel != null && + setPrevGeohashLevel != null && + prevGeohashLevel !== zoomLevelToGeohashLevel(mapEvents.getZoom()) + ) { + setPrevGeohashLevel(zoomLevelToGeohashLevel(mapEvents.getZoom())); + } + const boundsViewport: BoundsViewport = { bounds: constrainLongitudeToMainWorld( boundsToGeoBBox(mapEvents.getBounds()) @@ -322,6 +401,34 @@ function MapVEuMapEvents(props: MapVEuMapEventsProps) { center: [mapEvents.getCenter().lat, mapEvents.getCenter().lng], zoom: mapEvents.getZoom(), }); + + // map panning occurred + if (setIsPanning != null) + setIsPanning((prevMoveend: boolean) => !prevMoveend); + + // check bounds to update selectedMarkers: excluding invisible markers in the selectedMarkers + if ( + selectedMarkers != null && + selectedMarkers.length > 0 && + setSelectedMarkers != null + ) { + setSelectedMarkers( + (prevSelectedMarkers: markerDataProp[]): markerDataProp[] => { + const visibleMarkers = prevSelectedMarkers + .map((selectedMarker: markerDataProp) => { + if (mapEvents.getBounds().contains(selectedMarker.latLng)) { + return selectedMarker; + } + }) + .filter((item) => item != null); + + if (visibleMarkers != null) + return visibleMarkers as markerDataProp[]; + else return []; + } + ); + } + const boundsViewport: BoundsViewport = { bounds: constrainLongitudeToMainWorld( boundsToGeoBBox(mapEvents.getBounds()) @@ -333,6 +440,13 @@ function MapVEuMapEvents(props: MapVEuMapEventsProps) { baselayerchange: (e: { name: string }) => { onBaseLayerChanged && onBaseLayerChanged(e.name as BaseLayerChoice); }, + // map click event: remove selected highlight markers + click: () => { + removeClassName('highlight-donutmarker'); + removeClassName('highlight-chartmarker'); + removeClassName('highlight-bubblemarker'); + if (setSelectedMarkers != null) setSelectedMarkers([]); + }, }); return null; @@ -492,3 +606,12 @@ function constrainLongitudeToMainWorld({ northEast: { lat: north, lng: newEast }, }; } + +// remove marker's highlight class +function removeClassName(targetClass: string) { + const allElements = document.querySelectorAll('*'); + + allElements.forEach((element) => { + element.classList.remove(targetClass); + }); +} diff --git a/packages/libs/components/src/stories/ChartMarkers.stories.tsx b/packages/libs/components/src/stories/ChartMarkers.stories.tsx old mode 100644 new mode 100755 index 1c81c9496e..b10575a164 --- a/packages/libs/components/src/stories/ChartMarkers.stories.tsx +++ b/packages/libs/components/src/stories/ChartMarkers.stories.tsx @@ -181,6 +181,9 @@ export const TwoRequests: Story = (args) => { handleMarkerClick, legendRadioValue, setDependentAxisRange, + // add two undefined props for selectedMarkers & setSelectedMarkers + undefined, + undefined, 2000 ); if (!isCancelled) setMarkerElements(fullMarkers); @@ -262,6 +265,9 @@ export const LogScale: Story = (args) => { handleMarkerClick, legendRadioValue, setDependentAxisRange, + // add two undefined props for selectedMarkers & setSelectedMarkers + undefined, + undefined, 0, dependentAxisLogScale ); diff --git a/packages/libs/components/src/stories/DonutMarkers.stories.tsx b/packages/libs/components/src/stories/DonutMarkers.stories.tsx old mode 100644 new mode 100755 index f3ba84a4e1..f82fd12d12 --- a/packages/libs/components/src/stories/DonutMarkers.stories.tsx +++ b/packages/libs/components/src/stories/DonutMarkers.stories.tsx @@ -235,6 +235,9 @@ export const TwoRequests: Story = (args) => { defaultAnimationDuration, setLegendData, handleMarkerClick, + // add two undefined props for selectedMarkers & setSelectedMarkers + undefined, + undefined, 2000 ); if (!isCancelled) setMarkerElements(fullMarkers); diff --git a/packages/libs/components/src/stories/Map.stories.tsx b/packages/libs/components/src/stories/Map.stories.tsx old mode 100644 new mode 100755 index 8289771dbc..3c1e44648e --- a/packages/libs/components/src/stories/Map.stories.tsx +++ b/packages/libs/components/src/stories/Map.stories.tsx @@ -302,6 +302,9 @@ export const Tiny: Story = (args) => { defaultAnimationDuration, setLegendData, handleMarkerClick, + // add two undefined props for highlightedMarkers & setHighlightedMarkers + undefined, + undefined, 0, tinyLeafletZoomLevelToGeohashLevel, 20 diff --git a/packages/libs/components/src/stories/MarkerSelection.stories.tsx b/packages/libs/components/src/stories/MarkerSelection.stories.tsx new file mode 100755 index 0000000000..7253d55ce0 --- /dev/null +++ b/packages/libs/components/src/stories/MarkerSelection.stories.tsx @@ -0,0 +1,200 @@ +import React, { ReactElement, useState, useCallback } from 'react'; +import { Story, Meta } from '@storybook/react/types-6-0'; +// import { action } from '@storybook/addon-actions'; +import { BoundsViewport } from '../map/Types'; +import { BoundsDriftMarkerProps } from '../map/BoundsDriftMarker'; +import { defaultAnimationDuration } from '../map/config/map'; +import { leafletZoomLevelToGeohashLevel } from '../map/utils/leaflet-geohash'; +import { + getSpeciesDonuts, + getCollectionDateChartMarkers, +} from './api/getMarkersFromFixtureData'; + +import { LeafletMouseEvent } from 'leaflet'; +import { Viewport } from '../map/MapVEuMap'; + +// sidebar & legend +import MapVEuMap, { MapVEuMapProps } from '../map/MapVEuMap'; +import MapVEuMapSidebar from '../map/MapVEuMapSidebar'; +// import legend +import { LegendProps } from '../map/MapVEuLegendSampleList'; + +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, +} as Meta; + +const defaultAnimation = { + method: 'geohash', + animationFunction: geohashAnimation, + duration: defaultAnimationDuration, +}; + +export const DonutMarkers: Story = (args) => { + const [markerElements, setMarkerElements] = useState< + ReactElement[] + >([]); + const [legendData, setLegendData] = useState([]); + const [viewport, setViewport] = useState({ + center: [13, 16], + zoom: 4, + }); + + // make an array of objects state to list highlighted markers + const [selectedMarkers, setSelectedMarkers] = useState([]); + + console.log('selectedMarkers =', selectedMarkers); + + // check if map panning occured + const [isPanning, setIsPanning] = useState(false); + + // set initial prevGeohashLevel state + const [prevGeohashLevel, setPrevGeohashLevel] = useState( + leafletZoomLevelToGeohashLevel(viewport.zoom) + ); + + const handleMarkerClick = (e: LeafletMouseEvent) => {}; + + const handleViewportChanged = useCallback( + async (bvp: BoundsViewport) => { + const markers = await getSpeciesDonuts( + bvp, + defaultAnimationDuration, + setLegendData, + handleMarkerClick, + // pass selectedMarkers and its setState + selectedMarkers, + setSelectedMarkers + ); + setMarkerElements(markers); + }, + // Note: the dependency of the isPanning is required to pass the latest selectedMarkers to get function + // because the function calls Marker component (DonutMarker/ChartMarker) + [setMarkerElements, isPanning] + ); + + return ( + <> + + + + + ); +}; + +DonutMarkers.args = { + height: '100vh', + width: '100vw', + showGrid: true, +}; + +export const ChartMarkers: Story = (args) => { + const [markerElements, setMarkerElements] = useState< + ReactElement[] + >([]); + const [legendData, setLegendData] = useState([]); + const [legendRadioValue, setLegendRadioValue] = + useState('Individual'); + const [viewport, setViewport] = useState({ + center: [13, 0], + zoom: 6, + }); + + const [dependentAxisRange, setDependentAxisRange] = useState([ + 0, 0, + ]); + + const duration = defaultAnimationDuration; + + // make an array of objects state to list highlighted markers + const [selectedMarkers, setSelectedMarkers] = useState([]); + + console.log('selectedMarkers =', selectedMarkers); + + // check if map panning occured + const [isPanning, setIsPanning] = useState(false); + + // set initial prevGeohashLevel state + const [prevGeohashLevel, setPrevGeohashLevel] = useState( + leafletZoomLevelToGeohashLevel(viewport.zoom) + ); + + const handleMarkerClick = (e: LeafletMouseEvent) => {}; + + // send legendRadioValue instead of knob_YAxisRangeMethod: also send setYAxisRangeValue + const handleViewportChanged = useCallback( + async (bvp: BoundsViewport) => { + // anim add duration & scrambleKeys + const markers = await getCollectionDateChartMarkers( + bvp, + duration, + setLegendData, + handleMarkerClick, + legendRadioValue, + setDependentAxisRange, + // pass selectedMarkers and its setState + selectedMarkers, + setSelectedMarkers + ); + setMarkerElements(markers); + }, + // Note: the dependency of the isPanning is required to pass the latest selectedMarkers to get function + // because the function calls Marker component (DonutMarker/ChartMarker) + [setMarkerElements, legendRadioValue, isPanning] + ); + + return ( + <> + + + + + ); +}; + +ChartMarkers.args = { + height: '100vh', + width: '100vw', + showGrid: true, +}; diff --git a/packages/libs/components/src/stories/api/getMarkersFromFixtureData.tsx b/packages/libs/components/src/stories/api/getMarkersFromFixtureData.tsx old mode 100644 new mode 100755 index cf79387438..92c854a296 --- a/packages/libs/components/src/stories/api/getMarkersFromFixtureData.tsx +++ b/packages/libs/components/src/stories/api/getMarkersFromFixtureData.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { BoundsViewport, Bounds } from '../../map/Types'; import { allColorsHex, chartMarkerColorsHex } from '../../map/config/map'; import { leafletZoomLevelToGeohashLevel } from '../../map/utils/leaflet-geohash'; @@ -6,6 +6,8 @@ import DonutMarker, { DonutMarkerProps } from '../../map/DonutMarker'; import ChartMarker from '../../map/ChartMarker'; import { LeafletMouseEvent } from 'leaflet'; +import { markerDataProp } from '../../map/BoundsDriftMarker'; + function sleep(ms: number): Promise<() => void> { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -17,6 +19,9 @@ export const getSpeciesDonuts = async ( legendData: Array<{ label: string; value: number; color: string }> ) => void, handleMarkerClick: (e: LeafletMouseEvent) => void, + // add two new props, selectedMarkers and setSelectedMarkers + selectedMarkers?: markerDataProp[], + setSelectedMarkers?: React.Dispatch>, delay: number = 0, zoomLevelToGeohashLevel: ( zoomLevel: number @@ -126,6 +131,9 @@ export const getSpeciesDonuts = async ( duration={duration} markerScale={donutSize / 40} markerLabel={donutSize < 40 ? '' : undefined} + // pass two new props + selectedMarkers={selectedMarkers} + setSelectedMarkers={setSelectedMarkers} /> ); }); @@ -236,6 +244,9 @@ export const getCollectionDateChartMarkers = async ( handleMarkerClick: (e: LeafletMouseEvent) => void, legendRadioValue: string, setDependentAxisRange: (dependentAxisRange: number[]) => void, + // add two new props, selectedMarkers and setSelectedMarkers + selectedMarkers?: markerDataProp[], + setSelectedMarkers?: React.Dispatch>, delay: number = 0, dependentAxisLogScale?: boolean ) => { @@ -372,6 +383,9 @@ export const getCollectionDateChartMarkers = async ( duration={duration} onClick={handleMarkerClick} dependentAxisLogScale={dependentAxisLogScale} + // pass two new props + selectedMarkers={selectedMarkers} + setSelectedMarkers={setSelectedMarkers} /> ); }); diff --git a/packages/libs/eda/src/lib/map/MapVEu.scss b/packages/libs/eda/src/lib/map/MapVEu.scss index aba6ef1bb4..e7ada95555 100644 --- a/packages/libs/eda/src/lib/map/MapVEu.scss +++ b/packages/libs/eda/src/lib/map/MapVEu.scss @@ -27,3 +27,24 @@ } } } +/* donut and bubble markers highlight */ +.highlight-donutmarker, +.highlight-bubblemarker { + background-clip: padding-box; + border-radius: 50%; + /* add inset to reduce a gap between the element and the box-shadow */ + box-shadow: 2.5px 2.5px 0px 6px rgba(255, 255, 0, 1), + 2.5px 2.5px 0px 2px rgba(255, 255, 0, 1) inset; + z-index: 9999 !important; +} + +/* chart marker highlight */ +.highlight-chartmarker { + background-clip: padding-box; + /* the rounded corner of the chart marker has 10px in radius */ + border-radius: 10px; + /* add inset to reduce a gap between the element and the box-shadow */ + box-shadow: -1px -0.5px 0px 6px rgba(255, 255, 0, 1), + -1px -0.5px 0px 2px rgba(255, 255, 0, 1) inset; + z-index: 9999 !important; +} diff --git a/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx b/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx index 805b84f598..28cf340aa0 100755 --- a/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx @@ -84,6 +84,8 @@ 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'; + enum MapSideNavItemLabels { Download = 'Download', Filter = 'Filter', @@ -383,6 +385,16 @@ function MapAnalysisImpl(props: ImplProps) { 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); + + // set initial prevGeohashLevel state + const [prevGeohashLevel, setPrevGeohashLevel] = useState( + geoConfig?.zoomLevelToAggregationLevel(appState.viewport.zoom) + ); + const sidePanelMenuEntries: SidePanelMenuEntry[] = [ { type: 'heading', @@ -772,6 +784,9 @@ function MapAnalysisImpl(props: ImplProps) { filteredCounts, hideVizInputsAndControls, setHideVizInputsAndControls, + // selectedMarkers and its state function + selectedMarkers, + setSelectedMarkers, }; return ( @@ -864,6 +879,13 @@ function MapAnalysisImpl(props: ImplProps) { } // pass defaultViewport & isStandAloneMap props for custom zoom control defaultViewport={defaultViewport} + // multiple markers selection + // pass selectedMarkers and its setState + selectedMarkers={selectedMarkers} + setSelectedMarkers={setSelectedMarkers} + // pass geohash level and setState + prevGeohashLevel={prevGeohashLevel} + setPrevGeohashLevel={setPrevGeohashLevel} > {activeMapTypePlugin?.MapLayerComponent && ( ; + // pass selectedMarkers and its state function const markers = markerData.markerProps?.map((markerProps) => ( - + )); 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 index 2918cfec89..dd4c9e22cb 100644 --- a/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/BubbleMarkerMapType.tsx +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/BubbleMarkerMapType.tsx @@ -178,6 +178,10 @@ function BubbleMapConfigurationPanel(props: MapTypeConfigPanelProps) { * 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, @@ -189,8 +193,14 @@ function BubbleMapLayer(props: MapTypeMapLayerProps) { if (markersData.error) return ; + // pass selectedMarkers and its state function const markers = markersData.data?.markersData.map((markerProps) => ( - + )); return ( diff --git a/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/DonutMarkerMapType.tsx b/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/DonutMarkerMapType.tsx index e3a5cfb95a..6efd93f7ca 100644 --- a/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/DonutMarkerMapType.tsx +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/plugins/DonutMarkerMapType.tsx @@ -287,6 +287,10 @@ function ConfigPanelComponent(props: MapTypeConfigPanelProps) { } 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({ @@ -304,8 +308,14 @@ function MapLayerComponent(props: MapTypeMapLayerProps) { if (markerDataResponse.error) return ; + // pass selectedMarkers and its state function const markers = markerDataResponse.markerProps?.map((markerProps) => ( - + )); return ( <> diff --git a/packages/libs/eda/src/lib/map/analysis/mapTypes/types.ts b/packages/libs/eda/src/lib/map/analysis/mapTypes/types.ts index 66fd2bd865..fb521e2db5 100644 --- a/packages/libs/eda/src/lib/map/analysis/mapTypes/types.ts +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/types.ts @@ -9,6 +9,7 @@ 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[]; @@ -39,6 +40,9 @@ export interface MapTypeMapLayerProps { filtersIncludingViewport: Filter[]; hideVizInputsAndControls: boolean; setHideVizInputsAndControls: (hide: boolean) => void; + // selectedMarkers and its state function + selectedMarkers?: markerDataProp[]; + setSelectedMarkers?: React.Dispatch>; } /**