diff --git a/packages/libs/components/src/map/AreaSelect.tsx b/packages/libs/components/src/map/AreaSelect.tsx new file mode 100755 index 0000000000..b67d9fff04 --- /dev/null +++ b/packages/libs/components/src/map/AreaSelect.tsx @@ -0,0 +1,56 @@ +/* + * Select area by ctrl + mouse + */ + +// several type errors because leaflet-area-select does not have type definition +// so ignore it for now +// @ts-nocheck +import { useEffect } from 'react'; +import { useMap } from 'react-leaflet'; +import L from 'leaflet'; +// load modified leaflet-area-select directly +import './AreaSelectHack.js'; +import { Bounds as BoundsProp } from './Types'; + +interface AreaSelectProps { + setBoxCoord: (value: React.SetStateAction) => void; +} + +export default function AreaSelect({ setBoxCoord }: AreaSelectProps) { + const map = useMap(); + + useEffect(() => { + if (!map.selectArea) return; + + // map focus + map.getContainer().focus(); + + map.selectArea.enable(); + + // use ctrl key + map.selectArea.setControlKey(true); + + // get coordinates of selected area + map.on('areaselected', (e) => { + const coordinates = e.bounds.toBBoxString().split(',').map(Number); + // coordinates format is SW lng & lon and NE lng & lon + // so converting to SW lat & lng and NE lat & lng and set boxCoord + setBoxCoord({ + southWest: { lat: coordinates[1], lng: coordinates[0] }, + northEast: { lat: coordinates[3], lng: coordinates[2] }, + }); + }); + + // restrict selection area + const bounds = map.getBounds().pad(-0.25); + // check restricted area on start and move + map.selectArea.setValidate((layerPoint) => { + return bounds.contains(this._map.layerPointToLatLng(layerPoint)); + }); + + // switch it off + map.selectArea.setValidate(); + }, []); + + return null; +} diff --git a/packages/libs/components/src/map/AreaSelectHack.js b/packages/libs/components/src/map/AreaSelectHack.js new file mode 100644 index 0000000000..b0206ed70a --- /dev/null +++ b/packages/libs/components/src/map/AreaSelectHack.js @@ -0,0 +1,365 @@ +/* + * This is taken from leaflet-area-select library + * somehow, installing the library does not work properly + * Thus, directly loading it by trimming down some unnecessary codes + */ + +import L from 'leaflet'; + +const trueFn = function () { + return true; +}; + +/** + * @class L.Map.SelectArea + * @extends {L.Map.BoxZoom} + */ +L.Map.SelectArea = L.Map.BoxZoom.extend({ + statics: { + /** + * @static + * @type {String} + */ + AREA_SELECTED: 'areaselected', + + /** + * @static + * @type {String} + */ + AREA_SELECT_START: 'areaselectstart', + + /** + * @static + * @type {String} + */ + AREA_SELECTION_TOGGLED: 'areaselecttoggled', + }, + + options: { + shiftKey: false, + ctrlKey: true, + // enable metaKey + metaKey: true, + validate: trueFn, + autoDisable: false, + cursor: 'crosshair', + }, + + /** + * @param {L.Map} map + * @constructor + */ + initialize: function (map, options) { + L.Util.setOptions(this, options || {}); + L.Map.BoxZoom.prototype.initialize.call(this, map); + + /** + * @type {Function} + */ + this._validate = null; + + /** + * @type {Boolean} + */ + this._moved = false; + + /** + * @type {Boolean} + */ + this._autoDisable = + (!this.options.ctrlKey || !this.options.metaKey) && + this.options.autoDisable; + + /** + * @type {L.Point} + */ + this._lastLayerPoint = null; + + /** + * @type {String|Null} + */ + this._beforeCursor = null; + + this.setValidate(this.options.validate); + this.setAutoDisable(this.options.autoDisable); + }, + + /** + * @param {Function=} validate + * @return {SelectArea} + */ + setValidate: function (validate) { + var handler = this; + if (typeof validate !== 'function') { + validate = trueFn; + } + this._validate = function (layerPoint) { + return validate.call(handler, layerPoint); + }; + return this; + }, + + /** + * @param {Boolean} autoDisable + */ + setAutoDisable: function (autoDisable) { + this._autoDisable = !!autoDisable; + }, + + /** + * @param {Boolean} on + */ + setControlKey: function (on) { + var wasEnabled = this._enabled; + if (wasEnabled) this.disable(); + this.options.ctrlKey = !!on; + this.options.metaKey = !!on; + if (on) this.options.shiftKey = false; + if (wasEnabled) this.enable(); + }, + + /** + * @param {Boolean} on + */ + setShiftKey: function (on) { + var wasEnabled = this._enabled; + if (wasEnabled) this.disable(); + this.options.shiftKey = !!on; + if (on) { + this.options.ctrlKey = false; + this.options.metaKey = false; + } + if (wasEnabled) this.enable(); + }, + + /** + * Disable dragging or zoombox + * @param {Function=} validate + * @param {Boolean=} autoDisable + */ + enable: function (validate, autoDisable) { + if (this.options.shiftKey) { + if (this._map.boxZoom) { + this._map.boxZoom.disable(); + } + } else if (!this.options.ctrlKey) { + this._map.dragging.disable(); + } else if (!this.options.metaKey) { + this._map.dragging.disable(); + } + L.Map.BoxZoom.prototype.enable.call(this); + + if (!this.options.ctrlKey) this._setCursor(); + if (!this.options.metaKey) this._setCursor(); + + if (validate) this.setValidate(validate); + this.setAutoDisable(autoDisable); + + this._map.fire(L.Map.SelectArea.AREA_SELECTION_TOGGLED); + }, + + /** + * Re-enable box zoom or dragging + */ + disable: function () { + L.Map.BoxZoom.prototype.disable.call(this); + + if (!this.options.ctrlKey) this._restoreCursor(); + if (!this.options.metalKey) this._restoreCursor(); + + if (this.options.shiftKey) { + if (this._map.boxZoom) { + this._map.boxZoom.enable(); + } + } else { + this._map.dragging.enable(); + } + + this._map.fire(L.Map.SelectArea.AREA_SELECTION_TOGGLED); + }, + + /** + * Also listen to ESC to cancel interaction + * @override + */ + addHooks: function () { + L.Map.BoxZoom.prototype.addHooks.call(this); + L.DomEvent.on(document, 'keyup', this._onKeyUp, this) + .on(document, 'keydown', this._onKeyPress, this) + .on(document, 'contextmenu', this._onMouseDown, this) + .on(window, 'blur', this._onBlur, this); + this._map.on('dragstart', this._onMouseDown, this); + }, + + /** + * @override + */ + removeHooks: function () { + L.Map.BoxZoom.prototype.removeHooks.call(this); + L.DomEvent.off(document, 'keyup', this._onKeyUp, this) + .off(document, 'keydown', this._onKeyPress, this) + .off(document, 'contextmenu', this._onMouseDown, this) + .off(window, 'blur', this._onBlur, this); + this._map.off('dragstart', this._onMouseDown, this); + }, + + /** + * @override + */ + _onMouseDown: function (e) { + this._moved = false; + this._lastLayerPoint = null; + + // simplified && block shiftKey + mouse event + if (e.shiftKey || (e.which !== 1 && e.button !== 1)) { + return false; + } + + L.DomEvent.stop(e); + + var layerPoint = this._map.mouseEventToLayerPoint(e); + if (!this._validate(layerPoint)) return false; + + L.DomUtil.disableTextSelection(); + L.DomUtil.disableImageDrag(); + + this._startLayerPoint = layerPoint; + + L.DomEvent.on(document, 'mousemove', this._onMouseMove, this) + .on(document, 'mouseup', this._onMouseUp, this) + .on(document, 'keydown', this._onKeyDown, this); + }, + + /** + * @override + */ + _onMouseMove: function (e) { + if (!this._moved) { + this._box = L.DomUtil.create('div', 'leaflet-zoom-box', this._pane); + L.DomUtil.setPosition(this._box, this._startLayerPoint); + this._map.fire(L.Map.SelectArea.AREA_SELECT_START); + } + + var startPoint = this._startLayerPoint; + var box = this._box; + + var layerPoint = this._map.mouseEventToLayerPoint(e); + var offset = layerPoint.subtract(startPoint); + + if (!this._validate(layerPoint)) return; + this._lastLayerPoint = layerPoint; + + var newPos = new L.Point( + Math.min(layerPoint.x, startPoint.x), + Math.min(layerPoint.y, startPoint.y) + ); + + L.DomUtil.setPosition(box, newPos); + + this._moved = true; + + // TODO refactor: remove hardcoded 4 pixels + box.style.width = Math.max(0, Math.abs(offset.x) - 4) + 'px'; + box.style.height = Math.max(0, Math.abs(offset.y) - 4) + 'px'; + }, + + /** + * General on/off toggle + * @param {KeyboardEvent} e + */ + _onKeyUp: function (e) { + if (e.keyCode === 27) { + if (this._moved && this._box) { + this._finish(); + } + // this.disable(); + } else if (this.options.ctrlKey || this.options.metaKey) { + this._restoreCursor(); + this._map.dragging.enable(); + } + }, + + /** + * Key down listener to enable on ctrl-press + * @param {KeyboardEvent} e + */ + _onKeyPress: function (e) { + if ( + (this.options.ctrlKey && + (e.ctrlKey || e.type === 'dragstart') && + this._beforeCursor === null) || + (this.options.metaKey && + (e.metaKey || e.type === 'dragstart') && + this._beforeCursor === null) + ) { + this._setCursor(); + this._map.dragging._draggable._onUp(e); // hardcore + this._map.dragging.disable(); + } + }, + + /** + * Window blur listener to restore state + * @param {Event} e + */ + _onBlur: function (e) { + this._restoreCursor(); + this._map.dragging.enable(); + }, + + /** + * Set crosshair cursor + */ + _setCursor: function () { + this._beforeCursor = this._container.style.cursor; + this._container.style.cursor = this.options.cursor; + }, + + /** + * Restore status quo cursor + */ + _restoreCursor: function () { + this._container.style.cursor = this._beforeCursor; + this._beforeCursor = null; + }, + + /** + * @override + */ + _onMouseUp: function (e) { + this._finish(); + + var map = this._map; + var layerPoint = this._lastLayerPoint; // map.mouseEventToLayerPoint(e); + + if (!layerPoint || this._startLayerPoint.equals(layerPoint)) return; + L.DomEvent.stop(e); + + var bounds = new L.LatLngBounds( + map.layerPointToLatLng(this._startLayerPoint), + map.layerPointToLatLng(layerPoint) + ); + + if (this._autoDisable) { + this.disable(); + } else { + this._restoreCursor(); + } + + this._moved = false; + + L.Util.requestAnimFrame(function () { + map.fire(L.Map.SelectArea.AREA_SELECTED, { + bounds: bounds, + }); + }); + }, +}); + +// expose setting +L.Map.mergeOptions({ + selectArea: false, +}); + +// register hook +L.Map.addInitHook('addHandler', 'selectArea', L.Map.SelectArea); diff --git a/packages/libs/components/src/map/BoundsDriftMarker.tsx b/packages/libs/components/src/map/BoundsDriftMarker.tsx index 58f0bcd5e4..bfec167d38 100755 --- a/packages/libs/components/src/map/BoundsDriftMarker.tsx +++ b/packages/libs/components/src/map/BoundsDriftMarker.tsx @@ -368,6 +368,6 @@ export default function BoundsDriftMarker({ ); } -function mouseEventHasModifierKey(event: MouseEvent) { +export function mouseEventHasModifierKey(event: MouseEvent) { return event.ctrlKey || event.altKey || event.metaKey || event.shiftKey; } diff --git a/packages/libs/components/src/map/SemanticMarkers.tsx b/packages/libs/components/src/map/SemanticMarkers.tsx index b623d770f5..ce76f29480 100644 --- a/packages/libs/components/src/map/SemanticMarkers.tsx +++ b/packages/libs/components/src/map/SemanticMarkers.tsx @@ -11,6 +11,7 @@ import { BoundsDriftMarkerProps } from './BoundsDriftMarker'; import { useMap, useMapEvents } from 'react-leaflet'; import { LatLngBounds } from 'leaflet'; import { debounce, isEqual } from 'lodash'; +import { mouseEventHasModifierKey } from './BoundsDriftMarker'; export interface SemanticMarkersProps { markers: Array>; @@ -50,8 +51,13 @@ export default function SemanticMarkers({ // cancel marker selection with a single click on the map useMapEvents({ - click: () => { - if (setSelectedMarkers != null) setSelectedMarkers(undefined); + click: (e) => { + // excluding a combination of special keys and mouse click + if ( + setSelectedMarkers != null && + !mouseEventHasModifierKey(e.originalEvent) + ) + setSelectedMarkers(undefined); }, }); diff --git a/packages/libs/components/src/stories/MarkerSelection.stories.tsx b/packages/libs/components/src/stories/MarkerSelection.stories.tsx index 0bd786ec15..09b720368f 100755 --- a/packages/libs/components/src/stories/MarkerSelection.stories.tsx +++ b/packages/libs/components/src/stories/MarkerSelection.stories.tsx @@ -23,6 +23,10 @@ import geohashAnimation from '../map/animation_functions/geohash'; import SemanticMarkers from '../map/SemanticMarkers'; +// area selection +import AreaSelect from '../map/AreaSelect'; +import { Bounds as BoundsProp } from '../map/Types'; + export default { title: 'Map/Marker Selection', component: MapVEuMapSidebar, @@ -65,6 +69,9 @@ export const DonutMarkers: Story = (args) => { [setMarkerElements] ); + // coordinates of selected area + const [boxCoord, setBoxCoord] = useState(undefined); + return ( <> = (args) => { onBoundsChanged={handleViewportChanged} zoomLevelToGeohashLevel={leafletZoomLevelToGeohashLevel} > + {/* area selection by ctrl + mouse */} + + (undefined); + + const selectedMarkers = markerConfigurations.find( + (markerConfiguration) => + markerConfiguration.type === activeMarkerConfigurationType + )?.selectedMarkers; + + //DKDK temporarily enabled to check selectedMarkers + console.log('selectedMarkers =', selectedMarkers); + return ( {(apps: ComputationAppOverview[]) => { @@ -767,6 +782,8 @@ function MapAnalysisImpl(props: ImplProps) { setHideVizInputsAndControls, setStudyDetailsPanelConfig, headerButtons: HeaderButtons, + // pass coordinates of selected area + boxCoord: boxCoord, }; return ( @@ -856,6 +873,9 @@ function MapAnalysisImpl(props: ImplProps) { onMapDrag={closePanel} onMapZoom={closePanel} > + {/* area selection */} + + {activeMapTypePlugin?.MapLayerComponent && ( void, + boxCoord: BoundsProp | undefined, + markerProps?: DonutMarkerProps[] | ChartMarkerProps[] | BubbleMarkerProps[] +) { + // set useEffect for area selection to change selectedMarkers via setSelectedmarkers + // define useEffect here to avoid conditional call + // thus, this contains duplicate code, selectedMarkers + return useEffect(() => { + if ( + !markerDataResponse.error && + !markerDataResponse.isFetching && + boxCoord != null + ) { + // define selectedMarkers + const selectedMarkers = appState.markerConfigurations.find( + (markerConfiguration) => + markerConfiguration.type === appState.activeMarkerConfigurationType + )?.selectedMarkers; + + // find markers within area selection + const boxCoordMarkers = markerProps + ?.map((marker) => { + // check if the center of a marker is within selected area + return marker.position.lat >= boxCoord.southWest.lat && + marker.position.lat <= boxCoord.northEast.lat && + marker.position.lng >= boxCoord.southWest.lng && + marker.position.lng <= boxCoord.northEast.lng + ? marker.id + : ''; + }) + .filter((item: string) => item !== ''); + + // then, update selectedMarkers & check duplicate markers + setSelectedMarkers([ + ...(selectedMarkers ?? []), + ...(boxCoordMarkers ?? []) + .map((boxCoordMarker) => { + return selectedMarkers?.some( + (selectedMarker) => selectedMarker === boxCoordMarker + ) + ? '' + : boxCoordMarker; + }) + .filter((item) => item !== ''), + ]); + } + // additional dependency may cause infinite loop, e.g., markerDataResponse + }, [boxCoord]); +} 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 bb8dda4dfd..8407ef445a 100644 --- a/packages/libs/eda/src/lib/map/analysis/mapTypes/types.ts +++ b/packages/libs/eda/src/lib/map/analysis/mapTypes/types.ts @@ -10,6 +10,7 @@ import { ComputationAppOverview } from '../../../core/types/visualization'; import { AppState, PanelConfig } from '../appState'; import { EntityCounts } from '../../../core/hooks/entityCounts'; import { SiteInformationProps } from '../Types'; +import { Bounds as BoundsProp } from '@veupathdb/components/lib/map/Types'; // should we just use one type: MapTypeMapLayerProps? // and get rid of this one? @@ -50,6 +51,8 @@ export interface MapTypeMapLayerProps { ) => void; siteInformationProps?: SiteInformationProps; headerButtons?: React.FC; + // coordinates of selected area + boxCoord?: BoundsProp; } /**