diff --git a/.betterer.results b/.betterer.results index d08c98198a6..9b62f066430 100644 --- a/.betterer.results +++ b/.betterer.results @@ -8978,9 +8978,7 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "14"], [0, 0, 0, "Unexpected any. Specify a different type.", "15"], [0, 0, 0, "Unexpected any. Specify a different type.", "16"], - [0, 0, 0, "Unexpected any. Specify a different type.", "17"], - [0, 0, 0, "Do not use any type assertions.", "18"], - [0, 0, 0, "Unexpected any. Specify a different type.", "19"] + [0, 0, 0, "Unexpected any. Specify a different type.", "17"] ], "public/app/plugins/panel/geomap/components/DataHoverRows.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] diff --git a/public/app/plugins/panel/geomap/GeomapPanel.tsx b/public/app/plugins/panel/geomap/GeomapPanel.tsx index 92203974659..0647d99a707 100644 --- a/public/app/plugins/panel/geomap/GeomapPanel.tsx +++ b/public/app/plugins/panel/geomap/GeomapPanel.tsx @@ -7,11 +7,10 @@ import Attribution from 'ol/control/Attribution'; import ScaleLine from 'ol/control/ScaleLine'; import Zoom from 'ol/control/Zoom'; import { Coordinate } from 'ol/coordinate'; -import { createEmpty, extend, isEmpty } from 'ol/extent'; +import { isEmpty } from 'ol/extent'; import { defaults as interactionDefaults } from 'ol/interaction'; import MouseWheelZoom from 'ol/interaction/MouseWheelZoom'; import BaseLayer from 'ol/layer/Base'; -import VectorLayer from 'ol/layer/Vector'; import { fromLonLat, toLonLat } from 'ol/proj'; import React, { Component, ReactNode } from 'react'; import { Subject, Subscription } from 'rxjs'; @@ -40,6 +39,7 @@ import { getGlobalStyles } from './globalStyles'; import { defaultMarkersConfig, MARKERS_LAYER_ID } from './layers/data/markersLayer'; import { DEFAULT_BASEMAP_CONFIG, geomapLayerRegistry } from './layers/registry'; import { ControlsOptions, GeomapPanelOptions, MapLayerState, MapViewConfig, TooltipMode } from './types'; +import { getLayersExtent } from './utils/getLayersExtent'; import { centerPointRegistry, MapCenterID } from './view'; // Allows multiple panels to share the same view instance @@ -590,11 +590,7 @@ export class GeomapPanel extends Component { if (v.id === MapCenterID.Coordinates) { coord = [config.lon ?? 0, config.lat ?? 0]; } else if (v.id === MapCenterID.Fit) { - var extent = layers - .getArray() - .filter((l) => l instanceof VectorLayer) - .map((l) => (l as VectorLayer).getSource().getExtent() ?? []) - .reduce(extend, createEmpty()); + const extent = getLayersExtent(layers); if (!isEmpty(extent)) { view.fit(extent, { padding: [30, 30, 30, 30], diff --git a/public/app/plugins/panel/geomap/layers/data/routeLayer.tsx b/public/app/plugins/panel/geomap/layers/data/routeLayer.tsx index 29f4840eed4..23ad642e9f0 100644 --- a/public/app/plugins/panel/geomap/layers/data/routeLayer.tsx +++ b/public/app/plugins/panel/geomap/layers/data/routeLayer.tsx @@ -6,10 +6,15 @@ import { FrameGeometrySourceMode, PluginState, EventBus, + DataHoverEvent, + DataHoverClearEvent, + DataFrame, + TIME_SERIES_TIME_FIELD_NAME, } from '@grafana/data'; import Map from 'ol/Map'; import { FeatureLike } from 'ol/Feature'; -import { getLocationMatchers } from 'app/features/geo/utils/location'; +import { Subscription, throttleTime } from 'rxjs'; +import { getGeometryField, getLocationMatchers } from 'app/features/geo/utils/location'; import { getColorDimension } from 'app/features/dimensions'; import { defaultStyleConfig, StyleConfig, StyleDimensions } from '../../style/types'; import { StyleEditor } from './StyleEditor'; @@ -18,6 +23,11 @@ import VectorLayer from 'ol/layer/Vector'; import { isNumber } from 'lodash'; import { routeStyle } from '../../style/markers'; import { FrameVectorSource } from 'app/features/geo/utils/frameVectorSource'; +import { Group as LayerGroup } from 'ol/layer'; +import VectorSource from 'ol/source/Vector'; +import { Fill, Stroke, Style, Circle } from 'ol/style'; +import Feature from 'ol/Feature'; +import { alpha } from '@grafana/data/src/themes/colorManipulator'; // Configuration options for Circle overlays export interface RouteConfig { @@ -70,9 +80,7 @@ export const routeLayer: MapLayerRegistryItem = { const style = await getStyleConfigState(config.style); const location = await getLocationMatchers(options.location); const source = new FrameVectorSource(location); - const vectorLayer = new VectorLayer({ - source, - }); + const vectorLayer = new VectorLayer({ source }); if (!style.fields) { // Set a global style @@ -94,8 +102,70 @@ export const routeLayer: MapLayerRegistryItem = { }); } + // Crosshair layer + const crosshairFeature = new Feature({}); + const crosshairRadius = (style.base.lineWidth || 6) + 2; + const crosshairStyle = new Style({ + image: new Circle({ + radius: crosshairRadius, + stroke: new Stroke({ + color: alpha(style.base.color, 0.4), + width: crosshairRadius + 2 + }), + fill: new Fill({color: style.base.color}), + }) + }); + + const crosshairLayer = new VectorLayer({ + source: new VectorSource({ + features: [crosshairFeature], + }), + style: crosshairStyle, + }); + + const layer = new LayerGroup({ + layers: [vectorLayer, crosshairLayer] + }); + + // Crosshair sharing subscriptions + const subscriptions = new Subscription(); + + subscriptions.add( + eventBus + .getStream(DataHoverEvent) + .pipe(throttleTime(8)) + .subscribe({ + next: (event) => { + const feature = source.getFeatures()[0]; + const frame = feature?.get('frame') as DataFrame; + const time = event.payload?.point?.time as number; + if (frame && time) { + const timeField = frame.fields.find((f) => f.name === TIME_SERIES_TIME_FIELD_NAME); + if (timeField) { + const timestamps: number[] = timeField.values.toArray(); + const pointIdx = findNearestTimeIndex(timestamps, time); + if (pointIdx !== null) { + const out = getGeometryField(frame, location); + if (out.field) { + crosshairFeature.setGeometry(out.field.values.get(pointIdx)); + crosshairFeature.setStyle(crosshairStyle); + } + } + } + } + }, + }) + ); + + subscriptions.add( + eventBus.subscribe(DataHoverClearEvent, (event) => { + crosshairFeature.setStyle(new Style({})); + }) + ); + return { - init: () => vectorLayer, + init: () => layer, + dispose: () => subscriptions.unsubscribe(), update: (data: PanelData) => { if (!data.series?.length) { return; // ignore empty @@ -145,3 +215,34 @@ export const routeLayer: MapLayerRegistryItem = { // fill in the default values defaultOptions, }; + +function findNearestTimeIndex(timestamps: number[], time: number): number | null { + if (timestamps.length === 0) { + return null; + } else if (timestamps.length === 1) { + return 0; + } + const lastIdx = timestamps.length - 1; + if (time < timestamps[0]) { + return 0; + } else if (time > timestamps[lastIdx]) { + return lastIdx; + } + + const probableIdx = Math.abs(Math.round(lastIdx * (time - timestamps[0]) / (timestamps[lastIdx] - timestamps[0]))); + if (time < timestamps[probableIdx]) { + for (let i = probableIdx; i > 0; i--) { + if (time > timestamps[i]) { + return i; + } + } + return 0; + } else { + for (let i = probableIdx; i < lastIdx; i++) { + if (time < timestamps[i]) { + return i; + } + } + return lastIdx; + } +} diff --git a/public/app/plugins/panel/geomap/utils/getLayersExtent.ts b/public/app/plugins/panel/geomap/utils/getLayersExtent.ts new file mode 100644 index 00000000000..fe73c991a6a --- /dev/null +++ b/public/app/plugins/panel/geomap/utils/getLayersExtent.ts @@ -0,0 +1,33 @@ +import { Collection } from 'ol'; +import { createEmpty, extend, Extent } from 'ol/extent'; +import BaseLayer from 'ol/layer/Base'; +import LayerGroup from 'ol/layer/Group'; +import VectorLayer from 'ol/layer/Vector'; + +export function getLayersExtent(layers: Collection): Extent { + return layers + .getArray() + .filter((l) => l instanceof VectorLayer || l instanceof LayerGroup) + .flatMap((l) => { + if (l instanceof LayerGroup) { + return getLayerGroupExtent(l); + } else if (l instanceof VectorLayer) { + return l.getSource().getExtent() ?? []; + } + }) + .reduce(extend, createEmpty()); +} + +export function getLayerGroupExtent(lg: LayerGroup) { + return lg + .getLayers() + .getArray() + .filter((l) => l instanceof VectorLayer) + .map((l) => { + if (l instanceof VectorLayer) { + return l.getSource().getExtent() ?? []; + } else { + return []; + } + }); +}