Skip to content

Commit

Permalink
feat(map): keep map coordinates in url (#331)
Browse files Browse the repository at this point in the history
* feat(map): keep map coordinates in url

* fix(map): type check URL props and allow 0 as a valid coordinate in the URL

* fix(map): use absolute imports

Signed-off-by: vt90 <tomsavlad90@gmail.com>
  • Loading branch information
vt90 committed Dec 2, 2020
1 parent cbc780c commit 2c8cec8
Show file tree
Hide file tree
Showing 9 changed files with 196 additions and 14 deletions.
33 changes: 30 additions & 3 deletions packages/earth-map/src/components/map/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,19 @@ import { API_URL, MAPBOX_TOKEN } from 'config';
import experienceIMG from 'images/pins/experience-marker.svg';
import debounce from 'lodash/debounce';
import React, { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { renderToString } from 'react-dom/server';
import isEqual from 'react-fast-compare';
import { useTranslation } from 'react-i18next';
import Link from 'redux-first-router-link';
import { APP_ABOUT } from 'theme';

import { Map, Spinner, UserMenu } from '@marapp/earth-shared';

import {
extractCoordinatesFromUrl,
isValidUrlCoordinateGroup,
IUrlCoordinates,
} from '../../utils/map';
import BasemapComponent from '../basemap';
import MapControls from './controls';
import RecenterControl from './controls/recenter';
Expand Down Expand Up @@ -61,6 +66,7 @@ interface IMap {
}

interface IMapState {
initialUrlCoordinates?: IUrlCoordinates;
loadingTilesIntervalRef?: NodeJS.Timeout;
}

Expand All @@ -81,6 +87,8 @@ class MapComponent extends React.Component<IMap, IMapState> {
}

public componentDidMount() {
const initialUrlCoordinates = extractCoordinatesFromUrl();

const loadingTilesIntervalRef = setInterval(() => {
const isLoadingTiles = !this.map.areTilesLoaded();
const loadingIndicatorNode = document.querySelector('.map-load-indicator');
Expand All @@ -93,6 +101,7 @@ class MapComponent extends React.Component<IMap, IMapState> {
}, 100);

this.setState({
initialUrlCoordinates,
loadingTilesIntervalRef,
});
}
Expand Down Expand Up @@ -144,9 +153,10 @@ class MapComponent extends React.Component<IMap, IMapState> {
}

public onZoomChange = (zoom) => {
const { setMapViewport } = this.props;
const { viewport, setMapViewport } = this.props;

setMapViewport({
...viewport,
zoom,
transitionDuration: 500,
});
Expand Down Expand Up @@ -281,6 +291,23 @@ class MapComponent extends React.Component<IMap, IMapState> {
});
};

/**
* Once the map is loaded, check if there were any valid coordinates provided in the URL.
* If so, set the viewport in accordance with those coordinates
*/
public onLoad = () => {
const { initialUrlCoordinates } = this.state;

if (isValidUrlCoordinateGroup(initialUrlCoordinates)) {
this.onViewportChange({
...initialUrlCoordinates,
transitionDuration: 800,
});

this.onViewportChange.flush(); // execute directly the last call
}
};

public render() {
const {
selectedOpen,
Expand Down Expand Up @@ -313,6 +340,7 @@ class MapComponent extends React.Component<IMap, IMapState> {
onViewportChange={this.onViewportChange}
onClick={this.onClick}
onHover={this.onHover}
onLoad={this.onLoad}
onReady={this.onReady}
mapOptions={{
customAttribution: `
Expand All @@ -325,7 +353,6 @@ class MapComponent extends React.Component<IMap, IMapState> {
)}
`,
}}
// onLoad={this.onLoad}
transformRequest={this.onTransformRequest}
>
{(map) => {
Expand Down
42 changes: 41 additions & 1 deletion packages/earth-map/src/components/url/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,49 @@
import { PureComponent } from 'react';
import { replace } from 'redux-first-router';

export interface IUrlProp {
/**
* Provides the type of value encoded in the URL
*/
type: string;

/**
* The name of the field encoded in the url
* e.q. "location" would be presented in the URL as ?location=some_value
*/
value: string;

/**
* Selector for the redux store value that needs to be added in the URL
*/
redux: string;

/**
* If set to true, and the value from 'redux' is undefined, this value won't be added in the URL
*/
required: boolean;

/**
* Action dispatched once the URL value changes
*/
action(payload?: any): any;

/**
* Optional - transform function that allows data modification before the value is added to the URL
*/
mapValueToUrl?(reduxStoreValue: any): any;

/**
* Optional - transform function that modifies the received value from the url, before dispatching
* the redux action
*/
mapUrlToValue?(urlValue: any): any;
}

interface IUrl {
router: {};
url: string;
urlProps: any;
urlProps: IUrlProp[];
urlFromParams: {};
paramsFromUrl: {};
}
Expand All @@ -33,6 +72,7 @@ class UrlComponent extends PureComponent<IUrl, any> {
const { urlProps, paramsFromUrl } = this.props;

urlProps.forEach((r) => {
// @ts-ignore
const action = this.props[r.action];
const payload = paramsFromUrl[r.value];

Expand Down
16 changes: 13 additions & 3 deletions packages/earth-map/src/components/url/selectors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ export const getUrlFromParams = createSelector([urlProps, state], (_urlProps, _s

return {
...acc,
[current.value]: value,
[current.value]:
current.mapValueToUrl && typeof current.mapValueToUrl === 'function'
? current.mapValueToUrl(value)
: value,
};
}, {});
});
Expand All @@ -47,11 +50,18 @@ export const getParamsFromUrl = createSelector([urlProps, state], (_urlProps, _s
const value = query[current.value];

if (type === 'array') {
const val = value || [];
// the extracted value should be an array
// from the URL parse a string could be received
// make sure to transform it into an array
let val = value || [];
val = Array.isArray(val) ? val : [val];

return {
...acc,
[current.value]: Array.isArray(val) ? val : [val],
[current.value]:
current.mapUrlToValue && typeof current.mapUrlToValue === 'function'
? current.mapUrlToValue(val)
: val,
};
}

Expand Down
18 changes: 14 additions & 4 deletions packages/earth-map/src/modules/map/initial-state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,24 @@
specific language governing permissions and limitations under the License.
*/

import { extractCoordinatesFromUrl, isValidUrlCoordinateGroup } from 'utils/map';

import { APP_BASEMAPS } from '../../theme';

const coordinatesFromUrl = extractCoordinatesFromUrl();

export const INITIAL_VIEW_PORT = {
latitude: 20,
longitude: 0,
zoom: 2,
minZoom: 2,
};

export default {
viewport: {
zoom: 2,
minZoom: 2,
latitude: 20,
longitude: 0,
...INITIAL_VIEW_PORT,
// set initial state based on URL, otherwise s short URL flicker wold be visible
...(isValidUrlCoordinateGroup(coordinatesFromUrl) && coordinatesFromUrl),
},
bounds: {},
interactions: {},
Expand Down
3 changes: 2 additions & 1 deletion packages/earth-map/src/modules/map/reducers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
*/

import * as actions from './actions';
import initialState from './initial-state';
import initialState, { INITIAL_VIEW_PORT } from './initial-state';

export default {
[actions.setMap]: (state, { payload }) => ({ ...state, ...payload }),
Expand Down Expand Up @@ -89,6 +89,7 @@ export default {
const { mapStyle, mapLabels, mapRoads } = state;
return {
...initialState,
viewport: INITIAL_VIEW_PORT,
mapStyle,
mapLabels,
mapRoads,
Expand Down
14 changes: 13 additions & 1 deletion packages/earth-map/src/pages/earth/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,28 @@
specific language governing permissions and limitations under the License.
*/

import { IUrlProp } from 'components/url/component';
import { setLayersActive } from 'modules/layers/actions';
import { setMapViewport } from 'modules/map/actions';
import { mapReduxStoreViewportToUrlParams, mapUrlParamsToReduxStoreViewport } from 'utils/map';

export const URL_PROPS = [
export const URL_PROPS: IUrlProp[] = [
{
type: 'array',
value: 'layers',
redux: 'layers.active',
action: setLayersActive,
required: false,
},
{
type: 'array',
value: 'coordinates',
redux: 'map.viewport',
action: setMapViewport,
required: false,
mapValueToUrl: mapReduxStoreViewportToUrlParams,
mapUrlToValue: mapUrlParamsToReduxStoreViewport,
},
];

export default {
Expand Down
8 changes: 7 additions & 1 deletion packages/earth-map/src/store/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,16 @@ const initStore = (initialState = {}) => {
return reducers(state, action);
};

const composeEnhancer = composeWithDevTools({
trace: true,
traceLimit: 10,
});

const middlewares = applyMiddleware(thunk, routerMiddleware, sagaMiddleware);
const enhancers = composeWithDevTools(routerEnhancer, middlewares);
const enhancers = composeEnhancer(routerEnhancer, middlewares);

// create store
// @ts-ignore
const store: Store = createStore(rootReducer, initialState, enhancers);

// restore state from sessionStorage
Expand Down
5 changes: 5 additions & 0 deletions packages/earth-map/src/utils/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
*/

import { BASE_URL } from 'config';
import qs from 'query-string';
import React from 'react';
import { Deserializer } from 'ts-jsonapi';
import urljoin from 'url-join';
Expand Down Expand Up @@ -61,3 +62,7 @@ export const parseHintBold = (text: string = '') => {
* Deserializer
*/
export const deserializeData = (data) => DeserializerService.deserialize(data);

export const getUrlQueryParams = () => {
return qs.parse(window.location.search);
};
71 changes: 71 additions & 0 deletions packages/earth-map/src/utils/map.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { getUrlQueryParams } from './index';

export interface IUrlCoordinates {
latitude: number;
longitude: number;
zoom: number;
}

export const toUrlCoordinateNumber = (value: string): number =>
Number(parseFloat(value).toFixed(7));

export const isValidCoordinate = (value: number, limit: number): boolean =>
(value || value === 0) && value >= -1 * limit && value <= limit;

export const isValidLatitude = (value: number): boolean => isValidCoordinate(value, 90);

export const isValidLongitude = (value: number): boolean => isValidCoordinate(value, 180);

// https://docs.mapbox.com/mapbox-gl-js/style-spec/layers/?size=n_10_n#maxzoom
export const isValidZoom = (value) => value && value >= 0 && value <= 24;

/**
* Extract valid viewport coordinates from the url (string)
*/
export const extractCoordinatesFromUrl = (): IUrlCoordinates => {
const { coordinates } = getUrlQueryParams();

const result = {
latitude: null,
longitude: null,
zoom: null,
};

if (coordinates) {
const [latitude, longitude, zoom] = (coordinates as string).split(',');

result.latitude = toUrlCoordinateNumber(latitude);
result.longitude = toUrlCoordinateNumber(longitude);
result.zoom = parseInt(zoom, 10);
}

return result;
};

/**
* Based on the viewport stored in redux, under map.viewport
* extract only the valid properties that need to be stored in the URL
*/
export const mapReduxStoreViewportToUrlParams = (value: IUrlCoordinates) => {
return [
toUrlCoordinateNumber(String(value.latitude)),
toUrlCoordinateNumber(String(value.longitude)),
parseInt(String(value.zoom), 10),
];
};

/**
* Url params (from the URL component) are returned as an array
* Create a valid viewport, based on the coordinates provided by the URL component
*/
export const mapUrlParamsToReduxStoreViewport = (value: number[]): IUrlCoordinates => {
const [latitude, longitude, zoom] = value;

return { latitude, longitude, zoom };
};

export const isValidUrlCoordinateGroup = (value: IUrlCoordinates) => {
return (
isValidLatitude(value.latitude) && isValidLongitude(value.longitude) && isValidZoom(value.zoom)
);
};

0 comments on commit 2c8cec8

Please sign in to comment.