@defra/interactive-map 0.0.48-fmp-2 → 0.0.49-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.
@@ -65,6 +65,8 @@ export default class EsriLayerAdapter extends LayerAdapter {
65
65
  await _add(registryDataset)
66
66
  }
67
67
  }
68
+ // Reorder layers after adding the initially visible datasets
69
+ this._reorderLayers()
68
70
  // Add the non-visible datasets next
69
71
  for (const registryDataset of topLevelDatasets) {
70
72
  if (registryDataset.visibility !== 'visible') {
@@ -87,7 +89,6 @@ export default class EsriLayerAdapter extends LayerAdapter {
87
89
 
88
90
  _reorderLayers () {
89
91
  // Order: baseLayer first, then datasets by their registry order, then sketch layers last
90
- // Layers within a group layer are not reordered individually - they keep their existing relative order
91
92
  const allLayers = this._map?.allLayers?.items
92
93
  if (!allLayers?.length) {
93
94
  return
@@ -96,6 +97,20 @@ export default class EsriLayerAdapter extends LayerAdapter {
96
97
  const topLevelLayers = allLayers.filter(layer => layer.parent?.type !== 'group')
97
98
  const orderedLayers = [...topLevelLayers].sort((a, b) => getLayerSortOrder(a) - getLayerSortOrder(b))
98
99
  orderedLayers.forEach((layer, index) => this._map.reorder(layer, index))
100
+ // Finally reorder the items within the group layers
101
+ this._reorderGroupLayers()
102
+ }
103
+
104
+ _reorderGroupLayers () {
105
+ const groupLayers = Object.values(this._groupLayers)
106
+ groupLayers.forEach((groupLayer) => {
107
+ const children = groupLayer?.allLayers?.items || []
108
+ const orderedChildren = [...children]
109
+ .filter(Boolean)
110
+ .sort((a, b) => getLayerSortOrder(a) - getLayerSortOrder(b))
111
+
112
+ orderedChildren.forEach((layer, index) => groupLayer?.reorder?.(layer, index))
113
+ })
99
114
  }
100
115
 
101
116
  _addGroupLayer (esriGroupId) {
@@ -272,6 +287,9 @@ export default class EsriLayerAdapter extends LayerAdapter {
272
287
 
273
288
  // mapLayer could be a VectorTileLayer or a FeatureLayer, depending on the dataset type
274
289
  const mapLayer = this._mapVisibilityLayers[isSublayer ? parent.id : id]
290
+ if (!mapLayer) {
291
+ return
292
+ }
275
293
  if (registryDataset.type === 'FeatureService') {
276
294
  // FeatureLayers don't have style layers, so we don't need to apply style layer visibility or paint properties
277
295
  mapLayer.renderer = registryDataset.renderer
@@ -21,10 +21,18 @@ jest.mock('@arcgis/core/layers/GroupLayer.js', () =>
21
21
  return {
22
22
  ...opts,
23
23
  layers,
24
+ allLayers: { items: layers },
24
25
  add: jest.fn(layer => layers.push(layer)),
25
26
  remove: jest.fn(layer => {
26
27
  const idx = layers.indexOf(layer)
27
28
  if (idx !== -1) layers.splice(idx, 1)
29
+ }),
30
+ reorder: jest.fn((layer, index) => {
31
+ const current = layers.indexOf(layer)
32
+ if (current !== -1) {
33
+ layers.splice(current, 1)
34
+ }
35
+ layers.splice(index, 0, layer)
28
36
  })
29
37
  }
30
38
  })
@@ -349,6 +357,46 @@ describe('esriLayerAdapter', () => {
349
357
  expect(map.reorder).toHaveBeenCalledWith(baseLayer, 0)
350
358
  expect(map.reorder).toHaveBeenCalledWith(emptyGroupLayer, 1)
351
359
  })
360
+
361
+ it('reorders the child layers within a group by registry order', () => {
362
+ datasetRegistry._orderedDatasets = ['roads', 'schools']
363
+ const roads = { id: 'roads' }
364
+ const schools = { id: 'schools' }
365
+ const sketch = { id: 'ketchLayer-0' }
366
+ const baseLayer = { id: 'baseLayer' }
367
+ const groupLayer = {
368
+ id: 'group-1',
369
+ type: 'group',
370
+ allLayers: { items: [schools, sketch, roads] },
371
+ reorder: jest.fn()
372
+ }
373
+ adapter._groupLayers[groupLayer.id] = groupLayer
374
+ map.allLayers = { items: [groupLayer, baseLayer] }
375
+ map.reorder = jest.fn()
376
+
377
+ adapter._reorderLayers()
378
+
379
+ expect(groupLayer.reorder).toHaveBeenNthCalledWith(1, roads, 0)
380
+ expect(groupLayer.reorder).toHaveBeenNthCalledWith(2, schools, 1)
381
+ expect(groupLayer.reorder).toHaveBeenNthCalledWith(3, sketch, 2)
382
+ })
383
+
384
+ it('Copes when a groupLayer has no items', () => {
385
+ datasetRegistry._orderedDatasets = ['roads', 'schools']
386
+ const baseLayer = { id: 'baseLayer' }
387
+ const groupLayer = {
388
+ id: 'group-1',
389
+ type: 'group',
390
+ reorder: jest.fn()
391
+ }
392
+ adapter._groupLayers[groupLayer.id] = groupLayer
393
+ map.allLayers = { items: [groupLayer, baseLayer] }
394
+ map.reorder = jest.fn()
395
+
396
+ adapter._reorderLayers()
397
+
398
+ expect(groupLayer.reorder).not.toHaveBeenCalled()
399
+ })
352
400
  })
353
401
 
354
402
  // ─── _addFeatureLayers ────────────────────────────────────────────────────────
@@ -212,12 +212,12 @@ describe('MapLibreDataset', () => {
212
212
 
213
213
  it('returns a tiles-based id for a tile dataset (array tiles)', () => {
214
214
  const dataset = datasetRegistry.getDataset('existing-fields')
215
- expect(dataset.sourceId).toBe('tiles-3delfuv')
215
+ expect(dataset.sourceId).toBe('tiles-35m5lrb')
216
216
  })
217
217
 
218
218
  it('returns a tiles-based id when tiles is a plain string (line 76 non-array branch)', () => {
219
219
  const dataset = datasetRegistry.getDataset('existing-fields')
220
- expect(dataset.sourceId).toBe('tiles-3delfuv')
220
+ expect(dataset.sourceId).toBe('tiles-35m5lrb')
221
221
  })
222
222
 
223
223
  it('returns geojson-dynamic-{id} for a dynamic geojson source', () => {
@@ -242,7 +242,7 @@ describe('MapLibreDataset', () => {
242
242
  it('returns a vector source for a tile dataset', () => {
243
243
  expect(datasetRegistry.getDataset('existing-fields').source).toEqual({
244
244
  type: 'vector',
245
- tiles: 'https://farming-tiles-702a60f45633.herokuapp.com/field_parcels_with_hedges/{z}/{x}/{y}',
245
+ tiles: 'https://farming-tiles-702a60f45633.herokuapp.com/field_parcels_with_hedges_wgs84/{z}/{x}/{y}',
246
246
  minzoom: 10,
247
247
  maxzoom: 24
248
248
  })
@@ -395,8 +395,8 @@ describe('MapLibreDataset', () => {
395
395
  expect(datasetRegistry.getDataset('existing-fields').getFillSource({})).toEqual({
396
396
  id: 'existing-fields',
397
397
  type: 'fill',
398
- source: 'tiles-3delfuv',
399
- 'source-layer': 'field_parcels_filtered',
398
+ source: 'tiles-35m5lrb',
399
+ 'source-layer': 'field_parcels_wgs84',
400
400
  minzoom: 10,
401
401
  maxzoom: 24,
402
402
  layout: { visibility: 'visible' },
@@ -413,7 +413,7 @@ describe('MapLibreDataset', () => {
413
413
  .toEqual({
414
414
  id: 'hedge-control',
415
415
  type: 'line',
416
- source: 'tiles-3delfuv',
416
+ source: 'tiles-35m5lrb',
417
417
  'source-layer': 'hedge_control',
418
418
  minzoom: 10,
419
419
  maxzoom: 24,
@@ -428,8 +428,8 @@ describe('MapLibreDataset', () => {
428
428
  .toEqual({
429
429
  id: 'existing-fields-stroke',
430
430
  type: 'line',
431
- source: 'tiles-3delfuv',
432
- 'source-layer': 'field_parcels_filtered',
431
+ source: 'tiles-35m5lrb',
432
+ 'source-layer': 'field_parcels_wgs84',
433
433
  minzoom: 10,
434
434
  maxzoom: 24,
435
435
  layout: { visibility: 'visible' },
@@ -101,8 +101,8 @@ export const datasets = [
101
101
  id: 'existing-fields', // NOSONAR
102
102
  label: 'Existing fields',
103
103
  filter: ['all', ['==', ['get', 'sbi'], '106223377'], ['==', ['get', 'is_dominant_land_cover'], true]],
104
- tiles: 'https://farming-tiles-702a60f45633.herokuapp.com/field_parcels_with_hedges/{z}/{x}/{y}',
105
- sourceLayer: 'field_parcels_filtered',
104
+ tiles: 'https://farming-tiles-702a60f45633.herokuapp.com/field_parcels_with_hedges_wgs84/{z}/{x}/{y}',
105
+ sourceLayer: 'field_parcels_wgs84',
106
106
  minZoom: 10,
107
107
  maxZoom: 24,
108
108
  showInKey: true,
@@ -158,7 +158,7 @@ export const datasets = [
158
158
  }, {
159
159
  id: 'hedge-control', // NOSONAR
160
160
  label: 'Hedge control',
161
- tiles: ['https://farming-tiles-702a60f45633.herokuapp.com/field_parcels_with_hedges/{z}/{x}/{y}'],
161
+ tiles: ['https://farming-tiles-702a60f45633.herokuapp.com/field_parcels_with_hedges_wgs84/{z}/{x}/{y}'],
162
162
  sourceLayer: 'hedge_control',
163
163
  minZoom: 10,
164
164
  maxZoom: 24,
@@ -17,12 +17,12 @@ const symmetricWidth = (left, right) => left || right ? Math.max(left, right) :
17
17
  const subSlotMaxHeight = (columnHeight, siblingButtons, gap) => columnHeight - (siblingButtons ? siblingButtons + gap : 0)
18
18
 
19
19
  // bottomRightHeight is 0 when empty, falling back to the attributions' own height for spacing.
20
- // clearance is how far docked attributions have pushed .im-o-app__bottom-right up via
21
- // --bottom-right-clearance (see attributionsBottomRightClearance below) — without adding it
22
- // here, .im-o-app__right's computed bottom offset stays at the box's un-pushed position and
23
- // the floating right column overlaps the now-raised bottom-right buttons.
24
- const rightOffsetBottom = (containerPad, bottomRightHeight, attributionsHeight, clearance, gap) =>
25
- containerPad + (bottomRightHeight > 0 ? bottomRightHeight + clearance + gap : attributionsHeight)
20
+ // rise is how far attributions has lifted .im-o-app__bottom-right above .im-o-app__bottom's
21
+ // own bottom edge (see attributionsRise below) — without adding it here, .im-o-app__right's
22
+ // computed bottom offset stays at the box's un-lifted position and the floating right column
23
+ // overlaps the now-raised bottom-right buttons.
24
+ const rightOffsetBottom = (containerPad, bottomRightHeight, attributionsHeight, rise, gap) =>
25
+ containerPad + (bottomRightHeight > 0 ? bottomRightHeight + rise + gap : attributionsHeight)
26
26
 
27
27
  // Clears the bottom row's own TOP edge (not just its trailing gap below), so the hint
28
28
  // never overlaps the logo/attribution row itself, plus a gap above it. That trivially
@@ -78,13 +78,22 @@ const attributionsNaturalWidth = (attributionsEl) => {
78
78
  // longer fits, at its natural width, in the space beside the logo column.
79
79
  const isAttributionsStacked = (naturalWidth, availableWidth) => naturalWidth > availableWidth
80
80
 
81
- // Docked, attributions bleeds primaryGap below the row's bottom edge then grows upward from
82
- // there by its own height — once that height exceeds primaryGap, it creeps back up into the
83
- // row itself, where .im-o-app__bottom-right shares the same horizontal space. This is how far
84
- // that needs pushing up to stay clear. Stacked already has its own clearance (the stacked
85
- // rule's margin-top), so none is needed there.
86
- const attributionsBottomRightClearance = (isStacked, attributionsHeight, dividerGap, primaryGap) =>
87
- isStacked ? 0 : Math.max(0, attributionsHeight + dividerGap - primaryGap)
81
+ // How far attributions lifts .im-o-app__bottom-right above .im-o-app__bottom's own bottom
82
+ // edge. The same figure covers both modes, because both bleed primaryGap past that edge and
83
+ // then take attributionsHeight (plus a dividerGap) back up from there:
84
+ // - docked: the box grows upward from the bleed into the row itself, where
85
+ // .im-o-app__bottom-right shares the same horizontal space, so the row needs an explicit
86
+ // margin-bottom (--bottom-right-clearance) to stay clear of it;
87
+ // - stacked: the box is an extra flex line *below* the row, so it lifts the row by exactly
88
+ // that much on its own — no margin needed, hence bottomRightClearance's isStacked branch.
89
+ // Either way .im-o-app__right must clear the lift, so the offsets below use this, not the
90
+ // clearance. Clamped at 0: a box shorter than its own bleed lifts nothing.
91
+ const attributionsRise = (attributionsHeight, dividerGap, primaryGap) =>
92
+ Math.max(0, attributionsHeight + dividerGap - primaryGap)
93
+
94
+ // Stacked gets its lift from the extra flex line itself (see attributionsRise above), so only
95
+ // docked needs the row pushed up by a margin.
96
+ const bottomRightClearance = (isStacked, rise) => isStacked ? 0 : rise
88
97
 
89
98
  // Docks centred between the side columns when there's room, otherwise stacks full-width
90
99
  // (mobile always stacks). Sets the banner's own CSS vars and returns what the side-column
@@ -129,9 +138,9 @@ function applyAttributionsLayout ({ appContainer, bottom, attributions, dividerG
129
138
  // space-between` gap between the two columns isn't a fixed value (unlike `gap`) to reconstruct.
130
139
  const left = attributionsCol ? Math.round(attributionsCol.getBoundingClientRect().left - bottom.getBoundingClientRect().left) : 0
131
140
  appContainer.style.setProperty('--attributions-left', `${left}px`)
132
- const clearance = attributionsBottomRightClearance(isStacked, attributions.offsetHeight, dividerGap, primaryGap)
133
- appContainer.style.setProperty('--bottom-right-clearance', `${clearance}px`)
134
- return { clearance }
141
+ const rise = attributionsRise(attributions.offsetHeight, dividerGap, primaryGap)
142
+ appContainer.style.setProperty('--bottom-right-clearance', `${bottomRightClearance(isStacked, rise)}px`)
143
+ return { rise }
135
144
  }
136
145
 
137
146
  /**
@@ -179,7 +188,7 @@ function calculateLayout (layoutRefs, breakpoint) {
179
188
  ? bannerTop + bannerHeight + dividerGap
180
189
  : colHeight + top.offsetTop
181
190
 
182
- const { clearance: bottomRightClearance } = applyAttributionsLayout({ appContainer, bottom, attributions, dividerGap, primaryGap })
191
+ const { rise: attributionsLift } = applyAttributionsLayout({ appContainer, bottom, attributions, dividerGap, primaryGap })
183
192
 
184
193
  // === Left container offsets ===
185
194
  const leftOffsetTop = sideOffsetTop(topLeftCol.offsetHeight)
@@ -192,10 +201,10 @@ function calculateLayout (layoutRefs, breakpoint) {
192
201
  const bottomRightHeight = buttonHeight(bottomRightRef)
193
202
  const bottomContainerPad = main.offsetHeight - bottom.offsetTop - bottom.offsetHeight
194
203
  const rightOffsetTop = sideOffsetTop(topRightCol.offsetHeight)
195
- const rightEffectiveBottom = bottom.offsetTop + bottom.offsetHeight - bottomRightHeight - bottomRightClearance
204
+ const rightEffectiveBottom = bottom.offsetTop + bottom.offsetHeight - bottomRightHeight - attributionsLift
196
205
  const rightColumnHeight = rightEffectiveBottom - rightOffsetTop - dividerGap
197
206
  appContainer.style.setProperty('--right-offset-top', `${rightOffsetTop}px`)
198
- appContainer.style.setProperty('--right-offset-bottom', `${rightOffsetBottom(bottomContainerPad, bottomRightHeight, attributions.offsetHeight, bottomRightClearance, dividerGap)}px`)
207
+ appContainer.style.setProperty('--right-offset-bottom', `${rightOffsetBottom(bottomContainerPad, bottomRightHeight, attributions.offsetHeight, attributionsLift, dividerGap)}px`)
199
208
  appContainer.style.setProperty('--right-top-max-height', `${rightColumnHeight}px`)
200
209
 
201
210
  // === Keyboard hint bottom offset ===
@@ -235,7 +235,7 @@ describe('useLayoutMeasurements', () => {
235
235
  appendChild(layoutRefs.attributionsRef.current, { scrollWidth: 250 }) // attribution text, too wide to fit
236
236
  renderHook(() => useLayoutMeasurements())
237
237
  expect(layoutRefs.attributionsRef.current.classList.contains('im-o-app__attributions--stacked')).toBe(true)
238
- // stacked already has its own clearance (the stacked rule's own margin-top), so none needed here
238
+ // stacked is lifted by its own extra flex line, not a margin, so no clearance is needed here
239
239
  expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith('--bottom-right-clearance', '0px')
240
240
  })
241
241
 
@@ -263,27 +263,55 @@ describe('useLayoutMeasurements', () => {
263
263
  })
264
264
  renderHook(() => useLayoutMeasurements())
265
265
  // bottomContainerPad = 500 - 400 - 0 = 100
266
- // bottom-right-clearance = 16 (default attributions offsetHeight 16 + dividerGap 8 - primaryGap 8)
266
+ // attributions lift = 16 (default attributions offsetHeight 16 + dividerGap 8 - primaryGap 8)
267
267
  // expected = 100 + (20 + 16 + 8) = 144
268
268
  expect(layoutRefs.appContainerRef.current.style.setProperty)
269
269
  .toHaveBeenCalledWith('--right-offset-bottom', '144px')
270
270
  })
271
271
 
272
- test('adds bottom-right-clearance into right-offset-bottom so .im-o-app__right does not overlap a bottom-right box pushed up by tall attributions', () => {
272
+ test('adds the attributions lift into right-offset-bottom so .im-o-app__right does not overlap a bottom-right box pushed up by tall docked attributions', () => {
273
273
  const { layoutRefs } = setup({
274
274
  refs: {
275
275
  bottomRight: { offsetHeight: 20 },
276
- attributions: { offsetHeight: 40 } // clearance = 40 + 8 (dividerGap) - 8 (primaryGap) = 40
276
+ attributions: { offsetHeight: 40 } // lift = 40 + 8 (dividerGap) - 8 (primaryGap) = 40
277
277
  }
278
278
  })
279
279
  renderHook(() => useLayoutMeasurements())
280
280
  // bottomContainerPad = 500 - 400 - 0 = 100; expected = 100 + (20 + 40 + 8) = 168
281
- // Without the clearance term this regressed to 128px, letting .im-o-app__right's
281
+ // Without the lift term this regressed to 128px, letting .im-o-app__right's
282
282
  // bottom offset sit below the (margin-bottom-raised) bottom-right box's real top edge.
283
283
  expect(layoutRefs.appContainerRef.current.style.setProperty)
284
284
  .toHaveBeenCalledWith('--right-offset-bottom', '168px')
285
285
  })
286
286
 
287
+ // Stacked attributions takes a flex line of its own BELOW the bottom-right row, lifting the
288
+ // row by the same amount the docked box's margin-bottom does — but as part of
289
+ // .im-o-app__bottom's own grown height, not as a margin, so --bottom-right-clearance is 0.
290
+ // Reading the clearance here (rather than the mode-independent lift) left the right column's
291
+ // bottom offset at the un-lifted position, so a right-bottom scale bar overlapped a
292
+ // bottom-right draw menu on mobile — the breakpoint where attributions always stacks.
293
+ test('adds the attributions lift into right-offset-bottom when attributions is stacked, not just docked', () => {
294
+ const { layoutRefs } = setup({
295
+ refs: {
296
+ bottom: { rect: { left: 0 } },
297
+ bottomRight: { offsetHeight: 20 },
298
+ attributions: { offsetHeight: 40 } // lift = 40 + 8 (dividerGap) - 8 (primaryGap) = 40
299
+ }
300
+ })
301
+ appendChild(layoutRefs.bottomRef.current, {}) // logo column
302
+ appendChild(layoutRefs.bottomRef.current, { offsetWidth: 192, rect: { left: 115 } }) // bottom-right column
303
+ layoutRefs.bottomRef.current.appendChild(layoutRefs.attributionsRef.current) // sibling of both columns
304
+ appendChild(layoutRefs.attributionsRef.current, { scrollWidth: 250 }) // too wide to fit beside the logo
305
+ renderHook(() => useLayoutMeasurements())
306
+ const spy = layoutRefs.appContainerRef.current.style.setProperty
307
+ expect(layoutRefs.attributionsRef.current.classList.contains('im-o-app__attributions--stacked')).toBe(true)
308
+ expect(spy).toHaveBeenCalledWith('--bottom-right-clearance', '0px')
309
+ // Same 168px as the docked case above, despite the 0px clearance.
310
+ expect(spy).toHaveBeenCalledWith('--right-offset-bottom', '168px')
311
+ // rightEffectiveBottom = 400 + 0 - 20 - 40 = 340; 340 - 50 (rightOffsetTop) - 8 = 282
312
+ expect(spy).toHaveBeenCalledWith('--right-top-max-height', '282px')
313
+ })
314
+
287
315
  test('uses 0 when sub-slot refs have null current', () => {
288
316
  const { layoutRefs } = setup()
289
317
  layoutRefs.leftTopRef.current = null
@@ -266,6 +266,12 @@
266
266
  flex-wrap: wrap; // lets a stacked attributions row wrap onto its own line
267
267
  justify-content: space-between;
268
268
  align-items: flex-end;
269
+ // No effect on a single line (the common case), only between wrapped lines — covers both
270
+ // a too-wide .im-o-app__bottom-right dropping below the logo/bottom-left column, and the
271
+ // stacked attributions row below that (see its own comment further down: this same gap
272
+ // replaces what used to be its one-off margin-top, so a doubly-wrapped bottom stays evenly
273
+ // spaced rather than gapping only at the line the margin happened to be declared on).
274
+ row-gap: var(--divider-gap);
269
275
  z-index: -2; // Support masking the viewport
270
276
  }
271
277
 
@@ -323,7 +329,9 @@
323
329
  // (being bottom-pinned) pushes the logo and bottom-right buttons up rather than squeezing
324
330
  // the text into an ever-narrower column. The oversized flex-basis plus negative
325
331
  // right/bottom/left margin bleeds that row flush to the true edges the same way the docked
326
- // box's absolute offsets do above.
332
+ // box's absolute offsets do above; the gap above it comes from .im-o-app__bottom's own
333
+ // row-gap (not a margin-top here), so it stays exactly one gap even if .im-o-app__bottom-right
334
+ // has also wrapped onto a line of its own above it.
327
335
 
328
336
  .im-o-app__attributions:not(:empty) {
329
337
  position: absolute;
@@ -337,7 +345,7 @@
337
345
  .im-o-app__attributions--stacked:not(:empty) {
338
346
  position: static;
339
347
  flex-basis: calc(100% + var(--primary-gap) * 2);
340
- margin: var(--divider-gap) calc(var(--primary-gap) * -1) calc(var(--primary-gap) * -1) calc(var(--primary-gap) * -1);
348
+ margin: 0 calc(var(--primary-gap) * -1) calc(var(--primary-gap) * -1);
341
349
  }
342
350
 
343
351
  // ---------------------------------------------------