Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Exploration map controls #651

Merged
merged 8 commits into from
Sep 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions app/scripts/components/common/map/controls/coords.tsx
Original file line number Diff line number Diff line change
@@ -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(
() => (
<MapCoordsWrapper>
<CopyField value={value}>
{({ ref, showCopiedMsg }) => (
<Button
ref={ref}
// @ts-expect-error achromic-text exists but ui-library types are
// not up to date
variation='achromic-text'
size='small'
>
{showCopiedMsg ? 'Copied!' : value}
</Button>
)}
</CopyField>
</MapCoordsWrapper>
),
{ position: 'bottom-left' }
);

return null;
}
26 changes: 26 additions & 0 deletions app/scripts/components/common/map/controls/hooks/use-basemap.ts
Original file line number Diff line number Diff line change
@@ -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<BasemapId>(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
};
}
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement | null>(null);
const rootRef = useRef<ReturnType<typeof createRoot> | 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(
<ThemeProvider theme={theme}>{renderFn() as any}</ThemeProvider>
);

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(
<ThemeProvider theme={theme}>{renderFn() as any}</ThemeProvider>
);
}
}, [renderFn, theme]);
useControl(() => new ThemedControl(), opts);
return null;
}
13 changes: 13 additions & 0 deletions app/scripts/components/common/map/controls/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react';
import {
NavigationControl as MapboxGLNavigationControl,
ScaleControl as MapboxGLScaleControl
} from 'react-map-gl';

export function NavigationControl() {
return <MapboxGLNavigationControl position='top-left' showCompass={false} />;
}

export function ScaleControl() {
return <MapboxGLScaleControl position='bottom-left' />;
}
Original file line number Diff line number Diff line change
@@ -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 (
<li>
<DropMenuItem
active={id === activeProjection.id}
href='#'
onClick={(e) => {
e.preventDefault();
onChange({ id });
}}
>
{label}
</DropMenuItem>
</li>
);
}

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 (
<li>
<DropMenuItem
active={isActive}
href='#'
// data-dropdown='click.close'
onClick={(e) => {
e.preventDefault();
onChange({
...conicValues,
id
});
}}
>
{label}
</DropMenuItem>
{isActive && (
<ProjectionOptionsForm>
<FormFieldsetCompact>
<FormFieldsetHeader>
<FormLegend>Center Lon/Lat</FormLegend>
</FormFieldsetHeader>
<FormFieldsetBodyColumns>
{projectionConicCenter.map((field, idx) => (
<StressedFormGroupInput
key={field.id}
hideHeader
inputType='text'
inputSize='small'
id={`center-${field.id}`}
name={`center-${field.id}`}
label={field.label}
value={conicValues.center?.[idx]}
validate={field.validate}
onChange={(value) => {
onChangeConicValues(Number(value), 'center', idx);
}}
/>
))}
</FormFieldsetBodyColumns>
</FormFieldsetCompact>
<FormFieldsetCompact>
<FormFieldsetHeader>
<FormLegend>S/N Parallels</FormLegend>
</FormFieldsetHeader>
<FormFieldsetBodyColumns>
{projectionConicParallel.map((field, idx) => (
<StressedFormGroupInput
key={field.id}
hideHeader
inputType='text'
inputSize='small'
id={`parallels-${field.id}`}
name={`parallels-${field.id}`}
label={field.label}
value={conicValues.parallels?.[idx].toString() ?? ''}
validate={field.validate}
onChange={(value) => {
onChangeConicValues(Number(value), 'parallels', idx);
}}
/>
))}
</FormFieldsetBodyColumns>
</FormFieldsetCompact>
</ProjectionOptionsForm>
)}
</li>
);
}

export function ProjectionItemCustom(props: ProjectionItemConicProps) {
const { onChange, id, label, defaultConicValues, activeProjection } = props;

return (
<li>
<DropMenuItem
active={id === activeProjection.id}
href='#'
onClick={(e) => {
e.preventDefault();
onChange({ id, ...defaultConicValues });
}}
>
{label}
</DropMenuItem>
</li>
);
}
Loading
Loading