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..0e5bc2f39
--- /dev/null
+++ b/app/scripts/components/common/map/controls/coords.tsx
@@ -0,0 +1,77 @@
+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;
+
+ ${Button} {
+ 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 MapCoords() {
+ 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/map-options/geocoder.tsx b/app/scripts/components/common/map/controls/geocoder.tsx
similarity index 100%
rename from app/scripts/components/common/map/controls/map-options/geocoder.tsx
rename to app/scripts/components/common/map/controls/geocoder.tsx
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-themed-control.tsx b/app/scripts/components/common/map/controls/hooks/use-themed-control.tsx
new file mode 100644
index 000000000..f2bf5a90c
--- /dev/null
+++ b/app/scripts/components/common/map/controls/hooks/use-themed-control.tsx
@@ -0,0 +1,50 @@
+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
+ if (elementRef.current) {
+ rootRef.current?.unmount();
+ }
+ }
+ }
+
+ // 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..e5d766236
--- /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 ;
+}
\ No newline at end of file
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/controls/options.tsx b/app/scripts/components/common/map/controls/options.tsx
new file mode 100644
index 000000000..e332f2ae0
--- /dev/null
+++ b/app/scripts/components/common/map/controls/options.tsx
@@ -0,0 +1,272 @@
+import React from 'react';
+import styled from 'styled-components';
+import { glsp, themeVal } from '@devseed-ui/theme-provider';
+import { CollecticonGlobe } from '@devseed-ui/collecticons';
+import {
+ Dropdown,
+ DropMenu,
+ DropMenuItem,
+ DropTitle
+} from '@devseed-ui/dropdown';
+import { Button, createButtonStyles } from '@devseed-ui/button';
+import { FormSwitch } from '@devseed-ui/form';
+import { Subtitle } from '@devseed-ui/typography';
+
+import {
+ ProjectionItemConic,
+ ProjectionItemCustom,
+ ProjectionItemSimple
+} from './map-options/projection-items';
+import { MapOptionsProps } from './map-options/types';
+import { projectionsList } from './map-options/projections';
+import { BASEMAP_STYLES } from './map-options/basemap';
+import useThemedControl from './hooks/use-themed-control';
+import { ShadowScrollbarImproved as ShadowScrollbar } from '$components/common/shadow-scrollbar-improved';
+
+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)};
+`;
+
+/**
+ * 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(Button)`
+ &&& {
+ ${createButtonStyles({ variation: 'primary-fill', fitting: 'skinny' })}
+ }
+`;
+
+const shadowScrollbarProps = {
+ autoHeight: true,
+ autoHeightMax: 320
+};
+
+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='left'
+ >
+
+ Map options
+
+
+
+
+
+ Style
+
+
+
+ {BASEMAP_STYLES.map((basemap) => (
+
+ {
+ e.preventDefault();
+ onBasemapStyleIdChange?.(basemap.id);
+ }}
+ >
+ {basemap.label}
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+ 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-left'
+ });
+ return null;
+}
diff --git a/app/scripts/components/common/map/hooks/use-map-compare.ts b/app/scripts/components/common/map/hooks/use-map-compare.ts
index 7cb4d238f..b5d17b732 100644
--- a/app/scripts/components/common/map/hooks/use-map-compare.ts
+++ b/app/scripts/components/common/map/hooks/use-map-compare.ts
@@ -1,29 +1,25 @@
-import { useContext, useEffect } from "react";
+import { useContext, useEffect } from 'react';
import MapboxCompare from 'mapbox-gl-compare';
-import { MapsContext } from "../maps";
-import { useMaps } from "./use-maps";
+import { MapsContext } from '../maps';
+import useMaps from './use-maps';
export default function useMapCompare() {
- const maps = useMaps();
+ const { main, compared } = useMaps();
const { containerId } = useContext(MapsContext);
- const hasMapCompare = !!maps.compared;
+ const hasMapCompare = !!compared;
useEffect(() => {
- if (!maps.main) return;
+ if (!main) return;
- if (maps.compared) {
- const compare = new MapboxCompare(
- maps.main,
- maps.compared,
- `#${containerId}`,
- {
- mousemove: false,
- orientation: 'vertical'
- }
- );
+ 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]);
-}
\ No newline at end of file
+}
diff --git a/app/scripts/components/common/map/hooks/use-maps.ts b/app/scripts/components/common/map/hooks/use-maps.ts
index add233002..56f877e2b 100644
--- a/app/scripts/components/common/map/hooks/use-maps.ts
+++ b/app/scripts/components/common/map/hooks/use-maps.ts
@@ -2,7 +2,7 @@ import { useContext } from 'react';
import { useMap } from 'react-map-gl';
import { MapsContext } from '../maps';
-export function useMaps() {
+export default function useMaps() {
const { mainId, comparedId } = useContext(MapsContext);
const maps = useMap();
diff --git a/app/scripts/components/common/map/index.tsx b/app/scripts/components/common/map/index.tsx
index 65c9ee910..4bd1bb4e2 100644
--- a/app/scripts/components/common/map/index.tsx
+++ b/app/scripts/components/common/map/index.tsx
@@ -1,12 +1,12 @@
import React, { ReactNode } from 'react';
import { MapProvider } from 'react-map-gl';
-import Maps, { MapsProps } from './maps';
+import Maps, { MapsContextWrapperProps } from './maps';
export function Compare({ children }: { children: ReactNode }) {
return <>{children}>;
}
-export default function MapProviderWrapper(props: MapsProps) {
+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
index 04cc499a6..1e6ac3660 100644
--- a/app/scripts/components/common/map/map-component.tsx
+++ b/app/scripts/components/common/map/map-component.tsx
@@ -1,16 +1,20 @@
-import React, { useCallback, ReactElement, useContext } from 'react';
+import React, { useCallback, ReactElement, useContext, useMemo } from 'react';
import ReactMapGlMap 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 { StylesContext } from './styles';
+import { convertProjectionToMapbox } from '../mapbox/map-options/utils';
+import { useMapStyle } from './styles';
import { MapsContext } from './maps';
export default function MapComponent({
controls,
- isCompared
+ isCompared,
+ projection
}: {
controls: ReactElement[];
isCompared?: boolean;
+ projection?: ProjectionOptions;
}) {
const { initialViewState, setInitialViewState, mainId, comparedId } =
useContext(MapsContext);
@@ -26,7 +30,13 @@ export default function MapComponent({
[isCompared, setInitialViewState]
);
- const { style } = useContext(StylesContext);
+ // Get MGL projection from Veda projection
+ const mapboxProjection = useMemo(() => {
+ if (!projection) return undefined;
+ return convertProjectionToMapbox(projection);
+ }, [projection]);
+
+ const { style } = useMapStyle();
if (!style) return null;
@@ -34,9 +44,14 @@ export default function MapComponent({
{controls}
diff --git a/app/scripts/components/common/map/maps.tsx b/app/scripts/components/common/map/maps.tsx
index b2cc57baa..9899b7977 100644
--- a/app/scripts/components/common/map/maps.tsx
+++ b/app/scripts/components/common/map/maps.tsx
@@ -15,6 +15,7 @@ import {
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';
@@ -22,7 +23,7 @@ import MapboxStyleOverride from './mapbox-style-override';
import { Styles } from './styles';
import useMapCompare from './hooks/use-map-compare';
import MapComponent from './map-component';
-import { useMaps } from './hooks/use-maps';
+import useMaps from './hooks/use-maps';
const chevronRightURI = () =>
iconDataURI(CollecticonChevronRightSmall, {
@@ -68,8 +69,12 @@ const MapsContainer = styled.div`
}
`;
-function Maps({ children }: { children: ReactNode }) {
- // Instanciate MGL Compare, if compare is enabled
+type MapsProps = Pick & {
+ children: ReactNode;
+};
+
+function Maps({ children, projection }: MapsProps) {
+ // Instantiate MGL Compare, if compare is enabled
useMapCompare();
// Split children into layers and controls, using all children provided
@@ -117,24 +122,29 @@ function Maps({ children }: { children: ReactNode }) {
{generators}
-
+
{!!compareGenerators.length && (
{compareGenerators}
-
+
)}
);
}
-export interface MapsProps {
+export interface MapsContextWrapperProps {
children: ReactNode;
id: string;
+ projection?: ProjectionOptions;
}
-export default function MapsContextWrapper(props: MapsProps) {
+export default function MapsContextWrapper(props: MapsContextWrapperProps) {
const { id } = props;
const mainId = `main-map-${id}`;
const comparedId = `compared-map-${id}`;
diff --git a/app/scripts/components/common/map/style-generators/basemap.tsx b/app/scripts/components/common/map/style-generators/basemap.tsx
index 3d86bc907..3bfe56f16 100644
--- a/app/scripts/components/common/map/style-generators/basemap.tsx
+++ b/app/scripts/components/common/map/style-generators/basemap.tsx
@@ -59,9 +59,10 @@ export function Basemap({
setBaseStyle(styleJson as Style);
}, [styleJson]);
- // Apply labels and boundaries options, by setting visibility on related layers
- // For simplicity's sake, the Mapbox layer group (as set in Mapbox Studio) is used
- // to determine wehether a layer is a labels layer or boundaries or none of those.
+ // Apply labels and boundaries options, by setting visibility on related
+ // layers For simplicity's sake, the Mapbox layer group (as set in Mapbox
+ // Studio) is used to determine whether a layer is a labels layer or
+ // boundaries or none of those.
useEffect(() => {
if (!baseStyle) return;
diff --git a/app/scripts/components/common/map/utils.ts b/app/scripts/components/common/map/utils.ts
new file mode 100644
index 000000000..4ff661f57
--- /dev/null
+++ b/app/scripts/components/common/map/utils.ts
@@ -0,0 +1,4 @@
+import { validateRangeNum } from "$utils/utils";
+
+export const validateLon = validateRangeNum(-180, 180);
+export const validateLat = validateRangeNum(-90, 90);
\ No newline at end of file
diff --git a/app/scripts/components/exploration/index.tsx b/app/scripts/components/exploration/index.tsx
index eec897207..10afc3ea9 100644
--- a/app/scripts/components/exploration/index.tsx
+++ b/app/scripts/components/exploration/index.tsx
@@ -13,7 +13,15 @@ import PageHero from '$components/common/page-hero';
import { PageMainContent } from '$styles/page';
import Map, { Compare } from '$components/common/map';
import { Basemap } from '$components/common/map/style-generators/basemap';
-import GeocoderControl from '$components/common/map/controls/map-options/geocoder';
+import GeocoderControl from '$components/common/map/controls/geocoder';
+import {
+ NavigationControl,
+ ScaleControl
+} from '$components/common/map/controls';
+import MapCoordsControl from '$components/common/map/controls/coords';
+import MapOptionsControl from '$components/common/map/controls/options';
+import { projectionDefault } from '$components/common/map/controls/map-options/projections';
+import { useBasemap } from '$components/common/map/controls/hooks/use-basemap';
const Container = styled.div`
display: flex;
@@ -58,12 +66,22 @@ const Container = styled.div`
`;
function Exploration() {
- const [compare, setCompare] = useState(false);
+ const [compare, setCompare] = useState(true);
const [datasetModalRevealed, setDatasetModalRevealed] = useState(true);
const openModal = useCallback(() => setDatasetModalRevealed(true), []);
const closeModal = useCallback(() => setDatasetModalRevealed(false), []);
+ const [projection, setProjection] = useState(projectionDefault);
+
+ const {
+ mapBasemapId,
+ setBasemapId,
+ labelsOption,
+ boundariesOption,
+ onOptionChange
+ } = useBasemap();
+
useStacMetadataOnDatasets();
return (
@@ -79,12 +97,35 @@ function Exploration() {
-