Skip to content

Commit

Permalink
feat(url): sync globe state to url
Browse files Browse the repository at this point in the history
  • Loading branch information
pwambach committed Oct 15, 2019
1 parent f59f1ca commit 03461f6
Show file tree
Hide file tree
Showing 10 changed files with 249 additions and 4 deletions.
21 changes: 21 additions & 0 deletions src/scripts/actions/set-globe-projection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export const SET_GLOBE_PROJECTION = 'SET_GLOBE_PROJECTION';

export enum GlobeProjection {
Sphere = 'Sphere',
Mercator = 'Mercator',
PlateCaree = 'PlateCaree'
}

export interface SetGlobeProjectionAction {
type: typeof SET_GLOBE_PROJECTION;
projection: GlobeProjection;
}

const setGlobeProjectionAction = (
projection: GlobeProjection
): SetGlobeProjectionAction => ({
type: SET_GLOBE_PROJECTION,
projection
});

export default setGlobeProjectionAction;
15 changes: 15 additions & 0 deletions src/scripts/actions/set-globe-view.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import GlobeView from '../types/globe-view';

export const SET_GLOBE_VIEW = 'SET_GLOBE_VIEW';

export interface SetGlobeViewAction {
type: typeof SET_GLOBE_VIEW;
view: GlobeView;
}

const setGlobeViewAction = (view: GlobeView): SetGlobeViewAction => ({
type: SET_GLOBE_VIEW,
view
});

export default setGlobeViewAction;
3 changes: 3 additions & 0 deletions src/scripts/components/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import Menu from '../menu/menu';
import ProjectionMenu from '../projection-menu/projection-menu';
import PresentationSelector from '../presentation-selector/presentation-selector';
import ShowcaseSelector from '../showcase-selector/showcase-selector';
import UrlSync from '../url-sync/url-sync';

import translations from '../../i18n';
import styles from './app.styl';
Expand Down Expand Up @@ -58,6 +59,8 @@ const TranslatedApp: FunctionComponent<{}> = () => {
</Switch>
</div>
</IntlProvider>

<UrlSync />
</Router>
);
};
Expand Down
24 changes: 22 additions & 2 deletions src/scripts/components/globe/globe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,35 @@ const imageryProvider = window.Cesium.createTileMapServiceImageryProvider({
url: tileUrl
});

const cesiumOptions = {
homeButton: false,
fullscreenButton: false,
sceneModePicker: false,
infoBox: false,
geocoder: false,
navigationHelpButton: false,
animation: false,
timeline: false,
baseLayerPicker: false,
imageryProvider
};

interface Props {
active: boolean;
view: GlobeView;
projection: GlobeProjection;
onMouseEnter: () => void;
onChange: (view: View) => void;
onChange: (view: GlobeView) => void;
onMoveEnd: (view: GlobeView) => void;
}

const Globe: FunctionComponent<Props> = ({
view,
projection,
active,
onMouseEnter,
onChange
onChange,
onMoveEnd
}) => {
const [viewer, setViewer] = useState<Cesium.Viewer | null>(null);
const ref = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -67,6 +82,11 @@ const Globe: FunctionComponent<Props> = ({
isActiveRef.current && onChange(getGlobeView(scopedViewer));
});

// add camera move end listener
scopedViewer.camera.moveEnd.addEventListener(() => {
isActiveRef.current && onMoveEnd(getGlobeView(scopedViewer));
});

// clean up
return () => {
scopedViewer.destroy();
Expand Down
25 changes: 23 additions & 2 deletions src/scripts/components/globes/globes.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,39 @@
import React, {FunctionComponent, useState} from 'react';
import React, {FunctionComponent, useState, useEffect} from 'react';
import {useSelector, useDispatch} from 'react-redux';

import {selectedLayersSelector} from '../../reducers/selected-layers';
import {globeViewSelector} from '../../reducers/globe/view';
import {projectionSelector} from '../../reducers/globe/projection';
import Globe from '../globe/globe';

import setGlobeViewAction from '../../actions/set-globe-view';
import GlobeView from '../../types/globe-view';

import styles from './globes.styl';

const Globes: FunctionComponent<{}> = () => {
const dispatch = useDispatch();
const projection = useSelector(projectionSelector);
const globalGlobeView = useSelector(globeViewSelector);
const [currentView, setCurrentView] = useState<GlobeView | null>(null);
const [isMainActive, setIsMainActive] = useState(true);
const selectedLayers = useSelector(selectedLayersSelector);
const onChangeHandler = (view: View) => setCurrentView(view);
const onChangeHandler = (view: GlobeView) => setCurrentView(view);
const onMoveEndHandler = (view: GlobeView) =>
dispatch(setGlobeViewAction(view));

// apply changes to the app state view to our local view copy
// we don't use the app state view all the time to keep store updates low
useEffect(() => {
if (globalGlobeView) {
setCurrentView(globalGlobeView);
}
}, [globalGlobeView]);

// only render once the globe view has been set
if (!currentView || !projection) {
return null;
}

return (
<div className={styles.globes}>
Expand All @@ -24,6 +43,7 @@ const Globes: FunctionComponent<{}> = () => {
projection={projection}
onMouseEnter={() => setIsMainActive(true)}
onChange={onChangeHandler}
onMoveEnd={onMoveEndHandler}
/>

{selectedLayers.compare && (
Expand All @@ -33,6 +53,7 @@ const Globes: FunctionComponent<{}> = () => {
projection={projection}
onMouseEnter={() => setIsMainActive(false)}
onChange={onChangeHandler}
onMoveEnd={onMoveEndHandler}
/>
)}
</div>
Expand Down
25 changes: 25 additions & 0 deletions src/scripts/components/url-sync/url-sync.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {FunctionComponent, useEffect} from 'react';
import {useHistory, useLocation} from 'react-router-dom';
import {useSelector} from 'react-redux';

import {globeStateSelector} from '../../reducers/globe/index';
import {getParamString} from '../../libs/globe-url-parameter';

// syncs the query parameters of the url when values change in store
const UrlSync: FunctionComponent<{}> = () => {
const history = useHistory();
const location = useLocation();
const globeState = useSelector(globeStateSelector);

// set globe query params in url when globe state changes
useEffect(() => {
const params = new URLSearchParams(location.search);
const globeValue = getParamString(globeState);
params.set('globe', globeValue);
history.replace({search: params.toString()});
}, [globeState]);

return null;
};

export default UrlSync;
68 changes: 68 additions & 0 deletions src/scripts/libs/globe-url-parameter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {GlobeState} from '../reducers/globe/index';
import {GlobeProjection} from '../actions/set-globe-projection';

const char = 'l';

// parses window.location and generates a globe state from query params
//
// note: we do not use the location.search prop here because the HashRouter
// stores the query parameters in the location.hash prop
export function parseUrl(): GlobeState | null {
const {hash} = location;
// only take the query portion of the hash string
const queryString = hash.substr(hash.indexOf('?'));
const urlParams = new URLSearchParams(queryString);
const globeParam = urlParams.get('globe');

if (!globeParam) {
return null;
}

const splitted = globeParam.split(char);

if (splitted.length !== 7) {
return null;
}

// projection
const projectionChar = splitted[0];
const projection = Object.values(GlobeProjection).find(proj =>
proj.startsWith(projectionChar)
);

if (!projection) {
return null;
}

// globe view values
const values = splitted.slice(1).map(str => parseFloat(str));

if (values.some(num => isNaN(num))) {
return null;
}

return {
view: {
orientation: {
heading: values[0],
pitch: values[1],
roll: values[2]
},
destination: [values[3], values[4], values[5]]
},
projection
};
}

export function getParamString(globeState: GlobeState): string {
const {view, projection} = globeState;
const {orientation, destination} = view;
const {heading, pitch, roll} = orientation;

const orientationString = [heading, pitch, roll]
.map(num => num.toFixed(2))
.join(char);
const destinationString = destination.map(num => Math.round(num)).join(char);

return [projection[0], orientationString, destinationString].join(char);
}
15 changes: 15 additions & 0 deletions src/scripts/reducers/globe/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {combineReducers} from 'redux';
import projectionReducer from './projection';
import viewReducer from './view';
import {State} from '../index';

const globeReducer = combineReducers({
view: viewReducer,
projection: projectionReducer
});

export default globeReducer;

export type GlobeState = ReturnType<typeof globeReducer>;

export const globeStateSelector = (state: State): GlobeState => state.globe;
30 changes: 30 additions & 0 deletions src/scripts/reducers/globe/projection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {
SET_GLOBE_PROJECTION,
GlobeProjection,
SetGlobeProjectionAction
} from '../../actions/set-globe-projection';
import {parseUrl} from '../../libs/globe-url-parameter';
import config from '../../config/main';
import {State} from '../index';

// get initial state from url or fallback to default state in config
const globeState = parseUrl() || config.globe;
const initialState = globeState.projection;

function projectionReducer(
state: GlobeProjection = initialState,
action: SetGlobeProjectionAction
): GlobeProjection {
switch (action.type) {
case SET_GLOBE_PROJECTION:
return action.projection;
default:
return state;
}
}

export function projectionSelector(state: State): GlobeProjection {
return state.globe.projection;
}

export default projectionReducer;
27 changes: 27 additions & 0 deletions src/scripts/reducers/globe/view.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {SET_GLOBE_VIEW, SetGlobeViewAction} from '../../actions/set-globe-view';
import GlobeView from '../../types/globe-view';
import {State} from '../index';
import {parseUrl} from '../../libs/globe-url-parameter';
import config from '../../config/main';

// get initial state from url or fallback to default state in config
const globeState = parseUrl() || config.globe;
const initialState = globeState.view;

function globeViewReducer(
state: GlobeView = initialState,
action: SetGlobeViewAction
): GlobeView {
switch (action.type) {
case SET_GLOBE_VIEW:
return action.view;
default:
return state;
}
}

export function globeViewSelector(state: State): GlobeView {
return state.globe.view;
}

export default globeViewReducer;

0 comments on commit 03461f6

Please sign in to comment.