diff --git a/.betterer.results b/.betterer.results index 4b110d17c26..f43114a1877 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"] ], diff --git a/public/app/plugins/panel/geomap/layers/data/geojsonDynamic.test.ts b/public/app/plugins/panel/geomap/layers/data/geojsonDynamic.test.ts new file mode 100644 index 00000000000..613f9983fbf --- /dev/null +++ b/public/app/plugins/panel/geomap/layers/data/geojsonDynamic.test.ts @@ -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; + 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); + }); + }); +}); diff --git a/public/app/plugins/panel/geomap/layers/data/geojsonDynamic.ts b/public/app/plugins/panel/geomap/layers/data/geojsonDynamic.ts index b09aa0a2576..53bab286a52 100644 --- a/public/app/plugins/panel/geomap/layers/data/geojsonDynamic.ts +++ b/public/app/plugins/panel/geomap/layers/data/geojsonDynamic.ts @@ -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 = { + type: 'dynamic-geojson', + name: 'Dynamic GeoJSON', + config: defaultOptions, + tooltip: true, +}; + export const dynamicGeoJSONLayer: MapLayerRegistryItem = { id: 'dynamic-geojson', name: 'Dynamic GeoJSON', @@ -88,11 +97,20 @@ export const dynamicGeoJSONLayer: MapLayerRegistryItem(); + // 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(); + 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 { 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 +): 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); + } + } + }); + } +}