[Maps] scale control (#88031)

* set width

* cleanup

* add show scale toggle in map settings

* tslint

* remove unmapped css comment

* move scale for full screen mode so they do not collide

* change default and add unit test for scale_control

* simplify scale

* add tilda to label

* update jest expects

* eslint

* eslint

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2021-01-19 11:50:55 -07:00 committed by GitHub
parent 60f8b24529
commit c272d35b65
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 265 additions and 1 deletions

View file

@ -3,3 +3,4 @@
@import 'widget_overlay/index';
@import 'toolbar_overlay/index';
@import 'mb_map/features_tooltip/index';
@import 'mb_map/scale_control/index';

View file

@ -5,7 +5,7 @@
*/
import React from 'react';
import { EuiFormRow, EuiPanel, EuiTitle } from '@elastic/eui';
import { EuiFormRow, EuiPanel, EuiSwitch, EuiSwitchEvent, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { MapSettings } from '../../reducers/map';
@ -21,6 +21,10 @@ export function DisplayPanel({ settings, updateMapSetting }: Props) {
updateMapSetting('backgroundColor', color);
};
const onShowScale = (event: EuiSwitchEvent) => {
updateMapSetting('showScaleControl', event.target.checked);
};
return (
<EuiPanel>
<EuiTitle size="xs">
@ -43,6 +47,17 @@ export function DisplayPanel({ settings, updateMapSetting }: Props) {
onChange={onBackgroundColorChange}
/>
</EuiFormRow>
<EuiFormRow>
<EuiSwitch
label={i18n.translate('xpack.maps.mapSettingsPanel.showScaleLabel', {
defaultMessage: 'Show scale',
})}
checked={settings.showScaleControl}
onChange={onShowScale}
compressed
/>
</EuiFormRow>
</EuiPanel>
);
}

View file

@ -26,6 +26,7 @@ import {
getSpatialFiltersLayer,
getMapSettings,
} from '../../selectors/map_selectors';
import { getIsFullScreen } from '../../selectors/ui_selectors';
import { getInspectorAdapters } from '../../reducers/non_serializable_instances';
import { MapStoreState } from '../../reducers/store';
@ -38,6 +39,7 @@ function mapStateToProps(state: MapStoreState) {
goto: getGoto(state),
inspectorAdapters: getInspectorAdapters(state),
scrollZoom: getScrollZoom(state),
isFullScreen: getIsFullScreen(state),
};
}

View file

@ -18,6 +18,7 @@ import { Filter } from 'src/plugins/data/public';
import { ActionExecutionContext, Action } from 'src/plugins/ui_actions/public';
// @ts-expect-error
import { DrawControl } from './draw_control';
import { ScaleControl } from './scale_control';
// @ts-expect-error
import { TooltipControl } from './tooltip_control';
import { clampToLatBounds, clampToLonBounds } from '../../../common/elasticsearch_util';
@ -55,6 +56,7 @@ interface Props {
spatialFiltersLayer: ILayer;
goto?: Goto | null;
inspectorAdapters: Adapters;
isFullScreen: boolean;
scrollZoom: boolean;
extentChanged: (mapExtentState: MapExtentState) => void;
onMapReady: (mapExtentState: MapExtentState) => void;
@ -381,6 +383,7 @@ export class MBMap extends Component<Props, State> {
render() {
let drawControl;
let tooltipControl;
let scaleControl;
if (this.state.mbMap) {
drawControl = <DrawControl mbMap={this.state.mbMap} addFilters={this.props.addFilters} />;
tooltipControl = !this.props.settings.disableTooltipControl ? (
@ -394,6 +397,9 @@ export class MBMap extends Component<Props, State> {
renderTooltipContent={this.props.renderTooltipContent}
/>
) : null;
scaleControl = this.props.settings.showScaleControl ? (
<ScaleControl mbMap={this.state.mbMap} isFullScreen={this.props.isFullScreen} />
) : null;
}
return (
<div
@ -403,6 +409,7 @@ export class MBMap extends Component<Props, State> {
data-test-subj="mapContainer"
>
{drawControl}
{scaleControl}
{tooltipControl}
</div>
);

View file

@ -0,0 +1,27 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`isFullScreen 1`] = `
<div
className="mapScaleControl mapScaleControlFullScreen"
style={
Object {
"width": "62.7474245034397px",
}
}
>
~50 km
</div>
`;
exports[`render 1`] = `
<div
className="mapScaleControl"
style={
Object {
"width": "62.7474245034397px",
}
}
>
~50 km
</div>
`;

View file

@ -0,0 +1,15 @@
.mapScaleControl {
position: absolute;
z-index: $euiZLevel1;
left: $euiSizeM;
bottom: $euiSizeM;
pointer-events: none;
color: $euiTextColor;
border-left: 2px solid $euiTextColor;
border-bottom: 2px solid $euiTextColor;
text-align: right;
}
.mapScaleControlFullScreen {
bottom: $euiSizeL * 2;
}

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { ScaleControl } from './scale_control';

View file

@ -0,0 +1,67 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { mount, shallow } from 'enzyme';
import { ScaleControl } from './scale_control';
import { LngLat, LngLatBounds, Map as MapboxMap, PointLike } from 'mapbox-gl';
const CLIENT_HEIGHT_PIXELS = 1200;
const DISTANCE_METERS = 87653;
const mockMbMapHandlers: { [key: string]: () => void } = {};
const mockMBMap = ({
on: (eventName: string, callback: () => void) => {
mockMbMapHandlers[eventName] = callback;
},
off: (eventName: string) => {
delete mockMbMapHandlers[eventName];
},
getContainer: () => {
return {
clientHeight: CLIENT_HEIGHT_PIXELS,
};
},
getZoom: () => {
return 4;
},
getBounds: () => {
return ({
getNorth: () => {
return 75;
},
getSouth: () => {
return -60;
},
} as unknown) as LngLatBounds;
},
unproject: (point: PointLike) => {
return ({
distanceTo: (lngLat: LngLat) => {
return DISTANCE_METERS;
},
} as unknown) as LngLat;
},
} as unknown) as MapboxMap;
test('render', () => {
const component = shallow(<ScaleControl mbMap={mockMBMap} isFullScreen={false} />);
expect(component).toMatchSnapshot();
});
test('isFullScreen', () => {
const component = shallow(<ScaleControl mbMap={mockMBMap} isFullScreen={true} />);
expect(component).toMatchSnapshot();
});
test('should un-register all map callbacks on unmount', () => {
const component = mount(<ScaleControl mbMap={mockMBMap} isFullScreen={false} />);
expect(Object.keys(mockMbMapHandlers).length).toBe(1);
component.unmount();
expect(Object.keys(mockMbMapHandlers).length).toBe(0);
});

View file

@ -0,0 +1,121 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import classNames from 'classnames';
import React, { Component } from 'react';
import { Map as MapboxMap } from 'mapbox-gl';
const MAX_WIDTH = 110;
interface Props {
isFullScreen: boolean;
mbMap: MapboxMap;
}
interface State {
label: string;
width: number;
}
function getScaleDistance(value: number) {
const orderOfMagnitude = Math.floor(Math.log10(value));
const pow10 = Math.pow(10, orderOfMagnitude);
// reduce value to single order of magnitude to making rounding simple regardless of order of magnitude
const distance = value / pow10;
if (distance < 1) {
return pow10 * (Math.round(distance * 10) / 10);
}
// provide easy to multiple round numbers for scale distance so its easy to measure distances longer then the scale
if (distance >= 10) {
return 10 * pow10;
}
if (distance >= 5) {
return 5 * pow10;
}
if (distance >= 3) {
return 3 * pow10;
}
return Math.floor(distance) * pow10;
}
export class ScaleControl extends Component<Props, State> {
private _isMounted: boolean = false;
state: State = { label: '', width: 0 };
componentDidMount() {
this._isMounted = true;
this.props.mbMap.on('move', this._onUpdate);
this._onUpdate();
}
componentWillUnmount() {
this._isMounted = false;
this.props.mbMap.off('move', this._onUpdate);
}
_onUpdate = () => {
if (!this._isMounted) {
return;
}
const centerHeight = this.props.mbMap.getContainer().clientHeight / 2;
const leftLatLon = this.props.mbMap.unproject([0, centerHeight]);
const rightLatLon = this.props.mbMap.unproject([MAX_WIDTH, centerHeight]);
const maxDistanceMeters = leftLatLon.distanceTo(rightLatLon);
if (maxDistanceMeters >= 1000) {
this._setScale(
maxDistanceMeters / 1000,
i18n.translate('xpack.maps.kilometersAbbr', {
defaultMessage: 'km',
})
);
} else {
this._setScale(
maxDistanceMeters,
i18n.translate('xpack.maps.metersAbbr', {
defaultMessage: 'm',
})
);
}
};
_setScale(maxDistance: number, unit: string) {
const scaleDistance = getScaleDistance(maxDistance);
const zoom = this.props.mbMap.getZoom();
const bounds = this.props.mbMap.getBounds();
let label = `${scaleDistance} ${unit}`;
if (
zoom <= 4 ||
(zoom <= 6 && (bounds.getNorth() > 23.5 || bounds.getSouth() < -23.5)) ||
(zoom <= 8 && (bounds.getNorth() > 45 || bounds.getSouth() < -45))
) {
label = '~' + label;
}
this.setState({
width: MAX_WIDTH * (scaleDistance / maxDistance),
label,
});
}
render() {
return (
<div
className={classNames('mapScaleControl', {
mapScaleControlFullScreen: this.props.isFullScreen,
})}
style={{ width: `${this.state.width}px` }}
>
{this.state.label}
</div>
);
}
}

View file

@ -22,6 +22,7 @@ export function getDefaultMapSettings(): MapSettings {
browserLocation: { zoom: 2 },
maxZoom: MAX_ZOOM,
minZoom: MIN_ZOOM,
showScaleControl: false,
showSpatialFilters: true,
spatialFiltersAlpa: 0.3,
spatialFiltersFillColor: '#DA8B45',

View file

@ -55,6 +55,7 @@ export type MapSettings = {
};
maxZoom: number;
minZoom: number;
showScaleControl: boolean;
showSpatialFilters: boolean;
spatialFiltersAlpa: number;
spatialFiltersFillColor: string;