Geomap: Add tooltip support for dynamic geojson (#108203)
* Geomap: Add tooltip support for dynamic geojson * Add tests for tooltip function
This commit is contained in:
@@ -3798,9 +3798,6 @@ exports[`better eslint`] = {
|
||||
"public/app/plugins/panel/geomap/layers/basemaps/esri.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/plugins/panel/geomap/layers/data/geojsonDynamic.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/plugins/panel/geomap/layers/data/routeLayer.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import Feature from 'ol/Feature';
|
||||
import { Point, Polygon } from 'ol/geom';
|
||||
import VectorSource from 'ol/source/Vector';
|
||||
|
||||
import { FieldType, createDataFrame } from '@grafana/data';
|
||||
|
||||
import { updateFeaturePropertiesForTooltip } from './geojsonDynamic';
|
||||
|
||||
describe('Dynamic GeoJSON Layer', () => {
|
||||
describe('updateFeaturePropertiesForTooltip', () => {
|
||||
let source: VectorSource;
|
||||
let idToIdx: Map<string, number>;
|
||||
let mockFeature1: Feature;
|
||||
let mockFeature2: Feature;
|
||||
let mockFeature3: Feature;
|
||||
let forEachFeatureSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
source = new VectorSource();
|
||||
idToIdx = new Map();
|
||||
|
||||
// Create mock features with different IDs
|
||||
mockFeature1 = new Feature({
|
||||
geometry: new Point([0, 0]),
|
||||
});
|
||||
mockFeature1.setId('feature1');
|
||||
|
||||
mockFeature2 = new Feature({
|
||||
geometry: new Polygon([[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]]),
|
||||
});
|
||||
mockFeature2.setId('feature2');
|
||||
|
||||
mockFeature3 = new Feature({
|
||||
geometry: new Point([2, 2]),
|
||||
});
|
||||
mockFeature3.setId('feature3');
|
||||
|
||||
// Add features to source
|
||||
source.addFeature(mockFeature1);
|
||||
source.addFeature(mockFeature2);
|
||||
source.addFeature(mockFeature3);
|
||||
|
||||
// Set up spy for forEachFeature method
|
||||
forEachFeatureSpy = jest.spyOn(source, 'forEachFeature');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
source.clear();
|
||||
idToIdx.clear();
|
||||
forEachFeatureSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should update feature properties when frame and idField are provided', () => {
|
||||
const frame = createDataFrame({
|
||||
fields: [
|
||||
{ name: 'id', type: FieldType.string, values: ['feature1', 'feature2', 'feature3'] },
|
||||
{ name: 'value', type: FieldType.number, values: [10, 20, 30] },
|
||||
{ name: 'name', type: FieldType.string, values: ['First', 'Second', 'Third'] },
|
||||
],
|
||||
});
|
||||
|
||||
updateFeaturePropertiesForTooltip(source, frame, 'id', idToIdx);
|
||||
|
||||
// Check that idToIdx map is populated correctly
|
||||
expect(idToIdx.get('feature1')).toBe(0);
|
||||
expect(idToIdx.get('feature2')).toBe(1);
|
||||
expect(idToIdx.get('feature3')).toBe(2);
|
||||
|
||||
// Check that features have frame and rowIndex properties set
|
||||
expect(mockFeature1.get('frame')).toBe(frame);
|
||||
expect(mockFeature1.get('rowIndex')).toBe(0);
|
||||
|
||||
expect(mockFeature2.get('frame')).toBe(frame);
|
||||
expect(mockFeature2.get('rowIndex')).toBe(1);
|
||||
|
||||
expect(mockFeature3.get('frame')).toBe(frame);
|
||||
expect(mockFeature3.get('rowIndex')).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle partial matches between features and frame data', () => {
|
||||
const frame = createDataFrame({
|
||||
fields: [
|
||||
{ name: 'id', type: FieldType.string, values: ['feature1', 'feature3'] }, // missing feature2
|
||||
{ name: 'value', type: FieldType.number, values: [10, 30] },
|
||||
],
|
||||
});
|
||||
|
||||
updateFeaturePropertiesForTooltip(source, frame, 'id', idToIdx);
|
||||
|
||||
// Check that only matching features have properties set
|
||||
expect(mockFeature1.get('frame')).toBe(frame);
|
||||
expect(mockFeature1.get('rowIndex')).toBe(0);
|
||||
|
||||
expect(mockFeature2.get('frame')).toBeUndefined();
|
||||
expect(mockFeature2.get('rowIndex')).toBeUndefined();
|
||||
|
||||
expect(mockFeature3.get('frame')).toBe(frame);
|
||||
expect(mockFeature3.get('rowIndex')).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle features with no matching data', () => {
|
||||
const frame = createDataFrame({
|
||||
fields: [
|
||||
{ name: 'id', type: FieldType.string, values: ['nonexistent1', 'nonexistent2'] },
|
||||
{ name: 'value', type: FieldType.number, values: [10, 20] },
|
||||
],
|
||||
});
|
||||
|
||||
updateFeaturePropertiesForTooltip(source, frame, 'id', idToIdx);
|
||||
|
||||
// Check that no features have properties set since IDs don't match
|
||||
expect(mockFeature1.get('frame')).toBeUndefined();
|
||||
expect(mockFeature1.get('rowIndex')).toBeUndefined();
|
||||
|
||||
expect(mockFeature2.get('frame')).toBeUndefined();
|
||||
expect(mockFeature2.get('rowIndex')).toBeUndefined();
|
||||
|
||||
expect(mockFeature3.get('frame')).toBeUndefined();
|
||||
expect(mockFeature3.get('rowIndex')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should clear idToIdx map on each call', () => {
|
||||
// First call
|
||||
const frame1 = createDataFrame({
|
||||
fields: [
|
||||
{ name: 'id', type: FieldType.string, values: ['feature1'] },
|
||||
{ name: 'value', type: FieldType.number, values: [10] },
|
||||
],
|
||||
});
|
||||
|
||||
updateFeaturePropertiesForTooltip(source, frame1, 'id', idToIdx);
|
||||
expect(idToIdx.size).toBe(1);
|
||||
expect(idToIdx.get('feature1')).toBe(0);
|
||||
|
||||
// Second call with different data
|
||||
const frame2 = createDataFrame({
|
||||
fields: [
|
||||
{ name: 'id', type: FieldType.string, values: ['feature2', 'feature3'] },
|
||||
{ name: 'value', type: FieldType.number, values: [20, 30] },
|
||||
],
|
||||
});
|
||||
|
||||
updateFeaturePropertiesForTooltip(source, frame2, 'id', idToIdx);
|
||||
|
||||
// Map should be cleared and repopulated
|
||||
expect(idToIdx.size).toBe(2);
|
||||
expect(idToIdx.get('feature1')).toBeUndefined();
|
||||
expect(idToIdx.get('feature2')).toBe(0);
|
||||
expect(idToIdx.get('feature3')).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle features with null or undefined IDs', () => {
|
||||
// Add a feature with no ID
|
||||
const featureWithoutId = new Feature({
|
||||
geometry: new Point([3, 3]),
|
||||
});
|
||||
source.addFeature(featureWithoutId);
|
||||
|
||||
const frame = createDataFrame({
|
||||
fields: [
|
||||
{ name: 'id', type: FieldType.string, values: ['feature1', 'feature2'] },
|
||||
{ name: 'value', type: FieldType.number, values: [10, 20] },
|
||||
],
|
||||
});
|
||||
|
||||
updateFeaturePropertiesForTooltip(source, frame, 'id', idToIdx);
|
||||
|
||||
// Features with IDs should be processed
|
||||
expect(mockFeature1.get('frame')).toBe(frame);
|
||||
expect(mockFeature1.get('rowIndex')).toBe(0);
|
||||
|
||||
// Feature without ID should not be processed
|
||||
expect(featureWithoutId.get('frame')).toBeUndefined();
|
||||
expect(featureWithoutId.get('rowIndex')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle string and number IDs', () => {
|
||||
// Add features with number IDs
|
||||
const featureWithNumberId = new Feature({
|
||||
geometry: new Point([4, 4]),
|
||||
});
|
||||
featureWithNumberId.setId(123);
|
||||
source.addFeature(featureWithNumberId);
|
||||
|
||||
const frame = createDataFrame({
|
||||
fields: [
|
||||
{ name: 'id', type: FieldType.number, values: [123, 456] },
|
||||
{ name: 'value', type: FieldType.number, values: [100, 200] },
|
||||
],
|
||||
});
|
||||
|
||||
updateFeaturePropertiesForTooltip(source, frame, 'id', idToIdx);
|
||||
|
||||
// Feature with number ID should be processed (converted to string)
|
||||
expect(featureWithNumberId.get('frame')).toBe(frame);
|
||||
expect(featureWithNumberId.get('rowIndex')).toBe(0);
|
||||
});
|
||||
|
||||
it('should return early when frame is undefined', () => {
|
||||
updateFeaturePropertiesForTooltip(source, undefined, 'id', idToIdx);
|
||||
|
||||
expect(forEachFeatureSpy).not.toHaveBeenCalled();
|
||||
expect(idToIdx.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should return early when idField is undefined', () => {
|
||||
const frame = createDataFrame({
|
||||
fields: [
|
||||
{ name: 'id', type: FieldType.string, values: ['feature1'] },
|
||||
{ name: 'value', type: FieldType.number, values: [10] },
|
||||
],
|
||||
});
|
||||
|
||||
updateFeaturePropertiesForTooltip(source, frame, undefined, idToIdx);
|
||||
|
||||
expect(forEachFeatureSpy).not.toHaveBeenCalled();
|
||||
expect(idToIdx.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should return early when idField is empty string', () => {
|
||||
const frame = createDataFrame({
|
||||
fields: [
|
||||
{ name: 'id', type: FieldType.string, values: ['feature1'] },
|
||||
{ name: 'value', type: FieldType.number, values: [10] },
|
||||
],
|
||||
});
|
||||
|
||||
updateFeaturePropertiesForTooltip(source, frame, '', idToIdx);
|
||||
|
||||
expect(forEachFeatureSpy).not.toHaveBeenCalled();
|
||||
expect(idToIdx.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle when idField does not exist in frame', () => {
|
||||
const frame = createDataFrame({
|
||||
fields: [
|
||||
{ name: 'different_field', type: FieldType.string, values: ['feature1'] },
|
||||
{ name: 'value', type: FieldType.number, values: [10] },
|
||||
],
|
||||
});
|
||||
|
||||
updateFeaturePropertiesForTooltip(source, frame, 'nonexistent_field', idToIdx);
|
||||
|
||||
expect(forEachFeatureSpy).not.toHaveBeenCalled();
|
||||
expect(idToIdx.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should preserve existing feature properties while adding tooltip properties', () => {
|
||||
// Set some existing properties on the feature
|
||||
mockFeature1.set('existingProperty', 'existing value');
|
||||
mockFeature1.set('anotherProperty', 42);
|
||||
|
||||
const frame = createDataFrame({
|
||||
fields: [
|
||||
{ name: 'id', type: FieldType.string, values: ['feature1'] },
|
||||
{ name: 'value', type: FieldType.number, values: [10] },
|
||||
],
|
||||
});
|
||||
|
||||
updateFeaturePropertiesForTooltip(source, frame, 'id', idToIdx);
|
||||
|
||||
// Check that existing properties are preserved
|
||||
expect(mockFeature1.get('existingProperty')).toBe('existing value');
|
||||
expect(mockFeature1.get('anotherProperty')).toBe(42);
|
||||
|
||||
// Check that tooltip properties are added
|
||||
expect(mockFeature1.get('frame')).toBe(frame);
|
||||
expect(mockFeature1.get('rowIndex')).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
GrafanaTheme2,
|
||||
PluginState,
|
||||
EventBus,
|
||||
DataFrame,
|
||||
} from '@grafana/data';
|
||||
import { ComparisonOperation } from '@grafana/schema';
|
||||
import { findField } from 'app/features/dimensions/utils';
|
||||
@@ -65,6 +66,14 @@ export const DEFAULT_STYLE_RULE: FeatureStyleConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
// Default configuration with tooltip support enabled
|
||||
export const defaultDynamicGeoJSONConfig: MapLayerOptions<DynamicGeoJSONMapperConfig> = {
|
||||
type: 'dynamic-geojson',
|
||||
name: 'Dynamic GeoJSON',
|
||||
config: defaultOptions,
|
||||
tooltip: true,
|
||||
};
|
||||
|
||||
export const dynamicGeoJSONLayer: MapLayerRegistryItem<DynamicGeoJSONMapperConfig> = {
|
||||
id: 'dynamic-geojson',
|
||||
name: 'Dynamic GeoJSON',
|
||||
@@ -88,11 +97,20 @@ export const dynamicGeoJSONLayer: MapLayerRegistryItem<DynamicGeoJSONMapperConfi
|
||||
|
||||
const features = new ReplaySubject<FeatureLike[]>();
|
||||
|
||||
// Function to update feature properties for tooltip support
|
||||
const updateFeatureProperties = (frame?: DataFrame) => {
|
||||
updateFeaturePropertiesForTooltip(source, frame, config.idField, idToIdx);
|
||||
};
|
||||
|
||||
const key = source.on('change', () => {
|
||||
//one geojson loads
|
||||
if (source.getState() === 'ready') {
|
||||
unByKey(key);
|
||||
features.next(source.getFeatures());
|
||||
// Apply current data to newly loaded features
|
||||
if (currentFrame) {
|
||||
updateFeatureProperties(currentFrame);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -117,14 +135,16 @@ export const dynamicGeoJSONLayer: MapLayerRegistryItem<DynamicGeoJSONMapperConfi
|
||||
|
||||
const style = await getStyleConfigState(config.style);
|
||||
const idToIdx = new Map<string, number>();
|
||||
let currentFrame: DataFrame | undefined = undefined;
|
||||
|
||||
const vectorLayer = new VectorImage({
|
||||
source,
|
||||
style: (feature: FeatureLike) => {
|
||||
const idx = idToIdx.get(feature.getId() as string);
|
||||
const featureId = feature.getId();
|
||||
const idx = featureId != null ? idToIdx.get(String(featureId)) : undefined;
|
||||
const dims = style.dims;
|
||||
|
||||
if (idx && dims) {
|
||||
if (idx !== undefined && dims) {
|
||||
return new Style({
|
||||
fill: new Fill({ color: dims.color?.get(idx) }),
|
||||
stroke: new Stroke({ color: style.base.color, width: style.base.lineWidth ?? 1 }),
|
||||
@@ -174,12 +194,12 @@ export const dynamicGeoJSONLayer: MapLayerRegistryItem<DynamicGeoJSONMapperConfi
|
||||
update: (data: PanelData) => {
|
||||
const frame = data.series[0];
|
||||
if (frame) {
|
||||
const field = findField(frame, config.idField);
|
||||
if (field) {
|
||||
idToIdx.clear();
|
||||
field.values.forEach((v, i) => idToIdx.set(v, i));
|
||||
}
|
||||
|
||||
currentFrame = frame;
|
||||
|
||||
// Update feature properties for tooltip support
|
||||
updateFeatureProperties(frame);
|
||||
|
||||
// Update style dimensions for data-driven styling
|
||||
style.dims = getStyleDimension(frame, style, theme, config.dataStyle);
|
||||
}
|
||||
vectorLayer.changed();
|
||||
@@ -232,3 +252,39 @@ export const dynamicGeoJSONLayer: MapLayerRegistryItem<DynamicGeoJSONMapperConfi
|
||||
},
|
||||
defaultOptions,
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to update feature properties for tooltip support
|
||||
* @param source - OpenLayers vector source containing features
|
||||
* @param frame - DataFrame containing the data
|
||||
* @param idField - Field name to use for matching feature IDs
|
||||
* @param idToIdx - Map to store ID to row index mappings
|
||||
*/
|
||||
export function updateFeaturePropertiesForTooltip(
|
||||
source: VectorSource,
|
||||
frame: DataFrame | undefined,
|
||||
idField: string | undefined,
|
||||
idToIdx: Map<string, number>
|
||||
): void {
|
||||
if (!frame || !idField) {
|
||||
return;
|
||||
}
|
||||
|
||||
const field = findField(frame, idField);
|
||||
if (field) {
|
||||
idToIdx.clear();
|
||||
field.values.forEach((v, i) => idToIdx.set(String(v), i));
|
||||
|
||||
source.forEachFeature((feature) => {
|
||||
const featureId = feature.getId();
|
||||
if (featureId != null) {
|
||||
const rowIndex = idToIdx.get(String(featureId));
|
||||
if (rowIndex !== undefined) {
|
||||
// Set tooltip properties without overwriting existing GeoJSON properties
|
||||
feature.set('frame', frame);
|
||||
feature.set('rowIndex', rowIndex);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user