@defra/interactive-map 0.0.42-fmp-5 → 0.0.43-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 (86) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/esm/im-shell.js +1 -1
  4. package/dist/umd/im-core.js +1 -1
  5. package/dist/umd/index.js +1 -1
  6. package/package.json +1 -1
  7. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  8. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  9. package/plugins/datasets/src/registry/dataset.js +8 -12
  10. package/plugins/datasets/src/registry/dataset.test.js +17 -0
  11. package/plugins/datasets/src/registry/datasetRegistry.js +4 -5
  12. package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -1
  13. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
  14. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
  15. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -1
  16. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
  17. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  18. package/plugins/draw/src/DrawInit.jsx +23 -13
  19. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +15 -0
  20. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +18 -0
  21. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +9 -0
  22. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +12 -1
  23. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +18 -0
  24. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  25. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  26. package/plugins/interact/dist/umd/index.js +1 -1
  27. package/plugins/interact/src/hooks/useCrossHairVisibility.js +8 -3
  28. package/plugins/interact/src/hooks/useMapItemList.js +41 -12
  29. package/plugins/interact/src/hooks/useMapItemList.test.js +68 -3
  30. package/plugins/interact/src/utils/spatial.js +24 -1
  31. package/plugins/interact/src/utils/spatial.test.js +26 -1
  32. package/plugins/map-key/dist/css/index.css +1 -1
  33. package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -1
  34. package/plugins/map-key/dist/umd/im-map-key-plugin.js +1 -1
  35. package/plugins/map-key/dist/umd/index.js +1 -1
  36. package/plugins/map-key/src/components/Key/Key.jsx +3 -4
  37. package/plugins/map-key/src/components/Key/Key.module.scss +10 -8
  38. package/plugins/map-key/src/components/Key/KeyGroupItem.test.jsx +2 -2
  39. package/plugins/map-key/src/components/Key/KeyItem.jsx +2 -2
  40. package/plugins/map-key/src/components/Key/KeySvg.jsx +9 -7
  41. package/plugins/map-key/src/components/Key/KeySvg.test.jsx +4 -4
  42. package/plugins/map-key/src/components/Key/MapKey.jsx +5 -3
  43. package/plugins/map-key/src/manifest.js +1 -1
  44. package/plugins/map-key/src/utils/groupStyles.js +7 -0
  45. package/plugins/map-key/src/utils/mergeKeyGroupItems.js +12 -0
  46. package/plugins/map-key/src/utils/mergeKeyGroupItems.test.js +53 -0
  47. package/plugins/menu/dist/esm/im-menu-plugin.js +1 -1
  48. package/plugins/menu/dist/umd/im-menu-plugin.js +1 -1
  49. package/plugins/menu/dist/umd/index.js +1 -1
  50. package/plugins/menu/src/initialise/MenuInit.jsx +15 -3
  51. package/plugins/menu/src/initialise/MenuInit.test.jsx +25 -0
  52. package/plugins/menu/src/initialise/createMenu.js +3 -1
  53. package/plugins/menu/src/initialise/createMenu.test.js +20 -5
  54. package/plugins/menu/src/initialise/updateUrl.js +44 -0
  55. package/plugins/menu/src/initialise/updateUrl.test.js +91 -0
  56. package/plugins/menu/src/reducers/menuStateReducer.js +19 -1
  57. package/plugins/menu/src/reducers/menuStateReducer.test.js +53 -20
  58. package/plugins/menu/src/reducers/pluginState.js +1 -6
  59. package/plugins/menu/src/reducers/pluginState.test.js +0 -25
  60. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  61. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  62. package/providers/beta/openlayers/src/openlayersProvider.js +3 -3
  63. package/providers/beta/openlayers/src/openlayersProvider.test.js +6 -3
  64. package/providers/beta/openlayers/src/utils/queryFeatures.js +72 -0
  65. package/providers/beta/openlayers/src/utils/queryFeatures.test.js +122 -1
  66. package/src/App/components/CrossHair/CrossHair.jsx +19 -11
  67. package/src/App/components/CrossHair/CrossHair.module.scss +33 -12
  68. package/src/App/components/CrossHair/CrossHair.test.jsx +35 -11
  69. package/src/App/components/Markers/SymbolMarker.jsx +2 -3
  70. package/src/App/components/Markers/SymbolMarker.test.jsx +8 -9
  71. package/src/App/components/Panel/Panel.jsx +9 -1
  72. package/src/App/components/Panel/Panel.test.jsx +26 -4
  73. package/src/App/components/Viewport/Features.jsx +18 -4
  74. package/src/App/components/Viewport/Features.module.scss +4 -4
  75. package/src/App/components/Viewport/Features.test.jsx +155 -11
  76. package/src/App/components/Viewport/Viewport.jsx +3 -3
  77. package/src/App/hooks/useCrossHairAPI.js +29 -1
  78. package/src/App/hooks/useCrossHairAPI.test.js +17 -1
  79. package/src/App/hooks/useFeatureFocus.js +86 -28
  80. package/src/App/hooks/useFeatureFocus.test.js +167 -0
  81. package/src/App/hooks/useFeatureItems.js +1 -1
  82. package/src/App/hooks/useInterfaceAPI.js +2 -1
  83. package/src/App/hooks/useInterfaceAPI.test.js +9 -0
  84. package/src/InteractiveMap/InteractiveMap.js +13 -2
  85. package/src/InteractiveMap/InteractiveMap.test.js +33 -0
  86. package/src/config/appConfig.js +2 -2
@@ -154,6 +154,14 @@ describe('useFeatureFocus — onBlur', () => {
154
154
  const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS }))
155
155
  expect(() => act(() => result.current.onBlur())).not.toThrow()
156
156
  })
157
+
158
+ // The "moving between sibling options shouldn't count as a real blur/focus" behavior is
159
+ // driven by an internal isInternalFocusMoveRef, set only while focusOption()'s own .focus()
160
+ // call is synchronously in flight — not observable by calling onFocus/onBlur directly here,
161
+ // since that requires a real DOM focus transition bubbling through React. See the
162
+ // "Features + useFeatureFocus — roving tabindex real focus events" suite in Features.test.jsx,
163
+ // which renders the real markup and is the only level this can be faithfully exercised at
164
+ // (and is where the regression this guards against was actually found).
157
165
  })
158
166
 
159
167
  // ─── useFeatureFocus — keydown listener lifecycle ────────────────────────────
@@ -348,6 +356,70 @@ describe('useFeatureFocus — ArrowUp navigation', () => {
348
356
  })
349
357
  })
350
358
 
359
+ // ─── useFeatureFocus — Home/End navigation ───────────────────────────────────
360
+
361
+ describe('useFeatureFocus — Home/End navigation', () => {
362
+ const setup = () => {
363
+ const refs = makeRefs()
364
+ const el = refs.featuresRef.current
365
+ document.body.appendChild(el)
366
+ const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
367
+ return { result, el, unmount }
368
+ }
369
+
370
+ it('jumps to the first item on Home from anywhere in the list', () => {
371
+ const { result, el, unmount } = setup()
372
+ act(() => result.current.onFocus())
373
+ fireKey(el, 'ArrowDown')
374
+ fireKey(el, 'Home')
375
+ expect(result.current.activeFeatureId).toBe('a')
376
+ unmount(); el.remove()
377
+ })
378
+
379
+ it('jumps to the last item on End from anywhere in the list', () => {
380
+ const { result, el, unmount } = setup()
381
+ act(() => result.current.onFocus())
382
+ fireKey(el, 'End')
383
+ expect(result.current.activeFeatureId).toBe('c')
384
+ unmount(); el.remove()
385
+ })
386
+
387
+ it('does nothing when items is empty', () => {
388
+ const refs = makeRefs()
389
+ const el = refs.featuresRef.current
390
+ document.body.appendChild(el)
391
+ const { result, unmount } = renderHook(() => useFeatureFocus(refs))
392
+ fireKey(el, 'Home')
393
+ fireKey(el, 'End')
394
+ expect(result.current.activeFeatureId).toBeNull()
395
+ unmount(); el.remove()
396
+ })
397
+
398
+ it('stops propagation on Home/End so the viewport keyboard handler does not also handle it', () => {
399
+ const { el, unmount } = setup()
400
+ for (const key of ['Home', 'End']) {
401
+ const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
402
+ const stopSpy = jest.spyOn(event, 'stopPropagation')
403
+ act(() => el.dispatchEvent(event))
404
+ expect(stopSpy).toHaveBeenCalled()
405
+ }
406
+ unmount()
407
+ })
408
+
409
+ it('emits map:setactivefeature with the boundary id', () => {
410
+ const eb = makeEventBus()
411
+ const refs = makeRefs()
412
+ const el = refs.featuresRef.current
413
+ document.body.appendChild(el)
414
+ const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS, eventBus: eb }))
415
+ act(() => result.current.onFocus())
416
+ eb.emit.mockClear()
417
+ fireKey(el, 'End')
418
+ expect(eb.emit).toHaveBeenCalledWith(SET_ACTIVE, { id: 'c' })
419
+ unmount(); el.remove()
420
+ })
421
+ })
422
+
351
423
  // ─── useFeatureFocus — eventBus integration ───────────────────────────────────
352
424
 
353
425
  describe('useFeatureFocus — eventBus: map:setactivefeature listener', () => {
@@ -633,3 +705,98 @@ describe('useFeatureFocus — items change while focused', () => {
633
705
  unmount()
634
706
  })
635
707
  })
708
+
709
+ // ─── useFeatureFocus — tabbableId (roving tabindex resting position) ─────────
710
+
711
+ describe('useFeatureFocus — tabbableId', () => {
712
+ it('is null when items is empty', () => {
713
+ const { result } = renderHook(() => useFeatureFocus(makeRefs()))
714
+ expect(result.current.tabbableId).toBeNull()
715
+ })
716
+
717
+ it('defaults to the first item before the list has ever been focused', () => {
718
+ const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS }))
719
+ expect(result.current.tabbableId).toBe('a')
720
+ })
721
+
722
+ // Unlike onFocus's own resolution, tabbableId deliberately ignores selection — "prefer the
723
+ // selected item" is a real-Tab-in-only concern. If tabbableId also preferred it, selecting a
724
+ // single marker by clicking it on the map (which never touches lastActiveIdRef) would relocate
725
+ // tabIndex to match it on every click.
726
+ it('is not influenced by a selection change before any position is established', () => {
727
+ const eb = makeEventBus()
728
+ const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
729
+ act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'b', layerId: 'roads' }] }))
730
+ expect(result.current.tabbableId).toBe('a')
731
+ })
732
+
733
+ it('does not chase a single item toggled selected/deselected purely via mouse clicks, with no keyboard interaction at all', () => {
734
+ const eb = makeEventBus()
735
+ const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
736
+ act(() => eb.trigger(SELECTION_CHANGE, { selectedMarkers: ['a'] }))
737
+ expect(result.current.tabbableId).toBe('a')
738
+ act(() => eb.trigger(SELECTION_CHANGE, { selectedMarkers: ['b'] })) // clicked a different marker
739
+ expect(result.current.tabbableId).toBe('a') // still the structural first item — never moved
740
+ act(() => eb.trigger(SELECTION_CHANGE, { selectedMarkers: ['c'] }))
741
+ expect(result.current.tabbableId).toBe('a')
742
+ })
743
+
744
+ it('stays on the established position when a selection change arrives from elsewhere (e.g. clicking a marker on the map)', () => {
745
+ const eb = makeEventBus()
746
+ const refs = makeRefs()
747
+ const el = refs.featuresRef.current
748
+ document.body.appendChild(el)
749
+ const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS, eventBus: eb }))
750
+ act(() => result.current.onFocus()) // establishes position at 'a'
751
+ fireKey(el, 'ArrowDown') // establishes position at 'b'
752
+ act(() => result.current.onBlur())
753
+ // A selection made outside the list (e.g. a mouse click on a map marker) never touches
754
+ // lastActiveIdRef — it only changes selectedIds via interact:selectionchange.
755
+ act(() => eb.trigger(SELECTION_CHANGE, { selectedMarkers: ['c'] }))
756
+ expect(result.current.tabbableId).toBe('b') // stays put — does not jump to the newly-selected 'c'
757
+ unmount(); el.remove()
758
+ })
759
+ })
760
+
761
+ // ─── useFeatureFocus — selectItem (click / touch / voice-control activation) ─
762
+
763
+ describe('useFeatureFocus — selectItem', () => {
764
+ afterEach(() => { document.body.innerHTML = '' })
765
+
766
+ it('sets activeFeatureId to the clicked item', () => {
767
+ const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS }))
768
+ act(() => result.current.selectItem('b'))
769
+ expect(result.current.activeFeatureId).toBe('b')
770
+ })
771
+
772
+ it('emits map:setactivefeature then map:selectfeature for the clicked item', () => {
773
+ const eb = makeEventBus()
774
+ const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
775
+ act(() => result.current.selectItem('b'))
776
+ expect(eb.emit).toHaveBeenNthCalledWith(1, SET_ACTIVE, { id: 'b' })
777
+ expect(eb.emit).toHaveBeenNthCalledWith(2, SELECT)
778
+ })
779
+
780
+ it('moves real DOM focus to the clicked option', () => {
781
+ const refs = makeRefs()
782
+ const option = document.createElement('li')
783
+ option.dataset.id = 'b'
784
+ option.focus = jest.fn()
785
+ refs.featuresRef.current.appendChild(option)
786
+ const { result } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
787
+ act(() => result.current.selectItem('b'))
788
+ expect(option.focus).toHaveBeenCalledWith({ preventScroll: true })
789
+ })
790
+
791
+ it('does not throw when eventBus is not provided', () => {
792
+ const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS }))
793
+ expect(() => act(() => result.current.selectItem('b'))).not.toThrow()
794
+ })
795
+
796
+ it('does not throw when featuresRef.current is null (e.g. unmounted)', () => {
797
+ const refs = { ...makeRefs(), featuresRef: { current: null } }
798
+ const { result } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
799
+ expect(() => act(() => result.current.selectItem('b'))).not.toThrow()
800
+ expect(result.current.activeFeatureId).toBe('b') // state still updates even though there's no element to focus
801
+ })
802
+ })
@@ -9,7 +9,7 @@ const SET_FEATURES_SUPPRESSED = 'map:setfeaturessuppressed'
9
9
  * actually visible — which is what drives <Features>'s own tabIndex/aria-hidden.
10
10
  *
11
11
  * @param {object} eventBus
12
- * @returns {{ items: Array<{ id: string, label: string }>, multiselectable: boolean }}
12
+ * @returns {{ items: Array<{ id: string, label: string, x?: number, y?: number }>, multiselectable: boolean }}
13
13
  */
14
14
  export function useFeatureItems (eventBus) {
15
15
  const [items, setItems] = useState([])
@@ -58,7 +58,8 @@ export const useInterfaceAPI = () => {
58
58
  const handleAppClosed = () => dispatchRef.current({ type: 'TOGGLE_APP_VISIBLE', payload: false })
59
59
  const handleAddPanel = ({ id, config }) => dispatchRef.current({ type: 'ADD_PANEL', payload: { id, config } })
60
60
  const handleRemovePanel = (id) => dispatchRef.current({ type: 'REMOVE_PANEL', payload: id })
61
- const handleShowPanel = ({ id, focus = true }) => dispatchRef.current({ type: 'OPEN_PANEL', payload: { panelId: id, focusOnOpen: focus } })
61
+ const handleShowPanel = ({ id, focus = true, triggeringElement }) =>
62
+ dispatchRef.current({ type: 'OPEN_PANEL', payload: { panelId: id, focusOnOpen: focus, props: triggeringElement ? { triggeringElement } : undefined } })
62
63
  const handleHidePanel = (id) => dispatchRef.current({ type: 'CLOSE_PANEL', payload: id })
63
64
  const handleAddControl = ({ id, config }) => dispatchRef.current({ type: 'ADD_CONTROL', payload: { id, config } })
64
65
 
@@ -92,6 +92,15 @@ describe('useInterfaceAPI', () => {
92
92
  expect(mockDispatch).toHaveBeenCalledWith({ type: 'OPEN_PANEL', payload: { panelId: 'panel1', focusOnOpen: false } })
93
93
  })
94
94
 
95
+ it('dispatches OPEN_PANEL with props.triggeringElement when one is given, so Panel can return focus to it on close', () => {
96
+ const triggeringElement = document.createElement('button')
97
+ renderHook(() => useInterfaceAPI())
98
+ act(() => mockEventBus.emit('app:showpanel', { id: 'panel1', triggeringElement }))
99
+ expect(mockDispatch).toHaveBeenCalledWith({
100
+ type: 'OPEN_PANEL', payload: { panelId: 'panel1', focusOnOpen: true, props: { triggeringElement } }
101
+ })
102
+ })
103
+
95
104
  it('dispatches CLOSE_PANEL on app:hidepanel', () => {
96
105
  renderHook(() => useInterfaceAPI())
97
106
  act(() => mockEventBus.emit('app:hidepanel', 'panel1'))
@@ -461,12 +461,23 @@ export default class InteractiveMap {
461
461
  * Focus is moved to the panel by default. Set `focus: false` in options to
462
462
  * suppress this — useful when showing a panel and you want focus to remain on the button.
463
463
  *
464
+ * When the panel is later closed, focus returns to `triggeringElement` if one is given or
465
+ * can be inferred, falling back to the map viewport otherwise. If `triggeringElement` isn't
466
+ * passed explicitly, whatever currently has real DOM focus is captured automatically (e.g. a
467
+ * selected item in the map features list) — this only needs to be passed explicitly when the
468
+ * panel is opened from something that doesn't hold focus at the time (e.g a timer, or a
469
+ * genuine mouse click, which doesn't reliably focus its target in every browser).
470
+ *
464
471
  * @param {string} id - Panel identifier to show.
465
472
  * @param {object} [options]
466
473
  * @param {boolean} [options.focus=true] - Whether to move focus to the panel.
474
+ * @param {HTMLElement} [options.triggeringElement] - Element to return focus to on close.
475
+ * Defaults to the currently focused element, if any.
467
476
  */
468
- showPanel (id, { focus = true } = {}) {
469
- this.eventBus.emit(events.APP_SHOW_PANEL, { id, focus })
477
+ showPanel (id, { focus = true, triggeringElement } = {}) {
478
+ const resolvedTriggeringElement = triggeringElement ??
479
+ (document.activeElement && document.activeElement !== document.body ? document.activeElement : undefined)
480
+ this.eventBus.emit(events.APP_SHOW_PANEL, { id, focus, triggeringElement: resolvedTriggeringElement })
470
481
  }
471
482
 
472
483
  /**
@@ -580,6 +580,39 @@ describe('InteractiveMap — Public API Methods', () => {
580
580
  expect(map.eventBus.emit).toHaveBeenCalledWith('app:togglebuttonstate', { id: 'btn-1', prop: 'disabled', value: true })
581
581
  })
582
582
 
583
+ describe('showPanel — triggeringElement', () => {
584
+ afterEach(() => { document.body.innerHTML = '' })
585
+
586
+ it('passes an explicit triggeringElement through unchanged', () => {
587
+ const el = document.createElement('button')
588
+ map.showPanel('panel1', { triggeringElement: el })
589
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:showpanel', { id: 'panel1', focus: true, triggeringElement: el })
590
+ })
591
+
592
+ it('auto-captures the currently focused element when none is given', () => {
593
+ const el = document.createElement('button')
594
+ document.body.appendChild(el)
595
+ el.focus()
596
+ map.showPanel('panel1')
597
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:showpanel', { id: 'panel1', focus: true, triggeringElement: el })
598
+ })
599
+
600
+ it('does not fall back to document.body as a triggeringElement', () => {
601
+ document.body.focus?.() // no-op in jsdom, but document.activeElement defaults to body regardless
602
+ map.showPanel('panel1')
603
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:showpanel', { id: 'panel1', focus: true, triggeringElement: undefined })
604
+ })
605
+
606
+ it('an explicit triggeringElement takes priority over the currently focused element', () => {
607
+ const focused = document.createElement('button')
608
+ document.body.appendChild(focused)
609
+ focused.focus()
610
+ const explicit = document.createElement('button')
611
+ map.showPanel('panel1', { triggeringElement: explicit })
612
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:showpanel', { id: 'panel1', focus: true, triggeringElement: explicit })
613
+ })
614
+ })
615
+
583
616
  it('setContinueEnabled enables and disables the journey continue button', () => {
584
617
  map.setContinueEnabled(true)
585
618
  expect(map.eventBus.emit).toHaveBeenCalledWith('app:togglebuttonstate', { id: 'journeyContinue', prop: 'disabled', value: false })
@@ -98,7 +98,7 @@ export const defaultAppConfig = {
98
98
  desktop: buttonSlots
99
99
  }, {
100
100
  id: 'moveControls',
101
- label: 'Move and zoom controls',
101
+ label: 'Map controls',
102
102
  iconId: 'move',
103
103
  keepFocus: true,
104
104
  isExpanded: false,
@@ -157,7 +157,7 @@ export const defaultAppConfig = {
157
157
  svgContent: '<path d="m6 9 6 6 6-6"/>'
158
158
  }, {
159
159
  id: 'move',
160
- svgContent: '<path d="M12 2v20"/><path d="m15 19-3 3-3-3"/><path d="m19 9 3 3-3 3"/><path d="M2 12h20"/><path d="m5 9-3 3 3 3"/><path d="m9 5 3-3 3 3"/>'
160
+ svgContent: '<path d="M11.146 15.854a1.207 1.207 0 0 1 1.708 0l1.56 1.56A2 2 0 0 1 15 18.828V21a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1v-2.172a2 2 0 0 1 .586-1.414z"/><path d="M18.828 15a2 2 0 0 1-1.414-.586l-1.56-1.56a1.207 1.207 0 0 1 0-1.708l1.56-1.56A2 2 0 0 1 18.828 9H21a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1z"/><path d="M6.586 14.414A2 2 0 0 1 5.172 15H3a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h2.172a2 2 0 0 1 1.414.586l1.56 1.56a1.207 1.207 0 0 1 0 1.708z"/><path d="M9 3a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2.172a2 2 0 0 1-.586 1.414l-1.56 1.56a1.207 1.207 0 0 1-1.708 0l-1.56-1.56A2 2 0 0 1 9 5.172z"/>'
161
161
  }, {
162
162
  id: 'precision',
163
163
  svgContent: '<circle cx="12" cy="12" r="10"/><line x1="22" x2="18" y1="12" y2="12"/><line x1="6" x2="2" y1="12" y2="12"/><line x1="12" x2="12" y1="6" y2="2"/><line x1="12" x2="12" y1="22" y2="18"/>'