377 lines
12 KiB
TypeScript
377 lines
12 KiB
TypeScript
import { isNumber } from 'lodash';
|
|
import Feature, { FeatureLike } from 'ol/Feature';
|
|
import Map from 'ol/Map';
|
|
import { LineString, Point, SimpleGeometry } from 'ol/geom';
|
|
import { Group as LayerGroup } from 'ol/layer';
|
|
import VectorImage from 'ol/layer/VectorImage';
|
|
import VectorSource from 'ol/source/Vector';
|
|
import { Fill, Stroke, Style, Circle } from 'ol/style';
|
|
import FlowLine from 'ol-ext/style/FlowLine';
|
|
import { Subscription, throttleTime } from 'rxjs';
|
|
import tinycolor from 'tinycolor2';
|
|
|
|
import {
|
|
MapLayerRegistryItem,
|
|
PanelData,
|
|
GrafanaTheme2,
|
|
PluginState,
|
|
EventBus,
|
|
DataHoverEvent,
|
|
DataHoverClearEvent,
|
|
DataFrame,
|
|
FieldType,
|
|
} from '@grafana/data';
|
|
import { alpha } from '@grafana/data/src/themes/colorManipulator';
|
|
import { MapLayerOptions, FrameGeometrySourceMode } from '@grafana/schema';
|
|
import { FrameVectorSource } from 'app/features/geo/utils/frameVectorSource';
|
|
import { getGeometryField, getLocationMatchers } from 'app/features/geo/utils/location';
|
|
|
|
import { StyleEditor } from '../../editor/StyleEditor';
|
|
import { routeStyle } from '../../style/markers';
|
|
import { defaultStyleConfig, StyleConfig } from '../../style/types';
|
|
import { getStyleConfigState } from '../../style/utils';
|
|
import { getStyleDimension, isSegmentVisible } from '../../utils/utils';
|
|
|
|
// Configuration options for Circle overlays
|
|
export interface RouteConfig {
|
|
style: StyleConfig;
|
|
arrow?: 0 | 1 | -1;
|
|
}
|
|
|
|
const defaultOptions: RouteConfig = {
|
|
style: {
|
|
...defaultStyleConfig,
|
|
opacity: 1,
|
|
lineWidth: 2,
|
|
},
|
|
arrow: 0,
|
|
};
|
|
|
|
export const ROUTE_LAYER_ID = 'route';
|
|
|
|
// Used by default when nothing is configured
|
|
export const defaultRouteConfig: MapLayerOptions<RouteConfig> = {
|
|
type: ROUTE_LAYER_ID,
|
|
name: '', // will get replaced
|
|
config: defaultOptions,
|
|
location: {
|
|
mode: FrameGeometrySourceMode.Auto,
|
|
},
|
|
tooltip: false,
|
|
};
|
|
|
|
enum mapIndex {
|
|
x1 = 0,
|
|
y1 = 1,
|
|
x2 = 2,
|
|
y2 = 3,
|
|
}
|
|
|
|
const crosshairColor = '#607D8B';
|
|
|
|
/**
|
|
* Map layer configuration for circle overlay
|
|
*/
|
|
export const routeLayer: MapLayerRegistryItem<RouteConfig> = {
|
|
id: ROUTE_LAYER_ID,
|
|
name: 'Route',
|
|
description: 'Render data points as a route',
|
|
isBaseMap: false,
|
|
showLocation: true,
|
|
state: PluginState.beta,
|
|
|
|
/**
|
|
* Function that configures transformation and returns a transformer
|
|
* @param options
|
|
*/
|
|
create: async (map: Map, options: MapLayerOptions<RouteConfig>, eventBus: EventBus, theme: GrafanaTheme2) => {
|
|
// Assert default values
|
|
const config = {
|
|
...defaultOptions,
|
|
...options?.config,
|
|
};
|
|
|
|
const style = await getStyleConfigState(config.style);
|
|
const location = await getLocationMatchers(options.location);
|
|
const source = new FrameVectorSource(location);
|
|
const vectorLayer = new VectorImage({ source });
|
|
const hasArrows = config.arrow === 1 || config.arrow === -1;
|
|
|
|
if (!style.fields && !hasArrows) {
|
|
// Set a global style
|
|
const styleBase = routeStyle(style.base);
|
|
if (style.config.size && style.config.size.fixed) {
|
|
// Applies width to base style if specified
|
|
styleBase.getStroke().setWidth(style.config.size.fixed);
|
|
}
|
|
vectorLayer.setStyle(styleBase);
|
|
} else {
|
|
vectorLayer.setStyle((feature: FeatureLike) => {
|
|
const idx: number = feature.get('rowIndex');
|
|
const dims = style.dims;
|
|
if (!dims || !isNumber(idx)) {
|
|
return routeStyle(style.base);
|
|
}
|
|
|
|
const styles = [];
|
|
const geom = feature.getGeometry();
|
|
const opacity = style.config.opacity ?? 1;
|
|
if (geom instanceof SimpleGeometry) {
|
|
const coordinates = geom.getCoordinates();
|
|
if (coordinates) {
|
|
let startIndex = 0; // Index start for segment optimization
|
|
const pixelTolerance = 2; // For segment to be visible, it must be > 2 pixels (due to round ends)
|
|
for (let i = 0; i < coordinates.length - 1; i++) {
|
|
const segmentStartCoords = coordinates[startIndex];
|
|
const segmentEndCoords = coordinates[i + 1];
|
|
const color1 = tinycolor(
|
|
theme.visualization.getColorByName((dims.color && dims.color.get(startIndex)) ?? style.base.color)
|
|
)
|
|
.setAlpha(opacity)
|
|
.toString();
|
|
const color2 = tinycolor(
|
|
theme.visualization.getColorByName((dims.color && dims.color.get(i + 1)) ?? style.base.color)
|
|
)
|
|
.setAlpha(opacity)
|
|
.toString();
|
|
|
|
const arrowSize1 = (dims.size && dims.size.get(startIndex)) ?? style.base.size;
|
|
const arrowSize2 = (dims.size && dims.size.get(i + 1)) ?? style.base.size;
|
|
|
|
const flowStyle = new FlowLine({
|
|
visible: true,
|
|
lineCap: config.arrow === 0 ? 'round' : 'square',
|
|
color: color1,
|
|
color2: color2,
|
|
width: (dims.size && dims.size.get(startIndex)) ?? style.base.size,
|
|
width2: (dims.size && dims.size.get(i + 1)) ?? style.base.size,
|
|
});
|
|
if (config.arrow) {
|
|
flowStyle.setArrow(config.arrow);
|
|
if (config.arrow > 0) {
|
|
flowStyle.setArrowColor(color2);
|
|
flowStyle.setArrowSize((arrowSize2 ?? 0) * 1.5);
|
|
} else {
|
|
flowStyle.setArrowColor(color1);
|
|
flowStyle.setArrowSize((arrowSize1 ?? 0) * 1.5);
|
|
}
|
|
}
|
|
// Only render segment if change in pixel coordinates is significant enough
|
|
if (isSegmentVisible(map, pixelTolerance, segmentStartCoords, segmentEndCoords)) {
|
|
const LS = new LineString([segmentStartCoords, segmentEndCoords]);
|
|
flowStyle.setGeometry(LS);
|
|
styles.push(flowStyle);
|
|
startIndex = i + 1; // Because a segment was created, move onto the next one
|
|
}
|
|
}
|
|
// If no segments created, render a single point
|
|
if (styles.length === 0) {
|
|
const P = new Point(coordinates[0]);
|
|
const radius = ((dims.size && dims.size.get(0)) ?? style.base.size ?? 10) / 2;
|
|
const color = tinycolor(
|
|
theme.visualization.getColorByName((dims.color && dims.color.get(0)) ?? style.base.color)
|
|
)
|
|
.setAlpha(opacity)
|
|
.toString();
|
|
const ZoomOutCircle = new Style({
|
|
image: new Circle({
|
|
radius: radius,
|
|
fill: new Fill({
|
|
color: color,
|
|
}),
|
|
}),
|
|
});
|
|
ZoomOutCircle.setGeometry(P);
|
|
styles.push(ZoomOutCircle);
|
|
}
|
|
}
|
|
return styles;
|
|
}
|
|
|
|
const values = { ...style.base };
|
|
|
|
if (dims.color) {
|
|
values.color = dims.color.get(idx);
|
|
}
|
|
return routeStyle(values);
|
|
});
|
|
}
|
|
|
|
// Crosshair layer
|
|
const crosshairFeature = new Feature({});
|
|
const hLineFeature = new Feature({});
|
|
const vLineFeature = new Feature({});
|
|
const lineFeatures = [hLineFeature, vLineFeature];
|
|
const crosshairRadius = (style.base.lineWidth || 6) + 3;
|
|
const crosshairStyle = new Style({
|
|
image: new Circle({
|
|
radius: crosshairRadius,
|
|
stroke: new Stroke({
|
|
color: alpha(crosshairColor, 1),
|
|
width: 1,
|
|
}),
|
|
fill: new Fill({ color: alpha(crosshairColor, 0.4) }),
|
|
}),
|
|
});
|
|
const lineStyle = new Style({
|
|
stroke: new Stroke({
|
|
color: crosshairColor,
|
|
width: 1,
|
|
lineDash: [3, 3],
|
|
lineCap: 'square',
|
|
}),
|
|
});
|
|
|
|
const crosshairLayer = new VectorImage({
|
|
source: new VectorSource({
|
|
features: [crosshairFeature],
|
|
}),
|
|
style: crosshairStyle,
|
|
});
|
|
|
|
const linesLayer = new VectorImage({
|
|
source: new VectorSource({
|
|
features: lineFeatures,
|
|
}),
|
|
style: lineStyle,
|
|
});
|
|
|
|
const layer = new LayerGroup({
|
|
layers: [vectorLayer, crosshairLayer, linesLayer],
|
|
});
|
|
|
|
// Crosshair sharing subscriptions
|
|
const subscriptions = new Subscription();
|
|
|
|
subscriptions.add(
|
|
eventBus
|
|
.getStream(DataHoverEvent)
|
|
.pipe(throttleTime(8))
|
|
.subscribe({
|
|
next: (event) => {
|
|
const mapExtents = map.getView().calculateExtent(map.getSize());
|
|
const feature = source.getFeatures()[0];
|
|
const frame: DataFrame = feature?.get('frame');
|
|
const time = event.payload?.point?.time;
|
|
if (frame && time) {
|
|
const timeField = frame.fields.find((f) => f.type === FieldType.time);
|
|
if (timeField) {
|
|
const timestamps: number[] = timeField.values;
|
|
const pointIdx = findNearestTimeIndex(timestamps, time);
|
|
if (pointIdx !== null) {
|
|
const out = getGeometryField(frame, location);
|
|
if (out.field) {
|
|
const crosshairPoint: Point = out.field.values[pointIdx] as Point;
|
|
const crosshairPointCoords = crosshairPoint.getCoordinates();
|
|
crosshairFeature.setGeometry(crosshairPoint);
|
|
crosshairFeature.setStyle(crosshairStyle);
|
|
hLineFeature.setGeometry(
|
|
new LineString([
|
|
[mapExtents[mapIndex.x1], crosshairPointCoords[mapIndex.y1]],
|
|
[mapExtents[mapIndex.x2], crosshairPointCoords[mapIndex.y1]],
|
|
])
|
|
);
|
|
vLineFeature.setGeometry(
|
|
new LineString([
|
|
[crosshairPointCoords[mapIndex.x1], mapExtents[mapIndex.y1]],
|
|
[crosshairPointCoords[mapIndex.x1], mapExtents[mapIndex.y2]],
|
|
])
|
|
);
|
|
lineFeatures.forEach((feature) => feature.setStyle(lineStyle));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
})
|
|
);
|
|
|
|
subscriptions.add(
|
|
eventBus.subscribe(DataHoverClearEvent, (event) => {
|
|
crosshairFeature.setStyle(new Style({}));
|
|
lineFeatures.forEach((feature) => feature.setStyle(new Style({})));
|
|
})
|
|
);
|
|
|
|
return {
|
|
init: () => layer,
|
|
dispose: () => subscriptions.unsubscribe(),
|
|
update: (data: PanelData) => {
|
|
if (!data.series?.length) {
|
|
return; // ignore empty
|
|
}
|
|
|
|
for (const frame of data.series) {
|
|
if (style.fields || hasArrows) {
|
|
style.dims = getStyleDimension(frame, style, theme);
|
|
}
|
|
|
|
source.updateLineString(frame);
|
|
break; // Only the first frame for now!
|
|
}
|
|
},
|
|
|
|
// Route layer options
|
|
registerOptionsUI: (builder) => {
|
|
builder
|
|
.addCustomEditor({
|
|
id: 'config.style',
|
|
path: 'config.style',
|
|
name: 'Style',
|
|
editor: StyleEditor,
|
|
settings: {
|
|
simpleFixedValues: false,
|
|
},
|
|
defaultValue: defaultOptions.style,
|
|
})
|
|
.addRadio({
|
|
path: 'config.arrow',
|
|
name: 'Arrow',
|
|
settings: {
|
|
options: [
|
|
{ label: 'None', value: 0 },
|
|
{ label: 'Forward', value: 1 },
|
|
{ label: 'Reverse', value: -1 },
|
|
],
|
|
},
|
|
defaultValue: defaultOptions.arrow,
|
|
});
|
|
},
|
|
};
|
|
},
|
|
|
|
// 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 < lastIdx ? i + 1 : lastIdx;
|
|
}
|
|
}
|
|
return 0;
|
|
} else {
|
|
for (let i = probableIdx; i < lastIdx; i++) {
|
|
if (time < timestamps[i]) {
|
|
return i > 0 ? i - 1 : 0;
|
|
}
|
|
}
|
|
return lastIdx;
|
|
}
|
|
}
|