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:
@@ -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.", "5"],
|
||||||
[0, 0, 0, "Do not use any type assertions.", "6"]
|
[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": [
|
"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.", "0"],
|
||||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ export const getAvailableIcons = () =>
|
|||||||
'record-audio',
|
'record-audio',
|
||||||
'repeat',
|
'repeat',
|
||||||
'rocket',
|
'rocket',
|
||||||
|
'ruler-combined',
|
||||||
'save',
|
'save',
|
||||||
'search',
|
'search',
|
||||||
'search-minus',
|
'search-minus',
|
||||||
|
|||||||
@@ -1,39 +1,33 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import React, { CSSProperties, PureComponent } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
import { useStyles2 } from '@grafana/ui';
|
||||||
import { stylesFactory } from '@grafana/ui';
|
|
||||||
|
|
||||||
export interface OverlayProps {
|
export interface OverlayProps {
|
||||||
topRight?: React.ReactNode[];
|
topRight1?: React.ReactNode[];
|
||||||
|
topRight2?: React.ReactNode[];
|
||||||
bottomLeft?: React.ReactNode[];
|
bottomLeft?: React.ReactNode[];
|
||||||
blStyle?: CSSProperties;
|
blStyle?: CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GeomapOverlay extends PureComponent<OverlayProps> {
|
export const GeomapOverlay = ({ topRight1, topRight2, bottomLeft, blStyle }: OverlayProps) => {
|
||||||
style = getStyles(config.theme);
|
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) {
|
const getStyles = (topRight1Exists: boolean) => (theme: GrafanaTheme2) => ({
|
||||||
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) => ({
|
|
||||||
overlay: css`
|
overlay: css`
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -41,9 +35,15 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({
|
|||||||
z-index: 500;
|
z-index: 500;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
`,
|
`,
|
||||||
TR: css`
|
TR1: css`
|
||||||
|
right: 0.5em;
|
||||||
|
pointer-events: auto;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 0.5em;
|
||||||
|
`,
|
||||||
|
TR2: css`
|
||||||
|
position: absolute;
|
||||||
|
top: ${topRight1Exists ? '80' : '8'}px;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
`,
|
`,
|
||||||
@@ -53,4 +53,4 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({
|
|||||||
left: 8px;
|
left: 8px;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
`,
|
`,
|
||||||
}));
|
});
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { PanelEditExitedEvent } from 'app/types/events';
|
|||||||
import { GeomapOverlay, OverlayProps } from './GeomapOverlay';
|
import { GeomapOverlay, OverlayProps } from './GeomapOverlay';
|
||||||
import { GeomapTooltip } from './GeomapTooltip';
|
import { GeomapTooltip } from './GeomapTooltip';
|
||||||
import { DebugOverlay } from './components/DebugOverlay';
|
import { DebugOverlay } from './components/DebugOverlay';
|
||||||
|
import { MeasureOverlay } from './components/MeasureOverlay';
|
||||||
import { GeomapHoverPayload, GeomapLayerHover } from './event';
|
import { GeomapHoverPayload, GeomapLayerHover } from './event';
|
||||||
import { getGlobalStyles } from './globalStyles';
|
import { getGlobalStyles } from './globalStyles';
|
||||||
import { defaultMarkersConfig, MARKERS_LAYER_ID } from './layers/data/markersLayer';
|
import { defaultMarkersConfig, MARKERS_LAYER_ID } from './layers/data/markersLayer';
|
||||||
@@ -50,6 +51,7 @@ interface State extends OverlayProps {
|
|||||||
ttip?: GeomapHoverPayload;
|
ttip?: GeomapHoverPayload;
|
||||||
ttipOpen: boolean;
|
ttipOpen: boolean;
|
||||||
legends: ReactNode[];
|
legends: ReactNode[];
|
||||||
|
measureMenuActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GeomapLayerActions {
|
export interface GeomapLayerActions {
|
||||||
@@ -246,15 +248,11 @@ export class GeomapPanel extends Component<Props, State> {
|
|||||||
*/
|
*/
|
||||||
optionsChanged(options: GeomapPanelOptions) {
|
optionsChanged(options: GeomapPanelOptions) {
|
||||||
const oldOptions = this.props.options;
|
const oldOptions = this.props.options;
|
||||||
console.log('options changed!', options);
|
|
||||||
|
|
||||||
if (options.view !== oldOptions.view) {
|
if (options.view !== oldOptions.view) {
|
||||||
console.log('View changed');
|
|
||||||
this.map!.setView(this.initMapView(options.view, this.map!.getLayers()));
|
this.map!.setView(this.initMapView(options.view, this.map!.getLayers()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.controls !== oldOptions.controls) {
|
if (options.controls !== oldOptions.controls) {
|
||||||
console.log('Controls changed');
|
|
||||||
this.initControls(options.controls ?? { showZoom: true, showAttribution: true });
|
this.initControls(options.controls ?? { showZoom: true, showAttribution: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -361,6 +359,10 @@ export class GeomapPanel extends Component<Props, State> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
pointerMoveListener = (evt: MapBrowserEvent<UIEvent>) => {
|
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) {
|
if (!this.map || this.state.ttipOpen) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -652,12 +654,26 @@ export class GeomapPanel extends Component<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update the react overlays
|
// Update the react overlays
|
||||||
let topRight: ReactNode[] = [];
|
let topRight1: ReactNode[] = [];
|
||||||
if (options.showDebug) {
|
if (options.showMeasure) {
|
||||||
topRight = [<DebugOverlay key="debug" map={this.map} />];
|
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() {
|
getLegends() {
|
||||||
@@ -672,7 +688,7 @@ export class GeomapPanel extends Component<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
let { ttip, ttipOpen, topRight, legends } = this.state;
|
let { ttip, ttipOpen, topRight1, legends, topRight2 } = this.state;
|
||||||
const { options } = this.props;
|
const { options } = this.props;
|
||||||
const showScale = options.controls.showScale;
|
const showScale = options.controls.showScale;
|
||||||
if (!ttipOpen && options.tooltip?.mode === TooltipMode.None) {
|
if (!ttipOpen && options.tooltip?.mode === TooltipMode.None) {
|
||||||
@@ -684,7 +700,12 @@ export class GeomapPanel extends Component<Props, State> {
|
|||||||
<Global styles={this.globalCSS} />
|
<Global styles={this.globalCSS} />
|
||||||
<div className={this.style.wrap} onMouseLeave={this.clearTooltip}>
|
<div className={this.style.wrap} onMouseLeave={this.clearTooltip}>
|
||||||
<div className={this.style.map} ref={this.initMapRef}></div>
|
<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>
|
</div>
|
||||||
<GeomapTooltip ttip={ttip} isOpen={ttipOpen} onClose={this.tooltipPopupClosed} />
|
<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;
|
color: ${theme.colors.text.primary}; // #eee;
|
||||||
}
|
}
|
||||||
.ol-control {
|
.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 {
|
.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 {
|
.ol-control button {
|
||||||
color: ${theme.colors.secondary.text}; // white;
|
color: ${theme.colors.secondary.text}; // white;
|
||||||
|
|||||||
@@ -122,6 +122,13 @@ export const plugin = new PanelPlugin<GeomapPanelOptions>(GeomapPanel)
|
|||||||
description: 'Show map info',
|
description: 'Show map info',
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
})
|
})
|
||||||
|
.addBooleanSwitch({
|
||||||
|
category,
|
||||||
|
path: 'controls.showMeasure',
|
||||||
|
name: 'Show measure tools',
|
||||||
|
description: 'Show tools for making measurements on the map',
|
||||||
|
defaultValue: false,
|
||||||
|
})
|
||||||
.addRadio({
|
.addRadio({
|
||||||
category,
|
category,
|
||||||
path: 'tooltip.mode',
|
path: 'tooltip.mode',
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export interface ControlsOptions {
|
|||||||
|
|
||||||
// Show debug
|
// Show debug
|
||||||
showDebug?: boolean;
|
showDebug?: boolean;
|
||||||
|
|
||||||
|
// Show measure
|
||||||
|
showMeasure?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum TooltipMode {
|
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];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
Reference in New Issue
Block a user