diff --git a/.env b/.env index 89545aef4..9b9f3f8a0 100644 --- a/.env +++ b/.env @@ -4,7 +4,6 @@ APP_CONTACT_EMAIL=email@example.org API_RASTER_ENDPOINT='https://staging-raster.delta-backend.com' API_STAC_ENDPOINT='https://staging-stac.delta-backend.com' -API_XARRAY_ENDPOINT='https://dev-titiler-xarray.delta-backend.com/tilejson.json' # If the app is being served in from a subfolder, the domain url must be set. # For example, if the app is served from /mysite: diff --git a/app/graphics/content/tour-analysis.gif b/app/graphics/content/tour-analysis.gif new file mode 100644 index 000000000..288420b1a Binary files /dev/null and b/app/graphics/content/tour-analysis.gif differ diff --git a/app/graphics/content/tour-comparison.gif b/app/graphics/content/tour-comparison.gif new file mode 100644 index 000000000..f28778cd0 Binary files /dev/null and b/app/graphics/content/tour-comparison.gif differ diff --git a/app/scripts/components/analysis/utils.ts b/app/scripts/components/analysis/utils.ts index 89d485a4c..5486ef0bc 100644 --- a/app/scripts/components/analysis/utils.ts +++ b/app/scripts/components/analysis/utils.ts @@ -1,6 +1,7 @@ import { endOfDay, startOfDay, format } from 'date-fns'; import { Feature, FeatureCollection, MultiPolygon, Polygon } from 'geojson'; import { userTzDate2utcString } from '$utils/date'; +import { fixAntimeridian } from '$utils/antimeridian'; /** * Creates the appropriate filter object to send to STAC. @@ -16,6 +17,8 @@ export function getFilterPayload( aoi: FeatureCollection, collections: string[] ) { + const aoiMultiPolygon = fixAoiFcForStacSearch(aoi); + const filterPayload = { op: 'and', args: [ @@ -31,11 +34,9 @@ export function getFilterPayload( } ] }, - // Stac search spatial intersect needs to be done on a single feature. - // Using a Multipolygon { op: 's_intersects', - args: [{ property: 'geometry' }, combineFeatureCollection(aoi).geometry] + args: [{ property: 'geometry' }, aoiMultiPolygon.geometry] }, { op: 'in', @@ -50,9 +51,9 @@ export function getFilterPayload( * Converts a MultiPolygon to a Feature Collection of polygons. * * @param feature MultiPolygon feature - * + * * @see combineFeatureCollection() for opposite - * + * * @returns Feature Collection of Polygons */ export function multiPolygonToPolygons(feature: Feature) { @@ -75,7 +76,7 @@ export function multiPolygonToPolygons(feature: Feature) { * Converts a Feature Collection of polygons into a MultiPolygon * * @param featureCollection Feature Collection of Polygons - * + * * @see multiPolygonToPolygons() for opposite * * @returns MultiPolygon Feature @@ -95,6 +96,23 @@ export function combineFeatureCollection( }; } +/** + * Fixes the AOI feature collection for a STAC search by converting all polygons + * to a single multipolygon and ensuring that every polygon is inside the + * -180/180 range. + * @param aoi The AOI feature collection + * @returns AOI as a multipolygon with every polygon inside the -180/180 range + */ +export function fixAoiFcForStacSearch(aoi: FeatureCollection) { + // Stac search spatial intersect needs to be done on a single feature. + // Using a Multipolygon + const singleMultiPolygon = combineFeatureCollection(aoi); + // And every polygon must be inside the -180/180 range. + // See: https://github.com/NASA-IMPACT/veda-ui/issues/732 + const aoiMultiPolygon = fixAntimeridian(singleMultiPolygon); + return aoiMultiPolygon; +} + export function getDateRangeFormatted(startDate, endDate) { const dFormat = 'yyyy-MM-dd'; const startDateFormatted = format(startDate, dFormat); diff --git a/app/scripts/components/common/browse-controls/index.tsx b/app/scripts/components/common/browse-controls/index.tsx index 245fef34d..dab0c28f0 100644 --- a/app/scripts/components/common/browse-controls/index.tsx +++ b/app/scripts/components/common/browse-controls/index.tsx @@ -85,6 +85,20 @@ function BrowseControls(props: BrowseControlsProps) { return ( + + {taxonomiesOptions.map(({ name, values }) => ( + { + onAction(Actions.TAXONOMY, { key: name, value: v }); + }} + size={isLargeUp ? 'large' : 'medium'} + /> + ))} + - - {taxonomiesOptions.map(({ name, values }) => ( - { - onAction(Actions.TAXONOMY, { key: name, value: v }); - }} - size={isLargeUp ? 'large' : 'medium'} - /> - ))} - ); } diff --git a/app/scripts/components/common/browse-controls/use-browse-controls.ts b/app/scripts/components/common/browse-controls/use-browse-controls.ts index e34554814..30259e684 100644 --- a/app/scripts/components/common/browse-controls/use-browse-controls.ts +++ b/app/scripts/components/common/browse-controls/use-browse-controls.ts @@ -4,13 +4,14 @@ import useQsStateCreator from 'qs-state-hook'; import { set, omit } from 'lodash'; export enum Actions { + CLEAR = 'clear', SEARCH = 'search', SORT_FIELD = 'sfield', SORT_DIR = 'sdir', TAXONOMY = 'taxonomy' } -export type BrowserControlsAction = (what: Actions, value: any) => void; +export type BrowserControlsAction = (what: Actions, value?: any) => void; export interface FilterOption { id: string; @@ -85,6 +86,10 @@ export function useBrowserControls({ sortOptions }: BrowseControlsHookParams) { const onAction = useCallback( (what, value) => { switch (what) { + case Actions.CLEAR: + setSearch(''); + setTaxonomies({}); + break; case Actions.SEARCH: setSearch(value); break; diff --git a/app/scripts/components/common/card.tsx b/app/scripts/components/common/card.tsx index 0d64daab9..16e1a8088 100644 --- a/app/scripts/components/common/card.tsx +++ b/app/scripts/components/common/card.tsx @@ -332,6 +332,7 @@ interface CardComponentProps { parentTo?: string; footerContent?: ReactNode; onCardClickCapture?: MouseEventHandler; + onLinkClick?: MouseEventHandler; } function CardComponent(props: CardComponentProps) { @@ -349,13 +350,14 @@ function CardComponent(props: CardComponentProps) { parentName, parentTo, footerContent, - onCardClickCapture + onCardClickCapture, + onLinkClick } = props; const isExternalLink = linkTo.match(/^https?:\/\//); const linkProps = isExternalLink - ? { href: linkTo } - : { as: Link, to: linkTo }; + ? { href: linkTo, onClick: onLinkClick } + : { as: Link, to: linkTo, onClick: onLinkClick }; return ( + + {children} + + ); +} + +export default styled(EmptyHub)` max-width: 100%; grid-column: 1/-1; display: flex; @@ -16,14 +29,3 @@ const EmptyHubWrapper = styled.div` border: 1px dashed ${themeVal('color.base-300')}; gap: ${variableGlsp(1)}; `; - -export default function EmptyHub(props: { children: ReactNode }) { - const theme = useTheme(); - - return ( - - - {props.children} - - ); -} \ No newline at end of file diff --git a/app/scripts/components/common/icons/calendar-minus.tsx b/app/scripts/components/common/icons/calendar-minus.tsx new file mode 100644 index 000000000..8d089f605 --- /dev/null +++ b/app/scripts/components/common/icons/calendar-minus.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { createCollecticon } from '@devseed-ui/collecticons'; +import styled from 'styled-components'; + +export const CollecticonCalendarMinus = styled( + createCollecticon((props: any) => ( + + {props.title || 'Calendar with minus icon'} + + + + )) +)` + /* icons must be styled-components */ +`; diff --git a/app/scripts/components/common/icons/calendar-plus.tsx b/app/scripts/components/common/icons/calendar-plus.tsx new file mode 100644 index 000000000..2b570db4f --- /dev/null +++ b/app/scripts/components/common/icons/calendar-plus.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { createCollecticon } from '@devseed-ui/collecticons'; +import styled from 'styled-components'; + +export const CollecticonCalendarPlus = styled( + createCollecticon((props: any) => ( + + {props.title || 'Calendar with plus icon'} + + + + )) +)` + /* icons must be styled-components */ +`; diff --git a/app/scripts/components/common/icons/magnifier-minus.tsx b/app/scripts/components/common/icons/magnifier-minus.tsx new file mode 100644 index 000000000..7fa2f31ae --- /dev/null +++ b/app/scripts/components/common/icons/magnifier-minus.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { createCollecticon } from '@devseed-ui/collecticons'; +import styled from 'styled-components'; + +export const CollecticonMagnifierMinus = styled( + createCollecticon((props: any) => ( + + {props.title || 'Magnifier with minus icon'} + + + + )) +)` + /* icons must be styled-components */ +`; diff --git a/app/scripts/components/common/icons/magnifier-plus.tsx b/app/scripts/components/common/icons/magnifier-plus.tsx new file mode 100644 index 000000000..64778d5a3 --- /dev/null +++ b/app/scripts/components/common/icons/magnifier-plus.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { createCollecticon } from '@devseed-ui/collecticons'; +import styled from 'styled-components'; + +export const CollecticonMagnifierPlus = styled( + createCollecticon((props: any) => ( + + {props.title || 'Magnifier with plus icon'} + + + + )) +)` + /* icons must be styled-components */ +`; diff --git a/app/scripts/components/common/loading-skeleton.tsx b/app/scripts/components/common/loading-skeleton.tsx index fc3e4a46d..06ace02e1 100644 --- a/app/scripts/components/common/loading-skeleton.tsx +++ b/app/scripts/components/common/loading-skeleton.tsx @@ -15,7 +15,7 @@ const pulse = keyframes` } `; -const pulsingAnimation = css` +export const pulsingAnimation = css` animation: ${pulse} 0.8s ease 0s infinite alternate; `; diff --git a/app/scripts/components/common/map/controls/aoi/atoms.ts b/app/scripts/components/common/map/controls/aoi/atoms.ts new file mode 100644 index 000000000..b8dc68811 --- /dev/null +++ b/app/scripts/components/common/map/controls/aoi/atoms.ts @@ -0,0 +1,68 @@ +import { atom } from 'jotai'; +import { Feature, Polygon } from 'geojson'; +import { AoIFeature } from '../../types'; +import { decodeAois, encodeAois } from '$utils/polygon-url'; +import { atomWithUrlValueStability } from '$utils/params-location-atom/atom-with-url-value-stability'; + +// This is the atom acting as a single source of truth for the AOIs. +export const aoisSerialized = atomWithUrlValueStability({ + initialValue: new URLSearchParams(window.location.search).get('aois') ?? '', + urlParam: 'aois', + hydrate: (v) => v ?? '', + dehydrate: (v) => v, +}); + +// Getter atom to get AoiS as GeoJSON features from the hash. +export const aoisFeaturesAtom = atom((get) => { + const hash = get(aoisSerialized); + if (!hash) return []; + return decodeAois(hash); +}); + +// Setter atom to update AOIs geometries, writing directly to the hash atom. +export const aoisUpdateGeometryAtom = atom( + null, + (get, set, updates: Feature[]) => { + let newFeatures = [...get(aoisFeaturesAtom)]; + updates.forEach(({ id, geometry }) => { + const existingFeature = newFeatures.find((feature) => feature.id === id); + if (existingFeature) { + existingFeature.geometry = geometry; + } else { + const newFeature: AoIFeature = { + type: 'Feature', + id: id as string, + geometry, + selected: true, + properties: {} + }; + newFeatures = [...newFeatures, newFeature]; + } + }); + set(aoisSerialized, encodeAois(newFeatures)); + } +); + +// Setter atom to update AOIs selected state, writing directly to the hash atom. +export const aoisSetSelectedAtom = atom(null, (get, set, ids: string[]) => { + const features = get(aoisFeaturesAtom); + const newFeatures = features.map((feature) => { + return { ...feature, selected: ids.includes(feature.id as string) }; + }); + set(aoisSerialized, encodeAois(newFeatures)); +}); + +// Setter atom to delete AOIs, writing directly to the hash atom. +export const aoisDeleteAtom = atom(null, (get, set, ids: string[]) => { + const features = get(aoisFeaturesAtom); + const newFeatures = features.filter( + (feature) => !ids.includes(feature.id as string) + ); + set(aoisSerialized, encodeAois(newFeatures)); +}); + +export const aoiDeleteAllAtom = atom(null, (get, set) => { + set(aoisSerialized, encodeAois([])); +}); + +export const isDrawingAtom = atom(false); \ No newline at end of file diff --git a/app/scripts/components/common/map/controls/aoi/custom-aoi-control.tsx b/app/scripts/components/common/map/controls/aoi/custom-aoi-control.tsx new file mode 100644 index 000000000..b50accfee --- /dev/null +++ b/app/scripts/components/common/map/controls/aoi/custom-aoi-control.tsx @@ -0,0 +1,226 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Feature, Polygon } from 'geojson'; +import styled, { css } from 'styled-components'; +import { useSetAtom } from 'jotai'; +import bbox from '@turf/bbox'; +import { + CollecticonPencil, + CollecticonTrashBin, + CollecticonUpload2 +} from '@devseed-ui/collecticons'; +import { Toolbar, ToolbarLabel, VerticalDivider } from '@devseed-ui/toolbar'; +import { Button } from '@devseed-ui/button'; +import { themeVal, glsp, disabled } from '@devseed-ui/theme-provider'; + +import useMaps from '../../hooks/use-maps'; +import useAois from '../hooks/use-aois'; +import useThemedControl from '../hooks/use-themed-control'; +import CustomAoIModal from './custom-aoi-modal'; +import { aoiDeleteAllAtom } from './atoms'; + +import { TipToolbarIconButton } from '$components/common/tip-button'; +import { Tip } from '$components/common/tip'; +import { ShortcutCode } from '$styles/shortcut-code'; + +const AnalysisToolbar = styled(Toolbar)<{ visuallyDisabled: boolean }>` + background-color: ${themeVal('color.surface')}; + border-radius: ${themeVal('shape.rounded')}; + padding: ${glsp(0, 0.5)}; + box-shadow: ${themeVal('boxShadow.elevationC')}; + + ${({ visuallyDisabled }) => + visuallyDisabled && + css` + > * { + ${disabled()} + pointer-events: none; + } + `} + + ${ToolbarLabel} { + text-transform: none; + } +`; + +const FloatingBarSelf = styled.div` + position: absolute; + bottom: ${glsp()}; + left: 50%; + transform: translateX(-50%); + z-index: 100; +`; + +function CustomAoI({ + map, + disableReason +}: { + map: any; + disableReason?: React.ReactNode; +}) { + const [aoiModalRevealed, setAoIModalRevealed] = useState(false); + + const { onUpdate, isDrawing, setIsDrawing, features } = useAois(); + const aoiDeleteAll = useSetAtom(aoiDeleteAllAtom); + + // Needed so that this component re-renders to when the draw selection changes + // from feature to point. + const [, forceUpdate] = useState(0); + useEffect(() => { + const onSelChange = () => forceUpdate(Date.now()); + map.on('draw.selectionchange', onSelChange); + return () => { + map.off('draw.selectionchange', onSelChange); + }; + }, []); + + const onConfirm = (features: Feature[]) => { + const mbDraw = map?._drawControl; + setAoIModalRevealed(false); + if (!mbDraw) return; + onUpdate({ features }); + const fc = { + type: 'FeatureCollection', + features + }; + map.fitBounds(bbox(fc), { padding: 20 }); + mbDraw.add(fc); + }; + + const onTrashClick = useCallback(() => { + // We need to programmatically access the mapbox draw trash method which + // will do different things depending on the selected mode. + const mbDraw = map?._drawControl; + if (!mbDraw) return; + + // This is a peculiar situation: + // If we are in direct select (to select/add vertices) but not vertex is + // selected, the trash method doesn't do anything. So, in this case, we + // trigger the delete for the whole feature. + const selectedFeatures = mbDraw.getSelected().features; + if ( + mbDraw.getMode() === 'direct_select' && + selectedFeatures.length && + !mbDraw.getSelectedPoints().features.length + ) { + // Change mode so that the trash action works. + mbDraw.changeMode('simple_select', { + featureIds: selectedFeatures.map((f) => f.id) + }); + } + + // If nothing selected, delete all. + if (features.every((f) => !f.selected)) { + mbDraw.deleteAll(); + // The delete all method does not trigger the delete event, so we need to + // manually delete all the feature from the atom. + aoiDeleteAll(); + return; + } + mbDraw.trash(); + }, [features, aoiDeleteAll, map]); + + const isAreaSelected = !!map?._drawControl.getSelected().features.length; + const isPointSelected = + !!map?._drawControl.getSelectedPoints().features.length; + const hasFeatures = !!features.length; + + return ( + <> + +
+ + Analysis + + setIsDrawing(!isDrawing)} + > + + + setAoIModalRevealed(true)} + > + + + +
+
+ + {hasFeatures && ( + + )} + + setAoIModalRevealed(false)} + /> + + ); +} + +interface FloatingBarProps { + children: React.ReactNode; + container: HTMLElement; +} + +function FloatingBar(props: FloatingBarProps) { + const { container, children } = props; + return createPortal({children}, container); +} + +export default function CustomAoIControl({ + disableReason +}: { + disableReason?: React.ReactNode; +}) { + const { main } = useMaps(); + + const { isDrawing } = useAois(); + + // Start/stop the drawing. + useEffect(() => { + // @ts-expect-error Property '_drawControl' does not exist on type 'Map'. + // Property was added to access draw control. + const mbDraw = main?._drawControl; + if (!mbDraw) return; + + if (isDrawing) { + mbDraw.changeMode('draw_polygon'); + } else { + mbDraw.changeMode('simple_select', { + featureIds: mbDraw.getSelectedIds() + }); + } + }, [main, isDrawing]); + + useThemedControl( + () => , + { + position: 'top-left' + } + ); + return null; +} diff --git a/app/scripts/components/common/map/controls/aoi/custom-aoi-modal.tsx b/app/scripts/components/common/map/controls/aoi/custom-aoi-modal.tsx new file mode 100644 index 000000000..013d467be --- /dev/null +++ b/app/scripts/components/common/map/controls/aoi/custom-aoi-modal.tsx @@ -0,0 +1,220 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import styled from 'styled-components'; +import { Feature, Polygon } from 'geojson'; +import { Modal, ModalHeadline, ModalFooter } from '@devseed-ui/modal'; +import { Heading, Subtitle } from '@devseed-ui/typography'; + +import { Button } from '@devseed-ui/button'; +import { + glsp, + listReset, + themeVal, + visuallyHidden +} from '@devseed-ui/theme-provider'; +import { + CollecticonArrowUp, + CollecticonTickSmall, + CollecticonXmarkSmall, + CollecticonCircleExclamation, + CollecticonCircleTick, + CollecticonCircleInformation +} from '@devseed-ui/collecticons'; +import useCustomAoI, { acceptExtensions } from '../hooks/use-custom-aoi'; +import { variableGlsp, variableProseVSpace } from '$styles/variable-utils'; + +const UploadFileModalFooter = styled(ModalFooter)` + display: flex; + justify-content: right; + flex-flow: row nowrap; + gap: ${variableGlsp(0.25)}; +`; + +const ModalBodyInner = styled.div` + display: flex; + flex-flow: column nowrap; + gap: ${variableGlsp()}; +`; + +const UploadFileIntro = styled.div` + display: flex; + flex-flow: column nowrap; + gap: ${variableProseVSpace()}; +`; + +const FileUpload = styled.div` + display: flex; + flex-flow: nowrap; + align-items: center; + gap: ${variableGlsp(0.5)}; + + ${Button} { + flex-shrink: 0; + } + + ${Subtitle} { + overflow-wrap: anywhere; + } +`; + +const FileInput = styled.input` + ${visuallyHidden()} +`; + +const UploadInformation = styled.div` + padding: ${variableGlsp()}; + background: ${themeVal('color.base-50')}; + box-shadow: ${themeVal('boxShadow.inset')}; + border-radius: ${themeVal('shape.rounded')}; +`; + +const UploadListInfo = styled.ul` + ${listReset()} + display: flex; + flex-flow: column nowrap; + gap: ${glsp(0.25)}; + + li { + display: flex; + flex-flow: row nowrap; + gap: ${glsp(0.5)}; + align-items: top; + + > svg { + flex-shrink: 0; + margin-top: ${glsp(0.25)}; + } + } +`; + +const UploadInfoItemSuccess = styled.li` + color: ${themeVal('color.success')}; +`; + +const UploadInfoItemWarnings = styled.li` + color: ${themeVal('color.info')}; +`; + +const UploadInfoItemError = styled.li` + color: ${themeVal('color.danger')}; +`; + +interface CustomAoIModalProps { + revealed: boolean; + onCloseClick: () => void; + onConfirm: (features: Feature[]) => void; +} + +export default function CustomAoIModal({ + revealed, + onCloseClick, + onConfirm +}: CustomAoIModalProps) { + const { + features, + onUploadFile, + uploadFileError, + uploadFileWarnings, + fileInfo, + reset + } = useCustomAoI(); + const fileInputRef = useRef(null); + + const onUploadClick = useCallback(() => { + if (fileInputRef.current) fileInputRef.current.click(); + }, []); + + const onConfirmClick = useCallback(() => { + if (!features) return; + onConfirm(features); + onCloseClick(); + }, [features, onConfirm, onCloseClick]); + + useEffect(() => { + if (revealed) reset(); + }, [revealed, reset]); + + const hasInfo = !!uploadFileWarnings.length || !!features || uploadFileError; + + return ( + ( + +

Upload custom area

+
+ )} + content={ + + +

+ You can upload a zipped shapefile (*.zip) or a GeoJSON file + (*.json, *.geojson) to define a custom area of interest. +

+ + + {fileInfo && ( + + File: {fileInfo.name} ({fileInfo.type}). + + )} + + +
+ + {hasInfo && ( + + + + + {uploadFileWarnings.map((w) => ( + + + {w} + + ))} + {features && ( + + + File uploaded successfully. + + )} + {uploadFileError && ( + + {uploadFileError} + + )} + + + )} +
+ } + renderFooter={() => ( + + + + + )} + /> + ); +} diff --git a/app/scripts/components/common/map/controls/aoi/index.tsx b/app/scripts/components/common/map/controls/aoi/index.tsx new file mode 100644 index 000000000..6578800aa --- /dev/null +++ b/app/scripts/components/common/map/controls/aoi/index.tsx @@ -0,0 +1,55 @@ +import MapboxDraw from '@mapbox/mapbox-gl-draw'; +import { useTheme } from 'styled-components'; +import { useAtomValue } from 'jotai'; +import { useRef } from 'react'; +import { useControl } from 'react-map-gl'; +import useAois from '../hooks/use-aois'; +import { aoisFeaturesAtom } from './atoms'; +import { computeDrawStyles } from './style'; + +type DrawControlProps = MapboxDraw.DrawOptions; + +export default function DrawControl(props: DrawControlProps) { + const theme = useTheme(); + const control = useRef(); + const aoisFeatures = useAtomValue(aoisFeaturesAtom); + + const { onUpdate, onDelete, onSelectionChange, onDrawModeChange } = useAois(); + + useControl( + () => { + control.current = new MapboxDraw({ + displayControlsDefault: false, + styles: computeDrawStyles(theme), + ...props + }); + return control.current; + }, + ({ map }: { map: any }) => { + map._drawControl = control.current; + map.on('draw.create', onUpdate); + map.on('draw.update', onUpdate); + map.on('draw.delete', onDelete); + map.on('draw.selectionchange', onSelectionChange); + map.on('draw.modechange', onDrawModeChange); + map.on('load', () => { + control.current?.set({ + type: 'FeatureCollection', + features: aoisFeatures + }); + }); + }, + ({ map }: { map: any }) => { + map.off('draw.create', onUpdate); + map.off('draw.update', onUpdate); + map.off('draw.delete', onDelete); + map.off('draw.selectionchange', onSelectionChange); + map.off('draw.modechange', onDrawModeChange); + }, + { + position: 'top-left' + } + ); + + return null; +} diff --git a/app/scripts/components/common/map/controls/aoi/style.ts b/app/scripts/components/common/map/controls/aoi/style.ts new file mode 100644 index 000000000..9a44d6c3f --- /dev/null +++ b/app/scripts/components/common/map/controls/aoi/style.ts @@ -0,0 +1,140 @@ +import { DefaultTheme } from 'styled-components'; + +export const computeDrawStyles = (theme: DefaultTheme) => [ + { + id: 'gl-draw-polygon-fill-inactive', + type: 'fill', + filter: [ + 'all', + ['==', 'active', 'false'], + ['==', '$type', 'Polygon'], + ['!=', 'mode', 'static'] + ], + paint: { + 'fill-color': theme.color?.primary, + 'fill-outline-color': theme.color?.primary, + 'fill-opacity': 0.16 + } + }, + { + id: 'gl-draw-polygon-stroke-inactive', + type: 'line', + filter: [ + 'all', + ['==', 'active', 'false'], + ['==', '$type', 'Polygon'], + ['!=', 'mode', 'static'] + ], + layout: { + 'line-cap': 'round', + 'line-join': 'round' + }, + paint: { + 'line-color': theme.color?.primary, + 'line-width': 2 + } + }, + { + id: 'gl-draw-polygon-fill-active', + type: 'fill', + filter: ['all', ['==', 'active', 'true'], ['==', '$type', 'Polygon']], + paint: { + 'fill-color': theme.color?.primary, + 'fill-outline-color': theme.color?.primary, + 'fill-opacity': 0.16 + } + }, + { + id: 'gl-draw-polygon-stroke-active', + type: 'line', + filter: ['all', ['==', 'active', 'true'], ['==', '$type', 'Polygon']], + layout: { + 'line-cap': 'round', + 'line-join': 'round' + }, + paint: { + 'line-color': theme.color?.primary, + 'line-dasharray': [0.64, 2], + 'line-width': 2 + } + }, + { + id: 'gl-draw-line-active', + type: 'line', + filter: ['all', ['==', '$type', 'LineString'], ['==', 'active', 'true']], + layout: { + 'line-cap': 'round', + 'line-join': 'round' + }, + paint: { + 'line-color': theme.color?.primary, + 'line-dasharray': [0.64, 2], + 'line-width': 2 + } + }, + { + id: 'gl-draw-polygon-and-line-vertex-stroke-inactive', + type: 'circle', + filter: [ + 'all', + ['==', 'meta', 'vertex'], + ['==', '$type', 'Point'], + ['!=', 'mode', 'static'] + ], + paint: { + 'circle-radius': 6, + 'circle-color': '#fff' + } + }, + { + id: 'gl-draw-polygon-and-line-vertex-inactive', + type: 'circle', + filter: [ + 'all', + ['==', 'meta', 'vertex'], + ['==', '$type', 'Point'], + ['!=', 'mode', 'static'] + ], + paint: { + 'circle-radius': 4, + 'circle-color': theme.color?.primary + } + }, + { + id: 'gl-draw-point-stroke-active', + type: 'circle', + filter: [ + 'all', + ['==', '$type', 'Point'], + ['==', 'active', 'true'], + ['!=', 'meta', 'midpoint'] + ], + paint: { + 'circle-radius': 8, + 'circle-color': '#fff' + } + }, + { + id: 'gl-draw-point-active', + type: 'circle', + filter: [ + 'all', + ['==', '$type', 'Point'], + ['!=', 'meta', 'midpoint'], + ['==', 'active', 'true'] + ], + paint: { + 'circle-radius': 6, + 'circle-color': theme.color?.primary + } + }, + { + id: 'gl-draw-polygon-midpoint', + type: 'circle', + filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'midpoint']], + paint: { + 'circle-radius': 3, + 'circle-color': '#fff' + } + } +]; diff --git a/app/scripts/components/common/map/controls/coords.tsx b/app/scripts/components/common/map/controls/coords.tsx new file mode 100644 index 000000000..9b0a2d4ad --- /dev/null +++ b/app/scripts/components/common/map/controls/coords.tsx @@ -0,0 +1,79 @@ +import React, { useEffect, useState } from 'react'; +import { MapRef } from 'react-map-gl'; +import styled from 'styled-components'; +import { Button } from '@devseed-ui/button'; +import { themeVal } from '@devseed-ui/theme-provider'; +import useMaps from '../hooks/use-maps'; +import useThemedControl from './hooks/use-themed-control'; +import { round } from '$utils/format'; +import { CopyField } from '$components/common/copy-field'; + +const MapCoordsWrapper = styled.div` + /* Large width so parent will wrap */ + width: 100vw; + pointer-events: none !important; + + ${Button} { + pointer-events: auto; + background: ${themeVal('color.base-400a')}; + font-weight: ${themeVal('type.base.regular')}; + font-size: 0.75rem; + } + + && ${Button /* sc-selector */}:hover { + background: ${themeVal('color.base-500')}; + } +`; + +const getCoords = (mapInstance?: MapRef) => { + if (!mapInstance) return { lng: 0, lat: 0 }; + const mapCenter = mapInstance.getCenter(); + return { + lng: round(mapCenter.lng, 4), + lat: round(mapCenter.lat, 4) + }; +}; + +export default function MapCoordsControl() { + const { main } = useMaps(); + + const [position, setPosition] = useState(getCoords(main)); + + useEffect(() => { + const posListener = (e) => { + setPosition(getCoords(e.target)); + }; + + if (main) main.on('moveend', posListener); + + return () => { + if (main) main.off('moveend', posListener); + }; + }, [main]); + + const { lng, lat } = position; + const value = `W ${lng}, N ${lat}`; + + useThemedControl( + () => ( + + + {({ ref, showCopiedMsg }) => ( + + )} + + + ), + { position: 'bottom-left' } + ); + + return null; +} diff --git a/app/scripts/components/common/map/controls/geocoder.tsx b/app/scripts/components/common/map/controls/geocoder.tsx new file mode 100644 index 000000000..edf0b5649 --- /dev/null +++ b/app/scripts/components/common/map/controls/geocoder.tsx @@ -0,0 +1,20 @@ +import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder'; + +import { useControl } from 'react-map-gl'; + +export default function GeocoderControl() { + useControl( + () => { + return new MapboxGeocoder({ + accessToken: process.env.MAPBOX_TOKEN, + marker: false, + collapsed: true + }); + }, + { + position: 'top-right' + } + ); + + return null; +} diff --git a/app/scripts/components/common/map/controls/hooks/use-aois.ts b/app/scripts/components/common/map/controls/hooks/use-aois.ts new file mode 100644 index 000000000..8c7e4efd3 --- /dev/null +++ b/app/scripts/components/common/map/controls/hooks/use-aois.ts @@ -0,0 +1,70 @@ +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { useCallback } from 'react'; +import { Feature, Polygon } from 'geojson'; +import { toAoIid } from '../../utils'; +import { + aoisDeleteAtom, + aoisFeaturesAtom, + aoisSetSelectedAtom, + aoisUpdateGeometryAtom, + isDrawingAtom +} from '../aoi/atoms'; + +export default function useAois() { + const features = useAtomValue(aoisFeaturesAtom); + + const [isDrawing, setIsDrawing] = useAtom(isDrawingAtom); + + const aoisUpdateGeometry = useSetAtom(aoisUpdateGeometryAtom); + const update = useCallback( + (features: Feature[]) => { + aoisUpdateGeometry(features); + }, + [aoisUpdateGeometry] + ); + const onUpdate = useCallback( + (e) => { + const updates = e.features.map((f) => ({ + id: toAoIid(f.id), + geometry: f.geometry as Polygon + })); + update(updates); + }, + [update] + ); + + const aoiDelete = useSetAtom(aoisDeleteAtom); + const onDelete = useCallback( + (e) => { + const selectedIds = e.features.map((f) => toAoIid(f.id)); + aoiDelete(selectedIds); + }, + [aoiDelete] + ); + + const aoiSetSelected = useSetAtom(aoisSetSelectedAtom); + const onSelectionChange = useCallback( + (e) => { + const selectedIds = e.features.map((f) => toAoIid(f.id)); + aoiSetSelected(selectedIds); + }, + [aoiSetSelected] + ); + + const onDrawModeChange = useCallback((e) => { + if (e.mode === 'simple_select') { + setIsDrawing(false); + } + }, [setIsDrawing]); + + return { + features, + update, + onUpdate, + onDelete, + onSelectionChange, + onDrawModeChange, + isDrawing, + setIsDrawing + }; +} diff --git a/app/scripts/components/common/map/controls/hooks/use-basemap.ts b/app/scripts/components/common/map/controls/hooks/use-basemap.ts new file mode 100644 index 000000000..7f5b9dbe5 --- /dev/null +++ b/app/scripts/components/common/map/controls/hooks/use-basemap.ts @@ -0,0 +1,26 @@ +import { useCallback, useState } from 'react'; +import { BASEMAP_ID_DEFAULT, BasemapId, Option } from '../map-options/basemap'; + +export function useBasemap() { + const [mapBasemapId, setBasemapId] = useState(BASEMAP_ID_DEFAULT); + const [labelsOption, setLabelsOption] = useState(true); + const [boundariesOption, setBoundariesOption] = useState(true); + const onOptionChange = useCallback( + (option: Option, value: boolean) => { + if (option === 'labels') { + setLabelsOption(value); + } else { + setBoundariesOption(value); + } + }, + [setLabelsOption, setBoundariesOption] + ); + + return { + mapBasemapId, + setBasemapId, + labelsOption, + boundariesOption, + onOptionChange + }; +} diff --git a/app/scripts/components/common/map/controls/hooks/use-custom-aoi.tsx b/app/scripts/components/common/map/controls/hooks/use-custom-aoi.tsx new file mode 100644 index 000000000..d3e0f194f --- /dev/null +++ b/app/scripts/components/common/map/controls/hooks/use-custom-aoi.tsx @@ -0,0 +1,225 @@ +import { Feature, MultiPolygon, Polygon } from 'geojson'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import shp from 'shpjs'; +import simplify from '@turf/simplify'; + +import { multiPolygonToPolygons } from '../../utils'; +import { round } from '$utils/format'; + +const extensions = ['geojson', 'json', 'zip']; +export const acceptExtensions = extensions.map((ext) => `.${ext}`).join(', '); + +export interface FileInfo { + name: string; + extension: string; + type: 'Shapefile' | 'GeoJSON'; +} + +function getNumPoints(feature: Feature): number { + return feature.geometry.coordinates.reduce((acc, current) => { + return acc + current.length; + }, 0); +} + +function useCustomAoI() { + const [fileInfo, setFileInfo] = useState(null); + const [uploadFileError, setUploadFileError] = useState(null); + const [uploadFileWarnings, setUploadFileWarnings] = useState([]); + const reader = useRef(); + const [features, setFeatures] = useState[] | null>(null); + + useEffect(() => { + reader.current = new FileReader(); + + const setError = (error: string) => { + setUploadFileError(error); + setFeatures(null); + setUploadFileWarnings([]); + }; + + const onLoad = async () => { + if (!reader.current) return; + + let geojson; + if (typeof reader.current.result === 'string') { + const rawGeoJSON = reader.current.result; + if (!rawGeoJSON) { + setError('Error uploading file.'); + return; + } + try { + geojson = JSON.parse(rawGeoJSON as string); + } catch (e) { + setError('Error uploading file: invalid JSON'); + return; + } + } else { + try { + geojson = await shp(reader.current.result); + } catch (e) { + setError(`Error uploading file: invalid Shapefile (${e.message})`); + return; + } + } + + if (!geojson?.features?.length) { + setError('Error uploading file: Invalid GeoJSON'); + return; + } + + let warnings: string[] = []; + + if ( + geojson.features.some( + (feature) => + !['MultiPolygon', 'Polygon'].includes(feature.geometry.type) + ) + ) { + setError( + 'Wrong geometry type. Only polygons or multi polygons are accepted.' + ); + return; + } + + const features: Feature[] = geojson.features.reduce( + (acc, feature: Feature) => { + if (feature.geometry.type === 'MultiPolygon') { + return acc.concat( + multiPolygonToPolygons(feature as Feature) + ); + } + + return acc.concat(feature); + }, + [] + ); + + if (features.length > 200) { + setError('Only files with up to 200 polygons are accepted.'); + return; + } + + // Simplify features; + const originalTotalFeaturePoints = features.reduce( + (acc, f) => acc + getNumPoints(f), + 0 + ); + let numPoints = originalTotalFeaturePoints; + let tolerance = 0.001; + + // Remove holes from polygons as they're not supported. + let polygonHasRings = false; + let simplifiedFeatures = features.map>((feature) => { + if (feature.geometry.coordinates.length > 1) { + polygonHasRings = true; + return { + ...feature, + geometry: { + type: 'Polygon', + coordinates: [feature.geometry.coordinates[0]] + } + }; + } + + return feature; + }); + + if (polygonHasRings) { + warnings = [ + ...warnings, + 'Polygons with rings are not supported and were simplified to remove them' + ]; + } + + // If we allow up to 200 polygons and each polygon needs 4 points, we need + // at least 800, give an additional buffer and we get 1000. + while (numPoints > 1000 && tolerance < 5) { + simplifiedFeatures = simplifiedFeatures.map((feature) => + simplify(feature, { tolerance }) + ); + numPoints = simplifiedFeatures.reduce( + (acc, f) => acc + getNumPoints(f), + 0 + ); + tolerance = Math.min(tolerance * 1.8, 5); + } + + if (originalTotalFeaturePoints !== numPoints) { + warnings = [ + ...warnings, + `The geometry has been simplified (${round( + (1 - numPoints / originalTotalFeaturePoints) * 100 + )} % less).` + ]; + } + + setUploadFileWarnings(warnings); + setUploadFileError(null); + setFeatures( + simplifiedFeatures.map((feat, i) => ({ + id: `${new Date().getTime().toString().slice(-4)}${i}`, + ...feat + })) + ); + }; + + const onError = () => { + setError('Error uploading file'); + }; + + reader.current.addEventListener('load', onLoad); + reader.current.addEventListener('error', onError); + + return () => { + if (!reader.current) return; + reader.current.removeEventListener('load', onLoad); + reader.current.removeEventListener('error', onError); + }; + }, []); + + const onUploadFile = useCallback((event) => { + if (!reader.current) return; + + const file = event.target.files[0]; + if (!file) return; + + const [, extension] = file.name.match(/^.*\.(json|geojson|zip)$/i) ?? []; + + if (!extensions.includes(extension)) { + setUploadFileError( + 'Wrong file type. Only zipped shapefiles and geojson files are accepted.' + ); + return; + } + + setFileInfo({ + name: file.name, + extension, + type: extension === 'zip' ? 'Shapefile' : 'GeoJSON' + }); + + if (extension === 'zip') { + reader.current.readAsArrayBuffer(file); + } else if (extension === 'json' || extension === 'geojson') { + reader.current.readAsText(file); + } + }, []); + + const reset = useCallback(() => { + setFeatures(null); + setUploadFileWarnings([]); + setUploadFileError(null); + setFileInfo(null); + }, []); + + return { + features, + onUploadFile, + uploadFileError, + uploadFileWarnings, + fileInfo, + reset + }; +} + +export default useCustomAoI; diff --git a/app/scripts/components/common/map/controls/hooks/use-themed-control.tsx b/app/scripts/components/common/map/controls/hooks/use-themed-control.tsx new file mode 100644 index 000000000..ff50f88a6 --- /dev/null +++ b/app/scripts/components/common/map/controls/hooks/use-themed-control.tsx @@ -0,0 +1,57 @@ +import { IControl } from 'mapbox-gl'; +import React, { ReactNode, useEffect, useRef } from 'react'; +import { createRoot } from 'react-dom/client'; +import { useControl } from 'react-map-gl'; +import { useTheme, ThemeProvider } from 'styled-components'; + +export default function useThemedControl( + renderFn: () => ReactNode, + opts?: any +) { + const theme = useTheme(); + const elementRef = useRef(null); + const rootRef = useRef | null>(null); + + // Define the control methods and its lifecycle + class ThemedControl implements IControl { + onAdd() { + const el = document.createElement('div'); + el.className = 'mapboxgl-ctrl'; + elementRef.current = el; + + // Create a root and render the component + rootRef.current = createRoot(el); + + rootRef.current.render( + {renderFn() as any} + ); + + return el; + } + + onRemove() { + // Cleanup if necessary. + // Defer to next tick. + setTimeout(() => { + if (elementRef.current) { + rootRef.current?.unmount(); + rootRef.current = null; + } + + }, 1); + } + } + + // Listen for changes in dependencies and re-render if necessary + useEffect(() => { + if (rootRef.current) { + rootRef.current.render( + {renderFn() as any} + ); + } + }, [renderFn, theme]); + + useControl(() => new ThemedControl(), opts); + + return null; +} diff --git a/app/scripts/components/common/map/controls/index.tsx b/app/scripts/components/common/map/controls/index.tsx new file mode 100644 index 000000000..73ccddc1a --- /dev/null +++ b/app/scripts/components/common/map/controls/index.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { + NavigationControl as MapboxGLNavigationControl, + ScaleControl as MapboxGLScaleControl +} from 'react-map-gl'; + +export function NavigationControl() { + return ; +} + +export function ScaleControl() { + return ; +} diff --git a/app/scripts/components/common/map/controls/map-options/basemap.ts b/app/scripts/components/common/map/controls/map-options/basemap.ts new file mode 100644 index 000000000..4096e6a09 --- /dev/null +++ b/app/scripts/components/common/map/controls/map-options/basemap.ts @@ -0,0 +1,66 @@ +/** + * Basemap style requirements (followed by standaard Mapbox Studio styles) + * - have a layer named "admin-0-boundary-bg". Data will be added below + * this layer to ensure country oulines and labels are visible. + * - for label and boundaries layers to be toggled on and off, they must + * belong to a group specifically named - see GROUPS_BY_OPTION for the + * list of accepted group names + */ + +export const BASE_STYLE_PATH = 'https://api.mapbox.com/styles/v1/covid-nasa'; + +export const getStyleUrl = (mapboxId: string) => + `${BASE_STYLE_PATH}/${mapboxId}?access_token=${process.env.MAPBOX_TOKEN}`; + +export const BASEMAP_STYLES = [ + { + id: 'satellite', + label: 'Satellite', + mapboxId: 'cldu1cb8f00ds01p6gi583w1m', + thumbnailUrl: `https://api.mapbox.com/styles/v1/covid-nasa/cldu1cb8f00ds01p6gi583w1m/static/-9.14,38.7,10.5,0/480x320?access_token=${process.env.MAPBOX_TOKEN}` + }, + { + id: 'dark', + label: 'Default dark', + mapboxId: 'cldu14gii006801mgq3dn1jpd', + thumbnailUrl: `https://api.mapbox.com/styles/v1/mapbox/dark-v10/static/-9.14,38.7,10.5,0/480x320?access_token=${process.env.MAPBOX_TOKEN}` + }, + { + id: 'light', + label: 'Default light', + mapboxId: 'cldu0tceb000701qnrl7p9woh', + thumbnailUrl: `https://api.mapbox.com/styles/v1/mapbox/light-v10/static/-9.14,38.7,10.5,0/480x320?access_token=${process.env.MAPBOX_TOKEN}` + }, + { + id: 'topo', + label: 'Topo', + mapboxId: 'cldu1yayu00au01qqrbdahb3m', + thumbnailUrl: `https://api.mapbox.com/styles/v1/covid-nasa/cldu1yayu00au01qqrbdahb3m/static/-9.14,38.7,10.5,0/480x320?access_token=${process.env.MAPBOX_TOKEN}` + } +] as const; + +export const BASEMAP_ID_DEFAULT = 'satellite'; + +// Default style used in stories and analysis, satellite no labels +export const DEFAULT_MAP_STYLE_URL = + 'mapbox://styles/covid-nasa/ckb01h6f10bn81iqg98ne0i2y'; + +export const GROUPS_BY_OPTION: Record = { + labels: [ + 'Natural features, natural-labels', + 'Place labels, place-labels', + 'Point of interest labels, poi-labels', + 'Road network, road-labels', + 'Transit, transit-labels' + ], + boundaries: [ + 'Country Borders, country-borders', + 'Administrative boundaries, admin' + ] +}; + +export type Basemap = typeof BASEMAP_STYLES[number]; + +export type BasemapId = typeof BASEMAP_STYLES[number]['id']; + +export type Option = 'labels' | 'boundaries'; diff --git a/app/scripts/components/common/map/controls/map-options/index.tsx b/app/scripts/components/common/map/controls/map-options/index.tsx new file mode 100644 index 000000000..3a4e49c5e --- /dev/null +++ b/app/scripts/components/common/map/controls/map-options/index.tsx @@ -0,0 +1,279 @@ +import React from 'react'; +import styled from 'styled-components'; +import { glsp, themeVal } from '@devseed-ui/theme-provider'; +import { CollecticonMap } from '@devseed-ui/collecticons'; +import { + Dropdown, + DropMenu, + DropMenuItem, + DropTitle +} from '@devseed-ui/dropdown'; +import { createButtonStyles } from '@devseed-ui/button'; +import { FormSwitch } from '@devseed-ui/form'; +import { Subtitle } from '@devseed-ui/typography'; + +import useThemedControl from '../hooks/use-themed-control'; +import { + ProjectionItemConic, + ProjectionItemCustom, + ProjectionItemSimple +} from './projection-items'; +import { MapOptionsProps } from './types'; +import { projectionsList } from './projections'; +import { BASEMAP_STYLES } from './basemap'; + +import { TipButton } from '$components/common/tip-button'; + +const DropHeader = styled.div` + padding: ${glsp()}; + box-shadow: inset 0 -1px 0 0 ${themeVal('color.base-100a')}; +`; + +const DropBody = styled.div` + padding: ${glsp(0, 0, 1, 0)}; + max-height: 18rem; + overflow-y: scroll; +`; + +/** + * Override Dropdown styles to be wider and play well with the shadow scrollbar. + */ +const MapOptionsDropdown = styled(Dropdown)` + padding: 0; + max-width: 16rem; + + ${DropTitle} { + margin: 0; + } + + ${DropMenu} { + margin: 0; + padding-top: 0; + padding-bottom: 0; + } +`; + +// Why you ask? Very well: +// Mapbox's css has an instruction that sets the hover color for buttons to +// near black. The only way to override it is to increase the specificity and +// we need the button functions to get the correct color. +// The infamous instruction: +// .mapboxgl-ctrl button:not(:disabled):hover { +// background-color: rgba(0, 0, 0, 0.05); +// } +const SelectorButton = styled(TipButton)` + &&& { + ${createButtonStyles({ variation: 'surface-fill', fitting: 'skinny' } as any)} + background-color: ${themeVal('color.surface')}; + &:hover { + background-color: ${themeVal('color.surface')}; + } + & path { + fill: ${themeVal('color.base')}; + } + } +`; + +const ContentGroup = styled.div` + display: flex; + flex-flow: column nowrap; +`; + +const ContentGroupHeader = styled.div` + padding: ${glsp(1, 1, 0.5, 1)}; +`; + +const ContentGroupTitle = styled(Subtitle)` + /* styled-component */ +`; + +const ContentGroupBody = styled.div` + /* styled-component */ +`; + +const OptionSwitch = styled(FormSwitch)` + display: flex; + flex-flow: row nowrap; + justify-content: space-between; + width: 100%; + font-size: inherit; +`; + +const OptionMedia = styled.figure` + position: relative; + height: 2rem; + overflow: hidden; + border-radius: ${themeVal('shape.rounded')}; + flex-shrink: 0; + aspect-ratio: 1.5 / 1; + background: ${themeVal('color.base-50')}; + margin-left: auto; + + &::before { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 2; + content: ''; + box-shadow: inset 0 0 0 1px ${themeVal('color.base-100a')}; + border-radius: ${themeVal('shape.rounded')}; + pointer-events: none; + } +`; + +function MapOptions(props: MapOptionsProps) { + const { + projection, + onProjectionChange, + basemapStyleId, + onBasemapStyleIdChange, + labelsOption, + boundariesOption, + onOptionChange + } = props; + + return ( + ( + + + + )} + direction='down' + alignment='right' + > + + Map options + + + + + Style + + + + {BASEMAP_STYLES.map((basemap) => ( +
  • + { + e.preventDefault(); + onBasemapStyleIdChange?.(basemap.id); + }} + > + {basemap.label} + + Map style thumbnail + + +
  • + ))} +
    +
    +
    + + + + Details + + + +
  • + + { + onOptionChange?.('labels', e.target.checked); + }} + > + Labels + + +
  • +
  • + + { + onOptionChange?.('boundaries', e.target.checked); + }} + > + Boundaries + + +
  • +
    +
    +
    + + + + Projection + + + + {projectionsList.map((proj) => { + if (proj.isCustom && proj.conicValues) { + return ( + + ); + } else if (proj.conicValues) { + return ( + + ); + } else { + return ( + + ); + } + })} + + + +
    +
    + ); +} + +export default function MapOptionsControl(props: MapOptionsProps) { + useThemedControl(() => , { + position: 'top-right' + }); + return null; +} diff --git a/app/scripts/components/common/map/controls/map-options/projection-items.tsx b/app/scripts/components/common/map/controls/map-options/projection-items.tsx new file mode 100644 index 000000000..dd8872709 --- /dev/null +++ b/app/scripts/components/common/map/controls/map-options/projection-items.tsx @@ -0,0 +1,178 @@ +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { DropMenuItem } from '@devseed-ui/dropdown'; +import { glsp } from '@devseed-ui/theme-provider'; +import { FormFieldsetHeader, FormLegend } from '@devseed-ui/form'; + +import StressedFormGroupInput from '../../../stressed-form-group-input'; +import { validateLat, validateLon } from '../../utils'; + +import { + ProjectionItemConicProps, + ProjectionItemProps +} from './types'; +import { FormFieldsetBodyColumns, FormFieldsetCompact } from '$styles/fieldset'; + +const ProjectionOptionsForm = styled.div` + padding: ${glsp(0, 1)}; + + ${FormFieldsetHeader} { + padding-top: ${glsp(0.5)}; + padding-bottom: 0; + border: none; + } + + ${FormFieldsetBodyColumns} { + padding-top: ${glsp(0.5)}; + padding-bottom: ${glsp(0.5)}; + } +`; + +const projectionConicCenter = [ + { id: 'lng', label: 'Center Longitude', validate: validateLon }, + { id: 'lat', label: 'Center Latitude', validate: validateLat } +]; + +const projectionConicParallel = [ + { id: 'sParLat', label: 'Southern Parallel Lat', validate: validateLat }, + { id: 'nParLat', label: 'Northern Parallel Lat', validate: validateLat } +]; + +export function ProjectionItemSimple(props: ProjectionItemProps) { + const { onChange, id, label, activeProjection } = props; + + return ( +
  • + { + e.preventDefault(); + onChange({ id }); + }} + > + {label} + +
  • + ); +} + +export function ProjectionItemConic(props: ProjectionItemConicProps) { + const { onChange, id, label, defaultConicValues, activeProjection } = props; + + const isActive = id === activeProjection.id; + + const activeConicValues = isActive && activeProjection.center + ? { + center: activeProjection.center, + parallels: activeProjection.parallels + } + : null; + + // Keep the values the user enters to be able to restore them whenever they + // switch projections. + const [conicValues, setConicValues] = useState( + activeConicValues ?? defaultConicValues + ); + + // Store the conic values for the selected projection and register the change + // for the parent. + const onChangeConicValues = (value, field, idx) => { + const newConic = { + ...conicValues, + [field]: Object.assign([], conicValues[field], { + [idx]: value + }) + }; + setConicValues(newConic); + onChange({ id, ...newConic }); + }; + + return ( +
  • + { + e.preventDefault(); + onChange({ + ...conicValues, + id + }); + }} + > + {label} + + {isActive && ( + + + + Center Lon/Lat + + + {projectionConicCenter.map((field, idx) => ( + { + onChangeConicValues(Number(value), 'center', idx); + }} + /> + ))} + + + + + S/N Parallels + + + {projectionConicParallel.map((field, idx) => ( + { + onChangeConicValues(Number(value), 'parallels', idx); + }} + /> + ))} + + + + )} +
  • + ); +} + +export function ProjectionItemCustom(props: ProjectionItemConicProps) { + const { onChange, id, label, defaultConicValues, activeProjection } = props; + + return ( +
  • + { + e.preventDefault(); + onChange({ id, ...defaultConicValues }); + }} + > + {label} + +
  • + ); +} diff --git a/app/scripts/components/common/map/controls/map-options/projections.ts b/app/scripts/components/common/map/controls/map-options/projections.ts new file mode 100644 index 000000000..85f16ebc8 --- /dev/null +++ b/app/scripts/components/common/map/controls/map-options/projections.ts @@ -0,0 +1,127 @@ +import { MbProjectionOptions, ProjectionOptions } from 'veda'; + +import { validateLat, validateLon } from '../../utils'; +import { ProjectionListItem } from './types'; + +// The id is internal to the app. +// The mbId is the projection name to use with mapbox. This is needed because +// multiple projections can be made from the same mapbox Id just by tweaking the +// parallels and center values +export const projectionsList: ProjectionListItem[] = [ + { id: 'globe', mbId: 'globe', label: 'Globe' }, + { + id: 'albers', + mbId: 'albers', + label: 'Albers', + conicValues: { + center: [-96, 37.5], + parallels: [29.5, 45.5] + } + }, + { id: 'equalEarth', mbId: 'equalEarth', label: 'Equal Earth' }, + { id: 'equirectangular', mbId: 'equirectangular', label: 'Equirectangular' }, + { + id: 'lambertConformalConic', + mbId: 'lambertConformalConic', + label: 'Lambert Conformal Conic', + conicValues: { + center: [0, 30], + parallels: [30, 30] + } + }, + { id: 'mercator', mbId: 'mercator', label: 'Mercator' }, + { id: 'naturalEarth', mbId: 'naturalEarth', label: 'Natural Earth' }, + { id: 'winkelTripel', mbId: 'winkelTripel', label: 'Winkel Tripel' }, + { + id: 'polarNorth', + mbId: 'lambertConformalConic', + label: 'Polar North', + isCustom: true, + conicValues: { + center: [-40, 0], + parallels: [90, 90] + } + }, + { + id: 'polarSouth', + mbId: 'lambertConformalConic', + label: 'Polar South', + isCustom: true, + conicValues: { + center: [-40, 0], + parallels: [-89.99, -89.99] + } + } +]; + +// Default value for the projection state. +export const projectionDefault: ProjectionOptions = { + id: 'mercator' +}; + +/** + * Return the correct format needed by mapbox to display the projection. We use + * custom projections that do not exist in mapbox and therefore we need to get + * the correct name and parallels and center values. + * For example the projection with id polarNorth is actually named + * lambertConformalConic + */ +export const convertProjectionToMapbox = ( + projection: ProjectionOptions +): MbProjectionOptions => { + const p = projectionsList.find((proj) => proj.id === projection.id); + + if (!p) { + /* eslint-disable-next-line no-console */ + console.error('projection', projection); + throw new Error(`Invalid projection with id: ${projection.id}`); + } + + return { + center: p.conicValues?.center || projection.center, + parallels: p.conicValues?.parallels || projection.parallels, + name: p.mbId + }; +}; + +export function validateProjectionBlockProps({ + id, + center, + parallels +}: Partial) { + // Projections + const projectionErrors: string[] = []; + if (id) { + const allowedProjections = projectionsList.map((p) => p.id); + const projectionsConic = projectionsList + .filter((p) => !p.isCustom && !!p.conicValues) + .map((p) => p.id); + + if (!allowedProjections.includes(id)) { + const a = allowedProjections.join(', '); + projectionErrors.push(`- Invalid projectionId. Must be one of: ${a}.`); + } + + if (projectionsConic.includes(id)) { + if (!center || !validateLon(center[0]) || !validateLat(center[1])) { + const o = projectionsConic.join(', '); + projectionErrors.push( + `- Invalid projectionCenter. This property is required for ${o} projections. Use [longitude, latitude].` + ); + } + + if ( + !parallels || + !validateLat(parallels[0]) || + !validateLat(parallels[1]) + ) { + const o = projectionsConic.join(', '); + projectionErrors.push( + `- Invalid projectionParallels. This property is required for ${o} projections. Use [Southern parallel latitude, Northern parallel latitude].` + ); + } + } + } + + return projectionErrors; +} diff --git a/app/scripts/components/common/map/controls/map-options/types.ts b/app/scripts/components/common/map/controls/map-options/types.ts new file mode 100644 index 000000000..a0a066dfc --- /dev/null +++ b/app/scripts/components/common/map/controls/map-options/types.ts @@ -0,0 +1,36 @@ +import { MbProjectionOptions, ProjectionOptions } from 'veda'; +import { BasemapId, Option } from './basemap'; + +export interface MapOptionsProps { + onProjectionChange: (projection: ProjectionOptions) => void; + projection: ProjectionOptions; + basemapStyleId?: BasemapId; + onBasemapStyleIdChange?: (basemapId: BasemapId) => void; + labelsOption?: boolean; + boundariesOption?: boolean; + onOptionChange?: (option: Option, value: boolean) => void; +} + +export interface ProjectionConicOptions { + center: [number, number]; + parallels: [number, number]; +} + +export interface ProjectionListItem { + id: ProjectionOptions['id']; + mbId: MbProjectionOptions['name']; + label: string; + isCustom?: boolean; + conicValues?: ProjectionConicOptions; +} + +export interface ProjectionItemProps { + onChange: MapOptionsProps['onProjectionChange']; + id: ProjectionOptions['id']; + label: string; + activeProjection: ProjectionOptions; +} + +export type ProjectionItemConicProps = ProjectionItemProps & { + defaultConicValues: ProjectionConicOptions; +}; diff --git a/app/scripts/components/common/map/hooks/use-custom-marker.ts b/app/scripts/components/common/map/hooks/use-custom-marker.ts new file mode 100644 index 000000000..af38c3980 --- /dev/null +++ b/app/scripts/components/common/map/hooks/use-custom-marker.ts @@ -0,0 +1,26 @@ +import { useEffect } from 'react'; + +import markerSdfUrl from '../style/marker-sdf.png'; + +const CUSTOM_MARKER_ID = 'marker-sdf'; + +export const MARKER_LAYOUT = { + 'icon-image': CUSTOM_MARKER_ID, + 'icon-size': 0.25, + 'icon-anchor': 'bottom' +}; + +export default function useCustomMarker(mapInstance) { + useEffect(() => { + if (!mapInstance) return; + mapInstance.loadImage(markerSdfUrl, (error, image) => { + if (error) throw error; + if (!image) return; + if (mapInstance.hasImage(CUSTOM_MARKER_ID)) { + mapInstance.removeImage(CUSTOM_MARKER_ID); + } + // add image to the active style and make it SDF-enabled + mapInstance.addImage(CUSTOM_MARKER_ID, image, { sdf: true }); + }); + }, [mapInstance]); +} diff --git a/app/scripts/components/common/map/hooks/use-fit-bbox.ts b/app/scripts/components/common/map/hooks/use-fit-bbox.ts new file mode 100644 index 000000000..0b40b4603 --- /dev/null +++ b/app/scripts/components/common/map/hooks/use-fit-bbox.ts @@ -0,0 +1,33 @@ +import { useEffect } from "react"; +import { OptionalBbox } from "../types"; +import { FIT_BOUNDS_PADDING, checkFitBoundsFromLayer } from "../utils"; +import useMaps from "./use-maps"; + +/** + * Centers on the given bounds if the current position is not within the bounds, + * and there's no user defined position (via user initiated map movement). Gives + * preference to the layer defined bounds over the STAC collection bounds. + * + * @param isUserPositionSet Whether the user has set a position + * @param initialBbox Bounding box from the layer + * @param stacBbox Bounds from the STAC collection + */ +export default function useFitBbox( + isUserPositionSet: boolean, + initialBbox: OptionalBbox, + stacBbox: OptionalBbox +) { + const { current: mapInstance } = useMaps(); + useEffect(() => { + if (isUserPositionSet || !mapInstance) return; + + // Prefer layer defined bounds to STAC collection bounds. + const bounds = (initialBbox ?? stacBbox) as + | [number, number, number, number] + | undefined; + + if (bounds?.length && checkFitBoundsFromLayer(bounds, mapInstance)) { + mapInstance.fitBounds(bounds, { padding: FIT_BOUNDS_PADDING }); + } + }, [mapInstance, isUserPositionSet, initialBbox, stacBbox]); +} diff --git a/app/scripts/components/common/map/hooks/use-generator-params.ts b/app/scripts/components/common/map/hooks/use-generator-params.ts new file mode 100644 index 000000000..5eed747f3 --- /dev/null +++ b/app/scripts/components/common/map/hooks/use-generator-params.ts @@ -0,0 +1,9 @@ +import { useMemo } from 'react'; +import { BaseGeneratorParams } from '../types'; + +export default function useGeneratorParams(props: BaseGeneratorParams) { + return useMemo(() => { + return props; + // Memoize only required abse params + }, [props.generatorOrder, props.hidden, props.opacity]); +} diff --git a/app/scripts/components/common/map/hooks/use-layer-interaction.ts b/app/scripts/components/common/map/hooks/use-layer-interaction.ts new file mode 100644 index 000000000..cddab9d80 --- /dev/null +++ b/app/scripts/components/common/map/hooks/use-layer-interaction.ts @@ -0,0 +1,40 @@ + +import { Feature } from 'geojson'; +import { useEffect } from 'react'; +import useMaps from './use-maps'; + +interface LayerInteractionHookOptions { + layerId: string; + onClick: (features: Feature[]) => void; +} +export default function useLayerInteraction({ + layerId, + onClick +}: LayerInteractionHookOptions) { + const { current: mapInstance } = useMaps(); + useEffect(() => { + if (!mapInstance) return; + const onPointsClick = (e) => { + if (!e.features.length) return; + onClick(e.features); + }; + + const onPointsEnter = () => { + mapInstance.getCanvas().style.cursor = 'pointer'; + }; + + const onPointsLeave = () => { + mapInstance.getCanvas().style.cursor = ''; + }; + + mapInstance.on('click', layerId, onPointsClick); + mapInstance.on('mouseenter', layerId, onPointsEnter); + mapInstance.on('mouseleave', layerId, onPointsLeave); + + return () => { + mapInstance.off('click', layerId, onPointsClick); + mapInstance.off('mouseenter', layerId, onPointsEnter); + mapInstance.off('mouseleave', layerId, onPointsLeave); + }; + }, [layerId, mapInstance, onClick]); +} \ No newline at end of file diff --git a/app/scripts/components/common/map/hooks/use-map-compare.ts b/app/scripts/components/common/map/hooks/use-map-compare.ts new file mode 100644 index 000000000..275b1a7f1 --- /dev/null +++ b/app/scripts/components/common/map/hooks/use-map-compare.ts @@ -0,0 +1,24 @@ +import { useEffect } from 'react'; +import MapboxCompare from 'mapbox-gl-compare'; +import useMaps, { useMapsContext } from './use-maps'; + +export default function useMapCompare() { + const { main, compared } = useMaps(); + const { containerId } = useMapsContext(); + const hasMapCompare = !!compared; + useEffect(() => { + if (!main) return; + + if (compared) { + const compare = new MapboxCompare(main, compared, `#${containerId}`, { + mousemove: false, + orientation: 'vertical' + }); + + return () => { + compare.remove(); + }; + } + // main should be stable, while we are only interested here in the absence or presence of compared + }, [containerId, hasMapCompare]); +} diff --git a/app/scripts/components/common/map/hooks/use-map-style.ts b/app/scripts/components/common/map/hooks/use-map-style.ts new file mode 100644 index 000000000..d57e15622 --- /dev/null +++ b/app/scripts/components/common/map/hooks/use-map-style.ts @@ -0,0 +1,16 @@ +import { useContext } from "react"; +import { StylesContext } from "../styles"; +import useCustomMarker from "./use-custom-marker"; +import useMaps from "./use-maps"; + +export function useStylesContext() { + return useContext(StylesContext); +} + +export default function useMapStyle() { + const { updateStyle, style } = useStylesContext(); + const { current } = useMaps(); + useCustomMarker(current); + + return { updateStyle, style }; +} diff --git a/app/scripts/components/common/map/hooks/use-maps.ts b/app/scripts/components/common/map/hooks/use-maps.ts new file mode 100644 index 000000000..0ab679a5d --- /dev/null +++ b/app/scripts/components/common/map/hooks/use-maps.ts @@ -0,0 +1,23 @@ +import { useContext } from 'react'; +import { useMap } from 'react-map-gl'; +import { MapsContext } from '../maps'; +import { useStylesContext } from './use-map-style'; + +export function useMapsContext() { + return useContext(MapsContext); +} + +export default function useMaps() { + const { mainId, comparedId } = useMapsContext(); + const { isCompared } = useStylesContext(); + const maps = useMap(); + const main = maps[mainId]; + const compared = maps[comparedId]; + const current = isCompared ? compared : main; + + return { + main, + compared, + current, + }; +} diff --git a/app/scripts/components/common/map/index.tsx b/app/scripts/components/common/map/index.tsx new file mode 100644 index 000000000..973be7e1d --- /dev/null +++ b/app/scripts/components/common/map/index.tsx @@ -0,0 +1,25 @@ +import React, { ReactNode } from 'react'; +import { MapProvider } from 'react-map-gl'; +import Maps, { MapsContextWrapperProps } from './maps'; + +export const COMPARE_CONTAINER_NAME = 'CompareContainer'; +export function Compare({ children }: { children: ReactNode }) { + return <>{children}; +} + +Compare.displayName = COMPARE_CONTAINER_NAME; + +export const CONTROLS_CONTAINER_NAME = 'MapControlsContainer'; +export function MapControls({ children }: { children: ReactNode }) { + return <>{children}; +} + +MapControls.displayName = CONTROLS_CONTAINER_NAME; + +export default function MapProviderWrapper(props: MapsContextWrapperProps) { + return ( + + {props.children} + + ); +} diff --git a/app/scripts/components/common/map/map-component.tsx b/app/scripts/components/common/map/map-component.tsx new file mode 100644 index 000000000..22dd76deb --- /dev/null +++ b/app/scripts/components/common/map/map-component.tsx @@ -0,0 +1,65 @@ +import React, { useCallback, ReactElement, useMemo } from 'react'; +import ReactMapGlMap, { LngLatBoundsLike } from 'react-map-gl'; +import { ProjectionOptions } from 'veda'; +import 'mapbox-gl/dist/mapbox-gl.css'; +import 'mapbox-gl-compare/dist/mapbox-gl-compare.css'; +import { convertProjectionToMapbox } from '../mapbox/map-options/utils'; +import useMapStyle from './hooks/use-map-style'; +import { useMapsContext } from './hooks/use-maps'; + +const maxMapBounds: LngLatBoundsLike = [ + [-540, -90], // SW + [540, 90] // NE +]; + +export default function MapComponent({ + controls, + isCompared, + projection +}: { + controls: ReactElement[]; + isCompared?: boolean; + projection?: ProjectionOptions; +}) { + const { initialViewState, setInitialViewState, mainId, comparedId } = + useMapsContext(); + + const id = isCompared ? comparedId : mainId; + + const onMove = useCallback( + (evt) => { + if (!isCompared) { + setInitialViewState(evt.viewState); + } + }, + [isCompared, setInitialViewState] + ); + + // Get MGL projection from Veda projection + const mapboxProjection = useMemo(() => { + if (!projection) return undefined; + return convertProjectionToMapbox(projection); + }, [projection]); + + const { style } = useMapStyle(); + + if (!style) return null; + + return ( + + {controls} + + ); +} diff --git a/app/scripts/components/common/map/mapbox-style-override.ts b/app/scripts/components/common/map/mapbox-style-override.ts new file mode 100644 index 000000000..5e9254052 --- /dev/null +++ b/app/scripts/components/common/map/mapbox-style-override.ts @@ -0,0 +1,339 @@ +import { css } from 'styled-components'; +import { + createButtonGroupStyles, + createButtonStyles +} from '@devseed-ui/button'; +import { + iconDataURI, + CollecticonPlusSmall, + CollecticonMinusSmall, + CollecticonMagnifierLeft, + CollecticonXmarkSmall +} from '@devseed-ui/collecticons'; +import { glsp, themeVal } from '@devseed-ui/theme-provider'; +import { variableGlsp } from '$styles/variable-utils'; +import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css'; + +const MapboxStyleOverride = css` + .mapboxgl-control-container { + position: absolute; + z-index: 2; + inset: ${variableGlsp()}; + pointer-events: none; + + > * { + display: flex; + flex-flow: column nowrap; + gap: ${variableGlsp(0.5)}; + align-items: flex-start; + float: none; + } + + .mapboxgl-ctrl { + margin: 0; + pointer-events: none; + + > * { + pointer-events: auto; + } + + &:empty { + display: none; + } + } + + .mapboxgl-ctrl-attrib { + order: 100; + padding: 0; + background: none; + } + + .mapboxgl-ctrl-attrib-inner { + color: ${themeVal('color.surface')}; + border-radius: ${themeVal('shape.ellipsoid')}; + padding: ${glsp(0.125, 0.5)}; + font-size: 0.75rem; + line-height: 1rem; + background: ${themeVal('color.base-400a')}; + transform: translateY(-0.075rem); + + a, + a:visited { + color: inherit; + font-size: inherit; + line-height: inherit; + vertical-align: top; + text-decoration: none; + } + + a:hover { + opacity: 0.64; + } + } + } + + /* stylelint-disable no-descending-specificity */ + .mapboxgl-ctrl-logo, + .mapboxgl-ctrl-attrib-inner { + margin: 0; + opacity: 0.48; + transition: all 0.24s ease-in-out 0s; + + &:hover { + opacity: 1; + } + } + /* stylelint-enable no-descending-specificity */ + + .mapboxgl-ctrl-bottom-left { + flex-flow: row wrap; + align-items: center; + } + + .mapboxgl-ctrl-top-right { + align-items: end; + } + + .mapboxgl-ctrl-top-left { + flex-flow: row wrap; + align-items: center; + } + + .mapboxgl-ctrl-group { + ${createButtonGroupStyles({ orientation: 'vertical' } as any)} + background: none; + + &, + &:not(:empty) { + box-shadow: ${themeVal('boxShadow.elevationA')}; + } + + > button { + span { + display: none; + } + + &::before { + display: inline-block; + content: ''; + background-repeat: no-repeat; + background-size: 1rem 1rem; + width: 1rem; + height: 1rem; + } + } + + > button + button { + margin-top: -${themeVal('button.shape.border')}; + } + + > button:first-child:not(:last-child) { + &, + &::after { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } + + &::after { + clip-path: inset(-100% -100% 0 -100%); + } + } + > button:last-child:not(:first-child) { + &, + &::after { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + + &::after { + clip-path: inset(0 -100% -100% -100%); + } + } + > button:not(:first-child):not(:last-child) { + &, + &::after { + border-radius: 0; + } + + &::after { + clip-path: inset(0 -100%); + } + } + } + + .mapboxgl-ctrl-zoom-in.mapboxgl-ctrl-zoom-in, + .mapboxgl-ctrl-zoom-out.mapboxgl-ctrl-zoom-out { + ${createButtonStyles({ + variation: 'surface-fill', + fitting: 'skinny' + } as any)} + } + + .mapboxgl-ctrl-zoom-in.mapboxgl-ctrl-zoom-in::before { + background-image: url(${({ theme }) => + iconDataURI(CollecticonPlusSmall, { + color: theme.color?.base + })}); + } + + .mapboxgl-ctrl-zoom-out.mapboxgl-ctrl-zoom-out::before { + background-image: url(${({ theme }) => + iconDataURI(CollecticonMinusSmall, { + color: theme.color?.base + })}); + } + + .mapboxgl-marker:hover { + cursor: pointer; + } + + .mapboxgl-ctrl-scale { + color: ${themeVal('color.surface')}; + border-color: ${themeVal('color.surface')}; + background-color: ${themeVal('color.base-400a')}; + } + + /* GEOCODER styles */ + .mapboxgl-ctrl.mapboxgl-ctrl-geocoder { + background-color: ${themeVal('color.surface')}; + color: ${themeVal('type.base.color')}; + font: ${themeVal('type.base.style')} ${themeVal('type.base.weight')} + 0.875rem/1.25rem ${themeVal('type.base.family')}; + transition: all 0.24s ease 0s; + + &::before { + position: absolute; + top: 8px; + left: 8px; + content: ''; + width: 1rem; + height: 1rem; + background-image: url(${({ theme }) => + iconDataURI(CollecticonMagnifierLeft, { + color: theme.color?.base + })}); + background-repeat: no-repeat; + } + + &.mapboxgl-ctrl-geocoder--collapsed { + width: 2rem; + min-width: 2rem; + background-color: ${themeVal('color.surface')}; + + &::before { + background-image: url(${({ theme }) => + iconDataURI(CollecticonMagnifierLeft, { + color: theme.color?.base + })}); + } + } + + .mapboxgl-ctrl-geocoder--icon { + display: none; + } + + .mapboxgl-ctrl-geocoder--icon-loading { + top: 5px; + right: 8px; + } + + .mapboxgl-ctrl-geocoder--button { + width: 2rem; + height: 2rem; + top: 0; + right: 0; + background: none; + border-radius: ${themeVal('shape.rounded')}; + transition: all 0.24s ease 0s; + color: inherit; + + &:hover { + opacity: 0.64; + } + + &::before { + position: absolute; + top: 8px; + left: 8px; + content: ''; + width: 1rem; + height: 1rem; + background-image: url(${({ theme }) => + iconDataURI(CollecticonXmarkSmall, { + color: theme.color?.['base-300'] + })}); + } + } + + .mapboxgl-ctrl-geocoder--input { + height: 2rem; + width: 100%; + outline: none; + font: ${themeVal('type.base.style')} ${themeVal('type.base.weight')} + 0.875rem / ${themeVal('type.base.line')} ${themeVal( + 'type.base.family' +)}; + padding: 0.25rem 2rem; + color: inherit; + + &::placeholder { + color: inherit; + opacity: 0.64; + } + } + + .mapboxgl-ctrl-geocoder--powered-by { + display: none !important; + } + + .suggestions { + margin-bottom: 0.5rem; + border-radius: ${themeVal('shape.rounded')}; + font: inherit; + + a { + padding: 0.375rem 1rem; + color: inherit; + transition: all 0.24s ease 0s; + + &:hover { + opacity: 1; + color: ${themeVal('color.primary')}; + background: ${themeVal('color.primary-100')}; + } + } + + li { + &:first-child a { + padding-top: 0.5rem; + } + + &:last-child a { + padding-bottom: 0.75rem; + } + + &.active > a { + position: relative; + background: ${themeVal('color.primary-50')}; + color: ${themeVal('color.primary')}; + + &::before { + content: ''; + position: absolute; + left: 0; + top: 0; + height: 100%; + width: 0.25rem; + background: ${themeVal('color.primary')}; + } + + &:hover { + background: ${themeVal('color.primary-100')}; + } + } + } + } + } +`; + +export default MapboxStyleOverride; diff --git a/app/scripts/components/common/map/maps.tsx b/app/scripts/components/common/map/maps.tsx new file mode 100644 index 000000000..446a64f2a --- /dev/null +++ b/app/scripts/components/common/map/maps.tsx @@ -0,0 +1,208 @@ +import React, { + ReactNode, + Children, + useMemo, + ReactElement, + useState, + createContext +} from 'react'; +import styled from 'styled-components'; +import { + CollecticonChevronLeftSmall, + CollecticonChevronRightSmall, + iconDataURI +} from '@devseed-ui/collecticons'; +import { themeVal } from '@devseed-ui/theme-provider'; +import { ProjectionOptions } from 'veda'; +import useDimensions from 'react-cool-dimensions'; +import 'mapbox-gl/dist/mapbox-gl.css'; +import 'mapbox-gl-compare/dist/mapbox-gl-compare.css'; +import MapboxStyleOverride from './mapbox-style-override'; +import { ExtendedStyle, Styles } from './styles'; +import useMapCompare from './hooks/use-map-compare'; +import MapComponent from './map-component'; +import useMaps, { useMapsContext } from './hooks/use-maps'; +import { COMPARE_CONTAINER_NAME, CONTROLS_CONTAINER_NAME } from '.'; + +const chevronRightURI = () => + iconDataURI(CollecticonChevronRightSmall, { + color: 'white' + }); + +const chevronLeftURI = () => + iconDataURI(CollecticonChevronLeftSmall, { + color: 'white' + }); + +const MapsContainer = styled.div` + ${MapboxStyleOverride} + height: 100%; + + .mapboxgl-map { + position: absolute !important; + inset: 0; + + &.mouse-add .mapboxgl-canvas-container { + cursor: crosshair; + } + &.mouse-pointer .mapboxgl-canvas-container { + cursor: pointer; + } + &.mouse-move .mapboxgl-canvas-container { + cursor: move; + } + } + + .mapboxgl-compare .compare-swiper-vertical { + background: ${themeVal('color.primary')}; + display: flex; + align-items: center; + justify-content: center; + + &::before, + &::after { + display: inline-block; + content: ''; + background-repeat: no-repeat; + background-size: 1rem 1rem; + width: 1rem; + height: 1rem; + } + + &::before { + background-image: url('${chevronLeftURI()}'); + } + &::after { + background-image: url('${chevronRightURI()}'); + } + } +`; + +type MapsProps = Pick< + MapsContextWrapperProps, + 'projection' | 'onStyleUpdate' +> & { + children: ReactNode; +}; + +function Maps({ children, projection, onStyleUpdate }: MapsProps) { + // Instantiate MGL Compare, if compare is enabled + useMapCompare(); + + // Split children into layers and controls, using all children provided + const { generators, compareGenerators, controls } = useMemo(() => { + const childrenArr = Children.toArray(children) as ReactElement[]; + + const sortedChildren = childrenArr.reduce( + (acc, child) => { + // This is added so that we can use the component name in production + // where the function names are minified + // @ts-expect-error displayName is not in the type + const componentName = child.type.displayName ?? ''; + + if (componentName === COMPARE_CONTAINER_NAME) { + acc.compareGenerators = Children.toArray( + child.props.children + ) as ReactElement[]; + } else if (componentName == CONTROLS_CONTAINER_NAME) { + acc.controls = Children.toArray( + child.props.children + ) as ReactElement[]; + } else { + acc.generators = [...acc.generators, child]; + } + return acc; + }, + { + generators: [] as ReactElement[], + controls: [] as ReactElement[], + compareGenerators: [] as ReactElement[] + } + ); + + return sortedChildren; + }, [children]); + + const maps = useMaps(); + + const { observe } = useDimensions({ + onResize: () => { + setTimeout(() => { + maps.main?.resize(); + maps.compared?.resize(); + }, 0); + } + }); + + const { containerId } = useMapsContext(); + + return ( + + + {generators} + + + {!!compareGenerators.length && ( + + {compareGenerators} + + + )} + + ); +} + +export interface MapsContextWrapperProps { + children: ReactNode; + id: string; + projection?: ProjectionOptions; + onStyleUpdate?: (style: ExtendedStyle) => void; +} + +export default function MapsContextWrapper(props: MapsContextWrapperProps) { + const { id } = props; + const mainId = `main-map-${id}`; + const comparedId = `compared-map-${id}`; + const containerId = `comparison-container-${id}`; + + // Holds the initial view state for the main map, used by compare map at mount + const [initialViewState, setInitialViewState] = useState({ + latitude: 0, + longitude: 0, + zoom: 1 + }); + + return ( + + {props.children} + + ); +} + +interface MapsContextType { + initialViewState: any; + setInitialViewState: (viewState: any) => void; + mainId: string; + comparedId: string; + containerId: string; +} + +export const MapsContext = createContext({ + initialViewState: {}, + setInitialViewState: () => undefined, + mainId: '', + comparedId: '', + containerId: '' +}); diff --git a/app/scripts/components/common/map/style-generators/basemap.tsx b/app/scripts/components/common/map/style-generators/basemap.tsx new file mode 100644 index 000000000..b5e037b98 --- /dev/null +++ b/app/scripts/components/common/map/style-generators/basemap.tsx @@ -0,0 +1,118 @@ +import { useQuery } from '@tanstack/react-query'; +import { AnySourceImpl, Layer, Style } from 'mapbox-gl'; +import { useEffect, useState } from 'react'; +import { + BasemapId, + BASEMAP_STYLES, + getStyleUrl, + GROUPS_BY_OPTION +} from '../controls/map-options/basemap'; +import { ExtendedLayer } from '../types'; +import useMapStyle from '../hooks/use-map-style'; + +interface BasemapProps { + basemapStyleId?: BasemapId; + labelsOption?: boolean; + boundariesOption?: boolean; +} + +function mapGroupNameToGroupId( + groupNames: string[], + mapboxGroups: Record +) { + const groupsAsArray = Object.entries(mapboxGroups); + + return groupNames.map((groupName) => { + return groupsAsArray.find(([, group]) => group.name === groupName)?.[0]; + }); +} + +export function Basemap({ + basemapStyleId = 'satellite', + labelsOption = true, + boundariesOption = true +}: BasemapProps) { + const { updateStyle } = useMapStyle(); + + const [baseStyle, setBaseStyle] = useState