From 9caa5544ed77f75d7d2d1e55f113d3b195ea60a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Gonz=C3=A1lez=20Mu=C3=B1oz?= Date: Fri, 24 Nov 2023 15:10:14 +0100 Subject: [PATCH 1/2] multi-layer map tooltip --- .../data-tool/content/map/index.tsx | 12 +- .../map/layers-toolbox/layers-list/index.tsx | 1 + .../data-tool/content/map/popup/eez/index.tsx | 35 +++-- .../content/map/popup/generic/index.tsx | 119 ++++++++++++++ .../data-tool/content/map/popup/index.tsx | 108 +++++++++++-- .../map/popup/protected-area/index.tsx | 147 ++++++++++++++++++ frontend/src/lib/json-converter/index.ts | 4 + frontend/src/lib/utils/formats.ts | 4 +- frontend/src/styles/globals.css | 18 ++- 9 files changed, 420 insertions(+), 28 deletions(-) create mode 100644 frontend/src/containers/data-tool/content/map/popup/generic/index.tsx create mode 100644 frontend/src/containers/data-tool/content/map/popup/protected-area/index.tsx diff --git a/frontend/src/containers/data-tool/content/map/index.tsx b/frontend/src/containers/data-tool/content/map/index.tsx index d0a59c5f..547be00f 100644 --- a/frontend/src/containers/data-tool/content/map/index.tsx +++ b/frontend/src/containers/data-tool/content/map/index.tsx @@ -37,8 +37,8 @@ const DataToolMap: React.FC = () => { const isSidebarOpen = useAtomValue(sidebarAtom); const setPopup = useSetAtom(popupAtom); const { locationCode } = useParams(); - const hoveredPolygonId = useRef(null); const locationBbox = useAtomValue(bboxLocation); + const hoveredPolygonId = useRef[0] | null>(null); const locationsQuery = useGetLocations( {}, @@ -104,7 +104,7 @@ const DataToolMap: React.FC = () => { map.setFeatureState( { source: e.features?.[0].source, - id: hoveredPolygonId.current, + id: hoveredPolygonId.current.id, sourceLayer: e.features?.[0].sourceLayer, }, { hover: false } @@ -119,7 +119,7 @@ const DataToolMap: React.FC = () => { { hover: true } ); - hoveredPolygonId.current = e.features[0].id; + hoveredPolygonId.current = e.features[0]; } }, [map, hoveredPolygonId] @@ -129,7 +129,11 @@ const DataToolMap: React.FC = () => { if (hoveredPolygonId.current !== null) { map.setFeatureState( // ? not a fan of harcoding the sources here, but there is no other way to find out the source - { source: 'ezz-source', id: hoveredPolygonId.current, sourceLayer: 'eez_v11' }, + { + source: hoveredPolygonId.current.source, + id: hoveredPolygonId.current.id, + sourceLayer: hoveredPolygonId.current.sourceLayer, + }, { hover: false } ); } diff --git a/frontend/src/containers/data-tool/content/map/layers-toolbox/layers-list/index.tsx b/frontend/src/containers/data-tool/content/map/layers-toolbox/layers-list/index.tsx index 8023771b..49220165 100644 --- a/frontend/src/containers/data-tool/content/map/layers-toolbox/layers-list/index.tsx +++ b/frontend/src/containers/data-tool/content/map/layers-toolbox/layers-list/index.tsx @@ -30,6 +30,7 @@ const LayersDropdown = (): JSX.Element => { query: { select: ({ data }) => data, placeholderData: { data: [] }, + keepPreviousData: true, }, } ); diff --git a/frontend/src/containers/data-tool/content/map/popup/eez/index.tsx b/frontend/src/containers/data-tool/content/map/popup/eez/index.tsx index be4454c5..6aa16a13 100644 --- a/frontend/src/containers/data-tool/content/map/popup/eez/index.tsx +++ b/frontend/src/containers/data-tool/content/map/popup/eez/index.tsx @@ -77,28 +77,35 @@ const EEZLayerPopup = ({ locationId }) => { return DATA_REF.current; }, [popup, source, layersInteractiveIds, map, rendered]); - const locationQuery = useGetLocations( + const locationsQuery = useGetLocations( { filters: { - code: DATA?.ISO_SOV1, + code: { + $in: ['GLOB', DATA?.ISO_SOV1], + }, }, }, { query: { enabled: !!DATA?.ISO_SOV1, - select: ({ data }) => data[0], + select: ({ data }) => data, }, } ); + const worldLocation = locationsQuery.data?.find(({ attributes: { code } }) => code === 'GLOB'); + const currentLocation = locationsQuery.data?.find(({ attributes: { code } }) => code !== 'GLOB'); + // handle renderer const handleMapRender = useCallback(() => { - setRendered(!!map?.loaded() && !!map?.areTilesLoaded()); + setRendered(map?.loaded() && map?.areTilesLoaded()); }, [map]); useEffect(() => { map?.on('render', handleMapRender); + setRendered(map?.loaded() && map?.areTilesLoaded()); + return () => { map?.off('render', handleMapRender); }; @@ -106,26 +113,32 @@ const EEZLayerPopup = ({ locationId }) => { if (!DATA) return null; + const coverage = + currentLocation?.attributes?.totalMarineArea / worldLocation?.attributes?.totalMarineArea; + return ( <> -
+

{DATA?.GEONAME}

- {locationQuery.isFetching && !locationQuery.isFetched && ( + {locationsQuery.isFetching && !locationsQuery.isFetched && ( Loading... )} - {locationQuery.isFetched && !locationQuery.data && ( + {locationsQuery.isFetched && !locationsQuery.data && ( No data available )} - {locationQuery.isFetched && locationQuery.data && ( + {locationsQuery.isFetched && locationsQuery.data && ( <>
Marine conservation coverage
- 39% + {format({ + value: coverage, + id: 'formatPercentage', + })}
{format({ - value: locationQuery.data?.attributes?.totalMarineArea, + value: currentLocation?.attributes?.totalMarineArea, id: 'formatKM', })} Km2 @@ -135,7 +148,7 @@ const EEZLayerPopup = ({ locationId }) => { className="block border border-black p-4 text-center font-mono uppercase" href={`${ PAGES.dataTool - }/${locationQuery.data?.attributes?.code.toUpperCase()}?${searchParams.toString()}`} + }/${currentLocation?.attributes?.code.toUpperCase()}?${searchParams.toString()}`} > Open country insights diff --git a/frontend/src/containers/data-tool/content/map/popup/generic/index.tsx b/frontend/src/containers/data-tool/content/map/popup/generic/index.tsx new file mode 100644 index 00000000..fc541052 --- /dev/null +++ b/frontend/src/containers/data-tool/content/map/popup/generic/index.tsx @@ -0,0 +1,119 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { useMap } from 'react-map-gl'; + +import type { Feature } from 'geojson'; +import { useAtomValue } from 'jotai'; + +import { format } from '@/lib/utils/formats'; +import { layersInteractiveIdsAtom, popupAtom } from '@/store/map'; +import { useGetLayersId } from '@/types/generated/layer'; +import { LayerTyped, InteractionConfig } from '@/types/layers'; + +const TERMS_CLASSES = 'font-mono font-bold uppercase'; + +const GenericPopup = ({ + locationId, + ...restConfig +}: InteractionConfig & { locationId: number }) => { + const [rendered, setRendered] = useState(false); + const DATA_REF = useRef(); + const { default: map } = useMap(); + const { events } = restConfig; + + const popup = useAtomValue(popupAtom); + const layersInteractiveIds = useAtomValue(layersInteractiveIdsAtom); + + const layerQuery = useGetLayersId( + locationId, + { + populate: 'metadata', + }, + { + query: { + select: ({ data }) => ({ + source: (data.attributes as LayerTyped).config?.source, + click: (data.attributes as LayerTyped)?.interaction_config?.events.find( + (ev) => ev.type === 'click' + ), + }), + }, + } + ); + + const { source } = layerQuery.data; + + const DATA = useMemo(() => { + if (source?.type === 'vector' && rendered && popup && map) { + const point = map.project(popup.lngLat); + + // check if the point is outside the canvas + if ( + point.x < 0 || + point.x > map.getCanvas().width || + point.y < 0 || + point.y > map.getCanvas().height + ) { + return DATA_REF.current; + } + + const query = map.queryRenderedFeatures(point, { + layers: layersInteractiveIds, + }); + + const d = query.find((d) => { + return d.source === source.id; + })?.properties; + + DATA_REF.current = d; + + if (d) { + return DATA_REF.current; + } + } + + return DATA_REF.current; + }, [popup, source, layersInteractiveIds, map, rendered]); + + // handle renderer + const handleMapRender = useCallback(() => { + setRendered(map?.loaded() && map?.areTilesLoaded()); + }, [map]); + + useEffect(() => { + map?.on('render', handleMapRender); + + setRendered(map?.loaded() && map?.areTilesLoaded()); + + return () => { + map?.off('render', handleMapRender); + }; + }, [map, handleMapRender]); + + if (!DATA) return null; + + const values = events.find((ev) => ev.type === 'click')?.values; + const name = values?.find((v) => v.label === 'Name'); + const restValues = values?.filter((v) => v.label !== 'Name') ?? []; + + return ( + <> +
+ {name &&

{DATA?.[name.key]}

} +
+ {restValues.map(({ key, label, format: customFormat }) => ( +
+
{label}
+
+ {customFormat && format({ value: DATA[key], ...customFormat })} + {!customFormat && DATA[key]} +
+
+ ))} +
+
+ + ); +}; + +export default GenericPopup; diff --git a/frontend/src/containers/data-tool/content/map/popup/index.tsx b/frontend/src/containers/data-tool/content/map/popup/index.tsx index 6bda5d7f..bc44c715 100644 --- a/frontend/src/containers/data-tool/content/map/popup/index.tsx +++ b/frontend/src/containers/data-tool/content/map/popup/index.tsx @@ -1,18 +1,86 @@ +import { useCallback, useEffect, useState } from 'react'; + import { Popup } from 'react-map-gl'; import { useAtomValue, useSetAtom } from 'jotai'; +import Icon from '@/components/ui/icon'; +import { + Select, + SelectTrigger, + SelectContent, + SelectItem, + SelectValue, +} from '@/components/ui/select'; import PopupItem from '@/containers/data-tool/content/map/popup/item'; import { layersInteractiveAtom, popupAtom } from '@/containers/data-tool/store'; +import CloseIcon from '@/styles/icons/close.svg?sprite'; +import { useGetLayers } from '@/types/generated/layer'; + +import { useSyncMapLayers } from '../sync-settings'; const PopupContainer = () => { const popup = useAtomValue(popupAtom); const layersInteractive = useAtomValue(layersInteractiveAtom); - const lys = [...layersInteractive].reverse(); + const [syncedLayers] = useSyncMapLayers(); + + const [selectedLayerId, setSelectedLayerId] = useState(null); const setPopup = useSetAtom(popupAtom); - if (!Object.keys(popup).length) return null; + const availableSources = popup?.features?.map(({ source }) => source); + + const { data: layersInteractiveData } = useGetLayers( + { + filters: { + id: { + $in: layersInteractive, + }, + }, + }, + { + query: { + enabled: layersInteractive.length > 1, + select: ({ data }) => + data + .filter( + ({ + attributes: { + config: { + // @ts-expect-error will check later + source: { id: sourceId }, + }, + }, + }) => availableSources?.includes(sourceId) + ) + .map(({ attributes: { title: label }, id: value }) => ({ + label, + value: value.toString(), + })) + .sort((a, b) => + syncedLayers.indexOf(Number(a.value)) > syncedLayers.indexOf(Number(b.value)) ? 1 : -1 + ), + }, + } + ); + + const closePopup = useCallback(() => { + setPopup({}); + }, [setPopup]); + + useEffect(() => { + if (!layersInteractive.length) { + closePopup(); + } + }, [layersInteractive, closePopup]); + + useEffect(() => { + if (layersInteractiveData?.[0]?.value) { + setSelectedLayerId(Number(layersInteractiveData[0].value)); + } + }, [layersInteractiveData]); + + if (!Object.keys(popup).length || !popup?.features?.length) return null; return ( { longitude={popup.lngLat.lng} closeOnClick={false} closeButton={false} - style={{ - padding: 0, - }} maxWidth="300px" - onClose={() => setPopup({})} + onClose={closePopup} + className="min-w-[250px]" > -
- {lys.map((id) => ( - - ))} +
+
+ +
+ {availableSources.length > 1 && ( + + )} + {selectedLayerId && }
); diff --git a/frontend/src/containers/data-tool/content/map/popup/protected-area/index.tsx b/frontend/src/containers/data-tool/content/map/popup/protected-area/index.tsx new file mode 100644 index 00000000..4911a3dc --- /dev/null +++ b/frontend/src/containers/data-tool/content/map/popup/protected-area/index.tsx @@ -0,0 +1,147 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { useMap } from 'react-map-gl'; + +import type { Feature } from 'geojson'; +import { useAtomValue } from 'jotai'; + +import { cn } from '@/lib/classnames'; +import { format } from '@/lib/utils/formats'; +import { layersInteractiveIdsAtom, popupAtom } from '@/store/map'; +import { useGetLayersId } from '@/types/generated/layer'; +import { useGetLocations } from '@/types/generated/location'; +import { LayerTyped } from '@/types/layers'; + +const TERMS_CLASSES = 'font-mono uppercase'; + +const ProtectedAreaPopup = ({ locationId }: { locationId: number }) => { + const [rendered, setRendered] = useState(false); + const DATA_REF = useRef(); + const { default: map } = useMap(); + + const popup = useAtomValue(popupAtom); + const layersInteractiveIds = useAtomValue(layersInteractiveIdsAtom); + + const layerQuery = useGetLayersId( + locationId, + { + populate: 'metadata', + }, + { + query: { + select: ({ data }) => ({ + source: (data.attributes as LayerTyped).config?.source, + click: (data.attributes as LayerTyped)?.interaction_config?.events.find( + (ev) => ev.type === 'click' + ), + }), + }, + } + ); + + const { source } = layerQuery.data; + + const DATA = useMemo(() => { + if (source?.type === 'vector' && rendered && popup && map) { + const point = map.project(popup.lngLat); + + // check if the point is outside the canvas + if ( + point.x < 0 || + point.x > map.getCanvas().width || + point.y < 0 || + point.y > map.getCanvas().height + ) { + return DATA_REF.current; + } + const query = map.queryRenderedFeatures(point, { + layers: layersInteractiveIds, + }); + + const d = query.find((d) => { + return d.source === source.id; + })?.properties; + + DATA_REF.current = d; + + if (d) { + return DATA_REF.current; + } + } + + return DATA_REF.current; + }, [popup, source, layersInteractiveIds, map, rendered]); + + const locationQuery = useGetLocations( + { + filters: { + code: 'GLOB', + }, + }, + { + query: { + select: ({ data }) => data[0], + }, + } + ); + + // handle renderer + const handleMapRender = useCallback(() => { + setRendered(map?.loaded() && map?.areTilesLoaded()); + }, [map]); + + useEffect(() => { + map?.on('render', handleMapRender); + + setRendered(map?.loaded() && map?.areTilesLoaded()); + + return () => { + map?.off('render', handleMapRender); + }; + }, [map, handleMapRender]); + + if (!DATA) return null; + + const globalCoverage = DATA.REP_M_AREA / locationQuery.data?.attributes?.totalMarineArea; + + const classNameByMPAType = cn({ + 'text-green': DATA?.PA_DEF === '1', + 'text-violet': DATA?.PA_DEF === '0', + }); + + return ( + <> +
+

{DATA?.NAME}

+ {locationQuery.isFetching && !locationQuery.isFetched && ( + Loading... + )} + {locationQuery.isFetched && !locationQuery.data && ( + No data available + )} + {locationQuery.isFetched && locationQuery.data && ( + <> +
+
Global coverage
+
+ {format({ + value: globalCoverage, + id: 'formatPercentage', + })} +
+
+ {format({ + value: DATA?.REP_M_AREA, + id: 'formatKM', + })} + Km2 +
+
+ + )} +
+ + ); +}; + +export default ProtectedAreaPopup; diff --git a/frontend/src/lib/json-converter/index.ts b/frontend/src/lib/json-converter/index.ts index ce589aa4..af97ac18 100644 --- a/frontend/src/lib/json-converter/index.ts +++ b/frontend/src/lib/json-converter/index.ts @@ -10,6 +10,8 @@ import { JSONConfiguration, JSONConverter } from '@deck.gl/json/typed'; // } from '@/components/map/legend/item-types'; import EEZLayerLegend from '@/containers/data-tool/content/map/layers-toolbox/legend/eez'; import EEZLayerPopup from '@/containers/data-tool/content/map/popup/eez'; +import GenericPopup from '@/containers/data-tool/content/map/popup/generic'; +import ProtectedAreaPopup from '@/containers/data-tool/content/map/popup/protected-area'; import FUNCTIONS from '@/lib/utils'; import { ParamsConfig } from '@/types/layers'; @@ -26,6 +28,8 @@ export const JSON_CONFIGURATION = new JSONConfiguration({ reactComponents: { EEZLayerPopup, EEZLayerLegend, + GenericPopup, + ProtectedAreaPopup, // LegendTypeBasic, // LegendTypeChoropleth, // LegendTypeGradient, diff --git a/frontend/src/lib/utils/formats.ts b/frontend/src/lib/utils/formats.ts index 7bb6ee4b..50ae64b3 100644 --- a/frontend/src/lib/utils/formats.ts +++ b/frontend/src/lib/utils/formats.ts @@ -1,7 +1,6 @@ export function formatPercentage(value: number, options?: Intl.NumberFormatOptions) { const v = Intl.NumberFormat('en-US', { - minimumFractionDigits: 0, - maximumFractionDigits: 2, + maximumFractionDigits: 3, style: 'percent', ...options, }); @@ -15,6 +14,7 @@ export function formatKM(value: number, options?: Intl.NumberFormatOptions) { compactDisplay: 'short', unit: 'kilometer', unitDisplay: 'short', + maximumSignificantDigits: 3, ...options, }); diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 22b78fd3..e77c0609 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -18,7 +18,23 @@ } .mapboxgl-popup-content { - @apply !p-0 !bg-white !rounded-none; + @apply !p-0 !bg-white !rounded-none border border-black; box-shadow: 0 0px 0px 1px rgba(26, 28, 34, 0.1), 0 1px 2px rgba(0, 0, 0, 0.1); } +.mapboxgl-popup-anchor-top .mapboxgl-popup-tip { + @apply !border-b-black +} + +.mapboxgl-popup-anchor-right .mapboxgl-popup-tip { + @apply !border-l-black +} + +.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip { + @apply !border-t-black +} + +.mapboxgl-popup-anchor-left .mapboxgl-popup-tip { + @apply !border-r-black +} + From d5be31d109862d6717f134c66516ee7083cdd71d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Gonz=C3=A1lez=20Mu=C3=B1oz?= Date: Mon, 27 Nov 2023 13:13:46 +0100 Subject: [PATCH 2/2] multi-layer map tooltip --- .../containers/data-tool/content/map/popup/generic/index.tsx | 2 +- .../data-tool/content/map/popup/protected-area/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/containers/data-tool/content/map/popup/generic/index.tsx b/frontend/src/containers/data-tool/content/map/popup/generic/index.tsx index fc541052..f5b14eb2 100644 --- a/frontend/src/containers/data-tool/content/map/popup/generic/index.tsx +++ b/frontend/src/containers/data-tool/content/map/popup/generic/index.tsx @@ -5,8 +5,8 @@ import { useMap } from 'react-map-gl'; import type { Feature } from 'geojson'; import { useAtomValue } from 'jotai'; +import { layersInteractiveIdsAtom, popupAtom } from '@/containers/data-tool/store'; import { format } from '@/lib/utils/formats'; -import { layersInteractiveIdsAtom, popupAtom } from '@/store/map'; import { useGetLayersId } from '@/types/generated/layer'; import { LayerTyped, InteractionConfig } from '@/types/layers'; diff --git a/frontend/src/containers/data-tool/content/map/popup/protected-area/index.tsx b/frontend/src/containers/data-tool/content/map/popup/protected-area/index.tsx index 4911a3dc..64336311 100644 --- a/frontend/src/containers/data-tool/content/map/popup/protected-area/index.tsx +++ b/frontend/src/containers/data-tool/content/map/popup/protected-area/index.tsx @@ -5,9 +5,9 @@ import { useMap } from 'react-map-gl'; import type { Feature } from 'geojson'; import { useAtomValue } from 'jotai'; +import { layersInteractiveIdsAtom, popupAtom } from '@/containers/data-tool/store'; import { cn } from '@/lib/classnames'; import { format } from '@/lib/utils/formats'; -import { layersInteractiveIdsAtom, popupAtom } from '@/store/map'; import { useGetLayersId } from '@/types/generated/layer'; import { useGetLocations } from '@/types/generated/location'; import { LayerTyped } from '@/types/layers';