diff --git a/assets/templates/add-polygons.njk b/assets/templates/add-polygons.njk index d8a1bed0e..a3aee3051 100644 --- a/assets/templates/add-polygons.njk +++ b/assets/templates/add-polygons.njk @@ -7,7 +7,7 @@ {% block head %} - + {% endblock %} {% block bodyStart %} @@ -39,7 +39,7 @@ - + - + +
+ + + \ No newline at end of file diff --git a/demo/js/draw.js b/demo/js/draw.js index b4de26938..ec8a919bd 100755 --- a/demo/js/draw.js +++ b/demo/js/draw.js @@ -8,7 +8,7 @@ import maplibreProvider from '/providers/maplibre/src/index.js' import openNamesProvider from '/providers/beta/open-names/src/index.js' // Plugins import mapStylesPlugin from '/plugins/beta/map-styles/src/index.js' -import createDatasetsPlugin from '/plugins/beta/datasets/src/index.js' +import createDatasetsPlugin from '/plugins/datasets/src/index.js' import createDrawPlugin from '/plugins/beta/draw-ml/src/index.js' import scaleBarPlugin from '/plugins/beta/scale-bar/src/index.js' import searchPlugin from '/plugins/search/src/index.js' diff --git a/demo/js/esm.js b/demo/js/esm.js index 6f6be1d3a..c3aa398fd 100644 --- a/demo/js/esm.js +++ b/demo/js/esm.js @@ -7,7 +7,7 @@ import maplibreProvider from '/providers/maplibre/src/index.js' import openNamesProvider from '/providers/beta/open-names/src/index.js' // Plugins import mapStylesPlugin from '/plugins/beta/map-styles/src/index.js' -import createDatasetsPlugin from '/plugins/beta/datasets/dist/esm/index.js' +import createDatasetsPlugin from '/plugins/datasets/dist/esm/index.js' import scaleBarPlugin from '/plugins/beta/scale-bar/src/index.js' import searchPlugin from '/plugins/search/src/index.js' import createInteractPlugin from '/plugins/interact/src/index.js' diff --git a/demo/js/esri-datasets.js b/demo/js/esri-datasets.js new file mode 100644 index 000000000..4e2517826 --- /dev/null +++ b/demo/js/esri-datasets.js @@ -0,0 +1,596 @@ +import InteractiveMap from '../../src/index.js' +import esriProvider from '/providers/beta/esri/src/index.js' +// Plugins +import mapStylesPlugin from '/plugins/beta/map-styles/src/index.js' +import createDatasetsPlugin from '/plugins/datasets/src/index.js' +// Setup +import { vtsMapStyles27700 } from './mapStyles.js' +import { transformGeocodeRequest, transformVtsRequest3857, setupEsriConfig } from './auth.js' + +const nonFloodZoneLight = '#2b8cbe' +const nonFloodZoneDark = '#7fcdbb' +const white = '#ffffff' +const darkTeal = '#12393d' + +const COLOURS = { + // floodExtents: { default: nonFloodZoneLight, dark: nonFloodZoneDark }, + + depthOver2300: { default: '#7f2704', dark: '#238b45' }, + depth2300: { default: '#a63603', dark: '#41ab5d' }, + depth1200: { default: '#d94801', dark: '#74c476' }, + depth900: { default: '#f16913', dark: '#a1d99b' }, + depth600: { default: '#fd8d3c', dark: '#c7e9c0' }, + depth300: { default: '#fdae6b', dark: '#e5f5e0' }, + depth150: { default: '#fdd0a2', dark: '#f7fcf5' }, + + floodZone3: { default: '#003078', dark: '#e5f5e0' }, + floodZone2: { default: '#1d70b8', dark: '#41ab5d' }, + floodZoneClimateChange: { default: '#F4A582', dark: '#BF3D4A' }, + // floodZoneClimateChangeNoData: { default: darkTeal, dark: white }, + + floodDefences: { default: '#f47738', dark: '#f47738' }, + // waterStorageAreas: { default: darkTeal, dark: white }, + // mainRivers: { default: darkTeal, dark: white } +} + +// light tones > 2300 to < 150 +const nonFloodZoneDepthBandsLight = [COLOURS.depthOver2300.default, COLOURS.depth2300.default, COLOURS.depth1200.default, COLOURS.depth900.default, COLOURS.depth600.default, COLOURS.depth300.default, COLOURS.depth150.default] +// GREENS dark tones > 2300 to < 150 +const nonFloodZoneDepthBandsDark = [COLOURS.depthOver2300.dark, COLOURS.depth2300.dark, COLOURS.depth1200.dark, COLOURS.depth900.dark, COLOURS.depth600.dark, COLOURS.depth300.dark, COLOURS.depth150.dark] + +const datasetFloodZonesCC = { + id: 'floodzonescc', + label: 'Flood Zones Climate Change', + groupLabel: 'Datasets', + esriGroupId: 'floodzones-group', + tiles: `https://tiles.arcgis.com/tiles/JZM7qJpmv7vJ0Hzx/arcgis/rest/services/Flood_Zones_2_and_3_Rivers_and_Sea_CCP1_NON_PRODUCTION/VectorTileServer`, + showInKey: true, + visible: true, + sourceLayer: 'Flood Zones 2 and 3 Rivers and Sea CCP1', + sublayers: [ + { + id: 'climate-change', + label: 'Climate change (2070 to 2125)', + esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea CCP1/Flood Zones plus climate change/1', + showInKey: true, + visibleWhen: { + menu: { + dataset: ['floodzones'], timeframe: ['climatechange'] + } + }, + style: { + fill: { outdoor: '#F4A582', dark: '#BF3D4A' }, + stroke: 'none' + }, + }, + { + id: 'data-unavailable', + label: 'Climate change data unavailable', + showInKey: true, + visibleWhen: { + menu: { + dataset: ['floodzones'], timeframe: ['climatechange'] + } + }, + style: { // This is used just for the key - so that it renders the pattern correctly. + fillPattern: 'dot', + fillPatternForegroundColor: { outdoor: '#000000', dark: '#ffffff' }, + stroke: { outdoor: '#000000', dark: '#FFFFFF' }, + } + }, + { + id: 'data-unavailable-outline', + showInKey: false, + visibleWhen: { + menu: { dataset: ['floodzones'], timeframe: ['climatechange'] } + }, + style: { + stroke: { outdoor: '#000000', dark: '#FFFFFF' }, + }, + esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea CCP1/Unavailable/0' + }, + { + id: 'data-unavailable-light', + visibleWhen: { + mapStyleId: ['outdoor', 'black-and-white'], + menu: { dataset: ['floodzones'], timeframe: ['climatechange'] } + }, + esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea CCP1/Unavailable/1', + esriUseServerStyle: true, + showInKey: false, + }, + { + id: 'data-unavailable-dark', + visibleWhen: { + menu: { dataset: ['floodzones'], timeframe: ['climatechange'] }, + mapStyleId: ['dark'] + }, + esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea CCP1/Unavailable/2', + esriUseServerStyle: true, + showInKey: false, + } + ] +} + +const datasetFloodZones = { + id: 'floodzones', + label: 'Flood Zones', + groupLabel: 'Datasets', + esriGroupId: 'floodzones-group', + tiles: `https://tiles.arcgis.com/tiles/JZM7qJpmv7vJ0Hzx/arcgis/rest/services/Flood_Zones_2_and_3_Rivers_and_Sea_NON_PRODUCTION/VectorTileServer`, + showInKey: true, + sourceLayer: 'Flood Zones 2 and 3 Rivers and Sea', + visibleWhen: { + menu: { dataset: ['floodzones'] } + }, + sublayers: [ + { + id: 'flood-zone-2', + label: 'Flood Zone 2', + esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea/Flood Zone 2/1', + style: { + fill: { outdoor: '#1d70b8', dark: '#7fcdbb' }, + stroke: 'none' + }, + }, + { + id: 'flood-zone-3', + label: 'Flood Zone 3', + esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea/Flood Zone 3/1', + style: { + fill: { outdoor: '#003078', dark: '#e5f5e0' }, + stroke: 'none' + }, + } + ] +} + +const surfaceWaterDatasetGenerator = ({id, tileName, sourceLayer, timeframe, aep}) => { + const visibleWhenMenu = { dataset: ['surfacewater'], timeframe, aep } + const extentsDataset = { + id: `${id}-extents`, + label: 'Surface Water', + groupLabel: 'Datasets', + tiles: `https://tiles.arcgis.com/tiles/JZM7qJpmv7vJ0Hzx/arcgis/rest/services/${tileName}/VectorTileServer`, + showInKey: true, + sourceLayer, + style: { fill: { outdoor: nonFloodZoneLight, dark: nonFloodZoneDark }, }, + visibleWhen: { menu: visibleWhenMenu }, + sublayers: [ + { + id: 'depthOver2300', + esriStyleLayerId: `${sourceLayer}/>2300mm/1`, + showInKey: false, + visibleWhen: { menu: {...visibleWhenMenu, depth: ['depth150', 'depth300', 'depth600', 'depth900', 'depth1200', 'depth2300', 'depthOver2300'] } }, + }, + { + id: 'depth2300', + esriStyleLayerId: `${sourceLayer}/1200-2300mm/1`, + showInKey: false, + visibleWhen: { menu: {...visibleWhenMenu, depth: ['depth150', 'depth300', 'depth600', 'depth900', 'depth1200', 'depth2300'] } }, + }, + { + id: 'depth1200', + esriStyleLayerId: `${sourceLayer}/900-1200mm/1`, + showInKey: false, + visibleWhen: { menu: {...visibleWhenMenu, depth: ['depth150', 'depth300', 'depth600', 'depth900', 'depth1200'] } }, + }, + { + id: 'depth900', + esriStyleLayerId: `${sourceLayer}/600-900mm/1`, + showInKey: false, + visibleWhen: { menu: {...visibleWhenMenu, depth: ['depth150', 'depth300', 'depth600', 'depth900'] } }, + }, + { + id: 'depth600', + esriStyleLayerId: `${sourceLayer}/300-600mm/1`, + showInKey: false, + visibleWhen: { menu: {...visibleWhenMenu, depth: ['depth150', 'depth300', 'depth600'] } }, + }, + { + id: 'depth300', + esriStyleLayerId: `${sourceLayer}/150-300mm/1`, + showInKey: false, + visibleWhen: { menu: {...visibleWhenMenu, depth: ['depth150', 'depth300'] } }, + }, + { + id: 'depth150', + esriStyleLayerId: `${sourceLayer}/<150mm/1`, + showInKey: false, + visibleWhen: { menu: {...visibleWhenMenu, depth: ['depth150'] } }, + }, + ] + } + + const depthDataset = { + id: `${id}-depths`, + label: 'Surface Water Depth All', + groupLabel: 'Datasets', + tiles: `https://tiles.arcgis.com/tiles/JZM7qJpmv7vJ0Hzx/arcgis/rest/services/${tileName}/VectorTileServer`, + showInKey: true, + sourceLayer, + visibleWhen: { menu: {...visibleWhenMenu, depth: ['depthAll'] } }, + sublayers: [ + { + id: 'depthOver2300', + esriStyleLayerId: `${sourceLayer}/>2300mm/1`, + label: 'Extent over 2300mm', + style: { + fill: { outdoor: nonFloodZoneDepthBandsLight[0], dark: nonFloodZoneDepthBandsDark[0] }, + } + }, + { + id: 'depth2300', + esriStyleLayerId: `${sourceLayer}/1200-2300mm/1`, + label: 'Extent over 1200mm', + style: { + fill: { outdoor: nonFloodZoneDepthBandsLight[1], dark: nonFloodZoneDepthBandsDark[1] }, + } + }, + { + id: 'depth1200', + esriStyleLayerId: `${sourceLayer}/900-1200mm/1`, + label: 'Extent over 900mm', + style: { + fill: { outdoor: nonFloodZoneDepthBandsLight[2], dark: nonFloodZoneDepthBandsDark[2] }, + } + }, + { + id: 'depth900', + esriStyleLayerId: `${sourceLayer}/600-900mm/1`, + label: 'Extent over 600mm', + style: { + fill: { outdoor: nonFloodZoneDepthBandsLight[3], dark: nonFloodZoneDepthBandsDark[3] }, + } + }, + { + id: 'depth600', + esriStyleLayerId: `${sourceLayer}/300-600mm/1`, + label: 'Extent over 300mm', + style: { + fill: { outdoor: nonFloodZoneDepthBandsLight[4], dark: nonFloodZoneDepthBandsDark[4] }, + } + }, + { + id: 'depth300', + esriStyleLayerId: `${sourceLayer}/150-300mm/1`, + label: 'Extent over 150mm', + style: { + fill: { outdoor: nonFloodZoneDepthBandsLight[5], dark: nonFloodZoneDepthBandsDark[5] }, + } + }, + { + id: 'depth150', + esriStyleLayerId: `${sourceLayer}/<150mm/1`, + label: 'Extent up to 150mm', + style: { + fill: { outdoor: nonFloodZoneDepthBandsLight[6], dark: nonFloodZoneDepthBandsDark[6] }, + } + }, + ] + } + return [extentsDataset, depthDataset] +} + +const surfaceWaterExtentsKey = { + id: 'surfacewater-extents-key', + label: 'Surface Water', + groupLabel: 'Datasets', + showInKey: true, + style: { + stroke: { outdoor: nonFloodZoneLight, dark: nonFloodZoneDark }, + fill: { outdoor: nonFloodZoneLight, dark: nonFloodZoneDark }, + }, + sublayers: [ + { + id: 'key-150', + label: 'Full extend of flooding', + showInKey: true, + visibleWhen: { menu: { dataset: ['surfacewater'], depth: ['depth150'] } } + }, + { + id: 'key-300', + label: 'Extent over 150mm', + showInKey: true, + visibleWhen: { menu: { dataset: ['surfacewater'], depth: ['depth300'] } } + }, + { + id: 'key-600', + label: 'Extent over 300mm', + showInKey: true, + visibleWhen: { menu: { dataset: ['surfacewater'], depth: ['depth600'] } } + }, + { + id: 'key-900', + label: 'Extent over 600mm', + showInKey: true, + visibleWhen: { menu: { dataset: ['surfacewater'], depth: ['depth900'] } } + }, + { + id: 'key-1200', + label: 'Extent over 900mm', + showInKey: true, + visibleWhen: { menu: { dataset: ['surfacewater'], depth: ['depth1200'] } } + }, + { + id: 'key-2300', + label: 'Extent over 1200mm', + showInKey: true, + visibleWhen: { menu: { dataset: ['surfacewater'], depth: ['depth2300'] } } + }, + { + id: 'key-over-2300', + label: 'Extent over 2300mm', + showInKey: true, + visibleWhen: { menu: { dataset: ['surfacewater'], depth: ['depthOver2300'] } } + } + ] +} + +const datasetMainRivers = { + id: 'mainrivers', + label: 'Main Rivers', + groupLabel: 'Map features', + type: 'FeatureService', + tiles: 'https://services1.arcgis.com/JZM7qJpmv7vJ0Hzx/arcgis/rest/services/Statutory_Main_River_Map/FeatureServer', + showInKey: true, + sourceLayer: 'Statutory_Main_River_Map', + visible: false, + style: { + renderer: { + type: 'simple', + symbol: { + type: 'simple-line', + width: '3px', + color: { outdoor: darkTeal, dark: white }, + } + }, + stroke: { outdoor: darkTeal, dark: white }, + strokeWidth: 3 + } +} + +const datasetWaterStorageAreas = { + id: 'waterstorage', + label: 'Water Storage', + groupLabel: 'Map features', + type: 'FeatureService', + tiles: 'https://services1.arcgis.com/JZM7qJpmv7vJ0Hzx/arcgis/rest/services/Flood_Storage_Areas_NON_PRODUCTION/FeatureServer', + showInKey: true, + sourceLayer: 'Flood_Storage_Areas', + visible: false, + style: { + renderer: { + type: 'simple', + symbol: { + type: 'simple-fill', + style: 'diagonal-cross', + color: { outdoor: darkTeal, dark: white }, + outline: { + color: { outdoor: darkTeal, dark: white }, + width: 1 + } + } + }, + stroke: { outdoor: darkTeal, dark: white }, + strokeWidth: 1, + fillPattern: 'diagonal-cross-hatch', + fillPatternForegroundColor: { outdoor: darkTeal, dark: white }, + fillPatternBackgroundColor: 'transparent' + } +} + +const datasetFloodDefences = { + id: 'flooddefence', + label: 'Flood Defence', + groupLabel: 'Map features', + type: 'FeatureService', + tiles: 'https://services1.arcgis.com/JZM7qJpmv7vJ0Hzx/arcgis/rest/services/Defences_NON_PRODUCTION/FeatureServer', + showInKey: true, + sourceLayer: 'Defences', + visible: false, + style: { + renderer: { + type: 'simple', + symbol: { + type: 'simple-line', + width: '3px', + color: '#f47738', + } + }, + stroke: '#f47738', + strokeWidth: 3 + } +} + +const datasets = [ + datasetFloodZonesCC, + datasetFloodZones, + surfaceWaterExtentsKey, + // Surface Water Present Day + ...surfaceWaterDatasetGenerator({ + id: 'surfacewater-presentday-low', + tileName: 'Surface_Water_Spatial_Planning_1_in_1000_Depths_NON_PRODUCTION', + sourceLayer: 'Surface Water Spatial Planning 1 in 1000 Depths', + timeframe: ['presentday'], + aep: ['low'], + }), + ...surfaceWaterDatasetGenerator({ + id: 'surfacewater-presentday-medium', + tileName: 'Surface_Water_Spatial_Planning_1_in_100_Depths_NON_PRODUCTION', + sourceLayer: 'Surface Water Spatial Planning 1 in 100 Depths', + timeframe: ['presentday'], + aep: ['medium'], + }), + ...surfaceWaterDatasetGenerator({ + id: 'surfacewater-presentday-high', + tileName: 'Surface_Water_Spatial_Planning_1_in_30_Depths_NON_PRODUCTION', + sourceLayer: 'Surface Water Spatial Planning 1 in 30 Depths', + timeframe: ['presentday'], + aep: ['high'], + }), + // Surface Water Climate Change + ...surfaceWaterDatasetGenerator({ + id: 'surfacewater-climatechange-low', + tileName: 'Surface_Water_Spatial_Planning_1_in_1000_CCP1_Depths_NON_PRODUCTION', + sourceLayer: 'Surface Water Spatial Planning 1 in 1000 CCP1 Depths', + timeframe: ['climatechange'], + aep: ['low'], + }), + ...surfaceWaterDatasetGenerator({ + id: 'surfacewater-climatechange-medium', + tileName: 'Surface_Water_Spatial_Planning_1_in_100_CCP1_Depths_NON_PRODUCTION', + sourceLayer: 'Surface Water Spatial Planning 1 in 100 CCP1 Depths', + timeframe: ['climatechange'], + aep: ['medium'], + }), + ...surfaceWaterDatasetGenerator({ + id: 'surfacewater-climatechange-high', + tileName: 'Surface_Water_Spatial_Planning_1_in_30_CCP1_Depths_NON_PRODUCTION', + sourceLayer: 'Surface Water Spatial Planning 1 in 30 CCP1 Depths', + timeframe: ['climatechange'], + aep: ['high'], + }), + + datasetWaterStorageAreas, datasetFloodDefences, datasetMainRivers +] + +const menu = [ + { + id: 'dataset', + label: 'Datasets', + urlKey: 'dataset', + visibleWhen: true, + type: 'radio', // 'checkbox' or 'radio' + value: 'floodzones', // this is the default value for the menu, it should be one of the items' id + items: [ + { id: 'floodzones', label: 'Flood zones' }, + { id: 'surfacewater', label: 'Surface water' }, + { id: 'none', label: 'None', }, + ], + }, + { + id: 'timeframe', + label: 'Timeframe', + urlKey: 'dataset', + urlIndex: 1, // eg: surfacewater-presentday-high-depth or floodzones-climatechange + type: 'radio', + visibleWhen: { menu: { dataset: ['floodzones', 'surfacewater'] } }, + value: 'presentday', + items: [ + { id: 'presentday', label: 'Present day' }, + { id: 'climatechange', label: '2070 to 2125', visibleWhen: { menu: { dataset: ['floodzones'] } } }, + { id: 'climatechange', label: '2061 to 2125', visibleWhen: { menu: { dataset: ['surfacewater'] } } }, + ] + }, { + id: 'aep', + label: 'Annual likelihood of flooding', + urlKey: 'dataset', + urlIndex: 2, + type: 'radio', + visibleWhen: { menu: { dataset: ['surfacewater'] } }, + value: 'medium', + items: [ + { id: 'high', label: '1 in 30' }, + { id: 'medium', label: '1 in 100' }, + { id: 'low', label: '1 in 1000' }, + ] + }, { + id: 'depth', + label: 'Depth', + urlKey: 'dataset', + urlIndex: 3, + type: 'radio', + visibleWhen: { menu: { dataset: ['surfacewater'] } }, + subMenu: true, + value: 'depthAll', + items: [ + { id: 'depthAll', label: 'All depths', }, + { id: 'depth150', label: 'Full extent of flooding', }, + { id: 'depth300', label: 'Extent over 150mm', }, + { id: 'depth600', label: 'Extent over 300mm', }, + { id: 'depth900', label: 'Extent over 600mm', }, + { id: 'depth1200', label: 'Extent over 900mm', }, + { id: 'depth2300', label: 'Extent over 1200mm', }, + { id: 'depthOver2300', label: 'Extent over 2300mm', }, + ] + }, { + id: 'features', + groupLabel: 'Map features', + urlKey: 'features', + type: 'checkbox', + visibleWhen: true, + items: [ + { id: 'waterstorage', label: 'Water storage' }, + { id: 'flooddefence', label: 'Flood defence' }, + { id: 'mainrivers', label: 'Main rivers' }, + ] + } +] + +const datasetsPlugin = createDatasetsPlugin({ + manifest: { + panels: [{ + id: 'datasetsLayers', + desktop: { open: true, slot: 'side', width: '280px', dismissible: false}, + tablet: { slot: 'side', width: '280px', modal: true } + }], + buttons: [ + { + id: 'datasetsLayers', + excludeWhen: ({ appState }) => (appState?.breakpoint === 'desktop'), + } + ] + }, + globals: { + opacityMode: 'global', // 'dataset', 'global' or 'multiply' + opacity: 0.75, + visible: true + }, + datasets, + menu +}) + +const interactiveMap = new InteractiveMap('map', { + behaviour: 'mapOnly', + mapProvider: esriProvider({ setupConfig: setupEsriConfig }), + minZoom: 6, + maxZoom: 20, + autoColorScheme: true, + center: [481146,484971], + zoom: 13, + plugins: [ + datasetsPlugin, + mapStylesPlugin({ + mapStyles: vtsMapStyles27700, + manifest: { + buttons: [{ + id: 'mapStyles', + desktop: { slot: 'right-top', showLabel: true } + }], + panels: [ + { + id: 'mapStyles', + desktop: { slot: 'map-styles-button', width: '400px', modal: true } + } + ] + } + }) + ] +}) + +const testGlobalVisibility = () => { + setTimeout(() => datasetsPlugin.setDatasetVisibility(false), 3000) + setTimeout(() => datasetsPlugin.setDatasetVisibility(true), 6000) +} + +const testAddRemoveDataset = () => { + setTimeout(() => datasetsPlugin.removeDataset('floodzonescc'), 1000) + setTimeout(() => datasetsPlugin.removeDataset('floodzones'), 3000) + setTimeout(() => datasetsPlugin.addDataset(datasetFloodZones), 5000) +} + +interactiveMap.on('datasets:ready', function () { + // testGlobalVisibility() + // testAddRemoveDataset() +}) \ No newline at end of file diff --git a/demo/js/farming.js b/demo/js/farming.js index 8d0280cca..0ab1c872b 100755 --- a/demo/js/farming.js +++ b/demo/js/farming.js @@ -8,7 +8,7 @@ import openNamesProvider from '/providers/beta/open-names/src/index.js' // Plugins import useLocationPlugin from '/plugins/beta/use-location/src/index.js' import mapStylesPlugin from '/plugins/beta/map-styles/src/index.js' -import createDatasetsPlugin from '/plugins/beta/datasets/src/index.js' +import createDatasetsPlugin from '/plugins/datasets/src/index.js' import createDrawPlugin from '/plugins/beta/draw-ml/src/index.js' import scaleBarPlugin from '/plugins/beta/scale-bar/src/index.js' import searchPlugin from '/plugins/search/src/index.js' diff --git a/demo/js/gep.js b/demo/js/gep.js index a445bbe13..3fbcf6284 100644 --- a/demo/js/gep.js +++ b/demo/js/gep.js @@ -1,7 +1,7 @@ import InteractiveMap from '../../src/index.js' import { vtsMapStyles27700, apgbAerialStyle } from './mapStyles.js' import { transformGeocodeRequest, transformVtsRequest27700 } from './auth.js' -import '/plugins/beta/datasets/src/datasets.scss' // in a separate repo: import '@defra/interactive-map/plugins/datasets/css' +import '/plugins/datasets/src/datasets.scss' // in a separate repo: import '@defra/interactive-map/plugins/datasets/css' // Providers import openLayersProvider from '/providers/beta/openlayers/src/index.js' import openNamesProvider from '/providers/beta/open-names/src/index.js' diff --git a/demo/js/index.js b/demo/js/index.js index 4cd19d0ce..c3558a8bf 100755 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -1,7 +1,7 @@ // CSS // import '../../dist/css/index.css' // import '/plugins/beta/map-styles/dist/css/index.css' -// import '/plugins/beta/datasets/dist/css/index.css' +// import '/plugins/datasets/dist/css/index.css' // import '/plugins/beta/draw-ml/dist/css/index.css' // import '/plugins/beta/scale-bar/dist/css/index.css' // import '/plugins/search/dist/css/index.css' @@ -18,7 +18,7 @@ import openNamesProvider from '/providers/beta/open-names/src/index.js' // Plugins import useLocationPlugin from '/plugins/beta/use-location/src/index.js' import mapStylesPlugin from '/plugins/beta/map-styles/src/index.js' -import createDatasetsPlugin from '/plugins/beta/datasets/src/index.js' +import createDatasetsPlugin from '/plugins/datasets/src/index.js' // import createDrawPlugin from '/plugins/beta/draw-ml/src/index.js' import scaleBarPlugin from '/plugins/beta/scale-bar/src/index.js' import searchPlugin from '/plugins/search/src/index.js' diff --git a/demo/js/ml-datasets.js b/demo/js/ml-datasets.js index 9a5281b17..5601ea427 100644 --- a/demo/js/ml-datasets.js +++ b/demo/js/ml-datasets.js @@ -7,7 +7,7 @@ import maplibreProvider from '/providers/maplibre/src/index.js' import openNamesProvider from '/providers/beta/open-names/src/index.js' // Plugins import mapStylesPlugin from '/plugins/beta/map-styles/src/index.js' -import createDatasetsPlugin from '/plugins/beta/datasets/src/index.js' +import createDatasetsPlugin from '/plugins/datasets/src/index.js' import scaleBarPlugin from '/plugins/beta/scale-bar/src/index.js' import searchPlugin from '/plugins/search/src/index.js' import createInteractPlugin from '/plugins/interact/src/index.js' @@ -467,15 +467,15 @@ const testSetData = () => { } interactiveMap.on('datasets:ready', function () { - testGetters() - testInvalidApiCalls() - testFeatureVisibility() - testSetOpacity() - testSetStyle() - testVisibility() - testGlobalVisibility() - testRemoveAndAddDataset() - testSetData() + // testGetters() + // testInvalidApiCalls() + // testFeatureVisibility() + // testSetOpacity() + // testSetStyle() + // testVisibility() + // testGlobalVisibility() + // testRemoveAndAddDataset() + // testSetData() }) // Ref to the selected features diff --git a/demo/umd.html b/demo/umd.html index 18d9fd441..6d7554c1b 100644 --- a/demo/umd.html +++ b/demo/umd.html @@ -6,7 +6,7 @@ Interactive Map – UMD sanity check - + @@ -24,7 +24,7 @@

UMD sanity check – datasets plugin

- + diff --git a/docs/plugins/datasets.md b/docs/plugins/datasets.md index f546cbb2b..ec50cfc9a 100644 --- a/docs/plugins/datasets.md +++ b/docs/plugins/datasets.md @@ -33,10 +33,10 @@ const interactiveMap = new InteractiveMap({ ## UMD usage -Copy the entire `plugins/beta/datasets/dist/umd/` directory to `/your-assets-path/plugins/beta/datasets/umd/`. The plugin uses dynamic imports, so all files in the directory must be served from the same location. Then add the script tag: +Copy the entire `plugins/datasets/dist/umd/` directory to `/your-assets-path/plugins/datasets/umd/`. The plugin uses dynamic imports, so all files in the directory must be served from the same location. Then add the script tag: ```html - + ``` ```js @@ -68,7 +68,7 @@ const interactiveMap = new defra.InteractiveMap('map', { > [!NOTE] > **GOV.UK Prototype Kit** — skip the copy step. All files are served automatically. Use this path instead: > ```html -> +> > ``` ## Options diff --git a/govuk-prototype-kit.config.json b/govuk-prototype-kit.config.json index d648d9f3a..196bed9d6 100644 --- a/govuk-prototype-kit.config.json +++ b/govuk-prototype-kit.config.json @@ -62,7 +62,7 @@ "/dist/umd/", "/providers/maplibre/dist", "/providers/beta/open-names/dist", - "/plugins/beta/datasets/dist", + "/plugins/datasets/dist", "/plugins/interact/dist", "/plugins/beta/map-styles/dist", "/plugins/beta/scale-bar/dist", diff --git a/jest.config.mjs b/jest.config.mjs index d6501d5c3..abc8bb503 100755 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -21,6 +21,8 @@ export default { ], testPathIgnorePatterns: ['/src/test-utils.js'], coveragePathIgnorePatterns: [ + '/__mocks__/', + '/__data__/', '/node_modules/', '/public', '/src/index.umd.js', @@ -28,7 +30,7 @@ export default { '/coverage', '/demo', '/src/test-utils.js', - '/plugins/beta/datasets/', + '/plugins/datasets/', '/providers/beta/', '/plugins/beta/draw-es', '/plugins/beta/draw-ml', diff --git a/package.json b/package.json index 3c5a611fd..6d701137f 100755 --- a/package.json +++ b/package.json @@ -45,10 +45,10 @@ "require": "./plugins/interact/dist/umd/index.js" }, "./plugins/datasets": { - "import": "./plugins/beta/datasets/dist/esm/index.js" + "import": "./plugins/datasets/dist/esm/index.js" }, "./plugins/datasets/adapters/maplibre": { - "import": "./plugins/beta/datasets/dist/adapters/maplibre/esm/index.js" + "import": "./plugins/datasets/dist/adapters/maplibre/esm/index.js" }, "./plugins/map-styles": { "import": "./plugins/beta/map-styles/dist/esm/index.js", @@ -68,7 +68,7 @@ "./css": "./dist/css/index.css", "./plugins/draw-ml/css": "./plugins/beta/draw-ml/dist/css/index.css", "./plugins/scale-bar/css": "./plugins/beta/scale-bar/dist/css/index.css", - "./plugins/datasets/css": "./plugins/beta/datasets/dist/css/index.css", + "./plugins/datasets/css": "./plugins/datasets/dist/css/index.css", "./plugins/frame/css": "./plugins/beta/frame/dist/css/index.css", "./plugins/map-styles/css": "./plugins/beta/map-styles/dist/css/index.css", "./plugins/search/css": "./plugins/search/dist/css/index.css", diff --git a/plugins/beta/datasets/src/components/LayersMenu/LayersMenu.jsx b/plugins/beta/datasets/src/components/LayersMenu/LayersMenu.jsx deleted file mode 100644 index d9221cef6..000000000 --- a/plugins/beta/datasets/src/components/LayersMenu/LayersMenu.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react' -import { setDatasetVisibility } from '../../api/setDatasetVisibility.js' -import { LayersMenuCheckbox } from './LayersMenuCheckbox.jsx' -import { LayersMenuGroupWrapper } from './LayersMenuGroupWrapper.jsx' - -export const LayersMenu = ({ pluginState }) => { - const { menu = [] } = pluginState - const handleDatasetChange = (e) => { - const { value, checked } = e.target - setDatasetVisibility({ pluginState }, checked, { datasetId: value }) - } - - const hasGroups = menu.some(item => item.groupLabel) - const containerClass = `im-c-datasets-layers${hasGroups ? ' im-c-datasets-layers--has-groups' : ''}` - return ( -
- {// Each menuGroup - menu.map(menuGroup => - - {// Each menuGroupItem - menuGroup.items.map(menuGroupItem => - - ) - } - ) - } -
- ) -} diff --git a/plugins/datasets/src/adapters/esri/esriLayerAdapter.js b/plugins/datasets/src/adapters/esri/esriLayerAdapter.js new file mode 100644 index 000000000..8f0b6038b --- /dev/null +++ b/plugins/datasets/src/adapters/esri/esriLayerAdapter.js @@ -0,0 +1,248 @@ +import VectorTileLayer from '@arcgis/core/layers/VectorTileLayer.js' +import FeatureLayer from '@arcgis/core/layers/FeatureLayer.js' +import GroupLayer from '@arcgis/core/layers/GroupLayer.js' +import { LayerAdapter } from '../layerAdapter.js' +import { datasetRegistry } from '../../registry/datasetRegistry.js' +import { EsriDataset } from './registry/esriDataset.js' +import { logger } from '../../../../../src/services/logger.js' + +export default class EsriLayerAdapter extends LayerAdapter { + constructor (mapProvider) { + super() + this._mapProvider = mapProvider + this._map = mapProvider.map + + // _mapVisibilityLayers is a map of datasetId to VectorTileLayer or FeatureLayer instances + // it includes stand alone vectorTileLayers and vectorTileLayers that are part of a groupLayer + // but does not include group layers themselves, which are tracked in _groupLayers + this._mapVisibilityLayers = {} + + // _mapOpacityLayers is a map of datasetId to mapLayers where opacity is applied + // it includes featureLayers, vectorTileLayers and groupLayers + // but does not include vectorTileLayers that are part of a groupLayer + this._mapOpacityLayers = {} + + // _groupLayers is a map of esriGroupId to GroupLayer + this._groupLayers = {} + } + + createDataset (datasetDefinition) { + return new EsriDataset(datasetDefinition) + } + + async init () { + const topLevelDatasets = datasetRegistry.topLevelDatasets() + // ensure the datasets are added in order + for (const registryDataset of topLevelDatasets) { + await this._addLayers(registryDataset) + } + + // onMapStyleChange: handles showing and hiding sublayers based on the current mapStyle + // and updating the paint properties of the layers based on the dataset/mapStyle style + await this.onMapStyleChange() + + // Apply opacity to all layers + await this.applyGlobalOpacity() + + // Finally show all layers that are visible based on the dataset/mapStyle visibility + await Promise.all(topLevelDatasets.map(registryDataset => this.applyDatasetVisibility(registryDataset.id))) + } + + _addGroupLayer (esriGroupId) { + // Either adds a new group layer to the map, or returns an existing one if it already exists + if (!this._groupLayers[esriGroupId]) { + const groupLayer = new GroupLayer({ + id: esriGroupId, + opacity: 1, + visible: true + }) + this._groupLayers[esriGroupId] = groupLayer + this._map.add(groupLayer) + } + return this._groupLayers[esriGroupId] + } + + async _addFeatureLayers (registryDataset) { + const featureLayer = new FeatureLayer({ + id: registryDataset.id, + url: registryDataset.tiles, + renderer: registryDataset.renderer, + opacity: 1, + visible: false + }) + this._mapVisibilityLayers[registryDataset.id] = featureLayer + this._mapOpacityLayers[registryDataset.id] = featureLayer + try { + this._map.add(featureLayer) + return featureLayer.when() + } catch (error) { + logger.error(`Error adding FeatureLayer for dataset ${registryDataset.id}:`, error) + } + return null + } + + async _addLayers (registryDataset) { + const { type, esriGroupId, tiles } = registryDataset + if (!tiles) { + return + } + + if (type === 'FeatureService') { + return this._addFeatureLayers(registryDataset) + } + + const vectorTileParent = esriGroupId ? this._addGroupLayer(esriGroupId) : this._map + const vectorTileLayer = new VectorTileLayer({ + id: registryDataset.id, + url: registryDataset.tiles, + opacity: 1, + visible: false + }) + this._mapVisibilityLayers[registryDataset.id] = vectorTileLayer + this._mapOpacityLayers[registryDataset.id] = esriGroupId ? vectorTileParent : vectorTileLayer + vectorTileParent.add(vectorTileLayer) + return vectorTileLayer.when() + } + + async addDataset (datasetId) { + const registryDataset = datasetRegistry.getDataset(datasetId) + if (!registryDataset) { + logger.warn(`addDataset called, but Dataset with id ${datasetId} not found in registry`) + return + } + await this._addLayers(registryDataset) + const { parentId } = registryDataset + const vectorTileLayer = this._mapVisibilityLayers[parentId || datasetId] + this.applyDatasetOpacity(datasetId) + this._applyStyleLayerPaintProperties(registryDataset, vectorTileLayer) + this.applyDatasetVisibility(datasetId) + } + + async removeDataset (datasetId) { + const registryDataset = datasetRegistry.getDataset(datasetId) + if (!registryDataset) { + return + } + const { esriGroupId } = registryDataset + const vectorTileLayer = this._mapVisibilityLayers[datasetId] + // If the dataset is part of a group layer, we need to remove it from the group layer + const groupLayer = esriGroupId ? this._groupLayers[esriGroupId] : null + const vectorTileParent = groupLayer || this._map + + if (vectorTileLayer) { + // Remove the vectorTileLayer from the map or group layer + vectorTileParent.remove(vectorTileLayer) + // And remove the vectorTileLayer from the adapter's internal state + delete this._mapVisibilityLayers[datasetId] + delete this._mapOpacityLayers[datasetId] + } + + // If the group layer has no more sublayers, we need to also remove the group layer from the map + if (groupLayer?.layers.length === 0) { + this._map.remove(groupLayer) + delete this._groupLayers[esriGroupId] + } + } + + _applyRegistryDatasetVisibility (registryDataset) { + // if this is a sublayer, we need to apply the visibility to the vectorTileLayers style sheet + // if this is a top level dataset, we need to apply the visibility to the vectorTileLayer/ groupLayer itself + const { id, isSublayer, visible, parentId } = registryDataset + const vectorTileLayer = this._mapVisibilityLayers[isSublayer ? parentId : id] + if (!vectorTileLayer) { + return + } + + if (isSublayer) { + this._applyStyleLayerVisibility(registryDataset, vectorTileLayer) + // Don't apply the visibility change to the parent, since the parent may have other sublayers that are visible + return + } + if (visible) { + // No need to apply style layer visibility for datasets that are hidden + registryDataset.sublayers.forEach(sublayer => this._applyStyleLayerVisibility(sublayer, vectorTileLayer)) + } + vectorTileLayer.visible = visible + } + + async applyDatasetVisibility (datasetId) { + const registryDataset = datasetRegistry.getDataset(datasetId) + if (registryDataset) { + this._applyRegistryDatasetVisibility(registryDataset) + } + } + + async applyGlobalVisibility () { + datasetRegistry.forEachDataset(registryDataset => this._applyRegistryDatasetVisibility(registryDataset)) + } + + async applyDatasetOpacity (datasetId) { + const vectorTileLayer = this._mapOpacityLayers[datasetId] + const registryDataset = datasetRegistry.getDataset(datasetId) + if (vectorTileLayer && registryDataset) { + vectorTileLayer.opacity = registryDataset.opacity + } + } + + async applyGlobalOpacity () { + Object.entries(this._mapOpacityLayers).forEach(([datasetId, vectorTileLayer]) => { + const registryDataset = datasetRegistry.getDataset(datasetId) + if (registryDataset) { + vectorTileLayer.opacity = registryDataset.opacity + } + }) + } + + _applyStyleLayerVisibility (registryDataset, vectorTileLayer) { + const { esriStyleLayerId } = registryDataset + if (!esriStyleLayerId || !vectorTileLayer) { + return + } + vectorTileLayer.setStyleLayerVisibility(esriStyleLayerId, registryDataset.visibility) + } + + _applyStyleLayerPaintProperties (registryDataset, vectorTileLayer) { + const { esriStyleLayerId, useServerStyle } = registryDataset + if (useServerStyle || !esriStyleLayerId || !vectorTileLayer) { + return + } + const layerPaintProperties = vectorTileLayer.getPaintProperties(esriStyleLayerId) + if (layerPaintProperties) { + registryDataset.applyLayerPaintProperties(layerPaintProperties) + vectorTileLayer.setPaintProperties(esriStyleLayerId, registryDataset.applyLayerPaintProperties(layerPaintProperties)) + } + } + + async onMapStyleChange () { + datasetRegistry.forEach(registryDataset => { + const { id, isSublayer, parent } = registryDataset + + // mapLayer could be a VectorTileLayer or a FeatureLayer, depending on the dataset type + const mapLayer = this._mapVisibilityLayers[isSublayer ? parent.id : id] + if (registryDataset.type === 'FeatureService') { + // FeatureLayers don't have style layers, so we don't need to apply style layer visibility or paint properties + mapLayer.renderer = registryDataset.renderer + } else { + this._applyStyleLayerVisibility(registryDataset, mapLayer) + this._applyStyleLayerPaintProperties(registryDataset, mapLayer) + } + }) + // TODO - handle dynamic sources + } + + // onMapSizeChange is not applicable to the esriLayerAdapter + async onMapSizeChange () {} + + // Remaining methods are still todo + async applyFeatureFilter (...args) { + console.log('TODO: applyFeatureFilter', args) + } + + async setData (...args) { + console.log('TODO: setData', args) + } + + async applyStyle (...args) { + console.log('TODO: applyStyle', args) + } +} diff --git a/plugins/datasets/src/adapters/esri/esriLayerAdapter.test.js b/plugins/datasets/src/adapters/esri/esriLayerAdapter.test.js new file mode 100644 index 000000000..172ef854a --- /dev/null +++ b/plugins/datasets/src/adapters/esri/esriLayerAdapter.test.js @@ -0,0 +1,292 @@ +import { EsriDataset } from './registry/esriDataset.js' +import EsriLayerAdapter from './esriLayerAdapter.js' +import { datasetRegistry } from '../../registry/datasetRegistry.js' + +jest.mock('../../registry/datasetRegistry.js') +jest.mock('../../../../../src/services/logger.js') + +jest.mock('@arcgis/core/layers/VectorTileLayer.js', () => + jest.fn().mockImplementation((opts = {}) => ({ + ...opts, + add: jest.fn(), + when: jest.fn().mockResolvedValue(undefined), + setStyleLayerVisibility: jest.fn(), + getPaintProperties: jest.fn().mockReturnValue({}), + setPaintProperties: jest.fn() + })) +) +jest.mock('@arcgis/core/layers/GroupLayer.js', () => + jest.fn().mockImplementation((opts = {}) => { + const layers = [] + return { + ...opts, + layers, + add: jest.fn(layer => layers.push(layer)), + remove: jest.fn(layer => { + const idx = layers.indexOf(layer) + if (idx !== -1) layers.splice(idx, 1) + }) + } + }) +) + +// Uncovered: 124,145-149 + +const MAP_STYLE = { id: 'outdoor' } +const makeMap = () => { + const _added = {} + return { + add: jest.fn(({ id }) => { + // Ensure that the same layer is never added twice + expect(_added[id]).toBeUndefined() + _added[id] = true + }), + remove: jest.fn() + } +} +const makeMapProvider = (map) => ({ map }) + +describe('esriLayerAdapter', () => { + let map, mapProvider, adapter + + beforeEach(() => { + datasetRegistry.useEsriDatasets() + datasetRegistry.attachMapStyle(MAP_STYLE) + datasetRegistry.attachCreateDataset(def => new EsriDataset(def)) + map = makeMap() + mapProvider = makeMapProvider(map) + adapter = new EsriLayerAdapter(mapProvider, null, null) + }) + + // ─── createDataset ─────────────────────────────────────────────────────────── + + describe('createDataset', () => { + it('returns an EsriDataset instance', () => { + expect(adapter.createDataset({ id: 'test' })).toBeInstanceOf(EsriDataset) + }) + }) + + // ─── addDataset ────────────────────────────────────────────────────────────── + + describe('addDataset', () => { + it('copes and returns when the dataset is not in the registry', async () => { + await adapter.addDataset('unknown') + expect(adapter._mapVisibilityLayers.unknown).toBeUndefined() + }) + + it('adds a standalone dataset to the map and populates internal state', async () => { + await adapter.addDataset('esri-standalone') + expect(adapter._mapVisibilityLayers['esri-standalone']).toBeDefined() + expect(adapter._mapOpacityLayers['esri-standalone']).toBeDefined() + }) + + it('applies opacity and visibility after adding layers', async () => { + await adapter.addDataset('esri-standalone') + const vtl = adapter._mapVisibilityLayers['esri-standalone'] + expect(vtl.visible).toBe(true) + expect(adapter._mapOpacityLayers['esri-standalone'].opacity) + .toBe(datasetRegistry.getDataset('esri-standalone').opacity) + }) + + it('applies paint properties for datasets with esriStyleLayerId', async () => { + await adapter.addDataset('esri-standalone') + const vtl = adapter._mapVisibilityLayers['esri-standalone'] + expect(vtl.setPaintProperties).toHaveBeenCalledWith('standalone-style', expect.any(Object)) + }) + + it('does not apply paint properties for server-style datasets', async () => { + await adapter.addDataset('esri-server') + const vtl = adapter._mapVisibilityLayers['esri-server'] + expect(vtl.setPaintProperties).not.toHaveBeenCalled() + }) + + it('creates a group layer when adding a grouped dataset', async () => { + await adapter.addDataset('esri-grouped') + expect(adapter._mapVisibilityLayers['esri-grouped']).toBeDefined() + expect(adapter._groupLayers['my-group']).toBeDefined() + }) + }) + + // ─── removeDataset ──────────────────────────────────────────────────────────── + + describe('removeDataset', () => { + it('does nothing when dataset is not in the registry', async () => { + await expect(adapter.removeDataset('unknown')).resolves.toBeUndefined() + expect(map.remove).not.toHaveBeenCalled() + }) + + it('does nothing when the dataset has not been added to the adapter', async () => { + await adapter.removeDataset('esri-standalone') + expect(map.remove).not.toHaveBeenCalled() + }) + + it('removes a standalone vectorTileLayer from the map and clears internal state', async () => { + await adapter._addLayers(datasetRegistry.getDataset('esri-standalone')) + const vtl = adapter._mapVisibilityLayers['esri-standalone'] + await adapter.removeDataset('esri-standalone') + expect(map.remove).toHaveBeenCalledWith(vtl) + expect(adapter._mapVisibilityLayers['esri-standalone']).toBeUndefined() + expect(adapter._mapOpacityLayers['esri-standalone']).toBeUndefined() + }) + + it('removes the vectorTileLayer from its group layer but keeps the group when other layers remain', async () => { + await adapter._addLayers(datasetRegistry.getDataset('flood-zones-cc')) + await adapter._addLayers(datasetRegistry.getDataset('flood-zones')) + const vtl = adapter._mapVisibilityLayers['flood-zones-cc'] + const groupLayer = adapter._groupLayers['flood-zones-group'] + await adapter.removeDataset('flood-zones-cc') + expect(groupLayer.remove).toHaveBeenCalledWith(vtl) + expect(map.remove).not.toHaveBeenCalledWith(groupLayer) + expect(adapter._groupLayers['flood-zones-group']).toBeDefined() + expect(adapter._mapVisibilityLayers['flood-zones-cc']).toBeUndefined() + expect(adapter._mapOpacityLayers['flood-zones-cc']).toBeUndefined() + }) + + it('removes the group layer from the map when its last vectorTileLayer is removed', async () => { + await adapter._addLayers(datasetRegistry.getDataset('esri-grouped')) + const vtl = adapter._mapVisibilityLayers['esri-grouped'] + const groupLayer = adapter._groupLayers['my-group'] + await adapter.removeDataset('esri-grouped') + expect(groupLayer.remove).toHaveBeenCalledWith(vtl) + expect(map.remove).toHaveBeenCalledWith(groupLayer) + expect(adapter._groupLayers['my-group']).toBeUndefined() + expect(adapter._mapVisibilityLayers['esri-grouped']).toBeUndefined() + expect(adapter._mapOpacityLayers['esri-grouped']).toBeUndefined() + }) + }) + + // ─── applyDatasetVisibility ────────────────────────────────────────────────── + + describe('applyDatasetVisibility', () => { + beforeEach(async () => { + const standAlone = datasetRegistry.getDataset('esri-standalone') + await adapter._addLayers(standAlone) + }) + + it('applies visibility for a known dataset', async () => { + await adapter.applyDatasetVisibility('esri-standalone') + expect(adapter._mapVisibilityLayers['esri-standalone'].visible).toBe(true) + }) + + it('does nothing for an unknown dataset', async () => { + await expect(adapter.applyDatasetVisibility('unknown')).resolves.not.toThrow() + }) + + it('calls setStyleLayerVisibility for datasets with esriStyleLayerId', async () => { + const applyStyleLayerVisibilitySpy = jest.spyOn(adapter, '_applyStyleLayerVisibility') + await adapter._addLayers(datasetRegistry.getDataset('flood-zones')) + await adapter.applyDatasetVisibility('flood-zones-flood-zone-3') + expect(applyStyleLayerVisibilitySpy.mock.calls).toHaveLength(1) + }) + }) + + // ─── applyDatasetOpacity ───────────────────────────────────────────────────── + + describe('applyDatasetOpacity', () => { + it('sets opacity on the opacity layer for a known dataset', async () => { + await adapter._addLayers(datasetRegistry.getDataset('esri-standalone')) + await adapter.applyDatasetOpacity('esri-standalone') + expect(adapter._mapOpacityLayers['esri-standalone'].opacity) + .toBe(datasetRegistry.getDataset('esri-standalone').opacity) + }) + + it('does nothing when the dataset has no opacity layer', async () => { + await expect(adapter.applyDatasetOpacity('esri-standalone')).resolves.not.toThrow() + }) + + it('does nothing for an unknown dataset', async () => { + await adapter._addLayers(datasetRegistry.getDataset('esri-standalone')) + await expect(adapter.applyDatasetOpacity('unknown')).resolves.not.toThrow() + }) + }) + + // ─── applyGlobalOpacity ────────────────────────────────────────────────────── + + describe('applyGlobalOpacity', () => { + it('sets opacity on all opacity layers', async () => { + await adapter._addLayers(datasetRegistry.getDataset('esri-standalone')) + await adapter.applyGlobalOpacity() + expect(adapter._mapOpacityLayers['esri-standalone'].opacity) + .toBe(datasetRegistry.getDataset('esri-standalone').opacity) + }) + + it('skips entries whose dataset is not in the registry', async () => { + adapter._mapOpacityLayers['ghost-id'] = { opacity: 99 } + await expect(adapter.applyGlobalOpacity()).resolves.not.toThrow() + expect(adapter._mapOpacityLayers['ghost-id'].opacity).toBe(99) + }) + }) + + // ─── onMapStyleChange ──────────────────────────────────────────────────────── + + describe('onMapStyleChange', () => { + beforeEach(async () => { + await adapter._addLayers(datasetRegistry.getDataset('esri-standalone')) + await adapter._addLayers(datasetRegistry.getDataset('esri-server')) + }) + + it('calls setStyleLayerVisibility for datasets with esriStyleLayerId', async () => { + await adapter.onMapStyleChange() + expect(adapter._mapVisibilityLayers['esri-standalone'].setStyleLayerVisibility) + .toHaveBeenCalledWith('standalone-style', expect.any(String)) + }) + + it('calls setPaintProperties for datasets not using server style', async () => { + await adapter.onMapStyleChange() + expect(adapter._mapVisibilityLayers['esri-standalone'].setPaintProperties) + .toHaveBeenCalledWith('standalone-style', expect.any(Object)) + }) + + it('does not call setPaintProperties when useServerStyle is true', async () => { + await adapter.onMapStyleChange() + expect(adapter._mapVisibilityLayers['esri-server'].setPaintProperties).not.toHaveBeenCalled() + }) + }) + + // ─── init ──────────────────────────────────────────────────────────────────── + + describe('init', () => { + beforeEach(async () => { + await adapter.init() + }) + + it('adds VectorTileLayers for all top-level datasets', async () => { + expect(adapter._mapVisibilityLayers['flood-zones-cc']).toBeDefined() + expect(adapter._mapVisibilityLayers['flood-zones']).toBeDefined() + }) + + it('applies dataset visibility after adding layers', async () => { + expect(adapter._groupLayers['flood-zones-group'].visible).toBe(true) + expect(adapter._mapVisibilityLayers['flood-zones-cc'].visible).toBe(true) + expect(adapter._mapVisibilityLayers['flood-zones'].visible).toBe(false) + }) + + it('adds GroupLayers for datasets with esriGroupId', async () => { + expect(map.add).toHaveBeenCalledWith(expect.objectContaining({ id: 'flood-zones-group' })) + }) + + it('calls map.add for each top-level dataset', async () => { + expect(map.add.mock.calls).toHaveLength(5) // 3 top-level datasets + 2 group layers + }) + }) + + // ─── onMapSizeChange ───────────────────────────────────────────────────────── + + describe('onMapSizeChange', () => { + it('resolves without doing anything', async () => { + await expect(adapter.onMapSizeChange()).resolves.toBeUndefined() + }) + }) + + describe('applyGlobalVisibility', () => { + beforeEach(async () => { + await adapter.init() + }) + + it('applies visibility for all datasets', async () => { + const applyStyleLayerVisibilitySpy = jest.spyOn(adapter, '_applyStyleLayerVisibility') + await adapter.applyGlobalVisibility() + expect(applyStyleLayerVisibilitySpy.mock.calls).toHaveLength(7) + }) + }) +}) diff --git a/plugins/datasets/src/adapters/esri/registry/esriDataset.js b/plugins/datasets/src/adapters/esri/registry/esriDataset.js new file mode 100644 index 000000000..9a7f2be81 --- /dev/null +++ b/plugins/datasets/src/adapters/esri/registry/esriDataset.js @@ -0,0 +1,46 @@ +import { Dataset } from '../../../registry/dataset.js' +import { datasetRegistry } from '../../../registry/datasetRegistry.js' +import { getValueForStyle } from '../../../../../../src/utils/getValueForStyle.js' + +export class EsriDataset extends Dataset { + applyLayerPaintProperties (layerPaintProperties) { + const { mapStyle } = datasetRegistry + if (this.hasStroke) { + layerPaintProperties['line-color'] = getValueForStyle(this.style.stroke, mapStyle.id) + } + if (this.hasFill) { + layerPaintProperties['fill-color'] = getValueForStyle(this.style.fill, mapStyle.id) + } + return layerPaintProperties + } + + get esriGroupId () { + if (this._datasetDefinition.esriGroupId === undefined) { + return this.parent?.esriGroupId + } + return this._datasetDefinition.esriGroupId + } + + get useServerStyle () { + return Boolean(this._datasetDefinition.esriUseServerStyle) + } + + get renderer () { + if (this.type !== 'FeatureService') { + return undefined + } + const rendererDefinition = this._datasetDefinition.style?.renderer || this.parent?.renderer + if (!rendererDefinition) { + return undefined + } + const { mapStyle } = datasetRegistry + const renderer = JSON.parse(JSON.stringify(rendererDefinition)) + if (renderer.symbol?.color) { + renderer.symbol.color = getValueForStyle(renderer.symbol.color, mapStyle.id) + } + if (renderer.symbol?.outline?.color) { + renderer.symbol.outline.color = getValueForStyle(renderer.symbol.outline.color, mapStyle.id) + } + return renderer + } +} diff --git a/plugins/datasets/src/adapters/esri/registry/esriDataset.test.js b/plugins/datasets/src/adapters/esri/registry/esriDataset.test.js new file mode 100644 index 000000000..206c868fc --- /dev/null +++ b/plugins/datasets/src/adapters/esri/registry/esriDataset.test.js @@ -0,0 +1,73 @@ +import { EsriDataset } from './esriDataset.js' +import { datasetRegistry } from '../../../registry/datasetRegistry.js' + +jest.mock('../../../registry/datasetRegistry.js') + +const MAP_STYLE = { id: 'outdoor' } + +describe('EsriDataset', () => { + beforeEach(() => { + datasetRegistry.attachMapStyle(MAP_STYLE) + datasetRegistry.attachCreateDataset(def => new EsriDataset(def)) + datasetRegistry.mockExtend({ + // applyLayerPaintProperties + 'esri-bare': { id: 'esri-bare' }, + // esriGroupId — parent owns the id, child inherits it + 'esri-group': { id: 'esri-group', esriGroupId: 'group-123', sublayerIds: ['esri-child'] }, + 'esri-child': { id: 'esri-child', parentId: 'esri-group' }, + // useServerStyle + 'esri-server-style': { id: 'esri-server-style', esriUseServerStyle: true } + }) + }) + + // ─── applyLayerPaintProperties ────────────────────────────────────────────── + + describe('applyLayerPaintProperties', () => { + it('adds both line-color and fill-color when the dataset has stroke and fill', () => { + const ds = datasetRegistry.getDataset('land-covers-other') + const paint = {} + ds.applyLayerPaintProperties(paint) + expect(paint['line-color']).toBe('#1565C0') + expect(paint['fill-color']).toBe('rgba(0,0,255,0.1)') + }) + + it('does not add line-color or fill-color when the dataset has neither', () => { + const ds = datasetRegistry.getDataset('esri-bare') + const paint = {} + ds.applyLayerPaintProperties(paint) + expect(paint['line-color']).toBeUndefined() + expect(paint['fill-color']).toBeUndefined() + }) + }) + + // ─── esriGroupId ──────────────────────────────────────────────────────────── + + describe('esriGroupId', () => { + it('returns the esriGroupId from the dataset definition when set', () => { + const ds = datasetRegistry.getDataset('esri-group') + expect(ds.esriGroupId).toBe('group-123') + }) + + it('inherits esriGroupId from the parent when own esriGroupId is undefined', () => { + const child = datasetRegistry.getDataset('esri-child') + expect(child.esriGroupId).toBe('group-123') + }) + + it('returns undefined when esriGroupId is not set and there is no parent', () => { + const ds = datasetRegistry.getDataset('esri-bare') + expect(ds.esriGroupId).toBeUndefined() + }) + }) + + // ─── useServerStyle ───────────────────────────────────────────────────────── + + describe('useServerStyle', () => { + it('returns true when esriUseServerStyle is true', () => { + expect(datasetRegistry.getDataset('esri-server-style').useServerStyle).toBe(true) + }) + + it('returns false when esriUseServerStyle is not set', () => { + expect(datasetRegistry.getDataset('land-covers-other').useServerStyle).toBe(false) + }) + }) +}) diff --git a/plugins/datasets/src/adapters/layerAdapter.js b/plugins/datasets/src/adapters/layerAdapter.js new file mode 100644 index 000000000..2188f3a12 --- /dev/null +++ b/plugins/datasets/src/adapters/layerAdapter.js @@ -0,0 +1,9 @@ +export class LayerAdapter { + attachDynamicSources (dynamicSources) { + this._dynamicSources = dynamicSources + } + + get dynamicSources () { + return this._dynamicSources + } +} diff --git a/plugins/beta/datasets/src/adapters/loadLayerAdapter.js b/plugins/datasets/src/adapters/loadLayerAdapter.js similarity index 78% rename from plugins/beta/datasets/src/adapters/loadLayerAdapter.js rename to plugins/datasets/src/adapters/loadLayerAdapter.js index 1f24d8981..78dd8e3a0 100644 --- a/plugins/beta/datasets/src/adapters/loadLayerAdapter.js +++ b/plugins/datasets/src/adapters/loadLayerAdapter.js @@ -4,7 +4,11 @@ const importLayerAdapter = async (mapProvider) => { const { default: LayerAdapter } = await import(/* webpackChunkName: "im-datasets-ml-adapter" */ './maplibre/maplibreLayerAdapter.js') return LayerAdapter } - // TODO: add cases for EsriProvider, OpenLayersProvider and potentially LeafletProvider + case 'EsriProvider': { + const { default: LayerAdapter } = await import(/* webpackChunkName: "im-datasets-esri-adapter" */ './esri/esriLayerAdapter.js') + return LayerAdapter + } + // TODO: add cases for OpenLayersProvider and potentially LeafletProvider default: { throw new Error(`No layer adapter available for map provider ${mapProvider.name}. Please provide a compatible layer adapter.`) } @@ -28,6 +32,8 @@ export const loadLayerAdapter = async (mapProvider, symbolRegistry, patternRegis layerAdapter.applyGlobalOpacity = _layerAdapter.applyGlobalOpacity.bind(_layerAdapter) layerAdapter.addDataset = _layerAdapter.addDataset.bind(_layerAdapter) layerAdapter.applyFeatureFilter = _layerAdapter.applyFeatureFilter.bind(_layerAdapter) + layerAdapter.onMapStyleChange = _layerAdapter.onMapStyleChange?.bind(_layerAdapter) + layerAdapter.onMapSizeChange = _layerAdapter.onMapSizeChange?.bind(_layerAdapter) return _layerAdapter } diff --git a/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js b/plugins/datasets/src/adapters/maplibre/layerBuilders.js similarity index 96% rename from plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js rename to plugins/datasets/src/adapters/maplibre/layerBuilders.js index f4c59a6c0..fba157634 100644 --- a/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +++ b/plugins/datasets/src/adapters/maplibre/layerBuilders.js @@ -1,5 +1,5 @@ -import { getValueForStyle } from '../../../../../../src/utils/getValueForStyle.js' -import { getSymbolAnchor } from '../../../../../../src/utils/symbolUtils.js' +import { getValueForStyle } from '../../../../../src/utils/getValueForStyle.js' +import { getSymbolAnchor } from '../../../../../src/utils/symbolUtils.js' // ─── Fill layer ─────────────────────────────────────────────────────────────── diff --git a/plugins/datasets/src/adapters/maplibre/layerBuilders.test.js b/plugins/datasets/src/adapters/maplibre/layerBuilders.test.js new file mode 100644 index 000000000..6d3fdd8e9 --- /dev/null +++ b/plugins/datasets/src/adapters/maplibre/layerBuilders.test.js @@ -0,0 +1,83 @@ +import { addFillLayer, addStrokeLayer, addSymbolLayer } from './layerBuilders.js' + +// ─── helpers ────────────────────────────────────────────────────────────────── + +const makeMap = () => { + const layers = new Map() + return { + getLayer: jest.fn(id => layers.get(id) ?? null), + getSource: jest.fn(() => null), + addLayer: jest.fn(spec => layers.set(spec.id, spec)), + addSource: jest.fn() + } +} + +const makeDataset = (overrides = {}) => ({ + id: 'test-ds', + hasFill: false, + fillLayerId: null, + hasStroke: false, + strokeLayerId: null, + hasSymbol: false, + symbolLayerId: null, + style: {}, + opacity: 1, + getFillSource: jest.fn(paint => ({ id: 'test-ds', type: 'fill', paint })), + getStrokeSource: jest.fn(paint => ({ id: 'test-ds-stroke', type: 'line', paint })), + getSymbolSource: jest.fn((imageId, anchor) => ({ id: 'test-ds', type: 'symbol', layout: { 'icon-image': imageId } })), + ...overrides +}) + +// ─── addFillLayer ───────────────────────────────────────────────────────────── + +describe('addFillLayer', () => { + it('uses pixelRatio = 1 when the argument is omitted', () => { + const map = makeMap() + const patternRegistry = { getPatternImageId: jest.fn(() => null) } + const ds = makeDataset({ hasFill: true, fillLayerId: 'test-ds', style: { fill: '#ff0000' } }) + addFillLayer(map, ds, 'outdoor', patternRegistry) // no pixelRatio argument + expect(patternRegistry.getPatternImageId).toHaveBeenCalledWith(ds.style, 'outdoor', 1) + }) +}) + +// ─── addStrokeLayer ─────────────────────────────────────────────────────────── + +describe('addStrokeLayer', () => { + it('defaults line-width to 1 when strokeWidth is not set on the style', () => { + const map = makeMap() + const ds = makeDataset({ hasStroke: true, strokeLayerId: 'test-ds-stroke', style: { stroke: '#000000' } }) + addStrokeLayer(map, ds, 'outdoor') + expect(ds.getStrokeSource).toHaveBeenCalledWith(expect.objectContaining({ 'line-width': 1 })) + }) + + it('includes line-dasharray in the paint when strokeDashArray is set', () => { + const map = makeMap() + const ds = makeDataset({ + hasStroke: true, + strokeLayerId: 'test-ds-stroke', + style: { stroke: '#000000', strokeWidth: 2, strokeDashArray: [4, 2] } + }) + addStrokeLayer(map, ds, 'outdoor') + expect(ds.getStrokeSource).toHaveBeenCalledWith(expect.objectContaining({ 'line-dasharray': [4, 2] })) + }) +}) + +// ─── addSymbolLayer ─────────────────────────────────────────────────────────── + +describe('addSymbolLayer', () => { + it('returns early without adding a layer when symbolDef is null', () => { + const map = makeMap() + const symbolRegistry = { getSymbolDef: jest.fn(() => null), getSymbolImageId: jest.fn() } + const ds = makeDataset({ hasSymbol: true, symbolLayerId: 'test-ds', style: {} }) + addSymbolLayer(map, ds, { id: 'outdoor' }, symbolRegistry, 1) + expect(map.addLayer).not.toHaveBeenCalled() + }) + + it('returns early without adding a layer when imageId is null', () => { + const map = makeMap() + const symbolRegistry = { getSymbolDef: jest.fn(() => ({})), getSymbolImageId: jest.fn(() => null) } + const ds = makeDataset({ hasSymbol: true, symbolLayerId: 'test-ds', style: {} }) + addSymbolLayer(map, ds, { id: 'outdoor' }, symbolRegistry, 1) + expect(map.addLayer).not.toHaveBeenCalled() + }) +}) diff --git a/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js b/plugins/datasets/src/adapters/maplibre/maplibreLayerAdapter.js similarity index 91% rename from plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js rename to plugins/datasets/src/adapters/maplibre/maplibreLayerAdapter.js index a3641f2bd..d33011fca 100644 --- a/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +++ b/plugins/datasets/src/adapters/maplibre/maplibreLayerAdapter.js @@ -1,3 +1,4 @@ +import { LayerAdapter } from '../layerAdapter.js' import { addDatasetLayers } from './layerBuilders.js' import { MapLibreDataset } from './registry/mapLibreDataset.js' import { datasetRegistry } from '../../registry/datasetRegistry.js' @@ -14,13 +15,14 @@ import { datasetRegistry } from '../../registry/datasetRegistry.js' * Symbol image rasterisation is delegated to the map provider via * `mapProvider.addSymbolsToMap()`, keeping this adapter free of provider internals. */ -export default class MaplibreLayerAdapter { +export default class MaplibreLayerAdapter extends LayerAdapter { /** * @param {Object} mapProvider - Map provider instance (e.g. MapLibreProvider) * @param {Object} symbolRegistry * @param {Object} patternRegistry */ constructor (mapProvider, symbolRegistry, patternRegistry) { + super() this._mapProvider = mapProvider this._map = mapProvider.map this._symbolRegistry = symbolRegistry @@ -40,15 +42,14 @@ export default class MaplibreLayerAdapter { /** * Initialise all datasets: register patterns, add layers, then wait for idle. - * @param {Object} mapStyle * @returns {Promise} Resolves once the map has processed all layers. */ - async init (mapStyle) { + async init () { const { patternConfigs, symbolConfigs } = datasetRegistry.getPatternAndSymbolConfigs() - await this.addPatternsAndSymbolsToMap(patternConfigs, symbolConfigs, mapStyle) + await this.addPatternsAndSymbolsToMap(patternConfigs, symbolConfigs) this._symbolLayerIds.clear() - datasetRegistry.forEachDataset(registryDataset => this._addLayers(registryDataset, mapStyle)) + datasetRegistry.forEachDataset(registryDataset => this._addLayers(registryDataset)) await new Promise(resolve => this._map.once('idle', resolve)) } @@ -59,7 +60,8 @@ export default class MaplibreLayerAdapter { this._symbolLayerIds.delete(layerId) } - async addPatternsAndSymbolsToMap (patterns, symbols, mapStyle) { + async addPatternsAndSymbolsToMap (patterns, symbols) { + const mapStyle = datasetRegistry.mapStyle const mapStyleId = mapStyle.id return Promise.all([ this._mapProvider.addPatternsToMap(patterns, mapStyleId, this._patternRegistry), @@ -86,37 +88,35 @@ export default class MaplibreLayerAdapter { /** * Re-register patterns and re-add all layers after a basemap style change, * then reapply cached dynamic source data and hidden-feature filters. - * @param {Object} newMapStyle - * @param {Map} dynamicSources - datasetId → dynamic source instance * @returns {Promise} */ - async onMapStyleChange (newMapStyle, dynamicSources) { + async onMapStyleChange () { // MapLibre wipes all sources/layers on style change — must wait for idle first await new Promise(resolve => this._map.once('idle', resolve)) const { patternConfigs, symbolConfigs } = datasetRegistry.getPatternAndSymbolConfigs() - await this.addPatternsAndSymbolsToMap(patternConfigs, symbolConfigs, newMapStyle) + await this.addPatternsAndSymbolsToMap(patternConfigs, symbolConfigs) this._symbolLayerIds.clear() datasetRegistry.forEachDataset(registryDataset => { - this._addLayers(registryDataset, newMapStyle) + this._addLayers(registryDataset) this._applyFeatureFilter(registryDataset) }) // TODO: check dynamicSources still work // Re-push cached data for dynamic sources - dynamicSources.forEach(source => source.reapply()) + this.dynamicSources.forEach(source => source.reapply()) } /** * Re-register symbols at the new pixel ratio and update icon-image on all symbol layers. * Called when the map size changes so symbols are rasterised at the correct resolution. - * @param {Object} mapStyle * @returns {Promise} */ - async onMapSizeChange (mapStyle) { + async onMapSizeChange () { + const { mapStyle } = datasetRegistry const { patternConfigs, symbolConfigs } = datasetRegistry.getPatternAndSymbolConfigs() - await this.addPatternsAndSymbolsToMap(patternConfigs, symbolConfigs, mapStyle) + await this.addPatternsAndSymbolsToMap(patternConfigs, symbolConfigs) datasetRegistry.forEach(registryDataset => { const { fillLayerId, symbolLayerId } = registryDataset @@ -125,7 +125,9 @@ export default class MaplibreLayerAdapter { if (imageId) { this._map.setLayoutProperty(symbolLayerId, 'icon-image', imageId) } - } else if (fillLayerId && this._map.getLayer(fillLayerId)) { + return + } + if (fillLayerId && this._map.getLayer(fillLayerId)) { const imageId = this._patternRegistry.getPatternImageId(registryDataset.style, mapStyle.id, this._pixelRatio) if (imageId) { this._map.setPaintProperty(fillLayerId, 'fill-pattern', imageId) @@ -139,12 +141,11 @@ export default class MaplibreLayerAdapter { /** * Add a single dataset's source and layers to the map. * @param {string} datasetId - * @param {Object} mapStyle */ - async addDataset (datasetId, mapStyle) { + async addDataset (datasetId) { const registryDataset = datasetRegistry.getDataset(datasetId) - await this.addPatternsAndSymbolsToMap(registryDataset.patternConfigs, registryDataset.symbolConfigs, mapStyle) - this._addLayers(registryDataset, mapStyle) + await this.addPatternsAndSymbolsToMap(registryDataset.patternConfigs, registryDataset.symbolConfigs) + this._addLayers(registryDataset) } /** @@ -164,7 +165,7 @@ export default class MaplibreLayerAdapter { // Remove source if no other dataset is using it const sourceIsShared = datasetRegistry.topLevelDatasets() - .filter(registryDataset => registryDataset.id !== datasetId && registryDataset.sourceId === sourceId) + .filter(dataset => dataset.id !== datasetId && dataset.sourceId === sourceId) .length > 0 if (!sourceIsShared && this._map.getSource(sourceId)) { @@ -192,14 +193,13 @@ export default class MaplibreLayerAdapter { /** * Update a dataset's style and re-render all its layers. * @param {string} datasetId - Updated dataset (style changes already merged in) - * @param {Object} mapStyle * @returns {Promise} */ - async applyStyle (datasetId, mapStyle) { + async applyStyle (datasetId) { const registryDataset = datasetRegistry.getDataset(datasetId) registryDataset.layerIds.forEach(layerId => this.removeLayer(layerId)) - await this.addPatternsAndSymbolsToMap(registryDataset.patternConfigs, registryDataset.symbolConfigs, mapStyle) - this._addLayers(registryDataset, mapStyle) + await this.addPatternsAndSymbolsToMap(registryDataset.patternConfigs, registryDataset.symbolConfigs) + this._addLayers(registryDataset) } /** @@ -253,7 +253,8 @@ export default class MaplibreLayerAdapter { return this._mapProvider.map.getPixelRatio() } - _addLayers (registryDataset, mapStyle) { + _addLayers (registryDataset) { + const { mapStyle } = datasetRegistry const sourceId = addDatasetLayers(this._map, registryDataset, mapStyle, this._symbolRegistry, this._patternRegistry, this._pixelRatio) this._datasetSourceMap.set(registryDataset.id, sourceId) this._maintainSymbolOrdering(registryDataset) diff --git a/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.test.js b/plugins/datasets/src/adapters/maplibre/maplibreLayerAdapter.test.js similarity index 86% rename from plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.test.js rename to plugins/datasets/src/adapters/maplibre/maplibreLayerAdapter.test.js index 247d1b54b..2199a1fa1 100644 --- a/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.test.js +++ b/plugins/datasets/src/adapters/maplibre/maplibreLayerAdapter.test.js @@ -1,8 +1,7 @@ import MaplibreLayerAdapter from './maplibreLayerAdapter.js' -import { MapLibreDataset } from './registry/mapLibreDataset.js' import { datasetRegistry } from '../../registry/datasetRegistry.js' -import { symbolRegistry } from '../../../../../../src/services/symbolRegistry.js' -import { patternRegistry } from '../../../../../../src/services/patternRegistry.js' +import { symbolRegistry } from '../../../../../src/services/symbolRegistry.js' +import { patternRegistry } from '../../../../../src/services/patternRegistry.js' jest.mock('../../registry/datasetRegistry.js') @@ -42,9 +41,10 @@ const makeMapProvider = (map) => ({ const MAP_STYLE = { id: 'outdoor', layers: [] } let map, mapProvider, adapter +const dynamicSources = new Map() beforeEach(() => { - datasetRegistry.attachCreateDataset(def => new MapLibreDataset(def)) + datasetRegistry.attachMapStyle(MAP_STYLE) symbolRegistry.clear() symbolRegistry.initialise() patternRegistry.clear() @@ -53,32 +53,34 @@ beforeEach(() => { map = makeMap() mapProvider = makeMapProvider(map) adapter = new MaplibreLayerAdapter(mapProvider, symbolRegistry, patternRegistry) + adapter.attachDynamicSources(dynamicSources) + datasetRegistry.attachCreateDataset(adapter.createDataset) }) // ─── init ───────────────────────────────────────────────────────────────────── describe('init', () => { it('calls addPatternsAndSymbolsToMap before adding layers', async () => { - await adapter.init(MAP_STYLE) + await adapter.init() expect(mapProvider.addPatternsToMap).toHaveBeenCalled() expect(mapProvider.addSymbolsToMap).toHaveBeenCalled() }) it('adds fill + stroke layers for existing-fields', async () => { - await adapter.init(MAP_STYLE) + await adapter.init() expect(map.getLayer('existing-fields')).toMatchObject({ type: 'fill' }) expect(map.getLayer('existing-fields-stroke')).toMatchObject({ type: 'line' }) }) it('adds symbol layers for all historic-monuments sublayers', async () => { - await adapter.init(MAP_STYLE) + await adapter.init() expect(map.getLayer('historic-monuments-prehistoric')).toMatchObject({ type: 'symbol' }) expect(map.getLayer('historic-monuments-roman')).toMatchObject({ type: 'symbol' }) expect(map.getLayer('historic-monuments-medieval')).toMatchObject({ type: 'symbol' }) }) it('adds fill + stroke layers for land-covers sublayers', async () => { - await adapter.init(MAP_STYLE) + await adapter.init() expect(map.getLayer('land-covers-130-131')).toMatchObject({ type: 'fill' }) expect(map.getLayer('land-covers-130-131-stroke')).toMatchObject({ type: 'line' }) expect(map.getLayer('land-covers-332')).toMatchObject({ type: 'fill' }) @@ -87,49 +89,49 @@ describe('init', () => { }) it('adds a stroke-only layer for hedge-control', async () => { - await adapter.init(MAP_STYLE) + await adapter.init() expect(map.getLayer('hedge-control')).toMatchObject({ type: 'line' }) }) it('adds a vector source for existing-fields', async () => { - await adapter.init(MAP_STYLE) + await adapter.init() const ds = datasetRegistry.getDataset('existing-fields') expect(map.addSource).toHaveBeenCalledWith(ds.sourceId, expect.objectContaining({ type: 'vector' })) }) it('adds a geojson source for historic-monuments', async () => { - await adapter.init(MAP_STYLE) + await adapter.init() const ds = datasetRegistry.getDataset('historic-monuments') expect(map.addSource).toHaveBeenCalledWith(ds.sourceId, expect.objectContaining({ type: 'geojson' })) }) it('adds a dynamic geojson source for land-covers', async () => { - await adapter.init(MAP_STYLE) + await adapter.init() const ds = datasetRegistry.getDataset('land-covers') expect(map.addSource).toHaveBeenCalledWith(ds.sourceId, expect.objectContaining({ type: 'geojson' })) }) it('adds each source only once even when multiple sublayers share it', async () => { - await adapter.init(MAP_STYLE) + await adapter.init() const ds = datasetRegistry.getDataset('historic-monuments') const calls = map.addSource.mock.calls.filter(([id]) => id === ds.sourceId) expect(calls).toHaveLength(1) }) it('waits for map idle before resolving', async () => { - await adapter.init(MAP_STYLE) + await adapter.init() expect(map.once).toHaveBeenCalledWith('idle', expect.any(Function)) }) it('tracks symbol layers in _symbolLayerIds', async () => { - await adapter.init(MAP_STYLE) + await adapter.init() expect(adapter._symbolLayerIds.has('historic-monuments-prehistoric')).toBe(true) expect(adapter._symbolLayerIds.has('historic-monuments-roman')).toBe(true) expect(adapter._symbolLayerIds.has('historic-monuments-medieval')).toBe(true) }) it('does not track fill/line layers in _symbolLayerIds', async () => { - await adapter.init(MAP_STYLE) + await adapter.init() expect(adapter._symbolLayerIds.has('existing-fields')).toBe(false) expect(adapter._symbolLayerIds.has('land-covers-130-131')).toBe(false) expect(adapter._symbolLayerIds.has('hedge-control')).toBe(false) @@ -140,7 +142,7 @@ describe('init', () => { describe('removeLayer', () => { it('removes an existing layer from the map', async () => { - await adapter.init(MAP_STYLE) + await adapter.init() adapter.removeLayer('existing-fields') expect(map.removeLayer).toHaveBeenCalledWith('existing-fields') expect(map.getLayer('existing-fields')).toBeNull() @@ -152,7 +154,7 @@ describe('removeLayer', () => { }) it('removes the layer id from _symbolLayerIds', async () => { - await adapter.init(MAP_STYLE) + await adapter.init() expect(adapter._symbolLayerIds.has('historic-monuments-prehistoric')).toBe(true) adapter.removeLayer('historic-monuments-prehistoric') expect(adapter._symbolLayerIds.has('historic-monuments-prehistoric')).toBe(false) @@ -162,7 +164,7 @@ describe('removeLayer', () => { // ─── destroy ────────────────────────────────────────────────────────────────── describe('destroy', () => { - beforeEach(async () => { await adapter.init(MAP_STYLE) }) + beforeEach(async () => { await adapter.init() }) it('removes all layers from the map', () => { adapter.destroy() @@ -178,6 +180,11 @@ describe('destroy', () => { adapter.destroy() expect(adapter._datasetSourceMap.size).toBe(0) }) + + it('does not throw when getStyle returns null (covers _getLayersUsingSource early return)', () => { + map.getStyle.mockReturnValue(null) + expect(() => adapter.destroy()).not.toThrow() + }) }) // ─── addDataset ─────────────────────────────────────────────────────────────── @@ -201,12 +208,18 @@ describe('addDataset', () => { expect(mapProvider.addPatternsToMap).toHaveBeenCalled() expect(mapProvider.addSymbolsToMap).toHaveBeenCalled() }) + + it('does not call moveLayer when getStyle returns no layers (covers _getFirstSymbolLayerId null branch)', async () => { + map.getStyle.mockReturnValue({}) + await adapter.addDataset('existing-fields') + expect(map.moveLayer).not.toHaveBeenCalled() + }) }) // ─── removeDataset ──────────────────────────────────────────────────────────── describe('removeDataset', () => { - beforeEach(async () => { await adapter.init(MAP_STYLE) }) + beforeEach(async () => { await adapter.init() }) it('removes all layers for existing-fields', () => { adapter.removeDataset('existing-fields') @@ -254,7 +267,7 @@ describe('removeDataset', () => { // ─── setData ────────────────────────────────────────────────────────────────── describe('setData', () => { - beforeEach(async () => { await adapter.init(MAP_STYLE) }) + beforeEach(async () => { await adapter.init() }) it('calls source.setData for a dynamic dataset (land-covers)', () => { const ds = datasetRegistry.getDataset('land-covers') @@ -277,7 +290,7 @@ describe('setData', () => { // ─── applyDatasetVisibility ─────────────────────────────────────────────────── describe('applyDatasetVisibility', () => { - beforeEach(async () => { await adapter.init(MAP_STYLE) }) + beforeEach(async () => { await adapter.init() }) it('sets visibility to "visible" for existing-fields layers', () => { adapter.applyDatasetVisibility('existing-fields') @@ -302,12 +315,19 @@ describe('applyDatasetVisibility', () => { adapter.applyDatasetVisibility('unknown') expect(map.setLayoutProperty.mock.calls.length).toBe(before) }) + + it('skips setLayoutProperty for a layer that has been removed from the map', () => { + map._layers.delete('existing-fields') + map.setLayoutProperty.mockClear() + adapter.applyDatasetVisibility('existing-fields') + expect(map.setLayoutProperty).not.toHaveBeenCalledWith('existing-fields', 'visibility', expect.any(String)) + }) }) // ─── applyGlobalVisibility ──────────────────────────────────────────────────── describe('applyGlobalVisibility', () => { - beforeEach(async () => { await adapter.init(MAP_STYLE) }) + beforeEach(async () => { await adapter.init() }) it('sets visibility on layers for all top-level datasets', () => { map.setLayoutProperty.mockClear() @@ -324,7 +344,7 @@ describe('applyGlobalVisibility', () => { // ─── applyFeatureFilter ─────────────────────────────────────────────────────── describe('applyFeatureFilter', () => { - beforeEach(async () => { await adapter.init(MAP_STYLE) }) + beforeEach(async () => { await adapter.init() }) it('sets filter on all land-covers sublayers (parent has hiddenFeatures: [42])', () => { adapter.applyFeatureFilter('land-covers') @@ -358,12 +378,19 @@ describe('applyFeatureFilter', () => { adapter.applyFeatureFilter('unknown') expect(map.setFilter).not.toHaveBeenCalled() }) + + it('skips setFilter for a sublayer whose layer has been removed from the map', () => { + map._layers.delete('land-covers-130-131') + map.setFilter.mockClear() + adapter.applyFeatureFilter('land-covers') + expect(map.setFilter).not.toHaveBeenCalledWith('land-covers-130-131', expect.anything()) + }) }) // ─── applyDatasetOpacity ────────────────────────────────────────────────────── describe('applyDatasetOpacity', () => { - beforeEach(async () => { await adapter.init(MAP_STYLE) }) + beforeEach(async () => { await adapter.init() }) it('sets fill-opacity on the fill layer for existing-fields', () => { adapter.applyDatasetOpacity('existing-fields') @@ -393,12 +420,19 @@ describe('applyDatasetOpacity', () => { adapter.applyDatasetOpacity('unknown') expect(map.setPaintProperty.mock.calls.length).toBe(before) }) + + it('does not call setPaintProperty for a layer that has been removed from the map (covers _setPaintOpacity early return)', () => { + map._layers.delete('existing-fields') + map.setPaintProperty.mockClear() + adapter.applyDatasetOpacity('existing-fields') + expect(map.setPaintProperty).not.toHaveBeenCalledWith('existing-fields', expect.any(String), expect.any(Number)) + }) }) // ─── applyGlobalOpacity ─────────────────────────────────────────────────────── describe('applyGlobalOpacity', () => { - beforeEach(async () => { await adapter.init(MAP_STYLE) }) + beforeEach(async () => { await adapter.init() }) it('sets paint opacity on layers for all datasets', () => { map.setPaintProperty.mockClear() @@ -415,7 +449,7 @@ describe('applyGlobalOpacity', () => { // ─── applyStyle ─────────────────────────────────────────────────────────────── describe('applyStyle', () => { - beforeEach(async () => { await adapter.init(MAP_STYLE) }) + beforeEach(async () => { await adapter.init() }) it('removes old fill + stroke layers then re-adds them for existing-fields', async () => { await adapter.applyStyle('existing-fields', MAP_STYLE) @@ -441,7 +475,7 @@ describe('applyStyle', () => { // ─── onMapStyleChange ───────────────────────────────────────────────────────── describe('onMapStyleChange', () => { - beforeEach(async () => { await adapter.init(MAP_STYLE) }) + beforeEach(async () => { await adapter.init() }) it('waits for map idle before proceeding', async () => { let idleCb @@ -480,11 +514,11 @@ describe('onMapStyleChange', () => { // ─── onMapSizeChange ────────────────────────────────────────────────────────── describe('onMapSizeChange', () => { - beforeEach(async () => { await adapter.init(MAP_STYLE) }) + beforeEach(async () => { await adapter.init() }) it('updates icon-image layout property for historic-monuments symbol layers', async () => { map.setLayoutProperty.mockClear() - await adapter.onMapSizeChange(MAP_STYLE) + await adapter.onMapSizeChange() const symbolUpdates = map.setLayoutProperty.mock.calls.filter(([, prop]) => prop === 'icon-image') const ids = symbolUpdates.map(([id]) => id) expect(ids).toContain('historic-monuments-prehistoric') @@ -494,7 +528,7 @@ describe('onMapSizeChange', () => { it('updates fill-pattern paint property for land-covers sublayers', async () => { map.setPaintProperty.mockClear() - await adapter.onMapSizeChange(MAP_STYLE) + await adapter.onMapSizeChange() const patternUpdates = map.setPaintProperty.mock.calls.filter(([, prop]) => prop === 'fill-pattern') const ids = patternUpdates.map(([id]) => id) expect(ids).toContain('land-covers-130-131') @@ -503,14 +537,22 @@ describe('onMapSizeChange', () => { it('does not update fill-pattern for existing-fields (no fillPattern style)', async () => { map.setPaintProperty.mockClear() - await adapter.onMapSizeChange(MAP_STYLE) + await adapter.onMapSizeChange() const patternUpdates = map.setPaintProperty.mock.calls.filter(([, prop]) => prop === 'fill-pattern') expect(patternUpdates.map(([id]) => id)).not.toContain('existing-fields') }) it('calls addPatternsAndSymbolsToMap', async () => { mapProvider.addPatternsToMap.mockClear() - await adapter.onMapSizeChange(MAP_STYLE) + await adapter.onMapSizeChange() expect(mapProvider.addPatternsToMap).toHaveBeenCalled() }) + + it('does not call setLayoutProperty for icon-image when getSymbolImageId returns null', async () => { + jest.spyOn(symbolRegistry, 'getSymbolImageId').mockReturnValue(null) + map.setLayoutProperty.mockClear() + await adapter.onMapSizeChange() + const symbolUpdates = map.setLayoutProperty.mock.calls.filter(([, prop]) => prop === 'icon-image') + expect(symbolUpdates).toHaveLength(0) + }) }) diff --git a/plugins/beta/datasets/src/adapters/maplibre/registry/mapLibreDataset.js b/plugins/datasets/src/adapters/maplibre/registry/mapLibreDataset.js similarity index 94% rename from plugins/beta/datasets/src/adapters/maplibre/registry/mapLibreDataset.js rename to plugins/datasets/src/adapters/maplibre/registry/mapLibreDataset.js index 0bd1e77db..5538472af 100644 --- a/plugins/beta/datasets/src/adapters/maplibre/registry/mapLibreDataset.js +++ b/plugins/datasets/src/adapters/maplibre/registry/mapLibreDataset.js @@ -1,12 +1,10 @@ import { Dataset } from '../../../registry/dataset.js' -import { hashString } from '../../../../../../../src/utils/patternUtils.js' -import { anchorToMaplibre } from '../../../../../../../providers/maplibre/src/utils/symbolImages.js' -import { logger } from '../../../../../../../src/services/logger.js' +import { hashString } from '../../../../../../src/utils/patternUtils.js' +import { anchorToMaplibre } from '../../../../../../providers/maplibre/src/utils/symbolImages.js' +import { logger } from '../../../../../../src/services/logger.js' const MAX_TILE_ZOOM = 22 export class MapLibreDataset extends Dataset { - get visibility () { return this.visible ? 'visible' : 'none' } - get fillLayerId () { if (this.hasSublayers) { return null diff --git a/plugins/beta/datasets/src/adapters/maplibre/registry/mapLibreDataset.test.js b/plugins/datasets/src/adapters/maplibre/registry/mapLibreDataset.test.js similarity index 96% rename from plugins/beta/datasets/src/adapters/maplibre/registry/mapLibreDataset.test.js rename to plugins/datasets/src/adapters/maplibre/registry/mapLibreDataset.test.js index d2a9c1cb3..cecfb52ad 100644 --- a/plugins/beta/datasets/src/adapters/maplibre/registry/mapLibreDataset.test.js +++ b/plugins/datasets/src/adapters/maplibre/registry/mapLibreDataset.test.js @@ -1,12 +1,10 @@ import { MapLibreDataset } from './mapLibreDataset.js' import { datasetRegistry } from '../../../registry/datasetRegistry.js' -import { logger } from '../../../../../../../src/services/logger.js' +import { logger } from '../../../../../../src/services/logger.js' // Use the mock datasetRegistry with the demo datasets attached before each test // so we can test Dataset methods that depend on parent/sublayer relationships and styles jest.mock('../../../registry/datasetRegistry.js') -jest.mock('../../../../../../../src/services/logger.js', () => ({ - logger: { warn: jest.fn() } -})) +jest.mock('../../../../../../src/services/logger.js') describe('MapLibreDataset', () => { beforeEach(() => { @@ -309,6 +307,14 @@ describe('MapLibreDataset', () => { expect(datasetRegistry.getDataset('ds-string-with-prop').source).toEqual(expect.objectContaining({ type: 'geojson' })) expect(logger.warn).not.toHaveBeenCalled() }) + + it('adds promoteId keyed by sourceLayer when a vector source has both idProperty and sourceLayer', () => { + datasetRegistry.mockExtend({ 'ds-tiles-id-prop': { id: 'ds-tiles-id-prop', tiles: ['https://example.com/{z}/{x}/{y}'], idProperty: 'myId', sourceLayer: 'my-layer' } }) + expect(datasetRegistry.getDataset('ds-tiles-id-prop').source).toEqual(expect.objectContaining({ + type: 'vector', + promoteId: { 'my-layer': 'myId' } + })) + }) }) describe('getSymbolSource', () => { @@ -438,6 +444,10 @@ describe('MapLibreDataset', () => { expect(datasetRegistry.getDataset('land-covers')._hiddenFeaturesIdExpression).toEqual(['to-string', ['get', 'id']]) }) + it('uses get(idProperty) for a non-dynamic dataset with idProperty set', () => { + expect(datasetRegistry.getDataset('ds-no-transform')._hiddenFeaturesIdExpression).toEqual(['to-string', ['get', 'id']]) + }) + it('uses the feature id when idProperty is not set', () => { expect(datasetRegistry.getDataset('ds-bare')._hiddenFeaturesIdExpression).toEqual(['to-string', ['id']]) }) diff --git a/plugins/beta/datasets/src/api/addDataset.js b/plugins/datasets/src/api/addDataset.js similarity index 100% rename from plugins/beta/datasets/src/api/addDataset.js rename to plugins/datasets/src/api/addDataset.js diff --git a/plugins/beta/datasets/src/api/addDataset.test.js b/plugins/datasets/src/api/addDataset.test.js similarity index 100% rename from plugins/beta/datasets/src/api/addDataset.test.js rename to plugins/datasets/src/api/addDataset.test.js diff --git a/plugins/beta/datasets/src/api/getOpacity.js b/plugins/datasets/src/api/getOpacity.js similarity index 89% rename from plugins/beta/datasets/src/api/getOpacity.js rename to plugins/datasets/src/api/getOpacity.js index 53b9fb61b..58c9b1c15 100644 --- a/plugins/beta/datasets/src/api/getOpacity.js +++ b/plugins/datasets/src/api/getOpacity.js @@ -1,4 +1,4 @@ -import { logger } from '../../../../../src/services/logger.js' +import { logger } from '../../../../src/services/logger.js' import { datasetRegistry } from '../registry/datasetRegistry.js' export const getOpacity = ({ pluginState: { globals } }, options = {}) => { diff --git a/plugins/beta/datasets/src/api/getOpacity.test.js b/plugins/datasets/src/api/getOpacity.test.js similarity index 94% rename from plugins/beta/datasets/src/api/getOpacity.test.js rename to plugins/datasets/src/api/getOpacity.test.js index a1e943e0b..191a4d28b 100644 --- a/plugins/beta/datasets/src/api/getOpacity.test.js +++ b/plugins/datasets/src/api/getOpacity.test.js @@ -1,14 +1,12 @@ import { getOpacity } from './getOpacity.js' import { datasetRegistry } from '../registry/datasetRegistry.js' -import { logger } from '../../../../../src/services/logger.js' +import { logger } from '../../../../src/services/logger.js' jest.mock('../registry/datasetRegistry.js', () => ({ datasetRegistry: { getDataset: jest.fn() } })) -jest.mock('../../../../../src/services/logger.js', () => ({ - logger: { warn: jest.fn() } -})) +jest.mock('../../../../src/services/logger.js') describe('getOpacity', () => { beforeEach(() => { diff --git a/plugins/beta/datasets/src/api/getStyle.js b/plugins/datasets/src/api/getStyle.js similarity index 70% rename from plugins/beta/datasets/src/api/getStyle.js rename to plugins/datasets/src/api/getStyle.js index 0a6cd1c94..0f26d3401 100644 --- a/plugins/beta/datasets/src/api/getStyle.js +++ b/plugins/datasets/src/api/getStyle.js @@ -1,7 +1,7 @@ -import { logger } from '../../../../../src/services/logger.js' +import { logger } from '../../../../src/services/logger.js' import { datasetRegistry } from '../registry/datasetRegistry.js' -export const getStyle = ({ pluginState }, { datasetId, sublayerId } = {}) => { +export const getStyle = ({ _pluginState }, { datasetId, sublayerId } = {}) => { datasetId = sublayerId ? `${datasetId}-${sublayerId}` : datasetId const registryDataset = datasetRegistry.getDataset(datasetId) if (!registryDataset) { diff --git a/plugins/beta/datasets/src/api/getStyle.test.js b/plugins/datasets/src/api/getStyle.test.js similarity index 90% rename from plugins/beta/datasets/src/api/getStyle.test.js rename to plugins/datasets/src/api/getStyle.test.js index fc1b62327..6a40f8d7c 100644 --- a/plugins/beta/datasets/src/api/getStyle.test.js +++ b/plugins/datasets/src/api/getStyle.test.js @@ -1,14 +1,12 @@ import { getStyle } from './getStyle.js' import { datasetRegistry } from '../registry/datasetRegistry.js' -import { logger } from '../../../../../src/services/logger.js' +import { logger } from '../../../../src/services/logger.js' jest.mock('../registry/datasetRegistry.js', () => ({ datasetRegistry: { getDataset: jest.fn() } })) -jest.mock('../../../../../src/services/logger.js', () => ({ - logger: { warn: jest.fn() } -})) +jest.mock('../../../../src/services/logger.js') describe('getStyle', () => { beforeEach(() => { diff --git a/plugins/beta/datasets/src/api/removeDataset.js b/plugins/datasets/src/api/removeDataset.js similarity index 100% rename from plugins/beta/datasets/src/api/removeDataset.js rename to plugins/datasets/src/api/removeDataset.js diff --git a/plugins/beta/datasets/src/api/removeDataset.test.js b/plugins/datasets/src/api/removeDataset.test.js similarity index 100% rename from plugins/beta/datasets/src/api/removeDataset.test.js rename to plugins/datasets/src/api/removeDataset.test.js diff --git a/plugins/beta/datasets/src/api/setData.js b/plugins/datasets/src/api/setData.js similarity index 78% rename from plugins/beta/datasets/src/api/setData.js rename to plugins/datasets/src/api/setData.js index cf0ee23bd..a63f4f640 100644 --- a/plugins/beta/datasets/src/api/setData.js +++ b/plugins/datasets/src/api/setData.js @@ -1,8 +1,8 @@ -import { logger } from '../../../../../src/services/logger.js' +import { logger } from '../../../../src/services/logger.js' import { datasetRegistry } from '../registry/datasetRegistry.js' import { layerAdapter } from '../adapters/loadLayerAdapter.js' -export const setData = ({ pluginState }, geojson, { datasetId }) => { +export const setData = ({ _pluginState }, geojson, { datasetId }) => { const registryDataset = datasetRegistry.getDataset(datasetId) if (!registryDataset) { logger.warn(`setData: Dataset with id ${datasetId} not found`) diff --git a/plugins/beta/datasets/src/api/setData.test.js b/plugins/datasets/src/api/setData.test.js similarity index 92% rename from plugins/beta/datasets/src/api/setData.test.js rename to plugins/datasets/src/api/setData.test.js index 80b181428..43ace4c02 100644 --- a/plugins/beta/datasets/src/api/setData.test.js +++ b/plugins/datasets/src/api/setData.test.js @@ -1,6 +1,6 @@ import { setData } from './setData.js' import { datasetRegistry } from '../registry/datasetRegistry.js' -import { logger } from '../../../../../src/services/logger.js' +import { logger } from '../../../../src/services/logger.js' import { layerAdapter } from '../adapters/loadLayerAdapter.js' jest.mock('../adapters/loadLayerAdapter.js', () => ({ @@ -13,9 +13,7 @@ jest.mock('../registry/datasetRegistry.js', () => ({ datasetRegistry: { getDataset: jest.fn() } })) -jest.mock('../../../../../src/services/logger.js', () => ({ - logger: { warn: jest.fn() } -})) +jest.mock('../../../../src/services/logger.js') describe('setData', () => { const geojson = { type: 'FeatureCollection', features: [] } diff --git a/plugins/beta/datasets/src/api/setDatasetVisibility.js b/plugins/datasets/src/api/setDatasetVisibility.js similarity index 100% rename from plugins/beta/datasets/src/api/setDatasetVisibility.js rename to plugins/datasets/src/api/setDatasetVisibility.js diff --git a/plugins/beta/datasets/src/api/setDatasetVisibility.test.js b/plugins/datasets/src/api/setDatasetVisibility.test.js similarity index 100% rename from plugins/beta/datasets/src/api/setDatasetVisibility.test.js rename to plugins/datasets/src/api/setDatasetVisibility.test.js diff --git a/plugins/beta/datasets/src/api/setFeatureVisibility.js b/plugins/datasets/src/api/setFeatureVisibility.js similarity index 91% rename from plugins/beta/datasets/src/api/setFeatureVisibility.js rename to plugins/datasets/src/api/setFeatureVisibility.js index 1f3701ce4..7f7c09a35 100644 --- a/plugins/beta/datasets/src/api/setFeatureVisibility.js +++ b/plugins/datasets/src/api/setFeatureVisibility.js @@ -1,4 +1,4 @@ -import { logger } from '../../../../../src/services/logger.js' +import { logger } from '../../../../src/services/logger.js' import { datasetRegistry } from '../registry/datasetRegistry.js' export const setFeatureVisibility = ({ pluginState: { dispatch } }, visible, featureIds, { datasetId = null } = {}) => { diff --git a/plugins/beta/datasets/src/api/setFeatureVisibility.test.js b/plugins/datasets/src/api/setFeatureVisibility.test.js similarity index 94% rename from plugins/beta/datasets/src/api/setFeatureVisibility.test.js rename to plugins/datasets/src/api/setFeatureVisibility.test.js index 0dd5b5caf..4c7b910d4 100644 --- a/plugins/beta/datasets/src/api/setFeatureVisibility.test.js +++ b/plugins/datasets/src/api/setFeatureVisibility.test.js @@ -1,14 +1,12 @@ import { setFeatureVisibility } from './setFeatureVisibility.js' import { datasetRegistry } from '../registry/datasetRegistry.js' -import { logger } from '../../../../../src/services/logger.js' +import { logger } from '../../../../src/services/logger.js' jest.mock('../registry/datasetRegistry.js', () => ({ datasetRegistry: { getDataset: jest.fn() } })) -jest.mock('../../../../../src/services/logger.js', () => ({ - logger: { warn: jest.fn() } -})) +jest.mock('../../../../src/services/logger.js') describe('setFeatureVisibility', () => { const dispatch = jest.fn() diff --git a/plugins/beta/datasets/src/api/setGlobals.js b/plugins/datasets/src/api/setGlobals.js similarity index 90% rename from plugins/beta/datasets/src/api/setGlobals.js rename to plugins/datasets/src/api/setGlobals.js index 10195266d..99c16d90b 100644 --- a/plugins/beta/datasets/src/api/setGlobals.js +++ b/plugins/datasets/src/api/setGlobals.js @@ -1,4 +1,4 @@ -import { logger } from '../../../../../src/services/logger.js' +import { logger } from '../../../../src/services/logger.js' export const setGlobals = ({ pluginState: { dispatch } }, values) => { const { opacityMode } = values const payload = {} diff --git a/plugins/beta/datasets/src/api/setGlobals.test.js b/plugins/datasets/src/api/setGlobals.test.js similarity index 92% rename from plugins/beta/datasets/src/api/setGlobals.test.js rename to plugins/datasets/src/api/setGlobals.test.js index bb0e16512..f4bfcfa82 100644 --- a/plugins/beta/datasets/src/api/setGlobals.test.js +++ b/plugins/datasets/src/api/setGlobals.test.js @@ -1,9 +1,7 @@ import { setGlobals } from './setGlobals.js' -import { logger } from '../../../../../src/services/logger.js' +import { logger } from '../../../../src/services/logger.js' -jest.mock('../../../../../src/services/logger.js', () => ({ - logger: { warn: jest.fn() } -})) +jest.mock('../../../../src/services/logger.js') describe('setGlobals', () => { const dispatch = jest.fn() diff --git a/plugins/beta/datasets/src/api/setOpacity.js b/plugins/datasets/src/api/setOpacity.js similarity index 100% rename from plugins/beta/datasets/src/api/setOpacity.js rename to plugins/datasets/src/api/setOpacity.js diff --git a/plugins/beta/datasets/src/api/setOpacity.test.js b/plugins/datasets/src/api/setOpacity.test.js similarity index 100% rename from plugins/beta/datasets/src/api/setOpacity.test.js rename to plugins/datasets/src/api/setOpacity.test.js diff --git a/plugins/beta/datasets/src/api/setStyle.js b/plugins/datasets/src/api/setStyle.js similarity index 100% rename from plugins/beta/datasets/src/api/setStyle.js rename to plugins/datasets/src/api/setStyle.js diff --git a/plugins/beta/datasets/src/api/setStyle.test.js b/plugins/datasets/src/api/setStyle.test.js similarity index 100% rename from plugins/beta/datasets/src/api/setStyle.test.js rename to plugins/datasets/src/api/setStyle.test.js diff --git a/plugins/beta/datasets/src/components/Key/EmptyKey.jsx b/plugins/datasets/src/components/Key/EmptyKey.jsx similarity index 100% rename from plugins/beta/datasets/src/components/Key/EmptyKey.jsx rename to plugins/datasets/src/components/Key/EmptyKey.jsx diff --git a/plugins/beta/datasets/src/components/Key/EmptyKey.test.jsx b/plugins/datasets/src/components/Key/EmptyKey.test.jsx similarity index 100% rename from plugins/beta/datasets/src/components/Key/EmptyKey.test.jsx rename to plugins/datasets/src/components/Key/EmptyKey.test.jsx diff --git a/plugins/beta/datasets/src/components/Key/Key.jsx b/plugins/datasets/src/components/Key/Key.jsx similarity index 97% rename from plugins/beta/datasets/src/components/Key/Key.jsx rename to plugins/datasets/src/components/Key/Key.jsx index 40d9d3ff6..2b3582d5f 100755 --- a/plugins/beta/datasets/src/components/Key/Key.jsx +++ b/plugins/datasets/src/components/Key/Key.jsx @@ -7,7 +7,6 @@ import { datasetRegistry } from '../../registry/datasetRegistry.js' export const Key = ({ pluginConfig: { noKeyItemText }, mapState: { mapStyle }, - pluginState: { mappedDatasets }, services: { symbolRegistry, patternRegistry } }) => { const { items: keyGroups, hasGroups } = datasetRegistry.keyItems() diff --git a/plugins/beta/datasets/src/components/Key/Key.module.scss b/plugins/datasets/src/components/Key/Key.module.scss similarity index 100% rename from plugins/beta/datasets/src/components/Key/Key.module.scss rename to plugins/datasets/src/components/Key/Key.module.scss diff --git a/plugins/beta/datasets/src/components/Key/KeyGroupItem.jsx b/plugins/datasets/src/components/Key/KeyGroupItem.jsx similarity index 100% rename from plugins/beta/datasets/src/components/Key/KeyGroupItem.jsx rename to plugins/datasets/src/components/Key/KeyGroupItem.jsx diff --git a/plugins/beta/datasets/src/components/Key/KeyItem.jsx b/plugins/datasets/src/components/Key/KeyItem.jsx similarity index 84% rename from plugins/beta/datasets/src/components/Key/KeyItem.jsx rename to plugins/datasets/src/components/Key/KeyItem.jsx index f931a7dc0..439aed354 100644 --- a/plugins/beta/datasets/src/components/Key/KeyItem.jsx +++ b/plugins/datasets/src/components/Key/KeyItem.jsx @@ -1,7 +1,10 @@ -import { getValueForStyle } from '../../../../../../src/utils/getValueForStyle.js' +import { getValueForStyle } from '../../../../../src/utils/getValueForStyle.js' import { KeySvg } from './KeySvg.jsx' export const KeyItem = ({ registryDataset, symbolRegistry, patternRegistry, mapStyle }) => { + if (!registryDataset.showInKey) { + return null + } return (
diff --git a/plugins/beta/datasets/src/components/Key/KeySvg.jsx b/plugins/datasets/src/components/Key/KeySvg.jsx similarity index 100% rename from plugins/beta/datasets/src/components/Key/KeySvg.jsx rename to plugins/datasets/src/components/Key/KeySvg.jsx diff --git a/plugins/beta/datasets/src/components/Key/KeySvg.test.jsx b/plugins/datasets/src/components/Key/KeySvg.test.jsx similarity index 95% rename from plugins/beta/datasets/src/components/Key/KeySvg.test.jsx rename to plugins/datasets/src/components/Key/KeySvg.test.jsx index 494e9c4f2..4020fd161 100644 --- a/plugins/beta/datasets/src/components/Key/KeySvg.test.jsx +++ b/plugins/datasets/src/components/Key/KeySvg.test.jsx @@ -1,8 +1,8 @@ import { render } from '@testing-library/react' import { KeySvg } from './KeySvg' -import { symbolRegistry } from '../../../../../../src/services/symbolRegistry.js' -import { patternRegistry } from '../../../../../../src/services/patternRegistry.js' +import { symbolRegistry } from '../../../../../src/services/symbolRegistry.js' +import { patternRegistry } from '../../../../../src/services/patternRegistry.js' const getSymbolDef = jest.spyOn(symbolRegistry, 'getSymbolDef') diff --git a/plugins/beta/datasets/src/components/Key/KeySvgLine.jsx b/plugins/datasets/src/components/Key/KeySvgLine.jsx similarity index 85% rename from plugins/beta/datasets/src/components/Key/KeySvgLine.jsx rename to plugins/datasets/src/components/Key/KeySvgLine.jsx index 13fb3d87c..7d5b165ad 100644 --- a/plugins/beta/datasets/src/components/Key/KeySvgLine.jsx +++ b/plugins/datasets/src/components/Key/KeySvgLine.jsx @@ -1,5 +1,5 @@ import { svgProps, SVG_SIZE, SVG_CENTER } from './svgProperties.js' -import { getValueForStyle } from '../../../../../../src/utils/getValueForStyle.js' +import { getValueForStyle } from '../../../../../src/utils/getValueForStyle.js' export const KeySvgLine = ({ mapStyle, registryDataset }) => { const { style } = registryDataset diff --git a/plugins/beta/datasets/src/components/Key/KeySvgLine.test.jsx b/plugins/datasets/src/components/Key/KeySvgLine.test.jsx similarity index 93% rename from plugins/beta/datasets/src/components/Key/KeySvgLine.test.jsx rename to plugins/datasets/src/components/Key/KeySvgLine.test.jsx index 866aa3300..4a596b80a 100644 --- a/plugins/beta/datasets/src/components/Key/KeySvgLine.test.jsx +++ b/plugins/datasets/src/components/Key/KeySvgLine.test.jsx @@ -1,9 +1,9 @@ import { render } from '@testing-library/react' import { KeySvgLine } from './KeySvgLine' -import { getValueForStyle } from '../../../../../../src/utils/getValueForStyle' +import { getValueForStyle } from '../../../../../src/utils/getValueForStyle' -jest.mock('../../../../../../src/utils/getValueForStyle', () => ({ +jest.mock('../../../../../src/utils/getValueForStyle', () => ({ getValueForStyle: jest.fn((value) => value) })) diff --git a/plugins/beta/datasets/src/components/Key/KeySvgPattern.jsx b/plugins/datasets/src/components/Key/KeySvgPattern.jsx similarity index 100% rename from plugins/beta/datasets/src/components/Key/KeySvgPattern.jsx rename to plugins/datasets/src/components/Key/KeySvgPattern.jsx diff --git a/plugins/beta/datasets/src/components/Key/KeySvgPattern.test.jsx b/plugins/datasets/src/components/Key/KeySvgPattern.test.jsx similarity index 95% rename from plugins/beta/datasets/src/components/Key/KeySvgPattern.test.jsx rename to plugins/datasets/src/components/Key/KeySvgPattern.test.jsx index c806168a5..209d9953f 100644 --- a/plugins/beta/datasets/src/components/Key/KeySvgPattern.test.jsx +++ b/plugins/datasets/src/components/Key/KeySvgPattern.test.jsx @@ -1,6 +1,6 @@ import { render } from '@testing-library/react' import { KeySvgPattern } from './KeySvgPattern' -import { patternRegistry } from '../../../../../../src/services/patternRegistry.js' +import { patternRegistry } from '../../../../../src/services/patternRegistry.js' const getKeyPatternPaths = jest.spyOn(patternRegistry, 'getKeyPatternPaths') const defaultProps = { diff --git a/plugins/beta/datasets/src/components/Key/KeySvgRect.jsx b/plugins/datasets/src/components/Key/KeySvgRect.jsx similarity index 88% rename from plugins/beta/datasets/src/components/Key/KeySvgRect.jsx rename to plugins/datasets/src/components/Key/KeySvgRect.jsx index 86f233309..ac0bab836 100644 --- a/plugins/beta/datasets/src/components/Key/KeySvgRect.jsx +++ b/plugins/datasets/src/components/Key/KeySvgRect.jsx @@ -1,4 +1,4 @@ -import { getValueForStyle } from '../../../../../../src/utils/getValueForStyle.js' +import { getValueForStyle } from '../../../../../src/utils/getValueForStyle.js' import { svgProps, SVG_SIZE } from './svgProperties.js' export const KeySvgRect = ({ mapStyle, registryDataset }) => { diff --git a/plugins/beta/datasets/src/components/Key/KeySvgRect.test.jsx b/plugins/datasets/src/components/Key/KeySvgRect.test.jsx similarity index 94% rename from plugins/beta/datasets/src/components/Key/KeySvgRect.test.jsx rename to plugins/datasets/src/components/Key/KeySvgRect.test.jsx index 0365d8069..30da5bc72 100644 --- a/plugins/beta/datasets/src/components/Key/KeySvgRect.test.jsx +++ b/plugins/datasets/src/components/Key/KeySvgRect.test.jsx @@ -1,9 +1,9 @@ import { render } from '@testing-library/react' import { KeySvgRect } from './KeySvgRect' -import { getValueForStyle } from '../../../../../../src/utils/getValueForStyle' +import { getValueForStyle } from '../../../../../src/utils/getValueForStyle' -jest.mock('../../../../../../src/utils/getValueForStyle', () => ({ +jest.mock('../../../../../src/utils/getValueForStyle', () => ({ getValueForStyle: jest.fn((value) => value) })) diff --git a/plugins/beta/datasets/src/components/Key/KeySvgSymbol.jsx b/plugins/datasets/src/components/Key/KeySvgSymbol.jsx similarity index 94% rename from plugins/beta/datasets/src/components/Key/KeySvgSymbol.jsx rename to plugins/datasets/src/components/Key/KeySvgSymbol.jsx index e2b92f246..7e21a026e 100644 --- a/plugins/beta/datasets/src/components/Key/KeySvgSymbol.jsx +++ b/plugins/datasets/src/components/Key/KeySvgSymbol.jsx @@ -1,4 +1,4 @@ -import { getSymbolStyleColors, getSymbolViewBox } from '../../../../../../src/utils/symbolUtils.js' +import { getSymbolStyleColors, getSymbolViewBox } from '../../../../../src/utils/symbolUtils.js' import { svgSymbolProps } from './svgProperties.js' export const KeySvgSymbol = ({ symbolRegistry, registryDataset, mapStyle, symbolDef }) => { diff --git a/plugins/beta/datasets/src/components/Key/KeySvgSymbol.test.jsx b/plugins/datasets/src/components/Key/KeySvgSymbol.test.jsx similarity index 96% rename from plugins/beta/datasets/src/components/Key/KeySvgSymbol.test.jsx rename to plugins/datasets/src/components/Key/KeySvgSymbol.test.jsx index 4436c78f5..920fd3e28 100644 --- a/plugins/beta/datasets/src/components/Key/KeySvgSymbol.test.jsx +++ b/plugins/datasets/src/components/Key/KeySvgSymbol.test.jsx @@ -1,9 +1,9 @@ import { render } from '@testing-library/react' import { KeySvgSymbol } from './KeySvgSymbol' -import { getSymbolStyleColors, getSymbolViewBox } from '../../../../../../src/utils/symbolUtils.js' +import { getSymbolStyleColors, getSymbolViewBox } from '../../../../../src/utils/symbolUtils.js' -jest.mock('../../../../../../src/utils/symbolUtils.js', () => ({ +jest.mock('../../../../../src/utils/symbolUtils.js', () => ({ getSymbolStyleColors: jest.fn(() => ({ foreground: '#000', background: '#fff' })), getSymbolViewBox: jest.fn(() => '0 0 38 38') })) diff --git a/plugins/beta/datasets/src/components/Key/svgProperties.js b/plugins/datasets/src/components/Key/svgProperties.js similarity index 100% rename from plugins/beta/datasets/src/components/Key/svgProperties.js rename to plugins/datasets/src/components/Key/svgProperties.js diff --git a/plugins/beta/datasets/src/components/Key/svgProperties.test.js b/plugins/datasets/src/components/Key/svgProperties.test.js similarity index 100% rename from plugins/beta/datasets/src/components/Key/svgProperties.test.js rename to plugins/datasets/src/components/Key/svgProperties.test.js diff --git a/plugins/beta/datasets/src/components/LayersMenu/Layers.module.scss b/plugins/datasets/src/components/LayersMenu/Layers.module.scss similarity index 91% rename from plugins/beta/datasets/src/components/LayersMenu/Layers.module.scss rename to plugins/datasets/src/components/LayersMenu/Layers.module.scss index ce035ba65..da6b1ba0f 100644 --- a/plugins/beta/datasets/src/components/LayersMenu/Layers.module.scss +++ b/plugins/datasets/src/components/LayersMenu/Layers.module.scss @@ -51,10 +51,11 @@ } // GovUK style overide -.im-c-datasets-layers__item .govuk-checkboxes__item { +.im-c-datasets-layers__item .govuk-checkboxes__item, +.im-c-datasets-layers__item .govuk-radios__item { flex-wrap: nowrap; - .govuk-checkboxes__input { + .govuk-checkboxes__input, .govuk-radios__input { margin-left: -3px; } } diff --git a/plugins/datasets/src/components/LayersMenu/LayersMenu.jsx b/plugins/datasets/src/components/LayersMenu/LayersMenu.jsx new file mode 100644 index 000000000..d3998299c --- /dev/null +++ b/plugins/datasets/src/components/LayersMenu/LayersMenu.jsx @@ -0,0 +1,50 @@ +import React from 'react' +import { setDatasetVisibility } from '../../api/setDatasetVisibility.js' +import { LayersMenuCheckbox } from './LayersMenuCheckbox.jsx' +import { LayersRadioGroupWrapper } from './LayersRadioGroupWrapper.jsx' +import { LayersMenuGroupWrapper } from './LayersMenuGroupWrapper.jsx' + +export const LayersMenu = ({ pluginState }) => { + const { menu = [] } = pluginState + + const handleDatasetChange = (e) => { + const { value, checked } = e.target + setDatasetVisibility({ pluginState }, checked, { datasetId: value }) + } + + const hasGroups = menu.some(item => item.groupLabel) + const containerClass = `im-c-datasets-layers${hasGroups ? ' im-c-datasets-layers--has-groups' : ''}` + return ( +
+ {// Each menuGroup + menu.map(menuGroup => { + const { type } = menuGroup + if (type === 'checkbox') { + return ( + { + // Each menuGroupItem + menuGroup.items.map(menuGroupItem => ( + ) + ) + } + + ) + } else { + return ( + + ) + } + } + ) + } +
+ ) +} diff --git a/plugins/datasets/src/components/LayersMenu/LayersMenu.test.jsx b/plugins/datasets/src/components/LayersMenu/LayersMenu.test.jsx new file mode 100644 index 000000000..ad5c1169f --- /dev/null +++ b/plugins/datasets/src/components/LayersMenu/LayersMenu.test.jsx @@ -0,0 +1,175 @@ +import { render } from '@testing-library/react' +import { LayersMenu } from './LayersMenu.jsx' +import { setDatasetVisibility } from '../../api/setDatasetVisibility.js' + +jest.mock('../../api/setDatasetVisibility.js', () => ({ + setDatasetVisibility: jest.fn() +})) + +let capturedOnChange = null + +jest.mock('./LayersMenuCheckbox.jsx', () => ({ + LayersMenuCheckbox: ({ menuGroupItem, onChange }) => { + capturedOnChange = onChange + return ( +
+ ) + } +})) + +jest.mock('./LayersRadioGroupWrapper.jsx', () => ({ + LayersRadioGroupWrapper: ({ menuGroup }) => ( +
+ ) +})) + +jest.mock('./LayersMenuGroupWrapper.jsx', () => ({ + LayersMenuGroupWrapper: ({ menuGroup, children }) => ( +
+ {children} +
+ ) +})) + +const makePluginState = (menu = [], extra = {}) => ({ + menu, + dispatch: jest.fn(), + ...extra +}) + +describe('LayersMenu', () => { + beforeEach(() => { + setDatasetVisibility.mockClear() + }) + + describe('container class', () => { + it('renders the base container class when no groups have a groupLabel', () => { + const pluginState = makePluginState([{ id: 'g1', type: 'checkbox', items: [] }]) + const { container } = render() + const div = container.firstChild + expect(div.className).toBe('im-c-datasets-layers') + }) + + it('adds the --has-groups modifier when at least one group has a groupLabel', () => { + const pluginState = makePluginState([{ id: 'g1', type: 'checkbox', groupLabel: 'My Group', items: [] }]) + const { container } = render() + const div = container.firstChild + expect(div.className).toBe('im-c-datasets-layers im-c-datasets-layers--has-groups') + }) + }) + + describe('empty menu', () => { + it('renders the container with no children when menu is empty', () => { + const pluginState = makePluginState([]) + const { container } = render() + expect(container.firstChild.children).toHaveLength(0) + }) + + it('uses an empty array when menu is not provided', () => { + const { container } = render() + expect(container.firstChild.children).toHaveLength(0) + }) + }) + + describe('checkbox groups', () => { + it('renders a LayersMenuGroupWrapper for each checkbox group', () => { + const pluginState = makePluginState([ + { id: 'g1', type: 'checkbox', items: [] }, + { id: 'g2', type: 'checkbox', items: [] } + ]) + const { getAllByTestId } = render() + expect(getAllByTestId('layers-menu-group-wrapper')).toHaveLength(2) + }) + + it('renders a LayersMenuCheckbox for each item in a checkbox group', () => { + const pluginState = makePluginState([ + { id: 'g1', type: 'checkbox', items: [{ id: 'item-a' }, { id: 'item-b' }] } + ]) + const { getAllByTestId } = render() + expect(getAllByTestId('layers-menu-checkbox')).toHaveLength(2) + }) + + it('passes the correct group to LayersMenuGroupWrapper', () => { + const group = { id: 'group-x', type: 'checkbox', items: [] } + const pluginState = makePluginState([group]) + const { getByTestId } = render() + expect(getByTestId('layers-menu-group-wrapper').dataset.groupId).toBe('group-x') + }) + + it('passes the correct item to LayersMenuCheckbox', () => { + const pluginState = makePluginState([ + { id: 'g1', type: 'checkbox', items: [{ id: 'item-1' }] } + ]) + const { getByTestId } = render() + expect(getByTestId('layers-menu-checkbox').dataset.itemId).toBe('item-1') + }) + }) + + describe('radio groups', () => { + it('renders a LayersRadioGroupWrapper for non-checkbox groups', () => { + const pluginState = makePluginState([ + { id: 'r1', type: 'radio', items: [{ id: 'opt1' }, { id: 'opt2' }] } + ]) + const { getAllByTestId } = render() + expect(getAllByTestId('layers-radio-group-wrapper')).toHaveLength(1) + }) + + it('passes the correct group to LayersRadioGroupWrapper', () => { + const pluginState = makePluginState([ + { id: 'radio-group-1', type: 'radio', items: [] } + ]) + const { getByTestId } = render() + expect(getByTestId('layers-radio-group-wrapper').dataset.groupId).toBe('radio-group-1') + }) + + it('does not render a LayersMenuGroupWrapper for radio groups', () => { + const pluginState = makePluginState([{ id: 'r1', type: 'radio', items: [] }]) + const { queryByTestId } = render() + expect(queryByTestId('layers-menu-group-wrapper')).toBeNull() + }) + }) + + describe('mixed groups', () => { + it('renders both checkbox and radio wrappers when both types are present', () => { + const pluginState = makePluginState([ + { id: 'c1', type: 'checkbox', items: [] }, + { id: 'r1', type: 'radio', items: [] } + ]) + const { getByTestId } = render() + expect(getByTestId('layers-menu-group-wrapper')).toBeTruthy() + expect(getByTestId('layers-radio-group-wrapper')).toBeTruthy() + }) + }) + + describe('handleDatasetChange', () => { + it('calls setDatasetVisibility with the correct arguments when a checkbox changes', () => { + const pluginState = makePluginState([ + { id: 'g1', type: 'checkbox', items: [{ id: 'dataset-abc' }] } + ]) + render() + capturedOnChange({ target: { value: 'dataset-abc', checked: true } }) + expect(setDatasetVisibility).toHaveBeenCalledTimes(1) + expect(setDatasetVisibility).toHaveBeenCalledWith( + { pluginState }, + true, + { datasetId: 'dataset-abc' } + ) + }) + + it('passes checked: false to setDatasetVisibility when unchecking', () => { + const pluginState = makePluginState([ + { id: 'g1', type: 'checkbox', items: [{ id: 'dataset-xyz' }] } + ]) + render() + capturedOnChange({ target: { value: 'dataset-xyz', checked: false } }) + expect(setDatasetVisibility).toHaveBeenCalledWith( + { pluginState }, + false, + { datasetId: 'dataset-xyz' } + ) + }) + }) +}) diff --git a/plugins/beta/datasets/src/components/LayersMenu/LayersMenuCheckbox.jsx b/plugins/datasets/src/components/LayersMenu/LayersMenuCheckbox.jsx similarity index 100% rename from plugins/beta/datasets/src/components/LayersMenu/LayersMenuCheckbox.jsx rename to plugins/datasets/src/components/LayersMenu/LayersMenuCheckbox.jsx diff --git a/plugins/datasets/src/components/LayersMenu/LayersMenuCheckbox.test.jsx b/plugins/datasets/src/components/LayersMenu/LayersMenuCheckbox.test.jsx new file mode 100644 index 000000000..6f26b0be7 --- /dev/null +++ b/plugins/datasets/src/components/LayersMenu/LayersMenuCheckbox.test.jsx @@ -0,0 +1,117 @@ +import { render, screen, act } from '@testing-library/react' +import { datasetRegistry } from '../../registry/datasetRegistry.js' +import { LayersMenuCheckbox } from './LayersMenuCheckbox.jsx' + +jest.mock('../../registry/datasetRegistry.js', () => ({ + datasetRegistry: { + getDataset: jest.fn() + } +})) + +const onChange = jest.fn() +const menuGroupItem = { id: 'dataset-1' } + +const baseDataset = { + id: 'dataset-1', + label: 'Dataset One', + visible: true, + isLocallyVisible: true, + isSublayer: false, + parentId: undefined +} + +beforeEach(() => { + datasetRegistry.getDataset.mockReset() + onChange.mockReset() +}) + +describe('LayersMenuCheckbox', () => { + describe('when the dataset is not in the registry', () => { + it('returns null', () => { + datasetRegistry.getDataset.mockReturnValue(undefined) + const { container } = render() + expect(container.firstChild).toBeNull() + }) + }) + + describe('when the dataset exists', () => { + it('renders a checkbox input', () => { + datasetRegistry.getDataset.mockReturnValue(baseDataset) + const { container } = render() + expect(container.querySelector('input[type="checkbox"]')).toBeTruthy() + }) + + it('renders the dataset label', () => { + datasetRegistry.getDataset.mockReturnValue(baseDataset) + render() + expect(screen.getByText('Dataset One')).toBeTruthy() + }) + + it('associates the label with the input via htmlFor', () => { + datasetRegistry.getDataset.mockReturnValue(baseDataset) + const { container } = render() + const label = container.querySelector('label') + const input = container.querySelector('input') + expect(label.htmlFor).toBe(input.id) + }) + + it('calls onChange when the checkbox changes', () => { + datasetRegistry.getDataset.mockReturnValue(baseDataset) + const { container } = render() + const input = container.querySelector('input') + const propsKey = Object.keys(input).find(k => k.startsWith('__reactProps')) + act(() => { input[propsKey].onChange({ target: { checked: true, value: input.value } }) }) + expect(onChange).toHaveBeenCalledTimes(1) + }) + }) + + describe('checked state', () => { + it('is checked when isLocallyVisible is true', () => { + datasetRegistry.getDataset.mockReturnValue({ ...baseDataset, isLocallyVisible: true }) + const { container } = render() + expect(container.querySelector('input').checked).toBe(true) + }) + + it('is unchecked when isLocallyVisible is false', () => { + datasetRegistry.getDataset.mockReturnValue({ ...baseDataset, isLocallyVisible: false }) + const { container } = render() + expect(container.querySelector('input').checked).toBe(false) + }) + }) + + describe('item class', () => { + it('does not include the --checked modifier when visible is true', () => { + datasetRegistry.getDataset.mockReturnValue({ ...baseDataset, visible: true }) + const { container } = render() + expect(container.firstChild.className).not.toContain('im-c-datasets-layers__item--checked') + }) + + it('includes the --checked modifier when visible is false', () => { + datasetRegistry.getDataset.mockReturnValue({ ...baseDataset, visible: false }) + const { container } = render() + expect(container.firstChild.className).toContain('im-c-datasets-layers__item--checked') + }) + }) + + describe('data attributes', () => { + it('uses the dataset id as data-dataset-id when not a sublayer', () => { + datasetRegistry.getDataset.mockReturnValue({ ...baseDataset, isSublayer: false }) + const { container } = render() + const input = container.querySelector('input') + expect(input.dataset.datasetId).toBe('dataset-1') + expect(input.dataset.sublayerId).toBeUndefined() + }) + + it('uses parentId as data-dataset-id when a sublayer', () => { + datasetRegistry.getDataset.mockReturnValue({ ...baseDataset, id: 'sub-1', isSublayer: true, parentId: 'parent-1' }) + const { container } = render() + expect(container.querySelector('input').dataset.datasetId).toBe('parent-1') + }) + + it('uses the sublayer id as data-sublayer-id when a sublayer', () => { + datasetRegistry.getDataset.mockReturnValue({ ...baseDataset, id: 'sub-1', isSublayer: true, parentId: 'parent-1' }) + const { container } = render() + expect(container.querySelector('input').dataset.sublayerId).toBe('sub-1') + }) + }) +}) diff --git a/plugins/beta/datasets/src/components/LayersMenu/LayersMenuGroupWrapper.jsx b/plugins/datasets/src/components/LayersMenu/LayersMenuGroupWrapper.jsx similarity index 64% rename from plugins/beta/datasets/src/components/LayersMenu/LayersMenuGroupWrapper.jsx rename to plugins/datasets/src/components/LayersMenu/LayersMenuGroupWrapper.jsx index 8467486a5..912948ac3 100644 --- a/plugins/beta/datasets/src/components/LayersMenu/LayersMenuGroupWrapper.jsx +++ b/plugins/datasets/src/components/LayersMenu/LayersMenuGroupWrapper.jsx @@ -2,9 +2,6 @@ export const LayersMenuGroupWrapper = ({ menuGroup, children }) => { if (!menuGroup.groupLabel) { return <>{children} } - // TODO - is there any need for im-c-datasets-layers-group--items-checked - it isn't used anywhere - // const anyItemsChecked = false - // const wrapperClass = `govuk-form-group im-c-datasets-layers-group${anyItemsChecked ? ' im-c-datasets-layers-group--items-checked' : ''}` const wrapperClass = 'govuk-form-group im-c-datasets-layers-group' return (
diff --git a/plugins/datasets/src/components/LayersMenu/LayersMenuGroupWrapper.test.jsx b/plugins/datasets/src/components/LayersMenu/LayersMenuGroupWrapper.test.jsx new file mode 100644 index 000000000..45865a3e4 --- /dev/null +++ b/plugins/datasets/src/components/LayersMenu/LayersMenuGroupWrapper.test.jsx @@ -0,0 +1,68 @@ +import { render, screen } from '@testing-library/react' +import { LayersMenuGroupWrapper } from './LayersMenuGroupWrapper' + +const child = Child content + +describe('LayersMenuGroupWrapper', () => { + describe('without a groupLabel', () => { + it('renders children directly without a wrapping div', () => { + const { getByTestId, container } = render( + {child} + ) + expect(getByTestId('child')).toBeTruthy() + expect(container.querySelector('.govuk-form-group')).toBeNull() + }) + + it('does not render a fieldset when groupLabel is absent', () => { + const { container } = render( + {child} + ) + expect(container.querySelector('fieldset')).toBeNull() + }) + + it('does not render a fieldset when groupLabel is an empty string', () => { + const { container } = render( + {child} + ) + expect(container.querySelector('fieldset')).toBeNull() + }) + }) + + describe('with a groupLabel', () => { + it('renders the outer wrapper with the correct classes', () => { + const { container } = render( + {child} + ) + const wrapper = container.querySelector('.govuk-form-group.im-c-datasets-layers-group') + expect(wrapper).toBeTruthy() + }) + + it('renders a fieldset with the correct class', () => { + const { container } = render( + {child} + ) + expect(container.querySelector('fieldset.im-c-datasets-layers-group__fieldset')).toBeTruthy() + }) + + it('renders the groupLabel as the legend text', () => { + render( + {child} + ) + expect(screen.getByText('My Group')).toBeTruthy() + }) + + it('renders the legend with the correct class', () => { + const { container } = render( + {child} + ) + expect(container.querySelector('legend.im-c-datasets-layers-group__legend')).toBeTruthy() + }) + + it('renders children inside the fieldset', () => { + const { getByTestId } = render( + {child} + ) + expect(getByTestId('child')).toBeTruthy() + }) + }) +}) diff --git a/plugins/datasets/src/components/LayersMenu/LayersMenuRadio.jsx b/plugins/datasets/src/components/LayersMenu/LayersMenuRadio.jsx new file mode 100644 index 000000000..0ffefb89c --- /dev/null +++ b/plugins/datasets/src/components/LayersMenu/LayersMenuRadio.jsx @@ -0,0 +1,28 @@ +import { isVisibleWhen } from '../../registry/isVisibleWhen.js' + +export const LayersMenuRadio = ({ menuGroupItem, name, checked, onChange }) => { + const itemClass = 'im-c-datasets-layers__item govuk-radios govuk-radios--small"' + const { visibleWhen } = menuGroupItem + const visible = visibleWhen ? isVisibleWhen(visibleWhen) : true + if (!visible) { + return null + } + return ( +
+
+ + +
+
+ ) +} diff --git a/plugins/datasets/src/components/LayersMenu/LayersMenuRadio.test.jsx b/plugins/datasets/src/components/LayersMenu/LayersMenuRadio.test.jsx new file mode 100644 index 000000000..fc62b4edd --- /dev/null +++ b/plugins/datasets/src/components/LayersMenu/LayersMenuRadio.test.jsx @@ -0,0 +1,105 @@ +import { render, screen, act } from '@testing-library/react' +import { isVisibleWhen } from '../../registry/isVisibleWhen.js' +import { LayersMenuRadio } from './LayersMenuRadio.jsx' + +jest.mock('../../registry/isVisibleWhen.js', () => ({ + isVisibleWhen: jest.fn() +})) + +const onChange = jest.fn() +const baseItem = { id: 'radio-1', label: 'Option One' } + +beforeEach(() => { + isVisibleWhen.mockReturnValue(true) + onChange.mockReset() +}) + +describe('LayersMenuRadio', () => { + describe('visibility', () => { + it('renders when visibleWhen is not set', () => { + const { container } = render( + + ) + expect(container.querySelector('input[type="radio"]')).toBeTruthy() + }) + + it('does not call isVisibleWhen when visibleWhen is not set', () => { + render() + expect(isVisibleWhen).not.toHaveBeenCalled() + }) + + it('calls isVisibleWhen with the visibleWhen value', () => { + const visibleWhen = { menu: ['someValue'] } + render() + expect(isVisibleWhen).toHaveBeenCalledWith(visibleWhen) + }) + + it('returns null when isVisibleWhen resolves to false', () => { + isVisibleWhen.mockReturnValue(false) + const { container } = render( + + ) + expect(container.firstChild).toBeNull() + }) + + it('renders when isVisibleWhen resolves to true', () => { + isVisibleWhen.mockReturnValue(true) + const { container } = render( + + ) + expect(container.querySelector('input[type="radio"]')).toBeTruthy() + }) + }) + + describe('rendered output', () => { + it('renders the item label text', () => { + render() + expect(screen.getByText('Option One')).toBeTruthy() + }) + + it('sets the name attribute on the input', () => { + const { container } = render( + + ) + expect(container.querySelector('input').name).toBe('my-radio-group') + }) + + it('sets checked to true when checked prop is true', () => { + const { container } = render( + + ) + expect(container.querySelector('input').checked).toBe(true) + }) + + it('sets checked to false when checked prop is false', () => { + const { container } = render( + + ) + expect(container.querySelector('input').checked).toBe(false) + }) + + it('sets the input value to the item id', () => { + const { container } = render( + + ) + expect(container.querySelector('input').value).toBe('radio-1') + }) + + it('associates the label with the input via htmlFor', () => { + const { container } = render( + + ) + expect(container.querySelector('label').htmlFor).toBe('radio-1') + }) + + it('calls onChange when the radio input changes', () => { + const { container } = render( + + ) + const input = container.querySelector('input') + const propsKey = Object.keys(input).find(k => k.startsWith('__reactProps')) + act(() => { input[propsKey].onChange({ target: { value: 'radio-1' } }) }) + expect(onChange).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/plugins/datasets/src/components/LayersMenu/LayersRadioGroupWrapper.jsx b/plugins/datasets/src/components/LayersMenu/LayersRadioGroupWrapper.jsx new file mode 100644 index 000000000..9f12b17b6 --- /dev/null +++ b/plugins/datasets/src/components/LayersMenu/LayersRadioGroupWrapper.jsx @@ -0,0 +1,39 @@ +import React from 'react' +import { isVisibleWhen } from '../../registry/isVisibleWhen.js' +import { LayersMenuRadio } from './LayersMenuRadio.jsx' + +export const LayersRadioGroupWrapper = ({ pluginState, menuGroup }) => { + const { id, items, visibleWhen } = menuGroup + const visible = visibleWhen ? isVisibleWhen(visibleWhen) : true + if (!visible) { + return null + } + + const { menuState, dispatch } = pluginState + const value = menuState[id] + const handleChange = (event) => { + dispatch({ type: 'UPDATE_MENU_STATE', payload: { [id]: event.target.value } }) + } + + const wrapperClass = 'govuk-form-group im-c-datasets-layers-group' + return ( +
+
+ + {menuGroup.label} + +
+ {items.map((menuGroupItem) => + + )} +
+
+
+ ) +} diff --git a/plugins/datasets/src/components/LayersMenu/LayersRadioGroupWrapper.test.jsx b/plugins/datasets/src/components/LayersMenu/LayersRadioGroupWrapper.test.jsx new file mode 100644 index 000000000..7e2004d3d --- /dev/null +++ b/plugins/datasets/src/components/LayersMenu/LayersRadioGroupWrapper.test.jsx @@ -0,0 +1,136 @@ +import { render, screen, act } from '@testing-library/react' +import { isVisibleWhen } from '../../registry/isVisibleWhen.js' +import { LayersRadioGroupWrapper } from './LayersRadioGroupWrapper.jsx' + +jest.mock('../../registry/isVisibleWhen.js', () => ({ + isVisibleWhen: jest.fn() +})) + +let capturedOnChange = null + +jest.mock('./LayersMenuRadio.jsx', () => ({ + LayersMenuRadio: ({ menuGroupItem, checked, name, onChange }) => { + capturedOnChange = onChange + return ( +
+ ) + } +})) + +const makePluginState = (menuState = {}) => ({ + menuState, + dispatch: jest.fn() +}) + +const baseGroup = { + id: 'group-1', + label: 'My Group', + items: [{ id: 'opt-a' }, { id: 'opt-b' }] +} + +beforeEach(() => { + isVisibleWhen.mockClear() + isVisibleWhen.mockReturnValue(true) + capturedOnChange = null +}) + +describe('LayersRadioGroupWrapper', () => { + describe('visibility', () => { + it('returns null when isVisibleWhen resolves to false', () => { + isVisibleWhen.mockReturnValue(false) + const { container } = render( + + ) + expect(container.firstChild).toBeNull() + }) + + it('renders when visibleWhen is not set', () => { + const { container } = render( + + ) + expect(container.querySelector('.govuk-form-group')).toBeTruthy() + }) + + it('does not call isVisibleWhen when visibleWhen is not set', () => { + render() + expect(isVisibleWhen).not.toHaveBeenCalled() + }) + + it('calls isVisibleWhen with the visibleWhen value', () => { + const visibleWhen = { menu: ['someValue'] } + render() + expect(isVisibleWhen).toHaveBeenCalledWith(visibleWhen) + }) + }) + + describe('rendered output', () => { + it('renders the group label as the legend', () => { + render() + expect(screen.getByText('My Group')).toBeTruthy() + }) + + it('renders a LayersMenuRadio for each item', () => { + const { getAllByTestId } = render( + + ) + expect(getAllByTestId('layers-menu-radio')).toHaveLength(2) + }) + + it('passes the group id as the name to each radio item', () => { + const { getAllByTestId } = render( + + ) + getAllByTestId('layers-menu-radio').forEach(radio => { + expect(radio.dataset.name).toBe('group-1') + }) + }) + }) + + describe('checked state', () => { + it('marks the item matching menuState as checked', () => { + const { getAllByTestId } = render( + + ) + const radios = getAllByTestId('layers-menu-radio') + expect(radios[0].dataset.checked).toBe('true') + expect(radios[1].dataset.checked).toBe('false') + }) + + it('marks no item as checked when menuState has no value for the group', () => { + const { getAllByTestId } = render( + + ) + getAllByTestId('layers-menu-radio').forEach(radio => { + expect(radio.dataset.checked).toBe('false') + }) + }) + }) + + describe('handleChange', () => { + it('dispatches UPDATE_MENU_STATE with the selected item id', () => { + const pluginState = makePluginState({ 'group-1': 'opt-a' }) + render() + act(() => { capturedOnChange({ target: { value: 'opt-b' } }) }) + expect(pluginState.dispatch).toHaveBeenCalledWith({ + type: 'UPDATE_MENU_STATE', + payload: { 'group-1': 'opt-b' } + }) + }) + + it('dispatches using the group id as the payload key', () => { + const group = { ...baseGroup, id: 'another-group' } + const pluginState = makePluginState({ 'another-group': 'opt-a' }) + render() + act(() => { capturedOnChange({ target: { value: 'opt-b' } }) }) + expect(pluginState.dispatch).toHaveBeenCalledWith({ + type: 'UPDATE_MENU_STATE', + payload: { 'another-group': 'opt-b' } + }) + }) + }) +}) diff --git a/plugins/beta/datasets/src/datasets.scss b/plugins/datasets/src/datasets.scss similarity index 100% rename from plugins/beta/datasets/src/datasets.scss rename to plugins/datasets/src/datasets.scss diff --git a/plugins/beta/datasets/src/fetch/createDynamicSource.js b/plugins/datasets/src/fetch/createDynamicSource.js similarity index 100% rename from plugins/beta/datasets/src/fetch/createDynamicSource.js rename to plugins/datasets/src/fetch/createDynamicSource.js diff --git a/plugins/beta/datasets/src/fetch/fetchGeoJSON.js b/plugins/datasets/src/fetch/fetchGeoJSON.js similarity index 100% rename from plugins/beta/datasets/src/fetch/fetchGeoJSON.js rename to plugins/datasets/src/fetch/fetchGeoJSON.js diff --git a/plugins/beta/datasets/src/index.js b/plugins/datasets/src/index.js similarity index 100% rename from plugins/beta/datasets/src/index.js rename to plugins/datasets/src/index.js diff --git a/plugins/beta/datasets/src/initialise/DatasetsInit.jsx b/plugins/datasets/src/initialise/DatasetsInit.jsx similarity index 79% rename from plugins/beta/datasets/src/initialise/DatasetsInit.jsx rename to plugins/datasets/src/initialise/DatasetsInit.jsx index e1968af5c..36e740e68 100755 --- a/plugins/beta/datasets/src/initialise/DatasetsInit.jsx +++ b/plugins/datasets/src/initialise/DatasetsInit.jsx @@ -1,15 +1,16 @@ // src/plugins/datasets/datasetsInit.jsx import { useEffect, useRef } from 'react' -import { EVENTS } from '../../../../../src/config/events.js' +import { EVENTS } from '../../../../src/config/events.js' import { initialiseDatasets } from './initialiseDatasets.js' import { datasetRegistry } from '../registry/datasetRegistry.js' +import { setMenuState } from '../registry/isVisibleWhen.js' import { attachGlobalState } from '../registry/globalDataset.js' import { loadLayerAdapter, layerAdapter } from '../adapters/loadLayerAdapter.js' export function DatasetsInit ({ pluginConfig, pluginState, appState, mapState, mapProvider, services }) { const { dispatch } = pluginState const { eventBus, symbolRegistry, patternRegistry } = services - const isMapStyleReady = !!mapProvider.map?.getStyle() + const isBaseMapReady = Boolean(mapProvider?.isBaseMapReady()) // Keep a ref to the latest pluginState so event handlers can access current data const pluginStateRef = useRef(pluginState) @@ -22,7 +23,7 @@ export function DatasetsInit ({ pluginConfig, pluginState, appState, mapState, m const inModeWhitelist = pluginConfig.includeModes?.includes(appState.mode) ?? true const inExcludeModes = pluginConfig.excludeModes?.includes(appState.mode) ?? false - if (!isMapStyleReady || !inModeWhitelist || inExcludeModes) { + if (!isBaseMapReady || !inModeWhitelist || inExcludeModes) { return } @@ -47,11 +48,24 @@ export function DatasetsInit ({ pluginConfig, pluginState, appState, mapState, m } initDatasets() - }, [isMapStyleReady, appState.mode]) + }, [isBaseMapReady, appState.mode]) + + useEffect(() => { + setMenuState(pluginState.menuState) + datasetRegistry.invalidateKeyItems() + }, [pluginState.menuState]) useEffect(() => datasetRegistry.attach(pluginState.mappedDatasets, pluginState.orderedDatasets), [pluginState.mappedDatasets, pluginState.orderedDatasets]) + useEffect(() => { + datasetRegistry.attachMapStyle(mapState.mapStyle) + if (layerAdapter?.onMapStyleChange) { + layerAdapter.onMapStyleChange() + } + }, + [mapState.mapStyle]) + useEffect(() => attachGlobalState(pluginState.globals), [pluginState.globals]) // Call layerAdapter methods that are queued from state updates diff --git a/plugins/beta/datasets/src/initialise/defaults.js b/plugins/datasets/src/initialise/defaults.js similarity index 99% rename from plugins/beta/datasets/src/initialise/defaults.js rename to plugins/datasets/src/initialise/defaults.js index 7153f2ddc..62b6e66e9 100644 --- a/plugins/beta/datasets/src/initialise/defaults.js +++ b/plugins/datasets/src/initialise/defaults.js @@ -7,7 +7,6 @@ const datasetDefaults = { style: { stroke: '#d4351c', strokeWidth: 2, - fill: 'transparent', symbolDescription: 'red outline' } } diff --git a/plugins/beta/datasets/src/initialise/defaults.test.js b/plugins/datasets/src/initialise/defaults.test.js similarity index 99% rename from plugins/beta/datasets/src/initialise/defaults.test.js rename to plugins/datasets/src/initialise/defaults.test.js index 626add113..6e25a2715 100644 --- a/plugins/beta/datasets/src/initialise/defaults.test.js +++ b/plugins/datasets/src/initialise/defaults.test.js @@ -15,7 +15,6 @@ describe('datasetDefaults', () => { expect(datasetDefaults.style).toMatchObject({ stroke: '#d4351c', strokeWidth: 2, - fill: 'transparent', symbolDescription: 'red outline' }) }) diff --git a/plugins/beta/datasets/src/initialise/initialiseDatasets.js b/plugins/datasets/src/initialise/initialiseDatasets.js similarity index 80% rename from plugins/beta/datasets/src/initialise/initialiseDatasets.js rename to plugins/datasets/src/initialise/initialiseDatasets.js index 069c16837..91b70a1ad 100644 --- a/plugins/beta/datasets/src/initialise/initialiseDatasets.js +++ b/plugins/datasets/src/initialise/initialiseDatasets.js @@ -3,11 +3,13 @@ import { createDynamicSource } from '../fetch/createDynamicSource.js' import { applyDatasetDefaults, datasetDefaults } from './defaults.js' import { mappedDatasetsReducer } from '../reducers/mappedDatasetsReducer.js' import { datasetRegistry } from '../registry/datasetRegistry.js' +import { setMenuState } from '../registry/isVisibleWhen.js' +import { buildMenuState } from '../reducers/menuStateReducer.js' +import { datasetsToMenu } from '../reducers/datasetsToMenu.js' export const initialiseDatasets = ({ adapter, pluginConfig, - pluginStateRef, mapStyle, mapProvider, events, @@ -27,8 +29,11 @@ export const initialiseDatasets = ({ if (adapter.createDataset) { datasetRegistry.attachCreateDataset(adapter.createDataset) } - datasetRegistry.attach(mappedDatasets) - adapter.init(mapStyle).then(() => { + datasetRegistry.attach(mappedDatasets, orderedDatasets, mapStyle) + const menu = pluginConfig.menu || datasetsToMenu({ datasets: processedDatasets }) + setMenuState(buildMenuState(menu)) // Must be called before adapter.init so that menuState is set before any datasets are checked for visibility + + adapter.init().then(() => { datasetRegistry.forEachDataset(registryDataset => { if (!registryDataset.hasDynamicGeoJSON) { return @@ -41,29 +46,21 @@ export const initialiseDatasets = ({ }) dynamicSources.set(registryDataset.id, dynamicSource) }) + adapter.attachDynamicSources(dynamicSources) // TODO - apply dynamic source defaults here, and include in mappedDatasets + dispatch({ type: 'SET_MENU', payload: { menu } }) dispatch({ type: 'SET_DATASETS', payload: { datasets: processedDatasets, mappedDatasets, orderedDatasets } }) eventBus.emit('datasets:ready') }) - let currentMapStyle = mapStyle - - // Handle basemap style changes — delegate entirely to the adapter - const onSetMapStyle = (newMapStyle) => { - currentMapStyle = newMapStyle - adapter.onMapStyleChange(newMapStyle, dynamicSources) - } - const onMapSizeChange = () => { - adapter.onMapSizeChange(currentMapStyle) + adapter.onMapSizeChange() } - eventBus.on(events.MAP_SET_STYLE, onSetMapStyle) eventBus.on(events.MAP_SIZE_CHANGE, onMapSizeChange) return { remove () { - eventBus.off(events.MAP_SET_STYLE, onSetMapStyle) eventBus.off(events.MAP_SIZE_CHANGE, onMapSizeChange) // Clean up dynamic sources diff --git a/plugins/beta/datasets/src/initialise/initialiseDatasets.test.js b/plugins/datasets/src/initialise/initialiseDatasets.test.js similarity index 88% rename from plugins/beta/datasets/src/initialise/initialiseDatasets.test.js rename to plugins/datasets/src/initialise/initialiseDatasets.test.js index 46743d48d..9b1005dc8 100644 --- a/plugins/beta/datasets/src/initialise/initialiseDatasets.test.js +++ b/plugins/datasets/src/initialise/initialiseDatasets.test.js @@ -16,6 +16,7 @@ jest.mock('../registry/datasetRegistry.js', () => ({ const makeAdapter = (overrides = {}) => ({ init: jest.fn().mockResolvedValue(undefined), + attachDynamicSources: jest.fn(), onMapStyleChange: jest.fn(), onMapSizeChange: jest.fn(), setData: jest.fn(), @@ -42,7 +43,7 @@ const makeArgs = (overrides = {}) => { pluginStateRef: {}, mapStyle: { layers: [] }, mapProvider: { map: {} }, - events: { MAP_SET_STYLE: 'map:setStyle', MAP_SIZE_CHANGE: 'map:sizeChange' }, + events: { MAP_SIZE_CHANGE: 'map:sizeChange' }, dispatch: jest.fn(), eventBus, ...overrides @@ -60,7 +61,7 @@ describe('initialiseDatasets', () => { it('calls adapter.init with mapStyle', () => { const args = makeArgs() initialiseDatasets(args) - expect(args.adapter.init).toHaveBeenCalledWith(args.mapStyle) + expect(args.adapter.init).toHaveBeenCalledWith() }) it('attaches mappedDatasets to datasetRegistry', () => { @@ -94,10 +95,9 @@ describe('initialiseDatasets', () => { expect(args.dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'SET_GLOBAL_STATE' })) }) - it('registers MAP_SET_STYLE and MAP_SIZE_CHANGE event listeners', () => { + it('registers MAP_SET_STYLE event listeners', () => { const args = makeArgs() initialiseDatasets(args) - expect(args.eventBus.on).toHaveBeenCalledWith('map:setStyle', expect.any(Function)) expect(args.eventBus.on).toHaveBeenCalledWith('map:sizeChange', expect.any(Function)) }) @@ -153,28 +153,11 @@ describe('initialiseDatasets', () => { // ─── event handlers ─────────────────────────────────────────────────────────── describe('event handlers', () => { - it('delegates MAP_SET_STYLE to adapter.onMapStyleChange', () => { - const args = makeArgs() - initialiseDatasets(args) - const newStyle = { layers: [{ id: 'new' }] } - args.eventBus._handlers['map:setStyle'](newStyle) - expect(args.adapter.onMapStyleChange).toHaveBeenCalledWith(newStyle, expect.any(Map)) - }) - it('delegates MAP_SIZE_CHANGE to adapter.onMapSizeChange with current mapStyle', () => { const args = makeArgs() initialiseDatasets(args) args.eventBus._handlers['map:sizeChange']() - expect(args.adapter.onMapSizeChange).toHaveBeenCalledWith(args.mapStyle) - }) - - it('uses the updated mapStyle after MAP_SET_STYLE', () => { - const args = makeArgs() - initialiseDatasets(args) - const newStyle = { layers: [{ id: 'updated' }] } - args.eventBus._handlers['map:setStyle'](newStyle) - args.eventBus._handlers['map:sizeChange']() - expect(args.adapter.onMapSizeChange).toHaveBeenCalledWith(newStyle) + expect(args.adapter.onMapSizeChange).toHaveBeenCalled() }) }) @@ -185,7 +168,6 @@ describe('returned API', () => { const args = makeArgs() const instance = initialiseDatasets(args) instance.remove() - expect(args.eventBus.off).toHaveBeenCalledWith('map:setStyle', expect.any(Function)) expect(args.eventBus.off).toHaveBeenCalledWith('map:sizeChange', expect.any(Function)) expect(args.adapter.destroy).toHaveBeenCalled() }) diff --git a/plugins/beta/datasets/src/manifest.js b/plugins/datasets/src/manifest.js similarity index 97% rename from plugins/beta/datasets/src/manifest.js rename to plugins/datasets/src/manifest.js index 04f6105b4..91041469a 100755 --- a/plugins/beta/datasets/src/manifest.js +++ b/plugins/datasets/src/manifest.js @@ -67,7 +67,7 @@ export const manifest = { label: 'Layers', panelId: 'datasetsLayers', iconId: 'layers', - excludeWhen: ({ pluginConfig }) => !pluginConfig.datasets.some(l => + excludeWhen: ({ pluginConfig }) => !pluginConfig.menu && !pluginConfig.datasets.some(l => l.showInMenu || l.sublayers?.some(r => r.showInMenu) ), mobile: { diff --git a/plugins/beta/datasets/src/reducers/__data__/demoDatasets.js b/plugins/datasets/src/reducers/__data__/demoDatasets.js similarity index 88% rename from plugins/beta/datasets/src/reducers/__data__/demoDatasets.js rename to plugins/datasets/src/reducers/__data__/demoDatasets.js index 229f2c655..64cf922f8 100644 --- a/plugins/beta/datasets/src/reducers/__data__/demoDatasets.js +++ b/plugins/datasets/src/reducers/__data__/demoDatasets.js @@ -3,16 +3,16 @@ const pointData = { features: [{ type: 'Feature', properties: { category: 'prehistoric' }, - geometry: { coordinates: [-2.4558622, 54.5617135], type: 'Point' } + geometry: { coordinates: [-2.4558622, 54.5617135], type: 'Point' } // NOSONAR }, { type: 'Feature', properties: { category: 'roman' }, - geometry: { coordinates: [-2.439823, 54.5525437], type: 'Point' } + geometry: { coordinates: [-2.439823, 54.5525437], type: 'Point' } // NOSONAR }, { type: 'Feature', properties: { category: 'medieval' }, - geometry: { coordinates: [-2.4481939, 54.5575261], type: 'Point' } + geometry: { coordinates: [-2.4481939, 54.5575261], type: 'Point' } // NOSONAR }] } export const datasets = [ @@ -25,7 +25,7 @@ export const datasets = [ transformRequest: (url) => url + 'TRANSFORMED', // Required maxFeatures: 50000 // Optional: evict distant features when exceeded }, - hiddenFeatures: [42], + hiddenFeatures: [42], // NOSONAR query: {}, maxFeatures: 50000, // Optional: evict distant features when exceeded minZoom: 10, @@ -98,9 +98,8 @@ export const datasets = [ }] }, { - id: 'existing-fields', + id: 'existing-fields', // NOSONAR label: 'Existing fields', - // groupLabel: 'Test group', filter: ['all', ['==', ['get', 'sbi'], '106223377'], ['==', ['get', 'is_dominant_land_cover'], true]], tiles: 'https://farming-tiles-702a60f45633.herokuapp.com/field_parcels_with_hedges/{z}/{x}/{y}', sourceLayer: 'field_parcels_filtered', @@ -157,9 +156,8 @@ export const datasets = [ } }] }, { - id: 'hedge-control', + id: 'hedge-control', // NOSONAR label: 'Hedge control', - // groupLabel: 'Test group', tiles: ['https://farming-tiles-702a60f45633.herokuapp.com/field_parcels_with_hedges/{z}/{x}/{y}'], sourceLayer: 'hedge_control', minZoom: 10, @@ -195,9 +193,9 @@ export const expectedDatasetsMenuConfig = [ { visibleWhen: true, type: 'checkbox', - id: 'existing-fields', + id: 'existing-fields', // NOSONAR items: [ - { id: 'existing-fields', label: 'Existing fields' } + { id: 'existing-fields', label: 'Existing fields' } // NOSONAR ] }, { @@ -214,27 +212,22 @@ export const expectedDatasetsMenuConfig = [ { visibleWhen: true, type: 'checkbox', - id: 'hedge-control', + id: 'hedge-control', // NOSONAR items: [ - { id: 'hedge-control', label: 'Hedge control' } + { id: 'hedge-control', label: 'Hedge control' } // NOSONAR ] } ] -const landCovers = datasets[0] -const existingFields = datasets[1] -const historicMonuments = datasets[2] -const hedgeControl = datasets[3] -const landCoversMenuItem = expectedDatasetsMenuConfig[0] -const existingFieldsMenuItem = expectedDatasetsMenuConfig[1] -const historicMonumentsMenuItem = expectedDatasetsMenuConfig[2] -const hedgeControlMenuItem = expectedDatasetsMenuConfig[3] +const [landCovers, existingFields, historicMonuments, hedgeControl] = datasets +const [landCoversMenuItem, existingFieldsMenuItem, historicMonumentsMenuItem, hedgeControlMenuItem] = expectedDatasetsMenuConfig +const testGroupLabel = 'Test group' export const datasetsWithGroups = [ { ...landCovers }, - { ...existingFields, groupLabel: 'Test group' }, + { ...existingFields, groupLabel: testGroupLabel }, { ...historicMonuments }, - { ...hedgeControl, groupLabel: 'Test group', visible: true } + { ...hedgeControl, groupLabel: testGroupLabel, visible: true } ] export const expectedDatasetsMenuConfigWithGroups = [ @@ -242,8 +235,8 @@ export const expectedDatasetsMenuConfigWithGroups = [ { visibleWhen: true, type: 'checkbox', - groupLabel: 'Test group', - id: 'Test group', + groupLabel: testGroupLabel, + id: testGroupLabel, items: [ ...existingFieldsMenuItem.items, ...hedgeControlMenuItem.items diff --git a/plugins/datasets/src/reducers/__data__/esriDatasets.js b/plugins/datasets/src/reducers/__data__/esriDatasets.js new file mode 100644 index 000000000..56407d4d3 --- /dev/null +++ b/plugins/datasets/src/reducers/__data__/esriDatasets.js @@ -0,0 +1,144 @@ +export const datasets = [ + { + id: 'flood-zones-cc', + label: 'Flood Zones Climate Change', + groupLabel: 'Datasets', + esriGroupId: 'flood-zones-group', + tiles: 'https://tiles.arcgis.com/tiles/JZM7qJpmv7vJ0Hzx/arcgis/rest/services/Flood_Zones_2_and_3_Rivers_and_Sea_CCP1_NON_PRODUCTION/VectorTileServer', + showInKey: true, + showInMenu: true, + visible: true, + sourceLayer: 'Flood Zones 2 and 3 Rivers and Sea CCP1', + sublayers: [ + { + id: 'climate-change', + label: 'Climate change (2070 to 2125)', + esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea CCP1/Flood Zones plus climate change/1', + showInKey: true, + showInMenu: false, + style: { + fill: { outdoor: '#F4A582', dark: '#BF3D4A' }, + stroke: 'none' + } + }, + { + id: 'data-unavailable', + label: 'Climate change data unavailable', + style: { // This is used just for the key - so that it renders the pattern correctly. + fillPattern: 'dot', + fillPatternForegroundColor: { outdoor: '#000000', dark: '#ffffff' }, + stroke: { outdoor: '#000000', dark: '#FFFFFF' } + }, + showInKey: true, + showInMenu: false + }, + { + id: 'data-unavailable-outline', + style: { + stroke: { outdoor: '#000000', dark: '#FFFFFF' } + }, + esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea CCP1/Unavailable/0', + showInKey: false, + showInMenu: false + }, + { + id: 'data-unavailable-light', + visibleWhen: { mapStyleId: ['outdoor', 'black-and-white'] }, + esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea CCP1/Unavailable/1', + esriUseServerStyle: true, + showInKey: false, + showInMenu: false + }, + { + id: 'data-unavailable-dark', + visibleWhen: { mapStyleId: ['dark'] }, + esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea CCP1/Unavailable/2', + esriUseServerStyle: true, + showInKey: false, + showInMenu: false + } + ] + }, + { + id: 'flood-zones', + label: 'Flood Zones', + groupLabel: 'Datasets', + esriGroupId: 'flood-zones-group', + tiles: 'https://tiles.arcgis.com/tiles/JZM7qJpmv7vJ0Hzx/arcgis/rest/services/Flood_Zones_2_and_3_Rivers_and_Sea_NON_PRODUCTION/VectorTileServer', + showInKey: true, + visible: false, + // showInMenu: true, + sourceLayer: 'Flood Zones 2 and 3 Rivers and Sea', + sublayers: [ + { + id: 'flood-zone-2', + label: 'Flood Zone 2', + esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea/Flood Zone 2/1', + showInMenu: true, + style: { + fill: { outdoor: '#1d70b8', dark: '#7fcdbb' }, + stroke: 'none' + } + }, + { + id: 'flood-zone-3', + label: 'Flood Zone 3', + esriStyleLayerId: 'Flood Zones 2 and 3 Rivers and Sea/Flood Zone 3/1', + showInMenu: true, + style: { + fill: { outdoor: '#003078', dark: '#e5f5e0' }, + stroke: 'none' + } + } + ] + }, + { + id: 'esri-standalone', + label: 'Standalone Layer', + tiles: 'https://example.com/vtl/standalone', + visible: true, + esriStyleLayerId: 'standalone-style', + style: { fill: '#ff0000', stroke: '#000000' } + }, + // Grouped: belongs to a GroupLayer, not visible (covers the visible=false branch) + { + id: 'esri-grouped', + label: 'Grouped Layer', + tiles: 'https://example.com/vtl/grouped', + visible: false, + esriGroupId: 'my-group' + }, + // Server style: setPaintProperties should be skipped + { + id: 'esri-server', + label: 'Server Style Layer', + tiles: 'https://example.com/vtl/server', + visible: true, + esriStyleLayerId: 'server-style-layer', + esriUseServerStyle: true, + style: {} + }, + // Parent with sublayers that have esriStyleLayerId + { + id: 'esri-parent', + label: 'Parent With Sublayers', + tiles: 'https://example.com/vtl/parent', + visible: true, + sublayers: [ + { + id: 'sub-a', + label: 'Sub A', + esriStyleLayerId: 'style-sub-a', + visible: true, + style: { fill: '#00ff00' } + }, + { + id: 'sub-b', + label: 'Sub B', + esriStyleLayerId: 'style-sub-b', + visible: false, + style: {} + } + ] + } +] diff --git a/plugins/beta/datasets/src/reducers/datasetsToMenu.js b/plugins/datasets/src/reducers/datasetsToMenu.js similarity index 82% rename from plugins/beta/datasets/src/reducers/datasetsToMenu.js rename to plugins/datasets/src/reducers/datasetsToMenu.js index fc6521e2a..523079291 100644 --- a/plugins/beta/datasets/src/reducers/datasetsToMenu.js +++ b/plugins/datasets/src/reducers/datasetsToMenu.js @@ -10,8 +10,10 @@ export const datasetsToMenu = (state) => { const visibleSublayers = dataset.sublayers.filter(sublayer => dataset.showInMenu ? sublayer.showInMenu !== false : sublayer.showInMenu ) - if (!visibleSublayers.length) { return } - menu.push({ + if (dataset.showInMenu === false && !visibleSublayers.length) { + return + } + const groupObject = { id: dataset.id, groupLabel: dataset.label, visibleWhen: true, @@ -20,7 +22,17 @@ export const datasetsToMenu = (state) => { id: `${dataset.id}-${sublayer.id}`, label: sublayer.label })) - }) + } + // Temporary change until we handle multiple datasets with sublayers, that have a groupLabel + // So that the esri datasets with sublayers are displayed in the menu, even though they have a groupLabel + if (groupObject.items.length === 0) { + delete groupObject.groupLabel + groupObject.items.push({ + id: dataset.id, + label: dataset.label + }) + } + menu.push(groupObject) } else if (dataset.groupLabel) { // Check for existing group object for this groupLabel, or create it if it doesn't exist, // then add the dataset to its items and push it to the menu if it's new diff --git a/plugins/beta/datasets/src/reducers/datasetsToMenu.test.js b/plugins/datasets/src/reducers/datasetsToMenu.test.js similarity index 91% rename from plugins/beta/datasets/src/reducers/datasetsToMenu.test.js rename to plugins/datasets/src/reducers/datasetsToMenu.test.js index 3da636b11..3c3a68a58 100644 --- a/plugins/beta/datasets/src/reducers/datasetsToMenu.test.js +++ b/plugins/datasets/src/reducers/datasetsToMenu.test.js @@ -76,9 +76,9 @@ describe('datasetsToMenu', () => { expect(result[0].items).toEqual([{ id: 'test-a', label: 'A' }]) }) - it('does not add a menu entry when dataset has showInMenu: true but all sublayers opt out', () => { + it('Should have a single menu entry for the parent dataset when showInMenu: true but all sublayers opt out', () => { const result = datasetsToMenu({ datasets: [sublayerDataset(true, false)] }) - expect(result).toHaveLength(0) + expect(result).toEqual([{ id: 'test', items: [{ id: 'test', label: 'Test' }], type: 'checkbox', visibleWhen: true }]) }) }) }) diff --git a/plugins/beta/datasets/src/reducers/mappedDatasetsReducer.js b/plugins/datasets/src/reducers/mappedDatasetsReducer.js similarity index 100% rename from plugins/beta/datasets/src/reducers/mappedDatasetsReducer.js rename to plugins/datasets/src/reducers/mappedDatasetsReducer.js diff --git a/plugins/beta/datasets/src/reducers/mappedDatasetsReducer.test.js b/plugins/datasets/src/reducers/mappedDatasetsReducer.test.js similarity index 100% rename from plugins/beta/datasets/src/reducers/mappedDatasetsReducer.test.js rename to plugins/datasets/src/reducers/mappedDatasetsReducer.test.js diff --git a/plugins/datasets/src/reducers/menuStateReducer.js b/plugins/datasets/src/reducers/menuStateReducer.js new file mode 100644 index 000000000..839e771be --- /dev/null +++ b/plugins/datasets/src/reducers/menuStateReducer.js @@ -0,0 +1,9 @@ +export const buildMenuState = (menu) => { + const menuState = {} + menu.forEach(menuGroup => { + if (menuGroup.type === 'radio') { + menuState[menuGroup.id] = menuGroup.value || menuGroup.items?.[0].value + } + }) + return menuState +} diff --git a/plugins/beta/datasets/src/reducers/pluginState.js b/plugins/datasets/src/reducers/pluginState.js similarity index 87% rename from plugins/beta/datasets/src/reducers/pluginState.js rename to plugins/datasets/src/reducers/pluginState.js index cee81ca7f..8603ce1e4 100755 --- a/plugins/beta/datasets/src/reducers/pluginState.js +++ b/plugins/datasets/src/reducers/pluginState.js @@ -1,6 +1,7 @@ -import { datasetsToMenu, addDatasetToMenu, removeDatasetsFromMenu } from './datasetsToMenu.js' +import { addDatasetToMenu, removeDatasetsFromMenu } from './datasetsToMenu.js' +import { buildMenuState } from './menuStateReducer.js' import { mappedDatasetsReducer } from './mappedDatasetsReducer.js' -import { logger } from '../../../../../src/services/logger.js' +import { logger } from '../../../../src/services/logger.js' const initialState = { globals: { @@ -15,7 +16,9 @@ const initialState = { items: [], hasGroups: false }, - actionsArray: [] + actionsArray: [], + menu: [], + menuState: {} } const validateDatasetExists = (state, datasetId, prefix, suffix = 'not found') => { @@ -41,14 +44,31 @@ const setGlobalState = (state, payload) => { }) } +const updateMenuState = (state, payload) => { + return addAction('applyGlobalVisibility', [], { + ...state, + menuState: { ...state.menuState, ...payload } + }) +} + +const setMenu = (state, payload) => { + const { menu } = payload + // build the initial menuState for radios from the menu + const menuState = buildMenuState(menu) + return { + ...state, + menu, + menuState + } +} + const setDatasets = (state, payload) => { - const { datasets, mappedDatasets, orderedDatasets } = payload - const menu = payload.menu || datasetsToMenu({ datasets }) + const { mappedDatasets, orderedDatasets } = payload + return { ...state, mappedDatasets, - orderedDatasets, - menu + orderedDatasets } } @@ -93,6 +113,9 @@ const setDatasetVisibility = (state, payload) => { if (!validateDatasetExists(state, datasetId, 'setDatasetVisibility')) { return state } + if (state.mappedDatasets[datasetId].visible === visible) { + return state + } return addAction('applyDatasetVisibility', [datasetId, visible], { ...state, @@ -197,7 +220,9 @@ const actions = { HIDE_FEATURES: hideFeatures, SHOW_FEATURES: showFeatures, REMOVE_ADAPTER_ACTIONS: removeAdapterActions, - SET_GLOBAL_STATE: setGlobalState + SET_GLOBAL_STATE: setGlobalState, + SET_MENU: setMenu, + UPDATE_MENU_STATE: updateMenuState } export { diff --git a/plugins/beta/datasets/src/reducers/pluginState.test.js b/plugins/datasets/src/reducers/pluginState.test.js similarity index 98% rename from plugins/beta/datasets/src/reducers/pluginState.test.js rename to plugins/datasets/src/reducers/pluginState.test.js index 68b4089bf..82854fb52 100644 --- a/plugins/beta/datasets/src/reducers/pluginState.test.js +++ b/plugins/datasets/src/reducers/pluginState.test.js @@ -1,9 +1,7 @@ import { initialState, actions } from './pluginState.js' -import { logger } from '../../../../../src/services/logger.js' +import { logger } from '../../../../src/services/logger.js' -jest.mock('../../../../../src/services/logger.js', () => ({ - logger: { error: jest.fn() } -})) +jest.mock('../../../../src/services/logger.js') // Helper: build a minimal plugin state with some mapped datasets const makeState = (overrides = {}) => ({ @@ -56,13 +54,12 @@ describe('SET_DATASETS', () => { const payload = { datasets: [{ id: 'parks', label: 'Parks', showInMenu: true }], mappedDatasets: { parks: { id: 'parks', label: 'Parks' } }, - orderedDatasets: ['parks'], - menu: [{ id: 'parks', label: 'Parks', type: 'item' }] + orderedDatasets: ['parks'] } const result = actions.SET_DATASETS(state, payload) expect(result.mappedDatasets).toEqual(payload.mappedDatasets) expect(result.orderedDatasets).toEqual(payload.orderedDatasets) - expect(result.menu).toEqual(payload.menu) + expect(result.menu).toEqual([]) }) it('derives menu from datasets when menu is not in payload', () => { diff --git a/plugins/beta/datasets/src/registry/__mocks__/datasetRegistry.js b/plugins/datasets/src/registry/__mocks__/datasetRegistry.js similarity index 74% rename from plugins/beta/datasets/src/registry/__mocks__/datasetRegistry.js rename to plugins/datasets/src/registry/__mocks__/datasetRegistry.js index fbb2717bc..02eb2e5b8 100644 --- a/plugins/beta/datasets/src/registry/__mocks__/datasetRegistry.js +++ b/plugins/datasets/src/registry/__mocks__/datasetRegistry.js @@ -1,5 +1,6 @@ import { mappedDatasetsReducer } from '../../reducers/mappedDatasetsReducer.js' import { datasets as datasetDefinitions } from '../../reducers/__data__/demoDatasets.js' +import { datasets as esriDatasetDefinitions } from '../../reducers/__data__/esriDatasets.js' import { attachGlobalState } from '../globalDataset.js' const { datasetRegistry } = jest.requireActual('../datasetRegistry.js') const { mappedDatasets, orderedDatasets } = mappedDatasetsReducer({ datasets: datasetDefinitions }) @@ -26,4 +27,12 @@ datasetRegistry.mockExtend = (extraDatasets) => datasetRegistry.attach( [...orderedDatasets, ...Object.keys(extraDatasets)] ) +datasetRegistry.useEsriDatasets = (extraDatasets = {}) => { + const { mappedDatasets, orderedDatasets } = mappedDatasetsReducer({ datasets: esriDatasetDefinitions }) + datasetRegistry.attach( + { ...mappedDatasets, ...extraDatasets }, + [...orderedDatasets, ...Object.keys(extraDatasets)] + ) +} + export { datasetRegistry } diff --git a/plugins/beta/datasets/src/registry/createDataset.js b/plugins/datasets/src/registry/createDataset.js similarity index 100% rename from plugins/beta/datasets/src/registry/createDataset.js rename to plugins/datasets/src/registry/createDataset.js diff --git a/plugins/beta/datasets/src/registry/dataset.js b/plugins/datasets/src/registry/dataset.js similarity index 85% rename from plugins/beta/datasets/src/registry/dataset.js rename to plugins/datasets/src/registry/dataset.js index 2793ca96b..cb1e178f2 100644 --- a/plugins/beta/datasets/src/registry/dataset.js +++ b/plugins/datasets/src/registry/dataset.js @@ -1,6 +1,7 @@ import { datasetRegistry } from './datasetRegistry.js' +import { isVisibleWhen } from './isVisibleWhen.js' import { hasCustomVisualStyle } from '../initialise/defaults.js' -import { hasPattern } from '../../../../../src/utils/patternUtils.js' +import { hasPattern } from '../../../../src/utils/patternUtils.js' import { DynamicGeoJson } from './dynamicGeoJson.js' import { calculateOpacity, getGlobalVisibility } from './globalDataset.js' @@ -22,12 +23,24 @@ export class Dataset { get parentId () { return this._datasetDefinition.parentId } get minZoom () { return this._datasetDefinition.minZoom || this.parent?.minZoom } get maxZoom () { return this._datasetDefinition.maxZoom || this.parent?.maxZoom } + get type () { return this._datasetDefinition.type || this.parent?.type } + get showInKey () { const own = this._datasetDefinition.showInKey if (own !== undefined) { return own } return this.parent?.showInKey ?? false } + // Note visibleWhen is used in combination with visible. + // both must be true for the dataset to be visible. + // visibleWhen is used to show/hide datasets based on mapStyle. + get visibleWhen () { + if (this._datasetDefinition.visibleWhen === undefined) { + return this.parent?.visibleWhen + } + return this._datasetDefinition.visibleWhen + } + get groupLabel () { return this._datasetDefinition.groupLabel } get opacity () { @@ -83,6 +96,20 @@ export class Dataset { return this._datasetDefinition.visible && getGlobalVisibility() } + get esriStyleLayerId () { return this._datasetDefinition.esriStyleLayerId } + + get visibility () { + const { visible, visibleWhen } = this + if (visible && visibleWhen) { + return isVisibleWhen(visibleWhen) ? 'visible' : 'none' + } + return visible ? 'visible' : 'none' + } + + get keyVisibility () { + return this.visibility === 'visible' && this.showInKey + } + get symbolAnchor () { if (this.style?.symbolAnchor) { return this.style.symbolAnchor @@ -117,7 +144,7 @@ export class Dataset { if (sublayerIds) { return sublayerIds.map(id => datasetRegistry.getDataset(id)) } - return undefined + return [] } get parent () { diff --git a/plugins/beta/datasets/src/registry/dataset.test.js b/plugins/datasets/src/registry/dataset.test.js similarity index 99% rename from plugins/beta/datasets/src/registry/dataset.test.js rename to plugins/datasets/src/registry/dataset.test.js index d88fb69dd..c7bf00c14 100644 --- a/plugins/beta/datasets/src/registry/dataset.test.js +++ b/plugins/datasets/src/registry/dataset.test.js @@ -41,9 +41,9 @@ describe('Dataset class', () => { }) describe('sublayers', () => { - it('returns undefined for a dataset with no sublayerIds', () => { + it('returns an empty array for a dataset with no sublayerIds', () => { const dataset = datasetRegistry.getDataset('hedge-control') - expect(dataset.sublayers).toBeUndefined() + expect(dataset.sublayers).toEqual([]) }) it('returns a Dataset instance for each sublayer', () => { diff --git a/plugins/beta/datasets/src/registry/datasetDefinitionCache.js b/plugins/datasets/src/registry/datasetDefinitionCache.js similarity index 90% rename from plugins/beta/datasets/src/registry/datasetDefinitionCache.js rename to plugins/datasets/src/registry/datasetDefinitionCache.js index 05b0dc73f..18ec75eba 100644 --- a/plugins/beta/datasets/src/registry/datasetDefinitionCache.js +++ b/plugins/datasets/src/registry/datasetDefinitionCache.js @@ -13,8 +13,8 @@ export class DatasetDefinitionCache { const existingDefinition = this.idToDefinitionMap.get(registryDataset.id) const allIds = [existingDefinition.id, ...(existingDefinition?.sublayerIds || [])] allIds.forEach(id => { - const existingDefinition = this.idToDefinitionMap.get(id) - this.definitionToInstanceMap.delete(existingDefinition) + const definition = this.idToDefinitionMap.get(id) + this.definitionToInstanceMap.delete(definition) this.idToDefinitionMap.delete(id) }) } diff --git a/plugins/beta/datasets/src/registry/datasetRegistry.js b/plugins/datasets/src/registry/datasetRegistry.js similarity index 87% rename from plugins/beta/datasets/src/registry/datasetRegistry.js rename to plugins/datasets/src/registry/datasetRegistry.js index a8fcbfd8b..c952e0428 100644 --- a/plugins/beta/datasets/src/registry/datasetRegistry.js +++ b/plugins/datasets/src/registry/datasetRegistry.js @@ -2,27 +2,42 @@ import { createDataset } from './createDataset.js' import { DatasetDefinitionCache } from './datasetDefinitionCache.js' const datasetRegistry = { - attach (datasetsRef, orderedDatasetsRef) { + attach (datasetsRef, orderedDatasetsRef, mapStyle) { this._datasets = datasetsRef this._orderedDatasets = orderedDatasetsRef + this.attachMapStyle(mapStyle) this._invalidateChangedDatasets() }, + _definitionCache: new DatasetDefinitionCache(), + _invalidateCache () { // used in tests to clear the cache between runs this._definitionCache = new DatasetDefinitionCache() }, - _invalidateChangedDatasets () { // used in tests to clear the cache between runs + + _invalidateChangedDatasets () { if (this._datasets) { this._definitionCache.invalidateChangedDatasets(Object.values(this._datasets)) } else { this._invalidateCache() } }, + // createDataset defaults to a generic dataset factory function, but can be overridden by calling // attachCreateDataset, which allows the layer adapter to provide its own createDataset function, - attachCreateDataset (createDataset) { this._createDataset = createDataset }, + attachCreateDataset (newCreateDatasetFunction) { this._createDataset = newCreateDatasetFunction }, _createDataset: (datasetDefinition) => createDataset(datasetDefinition), + attachMapStyle (mapStyle) { + if (mapStyle) { + this._mapStyle = mapStyle + } + }, + + get mapStyle () { + return this._mapStyle + }, + // getDataset retrieves a dataset by id, creating a new Dataset instance that wraps the definition getDataset (id) { const definition = this.datasets[id] @@ -68,6 +83,9 @@ const datasetRegistry = { }, { patternConfigs: [], symbolConfigs: [] }) }, + invalidateKeyItems () { + this._lastKeyItemsDatasets = null + }, _lastKeyItems: {}, keyItems () { if (this.datasets === this._lastKeyItemsDatasets) { @@ -78,11 +96,11 @@ const datasetRegistry = { const seenGroups = new Set() let hasGroups = false this.forEachDataset((dataset) => { - if (!(dataset.showInKey && dataset.visible)) { + if (!(dataset.showInKey && dataset.keyVisibility)) { return } if (dataset.hasSublayers) { - const sublayers = dataset.sublayers.filter(sublayer => sublayer.visible) + const sublayers = dataset.sublayers.filter(sublayer => sublayer.keyVisibility) if (sublayers.length) { hasGroups = true items.push({ type: 'sublayers', dataset, sublayers }) diff --git a/plugins/beta/datasets/src/registry/datasetRegistry.test.js b/plugins/datasets/src/registry/datasetRegistry.test.js similarity index 100% rename from plugins/beta/datasets/src/registry/datasetRegistry.test.js rename to plugins/datasets/src/registry/datasetRegistry.test.js diff --git a/plugins/beta/datasets/src/registry/dynamicGeoJson.js b/plugins/datasets/src/registry/dynamicGeoJson.js similarity index 100% rename from plugins/beta/datasets/src/registry/dynamicGeoJson.js rename to plugins/datasets/src/registry/dynamicGeoJson.js diff --git a/plugins/beta/datasets/src/registry/globalDataset.js b/plugins/datasets/src/registry/globalDataset.js similarity index 100% rename from plugins/beta/datasets/src/registry/globalDataset.js rename to plugins/datasets/src/registry/globalDataset.js diff --git a/plugins/datasets/src/registry/isVisibleWhen.js b/plugins/datasets/src/registry/isVisibleWhen.js new file mode 100644 index 000000000..f9d32ece0 --- /dev/null +++ b/plugins/datasets/src/registry/isVisibleWhen.js @@ -0,0 +1,55 @@ +import { datasetRegistry } from './datasetRegistry.js' + +let _menuState = {} +export const setMenuState = (menuState) => { _menuState = menuState } + +const _isVisibleWhenMenuCheck = (menuVisibleWhen) => { + for (const [key, valueArray] of Object.entries(menuVisibleWhen)) { + const menuValue = _menuState[key] + if (!valueArray.includes(menuValue)) { + return false + } + } + return true +} + +const _isVisibleWhenMapStyleCheck = (visibleWhenValue) => { + const mapStylesArray = Array.isArray(visibleWhenValue) ? visibleWhenValue : [visibleWhenValue] + return mapStylesArray.includes(_getMapStyleId()) +} + +// exported so it can be mocked and overridden when testing +export const _getMapStyleId = () => datasetRegistry.mapStyle.id + +/** + * receives a visibleWhen boolean or object + * and returns a boolean indicating whether the dataset should be visible + * if visibleWhen is undefined, it returns true + * if visibleWhen is a boolean, it returns that boolean + * if visibleWhen is an object, it checks the properties of the object, against the relevant pluginState properties, + * and returns true if all properties are satisfied: + * @param {boolean|object} visibleWhen - the visibleWhen property of a dataset + * @returns {boolean} - true if the dataset should be visible, false otherwise + */ +export const isVisibleWhen = (visibleWhen) => { + if (visibleWhen === undefined || visibleWhen === null) { + return true + } + if (typeof visibleWhen === 'boolean') { + return visibleWhen + } + if (typeof visibleWhen === 'object') { + // check each property of the visibleWhen object against the relevant pluginState property + for (const [visibleWhenKey, visibleWhenValue] of Object.entries(visibleWhen)) { + if (visibleWhenKey === 'mapStyleId' && !_isVisibleWhenMapStyleCheck(visibleWhenValue)) { + return false + } + if (visibleWhenKey === 'menu' && !_isVisibleWhenMenuCheck(visibleWhenValue)) { + return false + } + } + return true + } + // Fallback to true if visibleWhen is incorrectly configured + return true +} diff --git a/plugins/datasets/src/registry/isVisibleWhen.test.js b/plugins/datasets/src/registry/isVisibleWhen.test.js new file mode 100644 index 000000000..8d35bdd02 --- /dev/null +++ b/plugins/datasets/src/registry/isVisibleWhen.test.js @@ -0,0 +1,67 @@ +import { isVisibleWhen, setMenuState } from './isVisibleWhen.js' +import { datasetRegistry } from './datasetRegistry.js' + +describe('isVisibleWhen', () => { + beforeEach(() => { + // reset menu state before each test + setMenuState({}) + }) + + it('returns true if visibleWhen is undefined', () => { + expect(isVisibleWhen(undefined)).toBe(true) + }) + + it('returns true if visibleWhen is null', () => { + expect(isVisibleWhen(null)).toBe(true) + }) + + it('returns the boolean value if visibleWhen is a boolean', () => { + expect(isVisibleWhen(true)).toBe(true) + expect(isVisibleWhen(false)).toBe(false) + }) + + it('returns true if all properties of visibleWhen object are satisfied', () => { + setMenuState({ datasets: 'floodZones', timeframe: 'climateChange' }) + const visibleWhen = { menu: { datasets: ['floodZones'], timeframe: ['climateChange'] } } + expect(isVisibleWhen(visibleWhen)).toBe(true) + }) + + it('returns false if any property of visibleWhen object is not satisfied', () => { + setMenuState({ datasets: 'floodZones', timeframe: 'climateChange' }) + const visibleWhen = { menu: { datasets: ['floodZones'], timeframe: ['presentDay'] } } + expect(isVisibleWhen(visibleWhen)).toBe(false) + }) + + it('returns true if mapStyleId is satisfied', () => { + const mapStyleId = 'outdoor' + datasetRegistry.attach([], [], { id: mapStyleId }) + const visibleWhen = { mapStyleId } + expect(isVisibleWhen(visibleWhen)).toBe(true) + }) + + it('returns false if mapStyleId is not satisfied', () => { + const mapStyleId = 'outdoor' + datasetRegistry.attach([], [], { id: mapStyleId }) + const visibleWhen = { mapStyleId: 'dark' } + expect(isVisibleWhen(visibleWhen)).toBe(false) + }) + + it('returns true if mapStyleId is an array and one of the values is satisfied', () => { + const mapStyleId = 'outdoor' + datasetRegistry.attach([], [], { id: mapStyleId }) + const visibleWhen = { mapStyleId: ['dark', mapStyleId] } + expect(isVisibleWhen(visibleWhen)).toBe(true) + }) + + it('returns false if mapStyleId is an array and none of the values are satisfied', () => { + const mapStyleId = 'outdoor' + datasetRegistry.attach([], [], { id: mapStyleId }) + const visibleWhen = { mapStyleId: ['dark', 'black-and-white'] } + expect(isVisibleWhen(visibleWhen)).toBe(false) + }) + + it('returns true if visibleWhen is incorrectly configured', () => { + const visibleWhen = 'incorrectly-configured' + expect(isVisibleWhen(visibleWhen)).toBe(true) + }) +}) diff --git a/plugins/beta/datasets/src/utils/bbox.js b/plugins/datasets/src/utils/bbox.js similarity index 94% rename from plugins/beta/datasets/src/utils/bbox.js rename to plugins/datasets/src/utils/bbox.js index bcb0d55e7..7fa337638 100755 --- a/plugins/beta/datasets/src/utils/bbox.js +++ b/plugins/datasets/src/utils/bbox.js @@ -103,10 +103,10 @@ export const getGeometryBbox = (geometry) => { case 'GeometryCollection': geometry.geometries.forEach(g => { const b = getGeometryBbox(g) - minX = Math.min(minX, b[0]) - minY = Math.min(minY, b[1]) - maxX = Math.max(maxX, b[2]) - maxY = Math.max(maxY, b[3]) + minX = Math.min(minX, b[0]) // west + minY = Math.min(minY, b[1]) // south + maxX = Math.max(maxX, b[2]) // east + maxY = Math.max(maxY, b[3]) // NOSONAR north }) break default: diff --git a/plugins/beta/datasets/src/utils/debounce.js b/plugins/datasets/src/utils/debounce.js similarity index 100% rename from plugins/beta/datasets/src/utils/debounce.js rename to plugins/datasets/src/utils/debounce.js diff --git a/providers/beta/esri/src/esriProvider.js b/providers/beta/esri/src/esriProvider.js index 384a063b2..047bf47eb 100644 --- a/providers/beta/esri/src/esriProvider.js +++ b/providers/beta/esri/src/esriProvider.js @@ -1,4 +1,5 @@ // src/plugins/mapStyles/EsriProvider.jsx +import { MapProvider } from '../../../mapProvider.js' import './esriProvider.scss' import esriConfig from '@arcgis/core/config.js' import TileInfo from '@arcgis/core/layers/support/TileInfo.js' @@ -14,8 +15,9 @@ import { queryVectorTileFeatures } from './utils/query.js' import { getExtentFromFlatCoords, getPointFromFlatCoords, getBboxFromGeoJSON } from './utils/coords.js' import { cleanDOM } from './utils/esriFixes.js' -export default class EsriProvider { +export default class EsriProvider extends MapProvider { constructor ({ mapProviderConfig = {}, events, eventBus }) { + super() this.events = events this.eventBus = eventBus this.capabilities = { @@ -102,6 +104,15 @@ export default class EsriProvider { this.baseTileLayer = baseTileLayer } + _isBaseMapReady = false + setBaseMapReady (ready) { + this._isBaseMapReady = ready + } + + isBaseMapReady () { + return this._isBaseMapReady + } + destroyMap () { this.mapEvents?.remove() this.appEvents?.remove() diff --git a/providers/beta/esri/src/mapEvents.js b/providers/beta/esri/src/mapEvents.js index a790113d4..ea0288443 100644 --- a/providers/beta/esri/src/mapEvents.js +++ b/providers/beta/esri/src/mapEvents.js @@ -54,6 +54,7 @@ export function attachMapEvents ({ // ready once(() => view.ready).then(() => { if (!destroyed) { + mapProvider.setBaseMapReady(true) eventBus.emit(events.MAP_READY, { map: mapProvider.map, view: mapProvider.view, diff --git a/providers/beta/openlayers/src/openlayersProvider.js b/providers/beta/openlayers/src/openlayersProvider.js index decfa66c6..54ddd624d 100644 --- a/providers/beta/openlayers/src/openlayersProvider.js +++ b/providers/beta/openlayers/src/openlayersProvider.js @@ -1,3 +1,4 @@ +import { MapProvider } from '../../../mapProvider.js' import OlMap from 'ol/Map.js' import View from 'ol/View.js' import { defaults as defaultInteractions } from 'ol/interaction/defaults.js' @@ -30,8 +31,9 @@ const toPaddingArray = (padding) => { proj4.defs(CRS, '+proj=tmerc +lat_0=49 +lon_0=-2 +k=0.9996012717 +x_0=400000 +y_0=-100000 +ellps=airy +towgs84=446.448,-125.157,542.06,0.15,0.247,0.842,-20.489 +units=m +no_defs') register(proj4) -export default class OpenLayersProvider { +export default class OpenLayersProvider extends MapProvider { constructor ({ mapProviderConfig = {}, events, eventBus }) { + super() this.events = events this.eventBus = eventBus this.capabilities = { diff --git a/providers/mapProvider.js b/providers/mapProvider.js new file mode 100644 index 000000000..19a55201e --- /dev/null +++ b/providers/mapProvider.js @@ -0,0 +1,5 @@ +export class MapProvider { + isBaseMapReady () { + throw new Error(this.name + ' must implement isBaseMapReady()') + } +} diff --git a/providers/mapProvider.test.js b/providers/mapProvider.test.js new file mode 100644 index 000000000..b16b44430 --- /dev/null +++ b/providers/mapProvider.test.js @@ -0,0 +1,32 @@ +import { MapProvider } from './mapProvider.js' + +describe('MapProvider', () => { + describe('isBaseMapReady', () => { + it('throws an error indicating the subclass must implement isBaseMapReady()', () => { + const provider = new MapProvider() + expect(() => provider.isBaseMapReady()).toThrow('must implement isBaseMapReady()') + }) + + it('includes the instance name in the error message when name is set', () => { + const provider = new MapProvider() + provider.name = 'TestProvider' + expect(() => provider.isBaseMapReady()).toThrow('TestProvider must implement isBaseMapReady()') + }) + + it('throws an Error instance', () => { + const provider = new MapProvider() + expect(() => provider.isBaseMapReady()).toThrow(Error) + }) + }) + + it('can be subclassed with an isBaseMapReady implementation', () => { + class ConcreteProvider extends MapProvider { + isBaseMapReady () { + return true + } + } + + const provider = new ConcreteProvider() + expect(provider.isBaseMapReady()).toBe(true) + }) +}) diff --git a/providers/maplibre/src/maplibreProvider.js b/providers/maplibre/src/maplibreProvider.js index 9140914a3..f96343a49 100755 --- a/providers/maplibre/src/maplibreProvider.js +++ b/providers/maplibre/src/maplibreProvider.js @@ -2,7 +2,7 @@ * @typedef {import('../../../src/types.js').MapProvider} MapProvider * @typedef {import('../../../src/types.js').MapProviderConfig} MapProviderConfig */ - +import { MapProvider } from '../../mapProvider.js' import { DEFAULTS, supportedShortcuts } from './defaults.js' import { cleanCanvas, applyPreventDefaultFix } from './utils/maplibreFixes.js' import { attachMapEvents } from './mapEvents.js' @@ -20,7 +20,7 @@ import { addPatternsToMap } from './utils/patternImages.js' * * @implements {MapProvider} */ -export default class MapLibreProvider { +export default class MapLibreProvider extends MapProvider { /** * @param {Object} options - Constructor options. * @param {any} options.mapFramework - The MapLibre GL JS module. @@ -29,6 +29,7 @@ export default class MapLibreProvider { * @param {Object} options.eventBus - Event emitter for publishing map events. */ constructor ({ mapFramework, mapProviderConfig = {}, events, eventBus }) { + super() this.maplibreModule = mapFramework this.events = events this.eventBus = eventBus @@ -118,6 +119,10 @@ export default class MapLibreProvider { }) } + isBaseMapReady () { + return Boolean(this.map?.getStyle()) + } + /** Destroy the map and clean up resources. */ destroyMap () { this.setHoverCursor([]) diff --git a/providers/maplibre/src/maplibreProvider.test.js b/providers/maplibre/src/maplibreProvider.test.js index 9ad505c64..927cd3261 100644 --- a/providers/maplibre/src/maplibreProvider.test.js +++ b/providers/maplibre/src/maplibreProvider.test.js @@ -64,7 +64,8 @@ describe('MapLibreProvider', () => { getPixelRatio: jest.fn(() => 1), getCanvas: jest.fn(() => ({ style: {} })), getLayer: jest.fn(() => true), - queryRenderedFeatures: jest.fn(() => []) + queryRenderedFeatures: jest.fn(() => []), + getStyle: jest.fn(() => ({ layers: [] })) } eventBus = { emit: jest.fn() } maplibreModule = { Map: jest.fn(() => map), LngLatBounds: jest.fn() } @@ -135,6 +136,28 @@ describe('MapLibreProvider', () => { expect(createMapLabelNavigator).toHaveBeenCalledWith(map, undefined, expect.anything(), eventBus) }) + describe('isBaseMapReady', () => { + test('returns the result of map.getStyle() after map is initialised', async () => { + const p = makeProvider() + await doInitMap(p) + const style = { layers: [{ id: 'background' }] } + map.getStyle.mockReturnValue(style) + expect(p.isBaseMapReady()).toEqual(true) + }) + + test('returns undefined when map has not been initialised', () => { + const p = makeProvider() + expect(p.isBaseMapReady()).toEqual(false) + }) + + test('returns undefined when map.getStyle() returns undefined', async () => { + const p = makeProvider() + await doInitMap(p) + map.getStyle.mockReturnValue(undefined) + expect(p.isBaseMapReady()).toEqual(false) + }) + }) + test('destroyMap: calls remove on mapEvents/appEvents if set; skips if absent', async () => { const p = makeProvider() await doInitMap(p) diff --git a/rollup.esm.mjs b/rollup.esm.mjs index f9cfef484..10505f1a4 100644 --- a/rollup.esm.mjs +++ b/rollup.esm.mjs @@ -177,11 +177,13 @@ const createESMConfig = (entryPath, outDir, isCore = false, manualChunks = null, ...(isCore ? [removeFullCssPlugin(cssDir)] : []), // Only runs when ANALYZE=1 is set; writes stats to dist/stats/.html - ...(process.env.ANALYZE ? [visualizer({ + ...(process.env.ANALYZE +? [visualizer({ filename: path.resolve(__dirname, 'dist/stats', `${outDir.replace(/\//g, '-')}.html`), open: false, gzipSize: true - })] : []) + })] +: []) ], output: { @@ -262,8 +264,8 @@ const ALL_BUILDS = [ manualChunks: (id) => { if (id.includes('/manifest')) return 'im-interact-plugin' } }, { - entryPath: './plugins/beta/datasets/src/index.js', - outDir: 'plugins/beta/datasets/dist/esm', + entryPath: './plugins/datasets/src/index.js', + outDir: 'plugins/datasets/dist/esm', manualChunks: (id) => { if (id.includes('/manifest')) return 'im-datasets-plugin' if (id.includes('maplibreLayerAdapter')) return 'im-datasets-ml-adapter' diff --git a/src/App/controls/keyboardActions.test.js b/src/App/controls/keyboardActions.test.js index 5ae89c5e8..7218aa950 100644 --- a/src/App/controls/keyboardActions.test.js +++ b/src/App/controls/keyboardActions.test.js @@ -7,9 +7,7 @@ jest.mock('../../services/reverseGeocode.js', () => ({ hasReverseGeocode: jest.fn() })) -jest.mock('../../services/logger.js', () => ({ - logger: { warn: jest.fn() } -})) +jest.mock('../../services/logger.js') const PAN_DELTA = 10 const NUDGE_PAN_DELTA = 5 diff --git a/src/App/renderer/mapButtons.test.js b/src/App/renderer/mapButtons.test.js index 8171a6933..52d8ca12d 100755 --- a/src/App/renderer/mapButtons.test.js +++ b/src/App/renderer/mapButtons.test.js @@ -3,7 +3,7 @@ import { mapButtons, getMatchingButtons, applySlotExclusivity, renderButton, res import { logger } from '../../services/logger.js' import { getPanelConfig } from '../registry/panelRegistry.js' -jest.mock('../../services/logger.js', () => ({ logger: { warn: jest.fn() } })) +jest.mock('../../services/logger.js') jest.mock('../registry/buttonRegistry.js') jest.mock('../registry/panelRegistry.js') jest.mock('../components/MapButton/MapButton.jsx', () => ({ diff --git a/src/InteractiveMap/deviceChecker.test.js b/src/InteractiveMap/deviceChecker.test.js index 6102998dc..facbfbf26 100755 --- a/src/InteractiveMap/deviceChecker.test.js +++ b/src/InteractiveMap/deviceChecker.test.js @@ -5,7 +5,7 @@ import { logger } from '../services/logger.js' jest.mock('./renderError.js') jest.mock('./domStateManager.js') -jest.mock('../services/logger.js', () => ({ logger: { warn: jest.fn() } })) +jest.mock('../services/logger.js') describe('checkDeviceSupport', () => { let rootEl, config diff --git a/src/services/__mocks__/logger.js b/src/services/__mocks__/logger.js new file mode 100644 index 000000000..ddf96c0c4 --- /dev/null +++ b/src/services/__mocks__/logger.js @@ -0,0 +1,4 @@ +export const logger = { + warn: jest.fn(), + error: jest.fn() +} diff --git a/webpack.dev.mjs b/webpack.dev.mjs index d315afa65..2e4bd9be7 100755 --- a/webpack.dev.mjs +++ b/webpack.dev.mjs @@ -25,6 +25,7 @@ export default { 'planning-ol': path.join(__dirname, 'demo/js/planning-ol.js'), gep: path.join(__dirname, 'demo/js/gep.js'), 'ml-datasets': path.join(__dirname, 'demo/js/ml-datasets.js'), + 'esri-datasets': path.join(__dirname, 'demo/js/esri-datasets.js'), esm: path.join(__dirname, 'demo/js/esm.js'), multimap: path.join(__dirname, 'demo/js/multimap.js') }, diff --git a/webpack.umd.mjs b/webpack.umd.mjs index 3704729f2..b67609617 100755 --- a/webpack.umd.mjs +++ b/webpack.umd.mjs @@ -144,7 +144,7 @@ const ALL_BUILDS = [ { entryPath: './plugins/beta/use-location/src/index.js', libraryPath: 'useLocationPlugin', outDir: 'plugins/beta/use-location/dist/umd' }, { entryPath: './plugins/search/src/index.js', libraryPath: 'searchPlugin', outDir: 'plugins/search/dist/umd' }, { entryPath: './plugins/interact/src/index.js', libraryPath: 'interactPlugin', outDir: 'plugins/interact/dist/umd' }, - { entryPath: './plugins/beta/datasets/src/index.js', libraryPath: 'datasetsPlugin', outDir: 'plugins/beta/datasets/dist/umd', cssOutDir: 'plugins/beta/datasets/dist' }, + { entryPath: './plugins/datasets/src/index.js', libraryPath: 'datasetsPlugin', outDir: 'plugins/datasets/dist/umd', cssOutDir: 'plugins/datasets/dist' }, { entryPath: './plugins/beta/map-styles/src/index.js', libraryPath: 'mapStylesPlugin', outDir: 'plugins/beta/map-styles/dist/umd' }, { entryPath: './plugins/beta/draw-ml/src/index.js', libraryPath: 'drawMLPlugin', outDir: 'plugins/beta/draw-ml/dist/umd' }, { entryPath: './plugins/beta/frame/src/index.js', libraryPath: 'framePlugin', outDir: 'plugins/beta/frame/dist/umd' }