@defra/interactive-map 0.0.16-alpha → 0.0.17-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/assets/images/slot-map.svg +264 -0
  2. package/dist/css/index.css +1 -1
  3. package/dist/esm/im-core.js +1 -1
  4. package/dist/esm/im-shell.js +1 -1
  5. package/dist/umd/im-core.js +1 -1
  6. package/dist/umd/index.js +1 -1
  7. package/docs/api/slots.md +16 -15
  8. package/docs/api.md +3 -3
  9. package/docs/getting-started.md +4 -1
  10. package/docs/plugins/datasets.md +561 -0
  11. package/docs/plugins.md +1 -1
  12. package/package.json +2 -2
  13. package/plugins/beta/datasets/dist/css/index.css +85 -15
  14. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  15. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  16. package/plugins/beta/datasets/dist/umd/index.js +1 -1
  17. package/plugins/beta/datasets/src/DatasetsInit.jsx +23 -8
  18. package/plugins/beta/datasets/src/adapters/maplibre/index.js +18 -0
  19. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +113 -0
  20. package/plugins/beta/datasets/src/adapters/maplibre/layerIds.js +69 -0
  21. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +338 -0
  22. package/plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js +48 -0
  23. package/plugins/beta/datasets/src/api/addDataset.js +2 -8
  24. package/plugins/beta/datasets/src/api/getOpacity.js +17 -0
  25. package/plugins/beta/datasets/src/api/getStyle.js +13 -0
  26. package/plugins/beta/datasets/src/api/removeDataset.js +2 -44
  27. package/plugins/beta/datasets/src/api/setData.js +8 -0
  28. package/plugins/beta/datasets/src/api/setDatasetVisibility.js +37 -0
  29. package/plugins/beta/datasets/src/api/setFeatureVisibility.js +22 -0
  30. package/plugins/beta/datasets/src/api/setOpacity.js +29 -0
  31. package/plugins/beta/datasets/src/api/setStyle.js +22 -0
  32. package/plugins/beta/datasets/src/datasets.js +29 -55
  33. package/plugins/beta/datasets/src/defaults.js +42 -8
  34. package/plugins/beta/datasets/src/fetch/createDynamicSource.js +34 -25
  35. package/plugins/beta/datasets/src/fetch/fetchGeoJSON.js +2 -2
  36. package/plugins/beta/datasets/src/manifest.js +24 -16
  37. package/plugins/beta/datasets/src/panels/Key.jsx +128 -50
  38. package/plugins/beta/datasets/src/panels/Key.module.scss +48 -9
  39. package/plugins/beta/datasets/src/panels/Layers.jsx +132 -29
  40. package/plugins/beta/datasets/src/panels/Layers.module.scss +50 -8
  41. package/plugins/beta/datasets/src/reducer.js +128 -9
  42. package/plugins/beta/datasets/src/styles/patterns.js +157 -0
  43. package/plugins/beta/datasets/src/utils/bbox.js +7 -5
  44. package/plugins/beta/datasets/src/utils/filters.js +5 -2
  45. package/plugins/beta/datasets/src/utils/mergeSublayer.js +78 -0
  46. package/plugins/beta/draw-ml/dist/css/index.css +1 -1
  47. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  48. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  49. package/plugins/beta/draw-ml/src/draw.scss +0 -7
  50. package/plugins/beta/draw-ml/src/manifest.js +16 -16
  51. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
  52. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
  53. package/plugins/beta/frame/src/Frame.jsx +5 -5
  54. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  55. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  56. package/plugins/beta/map-styles/src/manifest.js +1 -1
  57. package/plugins/beta/scale-bar/dist/css/index.css +1 -1
  58. package/plugins/beta/scale-bar/dist/esm/im-scale-bar-plugin.js +1 -1
  59. package/plugins/beta/scale-bar/dist/umd/im-scale-bar-plugin.js +1 -1
  60. package/plugins/beta/scale-bar/src/index.test.js +3 -3
  61. package/plugins/beta/scale-bar/src/manifest.js +3 -3
  62. package/plugins/beta/scale-bar/src/scaleBar.scss +2 -1
  63. package/plugins/interact/dist/css/index.css +1 -1
  64. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  65. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  66. package/plugins/interact/src/interact.scss +0 -7
  67. package/plugins/interact/src/manifest.js +14 -18
  68. package/plugins/interact/src/manifest.test.js +3 -1
  69. package/plugins/search/dist/css/index.css +1 -1
  70. package/plugins/search/src/components/Form/Form.module.scss +2 -1
  71. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  72. package/providers/maplibre/dist/umd/im-maplibre-framework.js +1 -1
  73. package/providers/maplibre/dist/umd/im-maplibre-framework.js.LICENSE.txt +1 -1
  74. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  75. package/providers/maplibre/src/utils/highlightFeatures.js +1 -0
  76. package/providers/maplibre/src/utils/highlightFeatures.test.js +1 -0
  77. package/src/App/components/Actions/Actions.jsx +2 -2
  78. package/src/App/components/Actions/Actions.module.scss +0 -7
  79. package/src/App/components/Actions/Actions.test.jsx +1 -1
  80. package/src/App/components/Icon/Icon.jsx +3 -2
  81. package/src/App/components/Icon/Icon.module.scss +4 -0
  82. package/src/App/components/Icon/Icon.test.jsx +43 -4
  83. package/src/App/components/MapButton/MapButton.jsx +42 -17
  84. package/src/App/components/MapButton/MapButton.module.scss +4 -13
  85. package/src/App/components/MapButton/MapButton.test.jsx +27 -3
  86. package/src/App/components/PopupMenu/PopupMenu.jsx +51 -274
  87. package/src/App/components/PopupMenu/PopupMenu.module.scss +14 -7
  88. package/src/App/components/PopupMenu/PopupMenu.test.jsx +70 -1
  89. package/src/App/components/PopupMenu/usePopupMenu.js +258 -0
  90. package/src/App/hooks/useButtonStateEvaluator.js +12 -2
  91. package/src/App/hooks/useButtonStateEvaluator.test.js +38 -4
  92. package/src/App/hooks/useInterfaceAPI.js +6 -0
  93. package/src/App/hooks/useLayoutMeasurements.js +84 -18
  94. package/src/App/hooks/useLayoutMeasurements.test.js +124 -17
  95. package/src/App/layout/Layout.jsx +12 -7
  96. package/src/App/layout/Layout.test.jsx +2 -2
  97. package/src/App/layout/layout.module.scss +67 -29
  98. package/src/App/registry/pluginRegistry.js +1 -1
  99. package/src/App/renderer/HtmlElementHost.jsx +2 -1
  100. package/src/App/renderer/HtmlElementHost.test.jsx +7 -7
  101. package/src/App/renderer/mapButtons.js +1 -1
  102. package/src/App/renderer/mapPanels.test.js +2 -2
  103. package/src/App/renderer/slotHelpers.js +2 -2
  104. package/src/App/renderer/slotHelpers.test.js +5 -5
  105. package/src/App/renderer/slots.js +9 -5
  106. package/src/App/store/AppProvider.jsx +3 -1
  107. package/src/App/store/AppProvider.test.jsx +1 -1
  108. package/src/App/store/ServiceProvider.jsx +3 -1
  109. package/src/App/store/appActionsMap.js +16 -0
  110. package/src/App/store/appActionsMap.test.js +27 -0
  111. package/src/App/store/appDispatchMiddleware.js +1 -1
  112. package/src/App/store/appDispatchMiddleware.test.js +2 -2
  113. package/src/App/store/appReducer.js +2 -0
  114. package/src/InteractiveMap/InteractiveMap.js +4 -0
  115. package/src/config/appConfig.js +5 -2
  116. package/src/config/events.js +28 -0
  117. package/src/scss/main.scss +1 -0
  118. package/src/scss/settings/_dimensions.scss +0 -1
  119. package/src/utils/getSafeZoneInset.js +9 -7
  120. package/src/utils/getSafeZoneInset.test.js +10 -10
  121. package/webpack.dev.mjs +1 -1
  122. package/docs/api/slot-map.svg +0 -1
  123. package/plugins/beta/datasets/src/api/hideDataset.js +0 -14
  124. package/plugins/beta/datasets/src/api/hideFeatures.js +0 -41
  125. package/plugins/beta/datasets/src/api/showDataset.js +0 -14
  126. package/plugins/beta/datasets/src/api/showFeatures.js +0 -44
  127. package/plugins/beta/datasets/src/handleSetMapStyle.js +0 -54
  128. package/plugins/beta/datasets/src/mapLayers.js +0 -164
  129. /package/src/{utils → services}/logger.js +0 -0
  130. /package/src/{utils → services}/logger.test.js +0 -0
@@ -1,19 +1,58 @@
1
- .im-c-datasets-key {
1
+ // When groups are present, every direct child gets a border-top
2
+ .im-c-datasets-key--has-groups > * {
3
+ border-top: 1px solid var(--button-hover-color);
4
+ }
5
+
6
+ // When no groups, only the first child gets a border-top
7
+ .im-c-datasets-key:not(.im-c-datasets-key--has-groups) > *:first-child {
8
+ border-top: 1px solid var(--button-hover-color);
9
+ }
2
10
 
11
+ .im-c-datasets-key__group:not(:last-child) {
12
+ padding-bottom: 5px;
3
13
  }
4
14
 
5
- .im-c-datasets-key__item-label {
15
+ .im-c-datasets-key__group-heading {
16
+ padding-top: 15px;
17
+ padding-bottom: 10px;
18
+ margin: 0;
19
+ font-size: 1rem;
20
+ font-weight: bold;
21
+ color: var(--foreground-color);
22
+ }
23
+
24
+ .im-c-datasets-key__item {
6
25
  display: flex;
7
26
  align-items: start;
8
- padding-top: 12px;
9
- padding-bottom: 12px;
10
- align-self: auto;
27
+ padding-top: 10px;
28
+ padding-bottom: 10px;
11
29
  font-size: 1rem;
12
- line-height: 1.2;
30
+
31
+ // When mixed with groups, flat items match group heading spacing
32
+ .im-c-datasets-key--has-groups > & {
33
+ padding-top: 15px;
34
+ padding-bottom: 15px;
35
+ }
36
+
37
+ // First item inside a group — heading already provides top spacing
38
+ .im-c-datasets-key__group &:first-child {
39
+ padding-top: 0;
40
+ }
41
+ }
42
+
43
+ // No-groups: first item needs 15px below the single border
44
+ .im-c-datasets-key:not(.im-c-datasets-key--has-groups) > .im-c-datasets-key__item:first-child {
45
+ padding-top: 15px;
13
46
  }
14
47
 
15
- .im-c-datasets-key__item-label svg {
48
+ // Last item in all scenarios — flat or inside the last group
49
+ .im-c-datasets-key > .im-c-datasets-key__item:last-child,
50
+ .im-c-datasets-key__group:last-child .im-c-datasets-key__item:last-child {
51
+ padding-bottom: 5px;
52
+ }
53
+
54
+ .im-c-datasets-key__item svg {
16
55
  position: relative;
17
56
  flex-shrink: 0;
18
- margin: 0px 13px 0 2px;
19
- }
57
+ margin: 0 13px 0 2px;
58
+ }
@@ -1,38 +1,141 @@
1
1
  import React from 'react'
2
- import { showDataset } from '../api/showDataset'
3
- import { hideDataset } from '../api/hideDataset'
2
+ import { setDatasetVisibility } from '../api/setDatasetVisibility'
4
3
 
5
- export const Layers = ({ pluginState, mapProvider }) => {
6
- const handleChange = (e) => {
7
- const { value, checked } = e.target
8
- if (checked) {
9
- showDataset({ mapProvider, pluginState }, value)
10
- } else {
11
- hideDataset({ mapProvider, pluginState }, value)
4
+ const CHECKBOX_LABEL_CLASS = 'im-c-datasets-layers__item-label govuk-label govuk-checkboxes__label'
5
+
6
+ const hasToggleableSublayers = (dataset) => dataset.sublayers?.some(sublayer => sublayer.toggleVisibility)
7
+
8
+ /**
9
+ * Collapse the filtered dataset list into ordered render items:
10
+ * { type: 'sublayers', dataset } dataset with sublayers (takes precedence)
11
+ * { type: 'group', groupLabel, datasets } — datasets sharing a groupLabel
12
+ * { type: 'flat', dataset } — standalone dataset
13
+ */
14
+ const buildRenderItems = (datasets) => {
15
+ const seenGroups = new Set()
16
+ const items = []
17
+ datasets.forEach(dataset => {
18
+ if (hasToggleableSublayers(dataset)) {
19
+ items.push({ type: 'sublayers', dataset })
20
+ return
12
21
  }
22
+ if (dataset.groupLabel) {
23
+ if (seenGroups.has(dataset.groupLabel)) {
24
+ return
25
+ }
26
+ seenGroups.add(dataset.groupLabel)
27
+ items.push({
28
+ type: 'group',
29
+ groupLabel: dataset.groupLabel,
30
+ datasets: datasets.filter(d => !hasToggleableSublayers(d) && d.groupLabel === dataset.groupLabel)
31
+ })
32
+ return
33
+ }
34
+ items.push({ type: 'flat', dataset })
35
+ })
36
+ return items
37
+ }
38
+
39
+ export const Layers = ({ pluginState }) => {
40
+ const handleDatasetChange = (e) => {
41
+ const { value, checked } = e.target
42
+ setDatasetVisibility({ pluginState }, checked, { datasetId: value })
13
43
  }
14
44
 
15
- return (
16
- <div className='im-c-datasets-layers'>
17
- <div className='govuk-form-group'>
18
- <fieldset className='govuk-fieldset'>
19
- <legend className='govuk-visually-hidden'>
20
- Layers
21
- </legend>
22
- <div className='govuk-checkboxes govuk-checkboxes--small' data-module='govuk-checkboxes'>
23
- {(pluginState.datasets || []).filter(dataset => dataset.showInLayers).map(dataset => (
24
- <div key={dataset.id} className={`im-c-datasets-layers__item${dataset.visibility !== 'hidden' ? ' im-c-datasets-layers__item--checked' : ''}`}>
25
- <div className='govuk-checkboxes__item'>
26
- <input className='govuk-checkboxes__input' id={dataset.id} name='layers' type='checkbox' value={dataset.id} checked={dataset.visibility !== 'hidden'} onChange={handleChange} />
27
- <label className='im-c-datasets-layers__item-label govuk-label govuk-checkboxes__label' htmlFor={dataset.id}>
28
- {dataset.label}
29
- </label>
30
- </div>
31
- </div>
32
- ))}
33
- </div>
34
- </fieldset>
45
+ const handleSublayerChange = (e) => {
46
+ const { checked } = e.target
47
+ const datasetId = e.target.dataset.datasetId
48
+ const sublayerId = e.target.dataset.sublayerId
49
+ setDatasetVisibility({ pluginState }, checked, { datasetId, sublayerId })
50
+ }
51
+
52
+ const renderDatasetItem = (dataset) => {
53
+ const itemClass = `im-c-datasets-layers__item govuk-checkboxes govuk-checkboxes--small${dataset.visibility === 'hidden' ? '' : ' im-c-datasets-layers__item--checked'}`
54
+ return (
55
+ <div key={dataset.id} className={itemClass} data-module='govuk-checkboxes'>
56
+ <div className='govuk-checkboxes__item'>
57
+ <input
58
+ className='govuk-checkboxes__input'
59
+ id={dataset.id}
60
+ name='layers'
61
+ type='checkbox'
62
+ value={dataset.id}
63
+ checked={dataset.visibility !== 'hidden'}
64
+ onChange={handleDatasetChange}
65
+ />
66
+ <label className={CHECKBOX_LABEL_CLASS} htmlFor={dataset.id}>
67
+ {dataset.label}
68
+ </label>
69
+ </div>
35
70
  </div>
71
+ )
72
+ }
73
+
74
+ const visibleDatasets = (pluginState.datasets || [])
75
+ .filter(dataset => dataset.toggleVisibility || hasToggleableSublayers(dataset))
76
+
77
+ const renderItems = buildRenderItems(visibleDatasets)
78
+ const hasGroups = renderItems.some(item => item.type === 'sublayers' || item.type === 'group')
79
+ const containerClass = `im-c-datasets-layers${hasGroups ? ' im-c-datasets-layers--has-groups' : ''}`
80
+
81
+ return (
82
+ <div className={containerClass}>
83
+ {renderItems.map(item => {
84
+ if (item.type === 'sublayers') {
85
+ const { dataset } = item
86
+ const anySublayerChecked = dataset.sublayers
87
+ .filter(sublayer => sublayer.toggleVisibility)
88
+ .some(sublayer => dataset.sublayerVisibility?.[sublayer.id] !== 'hidden')
89
+ const wrapperClass = `govuk-form-group im-c-datasets-layers-group${anySublayerChecked ? ' im-c-datasets-layers-group--items-checked' : ''}`
90
+ return (
91
+ <div key={dataset.id} className={wrapperClass}>
92
+ <fieldset className='im-c-datasets-layers-group__fieldset'>
93
+ <legend className='im-c-datasets-layers-group__legend'>{dataset.label}</legend>
94
+ {dataset.sublayers
95
+ .filter(sublayer => sublayer.toggleVisibility)
96
+ .map(sublayer => {
97
+ const sublayerVisible = dataset.sublayerVisibility?.[sublayer.id] !== 'hidden'
98
+ const inputId = `${dataset.id}-${sublayer.id}`
99
+ const itemClass = `im-c-datasets-layers__item govuk-checkboxes govuk-checkboxes--small${sublayerVisible ? ' im-c-datasets-layers__item--checked' : ''}`
100
+ return (
101
+ <div key={sublayer.id} className={itemClass} data-module='govuk-checkboxes'>
102
+ <div className='govuk-checkboxes__item'>
103
+ <input
104
+ className='govuk-checkboxes__input'
105
+ id={inputId}
106
+ type='checkbox'
107
+ checked={sublayerVisible}
108
+ data-dataset-id={dataset.id}
109
+ data-sublayer-id={sublayer.id}
110
+ onChange={handleSublayerChange}
111
+ />
112
+ <label className={CHECKBOX_LABEL_CLASS} htmlFor={inputId}>
113
+ {sublayer.label}
114
+ </label>
115
+ </div>
116
+ </div>
117
+ )
118
+ })}
119
+ </fieldset>
120
+ </div>
121
+ )
122
+ }
123
+
124
+ if (item.type === 'group') {
125
+ const anyDatasetChecked = item.datasets.some(d => d.visibility !== 'hidden')
126
+ const wrapperClass = `govuk-form-group im-c-datasets-layers-group${anyDatasetChecked ? ' im-c-datasets-layers-group--items-checked' : ''}`
127
+ return (
128
+ <div key={item.groupLabel} className={wrapperClass}>
129
+ <fieldset className='im-c-datasets-layers-group__fieldset'>
130
+ <legend className='im-c-datasets-layers-group__legend'>{item.groupLabel}</legend>
131
+ {item.datasets.map(dataset => renderDatasetItem(dataset))}
132
+ </fieldset>
133
+ </div>
134
+ )
135
+ }
136
+
137
+ return renderDatasetItem(item.dataset)
138
+ })}
36
139
  </div>
37
140
  )
38
141
  }
@@ -1,8 +1,37 @@
1
+ // When groups are present, every direct child (groups and flat items) gets a border-top
2
+ .im-c-datasets-layers--has-groups > * {
3
+ border-top: 1px solid var(--button-hover-color);
4
+ }
5
+
6
+ // When no groups, only the first child gets a border-top with 15px padding
7
+ .im-c-datasets-layers:not(.im-c-datasets-layers--has-groups) > *:first-child {
8
+ border-top: 1px solid var(--button-hover-color);
9
+ padding-top: 10px;
10
+ }
11
+
12
+ .im-c-datasets-layers > *:last-child {
13
+ margin-bottom: -5px;
14
+ }
15
+
16
+ .im-c-datasets-layers-group:not(:last-child) {
17
+ padding-bottom: 5px;
18
+ }
19
+
1
20
  .im-c-datasets-layers__item {
2
- border: 1px solid var(--button-hover-color);
3
- padding-left: 10px;
4
- &:not(last-child) {
5
- margin-bottom: 5px;
21
+ padding-bottom: 5px;
22
+
23
+ .im-c-datasets-layers--has-groups > & {
24
+ padding-top: 5px;
25
+ }
26
+
27
+ // Items inside a group have no individual padding or spacing between them
28
+ .im-c-datasets-layers-group & {
29
+ padding-top: 0;
30
+ padding-bottom: 0;
31
+
32
+ &:not(:last-child) {
33
+ margin-bottom: 0;
34
+ }
6
35
  }
7
36
  }
8
37
 
@@ -19,10 +48,6 @@
19
48
  color: var(--foreground-color);
20
49
  }
21
50
 
22
- .im-c-datasets-layers__item--checked {
23
- border-color: var(--app-border-color);
24
- }
25
-
26
51
  // GovUK style overide
27
52
  .im-c-datasets-layers__item .govuk-checkboxes__item {
28
53
  flex-wrap: nowrap;
@@ -31,3 +56,20 @@
31
56
  margin-left: -3px;
32
57
  }
33
58
  }
59
+
60
+ .im-c-datasets-layers-group__fieldset {
61
+ // Reset browser fieldset defaults
62
+ border: none;
63
+ padding: 0;
64
+ margin: 0;
65
+ min-width: 0;
66
+ }
67
+
68
+ .im-c-datasets-layers-group__legend {
69
+ padding-top: 15px;
70
+ padding-bottom: 10px;
71
+ padding-inline: 0;
72
+ font-size: 1rem;
73
+ font-weight: bold;
74
+ color: var(--foreground-color);
75
+ }
@@ -1,16 +1,27 @@
1
+ import { applyDatasetDefaults } from './defaults.js'
2
+
1
3
  const initialState = {
2
4
  datasets: null,
3
- hiddenFeatures: {} // { [layerId]: { idProperty: string, ids: string[] } }
5
+ hiddenFeatures: {}, // { [layerId]: { idProperty: string, ids: string[] } }
6
+ layerAdapter: null
7
+ }
8
+
9
+ const initSublayerVisibility = (dataset) => {
10
+ if (!dataset.sublayers?.length) {
11
+ return dataset
12
+ }
13
+ const sublayerVisibility = {}
14
+ dataset.sublayers.forEach(sublayer => {
15
+ sublayerVisibility[sublayer.id] = 'visible'
16
+ })
17
+ return { ...dataset, sublayerVisibility }
4
18
  }
5
19
 
6
20
  const setDatasets = (state, payload) => {
7
21
  const { datasets, datasetDefaults } = payload
8
22
  return {
9
23
  ...state,
10
- datasets: datasets.map(dataset => ({
11
- ...datasetDefaults,
12
- ...dataset
13
- }))
24
+ datasets: datasets.map(dataset => initSublayerVisibility(applyDatasetDefaults(dataset, datasetDefaults)))
14
25
  }
15
26
  }
16
27
 
@@ -20,7 +31,7 @@ const addDataset = (state, payload) => {
20
31
  ...state,
21
32
  datasets: [
22
33
  ...(state.datasets || []),
23
- { ...datasetDefaults, ...dataset }
34
+ initSublayerVisibility(applyDatasetDefaults(dataset, datasetDefaults))
24
35
  ]
25
36
  }
26
37
  }
@@ -43,6 +54,14 @@ const setDatasetVisibility = (state, payload) => {
43
54
  }
44
55
  }
45
56
 
57
+ const setGlobalVisibility = (state, payload) => {
58
+ const { visibility } = payload
59
+ return {
60
+ ...state,
61
+ datasets: state.datasets?.map(dataset => ({ ...dataset, visibility }))
62
+ }
63
+ }
64
+
46
65
  const hideFeatures = (state, payload) => {
47
66
  const { layerId, idProperty, featureIds } = payload
48
67
  const existing = state.hiddenFeatures[layerId]
@@ -61,12 +80,15 @@ const hideFeatures = (state, payload) => {
61
80
  const showFeatures = (state, payload) => {
62
81
  const { layerId, featureIds } = payload
63
82
  const existing = state.hiddenFeatures[layerId]
64
- if (!existing) return state
83
+ if (!existing) {
84
+ return state
85
+ }
65
86
 
66
87
  const newIds = existing.ids.filter(id => !featureIds.includes(id))
67
88
 
68
89
  if (newIds.length === 0) {
69
- const { [layerId]: _, ...rest } = state.hiddenFeatures
90
+ const rest = { ...state.hiddenFeatures }
91
+ delete rest[layerId]
70
92
  return { ...state, hiddenFeatures: rest }
71
93
  }
72
94
 
@@ -79,13 +101,110 @@ const showFeatures = (state, payload) => {
79
101
  }
80
102
  }
81
103
 
104
+ const setSublayerVisibility = (state, payload) => {
105
+ const { datasetId, sublayerId, visibility } = payload
106
+ return {
107
+ ...state,
108
+ datasets: state.datasets?.map(dataset => {
109
+ if (dataset.id !== datasetId) {
110
+ return dataset
111
+ }
112
+ return {
113
+ ...dataset,
114
+ sublayerVisibility: {
115
+ ...dataset.sublayerVisibility,
116
+ [sublayerId]: visibility
117
+ }
118
+ }
119
+ })
120
+ }
121
+ }
122
+
123
+ const setDatasetStyle = (state, payload) => {
124
+ const { datasetId, styleChanges } = payload
125
+ return {
126
+ ...state,
127
+ datasets: state.datasets?.map(dataset =>
128
+ dataset.id === datasetId ? { ...dataset, ...styleChanges } : dataset
129
+ )
130
+ }
131
+ }
132
+
133
+ const setSublayerStyle = (state, payload) => {
134
+ const { datasetId, sublayerId, styleChanges } = payload
135
+ return {
136
+ ...state,
137
+ datasets: state.datasets?.map(dataset => {
138
+ if (dataset.id !== datasetId) {
139
+ return dataset
140
+ }
141
+ return {
142
+ ...dataset,
143
+ sublayers: dataset.sublayers?.map(sublayer =>
144
+ sublayer.id === sublayerId
145
+ ? { ...sublayer, style: { ...sublayer.style, ...styleChanges } }
146
+ : sublayer
147
+ )
148
+ }
149
+ })
150
+ }
151
+ }
152
+
153
+ const setOpacity = (state, payload) => {
154
+ const { datasetId, opacity } = payload
155
+ return {
156
+ ...state,
157
+ datasets: state.datasets?.map(dataset =>
158
+ dataset.id === datasetId ? { ...dataset, opacity } : dataset
159
+ )
160
+ }
161
+ }
162
+
163
+ const setGlobalOpacity = (state, payload) => {
164
+ const { opacity } = payload
165
+ return {
166
+ ...state,
167
+ datasets: state.datasets?.map(dataset => ({ ...dataset, opacity }))
168
+ }
169
+ }
170
+
171
+ const setSublayerOpacity = (state, payload) => {
172
+ const { datasetId, sublayerId, opacity } = payload
173
+ return {
174
+ ...state,
175
+ datasets: state.datasets?.map(dataset => {
176
+ if (dataset.id !== datasetId) {
177
+ return dataset
178
+ }
179
+ return {
180
+ ...dataset,
181
+ sublayers: dataset.sublayers?.map(sublayer =>
182
+ sublayer.id === sublayerId
183
+ ? { ...sublayer, style: { ...sublayer.style, opacity } }
184
+ : sublayer
185
+ )
186
+ }
187
+ })
188
+ }
189
+ }
190
+
191
+ const setLayerAdapter = (state, payload) => ({ ...state, layerAdapter: payload })
192
+
82
193
  const actions = {
83
194
  SET_DATASETS: setDatasets,
84
195
  ADD_DATASET: addDataset,
85
196
  REMOVE_DATASET: removeDataset,
86
197
  SET_DATASET_VISIBILITY: setDatasetVisibility,
198
+ SET_GLOBAL_VISIBILITY: setGlobalVisibility,
199
+ SET_SUBLAYER_VISIBILITY: setSublayerVisibility,
200
+ SET_DATASET_STYLE: setDatasetStyle,
201
+ SET_SUBLAYER_STYLE: setSublayerStyle,
202
+ SET_OPACITY: setOpacity,
203
+ SET_GLOBAL_OPACITY: setGlobalOpacity,
204
+ SET_SUBLAYER_OPACITY: setSublayerOpacity,
87
205
  HIDE_FEATURES: hideFeatures,
88
- SHOW_FEATURES: showFeatures
206
+ SHOW_FEATURES: showFeatures,
207
+ SET_LAYER_ADAPTER: setLayerAdapter
89
208
  }
90
209
 
91
210
  export {
@@ -0,0 +1,157 @@
1
+ import { getValueForStyle } from '../../../../../src/utils/getValueForStyle.js'
2
+
3
+ // ─── Built-in pattern library ────────────────────────────────────────────────
4
+ // Each value is the inner SVG content (paths only, no wrapper).
5
+ // Paths are authored in a 16×16 coordinate space (power-of-two, tiles seamlessly).
6
+ // Use {{foreground}} and {{background}} tokens for colours.
7
+
8
+ const BUILT_IN_PATTERNS = {
9
+ 'cross-hatch': '<path d="M0 4.486V3.485h3.5V.001h1v3.484h7.002V.001h1v3.484h3.5v1.001h-3.5v7h3.5v.999h-3.5v3.516h-1v-3.516H4.499v3.516h-1v-3.516H0v-.999h3.5v-7H0zm11.501 0H4.499v7h7.002v-7z" fill="{{foreground}}"/>',
10
+ 'diagonal-cross-hatch': '<path d="M0 8.707V7.293L7.293 0h1.414L16 7.293v1.414L8.707 16H7.293L0 8.707zM.707 8L8 15.293 15.293 8 8 .707.707 8z" fill="{{foreground}}"/>',
11
+ 'forward-diagonal-hatch': '<path d="M16 8.707V7.293L7.293 16h1.414L16 8.707zm-16 0L8.707 0H7.293L0 7.293v1.414z" fill="{{foreground}}"/>',
12
+ 'backward-diagonal-hatch': '<path d="M0 8.707V7.293L8.707 16H7.293L0 8.707zm16 0L7.293 0h1.414L16 7.293v1.414z" fill="{{foreground}}"/>',
13
+ 'horizontal-hatch': '<path d="M0 4.5V3.499h15.999V4.5H0zm0 7h15.999V12.5H0v-1.001z" fill="{{foreground}}"/>',
14
+ 'vertical-hatch': '<path d="M3.501 16.001V0h1v16.001h-1zm7.998 0V0h1v16.001h-1z" fill="{{foreground}}"/>',
15
+ dot: '<path d="M3.999 2A2 2 0 0 1 6 3.999C6 5.103 5.103 6 3.999 6a2 2 0 0 1-1.999-2.001A2 2 0 0 1 3.999 2zm0 7.999C5.103 10 6 10.897 6 12.001A2 2 0 0 1 3.999 14a2 2 0 0 1-1.999-1.999A2 2 0 0 1 3.999 10zM11.999 2A2 2 0 0 1 14 3.999C14 5.103 13.103 6 11.999 6S10 5.103 10 3.999A2 2 0 0 1 11.999 2zm0 7.999c1.104 0 2.001.897 2.001 2.001A2 2 0 0 1 11.999 14 2 2 0 0 1 10 12.001c0-1.104.897-2.001 1.999-2.001z" fill="{{foreground}}"/>',
16
+ diamond: '<path d="M4 .465L7.535 4 4 7.535.465 4 4 .465zm0 7.999l3.535 3.535L4 15.535.465 11.999 4 8.464zm8-8l3.535 3.535-3.536 3.536L8.464 4 12 .464zm0 8.001L15.536 12 12 15.536 8.465 12 12 8.465z" fill="{{foreground}}"/>'
17
+ }
18
+
19
+ // Plugin-controlled border path used in the key symbol (20×20 coordinate space).
20
+ // This is always rendered as the first element, before the user-supplied content.
21
+ const KEY_BORDER_PATH = '<path d="M19 2.862v14.275c0 1.028-.835 1.862-1.862 1.862H2.863c-1.028 0-1.862-.835-1.862-1.862V2.862C1.001 1.834 1.836 1 2.863 1h14.275C18.166 1 19 1.835 19 2.862z" fill="{{background}}" stroke="{{foreground}}" stroke-width="2"/>'
22
+
23
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
24
+
25
+ export const hashString = (str) => {
26
+ let hash = 0
27
+ for (const ch of str) {
28
+ hash = ((hash << 5) - hash) + ch.codePointAt(0)
29
+ hash = hash & hash
30
+ }
31
+ return Math.abs(hash).toString(36) // NOSONAR: base36 encoding for compact alphanumeric hash string
32
+ }
33
+
34
+ export const injectColors = (content, foreground, background) =>
35
+ content
36
+ .replace(/\{\{foreground\}\}/g, foreground || 'black')
37
+ .replace(/\{\{background\}\}/g, background || 'transparent')
38
+
39
+ // ─── Public API ───────────────────────────────────────────────────────────────
40
+
41
+ /**
42
+ * Returns true if a dataset has a fill pattern configured.
43
+ * @param {Object} dataset
44
+ * @returns {boolean}
45
+ */
46
+ export const hasPattern = (dataset) => !!(dataset.fillPattern || dataset.fillPatternSvgContent)
47
+
48
+ /**
49
+ * Returns the raw (un-coloured) inner SVG content for a dataset's pattern.
50
+ * Custom fillPatternSvgContent takes precedence over built-in fillPattern ids.
51
+ * @param {Object} dataset
52
+ * @returns {string|null}
53
+ */
54
+ export const getPatternInnerContent = (dataset) => {
55
+ if (dataset.fillPatternSvgContent) {
56
+ return dataset.fillPatternSvgContent
57
+ }
58
+ if (dataset.fillPattern && BUILT_IN_PATTERNS[dataset.fillPattern]) {
59
+ return BUILT_IN_PATTERNS[dataset.fillPattern]
60
+ }
61
+ return null
62
+ }
63
+
64
+ /**
65
+ * Returns a deterministic image ID for a pattern + resolved colour combination.
66
+ * @param {Object} dataset
67
+ * @param {string} mapStyleId
68
+ * @returns {string|null}
69
+ */
70
+ export const getPatternImageId = (dataset, mapStyleId) => {
71
+ const innerContent = getPatternInnerContent(dataset)
72
+ if (!innerContent) {
73
+ return null
74
+ }
75
+ const fg = getValueForStyle(dataset.fillPatternForegroundColor, mapStyleId) || 'black'
76
+ const bg = getValueForStyle(dataset.fillPatternBackgroundColor, mapStyleId) || 'transparent'
77
+ return `pattern-${hashString(innerContent + fg + bg)}`
78
+ }
79
+
80
+ /**
81
+ * Returns colour-injected inner SVG path content for use in the Key symbol.
82
+ * The caller is responsible for wrapping this in the SVG element and border path.
83
+ * @param {Object} dataset
84
+ * @param {string} mapStyleId
85
+ * @returns {{ border: string, content: string }|null}
86
+ */
87
+ export const getKeyPatternPaths = (dataset, mapStyleId) => {
88
+ const innerContent = getPatternInnerContent(dataset)
89
+ if (!innerContent) {
90
+ return null
91
+ }
92
+ const fg = getValueForStyle(dataset.fillPatternForegroundColor, mapStyleId) || 'black'
93
+ const bg = getValueForStyle(dataset.fillPatternBackgroundColor, mapStyleId) || 'transparent'
94
+ const borderStroke = getValueForStyle(dataset.stroke, mapStyleId) || fg
95
+ return {
96
+ border: injectColors(KEY_BORDER_PATH, borderStroke, bg),
97
+ content: injectColors(innerContent, fg, bg)
98
+ }
99
+ }
100
+
101
+ // ─── Rasterisation ────────────────────────────────────────────────────────────
102
+
103
+ // Module-level cache: imageId → ImageData. Avoids re-rasterising identical patterns.
104
+ const imageDataCache = new Map()
105
+
106
+ const rasteriseToImageData = (svgString, width, height) =>
107
+ new Promise((resolve, reject) => {
108
+ const blob = new Blob([svgString], { type: 'image/svg+xml' })
109
+ const url = URL.createObjectURL(blob)
110
+ const img = new Image(width, height)
111
+ img.onload = () => {
112
+ const canvas = document.createElement('canvas')
113
+ canvas.width = width
114
+ canvas.height = height
115
+ const ctx = canvas.getContext('2d')
116
+ ctx.drawImage(img, 0, 0, width, height)
117
+ URL.revokeObjectURL(url)
118
+ resolve(ctx.getImageData(0, 0, width, height))
119
+ }
120
+ img.onerror = () => {
121
+ URL.revokeObjectURL(url)
122
+ reject(new Error(`Failed to rasterise pattern SVG: ${svgString.slice(0, 80)}`))
123
+ }
124
+ img.src = url
125
+ })
126
+
127
+ /**
128
+ * Rasterises a dataset's pattern SVG to ImageData, using an in-memory cache
129
+ * to avoid re-rasterising identical patterns. Framework-agnostic — callers
130
+ * are responsible for registering the result with their map framework.
131
+ *
132
+ * @param {Object} dataset
133
+ * @param {string} mapStyleId
134
+ * @returns {Promise<{ imageId: string, imageData: ImageData }|null>}
135
+ */
136
+ export const rasterisePattern = async (dataset, mapStyleId) => {
137
+ const innerContent = getPatternInnerContent(dataset)
138
+ if (!innerContent) {
139
+ return null
140
+ }
141
+
142
+ const fg = getValueForStyle(dataset.fillPatternForegroundColor, mapStyleId) || 'black'
143
+ const bg = getValueForStyle(dataset.fillPatternBackgroundColor, mapStyleId) || 'transparent'
144
+ const imageId = `pattern-${hashString(innerContent + fg + bg)}`
145
+
146
+ let imageData = imageDataCache.get(imageId)
147
+ if (!imageData) {
148
+ const colored = injectColors(innerContent, fg, bg)
149
+ const bgRect = `<rect width="16" height="16" fill="${bg}"/>`
150
+ // pixelRatio: 2 means the map treats this as an 8×8 logical tile — crisp on retina screens.
151
+ const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">${bgRect}${colored}</svg>`
152
+ imageData = await rasteriseToImageData(svgString, 16, 16)
153
+ imageDataCache.set(imageId, imageData)
154
+ }
155
+
156
+ return { imageId, imageData }
157
+ }