Skip to content

Commit

Permalink
feat: add url parameters for preserving map extent
Browse files Browse the repository at this point in the history
Ref: #13
  • Loading branch information
stdavis committed Aug 24, 2022
1 parent d7a3228 commit 640a4b4
Show file tree
Hide file tree
Showing 4 changed files with 150 additions and 5 deletions.
40 changes: 36 additions & 4 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import Home from '@arcgis/core/widgets/Home';
import { faFilter, faHandPointer } from '@fortawesome/free-solid-svg-icons';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import LayerSelector from '@ugrc/layer-selector';
import { useCallback, useEffect, useState } from 'react';
import debounce from 'lodash.debounce';
import { useCallback, useEffect, useRef, useState } from 'react';
import 'typeface-montserrat';
import './App.scss';
import Filter from './components/Filter';
Expand All @@ -16,6 +17,7 @@ import MapWidgetResizeHandle from './components/MapWidgetResizeHandle';
import ProjectInformation from './components/ProjectInformation';
import { MapServiceProvider, Sherlock } from './components/Sherlock';
import SplashScreen from './components/SplashScreen';
import { ACTION_TYPE, useURLState } from './components/URLState';
import useFilterReducer from './hooks/useFilterReducer';
import config from './services/config';
import { useSpecialTranslation } from './services/i18n';
Expand All @@ -26,15 +28,26 @@ function App() {
const [mapView, setMapView] = useState(null);
const [zoomToGraphic, setZoomToGraphic] = useState(null);
const [layerSelectorOptions, setLayerSelectorOptions] = useState(null);
const [urlState, dispatchURLState] = useURLState();
const mapInitialized = useRef(false);

const t = useSpecialTranslation();

// init map
useEffect(() => {
if (mapInitialized.current) return;

const map = new WebMap({
portalItem: { id: config.webMapId },
});
const view = new MapView({ map, container: 'mapDiv' });
const mapOptions = { map, container: 'mapDiv' };

if (urlState.x && urlState.y && urlState.scale) {
mapOptions.center = { x: urlState.x, y: urlState.y, spatialReference: 3857 };
mapOptions.scale = urlState.scale;
}

const view = new MapView(mapOptions);
view.popup = null;
view.ui.add(new Home({ view }), 'top-left');

Expand All @@ -47,7 +60,9 @@ function App() {
});

setMapView(view);
}, []);

mapInitialized.current = true;
}, [urlState.scale, urlState.x, urlState.y]);

const [displayedZoomGraphic, setDisplayedZoomGraphic] = useState(null);
const zoomTo = useCallback(
Expand Down Expand Up @@ -158,8 +173,25 @@ function App() {
setProjectInfoIsOpen(true);
}
});

mapView.watch(
'extent',
debounce((newExtent) => {
if (newExtent) {
dispatchURLState({
type: ACTION_TYPE,
meta: 'mapExtent',
payload: {
x: Math.round(newExtent.center.x),
y: Math.round(newExtent.center.y),
scale: Math.round(mapView.scale),
},
});
}
}, 100)
);
}
}, [mapView]);
}, [dispatchURLState, mapView]);

const [filterState, filterDispatch] = useFilterReducer();
const [filterIsOpen, setFilterIsOpen] = useState(config.openOnLoad.filter);
Expand Down
88 changes: 88 additions & 0 deletions src/components/URLState.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// TODO: This may be a good candidate for kitchen sink - utilities
import PropTypes from 'prop-types';
import { createContext, useContext, useEffect, useMemo } from 'react';
import { useImmerReducer } from 'use-immer';

const Context = createContext();

const LIST_SEPARATOR = '.';
export function stateToURLHash(state) {
const newState = {};
for (const key in state) {
if (Array.isArray(state[key])) {
newState[key] = state[key].join(LIST_SEPARATOR);
} else {
newState[key] = state[key];
}
}

return new URLSearchParams(newState).toString();
}

export function urlHashToState(hash) {
const newObject = {};
const searchParams = new URLSearchParams(hash.slice(1)); // slice off the "#"
for (const parameterName of searchParams.keys()) {
const parameter = searchParams.get(parameterName);

const parsedNumber = parseInt(parameter);

if (isNaN(parsedNumber)) {
if (parameter.includes(LIST_SEPARATOR)) {
newObject[parameterName] = parameter.split(LIST_SEPARATOR);
} else {
newObject[parameterName] = parameter;
}
} else {
newObject[parameterName] = parsedNumber;
}
}

return newObject;
}

export const ACTION_TYPE = 'update-url-state';
export function reducer(draft, action) {
if (action.type !== ACTION_TYPE) {
throw new Error(`unknown action type: ${action.type}`);
}

// TODO: handle arrays
if (typeof action.payload === 'object') {
for (const key in action.payload) {
draft[key] = action.payload[key];
}
} else {
draft[action.meta] = action.payload;
}
}

export default function URLState({ children }) {
const [state, dispatch] = useImmerReducer(reducer, urlHashToState(new URL(document.location.href).hash));

// update current url when the state is changed
useEffect(() => {
if (Object.keys(state).length === 0) return;

const url = new URL(document.location.href);
url.hash = stateToURLHash(state);

document.location.replace(url);
}, [state]);

return <Context.Provider value={useMemo(() => [state, dispatch], [dispatch, state])}>{children}</Context.Provider>;
}

URLState.propTypes = {
children: PropTypes.node.isRequired,
};

export function useURLState() {
const contextValue = useContext(Context);

if (!contextValue) {
throw new Error('useURLState must be used within the URLState component!');
}

return contextValue;
}
22 changes: 22 additions & 0 deletions src/components/URLState.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { stateToURLHash, urlHashToState } from './URLState';

describe('stateToURLHash', () => {
it('returns the correct data', () => {
expect(stateToURLHash({ a: 1 })).toEqual('a=1');
});

it('handles arrays', () => {
expect(stateToURLHash({ a: ['b', 'c', 'd'] })).toEqual('a=b.c.d');
});
});

describe('urlHashToState', () => {
it('returns the correct data', () => {
expect(urlHashToState('#a=1')).toEqual({ a: 1 });
});

it('handles arrays', () => {
expect(urlHashToState('#a=b.c.d')).toEqual({ a: ['b', 'c', 'd'] });
});
});
5 changes: 4 additions & 1 deletion src/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import { ErrorBoundary } from 'react-error-boundary';
import ErrorFallback from './components/ErrorFallback';
import URLState from './components/URLState';
import './index.css';
import { setConfigs } from './services/config';

Expand All @@ -16,7 +17,9 @@ fetch('config.json')
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => document.location.reload()}>
<App />
<URLState>
<App />
</URLState>
</ErrorBoundary>
</React.StrictMode>
);
Expand Down

0 comments on commit 640a4b4

Please sign in to comment.