diff --git a/public/model/documentLayerFunctions.ts b/public/model/documentLayerFunctions.ts index befcf5b5..0dfce730 100644 --- a/public/model/documentLayerFunctions.ts +++ b/public/model/documentLayerFunctions.ts @@ -8,6 +8,14 @@ import { parse } from 'wellknown'; import { DocumentLayerSpecification } from './mapLayerType'; import { convertGeoPointToGeoJSON, isGeoJSON } from '../utils/geo_formater'; import { getMaplibreBeforeLayerId, layerExistInMbSource } from './layersFunctions'; +import { + addCircleLayer, + addLineLayer, + addPolygonLayer, + updateCircleLayer, + updateLineLayer, + updatePolygonLayer, +} from './map/layer_operations'; interface MaplibreRef { current: Maplibre | null; @@ -23,23 +31,6 @@ const openSearchGeoJSONMap = new Map([ ['geometrycollection', 'GeometryCollection'], ]); -const buildLayerSuffix = (layerId: string, mapLibreType: string) => { - if (mapLibreType.toLowerCase() === 'circle') { - return layerId; - } - if (mapLibreType.toLowerCase() === 'line') { - return layerId + '-line'; - } - if (mapLibreType.toLowerCase() === 'fill') { - return layerId + '-fill'; - } - if (mapLibreType.toLowerCase() === 'fill-outline') { - return layerId + '-outline'; - } - // if unknown type is found, use layerId as default - return layerId; -}; - const getFieldValue = (data: any, name: string) => { if (!name) { return null; @@ -128,226 +119,62 @@ const addNewLayer = ( beforeLayerId: string | undefined ) => { const maplibreInstance = maplibreRef.current; + if (!maplibreInstance) { + return; + } const mbLayerBeforeId = getMaplibreBeforeLayerId(layerConfig, maplibreRef, beforeLayerId); - const addLineLayer = ( - documentLayerConfig: DocumentLayerSpecification, - beforeId: string | undefined - ) => { - const lineLayerId = buildLayerSuffix(documentLayerConfig.id, 'line'); - maplibreInstance?.addLayer( - { - id: lineLayerId, - type: 'line', - source: documentLayerConfig.id, - filter: ['==', '$type', 'LineString'], - paint: { - 'line-color': documentLayerConfig.style?.fillColor, - 'line-opacity': documentLayerConfig.opacity / 100, - 'line-width': documentLayerConfig.style?.borderThickness, - }, - }, - beforeId - ); - maplibreInstance?.setLayoutProperty(lineLayerId, 'visibility', documentLayerConfig.visibility); - maplibreInstance?.setLayerZoomRange( - lineLayerId, - documentLayerConfig.zoomRange[0], - documentLayerConfig.zoomRange[1] - ); - }; - - const addCircleLayer = ( - documentLayerConfig: DocumentLayerSpecification, - beforeId: string | undefined - ) => { - const circleLayerId = buildLayerSuffix(documentLayerConfig.id, 'circle'); - maplibreInstance?.addLayer( - { - id: circleLayerId, - type: 'circle', - source: layerConfig.id, - filter: ['==', '$type', 'Point'], - paint: { - 'circle-radius': documentLayerConfig.style?.markerSize, - 'circle-color': documentLayerConfig.style?.fillColor, - 'circle-opacity': documentLayerConfig.opacity / 100, - 'circle-stroke-width': documentLayerConfig.style?.borderThickness, - 'circle-stroke-color': documentLayerConfig.style?.borderColor, - }, - }, - beforeId - ); - maplibreInstance?.setLayoutProperty( - circleLayerId, - 'visibility', - documentLayerConfig.visibility - ); - maplibreInstance?.setLayerZoomRange( - circleLayerId, - documentLayerConfig.zoomRange[0], - documentLayerConfig.zoomRange[1] - ); - }; - - const addFillLayer = ( - documentLayerConfig: DocumentLayerSpecification, - beforeId: string | undefined - ) => { - const fillLayerId = buildLayerSuffix(documentLayerConfig.id, 'fill'); - maplibreInstance?.addLayer( + const source = getLayerSource(data, layerConfig); + maplibreInstance.addSource(layerConfig.id, { + type: 'geojson', + data: source, + }); + addCircleLayer( + maplibreInstance, + { + fillColor: layerConfig.style?.fillColor, + maxZoom: layerConfig.zoomRange[1], + minZoom: layerConfig.zoomRange[0], + opacity: layerConfig.opacity, + outlineColor: layerConfig.style?.borderColor, + radius: layerConfig.style?.markerSize, + sourceId: layerConfig.id, + visibility: layerConfig.visibility, + width: layerConfig.style?.borderThickness, + }, + mbLayerBeforeId + ); + const geoFieldType = getGeoFieldType(layerConfig); + if (geoFieldType === 'geo_shape') { + addLineLayer( + maplibreInstance, { - id: fillLayerId, - type: 'fill', - source: layerConfig.id, - filter: ['==', '$type', 'Polygon'], - paint: { - 'fill-color': documentLayerConfig.style?.fillColor, - 'fill-opacity': documentLayerConfig.opacity / 100, - }, + width: layerConfig.style?.borderThickness, + color: layerConfig.style?.fillColor, + maxZoom: layerConfig.zoomRange[1], + minZoom: layerConfig.zoomRange[0], + opacity: layerConfig.opacity, + sourceId: layerConfig.id, + visibility: layerConfig.visibility, }, - beforeId - ); - maplibreInstance?.setLayoutProperty(fillLayerId, 'visibility', documentLayerConfig.visibility); - maplibreInstance?.setLayerZoomRange( - fillLayerId, - documentLayerConfig.zoomRange[0], - documentLayerConfig.zoomRange[1] + mbLayerBeforeId ); - // Due to limitations on WebGL, fill can't render outlines with width wider than 1, - // so we have to create another style layer with type=line to apply width. - const outlineId = buildLayerSuffix(documentLayerConfig.id, 'fill-outline'); - maplibreInstance?.addLayer( + addPolygonLayer( + maplibreInstance, { - id: outlineId, - type: 'line', - source: layerConfig.id, - filter: ['==', '$type', 'Polygon'], - paint: { - 'line-color': layerConfig.style?.borderColor, - 'line-opacity': layerConfig.opacity / 100, - 'line-width': layerConfig.style?.borderThickness, - }, + width: layerConfig.style?.borderThickness, + fillColor: layerConfig.style?.fillColor, + maxZoom: layerConfig.zoomRange[1], + minZoom: layerConfig.zoomRange[0], + opacity: layerConfig.opacity, + sourceId: layerConfig.id, + outlineColor: layerConfig.style?.borderColor, + visibility: layerConfig.visibility, }, - beforeId + mbLayerBeforeId ); - maplibreInstance?.setLayoutProperty(outlineId, 'visibility', layerConfig.visibility); - maplibreInstance?.setLayerZoomRange( - outlineId, - documentLayerConfig.zoomRange[0], - documentLayerConfig.zoomRange[1] - ); - }; - - if (maplibreInstance) { - const source = getLayerSource(data, layerConfig); - maplibreInstance.addSource(layerConfig.id, { - type: 'geojson', - data: source, - }); - addCircleLayer(layerConfig, mbLayerBeforeId); - const geoFieldType = getGeoFieldType(layerConfig); - if (geoFieldType === 'geo_shape') { - addLineLayer(layerConfig, mbLayerBeforeId); - addFillLayer(layerConfig, mbLayerBeforeId); - } } }; -const updateCircleLayer = ( - maplibreInstance: Maplibre, - documentLayerConfig: DocumentLayerSpecification -) => { - const circleLayerId = buildLayerSuffix(documentLayerConfig.id, 'circle'); - const circleLayerStyle = documentLayerConfig.style; - maplibreInstance?.setLayerZoomRange( - circleLayerId, - documentLayerConfig.zoomRange[0], - documentLayerConfig.zoomRange[1] - ); - maplibreInstance?.setPaintProperty( - circleLayerId, - 'circle-opacity', - documentLayerConfig.opacity / 100 - ); - maplibreInstance?.setPaintProperty(circleLayerId, 'circle-color', circleLayerStyle?.fillColor); - maplibreInstance?.setPaintProperty( - circleLayerId, - 'circle-stroke-color', - circleLayerStyle?.borderColor - ); - maplibreInstance?.setPaintProperty( - circleLayerId, - 'circle-stroke-width', - circleLayerStyle?.borderThickness - ); - maplibreInstance?.setPaintProperty(circleLayerId, 'circle-radius', circleLayerStyle?.markerSize); -}; - -const updateLineLayer = ( - maplibreInstance: Maplibre, - documentLayerConfig: DocumentLayerSpecification -) => { - const lineLayerId = buildLayerSuffix(documentLayerConfig.id, 'line'); - maplibreInstance?.setLayerZoomRange( - lineLayerId, - documentLayerConfig.zoomRange[0], - documentLayerConfig.zoomRange[1] - ); - maplibreInstance?.setPaintProperty( - lineLayerId, - 'line-opacity', - documentLayerConfig.opacity / 100 - ); - maplibreInstance?.setPaintProperty( - lineLayerId, - 'line-color', - documentLayerConfig.style?.fillColor - ); - maplibreInstance?.setPaintProperty( - lineLayerId, - 'line-width', - documentLayerConfig.style?.borderThickness - ); -}; - -const updateFillLayer = ( - maplibreInstance: Maplibre, - documentLayerConfig: DocumentLayerSpecification -) => { - const fillLayerId = buildLayerSuffix(documentLayerConfig.id, 'fill'); - maplibreInstance?.setLayerZoomRange( - fillLayerId, - documentLayerConfig.zoomRange[0], - documentLayerConfig.zoomRange[1] - ); - maplibreInstance?.setPaintProperty( - fillLayerId, - 'fill-opacity', - documentLayerConfig.opacity / 100 - ); - maplibreInstance?.setPaintProperty( - fillLayerId, - 'fill-color', - documentLayerConfig.style?.fillColor - ); - maplibreInstance?.setPaintProperty( - fillLayerId, - 'fill-outline-color', - documentLayerConfig.style?.borderColor - ); - const outlineLayerId = buildLayerSuffix(documentLayerConfig.id, 'fill-outline'); - maplibreInstance?.setPaintProperty( - outlineLayerId, - 'line-color', - documentLayerConfig.style?.borderColor - ); - maplibreInstance?.setPaintProperty( - outlineLayerId, - 'line-width', - documentLayerConfig.style?.borderThickness - ); -}; - const updateLayerConfig = ( layerConfig: DocumentLayerSpecification, maplibreRef: MaplibreRef, @@ -360,11 +187,38 @@ const updateLayerConfig = ( // @ts-ignore dataSource.setData(getLayerSource(data, layerConfig)); } - updateCircleLayer(maplibreInstance, layerConfig); + updateCircleLayer(maplibreInstance, { + fillColor: layerConfig.style?.fillColor, + maxZoom: layerConfig.zoomRange[1], + minZoom: layerConfig.zoomRange[0], + opacity: layerConfig.opacity, + outlineColor: layerConfig.style?.borderColor, + radius: layerConfig.style?.markerSize, + sourceId: layerConfig.id, + visibility: layerConfig.visibility, + width: layerConfig.style?.borderThickness, + }); const geoFieldType = getGeoFieldType(layerConfig); if (geoFieldType === 'geo_shape') { - updateLineLayer(maplibreInstance, layerConfig); - updateFillLayer(maplibreInstance, layerConfig); + updateLineLayer(maplibreInstance, { + width: layerConfig.style?.borderThickness, + color: layerConfig.style?.fillColor, + maxZoom: layerConfig.zoomRange[1], + minZoom: layerConfig.zoomRange[0], + opacity: layerConfig.opacity, + sourceId: layerConfig.id, + visibility: layerConfig.visibility, + }); + updatePolygonLayer(maplibreInstance, { + width: layerConfig.style?.borderThickness, + fillColor: layerConfig.style?.fillColor, + maxZoom: layerConfig.zoomRange[1], + minZoom: layerConfig.zoomRange[0], + opacity: layerConfig.opacity, + sourceId: layerConfig.id, + outlineColor: layerConfig.style?.borderColor, + visibility: layerConfig.visibility, + }); } } }; diff --git a/public/model/map/__mocks__/layer.ts b/public/model/map/__mocks__/layer.ts new file mode 100644 index 00000000..1f307ff3 --- /dev/null +++ b/public/model/map/__mocks__/layer.ts @@ -0,0 +1,25 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export class MockLayer { + private layerProperties: Map = new Map(); + + constructor(id: string) { + this.layerProperties.set('id', id); + } + + public setProperty(name: string, value: any): this { + this.layerProperties.set(name, value); + return this; + } + + public getProperty(name: string): any { + return this.layerProperties.get(name); + } + + public hasProperty(name: string): boolean { + return this.layerProperties.has(name); + } +} diff --git a/public/model/map/__mocks__/map.ts b/public/model/map/__mocks__/map.ts new file mode 100644 index 00000000..cf89df85 --- /dev/null +++ b/public/model/map/__mocks__/map.ts @@ -0,0 +1,54 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { LayerSpecification } from 'maplibre-gl'; +import { MockLayer } from './layer'; + +export class MockMaplibreMap { + public _layers: MockLayer[]; + + constructor() { + this._layers = new Array(); + } + + getLayer(id: string): MockLayer[] { + return this._layers.filter((layer) => (layer.getProperty('id') as string).includes(id)); + } + + public setLayerZoomRange(layerId: string, minZoom: number, maxZoom: number) { + this.setProperty(layerId, 'minZoom', minZoom); + this.setProperty(layerId, 'maxZoom', maxZoom); + } + + public setLayoutProperty(layerId: string, property: string, value: any) { + this.setProperty(layerId, property, value); + } + + public setPaintProperty(layerId: string, property: string, value: any) { + this.setProperty(layerId, property, value); + } + + public setProperty(layerId: string, property: string, value: any) { + this.getLayer(layerId)?.forEach((layer) => { + layer.setProperty(property, value); + }); + } + + addLayer(layerSpec: LayerSpecification, beforeId?: string) { + const layer: MockLayer = new MockLayer(layerSpec.id); + Object.keys(layerSpec).forEach((key) => { + // @ts-ignore + layer.setProperty(key, layerSpec[key]); + }); + if (!beforeId) { + this._layers.push(layer); + return; + } + const beforeLayerIndex = this._layers.findIndex((l) => { + return l.getProperty('id') === beforeId; + }); + this._layers.splice(beforeLayerIndex, 0, layer); + } +} diff --git a/public/model/map/layer_operations.test.ts b/public/model/map/layer_operations.test.ts new file mode 100644 index 00000000..779fdb4f --- /dev/null +++ b/public/model/map/layer_operations.test.ts @@ -0,0 +1,279 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { + addCircleLayer, + addLineLayer, + addPolygonLayer, + updateCircleLayer, + updateLineLayer, + updatePolygonLayer, +} from './layer_operations'; +import { Map as Maplibre } from 'maplibre-gl'; +import { MockMaplibreMap } from './__mocks__/map'; + +describe('Circle layer', () => { + it('add new circle layer', () => { + const mockMap = new MockMaplibreMap(); + const sourceId: string = 'geojson-source'; + const expectedLayerId: string = sourceId + '-circle'; + expect(mockMap.getLayer(expectedLayerId).length).toBe(0); + expect( + addCircleLayer((mockMap as unknown) as Maplibre, { + maxZoom: 10, + minZoom: 2, + opacity: 60, + outlineColor: 'green', + radius: 10, + sourceId, + visibility: 'visible', + width: 2, + fillColor: 'red', + }) + ).toBe(expectedLayerId); + expect(mockMap.getLayer(sourceId).length).toBe(1); + + const addedLayer = mockMap.getLayer(sourceId)[0]; + + expect(addedLayer.getProperty('id')).toBe(expectedLayerId); + expect(addedLayer.getProperty('visibility')).toBe('visible'); + expect(addedLayer.getProperty('source')).toBe(sourceId); + expect(addedLayer.getProperty('type')).toBe('circle'); + expect(addedLayer.getProperty('filter')).toEqual(['==', '$type', 'Point']); + expect(addedLayer.getProperty('minZoom')).toBe(2); + expect(addedLayer.getProperty('maxZoom')).toBe(10); + expect(addedLayer.getProperty('circle-opacity')).toBe(0.6); + expect(addedLayer.getProperty('circle-color')).toBe('red'); + expect(addedLayer.getProperty('circle-stroke-color')).toBe('green'); + expect(addedLayer.getProperty('circle-stroke-width')).toBe(2); + expect(addedLayer.getProperty('circle-radius')).toBe(10); + }); + + it('update circle layer', () => { + const mockMap = new MockMaplibreMap(); + const sourceId: string = 'geojson-source'; + + // add layer first + const addedLayerId: string = addCircleLayer((mockMap as unknown) as Maplibre, { + maxZoom: 10, + minZoom: 2, + opacity: 60, + outlineColor: 'green', + radius: 10, + sourceId, + visibility: 'visible', + width: 2, + fillColor: 'red', + }); + expect( + updateCircleLayer((mockMap as unknown) as Maplibre, { + maxZoom: 12, + minZoom: 4, + opacity: 80, + outlineColor: 'yellow', + radius: 8, + sourceId, + visibility: 'none', + width: 7, + fillColor: 'blue', + }) + ).toBe(addedLayerId); + expect(mockMap.getLayer(addedLayerId).length).toBe(1); + + const updatedLayer = mockMap.getLayer(addedLayerId)[0]; + expect(updatedLayer.getProperty('id')).toBe(addedLayerId); + expect(updatedLayer.getProperty('visibility')).toBe('none'); + expect(updatedLayer.getProperty('source')).toBe(sourceId); + expect(updatedLayer.getProperty('type')).toBe('circle'); + expect(updatedLayer.getProperty('filter')).toEqual(['==', '$type', 'Point']); + expect(updatedLayer.getProperty('minZoom')).toBe(4); + expect(updatedLayer.getProperty('maxZoom')).toBe(12); + expect(updatedLayer.getProperty('circle-opacity')).toBe(0.8); + expect(updatedLayer.getProperty('circle-color')).toBe('blue'); + expect(updatedLayer.getProperty('circle-stroke-color')).toBe('yellow'); + expect(updatedLayer.getProperty('circle-stroke-width')).toBe(7); + expect(updatedLayer.getProperty('circle-radius')).toBe(8); + }); +}); + +describe('Line layer', () => { + it('add new Line layer', () => { + const mockMap = new MockMaplibreMap(); + const sourceId: string = 'geojson-source'; + const expectedLayerId: string = sourceId + '-line'; + expect(mockMap.getLayer(expectedLayerId).length).toBe(0); + expect( + addLineLayer((mockMap as unknown) as Maplibre, { + color: 'red', + maxZoom: 10, + minZoom: 2, + opacity: 60, + sourceId, + visibility: 'visible', + width: 2, + }) + ).toBe(expectedLayerId); + expect(mockMap.getLayer(sourceId).length).toBe(1); + const addedLayer = mockMap.getLayer(sourceId)[0]; + expect(addedLayer.getProperty('id')).toBe(expectedLayerId); + expect(addedLayer.getProperty('visibility')).toBe('visible'); + expect(addedLayer.getProperty('source')).toBe(sourceId); + expect(addedLayer.getProperty('type')).toBe('line'); + expect(addedLayer.getProperty('filter')).toEqual(['==', '$type', 'LineString']); + expect(addedLayer.getProperty('minZoom')).toBe(2); + expect(addedLayer.getProperty('maxZoom')).toBe(10); + expect(addedLayer.getProperty('line-opacity')).toBe(0.6); + expect(addedLayer.getProperty('line-color')).toBe('red'); + expect(addedLayer.getProperty('line-width')).toBe(2); + }); + + it('update line layer', () => { + const mockMap = new MockMaplibreMap(); + const sourceId: string = 'geojson-source'; + + // add layer first + const addedLineLayerId: string = addLineLayer((mockMap as unknown) as Maplibre, { + color: 'red', + maxZoom: 10, + minZoom: 2, + opacity: 60, + sourceId, + visibility: 'visible', + width: 2, + }); + expect( + updateLineLayer((mockMap as unknown) as Maplibre, { + color: 'blue', + maxZoom: 12, + minZoom: 4, + opacity: 80, + sourceId, + visibility: 'none', + width: 12, + }) + ).toBe(addedLineLayerId); + expect(mockMap.getLayer(addedLineLayerId).length).toBe(1); + + const updatedLayer = mockMap.getLayer(addedLineLayerId)[0]; + expect(updatedLayer.getProperty('id')).toBe(addedLineLayerId); + expect(updatedLayer.getProperty('visibility')).toBe('none'); + expect(updatedLayer.getProperty('source')).toBe(sourceId); + expect(updatedLayer.getProperty('type')).toBe('line'); + expect(updatedLayer.getProperty('filter')).toEqual(['==', '$type', 'LineString']); + expect(updatedLayer.getProperty('minZoom')).toBe(4); + expect(updatedLayer.getProperty('maxZoom')).toBe(12); + expect(updatedLayer.getProperty('line-opacity')).toBe(0.8); + expect(updatedLayer.getProperty('line-color')).toBe('blue'); + expect(updatedLayer.getProperty('line-width')).toBe(12); + }); +}); + +describe('Polygon layer', () => { + it('add new polygon layer', () => { + const mockMap = new MockMaplibreMap(); + const sourceId: string = 'geojson-source'; + const expectedFillLayerId = sourceId + '-fill'; + const expectedOutlineLayerId = expectedFillLayerId + '-outline'; + expect(mockMap.getLayer(expectedFillLayerId).length).toBe(0); + expect(mockMap.getLayer(expectedOutlineLayerId).length).toBe(0); + addPolygonLayer((mockMap as unknown) as Maplibre, { + maxZoom: 10, + minZoom: 2, + opacity: 60, + outlineColor: 'green', + sourceId, + visibility: 'visible', + width: 2, + fillColor: 'red', + }); + expect(mockMap.getLayer(sourceId).length).toBe(2); + + const fillLayer = mockMap + .getLayer(sourceId) + .filter((layer) => layer.getProperty('id').toString().endsWith('-fill'))[0]; + + expect(fillLayer.getProperty('id')).toBe(expectedFillLayerId); + expect(fillLayer.getProperty('visibility')).toBe('visible'); + expect(fillLayer.getProperty('source')).toBe(sourceId); + expect(fillLayer.getProperty('type')).toBe('fill'); + expect(fillLayer.getProperty('filter')).toEqual(['==', '$type', 'Polygon']); + expect(fillLayer.getProperty('minZoom')).toBe(2); + expect(fillLayer.getProperty('maxZoom')).toBe(10); + expect(fillLayer.getProperty('fill-opacity')).toBe(0.6); + expect(fillLayer.getProperty('fill-color')).toBe('red'); + const outlineLayer = mockMap + .getLayer(sourceId) + .filter((layer) => layer.getProperty('id').toString().endsWith('-fill-outline'))[0]; + expect(outlineLayer.getProperty('id')).toBe(expectedOutlineLayerId); + expect(outlineLayer.getProperty('visibility')).toBe('visible'); + expect(outlineLayer.getProperty('source')).toBe(sourceId); + expect(outlineLayer.getProperty('type')).toBe('line'); + expect(outlineLayer.getProperty('filter')).toEqual(['==', '$type', 'Polygon']); + expect(outlineLayer.getProperty('minZoom')).toBe(2); + expect(outlineLayer.getProperty('maxZoom')).toBe(10); + expect(outlineLayer.getProperty('line-opacity')).toBe(0.6); + expect(outlineLayer.getProperty('line-color')).toBe('green'); + expect(outlineLayer.getProperty('line-width')).toBe(2); + }); + + it('update polygon layer', () => { + const mockMap = new MockMaplibreMap(); + const sourceId: string = 'geojson-source'; + + const expectedFillLayerId = sourceId + '-fill'; + const expectedOutlineLayerId = expectedFillLayerId + '-outline'; + // add layer first + addPolygonLayer((mockMap as unknown) as Maplibre, { + maxZoom: 10, + minZoom: 2, + opacity: 60, + outlineColor: 'green', + sourceId, + visibility: 'visible', + width: 2, + fillColor: 'red', + }); + + expect(mockMap.getLayer(sourceId).length).toBe(2); + // update polygon for test + updatePolygonLayer((mockMap as unknown) as Maplibre, { + maxZoom: 12, + minZoom: 4, + opacity: 80, + outlineColor: 'yellow', + sourceId, + visibility: 'none', + width: 7, + fillColor: 'blue', + }); + + expect(mockMap.getLayer(sourceId).length).toBe(2); + const fillLayer = mockMap + .getLayer(sourceId) + .filter((layer) => layer.getProperty('id') === expectedFillLayerId)[0]; + + expect(fillLayer.getProperty('id')).toBe(expectedFillLayerId); + expect(fillLayer.getProperty('visibility')).toBe('none'); + expect(fillLayer.getProperty('source')).toBe(sourceId); + expect(fillLayer.getProperty('type')).toBe('fill'); + expect(fillLayer.getProperty('filter')).toEqual(['==', '$type', 'Polygon']); + expect(fillLayer.getProperty('minZoom')).toBe(4); + expect(fillLayer.getProperty('maxZoom')).toBe(12); + expect(fillLayer.getProperty('fill-opacity')).toBe(0.8); + expect(fillLayer.getProperty('fill-color')).toBe('blue'); + const outlineLayer = mockMap + .getLayer(sourceId) + .filter((layer) => layer.getProperty('id') === expectedOutlineLayerId)[0]; + expect(outlineLayer.getProperty('id')).toBe(expectedOutlineLayerId); + expect(outlineLayer.getProperty('visibility')).toBe('none'); + expect(outlineLayer.getProperty('source')).toBe(sourceId); + expect(outlineLayer.getProperty('type')).toBe('line'); + expect(outlineLayer.getProperty('filter')).toEqual(['==', '$type', 'Polygon']); + expect(outlineLayer.getProperty('minZoom')).toBe(4); + expect(outlineLayer.getProperty('maxZoom')).toBe(12); + expect(outlineLayer.getProperty('line-opacity')).toBe(0.8); + expect(outlineLayer.getProperty('line-color')).toBe('yellow'); + expect(outlineLayer.getProperty('line-width')).toBe(7); + }); +}); diff --git a/public/model/map/layer_operations.ts b/public/model/map/layer_operations.ts new file mode 100644 index 00000000..948f06c1 --- /dev/null +++ b/public/model/map/layer_operations.ts @@ -0,0 +1,180 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { Map as Maplibre } from 'maplibre-gl'; + +export interface LineLayerSpecification { + sourceId: string; + visibility: string; + color: string; + opacity: number; + width: number; + minZoom: number; + maxZoom: number; +} + +export const addLineLayer = ( + map: Maplibre, + specification: LineLayerSpecification, + beforeId?: string +): string => { + const lineLayerId = specification.sourceId + '-line'; + map.addLayer( + { + id: lineLayerId, + type: 'line', + source: specification.sourceId, + filter: ['==', '$type', 'LineString'], + }, + beforeId + ); + return updateLineLayer(map, specification, lineLayerId); +}; + +export const updateLineLayer = ( + map: Maplibre, + specification: LineLayerSpecification, + layerId?: string +): string => { + const lineLayerId = layerId ? layerId : specification.sourceId + '-line'; + map.setPaintProperty(lineLayerId, 'line-opacity', specification.opacity / 100); + map.setPaintProperty(lineLayerId, 'line-color', specification.color); + map.setPaintProperty(lineLayerId, 'line-width', specification.width); + map.setLayoutProperty(lineLayerId, 'visibility', specification.visibility); + map.setLayerZoomRange(lineLayerId, specification.minZoom, specification.maxZoom); + return lineLayerId; +}; + +export interface CircleLayerSpecification { + sourceId: string; + visibility: string; + fillColor: string; + outlineColor: string; + radius: number; + opacity: number; + width: number; + minZoom: number; + maxZoom: number; +} + +export const addCircleLayer = ( + map: Maplibre, + specification: CircleLayerSpecification, + beforeId?: string +): string => { + const circleLayerId = specification.sourceId + '-circle'; + map.addLayer( + { + id: circleLayerId, + type: 'circle', + source: specification.sourceId, + filter: ['==', '$type', 'Point'], + }, + beforeId + ); + return updateCircleLayer(map, specification, circleLayerId); +}; + +export const updateCircleLayer = ( + map: Maplibre, + specification: CircleLayerSpecification, + layerId?: string +): string => { + const circleLayerId = layerId ? layerId : specification.sourceId + '-circle'; + map.setLayoutProperty(circleLayerId, 'visibility', specification.visibility); + map.setLayerZoomRange(circleLayerId, specification.minZoom, specification.maxZoom); + map.setPaintProperty(circleLayerId, 'circle-opacity', specification.opacity / 100); + map.setPaintProperty(circleLayerId, 'circle-color', specification.fillColor); + map.setPaintProperty(circleLayerId, 'circle-stroke-color', specification.outlineColor); + map.setPaintProperty(circleLayerId, 'circle-stroke-width', specification.width); + map.setPaintProperty(circleLayerId, 'circle-stroke-opacity', specification.opacity / 100); + map.setPaintProperty(circleLayerId, 'circle-radius', specification.radius); + return circleLayerId; +}; + +export interface PolygonLayerSpecification { + sourceId: string; + visibility: string; + fillColor: string; + outlineColor: string; + opacity: number; + width: number; + minZoom: number; + maxZoom: number; +} + +export const addPolygonLayer = ( + map: Maplibre, + specification: PolygonLayerSpecification, + beforeId?: string +) => { + const fillLayerId = specification.sourceId + '-fill'; + map.addLayer( + { + id: fillLayerId, + type: 'fill', + source: specification.sourceId, + filter: ['==', '$type', 'Polygon'], + }, + beforeId + ); + updatePolygonFillLayer(map, specification, fillLayerId); + + // Due to limitations on WebGL, fill can't render outlines with width wider than 1, + // so we have to create another style layer with type=line to apply width. + const outlineId = fillLayerId + '-outline'; + map.addLayer( + { + id: outlineId, + type: 'line', + source: specification.sourceId, + filter: ['==', '$type', 'Polygon'], + }, + beforeId + ); + updateLineLayer( + map, + { + width: specification.width, + color: specification.outlineColor, + maxZoom: specification.maxZoom, + minZoom: specification.minZoom, + opacity: specification.opacity, + sourceId: specification.sourceId, + visibility: specification.visibility, + }, + outlineId + ); +}; + +export const updatePolygonLayer = (map: Maplibre, specification: PolygonLayerSpecification) => { + const fillLayerId: string = updatePolygonFillLayer(map, specification); + const outlineLayerId: string = fillLayerId + '-outline'; + updateLineLayer( + map, + { + width: specification.width, + color: specification.outlineColor, + maxZoom: specification.maxZoom, + minZoom: specification.minZoom, + opacity: specification.opacity, + sourceId: specification.sourceId, + visibility: specification.visibility, + }, + outlineLayerId + ); +}; + +const updatePolygonFillLayer = ( + map: Maplibre, + specification: PolygonLayerSpecification, + layerId?: string +): string => { + const fillLayerId = layerId ? layerId : specification.sourceId + '-fill'; + map.setLayoutProperty(fillLayerId, 'visibility', specification.visibility); + map.setLayerZoomRange(fillLayerId, specification.minZoom, specification.maxZoom); + map.setPaintProperty(fillLayerId, 'fill-opacity', specification.opacity / 100); + map.setPaintProperty(fillLayerId, 'fill-color', specification.fillColor); + return fillLayerId; +};