Skip to content

Commit

Permalink
[Feat] Unlocked split map viewports (#2170)
Browse files Browse the repository at this point in the history
  • Loading branch information
igorDykhta committed Mar 27, 2023
1 parent 8896dc1 commit 9fc98e8
Show file tree
Hide file tree
Showing 20 changed files with 808 additions and 139 deletions.
3 changes: 2 additions & 1 deletion src/actions/src/action-types.ts
Expand Up @@ -120,8 +120,9 @@ export const ActionTypes = {
UPDATE_MAP: `${ACTION_PREFIX}UPDATE_MAP`,
FIT_BOUNDS: `${ACTION_PREFIX}FIT_BOUNDS`,
TOGGLE_PERSPECTIVE: `${ACTION_PREFIX}TOGGLE_PERSPECTIVE`,
TOGGLE_SPLIT_MAP: `${ACTION_PREFIX}TOGGLE_SPLIT_MAP`,
TOGGLE_FULLSCREEN: `${ACTION_PREFIX}TOGGLE_FULLSCREEN`,
TOGGLE_SPLIT_MAP: `${ACTION_PREFIX}TOGGLE_SPLIT_MAP`,
TOGGLE_SPLIT_MAP_VIEWPORT: `${ACTION_PREFIX}TOGGLE_SPLIT_MAP_VIEWPORT`,

// mapStyle
MAP_CONFIG_CHANGE: `${ACTION_PREFIX}MAP_CONFIG_CHANGE`,
Expand Down
46 changes: 39 additions & 7 deletions src/actions/src/map-state-actions.ts
Expand Up @@ -54,7 +54,7 @@ export const fitBounds: (
{type: typeof ActionTypes.FIT_BOUNDS}
> = createAction(ActionTypes.FIT_BOUNDS, (bounds: Bounds) => ({payload: bounds}));

export type UpdateMapUpdaterAction = {payload: Viewport};
export type UpdateMapUpdaterAction = {payload: {viewport: Viewport; mapIndex?: number}};
/**
* Update map viewport
* @memberof mapStateActions
Expand All @@ -67,18 +67,25 @@ export type UpdateMapUpdaterAction = {payload: Viewport};
* @param {Number} [viewport.latitude] Latitude center of viewport on map in mercator projection
* @param {Number} [viewport.longitude] Longitude Center of viewport on map in mercator projection
* @param {boolean} [viewport.dragRotate] Whether to enable drag and rotate map into perspective viewport
* @param {number} mapIndex Index of which map to update the viewport of
* @public
* @example
* import {updateMap} from 'kepler.gl/actions';
* this.props.dispatch(updateMap({latitude: 37.75043, longitude: -122.34679, width: 800, height: 1200}));
* this.props.dispatch(updateMap({latitude: 37.75043, longitude: -122.34679, width: 800, height: 1200}, 0));
*/

export const updateMap: (
payload: Viewport
) => Merge<
UpdateMapUpdaterAction,
{type: typeof ActionTypes.UPDATE_MAP}
> = createAction(ActionTypes.UPDATE_MAP, (viewport: Viewport) => ({payload: viewport}));
viewport: Viewport,
mapIndex?: number
) => Merge<UpdateMapUpdaterAction, {type: typeof ActionTypes.UPDATE_MAP}> = createAction(
ActionTypes.UPDATE_MAP,
(viewport: Viewport, mapIndex?: number) => ({
payload: {
viewport,
mapIndex
}
})
);

export type ToggleSplitMapUpdaterAction = {
payload: number;
Expand All @@ -99,6 +106,31 @@ export const toggleSplitMap: (
{type: typeof ActionTypes.TOGGLE_SPLIT_MAP}
> = createAction(ActionTypes.TOGGLE_SPLIT_MAP, (index: number) => ({payload: index}));

export type ToggleSplitMapViewportUpdaterAction = {
payload: {
isViewportSynced?: boolean;
isZoomLocked?: boolean;
};
};

/**
* For split maps, toggle between having (un)synced viewports and (un)locked zooms
* @memberof mapStateActions
* @param {Object} syncInfo
* @param {boolean} [syncInfo.isViewportSynced] Are the 2 split maps having synced viewports?
* @param {boolean} [syncInfo.isZoomLocked] If split, are the zooms locked to each other or independent?
*/
export const toggleSplitMapViewport: (payload: {
isViewportSynced?: boolean;
isZoomLocked?: boolean;
}) => Merge<
ToggleSplitMapViewportUpdaterAction,
{type: typeof ActionTypes.TOGGLE_SPLIT_MAP_VIEWPORT}
> = createAction(
ActionTypes.TOGGLE_SPLIT_MAP_VIEWPORT,
(syncInfo: ToggleSplitMapViewportUpdaterAction['payload']) => ({payload: syncInfo})
);

/**
* This declaration is needed to group actions in docs
*/
Expand Down
6 changes: 3 additions & 3 deletions src/actions/src/vis-state-actions.ts
Expand Up @@ -876,17 +876,17 @@ export function toggleFilterFeature(
}

export type OnLayerHoverUpdaterAction = {
info: PickInfo<any> | null;
info: (PickInfo<any> & {mapIndex?: number}) | null;
};
/**
* Trigger layer hover event with hovered object
* @memberof visStateActions
* @param info - Object hovered, returned by deck.gl
* @param info - Object hovered, returned by deck.gl. Includes an optional `mapIndex` property for limiting the display of the `<MapPopover>` to the `<MapContainer>` the user is interacting with.
* @returns action
* @public
*/
export function onLayerHover(
info: PickInfo<any> | null
info: (PickInfo<any> & {mapIndex?: number}) | null
): Merge<OnLayerHoverUpdaterAction, {type: typeof ActionTypes.LAYER_HOVER}> {
return {
type: ActionTypes.LAYER_HOVER,
Expand Down
1 change: 1 addition & 0 deletions src/components/src/common/switch.tsx
Expand Up @@ -32,6 +32,7 @@ interface SwitchProps {
onFocus?: FocusEventHandler<HTMLInputElement>;
value?: string;
secondary?: boolean;
disabled?: boolean;
}

const Switch = (props: SwitchProps) => {
Expand Down
49 changes: 36 additions & 13 deletions src/components/src/geocoder-panel.tsx
Expand Up @@ -68,12 +68,21 @@ const PARSED_CONFIG = KeplerGlSchema.parseSavedConfig({

interface StyledGeocoderPanelProps {
width?: number;
unsyncedViewports: any;
index: any;
}

const StyledGeocoderPanel = styled.div<StyledGeocoderPanelProps>`
position: absolute;
top: ${props => props.theme.geocoderTop}px;
right: ${props => props.theme.geocoderRight}px;
right: ${props =>
props.unsyncedViewports
? // 2 geocoders: split mode and unsynced viewports
Number.isFinite(props.index) && props.index === 0
? `calc(50% + ${props.theme.geocoderRight}px)` // unsynced left geocoder (index 0)
: `${props.theme.geocoderRight}px` // unsynced right geocoder (index 1)
: // 1 geocoder: single mode OR split mode and synced viewports
`${props.theme.geocoderRight}px`};
width: ${props => (Number.isFinite(props.width) ? props.width : props.theme.geocoderWidth)}px;
box-shadow: ${props => props.theme.boxShadow};
z-index: 100;
Expand Down Expand Up @@ -125,6 +134,8 @@ interface GeocoderPanelProps {
width?: number;
appWidth: number;
className?: string;
index: number;
unsyncedViewports: boolean;
}

export default function GeocoderPanelFactory(): ComponentType<GeocoderPanelProps> {
Expand Down Expand Up @@ -161,29 +172,41 @@ export default function GeocoderPanelFactory(): ComponentType<GeocoderPanelProps
return;
}

this.props.updateMap({
latitude: centerAndZoom.center[1],
longitude: centerAndZoom.center[0],
// For marginal or invalid bounds, zoom may be NaN. Make sure to provide a valid value in order
// to avoid corrupt state and potential crashes as zoom is expected to be a number
...(Number.isFinite(centerAndZoom.zoom) ? {zoom: centerAndZoom.zoom} : {}),
pitch: 0,
bearing: 0,
transitionDuration: this.props.transitionDuration,
transitionInterpolator: new FlyToInterpolator()
});
this.props.updateMap(
{
latitude: centerAndZoom.center[1],
longitude: centerAndZoom.center[0],
// For marginal or invalid bounds, zoom may be NaN. Make sure to provide a valid value in order
// to avoid corrupt state and potential crashes as zoom is expected to be a number
...(Number.isFinite(centerAndZoom.zoom) ? {zoom: centerAndZoom.zoom} : {}),
pitch: 0,
bearing: 0,
transitionDuration: this.props.transitionDuration,
transitionInterpolator: new FlyToInterpolator()
},
this.props.index
);
};

removeMarker = () => {
this.removeGeocoderDataset();
};

render() {
const {isGeocoderEnabled, mapboxApiAccessToken, width, className} = this.props;
const {
className,
isGeocoderEnabled,
mapboxApiAccessToken,
width,
index,
unsyncedViewports
} = this.props;
return (
<StyledGeocoderPanel
className={classnames('geocoder-panel', className)}
width={width}
index={index}
unsyncedViewports={unsyncedViewports}
style={{display: isGeocoderEnabled ? 'block' : 'none'}}
>
{isValid(mapboxApiAccessToken) && (
Expand Down
53 changes: 47 additions & 6 deletions src/components/src/kepler-gl.tsx
Expand Up @@ -127,11 +127,34 @@ const BottomWidgetOuter = styled.div<BottomWidgetOuterProps>(
}`
);

export const mapFieldsSelector = (props: KeplerGLProps) => ({
export const isViewportDisjointed = props => {
return (
props.mapState.isSplit &&
!props.mapState.isViewportSynced &&
props.mapState.splitMapViewports.length > 1
);
};

export const mapStateSelector = (props, index) => {
if (!Number.isFinite(index)) {
// either no index arg or an invalid index was provided
// it is expected to be either 0 or 1 when in split mode
// only use the mapState
return props.mapState;
}

return isViewportDisjointed(props)
? // mix together the viewport properties intended for this disjointed <MapContainer> with the other necessary mapState properties
{...props.mapState, ...props.mapState.splitMapViewports[index]}
: // otherwise only use the mapState
props.mapState;
};

export const mapFieldsSelector = (props: KeplerGLProps, index: number = 0) => ({
getMapboxRef: props.getMapboxRef,
mapboxApiAccessToken: props.mapboxApiAccessToken,
mapboxApiUrl: props.mapboxApiUrl ? props.mapboxApiUrl : DEFAULT_KEPLER_GL_PROPS.mapboxApiUrl,
mapState: props.mapState,
mapState: mapStateSelector(props, index),
mapStyle: props.mapStyle,
onDeckInitialized: props.onDeckInitialized,
onViewStateChange: props.onViewStateChange,
Expand Down Expand Up @@ -449,7 +472,6 @@ function KeplerGlFactory(
const isExportingImage = uiState.exportImage.exporting;
const availableProviders = this.availableProviders(this.props);

const mapFields = mapFieldsSelector(this.props);
const filteredDatasets = this.filteredDatasetsSelector(this.props);
const sideFields = sidePanelSelector(this.props, availableProviders, filteredDatasets);
const plotContainerFields = plotContainerSelector(this.props);
Expand All @@ -459,9 +481,14 @@ function KeplerGlFactory(
const notificationPanelFields = notificationPanelSelector(this.props);

const mapContainers = !isSplit
? [<MapContainer primary={true} key={0} index={0} {...mapFields} />]
? [<MapContainer primary={true} key={0} index={0} {...mapFieldsSelector(this.props)} />]
: splitMaps.map((settings, index) => (
<MapContainer key={index} index={index} primary={index === 1} {...mapFields} />
<MapContainer
key={index}
index={index}
primary={index === 1}
{...mapFieldsSelector(this.props, index)}
/>
));

return (
Expand All @@ -485,7 +512,21 @@ function KeplerGlFactory(
{!uiState.readOnly && !readOnly && <SidePanel {...sideFields} />}
<MapsLayout className="maps">{mapContainers}</MapsLayout>
{isExportingImage && <PlotContainer {...plotContainerFields} />}
{interactionConfig.geocoder.enabled && <GeoCoderPanel {...geoCoderPanelFields} />}
{/* 1 geocoder: single mode OR split mode and synced viewports */}
{!isViewportDisjointed(this.props) && interactionConfig.geocoder.enabled && (
<GeoCoderPanel {...geoCoderPanelFields} index={0} unsyncedViewports={false} />
)}
{/* 2 geocoders: split mode and unsynced viewports */}
{isViewportDisjointed(this.props) &&
interactionConfig.geocoder.enabled &&
mapContainers.map((_mapContainer, index) => (
<GeoCoderPanel
key={index}
{...geoCoderPanelFields}
index={index}
unsyncedViewports={true}
/>
))}
<BottomWidgetOuter absolute={!hasPortableWidth(breakPointValues)}>
<BottomWidget
rootRef={this.bottomWidgetRef}
Expand Down
20 changes: 17 additions & 3 deletions src/components/src/map-container.tsx
Expand Up @@ -330,11 +330,11 @@ export default function MapContainerFactory(
};

_handleResize = dimensions => {
const {primary} = this.props;
const {primary, index} = this.props;
if (primary) {
const {mapStateActions} = this.props;
if (dimensions && dimensions.width > 0 && dimensions.height > 0) {
mapStateActions.updateMap(dimensions);
mapStateActions.updateMap(dimensions, index);
}
}
};
Expand Down Expand Up @@ -521,6 +521,12 @@ export default function MapContainerFactory(

/* eslint-disable complexity */
_renderMapPopover() {
// this check is for limiting the display of the `<MapPopover>` to the `<MapContainer>` the user is interacting with
// the DeckGL onHover event handler adds a `mapIndex` property which is available in the `hoverInfo` object of `visState`
if (this.props.index !== this.props.visState.hoverInfo?.mapIndex) {
return null;
}

// TODO: move this into reducer so it can be tested
const {
mapState,
Expand Down Expand Up @@ -733,6 +739,11 @@ export default function MapContainerFactory(
});
if (res) return;

// add `mapIndex` property which will end up in the the `hoverInfo` object of `visState`
// this is for limiting the display of the `<MapPopover>` to the `<MapContainer>` the user is interacting with
// @ts-ignore (does not fail with local yarn-test)
data.mapIndex = index;

visStateActions.onLayerHover(data);
}}
onClick={(data, event) => {
Expand Down Expand Up @@ -790,7 +801,8 @@ export default function MapContainerFactory(
viewState,
this.props.mapStateActions.updateMap,
this.props.onViewStateChange,
this.props.primary
this.props.primary,
this.props.index
);
};

Expand Down Expand Up @@ -846,6 +858,7 @@ export default function MapContainerFactory(
return (
<>
<MapControl
mapState={mapState}
datasets={datasets}
availableLocales={LOCALE_CODES_ARRAY}
dragRotate={mapState.dragRotate}
Expand All @@ -869,6 +882,7 @@ export default function MapContainerFactory(
onToggleSplitMap={mapStateActions.toggleSplitMap}
onMapToggleLayer={this._handleMapToggleLayer}
onToggleMapControl={this._toggleMapControl}
onToggleSplitMapViewport={mapStateActions.toggleSplitMapViewport}
onSetEditorMode={visStateActions.setEditorMode}
onSetLocale={uiStateActions.setLocale}
onToggleEditorVisibility={visStateActions.toggleEditorVisibility}
Expand Down

0 comments on commit 9fc98e8

Please sign in to comment.