diff --git a/.vscode/settings.json b/.vscode/settings.json index 1e681853..543a902a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,13 @@ "sonarlint.connectedMode.project": { "connectionId": "defra", "projectKey": "DEFRA_interactive-map" + }, + "sonarlint.rules": { + "javascript:S6774": { + "level": "off" + }, + "javascript:S100": { + "level": "off" + } } } \ No newline at end of file diff --git a/demo/js/draw.js b/demo/js/draw.js index c176c9ea..83717cf4 100755 --- a/demo/js/draw.js +++ b/demo/js/draw.js @@ -72,19 +72,15 @@ const datasetsPlugin = createDatasetsPlugin({ // showInKey: true, // toggleVisibility: true, // visibility: 'hidden', - stroke: { outdoor: '#0000ff', dark: '#ffffff' }, - strokeWidth: 2, - // symbol: '', - // symbolSvgContent: '', - // symbolForegroundColor: '', - // symbolBackgroundColor: '', - // symbolDescription: { outdoor: 'blue outline' }, - // symbolOffset: [], - // fill: 'rgba(0,0,255,0.1)', - fillPattern: 'diagonal-cross-hatch', - fillPatternForegroundColor: { outdoor: '#0000ff', dark: '#ffffff' }, - fillPatternBackgroundColor: 'transparent', - opacity: 0.5 + style: { + stroke: { outdoor: '#0000ff', dark: '#ffffff' }, + strokeWidth: 2, + // fill: 'rgba(0,0,255,0.1)', + fillPattern: 'diagonal-cross-hatch', + fillPatternForegroundColor: { outdoor: '#0000ff', dark: '#ffffff' }, + fillPatternBackgroundColor: 'transparent', + // opacity: 0.2 + } }] }) @@ -214,8 +210,8 @@ interactiveMap.on('map:ready', function (e) { }) interactiveMap.on('datasets:ready', function () { - // setTimeout(() => datasetsPlugin.hideDataset('field-parcels'), 2000) - // setTimeout(() => datasetsPlugin.showDataset('field-parcels'), 4000) + // setTimeout(() => datasetsPlugin.setDatasetVisibility(false, { datasetId: 'field-parcels' }), 2000) + // setTimeout(() => datasetsPlugin.setDatasetVisibility(true, { datasetId: 'field-parcels' }), 4000) }) // Ref to the selected features diff --git a/demo/js/farming.js b/demo/js/farming.js index bbdbccc2..04b9fe9e 100755 --- a/demo/js/farming.js +++ b/demo/js/farming.js @@ -21,10 +21,10 @@ var feature = { id: 'test1234', type: 'Feature', geometry: { coordinates: [[[-2. var interactPlugin = createInteractPlugin({ dataLayers: [{ layerId: 'field-parcels', - idProperty: 'gid' + // idProperty: 'gid' },{ layerId: 'linked-parcels', - idProperty: 'gid' + // idProperty: 'gid' }], interactionMode: 'select', // 'auto', 'select', 'marker' // defaults to 'marker' multiSelect: true, @@ -52,20 +52,15 @@ var datasetsPlugin = createDatasetsPlugin({ maxZoom: 24, showInKey: true, toggleVisibility: true, - stroke: { outdoor: '#0000ff', dark: '#ffffff' }, - strokeWidth: 2, - // strokeDashArray: [1, 2], - // symbol: '', - // symbolSvgContent: '', - // symbolForegroundColor: '', - // symbolBackgroundColor: '', - // symbolDescription: { outdoor: 'blue outline' }, - // symbolOffset: [], - fill: 'rgba(0,0,255,0.1)', - fillPattern: 'diagonal-cross-hatch', - fillPatternForegroundColor: { outdoor: '#0000ff', dark: '#ffffff' }, - fillPatternBackgroundColor: 'transparent', - opacity: 0.5 + style: { + stroke: { outdoor: '#0000ff', dark: '#ffffff' }, + strokeWidth: 2, + fill: 'rgba(0,0,255,0.1)', + fillPattern: 'diagonal-cross-hatch', + fillPatternForegroundColor: { outdoor: '#0000ff', dark: '#ffffff' }, + fillPatternBackgroundColor: 'transparent', + opacity: 0.5 + } }, // { // id: 'linked-parcels', diff --git a/demo/js/index.js b/demo/js/index.js index b23d3098..bab9563f 100755 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -30,10 +30,13 @@ const pointData = {type: 'FeatureCollection','features': [{'type': 'Feature','pr const interactPlugin = createInteractPlugin({ dataLayers: [{ - layerId: 'field-parcels', + layerId: 'field-parcels-130', // idProperty: 'gid' },{ - layerId: 'linked-parcels', + layerId: 'field-parcels-332', + // idProperty: 'gid' + },{ + layerId: 'field-parcels-other', // idProperty: 'gid' },{ layerId: 'OS/TopographicArea_1/Agricultural Land', @@ -98,8 +101,7 @@ const datasetsPlugin = createDatasetsPlugin({ // ], // tiles: ['https://farming-tiles-702a60f45633.herokuapp.com/field_parcels_with_hedges/{z}/{x}/{y}'], // sourceLayer: 'field_parcels_filtered', - featureLayer: '', - vectorTileLayer: '', + // featureLayer: '', // idProperty: 'id', // Enables dynamic fetching + deduplication // filter: ['get', ['propertyName', 'warning']], query: {}, @@ -110,39 +112,90 @@ const datasetsPlugin = createDatasetsPlugin({ showInKey: true, toggleVisibility: true, // visibility: 'hidden', - stroke: { outdoor: '#0000ff', dark: '#ffffff' }, - strokeWidth: 2, - // symbol: '', - // symbolSvgContent: '', - // symbolForegroundColor: '', - // symbolBackgroundColor: '', - // symbolDescription: { outdoor: 'blue outline' }, - // symbolOffset: [], - // fill: 'rgba(0,0,255,0.1)', - fillPattern: 'diagonal-cross-hatch', - fillPatternForegroundColor: { outdoor: '#0000ff', dark: '#ffffff' }, - fillPatternBackgroundColor: 'transparent', - featureStyleRules: [{ + // style: { + // stroke: { outdoor: '#0000ff', dark: '#ffffff' }, + // strokeWidth: 2, + // symbol: '', + // symbolSvgContent: '', + // symbolForegroundColor: '', + // symbolBackgroundColor: '', + // symbolDescription: { outdoor: 'blue outline' }, + // symbolOffset: [], + // fill: 'rgba(0,0,255,0.1)', + // fillPattern: 'diagonal-cross-hatch', + // fillPatternForegroundColor: { outdoor: '#0000ff', dark: '#ffffff' }, + // fillPatternBackgroundColor: 'transparent' + // }, + sublayers: [{ + id: '130', + label: 'Permanent grassland', + filter: ['==', ['get', 'dominant_land_cover'], '130'], // 'dominant_land_cover = "130"' + toggleVisibility: true, + style: { + stroke: { outdoor: '#82F584', dark: '#ffffff' }, + fillPattern: 'diagonal-cross-hatch', + fillPatternForegroundColor: { outdoor: '#82F584', dark: '#ffffff' }, + fillPatternBackgroundColor: 'transparent' + } + },{ id: '332', label: 'Woodland', filter: ['==', ['get', 'dominant_land_cover'], '332'], - stroke: { outdoor: '#00ff00', dark: '#ffffff' }, - fillPattern: 'cross-hatch', - fillPatternForegroundColor: { outdoor: '#00ff00', dark: '#ffffff' }, - fillPatternBackgroundColor: 'transparent', - toggleVisibility: true + toggleVisibility: true, + style: { + stroke: { outdoor: '#66CA7A', dark: '#ffffff' }, + fillPattern: 'dot', + fillPatternForegroundColor: { outdoor: '#66CA7A', dark: '#ffffff' }, + fillPatternBackgroundColor: 'transparent' + } },{ id: 'other', label: 'Others', - filter: ['!=', ['get', 'dominant_land_cover'], '332'], - stroke: { outdoor: '#0000ff', dark: '#ffffff' }, + filter: ['!', ['in', ['get', 'dominant_land_cover'], ['literal', ['130', '332']]]], + toggleVisibility: true, + style: { + stroke: { outdoor: ' #1d70b8', dark: '#ffffff' }, + fill: 'rgba(0,0,255,0.1)', + fillPattern: 'vertical-hatch', + fillPatternForegroundColor: { outdoor: '#1d70b8', dark: '#ffffff' }, + // fillPatternBackgroundColor: 'transparent' + } + }] + },{ + id: 'hedge-control', + 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, + maxZoom: 24, + showInKey: true, + toggleVisibility: true, + visibility: 'hidden', + keySymbolShape: 'line', + style: { + stroke: '#b58840', + fill: 'transparent', + strokeWidth: 4, + symbolDescription: { outdoor: 'blue outline' } + } + },{ + id: 'linked-parcels', + 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', + minZoom: 10, + maxZoom: 24, + showInKey: true, + toggleVisibility: true, + style: { + stroke: '#0000ff', + strokeWidth: 2, fill: 'rgba(0,0,255,0.1)', - // fillPattern: 'cross-hatch', - // fillPatternForegroundColor: { outdoor: '#00ff00', dark: '#ffffff' }, - // fillPatternBackgroundColor: 'transparent', - toggleVisibility: true - }], - opacity: 0.5 + symbolDescription: { outdoor: 'blue outline' } + } }] }) @@ -217,8 +270,9 @@ interactiveMap.on('map:ready', function (e) { }) interactiveMap.on('datasets:ready', function () { - // setTimeout(() => datasetsPlugin.hideDataset('field-parcels'), 2000) - // setTimeout(() => datasetsPlugin.showDataset('field-parcels'), 4000) + // setTimeout(() => datasetsPlugin.setFeatureVisibility(false, [55], { datasetId: 'field-parcels', idProperty: null }), 2000) + // setTimeout(() => datasetsPlugin.setFeatureVisibility(true, [55], { datasetId: 'field-parcels', idProperty: null }), 4000) + // setTimeout(() => datasetsPlugin.setStyle({ stroke: { outdoor: '#ff0000', dark: '#ffffff' }, fillPattern: 'horizontal-hatch', fillPatternForegroundColor: { outdoor: '#ff0000', dark: '#ffffff' } }, { datasetId: 'field-parcels', sublayerId: '130' }), 2000) }) // Ref to the selected features diff --git a/docs/plugins.md b/docs/plugins.md index 0a570624..0a25f754 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -28,7 +28,7 @@ Location search plugin with autocomplete functionality. Include custom datasets The following plugins are in early development. APIs and features may change. -### Datasets +### [Datasets](./plugins/datasets.md) Add datasets to your map, configure the display, layer toggling and render a key of symbology. diff --git a/docs/plugins/datasets.md b/docs/plugins/datasets.md new file mode 100644 index 00000000..4d719de6 --- /dev/null +++ b/docs/plugins/datasets.md @@ -0,0 +1,561 @@ +# Datasets Plugin + +The datasets plugin renders GeoJSON and vector tile datasets on the map, with support for sublayer style rules, layer visibility toggling, a key panel, and runtime style and data updates. + +## Usage + +```js +import createDatasetsPlugin from '@defra/interactive-map/plugins/beta/datasets' +import { maplibreLayerAdapter } from '@defra/interactive-map/plugins/beta/datasets/adapters/maplibre' + +const datasetsPlugin = createDatasetsPlugin({ + layerAdapter: maplibreLayerAdapter, + datasets: [ + { + id: 'my-parcels', + label: 'My parcels', + geojson: 'https://example.com/api/parcels', + minZoom: 10, + maxZoom: 24, + showInKey: true, + toggleVisibility: true, + style: { + stroke: '#d4351c', + strokeWidth: 2, + fill: 'transparent' + } + } + ] +}) + +const interactiveMap = new InteractiveMap({ + plugins: [datasetsPlugin] +}) +``` + +## Options + +Options are passed to the factory function when creating the plugin. + +--- + +### `layerAdapter` + +**Type:** `LayerAdapter` +**Required** + +The map provider adapter responsible for rendering datasets. Import `maplibreLayerAdapter` for MapLibre GL JS, or supply a custom adapter. + +```js +import { maplibreLayerAdapter } from '@defra/interactive-map/plugins/beta/datasets/adapters/maplibre' +``` + +--- + +### `datasets` + +**Type:** `Dataset[]` +**Required** + +Array of dataset configurations to render on the map. See [Dataset configuration](#dataset-configuration) below. + +--- + +### `includeModes` + +**Type:** `string[]` + +When set, the plugin only initialises when the app is in one of the specified modes. + +--- + +### `excludeModes` + +**Type:** `string[]` + +When set, the plugin does not initialise when the app is in one of the specified modes. + +--- + +## Dataset configuration + +Each entry in the `datasets` array describes one data source and how it should be rendered. + +--- + +### `id` + +**Type:** `string` +**Required** + +Unique identifier for the dataset. Used in all API method calls. + +--- + +### `label` + +**Type:** `string` + +Human-readable name shown in the Layers panel and Key panel. + +--- + +### `geojson` + +**Type:** `string | GeoJSON.FeatureCollection` + +GeoJSON source. Provide a URL string for remote data, or a GeoJSON object for inline data. Use alongside `transformRequest` to add authentication or append bbox parameters to the request. + +--- + +### `tiles` + +**Type:** `string[]` + +Array of vector tile URL templates (e.g. `https://example.com/tiles/{z}/{x}/{y}`). When set, the dataset uses a vector tile source instead of GeoJSON. + +--- + +### `sourceLayer` + +**Type:** `string` + +The layer name within the vector tile source to render. Required when using `tiles`. + +--- + +### `transformRequest` + +**Type:** `Function` + +A function called before each fetch to transform the request. Its primary purpose is to attach authentication credentials — API keys, OAuth tokens, or other headers. It also receives the current viewport context so you can append bbox or zoom parameters to the URL if your API supports spatial filtering. + +The plugin handles all dynamic fetching concerns (viewport tracking, debouncing, deduplication, caching, request cancellation) — `transformRequest` only needs to return the final URL and any headers. + +**Signature:** `transformRequest(url, { bbox, zoom, dataset })` + +| Argument | Type | Description | +|----------|------|-------------| +| `url` | `string` | The base URL from `geojson` | +| `bbox` | `number[]` | Current viewport bounds as `[west, south, east, north]` | +| `zoom` | `number` | Current map zoom level | +| `dataset` | `Object` | The full dataset configuration | + +Return either a plain URL string or an object `{ url, headers }`. The object form is needed when attaching auth headers. + +```js +// Auth headers only (no bbox filtering) +transformRequest: (url) => ({ + url, + headers: { Authorization: `Bearer ${getToken()}` } +}) + +// Append bbox to URL for server-side spatial filtering +transformRequest: (url, { bbox }) => { + const separator = url.includes('?') ? '&' : '?' + return { url: `${url}${separator}bbox=${bbox.join(',')}` } +} + +// Both — auth + bbox +transformRequest: (url, { bbox }) => { + const separator = url.includes('?') ? '&' : '?' + return { + url: `${url}${separator}bbox=${bbox.join(',')}`, + headers: { Authorization: `Bearer ${getToken()}` } + } +} +``` + +--- + +### `idProperty` + +**Type:** `string` + +Property name used to uniquely identify features. Required alongside `transformRequest` to enable dynamic bbox-based fetching — the plugin uses it internally to deduplicate features across successive viewport fetches. + +--- + +### `filter` + +**Type:** `FilterExpression` + +A MapLibre filter expression applied to the dataset's map layers. Features not matching the filter are not rendered. + +```js +filter: ['==', ['get', 'status'], 'active'] +``` + +--- + +### `minZoom` + +**Type:** `number` +**Default:** `6` + +Minimum zoom level at which the dataset is visible. + +--- + +### `maxZoom` + +**Type:** `number` +**Default:** `24` + +Maximum zoom level at which the dataset is visible. + +--- + +### `maxFeatures` + +**Type:** `number` + +Only applies to dynamic sources (those using `transformRequest`). Caps the number of features held in memory across all viewport fetches — older out-of-viewport features are evicted when the limit is exceeded. Omit for small or bounded datasets; set it when users are likely to pan extensively over a large dataset. + +--- + +### `visibility` + +**Type:** `'visible' | 'hidden'` +**Default:** `'visible'` + +Initial visibility of the dataset. + +--- + +### `showInKey` + +**Type:** `boolean` +**Default:** `false` + +When `true`, the dataset appears in the Key panel with its style symbol and label. + +--- + +### `toggleVisibility` + +**Type:** `boolean` +**Default:** `false` + +When `true`, the dataset appears in the Layers panel and can be toggled on and off by the user. + +--- + +### `groupLabel` + +**Type:** `string` + +Groups this dataset with others sharing the same `groupLabel` in the Layers panel, rendering them as a single collapsible group. + +--- + +### `keySymbolShape` + +**Type:** `'polygon' | 'line'` + +Overrides the shape used to render the key symbol for this dataset. Defaults to a polygon shape. + +--- + +### `style` + +**Type:** `Object` + +Visual style for the dataset. All style properties must be nested within this object. + +| Property | Type | Description | +|----------|------|-------------| +| `stroke` | `string \| Record` | Stroke (outline) colour. Accepts a plain colour string or a map-style-keyed object e.g. `{ outdoor: '#ff0000', dark: '#ffffff' }` | +| `strokeWidth` | `number` | Stroke width in pixels. **Default:** `2` | +| `strokeDashArray` | `number[]` | Dash pattern for the stroke e.g. `[4, 2]` | +| `fill` | `string \| Record` | Fill colour. Use `'transparent'` for no fill | +| `fillPattern` | `string` | Named fill pattern e.g. `'diagonal-cross-hatch'`, `'horizontal-hatch'`, `'dot'`, `'vertical-hatch'` | +| `fillPatternSvgContent` | `string` | Raw SVG content for a custom fill pattern | +| `fillPatternForegroundColor` | `string \| Record` | Foreground colour for the fill pattern | +| `fillPatternBackgroundColor` | `string \| Record` | Background colour for the fill pattern | +| `opacity` | `number` | Layer opacity from `0` to `1` | +| `symbolDescription` | `string \| Record` | Accessible description of the symbol shown in the key | +| `keySymbolShape` | `'polygon' \| 'line'` | Shape used for the key symbol | + +```js +style: { + stroke: { outdoor: '#d4351c', dark: '#ffffff' }, + strokeWidth: 2, + fill: 'rgba(212,53,28,0.1)', + symbolDescription: { outdoor: 'Red outline' } +} +``` + +--- + +### `sublayers` + +**Type:** `Sublayer[]` + +Array of sublayer rules that partition the dataset into visually distinct groups based on feature filters. Each sublayer is rendered as a separate pair of map layers. + +Sublayers inherit the parent dataset's style and only override what they specify. Fill precedence (highest to lowest): sublayer's own `fillPattern` → sublayer's own `fill` → parent's `fillPattern` → parent's `fill`. + +#### `Sublayer` properties + +| Property | Type | Description | +|----------|------|-------------| +| `id` | `string` | **Required.** Unique identifier within the dataset | +| `label` | `string` | Human-readable name shown in the Layers and Key panels | +| `filter` | `FilterExpression` | MapLibre filter expression to match features for this sublayer | +| `style` | `Object` | Style overrides for this sublayer. Accepts the same properties as the dataset `style` object | +| `showInKey` | `boolean` | Shows this sublayer in the Key panel. Inherits from dataset if not set | +| `toggleVisibility` | `boolean` | Shows this sublayer in the Layers panel. **Default:** `false` | + +```js +sublayers: [ + { + id: 'active', + label: 'Active parcels', + filter: ['==', ['get', 'status'], 'active'], + toggleVisibility: true, + style: { + stroke: '#00703c', + fill: 'rgba(0,112,60,0.1)', + symbolDescription: 'Green outline' + } + }, + { + id: 'inactive', + label: 'Inactive parcels', + filter: ['==', ['get', 'status'], 'inactive'], + toggleVisibility: true, + style: { + stroke: '#d4351c', + fillPattern: 'diagonal-cross-hatch', + fillPatternForegroundColor: '#d4351c' + } + } +] +``` + +--- + +## Methods + +Methods are called on the plugin instance after the `datasets:ready` event. + +The API follows a consistent pattern: the primary value is the first argument, with an optional scope object as the second argument. Omitting the scope applies the operation globally where supported. + +--- + +### `addDataset(dataset)` + +Add a new dataset to the map at runtime. + +| Argument | Type | Description | +|----------|------|-------------| +| `dataset` | `Dataset` | Dataset configuration object. Accepts the same properties as `datasets` array entries | + +```js +interactiveMap.on('datasets:ready', () => { + datasetsPlugin.addDataset({ + id: 'new-layer', + geojson: 'https://example.com/api/features', + minZoom: 10, + style: { stroke: '#0000ff' } + }) +}) +``` + +--- + +### `removeDataset(datasetId)` + +Remove a dataset from the map. + +| Argument | Type | Description | +|----------|------|-------------| +| `datasetId` | `string` | ID of the dataset to remove | + +```js +datasetsPlugin.removeDataset('my-parcels') +``` + +--- + +### `setDatasetVisibility(visible, scope?)` + +Set the visibility of datasets or sublayers. Omit `scope` to apply to all datasets globally. + +When showing a dataset that has sublayers, any sublayers that were individually hidden before the dataset was hidden will remain hidden — their individual visibility state is preserved. + +| Argument | Type | Description | +|----------|------|-------------| +| `visible` | `boolean` | `true` to show, `false` to hide | +| `scope.datasetId` | `string` | Optional. When omitted, applies to all datasets | +| `scope.sublayerId` | `string` | Optional. When provided alongside `datasetId`, targets a single sublayer | + +```js +// Global — all datasets +datasetsPlugin.setDatasetVisibility(false) +datasetsPlugin.setDatasetVisibility(true) + +// Single dataset +datasetsPlugin.setDatasetVisibility(false, { datasetId: 'my-parcels' }) + +// Single sublayer +datasetsPlugin.setDatasetVisibility(false, { datasetId: 'my-parcels', sublayerId: 'active' }) +``` + +--- + +### `setFeatureVisibility(visible, featureIds, scope)` + +Show or hide specific features within a dataset without removing them from the source. + +| Argument | Type | Description | +|----------|------|-------------| +| `visible` | `boolean` | `true` to show, `false` to hide | +| `featureIds` | `(string \| number)[]` | IDs of the features to target | +| `scope.datasetId` | `string` | ID of the dataset | +| `scope.idProperty` | `string \| null` | Property name to match features on. Pass `null` to match against the top-level `feature.id` | + +```js +// Hide by a feature property +datasetsPlugin.setFeatureVisibility(false, [123, 456], { + datasetId: 'my-parcels', + idProperty: 'parcel_id' +}) + +// Show using feature.id +datasetsPlugin.setFeatureVisibility(true, [123, 456], { + datasetId: 'my-parcels', + idProperty: null +}) +``` + +--- + +### `setStyle(style, scope)` + +Update the visual style of a dataset or sublayer at runtime. When targeting a sublayer, only the properties specified are overridden — the sublayer inherits all other styles from the parent dataset. + +| Argument | Type | Description | +|----------|------|-------------| +| `style` | `Object` | Style properties to apply. Accepts the same properties as `dataset.style` | +| `scope.datasetId` | `string` | ID of the dataset | +| `scope.sublayerId` | `string` | Optional. When provided, targets a single sublayer | + +```js +// Dataset level +datasetsPlugin.setStyle( + { stroke: '#0000ff', strokeWidth: 3 }, + { datasetId: 'my-parcels' } +) + +// Sublayer level +datasetsPlugin.setStyle( + { stroke: '#00703c', fillPattern: 'diagonal-cross-hatch', fillPatternForegroundColor: '#00703c' }, + { datasetId: 'my-parcels', sublayerId: 'active' } +) +``` + +--- + +### `getStyle(scope)` + +Returns the current style object for a dataset or sublayer, or `null` if not found. + +| Argument | Type | Description | +|----------|------|-------------| +| `scope.datasetId` | `string` | ID of the dataset | +| `scope.sublayerId` | `string` | Optional. When provided, returns the sublayer's style | + +```js +// Dataset style +const style = datasetsPlugin.getStyle({ datasetId: 'my-parcels' }) + +// Sublayer style +const style = datasetsPlugin.getStyle({ datasetId: 'my-parcels', sublayerId: 'active' }) +``` + +--- + +### `setOpacity(opacity, scope?)` + +Set the opacity of datasets or a sublayer. Safe to call on every tick from a slider — uses `setPaintProperty` internally rather than removing and re-adding layers. Omit `scope` to apply globally. + +| Argument | Type | Description | +|----------|------|-------------| +| `opacity` | `number` | Opacity from `0` (transparent) to `1` (fully opaque) | +| `scope.datasetId` | `string` | Optional. When omitted, applies to all datasets | +| `scope.sublayerId` | `string` | Optional. When provided alongside `datasetId`, targets a single sublayer | + +```js +// Global — all datasets +datasetsPlugin.setOpacity(0.5) + +// Single dataset +datasetsPlugin.setOpacity(0.5, { datasetId: 'my-parcels' }) + +// Single sublayer +datasetsPlugin.setOpacity(0.5, { datasetId: 'my-parcels', sublayerId: 'active' }) +``` + +--- + +### `getOpacity(scope?)` + +Returns the current opacity for a dataset or sublayer. When called without arguments, returns the first dataset's opacity — useful for initialising a global slider. Returns `null` if not found. + +| Argument | Type | Description | +|----------|------|-------------| +| `scope.datasetId` | `string` | Optional. When omitted, returns the first dataset's opacity | +| `scope.sublayerId` | `string` | Optional. When provided alongside `datasetId`, returns the sublayer's opacity | + +```js +// Global — read back after setOpacity() for slider initialisation +const opacity = datasetsPlugin.getOpacity() + +// Single dataset +const opacity = datasetsPlugin.getOpacity({ datasetId: 'my-parcels' }) + +// Single sublayer +const opacity = datasetsPlugin.getOpacity({ datasetId: 'my-parcels', sublayerId: 'active' }) +``` + +--- + +### `setData(geojson, scope)` + +Replace the GeoJSON data for a dataset source. Has no effect on vector tile datasets. + +| Argument | Type | Description | +|----------|------|-------------| +| `geojson` | `GeoJSON.FeatureCollection` | New GeoJSON data | +| `scope.datasetId` | `string` | ID of the dataset | + +```js +datasetsPlugin.setData( + { type: 'FeatureCollection', features: [...] }, + { datasetId: 'my-parcels' } +) +``` + +--- + +## Events + +Subscribe to events using `interactiveMap.on()`. + +--- + +### `datasets:ready` + +Emitted once all datasets have been initialised and rendered on the map. + +**Payload:** None + +```js +interactiveMap.on('datasets:ready', () => { + console.log('Datasets are ready') + // Safe to call API methods from here + const style = datasetsPlugin.getStyle({ datasetId: 'my-parcels' }) // unchanged — scope object +}) +``` diff --git a/package-lock.json b/package-lock.json index 3e5c0ff4..f4fa5903 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@turf/polygon-to-line": "^7.3.3", "accessible-autocomplete": "^3.0.1", "govuk-frontend": "^5.13.0", - "maplibre-gl": "^5.15.0", + "maplibre-gl": "^5.21.1", "polygon-splitter": "^0.0.11", "preact": "^10.27.2", "tslib": "^2.8.1" @@ -5303,7 +5303,9 @@ } }, "node_modules/@jest/environment-jsdom-abstract/node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -6023,17 +6025,6 @@ "geojson-normalize": "geojson-normalize" } }, - "node_modules/@mapbox/geojson-rewind": { - "version": "0.5.2", - "license": "ISC", - "dependencies": { - "get-stream": "^6.0.1", - "minimist": "^1.2.6" - }, - "bin": { - "geojson-rewind": "geojson-rewind" - } - }, "node_modules/@mapbox/jsonlint-lines-primitives": { "version": "2.0.2", "engines": { @@ -6090,11 +6081,18 @@ } }, "node_modules/@maplibre/geojson-vt": { - "version": "5.0.4", - "license": "ISC" + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.0.4.tgz", + "integrity": "sha512-HYv3POhMRCdhP3UPPATM/hfcy6/WuVIf5FKboH8u/ZuFMTnAIcSVlq5nfOqroLokd925w2QtE7YwquFOIacwVQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } }, "node_modules/@maplibre/maplibre-gl-style-spec": { - "version": "24.4.1", + "version": "24.7.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.7.0.tgz", + "integrity": "sha512-Ed7rcKYU5iELfablg9Mj+TVCsXsPBgdMyXPRAxb2v7oWg9YJnpQdZ5msDs1LESu/mtXy3Z48Vdppv2t/x5kAhw==", "license": "ISC", "dependencies": { "@mapbox/jsonlint-lines-primitives": "~2.0.2", @@ -6112,14 +6110,18 @@ } }, "node_modules/@maplibre/mlt": { - "version": "1.1.5", + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.8.tgz", + "integrity": "sha512-8vtfYGidr1rNkv5IwIoU2lfe3Oy+Wa8HluzQYcQi9cveU9K3pweAal/poQj4GJ0K/EW4bTQp2wVAs09g2yDRZg==", "license": "(MIT OR Apache-2.0)", "dependencies": { "@mapbox/point-geometry": "^1.1.0" } }, "node_modules/@maplibre/vt-pbf": { - "version": "4.2.1", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz", + "integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==", "license": "MIT", "dependencies": { "@mapbox/point-geometry": "^1.1.0", @@ -6131,6 +6133,12 @@ "supercluster": "^8.0.1" } }, + "node_modules/@maplibre/vt-pbf/node_modules/@maplibre/geojson-vt": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz", + "integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==", + "license": "ISC" + }, "node_modules/@mdx-js/mdx": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", @@ -8029,7 +8037,9 @@ } }, "node_modules/@parcel/watcher/node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "optional": true, @@ -8423,9 +8433,9 @@ "license": "MIT" }, "node_modules/@rollup/plugin-commonjs/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -8536,9 +8546,9 @@ "license": "MIT" }, "node_modules/@rollup/pluginutils/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -11692,7 +11702,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -15979,7 +15991,9 @@ "license": "MIT" }, "node_modules/flatted": { - "version": "3.3.3", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -16440,9 +16454,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -18661,7 +18675,9 @@ } }, "node_modules/jest-environment-jsdom/node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -19305,6 +19321,8 @@ }, "node_modules/json-stringify-pretty-compact": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", "license": "MIT" }, "node_modules/json5": { @@ -20103,22 +20121,22 @@ } }, "node_modules/maplibre-gl": { - "version": "5.17.0", + "version": "5.21.1", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.21.1.tgz", + "integrity": "sha512-zto1RTnFkOpOO1bm93ElCXF1huey2N4LvXaGLMFcYAu9txh0OhGIdX1q3LZLkrMKgMxMeYduaQo+DVNzg098fg==", "license": "BSD-3-Clause", "dependencies": { - "@mapbox/geojson-rewind": "^0.5.2", "@mapbox/jsonlint-lines-primitives": "^2.0.2", "@mapbox/point-geometry": "^1.1.0", "@mapbox/tiny-sdf": "^2.0.7", "@mapbox/unitbezier": "^0.0.1", "@mapbox/vector-tile": "^2.0.4", "@mapbox/whoots-js": "^3.1.0", - "@maplibre/geojson-vt": "^5.0.4", - "@maplibre/maplibre-gl-style-spec": "^24.4.1", - "@maplibre/mlt": "^1.1.2", - "@maplibre/vt-pbf": "^4.2.1", + "@maplibre/geojson-vt": "^6.0.4", + "@maplibre/maplibre-gl-style-spec": "^24.7.0", + "@maplibre/mlt": "^1.1.8", + "@maplibre/vt-pbf": "^4.3.0", "@types/geojson": "^7946.0.16", - "@types/supercluster": "^7.1.3", "earcut": "^3.0.2", "gl-matrix": "^3.4.4", "kdbush": "^4.0.2", @@ -20126,7 +20144,6 @@ "pbf": "^4.0.1", "potpack": "^2.1.0", "quickselect": "^3.0.0", - "supercluster": "^8.0.1", "tinyqueue": "^3.0.0" }, "engines": { @@ -23646,7 +23663,9 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.12", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, "node_modules/path-type": { @@ -23708,7 +23727,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -27494,9 +27515,9 @@ } }, "node_modules/rollup-plugin-visualizer/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -27943,9 +27964,9 @@ } }, "node_modules/serialize-javascript": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.4.tgz", - "integrity": "sha512-DuGdB+Po43Q5Jxwpzt1lhyFSYKryqoNjQSA9M92tyw0lyHIOur+XCalOUe0KTJpyqzT8+fQ5A0Jf7vCx/NKmIg==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", + "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", "license": "BSD-3-Clause", "engines": { "node": ">=20.0.0" @@ -29485,7 +29506,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -29910,9 +29933,9 @@ } }, "node_modules/undici": { - "version": "7.22.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", - "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", + "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -30623,9 +30646,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -31448,7 +31471,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "1.10.2", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "dev": true, "license": "ISC", "engines": { diff --git a/package.json b/package.json index 0e7a96e9..1c20274a 100755 --- a/package.json +++ b/package.json @@ -218,7 +218,7 @@ "@turf/polygon-to-line": "^7.3.3", "accessible-autocomplete": "^3.0.1", "govuk-frontend": "^5.13.0", - "maplibre-gl": "^5.15.0", + "maplibre-gl": "^5.21.1", "polygon-splitter": "^0.0.11", "preact": "^10.27.2", "tslib": "^2.8.1" diff --git a/plugins/beta/datasets/src/adapters/maplibre/index.js b/plugins/beta/datasets/src/adapters/maplibre/index.js index 2e02b115..d9c3a8bf 100644 --- a/plugins/beta/datasets/src/adapters/maplibre/index.js +++ b/plugins/beta/datasets/src/adapters/maplibre/index.js @@ -14,5 +14,5 @@ * }) */ export const maplibreLayerAdapter = { - load: () => import(/* webpackChunkName: "im-datasets-ml-adapter" */ './adapter.js') + load: () => import(/* webpackChunkName: "im-datasets-ml-adapter" */ './maplibreLayerAdapter.js') } diff --git a/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js b/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js new file mode 100644 index 00000000..c1870a74 --- /dev/null +++ b/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js @@ -0,0 +1,113 @@ +import { getValueForStyle } from '../../../../../../src/utils/getValueForStyle.js' +import { hasPattern, getPatternImageId } from '../../styles/patterns.js' +import { mergeSublayer } from '../../utils/mergeSublayer.js' +import { getSourceId, getLayerIds, getSublayerLayerIds, isDynamicSource, MAX_TILE_ZOOM } from './layerIds.js' + +// ─── Source ─────────────────────────────────────────────────────────────────── + +export const addSource = (map, dataset, sourceId) => { + if (map.getSource(sourceId)) { + return + } + if (dataset.tiles) { + map.addSource(sourceId, { + type: 'vector', + tiles: dataset.tiles, + minzoom: dataset.minZoom || 0, + maxzoom: dataset.maxZoom || MAX_TILE_ZOOM + }) + return + } + if (dataset.geojson) { + const initialData = isDynamicSource(dataset) + ? { type: 'FeatureCollection', features: [] } + : dataset.geojson + map.addSource(sourceId, { type: 'geojson', data: initialData, generateId: true }) + } +} + +// ─── Fill layer ─────────────────────────────────────────────────────────────── + +export const addFillLayer = (map, config, layerId, sourceId, sourceLayer, visibility, mapStyleId) => { + if (!layerId || map.getLayer(layerId)) { + return + } + if (!config.fill && !hasPattern(config)) { + return + } + const patternImageId = hasPattern(config) ? getPatternImageId(config, mapStyleId) : null + const paint = patternImageId + ? { 'fill-pattern': patternImageId, 'fill-opacity': config.opacity || 1 } + : { 'fill-color': getValueForStyle(config.fill, mapStyleId), 'fill-opacity': config.opacity || 1 } + map.addLayer({ + id: layerId, + type: 'fill', + source: sourceId, + 'source-layer': sourceLayer, + layout: { visibility }, + paint, + ...(config.filter ? { filter: config.filter } : {}) + }) +} + +// ─── Stroke layer ───────────────────────────────────────────────────────────── + +export const addStrokeLayer = (map, config, layerId, sourceId, sourceLayer, visibility, mapStyleId) => { + if (!layerId || !config.stroke || map.getLayer(layerId)) { + return + } + map.addLayer({ + id: layerId, + type: 'line', + source: sourceId, + 'source-layer': sourceLayer, + layout: { visibility }, + paint: { + 'line-color': getValueForStyle(config.stroke, mapStyleId), + 'line-width': config.strokeWidth || 1, + 'line-opacity': config.opacity || 1, + ...(config.strokeDashArray ? { 'line-dasharray': config.strokeDashArray } : {}) + }, + ...(config.filter ? { filter: config.filter } : {}) + }) +} + +// ─── Dataset layers ─────────────────────────────────────────────────────────── + +export const addSublayerLayers = (map, dataset, sublayer, sourceId, sourceLayer, mapStyleId) => { + const merged = mergeSublayer(dataset, sublayer) + const { fillLayerId, strokeLayerId } = getSublayerLayerIds(dataset.id, sublayer.id) + const parentHidden = dataset.visibility === 'hidden' + const sublayerHidden = dataset.sublayerVisibility?.[sublayer.id] === 'hidden' + const visibility = (parentHidden || sublayerHidden) ? 'none' : 'visible' + addFillLayer(map, merged, fillLayerId, sourceId, sourceLayer, visibility, mapStyleId) + addStrokeLayer(map, merged, strokeLayerId, sourceId, sourceLayer, visibility, mapStyleId) +} + +/** + * Add all layers (and source if needed) for a dataset. + * Returns the sourceId so the caller can track the datasetId → sourceId mapping. + * @param {Object} map - MapLibre map instance + * @param {Object} dataset + * @param {string} mapStyleId + * @returns {string} sourceId + */ +export const addDatasetLayers = (map, dataset, mapStyleId) => { + const sourceId = getSourceId(dataset) + addSource(map, dataset, sourceId) + + const sourceLayer = dataset.tiles?.length ? dataset.sourceLayer : undefined + + if (dataset.sublayers?.length) { + dataset.sublayers.forEach(sublayer => { + addSublayerLayers(map, dataset, sublayer, sourceId, sourceLayer, mapStyleId) + }) + return sourceId + } + + const { fillLayerId, strokeLayerId } = getLayerIds(dataset) + const visibility = dataset.visibility === 'hidden' ? 'none' : 'visible' + addFillLayer(map, dataset, fillLayerId, sourceId, sourceLayer, visibility, mapStyleId) + addStrokeLayer(map, dataset, strokeLayerId, sourceId, sourceLayer, visibility, mapStyleId) + return sourceId +} diff --git a/plugins/beta/datasets/src/adapters/maplibre/layerIds.js b/plugins/beta/datasets/src/adapters/maplibre/layerIds.js new file mode 100644 index 00000000..433ce5cb --- /dev/null +++ b/plugins/beta/datasets/src/adapters/maplibre/layerIds.js @@ -0,0 +1,69 @@ +import { hasPattern } from '../../styles/patterns.js' + +// ─── Internal helpers ───────────────────────────────────────────────────────── + +export const isDynamicSource = (dataset) => + typeof dataset.geojson === 'string' && + !!dataset.idProperty && + typeof dataset.transformRequest === 'function' + +const HASH_BASE = 36 +const MAX_TILE_ZOOM = 22 + +export { MAX_TILE_ZOOM } + +export const hashString = (str) => { + let hash = 0 + for (const ch of str) { + hash = Math.trunc(((hash << 5) - hash) + ch.codePointAt(0)) + } + return Math.abs(hash).toString(HASH_BASE) +} + +// ─── Source ID ──────────────────────────────────────────────────────────────── + +export const getSourceId = (dataset) => { + if (dataset.tiles) { + const tilesKey = Array.isArray(dataset.tiles) ? dataset.tiles.join(',') : dataset.tiles + return `tiles-${hashString(tilesKey)}` + } + if (dataset.geojson) { + if (isDynamicSource(dataset)) { + return `geojson-dynamic-${dataset.id}` + } + if (typeof dataset.geojson === 'string') { + return `geojson-${hashString(dataset.geojson)}` + } + return `geojson-${dataset.id}` + } + return `source-${dataset.id}` +} + +// ─── Layer IDs ──────────────────────────────────────────────────────────────── + +export const getLayerIds = (dataset) => { + const hasFill = !!dataset.fill || hasPattern(dataset) + const hasStroke = !!dataset.stroke + const fillLayerId = hasFill ? dataset.id : null + let strokeLayerId = null + if (hasStroke) { + strokeLayerId = hasFill ? `${dataset.id}-stroke` : dataset.id + } + return { fillLayerId, strokeLayerId } +} + +export const getSublayerLayerIds = (datasetId, sublayerId) => ({ + fillLayerId: `${datasetId}-${sublayerId}`, + strokeLayerId: `${datasetId}-${sublayerId}-stroke` +}) + +export const getAllLayerIds = (dataset) => { + if (dataset.sublayers?.length) { + return dataset.sublayers.flatMap(sublayer => { + const { fillLayerId, strokeLayerId } = getSublayerLayerIds(dataset.id, sublayer.id) + return [strokeLayerId, fillLayerId] + }) + } + const { fillLayerId, strokeLayerId } = getLayerIds(dataset) + return [strokeLayerId, fillLayerId].filter(Boolean) +} diff --git a/plugins/beta/datasets/src/adapters/maplibre/adapter.js b/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js similarity index 51% rename from plugins/beta/datasets/src/adapters/maplibre/adapter.js rename to plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js index 43731318..bbf2c490 100644 --- a/plugins/beta/datasets/src/adapters/maplibre/adapter.js +++ b/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js @@ -1,28 +1,14 @@ -import { getValueForStyle } from '../../../../../../src/utils/getValueForStyle.js' -import { hasPattern, getPatternImageId, rasterisePattern } from '../../styles/patterns.js' import { applyExclusionFilter } from '../../utils/filters.js' - -const isDynamicSource = (dataset) => - typeof dataset.geojson === 'string' && - !!dataset.idProperty && - typeof dataset.transformRequest === 'function' - -const hashString = (str) => { - const HASH_BASE = 36 - let hash = 0 - for (const ch of str) { - hash = ((hash << 5) - hash) + ch.codePointAt(0) - hash = hash & hash - } - return Math.abs(hash).toString(HASH_BASE) -} +import { getSourceId, getLayerIds, getSublayerLayerIds, getAllLayerIds } from './layerIds.js' +import { addDatasetLayers, addSublayerLayers } from './layerBuilders.js' +import { registerPatterns } from './patternRegistry.js' /** * MapLibre GL JS implementation of the LayerAdapter interface for the datasets plugin. * * Owns all map-framework-specific concerns: - * - Source and layer creation (fill + stroke layers per dataset) - * - Pattern image registration (rasterisation + map.addImage) + * - Source and layer creation (delegated to layerBuilders) + * - Pattern image registration (delegated to patternRegistry) * - Visibility toggling, feature filtering, style changes * - Style-change recovery (re-adding layers after basemap swap) */ @@ -42,7 +28,7 @@ export default class MaplibreLayerAdapter { * @returns {Promise} Resolves once the map has processed all layers. */ async init (datasets, mapStyleId) { - await this._registerPatterns(datasets, mapStyleId) + await registerPatterns(this._map, datasets, mapStyleId) datasets.forEach(dataset => this._addLayers(dataset, mapStyleId)) await new Promise(resolve => this._map.once('idle', resolve)) } @@ -54,9 +40,11 @@ export default class MaplibreLayerAdapter { destroy (datasets) { const removedSourceIds = new Set() datasets.forEach(dataset => { - const sourceId = this._getSourceId(dataset) + const sourceId = getSourceId(dataset) this._getLayersUsingSource(sourceId).forEach(layerId => { - if (this._map.getLayer(layerId)) this._map.removeLayer(layerId) + if (this._map.getLayer(layerId)) { + this._map.removeLayer(layerId) + } }) if (!removedSourceIds.has(sourceId) && this._map.getSource(sourceId)) { this._map.removeSource(sourceId) @@ -79,7 +67,7 @@ export default class MaplibreLayerAdapter { // MapLibre wipes all sources/layers on style change — must wait for idle first await new Promise(resolve => this._map.once('idle', resolve)) - await this._registerPatterns(datasets, newStyleId) + await registerPatterns(this._map, datasets, newStyleId) datasets.forEach(dataset => this._addLayers(dataset, newStyleId)) // Re-push cached data for dynamic sources @@ -114,18 +102,16 @@ export default class MaplibreLayerAdapter { * @param {Object[]} allDatasets - Full current dataset list, for shared-source check. */ removeDataset (dataset, allDatasets) { - const { fillLayerId, strokeLayerId } = this._getLayerIds(dataset) - const sourceId = this._getSourceId(dataset) + const sourceId = getSourceId(dataset) - ;[strokeLayerId, fillLayerId].forEach(layerId => { - if (layerId && this._map.getLayer(layerId)) { + getAllLayerIds(dataset).forEach(layerId => { + if (this._map.getLayer(layerId)) { this._map.removeLayer(layerId) } }) - const sourceIsShared = allDatasets.some( - d => d.id !== dataset.id && this._getSourceId(d) === sourceId - ) + const sourceIsShared = allDatasets.some(d => d.id !== dataset.id && getSourceId(d) === sourceId) + if (!sourceIsShared && this._map.getSource(sourceId)) { this._map.removeSource(sourceId) } @@ -149,6 +135,36 @@ export default class MaplibreLayerAdapter { this._setDatasetVisibility(datasetId, 'none') } + /** + * Make a single sublayer's layers visible. + * @param {string} datasetId + * @param {string} sublayerId + */ + showSublayer (datasetId, sublayerId) { + const { fillLayerId, strokeLayerId } = getSublayerLayerIds(datasetId, sublayerId) + if (this._map.getLayer(fillLayerId)) { + this._map.setLayoutProperty(fillLayerId, 'visibility', 'visible') + } + if (this._map.getLayer(strokeLayerId)) { + this._map.setLayoutProperty(strokeLayerId, 'visibility', 'visible') + } + } + + /** + * Hide a single sublayer's layers. + * @param {string} datasetId + * @param {string} sublayerId + */ + hideSublayer (datasetId, sublayerId) { + const { fillLayerId, strokeLayerId } = getSublayerLayerIds(datasetId, sublayerId) + if (this._map.getLayer(fillLayerId)) { + this._map.setLayoutProperty(fillLayerId, 'visibility', 'none') + } + if (this._map.getLayer(strokeLayerId)) { + this._map.setLayoutProperty(strokeLayerId, 'visibility', 'none') + } + } + // ─── Feature operations ───────────────────────────────────────────────────── /** @@ -171,17 +187,74 @@ export default class MaplibreLayerAdapter { this._applyFeatureFilter(dataset, idProperty, allHiddenIds) } - // ─── New API stubs ─────────────────────────────────────────────────────────── + /** + * Update a dataset's style and re-render all its layers. + * @param {Object} dataset - Updated dataset (style changes already merged in) + * @param {string} mapStyleId + * @returns {Promise} + */ + async setStyle (dataset, mapStyleId) { + getAllLayerIds(dataset).forEach(layerId => { + if (this._map.getLayer(layerId)) { + this._map.removeLayer(layerId) + } + }) + await registerPatterns(this._map, [dataset], mapStyleId) + this._addLayers(dataset, mapStyleId) + } /** - * Update a dataset's style properties (fill, stroke, opacity, pattern etc). - * @param {Object} dataset + * Update a single sublayer's style and re-render its layers. + * @param {Object} dataset - Updated dataset (sublayer style changes already merged in) + * @param {string} sublayerId * @param {string} mapStyleId - * @param {Object} styleChanges - * @stub + * @returns {Promise} */ - setStyle (dataset, mapStyleId, styleChanges) { - // TODO: implement — map.setPaintProperty for fill-color, line-color, opacity etc + async setSublayerStyle (dataset, sublayerId, mapStyleId) { + const { fillLayerId, strokeLayerId } = getSublayerLayerIds(dataset.id, sublayerId) + if (this._map.getLayer(fillLayerId)) { + this._map.removeLayer(fillLayerId) + } + if (this._map.getLayer(strokeLayerId)) { + this._map.removeLayer(strokeLayerId) + } + const sublayer = dataset.sublayers?.find(s => s.id === sublayerId) + if (!sublayer) { + return + } + await registerPatterns(this._map, [dataset], mapStyleId) + const sourceId = this._datasetSourceMap.get(dataset.id) + const sourceLayer = dataset.tiles?.length ? dataset.sourceLayer : undefined + addSublayerLayers(this._map, dataset, sublayer, sourceId, sourceLayer, mapStyleId) + } + + /** + * Set opacity for all layers belonging to a dataset. + * Uses setPaintProperty directly — safe to call on every slider tick. + * @param {string} datasetId + * @param {number} opacity + */ + setOpacity (datasetId, opacity) { + const style = this._map.getStyle() + if (!style?.layers) { + return + } + style.layers + .filter(layer => layer.id === datasetId || layer.id.startsWith(`${datasetId}-`)) + .forEach(layer => this._setPaintOpacity(layer.id, opacity)) + } + + /** + * Set opacity for a single sublayer's fill and stroke layers. + * Uses setPaintProperty directly — safe to call on every slider tick. + * @param {string} datasetId + * @param {string} sublayerId + * @param {number} opacity + */ + setSublayerOpacity (datasetId, sublayerId, opacity) { + const { fillLayerId, strokeLayerId } = getSublayerLayerIds(datasetId, sublayerId) + this._setPaintOpacity(fillLayerId, opacity) + this._setPaintOpacity(strokeLayerId, opacity) } /** @@ -191,57 +264,50 @@ export default class MaplibreLayerAdapter { */ setData (datasetId, geojson) { const sourceId = this._datasetSourceMap.get(datasetId) - if (!sourceId) return + if (!sourceId) { + return + } const source = this._map.getSource(sourceId) if (source && typeof source.setData === 'function') { source.setData(geojson) } } - // ─── Private helpers ───────────────────────────────────────────────────────── - - _getSourceId (dataset) { - if (dataset.tiles) { - const tilesKey = Array.isArray(dataset.tiles) ? dataset.tiles.join(',') : dataset.tiles - return `tiles-${hashString(tilesKey)}` - } - if (dataset.geojson) { - if (isDynamicSource(dataset)) return `geojson-dynamic-${dataset.id}` - if (typeof dataset.geojson === 'string') return `geojson-${hashString(dataset.geojson)}` - return `geojson-${dataset.id}` - } - return `source-${dataset.id}` - } - - _getLayerIds (dataset) { - const hasFill = !!dataset.fill || hasPattern(dataset) - const hasStroke = !!dataset.stroke - const fillLayerId = hasFill ? dataset.id : null - const strokeLayerId = hasStroke ? (hasFill ? `${dataset.id}-stroke` : dataset.id) : null - return { fillLayerId, strokeLayerId } - } + // ─── Private ───────────────────────────────────────────────────────────────── - _getLayersUsingSource (sourceId) { - const style = this._map.getStyle() - if (!style?.layers) return [] - return style.layers - .filter(layer => layer.source === sourceId) - .map(layer => layer.id) + _addLayers (dataset, mapStyleId) { + const sourceId = addDatasetLayers(this._map, dataset, mapStyleId) + this._datasetSourceMap.set(dataset.id, sourceId) } _setDatasetVisibility (datasetId, visibility) { - const fillLayerId = datasetId - const strokeLayerId = `${datasetId}-stroke` - if (this._map.getLayer(fillLayerId)) { - this._map.setLayoutProperty(fillLayerId, 'visibility', visibility) - } - if (this._map.getLayer(strokeLayerId)) { - this._map.setLayoutProperty(strokeLayerId, 'visibility', visibility) + const style = this._map.getStyle() + if (!style?.layers) { + return } + // Covers base fill layer (datasetId) and all suffixed layers + // (-stroke, -${sublayerId}, -${sublayerId}-stroke) without needing the dataset object. + style.layers + .filter(layer => + layer.id === datasetId || + layer.id.startsWith(`${datasetId}-`) + ) + .forEach(layer => this._map.setLayoutProperty(layer.id, 'visibility', visibility)) } _applyFeatureFilter (dataset, idProperty, excludeIds) { - const { fillLayerId, strokeLayerId } = this._getLayerIds(dataset) + if (dataset.sublayers?.length) { + dataset.sublayers.forEach(sublayer => { + const { fillLayerId: subFillId, strokeLayerId: subStrokeId } = getSublayerLayerIds(dataset.id, sublayer.id) + const sublayerFilter = dataset.filter && sublayer.filter + ? ['all', dataset.filter, sublayer.filter] + : (sublayer.filter || dataset.filter || null) + applyExclusionFilter(this._map, subFillId, sublayerFilter, idProperty, excludeIds) + applyExclusionFilter(this._map, subStrokeId, sublayerFilter, idProperty, excludeIds) + }) + return + } + const { fillLayerId, strokeLayerId } = getLayerIds(dataset) const originalFilter = dataset.filter || null if (fillLayerId) { applyExclusionFilter(this._map, fillLayerId, originalFilter, idProperty, excludeIds) @@ -251,82 +317,22 @@ export default class MaplibreLayerAdapter { } } - async _registerPatterns (datasets, mapStyleId) { - const patternDatasets = datasets.filter(hasPattern) - if (!patternDatasets.length) return - - await Promise.all(patternDatasets.map(async (dataset) => { - const imageId = getPatternImageId(dataset, mapStyleId) - if (!imageId || this._map.hasImage(imageId)) return - - const result = await rasterisePattern(dataset, mapStyleId) - if (result) { - this._map.addImage(result.imageId, result.imageData, { pixelRatio: 2 }) - } - })) - } - - _addLayers (dataset, mapStyleId) { - const sourceId = this._getSourceId(dataset) - - // Track datasetId → sourceId for setData() - this._datasetSourceMap.set(dataset.id, sourceId) - - // ── Add source ──────────────────────────────────────────────────────────── - if (!this._map.getSource(sourceId)) { - if (dataset.tiles) { - this._map.addSource(sourceId, { - type: 'vector', - tiles: dataset.tiles, - minzoom: dataset.minZoom || 0, - maxzoom: dataset.maxZoom || 22 - }) - } else if (dataset.geojson) { - const initialData = isDynamicSource(dataset) - ? { type: 'FeatureCollection', features: [] } - : dataset.geojson - this._map.addSource(sourceId, { type: 'geojson', data: initialData }) - } - } - - const { fillLayerId, strokeLayerId } = this._getLayerIds(dataset) - const visibility = dataset.visibility === 'hidden' ? 'none' : 'visible' - const sourceLayer = dataset.tiles?.length ? dataset.sourceLayer : undefined - - // ── Add fill layer ──────────────────────────────────────────────────────── - if (fillLayerId && !this._map.getLayer(fillLayerId)) { - const patternImageId = hasPattern(dataset) ? getPatternImageId(dataset, mapStyleId) : null - const fillPaint = patternImageId - ? { 'fill-pattern': patternImageId, 'fill-opacity': dataset.opacity || 1 } - : { 'fill-color': getValueForStyle(dataset.fill, mapStyleId), 'fill-opacity': dataset.opacity || 1 } - - this._map.addLayer({ - id: fillLayerId, - type: 'fill', - source: sourceId, - 'source-layer': sourceLayer, - layout: { visibility }, - paint: fillPaint, - ...(dataset.filter ? { filter: dataset.filter } : {}) - }) + _setPaintOpacity (layerId, opacity) { + const layer = this._map.getLayer(layerId) + if (!layer) { + return } + const prop = layer.type === 'line' ? 'line-opacity' : 'fill-opacity' + this._map.setPaintProperty(layerId, prop, opacity) + } - // ── Add stroke layer ────────────────────────────────────────────────────── - if (strokeLayerId && !this._map.getLayer(strokeLayerId)) { - this._map.addLayer({ - id: strokeLayerId, - type: 'line', - source: sourceId, - 'source-layer': sourceLayer, - layout: { visibility }, - paint: { - 'line-color': getValueForStyle(dataset.stroke, mapStyleId), - 'line-width': dataset.strokeWidth || 1, - 'line-opacity': dataset.opacity || 1, - ...(dataset.strokeDashArray ? { 'line-dasharray': dataset.strokeDashArray } : {}) - }, - ...(dataset.filter ? { filter: dataset.filter } : {}) - }) + _getLayersUsingSource (sourceId) { + const style = this._map.getStyle() + if (!style?.layers) { + return [] } + return style.layers + .filter(layer => layer.source === sourceId) + .map(layer => layer.id) } } diff --git a/plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js b/plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js new file mode 100644 index 00000000..07c928c0 --- /dev/null +++ b/plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js @@ -0,0 +1,48 @@ +import { hasPattern, getPatternImageId, rasterisePattern } from '../../styles/patterns.js' +import { mergeSublayer } from '../../utils/mergeSublayer.js' + +/** + * Collect all style configs that require a pattern image: top-level datasets + * and any sublayers whose merged style has a pattern. + * @param {Object[]} datasets + * @returns {Object[]} + */ +const getPatternConfigs = (datasets) => + datasets.flatMap(dataset => { + const configs = hasPattern(dataset) ? [dataset] : [] + if (dataset.sublayers?.length) { + dataset.sublayers.forEach(sublayer => { + const merged = mergeSublayer(dataset, sublayer) + if (hasPattern(merged)) { + configs.push(merged) + } + }) + } + return configs + }) + +/** + * Register all required pattern images with the map. + * Skips images that are already registered (safe to call on style change). + * @param {Object} map - MapLibre map instance + * @param {Object[]} datasets + * @param {string} mapStyleId + * @returns {Promise} + */ +export const registerPatterns = async (map, datasets, mapStyleId) => { + const patternConfigs = getPatternConfigs(datasets) + if (!patternConfigs.length) { + return + } + + await Promise.all(patternConfigs.map(async (config) => { + const imageId = getPatternImageId(config, mapStyleId) + if (!imageId || map.hasImage(imageId)) { + return + } + const result = await rasterisePattern(config, mapStyleId) + if (result) { + map.addImage(result.imageId, result.imageData, { pixelRatio: 2 }) + } + })) +} diff --git a/plugins/beta/datasets/src/api/getOpacity.js b/plugins/beta/datasets/src/api/getOpacity.js new file mode 100644 index 00000000..7b0ae58e --- /dev/null +++ b/plugins/beta/datasets/src/api/getOpacity.js @@ -0,0 +1,17 @@ +export const getOpacity = ({ pluginState }, options) => { + const { datasetId, sublayerId } = options || {} + + if (sublayerId) { + const dataset = pluginState.datasets?.find(d => d.id === datasetId) + const sublayer = dataset?.sublayers?.find(s => s.id === sublayerId) + return sublayer?.style?.opacity ?? null + } + + if (datasetId) { + const dataset = pluginState.datasets?.find(d => d.id === datasetId) + return dataset?.opacity ?? null + } + + // Global — return first dataset's opacity + return pluginState.datasets?.[0]?.opacity ?? null +} diff --git a/plugins/beta/datasets/src/api/getStyle.js b/plugins/beta/datasets/src/api/getStyle.js new file mode 100644 index 00000000..f6ac0a5c --- /dev/null +++ b/plugins/beta/datasets/src/api/getStyle.js @@ -0,0 +1,13 @@ +export const getStyle = ({ pluginState }, { datasetId, sublayerId } = {}) => { + const dataset = pluginState.datasets?.find(d => d.id === datasetId) + if (!dataset) { + return null + } + + if (sublayerId) { + const sublayer = dataset.sublayers?.find(s => s.id === sublayerId) + return sublayer?.style ?? null + } + + return dataset.style ?? null +} diff --git a/plugins/beta/datasets/src/api/hideDataset.js b/plugins/beta/datasets/src/api/hideDataset.js deleted file mode 100644 index 7943dede..00000000 --- a/plugins/beta/datasets/src/api/hideDataset.js +++ /dev/null @@ -1,4 +0,0 @@ -export const hideDataset = ({ pluginState }, datasetId) => { - pluginState.layerAdapter?.hideDataset(datasetId) - pluginState.dispatch({ type: 'SET_DATASET_VISIBILITY', payload: { id: datasetId, visibility: 'hidden' } }) -} diff --git a/plugins/beta/datasets/src/api/hideFeatures.js b/plugins/beta/datasets/src/api/hideFeatures.js deleted file mode 100644 index 414d6e42..00000000 --- a/plugins/beta/datasets/src/api/hideFeatures.js +++ /dev/null @@ -1,17 +0,0 @@ -export const hideFeatures = ({ pluginState }, { featureIds, idProperty, datasetId }) => { - const dataset = pluginState.datasets?.find(d => d.id === datasetId) - if (!dataset) return - - const existingHidden = pluginState.hiddenFeatures[datasetId] - const allHiddenIds = existingHidden - ? [...new Set([...existingHidden.ids, ...featureIds])] - : featureIds - - // Update state (store by datasetId, not individual layer IDs) - pluginState.dispatch({ - type: 'HIDE_FEATURES', - payload: { layerId: datasetId, idProperty, featureIds } - }) - - pluginState.layerAdapter?.hideFeatures(dataset, idProperty, allHiddenIds) -} diff --git a/plugins/beta/datasets/src/api/removeDataset.js b/plugins/beta/datasets/src/api/removeDataset.js index 2903e8f8..705ea729 100644 --- a/plugins/beta/datasets/src/api/removeDataset.js +++ b/plugins/beta/datasets/src/api/removeDataset.js @@ -1,6 +1,8 @@ export const removeDataset = ({ pluginState }, datasetId) => { const dataset = pluginState.datasets?.find(d => d.id === datasetId) - if (!dataset) return + if (!dataset) { + return + } pluginState.layerAdapter?.removeDataset(dataset, pluginState.datasets) pluginState.dispatch({ type: 'REMOVE_DATASET', payload: { id: datasetId } }) diff --git a/plugins/beta/datasets/src/api/setData.js b/plugins/beta/datasets/src/api/setData.js index edc85301..9f718321 100644 --- a/plugins/beta/datasets/src/api/setData.js +++ b/plugins/beta/datasets/src/api/setData.js @@ -1,4 +1,8 @@ -export const setData = ({ pluginState }, { datasetId, geojson }) => { - // TODO: dispatch state update if dataset data needs to be tracked in state +export const setData = ({ pluginState, services }, geojson, { datasetId }) => { + const dataset = pluginState.datasets?.find(d => d.id === datasetId) + if (dataset?.tiles) { + services.logger.warn(`setData called on vector tile dataset "${datasetId}" — has no effect`) + return + } pluginState.layerAdapter?.setData(datasetId, geojson) } diff --git a/plugins/beta/datasets/src/api/setDatasetVisibility.js b/plugins/beta/datasets/src/api/setDatasetVisibility.js new file mode 100644 index 00000000..d3be96a5 --- /dev/null +++ b/plugins/beta/datasets/src/api/setDatasetVisibility.js @@ -0,0 +1,37 @@ +export const setDatasetVisibility = ({ pluginState }, visible, options = {}) => { + const { datasetId, sublayerId } = options + + if (sublayerId) { + const visibility = visible ? 'visible' : 'hidden' + pluginState.layerAdapter?.[visible ? 'showSublayer' : 'hideSublayer'](datasetId, sublayerId) + pluginState.dispatch({ type: 'SET_SUBLAYER_VISIBILITY', payload: { datasetId, sublayerId, visibility } }) + return + } + + if (datasetId) { + pluginState.layerAdapter?.[visible ? 'showDataset' : 'hideDataset'](datasetId) + pluginState.dispatch({ type: 'SET_DATASET_VISIBILITY', payload: { id: datasetId, visibility: visible ? 'visible' : 'hidden' } }) + if (visible) { + const dataset = pluginState.datasets?.find(d => d.id === datasetId) + Object.entries(dataset?.sublayerVisibility || {}).forEach(([subId, vis]) => { + if (vis === 'hidden') { + pluginState.layerAdapter?.hideSublayer(datasetId, subId) + } + }) + } + return + } + + // Global + pluginState.dispatch({ type: 'SET_GLOBAL_VISIBILITY', payload: { visibility: visible ? 'visible' : 'hidden' } }) + pluginState.datasets?.forEach(dataset => { + pluginState.layerAdapter?.[visible ? 'showDataset' : 'hideDataset'](dataset.id) + if (visible) { + Object.entries(dataset.sublayerVisibility || {}).forEach(([subId, vis]) => { + if (vis === 'hidden') { + pluginState.layerAdapter?.hideSublayer(dataset.id, subId) + } + }) + } + }) +} diff --git a/plugins/beta/datasets/src/api/setFeatureVisibility.js b/plugins/beta/datasets/src/api/setFeatureVisibility.js new file mode 100644 index 00000000..cbc5a992 --- /dev/null +++ b/plugins/beta/datasets/src/api/setFeatureVisibility.js @@ -0,0 +1,22 @@ +export const setFeatureVisibility = ({ pluginState }, visible, featureIds, { datasetId, idProperty = null } = {}) => { + const dataset = pluginState.datasets?.find(d => d.id === datasetId) + if (!dataset) { + return + } + + if (visible) { + const existingHidden = pluginState.hiddenFeatures[datasetId] + if (!existingHidden) { + return + } + const remainingHiddenIds = existingHidden.ids.filter(id => !featureIds.includes(id)) + pluginState.dispatch({ type: 'SHOW_FEATURES', payload: { layerId: datasetId, featureIds } }) + pluginState.layerAdapter?.showFeatures(dataset, idProperty, remainingHiddenIds) + } else { + const existingHidden = pluginState.hiddenFeatures[datasetId] + const existingIds = existingHidden?.ids || [] + const allHiddenIds = [...new Set([...existingIds, ...featureIds])] + pluginState.dispatch({ type: 'HIDE_FEATURES', payload: { layerId: datasetId, idProperty, featureIds } }) + pluginState.layerAdapter?.hideFeatures(dataset, idProperty, allHiddenIds) + } +} diff --git a/plugins/beta/datasets/src/api/setOpacity.js b/plugins/beta/datasets/src/api/setOpacity.js new file mode 100644 index 00000000..e3b5cec3 --- /dev/null +++ b/plugins/beta/datasets/src/api/setOpacity.js @@ -0,0 +1,29 @@ +export const setOpacity = ({ pluginState }, opacity, options) => { + const { datasetId, sublayerId } = options || {} + + if (sublayerId) { + const dataset = pluginState.datasets?.find(d => d.id === datasetId) + if (!dataset) { + return + } + pluginState.dispatch({ type: 'SET_SUBLAYER_OPACITY', payload: { datasetId, sublayerId, opacity } }) + pluginState.layerAdapter?.setSublayerOpacity(datasetId, sublayerId, opacity) + return + } + + if (datasetId) { + const dataset = pluginState.datasets?.find(d => d.id === datasetId) + if (!dataset) { + return + } + pluginState.dispatch({ type: 'SET_OPACITY', payload: { datasetId, opacity } }) + pluginState.layerAdapter?.setOpacity(datasetId, opacity) + return + } + + // Global + pluginState.dispatch({ type: 'SET_GLOBAL_OPACITY', payload: { opacity } }) + pluginState.datasets?.forEach(d => { + pluginState.layerAdapter?.setOpacity(d.id, opacity) + }) +} diff --git a/plugins/beta/datasets/src/api/setStyle.js b/plugins/beta/datasets/src/api/setStyle.js index 1446a96c..26c9473d 100644 --- a/plugins/beta/datasets/src/api/setStyle.js +++ b/plugins/beta/datasets/src/api/setStyle.js @@ -1,7 +1,22 @@ -export const setStyle = ({ pluginState, mapState }, { datasetId, ...styleChanges }) => { +export const setStyle = ({ pluginState, mapState }, style, { datasetId, sublayerId } = {}) => { const dataset = pluginState.datasets?.find(d => d.id === datasetId) - if (!dataset) return + if (!dataset) { + return + } - // TODO: dispatch state update for style changes - pluginState.layerAdapter?.setStyle(dataset, mapState.mapStyle.id, styleChanges) + if (sublayerId) { + pluginState.dispatch({ type: 'SET_SUBLAYER_STYLE', payload: { datasetId, sublayerId, styleChanges: style } }) + const updatedSublayerDataset = { + ...dataset, + sublayers: dataset.sublayers?.map(sublayer => + sublayer.id === sublayerId ? { ...sublayer, style: { ...sublayer.style, ...style } } : sublayer + ) + } + pluginState.layerAdapter?.setSublayerStyle(updatedSublayerDataset, sublayerId, mapState.mapStyle.id) + return + } + + pluginState.dispatch({ type: 'SET_DATASET_STYLE', payload: { datasetId, styleChanges: style } }) + const updatedDataset = { ...dataset, ...style } + pluginState.layerAdapter?.setStyle(updatedDataset, mapState.mapStyle.id) } diff --git a/plugins/beta/datasets/src/api/showDataset.js b/plugins/beta/datasets/src/api/showDataset.js deleted file mode 100644 index abafeb37..00000000 --- a/plugins/beta/datasets/src/api/showDataset.js +++ /dev/null @@ -1,4 +0,0 @@ -export const showDataset = ({ pluginState }, datasetId) => { - pluginState.layerAdapter?.showDataset(datasetId) - pluginState.dispatch({ type: 'SET_DATASET_VISIBILITY', payload: { id: datasetId, visibility: 'visible' } }) -} diff --git a/plugins/beta/datasets/src/api/showFeatures.js b/plugins/beta/datasets/src/api/showFeatures.js deleted file mode 100644 index 1868c047..00000000 --- a/plugins/beta/datasets/src/api/showFeatures.js +++ /dev/null @@ -1,17 +0,0 @@ -export const showFeatures = ({ pluginState }, { featureIds, idProperty, datasetId }) => { - const existingHidden = pluginState.hiddenFeatures[datasetId] - if (!existingHidden) return - - const dataset = pluginState.datasets?.find(d => d.id === datasetId) - if (!dataset) return - - const remainingHiddenIds = existingHidden.ids.filter(id => !featureIds.includes(id)) - - // Update state - pluginState.dispatch({ - type: 'SHOW_FEATURES', - payload: { layerId: datasetId, featureIds } - }) - - pluginState.layerAdapter?.showFeatures(dataset, idProperty, remainingHiddenIds) -} diff --git a/plugins/beta/datasets/src/datasets.js b/plugins/beta/datasets/src/datasets.js index 8833065a..f14d3879 100644 --- a/plugins/beta/datasets/src/datasets.js +++ b/plugins/beta/datasets/src/datasets.js @@ -1,4 +1,6 @@ import { createDynamicSource } from './fetch/createDynamicSource.js' +// NOSONAR: applyDatasetDefaults and datasetDefaults are used in processedDatasets.map +import { applyDatasetDefaults, datasetDefaults } from './defaults.js' const isDynamicSource = (dataset) => typeof dataset.geojson === 'string' && @@ -22,9 +24,12 @@ export const createDatasets = ({ const getHiddenFeatures = () => pluginStateRef.current.hiddenFeatures || {} // Initialise all datasets via the adapter, then set up dynamic sources - adapter.init(datasets, mapStyleId).then(() => { - datasets.forEach(dataset => { - if (!isDynamicSource(dataset)) return + const processedDatasets = datasets.map(d => applyDatasetDefaults(d, datasetDefaults)) + adapter.init(processedDatasets, mapStyleId).then(() => { + processedDatasets.forEach(dataset => { + if (!isDynamicSource(dataset)) { + return + } const dynamicSource = createDynamicSource({ dataset, diff --git a/plugins/beta/datasets/src/defaults.js b/plugins/beta/datasets/src/defaults.js index bee0c428..7cfc436a 100644 --- a/plugins/beta/datasets/src/defaults.js +++ b/plugins/beta/datasets/src/defaults.js @@ -1,15 +1,49 @@ const datasetDefaults = { - stroke: '#d4351c', - strokeWidth: 2, - fill: 'transparent', - symbolDescription: 'red outline', minZoom: 6, maxZoom: 24, showInKey: false, toggleVisibility: false, - visibility: 'visible' + visibility: 'visible', + style: { + stroke: '#d4351c', + strokeWidth: 2, + fill: 'transparent', + symbolDescription: 'red outline' + } } -export { - datasetDefaults +// All properties considered style properties — must be provided via dataset.style, not at the top level. +const STYLE_PROPS = [ + 'stroke', 'strokeWidth', 'strokeDashArray', + 'fill', 'fillPattern', 'fillPatternSvgContent', 'fillPatternForegroundColor', 'fillPatternBackgroundColor', + 'opacity', 'symbolDescription', 'keySymbolShape' +] + +// Props whose presence in a style object indicates a custom visual style. +// When any are set, the default symbolDescription is not appropriate. +const VISUAL_STYLE_PROPS = ['stroke', 'fill', 'fillPattern', 'fillPatternSvgContent'] + +const hasCustomVisualStyle = (style) => + VISUAL_STYLE_PROPS.some(prop => prop in style) + +/** + * Merge a dataset config with defaults, flattening the nested `style` object. + * Style properties must be provided via dataset.style — top-level occurrences are ignored. + * symbolDescription from defaults.style is dropped when custom visual styles + * are present and the dataset doesn't explicitly set its own symbolDescription. + */ +const applyDatasetDefaults = (dataset, defaults) => { + const style = dataset.style || {} + const mergedStyle = { ...defaults.style, ...style } + if (!('symbolDescription' in style) && hasCustomVisualStyle(style)) { + delete mergedStyle.symbolDescription + } + const topLevel = { ...dataset } + delete topLevel.style + STYLE_PROPS.forEach(prop => delete topLevel[prop]) + const topLevelDefaults = { ...defaults } + delete topLevelDefaults.style + return { ...topLevelDefaults, ...topLevel, ...mergedStyle } } + +export { datasetDefaults, hasCustomVisualStyle, applyDatasetDefaults } diff --git a/plugins/beta/datasets/src/fetch/createDynamicSource.js b/plugins/beta/datasets/src/fetch/createDynamicSource.js index 707a35ad..63d1417e 100644 --- a/plugins/beta/datasets/src/fetch/createDynamicSource.js +++ b/plugins/beta/datasets/src/fetch/createDynamicSource.js @@ -94,15 +94,21 @@ export const createDynamicSource = ({ dataset, map, onUpdate }) => { */ const fetchData = async () => { const zoom = map.getZoom() - if (zoom < minZoom) return + if (zoom < minZoom) { + return + } const currentBbox = getBboxArray(map) // Skip if current viewport is already covered - if (fetchedBbox && bboxContains(fetchedBbox, currentBbox)) return + if (fetchedBbox && bboxContains(fetchedBbox, currentBbox)) { + return + } // Abort any in-flight request — new viewport takes priority - if (currentController) currentController.abort() + if (currentController) { + currentController.abort() + } currentController = new AbortController() try { @@ -140,7 +146,9 @@ export const createDynamicSource = ({ dataset, map, onUpdate }) => { // Update map source onUpdate(dataset.id, toFeatureCollection()) } catch (error) { - if (error.name === 'AbortError') return + if (error.name === 'AbortError') { + return + } console.error(`Failed to fetch dynamic GeoJSON for ${dataset.id}:`, error) } } @@ -165,7 +173,9 @@ export const createDynamicSource = ({ dataset, map, onUpdate }) => { destroy () { map.off('moveend', handleMoveEnd) debouncedFetch.cancel() - if (currentController) currentController.abort() + if (currentController) { + currentController.abort() + } }, /** diff --git a/plugins/beta/datasets/src/fillPatterns.js b/plugins/beta/datasets/src/fillPatterns.js deleted file mode 100644 index 42104565..00000000 --- a/plugins/beta/datasets/src/fillPatterns.js +++ /dev/null @@ -1,9 +0,0 @@ -// Re-exports from styles/patterns.js for backward compatibility. -// Map-registration logic (registerPatternImages) now lives in the layer adapter. -export { - hasPattern, - getPatternInnerContent, - getPatternImageId, - getKeyPatternPaths, - rasterisePattern -} from './styles/patterns.js' diff --git a/plugins/beta/datasets/src/manifest.js b/plugins/beta/datasets/src/manifest.js index 9f4d350f..baad1932 100755 --- a/plugins/beta/datasets/src/manifest.js +++ b/plugins/beta/datasets/src/manifest.js @@ -3,13 +3,14 @@ import { initialState, actions } from './reducer.js' import { DatasetsInit } from './DatasetsInit.jsx' import { Layers } from './panels/Layers.jsx' import { Key } from './panels/Key.jsx' -import { showDataset } from './api/showDataset.js' -import { hideDataset } from './api/hideDataset.js' import { addDataset } from './api/addDataset.js' import { removeDataset } from './api/removeDataset.js' -import { showFeatures } from './api/showFeatures.js' -import { hideFeatures } from './api/hideFeatures.js' +import { setDatasetVisibility } from './api/setDatasetVisibility.js' +import { setFeatureVisibility } from './api/setFeatureVisibility.js' import { setStyle } from './api/setStyle.js' +import { getStyle } from './api/getStyle.js' +import { setOpacity } from './api/setOpacity.js' +import { getOpacity } from './api/getOpacity.js' import { setData } from './api/setData.js' export const manifest = { @@ -32,14 +33,14 @@ export const manifest = { slot: 'left-top', dismissible: true, exclusive: true, - width: '300px' + width: '260px' }, desktop: { slot: 'left-top', modal: false, dismissible: true, exclusive: true, - width: '320px' + width: '280px' }, render: Layers }, { @@ -51,11 +52,11 @@ export const manifest = { }, tablet: { slot: 'left-top', - width: '300px' + width: '260px' }, desktop: { slot: 'left-top', - width: '320px' + width: '280px' }, render: Key }], @@ -65,7 +66,9 @@ export const manifest = { label: 'Layers', panelId: 'datasetsLayers', iconId: 'layers', - excludeWhen: ({ pluginConfig }) => !pluginConfig.datasets.find(l => l.toggleVisibility), + excludeWhen: ({ pluginConfig }) => !pluginConfig.datasets.some(l => + l.toggleVisibility || l.sublayers?.some(r => r.toggleVisibility) + ), mobile: { slot: 'top-left', showLabel: true @@ -83,7 +86,7 @@ export const manifest = { label: 'Key', panelId: 'datasetsKey', iconId: 'key', - excludeWhen: ({ pluginConfig }) => !pluginConfig.datasets.find(l => l.showInKey), + excludeWhen: ({ pluginConfig }) => !pluginConfig.datasets.some(l => l.showInKey), mobile: { slot: 'top-left', showLabel: false @@ -107,13 +110,14 @@ export const manifest = { }], api: { - showDataset, - hideDataset, addDataset, removeDataset, - showFeatures, - hideFeatures, + setDatasetVisibility, + setFeatureVisibility, setStyle, + getStyle, + setOpacity, + getOpacity, setData } } diff --git a/plugins/beta/datasets/src/panels/Key.jsx b/plugins/beta/datasets/src/panels/Key.jsx index 80fa15f5..0fa6ca98 100755 --- a/plugins/beta/datasets/src/panels/Key.jsx +++ b/plugins/beta/datasets/src/panels/Key.jsx @@ -1,55 +1,85 @@ import React from 'react' import { getValueForStyle } from '../../../../../src/utils/getValueForStyle' -import { hasPattern, getKeyPatternPaths } from '../fillPatterns.js' +import { hasPattern, getKeyPatternPaths } from '../styles/patterns.js' +import { mergeSublayer } from '../utils/mergeSublayer.js' + +const SVG_SIZE = 20 +const SVG_CENTER = SVG_SIZE / 2 +const PATTERN_INSET = 2 + +const buildKeyGroups = (datasets) => { + const seenGroups = new Set() + const items = [] + datasets.forEach(dataset => { + if (dataset.sublayers?.length) { + items.push({ type: 'sublayers', dataset }) + return + } + if (dataset.groupLabel) { + if (seenGroups.has(dataset.groupLabel)) { + return + } + seenGroups.add(dataset.groupLabel) + items.push({ + type: 'group', + groupLabel: dataset.groupLabel, + datasets: datasets.filter(d => !d.sublayers?.length && d.groupLabel === dataset.groupLabel) + }) + return + } + items.push({ type: 'flat', dataset }) + }) + return items +} export const Key = ({ mapState, pluginState }) => { const { mapStyle } = mapState - const itemSymbol = (dataset) => { + const itemSymbol = (config) => { const svgProps = { xmlns: 'http://www.w3.org/2000/svg', - width: '20', - height: '20', - viewBox: '0 0 20 20', + width: SVG_SIZE, + height: SVG_SIZE, + viewBox: `0 0 ${SVG_SIZE} ${SVG_SIZE}`, 'aria-hidden': 'true', focusable: 'false' } - if (hasPattern(dataset)) { - const paths = getKeyPatternPaths(dataset, mapStyle.id) + if (hasPattern(config)) { + const paths = getKeyPatternPaths(config, mapStyle.id) return ( - + ) } return ( - {dataset.keySymbolShape === 'line' + {config.keySymbolShape === 'line' ? ( ) : ( )} @@ -57,21 +87,54 @@ export const Key = ({ mapState, pluginState }) => { ) } + const renderEntry = (key, config) => ( +
+
{itemSymbol(config)}
+
+ {config.label} + {config.symbolDescription && ( + + ({getValueForStyle(config.symbolDescription, mapStyle.id)}) + + )} +
+
+ ) + + const visibleDatasets = (pluginState.datasets || []) + .filter(dataset => dataset.showInKey && dataset.visibility !== 'hidden') + + const keyGroups = buildKeyGroups(visibleDatasets) + const hasGroups = keyGroups.some(item => item.type === 'sublayers' || item.type === 'group') + const containerClass = `im-c-datasets-key${hasGroups ? ' im-c-datasets-key--has-groups' : ''}` + return ( -
- {(pluginState.datasets || []).filter(dataset => dataset.showInKey && dataset.visibility !== 'hidden').map(dataset => ( -
-
- {itemSymbol(dataset)} - {dataset.label} - {dataset.symbolDescription && ( - - ({getValueForStyle(dataset.symbolDescription, mapStyle.id)}) - - )} -
-
- ))} +
+ {keyGroups.map(item => { + if (item.type === 'sublayers') { + const headingId = `key-heading-${item.dataset.id}` + return ( +
+

{item.dataset.label}

+ {item.dataset.sublayers + .filter(sublayer => item.dataset.sublayerVisibility?.[sublayer.id] !== 'hidden') + .map(sublayer => renderEntry(`${item.dataset.id}-${sublayer.id}`, mergeSublayer(item.dataset, sublayer)))} +
+ ) + } + + if (item.type === 'group') { + const headingId = `key-heading-${item.groupLabel.toLowerCase().replaceAll(/\s+/g, '-')}` + return ( +
+

{item.groupLabel}

+ {item.datasets.map(dataset => renderEntry(dataset.id, dataset))} +
+ ) + } + + return renderEntry(item.dataset.id, item.dataset) + })}
) } diff --git a/plugins/beta/datasets/src/panels/Key.module.scss b/plugins/beta/datasets/src/panels/Key.module.scss index 9f5f7798..aa2a2185 100644 --- a/plugins/beta/datasets/src/panels/Key.module.scss +++ b/plugins/beta/datasets/src/panels/Key.module.scss @@ -1,23 +1,58 @@ -.im-c-datasets-key { +// When groups are present, every direct child gets a border-top +.im-c-datasets-key--has-groups > * { + border-top: 1px solid var(--button-hover-color); +} + +// When no groups, only the first child gets a border-top +.im-c-datasets-key:not(.im-c-datasets-key--has-groups) > *:first-child { + border-top: 1px solid var(--button-hover-color); +} +.im-c-datasets-key__group:not(:last-child) { + padding-bottom: 5px; +} + +.im-c-datasets-key__group-heading { + padding-top: 15px; + padding-bottom: 10px; + margin: 0; + font-size: 1rem; + font-weight: bold; + color: var(--foreground-color); } -.im-c-datasets-key__item-label { +.im-c-datasets-key__item { display: flex; align-items: start; - padding-top: 12px; - padding-bottom: 12px; - align-self: auto; + padding-top: 10px; + padding-bottom: 10px; font-size: 1rem; - line-height: 1.2; + + // When mixed with groups, flat items match group heading spacing + .im-c-datasets-key--has-groups > & { + padding-top: 15px; + padding-bottom: 15px; + } + + // First item inside a group — heading already provides top spacing + .im-c-datasets-key__group &:first-child { + padding-top: 0; + } } -.im-c-datasets-key__item:last-child .im-c-datasets-key__item-label { - padding-bottom: 2px; +// No-groups: first item needs 15px below the single border +.im-c-datasets-key:not(.im-c-datasets-key--has-groups) > .im-c-datasets-key__item:first-child { + padding-top: 15px; } -.im-c-datasets-key__item-label svg { +// Last item in all scenarios — flat or inside the last group +.im-c-datasets-key > .im-c-datasets-key__item:last-child, +.im-c-datasets-key__group:last-child .im-c-datasets-key__item:last-child { + padding-bottom: 5px; +} + +.im-c-datasets-key__item svg { position: relative; flex-shrink: 0; - margin: 0px 13px 0 2px; -} \ No newline at end of file + margin: 0 13px 0 2px; +} diff --git a/plugins/beta/datasets/src/panels/Layers.jsx b/plugins/beta/datasets/src/panels/Layers.jsx index c4102421..6c3d4e66 100755 --- a/plugins/beta/datasets/src/panels/Layers.jsx +++ b/plugins/beta/datasets/src/panels/Layers.jsx @@ -1,38 +1,141 @@ import React from 'react' -import { showDataset } from '../api/showDataset' -import { hideDataset } from '../api/hideDataset' +import { setDatasetVisibility } from '../api/setDatasetVisibility' + +const CHECKBOX_LABEL_CLASS = 'im-c-datasets-layers__item-label govuk-label govuk-checkboxes__label' + +const hasToggleableSublayers = (dataset) => dataset.sublayers?.some(sublayer => sublayer.toggleVisibility) + +/** + * Collapse the filtered dataset list into ordered render items: + * { type: 'sublayers', dataset } — dataset with sublayers (takes precedence) + * { type: 'group', groupLabel, datasets } — datasets sharing a groupLabel + * { type: 'flat', dataset } — standalone dataset + */ +const buildRenderItems = (datasets) => { + const seenGroups = new Set() + const items = [] + datasets.forEach(dataset => { + if (hasToggleableSublayers(dataset)) { + items.push({ type: 'sublayers', dataset }) + return + } + if (dataset.groupLabel) { + if (seenGroups.has(dataset.groupLabel)) { + return + } + seenGroups.add(dataset.groupLabel) + items.push({ + type: 'group', + groupLabel: dataset.groupLabel, + datasets: datasets.filter(d => !hasToggleableSublayers(d) && d.groupLabel === dataset.groupLabel) + }) + return + } + items.push({ type: 'flat', dataset }) + }) + return items +} export const Layers = ({ pluginState }) => { - const handleChange = (e) => { + const handleDatasetChange = (e) => { const { value, checked } = e.target - if (checked) { - showDataset({ pluginState }, value) - } else { - hideDataset({ pluginState }, value) - } + setDatasetVisibility({ pluginState }, checked, { datasetId: value }) } - return ( -
-
-
- - Layers - -
- {(pluginState.datasets || []).filter(dataset => dataset.toggleVisibility).map(dataset => ( -
-
- - -
-
- ))} -
-
+ const handleSublayerChange = (e) => { + const { checked } = e.target + const datasetId = e.target.dataset.datasetId + const sublayerId = e.target.dataset.sublayerId + setDatasetVisibility({ pluginState }, checked, { datasetId, sublayerId }) + } + + const renderDatasetItem = (dataset) => { + const itemClass = `im-c-datasets-layers__item govuk-checkboxes govuk-checkboxes--small${dataset.visibility === 'hidden' ? '' : ' im-c-datasets-layers__item--checked'}` + return ( +
+
+ + +
+ ) + } + + const visibleDatasets = (pluginState.datasets || []) + .filter(dataset => dataset.toggleVisibility || hasToggleableSublayers(dataset)) + + const renderItems = buildRenderItems(visibleDatasets) + const hasGroups = renderItems.some(item => item.type === 'sublayers' || item.type === 'group') + const containerClass = `im-c-datasets-layers${hasGroups ? ' im-c-datasets-layers--has-groups' : ''}` + + return ( +
+ {renderItems.map(item => { + if (item.type === 'sublayers') { + const { dataset } = item + const anySublayerChecked = dataset.sublayers + .filter(sublayer => sublayer.toggleVisibility) + .some(sublayer => dataset.sublayerVisibility?.[sublayer.id] !== 'hidden') + const wrapperClass = `govuk-form-group im-c-datasets-layers-group${anySublayerChecked ? ' im-c-datasets-layers-group--items-checked' : ''}` + return ( +
+
+ {dataset.label} + {dataset.sublayers + .filter(sublayer => sublayer.toggleVisibility) + .map(sublayer => { + const sublayerVisible = dataset.sublayerVisibility?.[sublayer.id] !== 'hidden' + const inputId = `${dataset.id}-${sublayer.id}` + const itemClass = `im-c-datasets-layers__item govuk-checkboxes govuk-checkboxes--small${sublayerVisible ? ' im-c-datasets-layers__item--checked' : ''}` + return ( +
+
+ + +
+
+ ) + })} +
+
+ ) + } + + if (item.type === 'group') { + const anyDatasetChecked = item.datasets.some(d => d.visibility !== 'hidden') + const wrapperClass = `govuk-form-group im-c-datasets-layers-group${anyDatasetChecked ? ' im-c-datasets-layers-group--items-checked' : ''}` + return ( +
+
+ {item.groupLabel} + {item.datasets.map(dataset => renderDatasetItem(dataset))} +
+
+ ) + } + + return renderDatasetItem(item.dataset) + })}
) } diff --git a/plugins/beta/datasets/src/panels/Layers.module.scss b/plugins/beta/datasets/src/panels/Layers.module.scss index 9a48e776..71735204 100644 --- a/plugins/beta/datasets/src/panels/Layers.module.scss +++ b/plugins/beta/datasets/src/panels/Layers.module.scss @@ -1,8 +1,37 @@ +// When groups are present, every direct child (groups and flat items) gets a border-top +.im-c-datasets-layers--has-groups > * { + border-top: 1px solid var(--button-hover-color); +} + +// When no groups, only the first child gets a border-top with 15px padding +.im-c-datasets-layers:not(.im-c-datasets-layers--has-groups) > *:first-child { + border-top: 1px solid var(--button-hover-color); + padding-top: 10px; +} + +.im-c-datasets-layers > *:last-child { + margin-bottom: -5px; +} + +.im-c-datasets-layers-group:not(:last-child) { + padding-bottom: 5px; +} + .im-c-datasets-layers__item { - border: 1px solid var(--button-hover-color); - padding-left: 10px; - &:not(:last-child) { - margin-bottom: 5px; + padding-bottom: 5px; + + .im-c-datasets-layers--has-groups > & { + padding-top: 5px; + } + + // Items inside a group have no individual padding or spacing between them + .im-c-datasets-layers-group & { + padding-top: 0; + padding-bottom: 0; + + &:not(:last-child) { + margin-bottom: 0; + } } } @@ -19,10 +48,6 @@ color: var(--foreground-color); } -.im-c-datasets-layers__item--checked { - border-color: var(--app-border-color); -} - // GovUK style overide .im-c-datasets-layers__item .govuk-checkboxes__item { flex-wrap: nowrap; @@ -31,3 +56,20 @@ margin-left: -3px; } } + +.im-c-datasets-layers-group__fieldset { + // Reset browser fieldset defaults + border: none; + padding: 0; + margin: 0; + min-width: 0; +} + +.im-c-datasets-layers-group__legend { + padding-top: 15px; + padding-bottom: 10px; + padding-inline: 0; + font-size: 1rem; + font-weight: bold; + color: var(--foreground-color); +} diff --git a/plugins/beta/datasets/src/reducer.js b/plugins/beta/datasets/src/reducer.js index 8c3205f2..abe59162 100755 --- a/plugins/beta/datasets/src/reducer.js +++ b/plugins/beta/datasets/src/reducer.js @@ -1,17 +1,27 @@ +import { applyDatasetDefaults } from './defaults.js' + const initialState = { datasets: null, hiddenFeatures: {}, // { [layerId]: { idProperty: string, ids: string[] } } layerAdapter: null } +const initSublayerVisibility = (dataset) => { + if (!dataset.sublayers?.length) { + return dataset + } + const sublayerVisibility = {} + dataset.sublayers.forEach(sublayer => { + sublayerVisibility[sublayer.id] = 'visible' + }) + return { ...dataset, sublayerVisibility } +} + const setDatasets = (state, payload) => { const { datasets, datasetDefaults } = payload return { ...state, - datasets: datasets.map(dataset => ({ - ...datasetDefaults, - ...dataset - })) + datasets: datasets.map(dataset => initSublayerVisibility(applyDatasetDefaults(dataset, datasetDefaults))) } } @@ -21,7 +31,7 @@ const addDataset = (state, payload) => { ...state, datasets: [ ...(state.datasets || []), - { ...datasetDefaults, ...dataset } + initSublayerVisibility(applyDatasetDefaults(dataset, datasetDefaults)) ] } } @@ -44,6 +54,14 @@ const setDatasetVisibility = (state, payload) => { } } +const setGlobalVisibility = (state, payload) => { + const { visibility } = payload + return { + ...state, + datasets: state.datasets?.map(dataset => ({ ...dataset, visibility })) + } +} + const hideFeatures = (state, payload) => { const { layerId, idProperty, featureIds } = payload const existing = state.hiddenFeatures[layerId] @@ -62,12 +80,15 @@ const hideFeatures = (state, payload) => { const showFeatures = (state, payload) => { const { layerId, featureIds } = payload const existing = state.hiddenFeatures[layerId] - if (!existing) return state + if (!existing) { + return state + } const newIds = existing.ids.filter(id => !featureIds.includes(id)) if (newIds.length === 0) { - const { [layerId]: _, ...rest } = state.hiddenFeatures + const rest = { ...state.hiddenFeatures } + delete rest[layerId] return { ...state, hiddenFeatures: rest } } @@ -80,6 +101,93 @@ const showFeatures = (state, payload) => { } } +const setSublayerVisibility = (state, payload) => { + const { datasetId, sublayerId, visibility } = payload + return { + ...state, + datasets: state.datasets?.map(dataset => { + if (dataset.id !== datasetId) { + return dataset + } + return { + ...dataset, + sublayerVisibility: { + ...dataset.sublayerVisibility, + [sublayerId]: visibility + } + } + }) + } +} + +const setDatasetStyle = (state, payload) => { + const { datasetId, styleChanges } = payload + return { + ...state, + datasets: state.datasets?.map(dataset => + dataset.id === datasetId ? { ...dataset, ...styleChanges } : dataset + ) + } +} + +const setSublayerStyle = (state, payload) => { + const { datasetId, sublayerId, styleChanges } = payload + return { + ...state, + datasets: state.datasets?.map(dataset => { + if (dataset.id !== datasetId) { + return dataset + } + return { + ...dataset, + sublayers: dataset.sublayers?.map(sublayer => + sublayer.id === sublayerId + ? { ...sublayer, style: { ...sublayer.style, ...styleChanges } } + : sublayer + ) + } + }) + } +} + +const setOpacity = (state, payload) => { + const { datasetId, opacity } = payload + return { + ...state, + datasets: state.datasets?.map(dataset => + dataset.id === datasetId ? { ...dataset, opacity } : dataset + ) + } +} + +const setGlobalOpacity = (state, payload) => { + const { opacity } = payload + return { + ...state, + datasets: state.datasets?.map(dataset => ({ ...dataset, opacity })) + } +} + +const setSublayerOpacity = (state, payload) => { + const { datasetId, sublayerId, opacity } = payload + return { + ...state, + datasets: state.datasets?.map(dataset => { + if (dataset.id !== datasetId) { + return dataset + } + return { + ...dataset, + sublayers: dataset.sublayers?.map(sublayer => + sublayer.id === sublayerId + ? { ...sublayer, style: { ...sublayer.style, opacity } } + : sublayer + ) + } + }) + } +} + const setLayerAdapter = (state, payload) => ({ ...state, layerAdapter: payload }) const actions = { @@ -87,6 +195,13 @@ const actions = { ADD_DATASET: addDataset, REMOVE_DATASET: removeDataset, SET_DATASET_VISIBILITY: setDatasetVisibility, + SET_GLOBAL_VISIBILITY: setGlobalVisibility, + SET_SUBLAYER_VISIBILITY: setSublayerVisibility, + SET_DATASET_STYLE: setDatasetStyle, + SET_SUBLAYER_STYLE: setSublayerStyle, + SET_OPACITY: setOpacity, + SET_GLOBAL_OPACITY: setGlobalOpacity, + SET_SUBLAYER_OPACITY: setSublayerOpacity, HIDE_FEATURES: hideFeatures, SHOW_FEATURES: showFeatures, SET_LAYER_ADAPTER: setLayerAdapter diff --git a/plugins/beta/datasets/src/styles/patterns.js b/plugins/beta/datasets/src/styles/patterns.js index e262fd6b..7671c2b7 100644 --- a/plugins/beta/datasets/src/styles/patterns.js +++ b/plugins/beta/datasets/src/styles/patterns.js @@ -28,7 +28,7 @@ export const hashString = (str) => { hash = ((hash << 5) - hash) + ch.codePointAt(0) hash = hash & hash } - return Math.abs(hash).toString(36) + return Math.abs(hash).toString(36) // NOSONAR: base36 encoding for compact alphanumeric hash string } export const injectColors = (content, foreground, background) => @@ -52,7 +52,9 @@ export const hasPattern = (dataset) => !!(dataset.fillPattern || dataset.fillPat * @returns {string|null} */ export const getPatternInnerContent = (dataset) => { - if (dataset.fillPatternSvgContent) return dataset.fillPatternSvgContent + if (dataset.fillPatternSvgContent) { + return dataset.fillPatternSvgContent + } if (dataset.fillPattern && BUILT_IN_PATTERNS[dataset.fillPattern]) { return BUILT_IN_PATTERNS[dataset.fillPattern] } @@ -67,7 +69,9 @@ export const getPatternInnerContent = (dataset) => { */ export const getPatternImageId = (dataset, mapStyleId) => { const innerContent = getPatternInnerContent(dataset) - if (!innerContent) return null + if (!innerContent) { + return null + } const fg = getValueForStyle(dataset.fillPatternForegroundColor, mapStyleId) || 'black' const bg = getValueForStyle(dataset.fillPatternBackgroundColor, mapStyleId) || 'transparent' return `pattern-${hashString(innerContent + fg + bg)}` @@ -82,7 +86,9 @@ export const getPatternImageId = (dataset, mapStyleId) => { */ export const getKeyPatternPaths = (dataset, mapStyleId) => { const innerContent = getPatternInnerContent(dataset) - if (!innerContent) return null + if (!innerContent) { + return null + } const fg = getValueForStyle(dataset.fillPatternForegroundColor, mapStyleId) || 'black' const bg = getValueForStyle(dataset.fillPatternBackgroundColor, mapStyleId) || 'transparent' const borderStroke = getValueForStyle(dataset.stroke, mapStyleId) || fg @@ -129,7 +135,9 @@ const rasteriseToImageData = (svgString, width, height) => */ export const rasterisePattern = async (dataset, mapStyleId) => { const innerContent = getPatternInnerContent(dataset) - if (!innerContent) return null + if (!innerContent) { + return null + } const fg = getValueForStyle(dataset.fillPatternForegroundColor, mapStyleId) || 'black' const bg = getValueForStyle(dataset.fillPatternBackgroundColor, mapStyleId) || 'transparent' diff --git a/plugins/beta/datasets/src/utils/bbox.js b/plugins/beta/datasets/src/utils/bbox.js index f6cd47d1..bcb0d55e 100755 --- a/plugins/beta/datasets/src/utils/bbox.js +++ b/plugins/beta/datasets/src/utils/bbox.js @@ -22,7 +22,7 @@ export const bboxContains = (outer, inner) => { inner[0] >= outer[0] && // west inner[1] >= outer[1] && // south inner[2] <= outer[2] && // east - inner[3] <= outer[3] // north + inner[3] <= outer[3] // NOSONAR, north ) } @@ -40,7 +40,7 @@ export const expandBbox = (existing, addition) => { Math.min(existing[0], addition[0]), // west Math.min(existing[1], addition[1]), // south Math.max(existing[2], addition[2]), // east - Math.max(existing[3], addition[3]) // north + Math.max(existing[3], addition[3]) // NOSONAR, north ] } @@ -57,8 +57,8 @@ export const bboxIntersects = (a, b) => { return !( a[2] < b[0] || // a is left of b a[0] > b[2] || // a is right of b - a[3] < b[1] || // a is below b - a[1] > b[3] // a is above b + a[3] < b[1] || // NOSONAR a is below b + a[1] > b[3] // NOSONAR a is above b ) } @@ -98,7 +98,7 @@ export const getGeometryBbox = (geometry) => { processCoords(geometry.coordinates, 2) break case 'MultiPolygon': - processCoords(geometry.coordinates, 3) + processCoords(geometry.coordinates, 3) // NOSONAR: 3 = coordinate nesting depth for MultiPolygon ([polygons][rings][points]) break case 'GeometryCollection': geometry.geometries.forEach(g => { @@ -109,6 +109,8 @@ export const getGeometryBbox = (geometry) => { maxY = Math.max(maxY, b[3]) }) break + default: + throw new Error(`Unsupported geometry type: ${geometry.type}`) } return [minX, minY, maxX, maxY] diff --git a/plugins/beta/datasets/src/utils/filters.js b/plugins/beta/datasets/src/utils/filters.js index fc48be3b..b4ce35a5 100755 --- a/plugins/beta/datasets/src/utils/filters.js +++ b/plugins/beta/datasets/src/utils/filters.js @@ -10,7 +10,8 @@ export const buildExclusionFilter = (originalFilter, idProperty, excludeIds) => // Coerce both sides to strings to handle number/string type mismatches // When no idProperty, use feature-level id via ['id'] (GeoJSON feature.id) const idExpr = idProperty ? ['to-string', ['get', idProperty]] : ['to-string', ['id']] - const stringIds = excludeIds.map(id => String(id)) + // Convert all IDs to strings; map passes each element as the first argument to String + const stringIds = excludeIds.map(String) const exclusionFilter = ['!', ['in', idExpr, ['literal', stringIds]]] if (!originalFilter) { diff --git a/plugins/beta/datasets/src/utils/mergeSublayer.js b/plugins/beta/datasets/src/utils/mergeSublayer.js new file mode 100644 index 00000000..b6e68242 --- /dev/null +++ b/plugins/beta/datasets/src/utils/mergeSublayer.js @@ -0,0 +1,78 @@ +import { hasCustomVisualStyle } from '../defaults.js' + +const getFillProps = (dataset, sublayerStyle) => { + if (sublayerStyle.fillPattern || sublayerStyle.fillPatternSvgContent) { + return { + fillPattern: sublayerStyle.fillPattern, + fillPatternSvgContent: sublayerStyle.fillPatternSvgContent, + fillPatternForegroundColor: sublayerStyle.fillPatternForegroundColor ?? dataset.fillPatternForegroundColor, + fillPatternBackgroundColor: sublayerStyle.fillPatternBackgroundColor ?? dataset.fillPatternBackgroundColor + } + } + if ('fill' in sublayerStyle) { + // Sublayer explicitly sets a plain fill — do not inherit any parent pattern + return { fill: sublayerStyle.fill } + } + return { + fill: dataset.fill, + fillPattern: dataset.fillPattern, + fillPatternSvgContent: dataset.fillPatternSvgContent, + fillPatternForegroundColor: dataset.fillPatternForegroundColor, + fillPatternBackgroundColor: dataset.fillPatternBackgroundColor + } +} + +const getCombinedFilter = (datasetFilter, sublayerFilter) => { + if (datasetFilter && sublayerFilter) { + return ['all', datasetFilter, sublayerFilter] + } + return sublayerFilter || datasetFilter || null +} + +const getSymbolDescription = (dataset, sublayerStyle) => { + if ('symbolDescription' in sublayerStyle) { + return sublayerStyle.symbolDescription + } + if (hasCustomVisualStyle(sublayerStyle)) { + return undefined + } + return dataset.symbolDescription +} + +/** + * Merge a sublayer with its parent dataset, producing a flat style + * object suitable for layer creation and key symbol rendering. + * + * The sublayer's nested `style` object is flattened before merging. + * + * Fill precedence (highest to lowest): + * 1. Sublayer's own fillPattern + * 2. Sublayer's own fill (explicit, even if transparent — clears any parent pattern) + * 3. Parent's fillPattern + * 4. Parent's fill + * + * symbolDescription is only inherited from the parent when the sublayer has no + * custom visual styles of its own. If the sublayer overrides stroke/fill/pattern + * without setting symbolDescription explicitly, no description is shown. + */ +export const mergeSublayer = (dataset, sublayer) => { + const sublayerStyle = sublayer.style || {} + const combinedFilter = getCombinedFilter(dataset.filter, sublayer.filter) + + return { + id: sublayer.id, + label: sublayer.label, + stroke: sublayerStyle.stroke ?? dataset.stroke, + strokeWidth: sublayerStyle.strokeWidth ?? dataset.strokeWidth, + strokeDashArray: sublayerStyle.strokeDashArray ?? dataset.strokeDashArray, + opacity: sublayerStyle.opacity ?? dataset.opacity, + keySymbolShape: sublayerStyle.keySymbolShape ?? dataset.keySymbolShape, + symbolDescription: getSymbolDescription(dataset, sublayerStyle), + showInKey: sublayer.showInKey ?? dataset.showInKey, + toggleVisibility: sublayer.toggleVisibility ?? false, + filter: combinedFilter, + minZoom: dataset.minZoom, + maxZoom: dataset.maxZoom, + ...getFillProps(dataset, sublayerStyle) + } +} diff --git a/providers/maplibre/src/utils/highlightFeatures.js b/providers/maplibre/src/utils/highlightFeatures.js index 6b56c815..411b8e0b 100755 --- a/providers/maplibre/src/utils/highlightFeatures.js +++ b/providers/maplibre/src/utils/highlightFeatures.js @@ -60,6 +60,7 @@ const applyHighlightLayer = (map, id, type, sourceId, srcLayer, paint, filter) = map.setPaintProperty(id, prop, value) }) map.setFilter(id, filter) + map.moveLayer(id) } const calculateBounds = (LngLatBounds, renderedFeatures) => { diff --git a/providers/maplibre/src/utils/highlightFeatures.test.js b/providers/maplibre/src/utils/highlightFeatures.test.js index 6bc21e90..162eac46 100644 --- a/providers/maplibre/src/utils/highlightFeatures.test.js +++ b/providers/maplibre/src/utils/highlightFeatures.test.js @@ -16,6 +16,7 @@ describe('Highlighting Utils', () => { _highlightedSources: new Set(['stale']), getLayer: jest.fn(), addLayer: jest.fn(), + moveLayer: jest.fn(), setFilter: jest.fn(), setPaintProperty: jest.fn(), queryRenderedFeatures: jest.fn() diff --git a/src/App/registry/pluginRegistry.js b/src/App/registry/pluginRegistry.js index 64b46dfb..a5fbc24e 100755 --- a/src/App/registry/pluginRegistry.js +++ b/src/App/registry/pluginRegistry.js @@ -2,7 +2,7 @@ import { registerIcon } from './iconRegistry.js' import { registerKeyboardShortcut } from './keyboardShortcutRegistry.js' import { allowedSlots } from '../renderer/slots.js' -import { logger } from '../../utils/logger.js' +import { logger } from '../../services/logger.js' const asArray = (value) => Array.isArray(value) ? value : [value] diff --git a/src/App/renderer/mapButtons.js b/src/App/renderer/mapButtons.js index d228a2b0..41898b4b 100755 --- a/src/App/renderer/mapButtons.js +++ b/src/App/renderer/mapButtons.js @@ -1,7 +1,7 @@ // src/core/renderers/mapButtons.js import { MapButton } from '../components/MapButton/MapButton.jsx' import { allowedSlots } from './slots.js' -import { logger } from '../../utils/logger.js' +import { logger } from '../../services/logger.js' function getMatchingButtons ({ appState, buttonConfig, slot, evaluateProp }) { const { breakpoint, mode } = appState diff --git a/src/App/store/ServiceProvider.jsx b/src/App/store/ServiceProvider.jsx index 254d2367..2fdf6f87 100755 --- a/src/App/store/ServiceProvider.jsx +++ b/src/App/store/ServiceProvider.jsx @@ -5,6 +5,7 @@ import { createAnnouncer } from '../../services/announcer.js' import { reverseGeocode } from '../../services/reverseGeocode.js' import { useConfig } from '../store/configContext.js' import { closeApp } from '../../services/closeApp.js' +import { logger } from '../../services/logger.js' export const ServiceContext = createContext(null) @@ -19,7 +20,8 @@ export const ServiceProvider = ({ eventBus, children }) => { events: EVENTS, eventBus, mapStatusRef, - closeApp: () => closeApp(id, handleExitClick, eventBus) + closeApp: () => closeApp(id, handleExitClick, eventBus), + logger }), [announce]) return ( diff --git a/src/App/store/appDispatchMiddleware.js b/src/App/store/appDispatchMiddleware.js index fcfbb718..2c88ab59 100644 --- a/src/App/store/appDispatchMiddleware.js +++ b/src/App/store/appDispatchMiddleware.js @@ -3,7 +3,7 @@ import { EVENTS as events } from '../../config/events.js' import { defaultPanelConfig, defaultButtonConfig, defaultControlConfig } from '../../config/appConfig.js' import { deepMerge } from '../../utils/deepMerge.js' import { allowedSlots } from '../renderer/slots.js' -import { logger } from '../../utils/logger.js' +import { logger } from '../../services/logger.js' const BREAKPOINTS = ['mobile', 'tablet', 'desktop'] diff --git a/src/utils/logger.js b/src/services/logger.js similarity index 100% rename from src/utils/logger.js rename to src/services/logger.js diff --git a/src/utils/logger.test.js b/src/services/logger.test.js similarity index 100% rename from src/utils/logger.test.js rename to src/services/logger.test.js