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/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} + + 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-left' + }); + return null; +} diff --git a/app/scripts/components/common/map/map-component.tsx b/app/scripts/components/common/map/map-component.tsx index 04cc499a6..dbd1d3d33 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 { convertProjectionToMapbox } from '../mapbox/map-options/utils'; import { StylesContext } 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,6 +30,12 @@ export default function MapComponent({ [isCompared, setInitialViewState] ); + // Get MGL projection from Veda projection + const mapboxProjection = useMemo(() => { + if (!projection) return undefined; + return convertProjectionToMapbox(projection); + }, [projection]); + const { style } = useContext(StylesContext); if (!style) return null; @@ -37,6 +47,7 @@ export default function MapComponent({ initialViewState={initialViewState} mapStyle={style as any} onMove={onMove} + projection={mapboxProjection} > {controls} diff --git a/app/scripts/components/common/map/maps.tsx b/app/scripts/components/common/map/maps.tsx index 6872c3214..acbe5c5b1 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'; @@ -68,7 +69,11 @@ const MapsContainer = styled.div` } `; -function Maps({ children }: { children: ReactNode }) { +type MapsProps = Pick & { + children: ReactNode; +}; + +function Maps({ children, projection }: MapsProps) { // Instanciate MGL Compare, if compare is enabled useMapCompare(); @@ -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/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 dd990d943..a6353d470 100644 --- a/app/scripts/components/exploration/index.tsx +++ b/app/scripts/components/exploration/index.tsx @@ -13,8 +13,14 @@ 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/geocoder'; -import { NavigationControl, ScaleControl } from '$components/common/map/controls'; -import Coords from '$components/common/map/controls/coords'; +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; @@ -65,6 +71,16 @@ function Exploration() { const openModal = useCallback(() => setDatasetModalRevealed(true), []); const closeModal = useCallback(() => setDatasetModalRevealed(false), []); + const [projection, setProjection] = useState(projectionDefault); + + const { + mapBasemapId, + setBasemapId, + labelsOption, + boundariesOption, + onOptionChange + } = useBasemap(); + return ( <> - - + + {/* Map layers */} + + {/* Map controls */} - + + {compare && ( + // Compare map layers