Geomap: Add measuring tools (#51608)

* Geomap: add measuring tools

* Add measure type selection

* Add controls and state to measure overlay

* Override tooltip mouse events when menu active

* Move measure tools to top right

* Lay groundwork for units and consolidate measuring

* Create measure vector layer class

* Improve styling to match other overlay controls

* Consolidate styling and use theme2

* Update unit language and add km2

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
Co-authored-by: nmarrs <nathanielmarrs@gmail.com>
This commit is contained in:
Drew Slobodnjak 2022-08-03 16:19:30 -07:00 committed by GitHub
parent 6c58ea66a9
commit fe0f193189
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 595 additions and 42 deletions

View File

@ -8639,6 +8639,11 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "5"],
[0, 0, 0, "Do not use any type assertions.", "6"]
],
"public/app/plugins/panel/geomap/components/MeasureVectorLayer.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"]
],
"public/app/plugins/panel/geomap/editor/FrameSelectionEditor.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]

View File

@ -148,6 +148,7 @@ export const getAvailableIcons = () =>
'record-audio',
'repeat',
'rocket',
'ruler-combined',
'save',
'search',
'search-minus',

View File

@ -1,39 +1,33 @@
import { css } from '@emotion/css';
import React, { CSSProperties, PureComponent } from 'react';
import React, { CSSProperties } from 'react';
import { GrafanaTheme } from '@grafana/data';
import { config } from '@grafana/runtime';
import { stylesFactory } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
export interface OverlayProps {
topRight?: React.ReactNode[];
topRight1?: React.ReactNode[];
topRight2?: React.ReactNode[];
bottomLeft?: React.ReactNode[];
blStyle?: CSSProperties;
}
export class GeomapOverlay extends PureComponent<OverlayProps> {
style = getStyles(config.theme);
export const GeomapOverlay = ({ topRight1, topRight2, bottomLeft, blStyle }: OverlayProps) => {
const topRight1Exists = (topRight1 && topRight1.length > 0) ?? false;
const styles = useStyles2(getStyles(topRight1Exists));
return (
<div className={styles.overlay}>
{Boolean(topRight1?.length) && <div className={styles.TR1}>{topRight1}</div>}
{Boolean(topRight2?.length) && <div className={styles.TR2}>{topRight2}</div>}
{Boolean(bottomLeft?.length) && (
<div className={styles.BL} style={blStyle}>
{bottomLeft}
</div>
)}
</div>
);
};
constructor(props: OverlayProps) {
super(props);
}
render() {
const { topRight, bottomLeft } = this.props;
return (
<div className={this.style.overlay}>
{Boolean(topRight?.length) && <div className={this.style.TR}>{topRight}</div>}
{Boolean(bottomLeft?.length) && (
<div className={this.style.BL} style={this.props.blStyle}>
{bottomLeft}
</div>
)}
</div>
);
}
}
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
const getStyles = (topRight1Exists: boolean) => (theme: GrafanaTheme2) => ({
overlay: css`
position: absolute;
width: 100%;
@ -41,9 +35,15 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({
z-index: 500;
pointer-events: none;
`,
TR: css`
TR1: css`
right: 0.5em;
pointer-events: auto;
position: absolute;
top: 8px;
top: 0.5em;
`,
TR2: css`
position: absolute;
top: ${topRight1Exists ? '80' : '8'}px;
right: 8px;
pointer-events: auto;
`,
@ -53,4 +53,4 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({
left: 8px;
pointer-events: auto;
`,
}));
});

View File

@ -34,6 +34,7 @@ import { PanelEditExitedEvent } from 'app/types/events';
import { GeomapOverlay, OverlayProps } from './GeomapOverlay';
import { GeomapTooltip } from './GeomapTooltip';
import { DebugOverlay } from './components/DebugOverlay';
import { MeasureOverlay } from './components/MeasureOverlay';
import { GeomapHoverPayload, GeomapLayerHover } from './event';
import { getGlobalStyles } from './globalStyles';
import { defaultMarkersConfig, MARKERS_LAYER_ID } from './layers/data/markersLayer';
@ -50,6 +51,7 @@ interface State extends OverlayProps {
ttip?: GeomapHoverPayload;
ttipOpen: boolean;
legends: ReactNode[];
measureMenuActive?: boolean;
}
export interface GeomapLayerActions {
@ -246,15 +248,11 @@ export class GeomapPanel extends Component<Props, State> {
*/
optionsChanged(options: GeomapPanelOptions) {
const oldOptions = this.props.options;
console.log('options changed!', options);
if (options.view !== oldOptions.view) {
console.log('View changed');
this.map!.setView(this.initMapView(options.view, this.map!.getLayers()));
}
if (options.controls !== oldOptions.controls) {
console.log('Controls changed');
this.initControls(options.controls ?? { showZoom: true, showAttribution: true });
}
}
@ -361,6 +359,10 @@ export class GeomapPanel extends Component<Props, State> {
};
pointerMoveListener = (evt: MapBrowserEvent<UIEvent>) => {
// If measure menu is open, bypass tooltip logic and display measuring mouse events
if (this.state.measureMenuActive) {
return true;
}
if (!this.map || this.state.ttipOpen) {
return false;
}
@ -652,12 +654,26 @@ export class GeomapPanel extends Component<Props, State> {
}
// Update the react overlays
let topRight: ReactNode[] = [];
if (options.showDebug) {
topRight = [<DebugOverlay key="debug" map={this.map} />];
let topRight1: ReactNode[] = [];
if (options.showMeasure) {
topRight1 = [
<MeasureOverlay
key="measure"
map={this.map}
// Lifts menuActive state and resets tooltip state upon close
menuActiveState={(value: boolean) => {
this.setState({ ttipOpen: value, measureMenuActive: value });
}}
/>,
];
}
this.setState({ topRight });
let topRight2: ReactNode[] = [];
if (options.showDebug) {
topRight2 = [<DebugOverlay key="debug" map={this.map} />];
}
this.setState({ topRight1, topRight2 });
}
getLegends() {
@ -672,7 +688,7 @@ export class GeomapPanel extends Component<Props, State> {
}
render() {
let { ttip, ttipOpen, topRight, legends } = this.state;
let { ttip, ttipOpen, topRight1, legends, topRight2 } = this.state;
const { options } = this.props;
const showScale = options.controls.showScale;
if (!ttipOpen && options.tooltip?.mode === TooltipMode.None) {
@ -684,7 +700,12 @@ export class GeomapPanel extends Component<Props, State> {
<Global styles={this.globalCSS} />
<div className={this.style.wrap} onMouseLeave={this.clearTooltip}>
<div className={this.style.map} ref={this.initMapRef}></div>
<GeomapOverlay bottomLeft={legends} topRight={topRight} blStyle={{ bottom: showScale ? '35px' : '8px' }} />
<GeomapOverlay
bottomLeft={legends}
topRight1={topRight1}
topRight2={topRight2}
blStyle={{ bottom: showScale ? '35px' : '8px' }}
/>
</div>
<GeomapTooltip ttip={ttip} isOpen={ttipOpen} onClose={this.tooltipPopupClosed} />
</>

View File

@ -0,0 +1,139 @@
import { css } from '@emotion/css';
import Map from 'ol/Map';
import React, { useMemo, useRef, useState } from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Button, IconButton, RadioButtonGroup, Select, stylesFactory } from '@grafana/ui';
import { config } from 'app/core/config';
import { MapMeasure, MapMeasureOptions, measures } from '../utils/measure';
import { MeasureVectorLayer } from './MeasureVectorLayer';
type Props = {
map: Map;
menuActiveState: (value: boolean) => void;
};
export const MeasureOverlay = ({ map, menuActiveState }: Props) => {
const vector = useRef(new MeasureVectorLayer());
const measureStyle = getStyles(config.theme2);
// Menu State Management
const [firstLoad, setFirstLoad] = useState<boolean>(true);
const [menuActive, setMenuActive] = useState<boolean>(false);
// Options State
const [options, setOptions] = useState<MapMeasureOptions>({
action: measures[0].value!,
unit: measures[0].units[0].value!,
});
const unit = useMemo(() => {
const action = measures.find((m: MapMeasure) => m.value === options.action) ?? measures[0];
const current = action.getUnit(options.unit);
vector.current.setOptions(options);
return {
current,
options: action.units,
};
}, [options]);
const clearPrevious = true;
const showSegments = false;
function toggleMenu() {
setMenuActive(!menuActive);
// Lift menu state
// TODO: consolidate into one state
menuActiveState(!menuActive);
if (menuActive) {
map.removeInteraction(vector.current.draw);
vector.current.setVisible(false);
} else {
if (firstLoad) {
// Initialize on first load
setFirstLoad(false);
map.addLayer(vector.current);
map.addInteraction(vector.current.modify);
}
vector.current.setVisible(true);
map.removeInteraction(vector.current.draw); // Remove last interaction
const a = measures.find((v: MapMeasure) => v.value === options.action) ?? measures[0];
vector.current.addInteraction(map, a.geometry, showSegments, clearPrevious);
}
}
return (
<div className={`${measureStyle.infoWrap} ${!menuActive ? measureStyle.infoWrapClosed : null}`}>
{menuActive ? (
<div>
<div className={measureStyle.rowGroup}>
<RadioButtonGroup
value={options.action}
options={measures}
size="md"
fullWidth={false}
onChange={(e: string) => {
map.removeInteraction(vector.current.draw);
const m = measures.find((v: MapMeasure) => v.value === e) ?? measures[0];
const unit = m.getUnit(options.unit);
setOptions({ ...options, action: m.value!, unit: unit.value! });
vector.current.addInteraction(map, m.geometry, showSegments, clearPrevious);
}}
/>
<Button className={measureStyle.button} icon="times" variant="secondary" size="sm" onClick={toggleMenu} />
</div>
<Select
className={measureStyle.unitSelect}
value={unit.current}
options={unit.options}
isSearchable={false}
onChange={(v: SelectableValue<string>) => {
const a = measures.find((v: SelectableValue<string>) => v.value === options.action) ?? measures[0];
const unit = a.getUnit(v.value) ?? a.units[0];
setOptions({ ...options, unit: unit.value! });
}}
/>
</div>
) : (
<IconButton
className={measureStyle.icon}
name="ruler-combined"
tooltip="show measure tools"
tooltipPlacement="left"
onClick={toggleMenu}
/>
)}
</div>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
button: css`
margin-left: auto;
`,
icon: css`
background-color: ${theme.colors.secondary.main};
display: inline-block;
height: 19.25px;
margin: 1px;
width: 19.25px;
`,
infoWrap: css`
color: ${theme.colors.text};
background-color: ${theme.colors.background.secondary};
border-radius: 4px;
padding: 2px;
`,
infoWrapClosed: css`
height: 25.25px;
width: 25.25px;
`,
rowGroup: css`
display: flex;
justify-content: flex-end;
`,
unitSelect: css`
min-width: 200px;
`,
}));

View File

@ -0,0 +1,255 @@
import { FeatureLike } from 'ol/Feature';
import Map from 'ol/Map';
import { Coordinate } from 'ol/coordinate';
import { Geometry, LineString, Point, Polygon } from 'ol/geom';
import { Type } from 'ol/geom/Geometry';
import { Draw, Modify } from 'ol/interaction';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import { getArea, getLength } from 'ol/sphere';
import { Circle as CircleStyle, Fill, RegularShape, Stroke, Style, Text } from 'ol/style';
import { formattedValueToString } from '@grafana/data';
import { MapMeasureOptions, measures } from '../utils/measure';
export class MeasureVectorLayer extends VectorLayer<VectorSource> {
opts: MapMeasureOptions = {
action: 'length',
unit: 'm',
};
constructor() {
super({
source: new VectorSource(),
});
this.setStyle((feature) => {
return this.styleFunction(feature, false);
});
this.setVisible(true);
}
setOptions(options: MapMeasureOptions) {
this.opts = options;
this.getSource()?.refresh();
}
getMapMeasurement(geo: Geometry): string {
let v = 0;
let action = measures[0];
if (this.opts.action === 'area') {
action = measures[1];
v = getArea(geo);
} else {
v = getLength(geo);
}
return formattedValueToString(action.getUnit(this.opts.unit).format(v));
}
segmentStyle = new Style({
text: new Text({
font: '12px Calibri,sans-serif',
fill: new Fill({
color: 'rgba(255, 255, 255, 1)',
}),
backgroundFill: new Fill({
color: 'rgba(0, 0, 0, 0.4)',
}),
padding: [2, 2, 2, 2],
textBaseline: 'bottom',
offsetY: -12,
}),
image: new RegularShape({
radius: 6,
points: 3,
angle: Math.PI,
displacement: [0, 8],
fill: new Fill({
color: 'rgba(0, 0, 0, 0.4)',
}),
}),
});
segmentStyles = [this.segmentStyle];
// Open Layer styles
shapeStyle = [
new Style({
fill: new Fill({
color: 'rgba(255, 255, 255, 0.2)',
}),
image: new CircleStyle({
radius: 5,
stroke: new Stroke({
color: 'rgba(0, 0, 0, 0.7)',
}),
fill: new Fill({
color: 'rgba(255, 255, 255, 0.2)',
}),
}),
}),
new Style({
stroke: new Stroke({
color: [0, 0, 0, 1],
width: 2,
lineDash: [4, 8],
lineDashOffset: 6,
}),
}),
new Style({
stroke: new Stroke({
color: [255, 255, 255, 1],
width: 2,
lineDash: [4, 8],
}),
}),
];
labelStyle = new Style({
text: new Text({
font: '14px Calibri,sans-serif',
fill: new Fill({
color: 'rgba(255, 255, 255, 1)',
}),
backgroundFill: new Fill({
color: 'rgba(0, 0, 0, 0.7)',
}),
padding: [3, 3, 3, 3],
textBaseline: 'bottom',
offsetY: -15,
}),
image: new RegularShape({
radius: 8,
points: 3,
angle: Math.PI,
displacement: [0, 10],
fill: new Fill({
color: 'rgba(0, 0, 0, 0.7)',
}),
}),
});
tipStyle = new Style({
text: new Text({
font: '12px Calibri,sans-serif',
fill: new Fill({
color: 'rgba(255, 255, 255, 1)',
}),
backgroundFill: new Fill({
color: 'rgba(0, 0, 0, 0.4)',
}),
padding: [2, 2, 2, 2],
textAlign: 'left',
offsetX: 15,
}),
});
modifyStyle = new Style({
image: new CircleStyle({
radius: 5,
stroke: new Stroke({
color: 'rgba(0, 0, 0, 0.7)',
}),
fill: new Fill({
color: 'rgba(0, 0, 0, 0.4)',
}),
}),
text: new Text({
text: 'Drag to modify',
font: '12px Calibri,sans-serif',
fill: new Fill({
color: 'rgba(255, 255, 255, 1)',
}),
backgroundFill: new Fill({
color: 'rgba(0, 0, 0, 0.7)',
}),
padding: [2, 2, 2, 2],
textAlign: 'left',
offsetX: 15,
}),
});
readonly modify = new Modify({ source: this.getSource()!, style: this.modifyStyle });
tipPoint!: Geometry;
draw!: Draw; // global so we can remove it later
styleFunction(feature: FeatureLike, segments: boolean, drawType?: string, tip?: string): Style[] {
const styles = [...this.shapeStyle];
const geometry = feature.getGeometry() as Geometry;
if (geometry) {
const type = geometry.getType();
let point: Point;
let label: string;
let line: LineString;
if (!drawType || drawType === type) {
if (type === 'Polygon') {
const poly = geometry as Polygon;
point = poly.getInteriorPoint();
label = this.getMapMeasurement(geometry);
line = new LineString(poly.getCoordinates()[0]);
} else if (type === 'LineString') {
line = geometry as LineString;
point = new Point(line.getLastCoordinate());
label = this.getMapMeasurement(geometry);
}
}
if (segments && line!) {
let count = 0;
line.forEachSegment((a: Coordinate, b: Coordinate) => {
const segment = new LineString([a, b]);
const label = this.getMapMeasurement(segment);
if (this.segmentStyles.length - 1 < count) {
this.segmentStyles.push(this.segmentStyle.clone());
}
const segmentPoint = new Point(segment.getCoordinateAt(0.5));
this.segmentStyles[count].setGeometry(segmentPoint);
this.segmentStyles[count].getText().setText(label);
styles.push(this.segmentStyles[count]);
count++;
});
}
if (label!) {
this.labelStyle.setGeometry(point!);
this.labelStyle.getText().setText(label);
styles.push(this.labelStyle);
}
if (tip && type === 'Point' && !this.modify.getOverlay().getSource().getFeatures().length) {
this.tipPoint = geometry;
this.tipStyle.getText().setText(tip);
styles.push(this.tipStyle);
}
}
return styles;
}
addInteraction(map: Map, typeSelect: Type, showSegments: boolean, clearPrevious: boolean) {
const drawType = typeSelect;
const activeTip =
' Click to continue ' + (drawType === 'Polygon' ? 'polygon' : 'line') + ' \n (double-click to end) ';
const idleTip = ' Click to start ';
let tip = idleTip;
this.draw = new Draw({
source: this.getSource()!,
type: drawType,
style: (feature) => {
return this.styleFunction(feature, showSegments, drawType, tip);
},
});
this.draw.on('drawstart', () => {
if (clearPrevious) {
this.getSource()!.clear();
}
this.modify.setActive(false);
tip = activeTip;
});
this.draw.on('drawend', () => {
this.modifyStyle.setGeometry(this.tipPoint);
this.modify.setActive(true);
map.once('pointermove', () => {
this.modifyStyle.setGeometry('');
});
tip = idleTip;
});
this.modify.setActive(true);
map.addInteraction(this.draw);
}
}

View File

@ -56,10 +56,10 @@ export function getGlobalStyles(theme: GrafanaTheme2) {
color: ${theme.colors.text.primary}; // #eee;
}
.ol-control {
background-color: ${theme.colors.background.secondary}; //rgba(255,255,255,0.4);
background-color: ${theme.colors.background.primary}; //rgba(255,255,255,0.4);
}
.ol-control:hover {
background-color: ${theme.colors.action.hover}; // rgba(255,255,255,0.6);
background-color: ${theme.colors.background.secondary}; // rgba(255,255,255,0.6);
}
.ol-control button {
color: ${theme.colors.secondary.text}; // white;

View File

@ -122,6 +122,13 @@ export const plugin = new PanelPlugin<GeomapPanelOptions>(GeomapPanel)
description: 'Show map info',
defaultValue: false,
})
.addBooleanSwitch({
category,
path: 'controls.showMeasure',
name: 'Show measure tools',
description: 'Show tools for making measurements on the map',
defaultValue: false,
})
.addRadio({
category,
path: 'tooltip.mode',

View File

@ -26,6 +26,9 @@ export interface ControlsOptions {
// Show debug
showDebug?: boolean;
// Show measure
showMeasure?: boolean;
}
export enum TooltipMode {

View File

@ -0,0 +1,16 @@
import { measures } from './measure';
describe('get measure utils', () => {
it('switch from length to area', () => {
const length = measures[0];
expect(length.value).toBe('length');
expect(length.getUnit('ft').value).toBe('ft');
expect(length.getUnit('ft2').value).toBe('ft');
});
it('switch from area to length', () => {
const area = measures[1];
expect(area.value).toBe('area');
expect(area.getUnit('ft2').value).toBe('ft2');
expect(area.getUnit('ft').value).toBe('ft2');
});
});

View File

@ -0,0 +1,106 @@
import { FormattedValue, getValueFormat, SelectableValue, toFixedUnit } from '@grafana/data';
type MeasureAction = 'area' | 'length';
export interface MapMeasureOptions {
action: MeasureAction;
unit: string;
color?: string;
}
export interface MapMeasure extends SelectableValue<MeasureAction> {
geometry: 'Polygon' | 'LineString';
units: MapUnit[];
/**
* This will get the best unit for the selected string or a default
* This is helpful when converting from area<>length, we should try to stay
* in the same category of unit if possible
*/
getUnit: (v?: string) => MapUnit;
}
export interface MapUnit extends SelectableValue<string> {
format: (si: number) => FormattedValue;
}
export const measures: MapMeasure[] = [
{
value: 'length',
label: 'Length',
geometry: 'LineString',
units: [
{
label: 'Metric (m/km)',
value: 'm',
format: (m: number) => getValueFormat('lengthm')(m),
},
{
label: 'Feet (ft)',
value: 'ft',
format: (m: number) => getValueFormat('lengthft')(m * 3.28084),
},
{
label: 'Miles (mi)',
value: 'mi',
format: (m: number) => getValueFormat('lengthmi')(m / 1609.0),
},
{
label: 'Nautical miles (nmi)',
value: 'nmi',
format: (m: number) => getValueFormat('nmi')(m / 1852.0),
},
],
getUnit: (v?: string) => {
const units = measures[0].units;
if (v?.endsWith('2')) {
v = v.substring(0, v.length - 1);
}
return units.find((u) => u.value === v) ?? units[0];
},
},
{
value: 'area',
label: 'Area',
geometry: 'Polygon',
units: [
{
label: 'Square Meters (m²)',
value: 'm2',
format: (m2: number) => getValueFormat('areaM2')(m2),
},
{
label: 'Square Kilometers (km²)',
value: 'km2',
format: (m2: number) => toFixedUnit('km²')(m2 * 1e-6),
},
{
label: 'Square Feet (ft²)',
value: 'ft2',
format: (m2: number) => getValueFormat('areaF2')(m2 * 10.76391),
},
{
label: 'Square Miles (mi²)',
value: 'mi2',
format: (m2: number) => getValueFormat('areaMI2')(m2 * 3.861e-7),
},
{
label: 'Acres',
value: 'acre2',
format: (m2: number) => toFixedUnit('acre')(m2 * 2.47105e-4),
},
{
label: 'Hectare',
value: 'hectare2',
format: (m2: number) => toFixedUnit('ha')(m2 * 1e-4),
},
],
getUnit: (v?: string) => {
const units = measures[1].units;
if (!v?.endsWith('2')) {
v += '2';
}
return units.find((u) => u.value === v) ?? units[0];
},
},
];