mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
6c58ea66a9
commit
fe0f193189
@ -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"]
|
||||
|
@ -148,6 +148,7 @@ export const getAvailableIcons = () =>
|
||||
'record-audio',
|
||||
'repeat',
|
||||
'rocket',
|
||||
'ruler-combined',
|
||||
'save',
|
||||
'search',
|
||||
'search-minus',
|
||||
|
@ -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;
|
||||
`,
|
||||
}));
|
||||
});
|
||||
|
@ -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} />
|
||||
</>
|
||||
|
139
public/app/plugins/panel/geomap/components/MeasureOverlay.tsx
Normal file
139
public/app/plugins/panel/geomap/components/MeasureOverlay.tsx
Normal 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;
|
||||
`,
|
||||
}));
|
255
public/app/plugins/panel/geomap/components/MeasureVectorLayer.ts
Normal file
255
public/app/plugins/panel/geomap/components/MeasureVectorLayer.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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',
|
||||
|
@ -26,6 +26,9 @@ export interface ControlsOptions {
|
||||
|
||||
// Show debug
|
||||
showDebug?: boolean;
|
||||
|
||||
// Show measure
|
||||
showMeasure?: boolean;
|
||||
}
|
||||
|
||||
export enum TooltipMode {
|
||||
|
16
public/app/plugins/panel/geomap/utils/measure.test.ts
Normal file
16
public/app/plugins/panel/geomap/utils/measure.test.ts
Normal 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');
|
||||
});
|
||||
});
|
106
public/app/plugins/panel/geomap/utils/measure.ts
Normal file
106
public/app/plugins/panel/geomap/utils/measure.ts
Normal 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];
|
||||
},
|
||||
},
|
||||
];
|
Loading…
Reference in New Issue
Block a user