diff --git a/.vscode/settings.json b/.vscode/settings.json index 57e0785d..1e681853 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { "sonarlint.connectedMode.project": { "connectionId": "defra", - "projectKey": "DEFRA_defra-map" + "projectKey": "DEFRA_interactive-map" } } \ No newline at end of file diff --git a/demo/forms.html b/demo/draw.html old mode 100755 new mode 100644 similarity index 77% rename from demo/forms.html rename to demo/draw.html index 74827474..b0c67901 --- a/demo/forms.html +++ b/demo/draw.html @@ -9,10 +9,8 @@ -

Test

+

Draw tools demo

-

Another map

-
- + \ No newline at end of file diff --git a/demo/js/draw.js b/demo/js/draw.js new file mode 100755 index 00000000..c176c9ea --- /dev/null +++ b/demo/js/draw.js @@ -0,0 +1,313 @@ +// InteractiveMap +import InteractiveMap from '../../src/index.js' +import { openMapStyles, vtsMapStyles3857 } from './mapStyles.js' +import { parcelSearch, gridRefSearchETRS89 } from './searchCustomDatasets.js' +import { transformGeocodeRequest, transformTileRequest, transformDataRequest } from './auth.js' +// Providers +import maplibreProvider from '/providers/maplibre/src/index.js' +import openNamesProvider from '/providers/beta/open-names/src/index.js' +// Plugins +import mapStylesPlugin from '/plugins/beta/map-styles/src/index.js' +import createDatasetsPlugin from '/plugins/beta/datasets/src/index.js' +import { maplibreLayerAdapter } from '/plugins/beta/datasets/src/adapters/maplibre/index.js' +import createDrawPlugin from '/plugins/beta/draw-ml/src/index.js' +import scaleBarPlugin from '/plugins/beta/scale-bar/src/index.js' +import searchPlugin from '/plugins/search/src/index.js' +import createInteractPlugin from '/plugins/interact/src/index.js' + +const pointData = {type: 'FeatureCollection','features': [{'type': 'Feature','properties': {},'geometry': {'coordinates': [-2.882445487962059,54.70938250564518],'type': 'Point'}},{'type': 'Feature','properties': {},'geometry': {'coordinates': [-2.8775970686837695,54.70966586215056],'type': 'Point'}},{'type': 'Feature','properties': {},'geometry': {'coordinates': [-2.8732152153681056,54.70892223300439],'type': 'Point'}}]} + +const interactPlugin = createInteractPlugin({ + dataLayers: [{ + layerId: 'field-parcels', + // idProperty: 'gid' + },{ + layerId: 'linked-parcels', + // idProperty: 'gid' + },{ + layerId: 'OS/TopographicArea_1/Agricultural Land', + idProperty: 'TOID' + },{ + layerId: 'fill-inactive.cold', + idProperty: 'id' + },{ + layerId: 'stroke-inactive.cold', + idProperty: 'id' + }], + debug: true, + interactionMode: 'select', // 'auto', 'select', 'marker' // defaults to 'marker' + multiSelect: true, + contiguous: true, + deselectOnClickOutside: true +}) + +const drawPlugin = createDrawPlugin({ + snapLayers: ['OS/TopographicArea_1/Agricultural Land', 'OS/TopographicLine/Building Outline'] +}) + +const datasetsPlugin = createDatasetsPlugin({ + layerAdapter: maplibreLayerAdapter, + + // Example: Dynamic bbox-based fetching (uncomment to test) + datasets: [{ + id: 'field-parcels', + label: 'Field parcels', + geojson: `${process.env.FARMING_API_URL}/api/collections/parcels/items?sbi=106325052`, // 106200212 + // 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', + featureLayer: '', + vectorTileLayer: '', + // idProperty: 'id', // Enables dynamic fetching + deduplication + // filter: ['get', ['propertyName', 'warning']], + query: {}, + transformRequest: transformDataRequest, // Builds URL with bbox + maxFeatures: 50000, // Optional: evict distant features when exceeded + minZoom: 10, + maxZoom: 24, + // 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 + }] +}) + +const interactiveMap = new InteractiveMap('map', { + behaviour: 'hybrid', + mapProvider: maplibreProvider(), + reverseGeocodeProvider: openNamesProvider({ + url: process.env.OS_NEAREST_URL, + // url: '/api/os-nearest-proxy?query={query}', + transformRequest: transformGeocodeRequest + // showMarker: true + }), + // maxMobileWidth: 700, + // minDesktopWidth: 960, + mapLabel: 'Map showing Carlisle', + // zoom: 14, + minZoom: 6, + maxZoom: 20, + autoColorScheme: true, + // center: [-2.938769, 54.893806], + bounds: [-2.989707, 54.864555, -2.878635, 54.937635], + containerHeight: '650px', + transformRequest: transformTileRequest, + enableZoomControls: true, + readMapText: true, + // enableFullscreen: true, + // hasExitButton: true, + // markers: [{ + // id: 'location', + // coords: [-2.9592267, 54.9045977], + // color: { outdoor: '#ff0000', dark: '#00ff00' } + // }], + mapStyle: { + url: process.env.OUTDOOR_URL, + logo: '/assets/images/os-logo.svg', + logoAltText: 'Ordnance survey logo', + attribution: `Contains OS data ${String.fromCharCode(169)} Crown copyright and database rights ${(new Date()).getFullYear()}`, + backgroundColor: '#f5f5f0' + }, + plugins: [ + datasetsPlugin, + mapStylesPlugin({ + mapStyles: vtsMapStyles3857 + }), + // scaleBarPlugin({ + // units: 'metric' + // }), + searchPlugin({ + transformRequest: transformGeocodeRequest, + osNamesURL: process.env.OS_NAMES_URL, + customDatasets: [parcelSearch, gridRefSearchETRS89], + width: '300px', + showMarker: false, + // expanded: true + }), + interactPlugin, + drawPlugin + ] + // search +}) + +interactiveMap.on('app:ready', function (e) { + // console.log('app:ready') +}) + +interactiveMap.on('map:ready', function (e) { + // framePlugin.addFrame('test', { + // aspectRatio: 1 + // }) + interactPlugin.enable() + interactiveMap.addButton('geometryActions', { + label: 'Draw tools', + mobile: { slot: 'bottom-right', order: 3 }, + tablet: { slot: 'top-middle', order: 3 }, + desktop: { slot: 'top-middle', order: 3 }, + menuItems: [{ + id: 'drawPolygon', + label: 'Draw polygon', + iconSvgContent: '', + onClick: function (e) { + interactiveMap.toggleButtonState('geometryActions', 'hidden', true) + drawPlugin.newPolygon(crypto.randomUUID(), { + stroke: '#e6c700', + fill: 'rgba(255, 221, 0, 0.1)' + }) + } + },{ + id: 'drawLine', + label: 'Draw line', + iconSvgContent: '', + onClick: function (e) { + interactiveMap.toggleButtonState('geometryActions', 'hidden', true) + drawPlugin.newLine(crypto.randomUUID(), { + stroke: { outdoor: '#99704a', dark: '#ffffff' }, + strokeWidth: 6 + }) + } + },{ + id: 'editFeature', + label: 'Edit feature', + iconSvgContent: '', + isDisabled: true, + onClick: function (e) { + const editSuccess = drawPlugin.editFeature(selectedFeatureIds[0]) + if (!editSuccess) { + return + } + interactiveMap.toggleButtonState('geometryActions', 'hidden', true) + interactPlugin.disable() + } + },{ + id: 'deleteFeature', + label: 'Delete feature', + iconSvgContent: '', + isDisabled: true, + onClick: function (e) { + interactiveMap.toggleButtonState('geometryActions', 'hidden', false) + drawPlugin.deleteFeature(selectedFeatureIds) + interactPlugin.clear() + interactiveMap.toggleButtonState('drawPolygon', 'disabled', false) + interactiveMap.toggleButtonState('drawLine', 'disabled', false) + interactiveMap.toggleButtonState('editFeature', 'disabled', true) + interactiveMap.toggleButtonState('deleteFeature', 'disabled', true) + } + }] + }) +}) + +interactiveMap.on('datasets:ready', function () { + // setTimeout(() => datasetsPlugin.hideDataset('field-parcels'), 2000) + // setTimeout(() => datasetsPlugin.showDataset('field-parcels'), 4000) +}) + +// Ref to the selected features +let selectedFeatureIds = [] + +interactiveMap.on('draw:ready', function () { + drawPlugin.addFeature({ + id: 'test1234', + type: 'Feature', + geometry: {'type':'Polygon','coordinates':[[[-2.8792962,54.7095463],[-2.8773445,54.7089363],[-2.8755615,54.7080257],[-2.8750521,54.7079797],[-2.8740651,54.7079522],[-2.8734760,54.7086512],[-2.8739855,54.7091846],[-2.8748292,54.7098284],[-2.8752749,54.7103526],[-2.8762460,54.7104170],[-2.8765803,54.7103342],[-2.8783315,54.7105366],[-2.8784429,54.7101319],[-2.8786499,54.7099571],[-2.8791275,54.7099112],[-2.8792962,54.7095463]],[[-2.8779654,54.7097916],[-2.8768886,54.7094843],[-2.8758538,54.7094200],[-2.8754081,54.7096223],[-2.8754559,54.7099442],[-2.8756947,54.7102201],[-2.8761404,54.7102569],[-2.8767236,54.7101963],[-2.8774559,54.7102606],[-2.8778698,54.7101135],[-2.8779654,54.7097916]]]}, + // geometry: { type: 'Polygon', coordinates: [[[-2.9406643378873127,54.918060570259456],[-2.9092219779267054,54.91564249172612],[-2.904350626383433,54.90329530000005],[-2.909664828067463,54.89540129642464],[-2.9225074821353587,54.88979816151294],[-2.937121536764323,54.88826989853317],[-2.95682836800691,54.88916139231736],[-2.965463945742613,54.898966521920045],[-2.966349646023133,54.910805898763385],[-2.9406643378873127,54.918060570259456]]] }, + stroke: 'rgba(0,112,60,1)', + fill: 'rgba(0,112,60,0.2)', + strokeWidth: 2 + }) + // drawPlugin.split('test1234', { + // snapLayers: ['OS/TopographicArea_1/Agricultural Land'] + // }) + // drawPlugin.newPolygon('test', { + // snapLayers: ['OS/TopographicArea_1/Agricultural Land'] + // }) + // drawPlugin.editFeature('test1234', { + // snapLayers: ['OS/TopographicArea_1/Agricultural Land'] + // }) +}) + +interactiveMap.on('draw:started', function (e) { + console.log('draw:started') + interactPlugin.disable() +}) + +interactiveMap.on('draw:created', function (e) { + console.log('draw:created', e) + interactiveMap.toggleButtonState('geometryActions', 'hidden', false) + interactPlugin.enable() +}) + +interactiveMap.on('draw:updated', function (e) { + console.log('draw:updated', e) +}) + +interactiveMap.on('draw:edited', function (e) { + console.log('draw:edited', e) // Should be editcomplete + interactiveMap.toggleButtonState('geometryActions', 'hidden', false) + interactPlugin.enable() +}) + +interactiveMap.on('draw:cancelled', function (e) { + console.log('draw:cancelled', e) + interactiveMap.toggleButtonState('geometryActions', 'hidden', false) + interactPlugin.enable() +}) + +interactiveMap.on('interact:done', function (e) { + console.log('interact:done', e) +}) + +interactiveMap.on('interact:cancel', function (e) { + console.log('interact:cancel', e) + interactPlugin.enable() +}) + +interactiveMap.on('interact:selectionchange', function (e) { + const drawLayers = ['stroke-inactive.cold', 'fill-inactive.cold'] + const singleFeature = e.selectedFeatures.length === 1 + const anyFeature = e.selectedFeatures.length > 0 + const isDrawFeature = singleFeature && drawLayers.includes(e.selectedFeatures[0].layerId) + const allDrawFeatures = anyFeature && e.selectedFeatures.every(function (f) { return drawLayers.includes(f.layerId) }) + selectedFeatureIds = e.selectedFeatures.map(function (f) { return f.featureId }) + interactiveMap.toggleButtonState('drawPolygon', 'disabled', !!singleFeature) + interactiveMap.toggleButtonState('drawLine', 'disabled', !!singleFeature) + interactiveMap.toggleButtonState('editFeature', 'disabled', !isDrawFeature) + interactiveMap.toggleButtonState('deleteFeature', 'disabled', !allDrawFeatures) +}) + +interactiveMap.on('interact:markerchange', function (e) { + // console.log('interact:markerchange', e) +}) + +// Update selected feature +interactiveMap.on('search:match', function (e) { + if (e.type !== 'parcel') { + return + } + interactPlugin.selectFeature({ + idProperty: 'id', + featureId: e.properties.ngc, + layerId: 'linked-parcels' + }) +}) + +// Hide selected feature +interactiveMap.on('search:clear', function (e) { + // console.log('Search clear') +}) \ No newline at end of file diff --git a/demo/js/farming.js b/demo/js/farming.js index d89c3e8e..bbdbccc2 100755 --- a/demo/js/farming.js +++ b/demo/js/farming.js @@ -36,57 +36,73 @@ var datasetsPlugin = createDatasetsPlugin({ datasets: [{ id: 'field-parcels', label: 'Field parcels', - 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', - stroke: { outdoor: '#b1b4b6', dark: '#28a197', aerial: 'rgba(40,161,151,0.8)', 'black-and-white': '#28a197' }, - strokeWidth: 2, - // strokeDashArray: [1, 2], - fill: 'transparent', - symbolDescription: { outdoor: 'turquiose outline' }, + geojson: `${process.env.FARMING_API_URL}/api/collections/parcels/items?sbi=106325052`, // 106200212 + transformRequest: transformDataRequest, + maxFeatures: 50000, + // 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', + // featureLayer: '', + // vectorTileLayer: '', minZoom: 10, maxZoom: 24, showInKey: true, - toggleVisibility: true - },{ - id: 'linked-parcels', - label: 'Existing fields', - 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', - stroke: '#0000ff', + 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)', - symbolDescription: { outdoor: 'blue outline' }, - minZoom: 10, - maxZoom: 24, - showInKey: true, - toggleVisibility: true - },{ - id: 'hedge-control', - label: 'Hedge control', - tiles: ['https://farming-tiles-702a60f45633.herokuapp.com/field_parcels_with_hedges/{z}/{x}/{y}'], - sourceLayer: 'hedge_control', - stroke: '#b58840', - fill: 'transparent', - strokeWidth: 4, - symbolDescription: { outdoor: 'blue outline' }, - minZoom: 10, - maxZoom: 24, - showInKey: true, - toggleVisibility: true, - visibility: 'hidden', - keySymbolShape: 'line' - }] -}) + fillPattern: 'diagonal-cross-hatch', + fillPatternForegroundColor: { outdoor: '#0000ff', dark: '#ffffff' }, + fillPatternBackgroundColor: 'transparent', + opacity: 0.5 + }, + // { + // id: 'linked-parcels', + // label: 'Existing fields', + // 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', + // stroke: '#0000ff', + // strokeWidth: 2, + // fill: 'rgba(0,0,255,0.1)', + // symbolDescription: { outdoor: 'blue outline' }, + // minZoom: 10, + // maxZoom: 24, + // showInKey: true, + // toggleVisibility: true + // }, + // { + // id: 'hedge-control', + // label: 'Hedge control', + // tiles: ['https://farming-tiles-702a60f45633.herokuapp.com/field_parcels_with_hedges/{z}/{x}/{y}'], + // sourceLayer: 'hedge_control', + // stroke: '#b58840', + // fill: 'transparent', + // strokeWidth: 4, + // symbolDescription: { outdoor: 'blue outline' }, + // minZoom: 10, + // maxZoom: 24, + // showInKey: true, + // toggleVisibility: true, + // visibility: 'hidden', + // keySymbolShape: 'line' + // } +]}) var drawPlugin = createDrawPlugin() @@ -130,9 +146,6 @@ var interactiveMap = new InteractiveMap('map', { backgroundColor: '#f5f5f0' }, plugins: [ - mapStylesPlugin({ - mapStyles: vtsMapStyles3857 - }), scaleBarPlugin({ units: 'metric' }), @@ -142,13 +155,13 @@ var interactiveMap = new InteractiveMap('map', { customDatasets: [parcelSearch, gridRefSearchETRS89], width: '300px', showMarker: false, - // manifest: { controls: [{ id: 'search', - // inline: false - // }]} // expanded: true }), // useLocationPlugin(), datasetsPlugin, + mapStylesPlugin({ + mapStyles: vtsMapStyles3857 + }), interactPlugin, // framePlugin, // drawPlugin diff --git a/demo/js/forms.js b/demo/js/forms.js deleted file mode 100755 index b05f17c1..00000000 --- a/demo/js/forms.js +++ /dev/null @@ -1,180 +0,0 @@ -import InteractiveMap from '../../src/index.js' -import { vtsMapStyles3857 } from './mapStyles.js' -import { parcelSearch, gridRefSearchETRS89 } from './searchCustomDatasets.js' -import { transformGeocodeRequest, transformTileRequest } from './auth.js' -// Providers -import maplibreProvider from '/providers/maplibre/src/index.js' -import openNamesProvider from '/providers/beta/open-names/src/index.js' -// Plugins -import useLocationPlugin from '/plugins/beta/use-location/src/index.js' -import mapStylesPlugin from '/plugins/beta/map-styles/src/index.js' -import scaleBarPlugin from '/plugins/beta/scale-bar/src/index.js' -import searchPlugin from '/plugins/search/src/index.js' -import createInteractPlugin from '/plugins/interact/src/index.js' - -var interactPlugin = createInteractPlugin({ - // dataLayers: [], - markerColor: { outdoor: '#ff0000' }, - // closeOnDone: false, - // closeOnCancel: false, - interactionMode: 'marker', // 'auto', 'select', 'marker' // defaults to 'marker' - multiSelect: false -}) - -var interactiveMap = new InteractiveMap('map', { - behaviour: 'hybrid', - mapProvider: maplibreProvider(), - reverseGeocodeProvider: openNamesProvider({ - url: process.env.OS_NEAREST_URL, - // url: '/api/os-nearest-proxy?query={query}', - transformRequest: transformGeocodeRequest - // showMarker: true - }), - // maxMobileWidth: 700, - // minDesktopWidth: 960, - mapLabel: 'Map showing Carlisle', - // zoom: 14, - minZoom: 6, - maxZoom: 20, - autoColorScheme: true, - enableZoomControls: true, - // center: [-2.938769, 54.893806], - bounds: [-2.989707, 54.864555, -2.878635, 54.937635], - containerHeight: '650px', - transformRequest: transformTileRequest, - // enableFullscreen: true, - // hasExitButton: true, - // markers: [{ - // id: 'location', - // coords: [-2.9592267, 54.9045977], - // color: { outdoor: '#ff0000', dark: '#00ff00' } - // }], - // mapStyle: { - // url: process.env.OUTDOOR_URL, - // logo: '/assets/images/os-logo.svg', - // logoAltText: 'Ordnance survey logo', - // attribution: `Contains OS data ${String.fromCharCode(169)} Crown copyright and database rights ${(new Date()).getFullYear()}`, - // backgroundColor: '#f5f5f0' - // }, - plugins: [ - mapStylesPlugin({ - mapStyles: vtsMapStyles3857 - }), - scaleBarPlugin({ - units: 'metric' - }), - searchPlugin({ - transformRequest: transformGeocodeRequest, - osNamesURL: process.env.OS_NAMES_URL, - customDatasets: [parcelSearch, gridRefSearchETRS89], - width: '300px', - showMarker: false, - // expanded: true - }), - useLocationPlugin(), - interactPlugin - ] - // search -}) - -var defraMap2 = new InteractiveMap('map2', { - behaviour: 'inline', - mapProvider: maplibreProvider(), - reverseGeocodeProvider: openNamesProvider({ - url: process.env.OS_NEAREST_URL, - // url: '/api/os-nearest-proxy?query={query}', - transformRequest: transformGeocodeRequest - // showMarker: true - }), - // maxMobileWidth: 700, - // minDesktopWidth: 960, - mapLabel: 'Map showing Carlisle', - // zoom: 14, - minZoom: 6, - maxZoom: 20, - autoColorScheme: true, - // center: [-2.938769, 54.893806], - bounds: [-2.989707, 54.864555, -2.878635, 54.937635], - containerHeight: '650px', - transformRequest: transformTileRequest, - // enableFullscreen: true, - // hasExitButton: true, - // markers: [{ - // id: 'location', - // coords: [-2.9592267, 54.9045977], - // color: { outdoor: '#ff0000', dark: '#00ff00' } - // }], - // mapStyle: { - // url: process.env.OUTDOOR_URL, - // logo: '/assets/images/os-logo.svg', - // logoAltText: 'Ordnance survey logo', - // attribution: `Contains OS data ${String.fromCharCode(169)} Crown copyright and database rights ${(new Date()).getFullYear()}`, - // backgroundColor: '#f5f5f0' - // }, - plugins: [ - mapStylesPlugin({ - mapStyles: vtsMapStyles3857 - }), - scaleBarPlugin({ - units: 'metric' - }), - // searchPlugin({ - // transformRequest: transformGeocodeRequest, - // osNamesURL: process.env.OS_NAMES_URL, - // customDatasets: searchCustomDatasets, - // width: '300px', - // showMarker: false, - // // isExpanded: true - // }), - // useLocationPlugin(), - interactPlugin - ] - // search -}) - -interactiveMap.on('map:ready', function (e) { - interactiveMap.addPanel('tooltip', { - label: 'How to use the map', - html: ` -

Help text...

- `, - mobile: { - slot: 'bottom', - }, - tablet: { - slot: 'bottom' - }, - desktop: { - slot: 'bottom' - } - }) -}) - -interactiveMap.on('draw:ready', function () { - // drawPlugin.newPolygon('test') - // drawPlugin.editFeature(featureGeoJSON) -}) - -interactiveMap.on('interact:done', function (e) { - console.log('interact:done', e) -}) - -interactiveMap.on('interact:cancel', function (e) { - console.log('interact:cancel', e) -}) - -interactiveMap.on('interact:selectionchange', function (e) { - console.log('interact:selectionchange', e) -}) - -interactiveMap.on('interact:markerchange', function (e) { - console.log('interact:markerchange', e) -}) - -interactiveMap.on('app:panelopened', function (e) { - console.log('app:panelopened', e) -}) - -interactiveMap.on('app:panelclosed', function (e) { - console.log('app:panelclosed', e) -}) \ No newline at end of file diff --git a/demo/js/index.js b/demo/js/index.js index 356fa1cb..b23d3098 100755 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -20,7 +20,7 @@ import useLocationPlugin from '/plugins/beta/use-location/src/index.js' import mapStylesPlugin from '/plugins/beta/map-styles/src/index.js' import createDatasetsPlugin from '/plugins/beta/datasets/src/index.js' import { maplibreLayerAdapter } from '/plugins/beta/datasets/src/adapters/maplibre/index.js' -import createDrawPlugin from '/plugins/beta/draw-ml/src/index.js' +// import createDrawPlugin from '/plugins/beta/draw-ml/src/index.js' import scaleBarPlugin from '/plugins/beta/scale-bar/src/index.js' import searchPlugin from '/plugins/search/src/index.js' import createInteractPlugin from '/plugins/interact/src/index.js' @@ -45,17 +45,13 @@ const interactPlugin = createInteractPlugin({ layerId: 'stroke-inactive.cold', idProperty: 'id' }], - // debug: true, + debug: true, interactionMode: 'select', // 'auto', 'select', 'marker' // defaults to 'marker' multiSelect: true, contiguous: true, deselectOnClickOutside: true }) -const drawPlugin = createDrawPlugin({ - snapLayers: ['OS/TopographicArea_1/Agricultural Land', 'OS/TopographicLine/Building Outline'] -}) - const framePlugin = createFramePlugin({ aspectRatio: 1.5 }) @@ -122,10 +118,30 @@ const datasetsPlugin = createDatasetsPlugin({ // symbolBackgroundColor: '', // symbolDescription: { outdoor: 'blue outline' }, // symbolOffset: [], - fill: 'rgba(0,0,255,0.1)', + // fill: 'rgba(0,0,255,0.1)', fillPattern: 'diagonal-cross-hatch', fillPatternForegroundColor: { outdoor: '#0000ff', dark: '#ffffff' }, fillPatternBackgroundColor: 'transparent', + featureStyleRules: [{ + 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 + },{ + id: 'other', + label: 'Others', + filter: ['!=', ['get', 'dominant_land_cover'], '332'], + stroke: { outdoor: '#0000ff', dark: '#ffffff' }, + fill: 'rgba(0,0,255,0.1)', + // fillPattern: 'cross-hatch', + // fillPatternForegroundColor: { outdoor: '#00ff00', dark: '#ffffff' }, + // fillPatternBackgroundColor: 'transparent', + toggleVisibility: true + }], opacity: 0.5 }] }) @@ -167,13 +183,13 @@ const interactiveMap = new InteractiveMap('map', { backgroundColor: '#f5f5f0' }, plugins: [ - // datasetsPlugin, + datasetsPlugin, mapStylesPlugin({ mapStyles: vtsMapStyles3857 }), - // scaleBarPlugin({ - // units: 'metric' - // }), + scaleBarPlugin({ + units: 'metric' + }), searchPlugin({ transformRequest: transformGeocodeRequest, osNamesURL: process.env.OS_NAMES_URL, @@ -184,8 +200,7 @@ const interactiveMap = new InteractiveMap('map', { }), // useLocationPlugin(), interactPlugin, - framePlugin, - drawPlugin + framePlugin ] // search }) @@ -199,63 +214,6 @@ interactiveMap.on('map:ready', function (e) { // aspectRatio: 1 // }) interactPlugin.enable() - interactiveMap.addButton('geometryActions', { - label: 'Draw tools', - iconSvgContent: '', - mobile: { slot: 'bottom-right', order: 3 }, - tablet: { slot: 'top-middle', order: 3 }, - desktop: { slot: 'top-middle', order: 3 }, - menuItems: [{ - id: 'drawPolygon', - label: 'Draw polygon', - iconSvgContent: '', - onClick: function (e) { - interactiveMap.toggleButtonState('geometryActions', 'hidden', true) - drawPlugin.newPolygon(crypto.randomUUID(), { - stroke: '#e6c700', - fill: 'rgba(255, 221, 0, 0.1)' - }) - } - },{ - id: 'drawLine', - label: 'Draw line', - iconSvgContent: '', - onClick: function (e) { - interactiveMap.toggleButtonState('geometryActions', 'hidden', true) - drawPlugin.newLine(crypto.randomUUID(), { - stroke: { outdoor: '#99704a', dark: '#ffffff' }, - strokeWidth: 6 - }) - } - },{ - id: 'editFeature', - label: 'Edit feature', - iconSvgContent: '', - isDisabled: true, - onClick: function (e) { - const editSuccess = drawPlugin.editFeature(selectedFeatureIds[0]) - if (!editSuccess) { - return - } - interactiveMap.toggleButtonState('geometryActions', 'hidden', true) - interactPlugin.disable() - } - },{ - id: 'deleteFeature', - label: 'Delete feature', - iconSvgContent: '', - isDisabled: true, - onClick: function (e) { - interactiveMap.toggleButtonState('geometryActions', 'hidden', false) - drawPlugin.deleteFeature(selectedFeatureIds) - interactPlugin.clear() - interactiveMap.toggleButtonState('drawPolygon', 'disabled', false) - interactiveMap.toggleButtonState('drawLine', 'disabled', false) - interactiveMap.toggleButtonState('editFeature', 'disabled', true) - interactiveMap.toggleButtonState('deleteFeature', 'disabled', true) - } - }] - }) }) interactiveMap.on('datasets:ready', function () { @@ -266,54 +224,6 @@ interactiveMap.on('datasets:ready', function () { // Ref to the selected features let selectedFeatureIds = [] -interactiveMap.on('draw:ready', function () { - drawPlugin.addFeature({ - id: 'test1234', - type: 'Feature', - geometry: {'type':'Polygon','coordinates':[[[-2.8792962,54.7095463],[-2.8773445,54.7089363],[-2.8755615,54.7080257],[-2.8750521,54.7079797],[-2.8740651,54.7079522],[-2.8734760,54.7086512],[-2.8739855,54.7091846],[-2.8748292,54.7098284],[-2.8752749,54.7103526],[-2.8762460,54.7104170],[-2.8765803,54.7103342],[-2.8783315,54.7105366],[-2.8784429,54.7101319],[-2.8786499,54.7099571],[-2.8791275,54.7099112],[-2.8792962,54.7095463]],[[-2.8779654,54.7097916],[-2.8768886,54.7094843],[-2.8758538,54.7094200],[-2.8754081,54.7096223],[-2.8754559,54.7099442],[-2.8756947,54.7102201],[-2.8761404,54.7102569],[-2.8767236,54.7101963],[-2.8774559,54.7102606],[-2.8778698,54.7101135],[-2.8779654,54.7097916]]]}, - // geometry: { type: 'Polygon', coordinates: [[[-2.9406643378873127,54.918060570259456],[-2.9092219779267054,54.91564249172612],[-2.904350626383433,54.90329530000005],[-2.909664828067463,54.89540129642464],[-2.9225074821353587,54.88979816151294],[-2.937121536764323,54.88826989853317],[-2.95682836800691,54.88916139231736],[-2.965463945742613,54.898966521920045],[-2.966349646023133,54.910805898763385],[-2.9406643378873127,54.918060570259456]]] }, - stroke: 'rgba(0,112,60,1)', - fill: 'rgba(0,112,60,0.2)', - strokeWidth: 2 - }) - // drawPlugin.split('test1234', { - // snapLayers: ['OS/TopographicArea_1/Agricultural Land'] - // }) - // drawPlugin.newPolygon('test', { - // snapLayers: ['OS/TopographicArea_1/Agricultural Land'] - // }) - // drawPlugin.editFeature('test1234', { - // snapLayers: ['OS/TopographicArea_1/Agricultural Land'] - // }) -}) - -interactiveMap.on('draw:started', function (e) { - console.log('draw:started') - interactPlugin.disable() -}) - -interactiveMap.on('draw:created', function (e) { - console.log('draw:created', e) - interactiveMap.toggleButtonState('geometryActions', 'hidden', false) - interactPlugin.enable() -}) - -interactiveMap.on('draw:updated', function (e) { - console.log('draw:updated', e) -}) - -interactiveMap.on('draw:edited', function (e) { - console.log('draw:edited', e) // Should be editcomplete - interactiveMap.toggleButtonState('geometryActions', 'hidden', false) - interactPlugin.enable() -}) - -interactiveMap.on('draw:cancelled', function (e) { - console.log('draw:cancelled', e) - interactiveMap.toggleButtonState('geometryActions', 'hidden', false) - interactPlugin.enable() -}) - interactiveMap.on('interact:done', function (e) { console.log('interact:done', e) }) diff --git a/demo/js/planning.js b/demo/js/planning.js index 57b9c0f2..75a4e8bd 100755 --- a/demo/js/planning.js +++ b/demo/js/planning.js @@ -134,7 +134,7 @@ interactiveMap.on('app:ready', function (e) { interactiveMap.addPanel('key', { label: 'Key', html: renderKeyHTML(), - mobile: { slot: 'bottom', open: false, exclusive: true }, + mobile: { slot: 'drawer', open: false, exclusive: true }, tablet: { slot: 'left-top', width: '260px', open: false, exclusive: true }, desktop: { slot: 'left-top', width: '280px', open: false, exclusive: true } }) diff --git a/plugins/beta/scale-bar/src/scaleBar.scss b/plugins/beta/scale-bar/src/scaleBar.scss index 9bc8daae..bcfcf31d 100755 --- a/plugins/beta/scale-bar/src/scaleBar.scss +++ b/plugins/beta/scale-bar/src/scaleBar.scss @@ -9,10 +9,9 @@ margin-left: auto; text-align: right; padding-bottom: 5px; - margin-bottom: 5px; color: var(--map-overlay-foreground-color); user-select: none; - min-height: 20px; // Required to ensure padding is calculated before scale bar is rendered + height: 25px; // Required to ensure padding is calculated before scale bar is rendered width: 100px; } diff --git a/plugins/search/src/components/Form/Form.module.scss b/plugins/search/src/components/Form/Form.module.scss index 157fb422..418a36f8 100755 --- a/plugins/search/src/components/Form/Form.module.scss +++ b/plugins/search/src/components/Form/Form.module.scss @@ -44,11 +44,12 @@ // Status .im-c-search__status { position: absolute; - top: calc(100% + 1px); + top: 100%; left: 0; right: 0; padding: var(--divider-gap) calc(var(--divider-gap) + 2px); background-color: var(--background-color); + border-top: 1px solid var(--button-hover-color); border-bottom-left-radius: var(--button-border-radius); border-bottom-right-radius: var(--button-border-radius); } diff --git a/src/App/components/Actions/Actions.module.scss b/src/App/components/Actions/Actions.module.scss index 89f18138..96a9bb4b 100755 --- a/src/App/components/Actions/Actions.module.scss +++ b/src/App/components/Actions/Actions.module.scss @@ -6,11 +6,6 @@ padding: var(--panel-margin); max-height: 200px; - @media (prefers-reduced-motion: no-preference) { - transition: max-height var(--duration) ease, opacity var(--duration) ease, - padding-top var(--duration) ease, padding-bottom var(--duration) ease; - } - &--border-top { border-top: 1px solid var(--app-border-color); } diff --git a/src/App/components/Icon/Icon.jsx b/src/App/components/Icon/Icon.jsx index f70c2483..468b3c5d 100644 --- a/src/App/components/Icon/Icon.jsx +++ b/src/App/components/Icon/Icon.jsx @@ -3,8 +3,8 @@ import { getIconRegistry } from '../../registry/iconRegistry.js' // eslint-disable-next-line camelcase, react/jsx-pascal-case // sonarjs/disable-next-line function-name -export const Icon = ({ id, svgContent }) => { - const icon = getIconRegistry()[id] || svgContent +export const Icon = ({ id, svgContent, isMenu }) => { + const icon = isMenu ? getIconRegistry().chevron : (getIconRegistry()[id] || svgContent) return ( { aria-hidden='true' focusable='false' dangerouslySetInnerHTML={{ __html: icon }} + className={`im-c-icon${isMenu ? ' im-c-icon--narrow' : ''}`} /> ) } diff --git a/src/App/components/Icon/Icon.module.scss b/src/App/components/Icon/Icon.module.scss new file mode 100644 index 00000000..0505c847 --- /dev/null +++ b/src/App/components/Icon/Icon.module.scss @@ -0,0 +1,4 @@ +.im-c-icon--narrow { + margin-left: -3px; + margin-right: -3px; +} \ No newline at end of file diff --git a/src/App/components/Icon/Icon.test.jsx b/src/App/components/Icon/Icon.test.jsx index e68e7006..9330cc74 100644 --- a/src/App/components/Icon/Icon.test.jsx +++ b/src/App/components/Icon/Icon.test.jsx @@ -33,27 +33,66 @@ describe('Icon component', () => { it('renders the SVG from the registry when id is provided', () => { getIconRegistry.mockReturnValue({ close: '' }) const { container } = render() - expect(container.querySelector('svg').innerHTML).toContain(' { getIconRegistry.mockReturnValue({}) const fallbackSVG = '' const { container } = render() - expect(container.querySelector('svg').innerHTML).toContain(' { const fallbackSVG = '' getIconRegistry.mockReturnValue({}) const { container } = render() - expect(container.querySelector('svg').innerHTML).toContain(' { getIconRegistry.mockReturnValue({ check: '' }) const fallbackSVG = '' const { container } = render() - expect(container.querySelector('svg').innerHTML).toContain(' { + getIconRegistry.mockReturnValue({ + chevron: '', + close: '' + }) + const { container } = render() + expect(container.querySelector('svg').innerHTML).toContain(' { + getIconRegistry.mockReturnValue({ + chevron: '' + }) + const fallbackSVG = '' + const { container } = render( + + ) + expect(container.querySelector('svg').innerHTML).toContain(' { + getIconRegistry.mockReturnValue({ + chevron: '' + }) + const { container } = render() + const svg = container.querySelector('svg') + expect(svg).toHaveClass('im-c-icon--narrow') + }) + + it('renders nothing if isMenu is true and chevron is missing', () => { + getIconRegistry.mockReturnValue({}) + const { container } = render() + expect(container.querySelector('svg').innerHTML).toBe('') }) }) diff --git a/src/App/components/MapButton/MapButton.jsx b/src/App/components/MapButton/MapButton.jsx index ea1cb93b..c824012c 100755 --- a/src/App/components/MapButton/MapButton.jsx +++ b/src/App/components/MapButton/MapButton.jsx @@ -46,6 +46,37 @@ const handleKeyUp = (e) => { } } +const captureMenuRect = (buttonRefs, buttonId, setMenuRect) => { + const btn = buttonRefs.current[buttonId] + if (!btn) { + return + } + setMenuRect(btn.getBoundingClientRect().toJSON()) +} + +/** + * Returns a keyup handler for buttons that control a popup menu. + * ArrowDown opens the menu at the first item; ArrowUp opens at the last. + * @param {boolean} hasMenu - Whether the button has a popup menu + * @param {Object} buttonRefs - React ref map of button elements + * @param {string} buttonId - Unique button identifier + * @param {Function} setMenuStartPos - State setter for menu start position + * @param {Function} setMenuRect - State setter for button bounding rect + * @param {Function} setIsPopupOpen - State setter for popup open state + * @returns {Function} Keyboard event handler + */ +const makePopupKeyUpHandler = (hasMenu, buttonRefs, buttonId, setMenuStartPos, setMenuRect, setIsPopupOpen) => (e) => { + if (hasMenu && ['ArrowDown', 'ArrowUp'].includes(e.key)) { + e.preventDefault() + setMenuStartPos(e.key === 'ArrowUp' ? 'last' : 'first') + captureMenuRect(buttonRefs, buttonId, setMenuRect) + setIsPopupOpen(true) + } +} + +const getButtonSlot = (panelId, buttonId) => + panelId ? `${stringToKebab(buttonId)}-button` : undefined + /** * Determines the controlled element (panel or popup menu) for ARIA attributes. * @param {Object} options - Configuration options @@ -182,16 +213,10 @@ export const MapButton = ({ const Element = href ? 'a' : 'button' const hasMenu = menuItems?.length >= 1 + const showIcon = iconId || iconSvgContent || hasMenu + const buttonSlot = getButtonSlot(panelId, buttonId) const controlledElement = getControlledElement({ idPrefix, panelId, buttonId, hasMenu }) - const captureMenuRect = () => { - const btn = buttonRefs.current[buttonId] - if (!btn) { - return - } - setMenuRect(btn.getBoundingClientRect().toJSON()) - } - /** * Handles button click events. * Toggles popup menu visibility if the button controls a popup. @@ -207,7 +232,7 @@ export const MapButton = ({ /* istanbul ignore next as pointerType can't be tested in jest */ setMenuStartPos(isKeyboard ? 'first' : null) if (!isPopupOpen) { - captureMenuRect() + captureMenuRect(buttonRefs, buttonId, setMenuRect) } setIsPopupOpen((prev) => !prev) } @@ -216,20 +241,7 @@ export const MapButton = ({ } } - /** - * Handles key up events on buttons that control popup menus. - * ArrowDown opens the menu at the first item. - * ArrowUp opens the menu at the last item. - * @param {React.KeyboardEvent} e - The keyboard event - */ - const handleButtonKeyUp = e => { - if (hasMenu && ['ArrowDown', 'ArrowUp'].includes(e.key)) { - e.preventDefault() - setMenuStartPos(e.key === 'ArrowUp' ? 'last' : 'first') - captureMenuRect() - setIsPopupOpen(true) - } - } + const handleButtonKeyUp = makePopupKeyUpHandler(hasMenu, buttonRefs, buttonId, setMenuStartPos, setMenuRect, setIsPopupOpen) const buttonProps = buildButtonProps({ appId, @@ -249,7 +261,7 @@ export const MapButton = ({ const buttonEl = ( - {(iconId || iconSvgContent) && } + {showIcon && } {showLabel && {label}} ) @@ -257,11 +269,11 @@ export const MapButton = ({ return (
{showLabel ? buttonEl : {buttonEl}} - {panelId && } + {buttonSlot && } {isPopupOpen && }
) diff --git a/src/App/components/MapButton/MapButton.test.jsx b/src/App/components/MapButton/MapButton.test.jsx index 4089a710..15a364bb 100755 --- a/src/App/components/MapButton/MapButton.test.jsx +++ b/src/App/components/MapButton/MapButton.test.jsx @@ -206,4 +206,10 @@ describe('MapButton', () => { fireEvent.keyUp(el, { key: 'Enter' }) expect(spy).not.toHaveBeenCalled() }) + + it('renders no Icon when iconId, iconSvgContent and menuItems are all absent', () => { + render() + expect(screen.queryByRole('img', { hidden: true })).toBeNull() + expect(screen.queryByTestId('icon')).toBeNull() + }) }) diff --git a/src/App/hooks/useButtonStateEvaluator.js b/src/App/hooks/useButtonStateEvaluator.js index 4ef748ef..40e8a1b5 100755 --- a/src/App/hooks/useButtonStateEvaluator.js +++ b/src/App/hooks/useButtonStateEvaluator.js @@ -1,4 +1,3 @@ -// src/core/hooks/useButtonStateEvaluator.js import { useLayoutEffect, useContext } from 'react' import { useApp } from '../store/appContext.js' import { useConfig } from '../store/configContext.js' @@ -61,6 +60,12 @@ export function useButtonStateEvaluator (evaluateProp) { } const { dispatch } = appState + let dispatchCount = 0 + + const trackingDispatch = (action) => { + dispatchCount++ + dispatch(action) + } pluginRegistry.registeredPlugins.forEach(plugin => { const buttons = (plugin?.manifest?.buttons ?? []).flatMap(b => [b, ...(b.menuItems ?? [])]) @@ -70,10 +75,15 @@ export function useButtonStateEvaluator (evaluateProp) { btn, pluginId: plugin.id, appState, - dispatch, + dispatch: trackingDispatch, evaluateProp }) ) }) + + if (dispatchCount === 0 && !appState.arePluginsEvaluated) { + // No changes and flag not yet set — all button states have settled. + dispatch({ type: 'PLUGINS_EVALUATED' }) + } }, [appState, pluginContext, evaluateProp]) } diff --git a/src/App/hooks/useButtonStateEvaluator.test.js b/src/App/hooks/useButtonStateEvaluator.test.js index 69800916..bde1170e 100644 --- a/src/App/hooks/useButtonStateEvaluator.test.js +++ b/src/App/hooks/useButtonStateEvaluator.test.js @@ -22,6 +22,7 @@ describe('useButtonStateEvaluator', () => { hiddenButtons: new Set(), pressedButtons: new Set(), expandedButtons: new Set(), + arePluginsEvaluated: true, // stable by default; override in settlement tests dispatch: mockDispatch } useApp.mockReturnValue(mockAppState) @@ -149,8 +150,6 @@ describe('useButtonStateEvaluator', () => { }) it('covers fallback to empty array when manifest or buttons is missing', () => { - // Branch 1: Plugin exists but manifest is missing - // Branch 2: Manifest exists but buttons is missing mockPluginRegistry.registeredPlugins = [ { id: 'p1' }, { id: 'p2', manifest: {} }, @@ -158,9 +157,44 @@ describe('useButtonStateEvaluator', () => { ] renderHook(() => useButtonStateEvaluator((fn) => fn())) + expect(mockDispatch).not.toHaveBeenCalled() + }) + + // --- Plugin evaluation settlement --- + + it('dispatches PLUGINS_EVALUATED when no button states changed and arePluginsEvaluated is false', () => { + mockAppState.arePluginsEvaluated = false + renderHook(() => useButtonStateEvaluator((fn) => fn())) + expect(mockDispatch).toHaveBeenCalledWith({ type: 'PLUGINS_EVALUATED' }) + }) - // If the fallback (|| []) works, the code continues to the next plugin - // without throwing a "cannot read property forEach of undefined" error. + it('does not dispatch PLUGINS_EVALUATED when arePluginsEvaluated is already true', () => { + mockAppState.arePluginsEvaluated = true + renderHook(() => useButtonStateEvaluator((fn) => fn())) expect(mockDispatch).not.toHaveBeenCalled() }) + + it('does not dispatch CLEAR_PLUGINS_EVALUATED or PLUGINS_EVALUATED when button states change', () => { + mockAppState.arePluginsEvaluated = false + mockPluginRegistry.registeredPlugins = [{ + id: 'p1', + manifest: { buttons: [{ id: 'btn1', hiddenWhen: () => true }] } + }] + + renderHook(() => useButtonStateEvaluator((fn) => fn())) + expect(mockDispatch).toHaveBeenCalledWith({ type: 'TOGGLE_BUTTON_HIDDEN', payload: { id: 'btn1', isHidden: true } }) + expect(mockDispatch).not.toHaveBeenCalledWith({ type: 'CLEAR_PLUGINS_EVALUATED' }) + expect(mockDispatch).not.toHaveBeenCalledWith({ type: 'PLUGINS_EVALUATED' }) + }) + + it('does not dispatch CLEAR_PLUGINS_EVALUATED when button states change and already evaluated', () => { + mockAppState.arePluginsEvaluated = true + mockPluginRegistry.registeredPlugins = [{ + id: 'p1', + manifest: { buttons: [{ id: 'btn1', hiddenWhen: () => true }] } + }] + + renderHook(() => useButtonStateEvaluator((fn) => fn())) + expect(mockDispatch).not.toHaveBeenCalledWith({ type: 'CLEAR_PLUGINS_EVALUATED' }) + }) }) diff --git a/src/App/hooks/useInterfaceAPI.js b/src/App/hooks/useInterfaceAPI.js index e26600e9..76e50208 100644 --- a/src/App/hooks/useInterfaceAPI.js +++ b/src/App/hooks/useInterfaceAPI.js @@ -54,12 +54,16 @@ export const useInterfaceAPI = () => { } } + const handleAppVisible = () => dispatchRef.current({ type: 'TOGGLE_APP_VISIBLE', payload: true }) + const handleAppHidden = () => dispatchRef.current({ type: 'TOGGLE_APP_VISIBLE', payload: false }) const handleAddPanel = ({ id, config }) => dispatchRef.current({ type: 'ADD_PANEL', payload: { id, config } }) const handleRemovePanel = (id) => dispatchRef.current({ type: 'REMOVE_PANEL', payload: id }) const handleShowPanel = (id) => dispatchRef.current({ type: 'OPEN_PANEL', payload: { panelId: id } }) const handleHidePanel = (id) => dispatchRef.current({ type: 'CLOSE_PANEL', payload: id }) const handleAddControl = ({ id, config }) => dispatchRef.current({ type: 'ADD_CONTROL', payload: { id, config } }) + eventBus.on(events.APP_VISIBLE, handleAppVisible) + eventBus.on(events.APP_HIDDEN, handleAppHidden) eventBus.on(events.APP_ADD_BUTTON, handleAddButton) eventBus.on(events.APP_TOGGLE_BUTTON_STATE, handleToggleButtonState) eventBus.on(events.APP_ADD_PANEL, handleAddPanel) @@ -69,6 +73,8 @@ export const useInterfaceAPI = () => { eventBus.on(events.APP_ADD_CONTROL, handleAddControl) return () => { + eventBus.off(events.APP_VISIBLE, handleAppVisible) + eventBus.off(events.APP_HIDDEN, handleAppHidden) eventBus.off(events.APP_ADD_BUTTON, handleAddButton) eventBus.off(events.APP_TOGGLE_BUTTON_STATE, handleToggleButtonState) eventBus.off(events.APP_ADD_PANEL, handleAddPanel) diff --git a/src/App/hooks/useLayoutMeasurements.js b/src/App/hooks/useLayoutMeasurements.js index ce9a5c7a..9bb4736d 100755 --- a/src/App/hooks/useLayoutMeasurements.js +++ b/src/App/hooks/useLayoutMeasurements.js @@ -12,8 +12,47 @@ const topColWidth = (left, right) => const subSlotMaxHeight = (columnHeight, siblingButtons, gap) => columnHeight - (siblingButtons ? siblingButtons + gap : 0) +/** + * Manages all layout measurements for the map overlay and dispatches the safe + * zone inset used by the map to pad `fitBounds` / `setView` operations. + * + * ## Lifecycle + * + * The safe zone must only be dispatched once every plugin button's reactive + * props (`hiddenWhen`, `enableWhen`, `pressedWhen`, `expandedWhen`) have been + * evaluated for the current app/map state. Dispatching too early — before + * buttons that affect layout (e.g. the actions bar) have their correct + * visibility — produces a stale inset that causes the map to jump when the UI + * then settles into its real state. + * + * ### Trigger events + * The following state changes can alter which buttons are visible and therefore + * how much space the UI occupies: + * - `breakpoint` — responsive layout changes (desktop ↔ mobile / tablet) + * - `mapSize` — map container size variant changes + * - `isMapReady` — plugins are enabled on `map:ready`, changing button visibility + * - `isFullscreen` — fullscreen entry/exit changes which buttons are visible + * - `appVisible` — app shown/hidden by parent HTML outside React (hybrid mode) + * + * When any of these change, `CLEAR_PLUGINS_EVALUATED` is dispatched (Effect 2), + * which prevents the safe zone from being re-dispatched until + * `useButtonStateEvaluator` has completed a full pass with no button state + * changes and sets `PLUGINS_EVALUATED` again. + * + * ### Safe zone dispatch + * Effect 3 fires whenever `arePluginsEvaluated` transitions to `true`, at which + * point DOM dimensions are stable and `getSafeZoneInset` can be read reliably. + * A `requestAnimationFrame` is used to ensure the browser has committed all + * layout changes before measuring. + * + * ### Resize observer + * Effect 4 keeps CSS custom properties up to date whenever any observed element + * resizes (e.g. panels opening, banner appearing, actions buttons toggling). + * It does not dispatch the safe zone — safe zone dispatch is owned entirely by + * Effect 3 to prevent jumps on panel open/close and other non-structural resizes. + */ export function useLayoutMeasurements () { - const { dispatch, breakpoint, layoutRefs } = useApp() + const { dispatch, breakpoint, layoutRefs, arePluginsEvaluated, appVisible, isFullscreen } = useApp() const { mapSize, isMapReady } = useMap() const { @@ -23,18 +62,20 @@ export function useLayoutMeasurements () { topRef, topLeftColRef, topRightColRef, - bottomRef, - bottomRightRef, - actionsRef, leftTopRef, leftBottomRef, rightTopRef, - rightBottomRef + rightBottomRef, + bottomRef, + bottomRightRef, + attributionsRef, + drawerRef, + actionsRef } = layoutRefs - // ----------------------------- - // 1. Calculate layout CSS vars (side effect) - // ----------------------------- + // -------------------------------- + // 1. Calculate layout CSS vars (pure side effect, no dispatch) + // -------------------------------- const calculateLayout = () => { const appContainer = appContainerRef.current const main = mainRef.current @@ -42,6 +83,7 @@ export function useLayoutMeasurements () { const topLeftCol = topLeftColRef.current const topRightCol = topRightColRef.current const bottom = bottomRef.current + const attributions = attributionsRef.current if ([main, top, bottom].some(r => !r)) { return @@ -69,7 +111,7 @@ export function useLayoutMeasurements () { const rightOffsetTop = topRightCol.offsetHeight + top.offsetTop const rightEffectiveBottom = bottom.offsetTop + bottom.offsetHeight - bottomRightHeight const rightColumnHeight = rightEffectiveBottom - rightOffsetTop - dividerGap - const rightOffsetBottom = Math.max(bottomRightHeight, dividerGap) + bottomContainerPad + dividerGap + const rightOffsetBottom = bottomContainerPad + (bottomRightHeight > 0 ? (bottomRightHeight + dividerGap) : attributions.offsetHeight) appContainer.style.setProperty('--right-offset-top', `${rightOffsetTop}px`) appContainer.style.setProperty('--right-offset-bottom', `${rightOffsetBottom}px`) appContainer.style.setProperty('--right-top-max-height', `${rightColumnHeight}px`) @@ -82,22 +124,38 @@ export function useLayoutMeasurements () { } // -------------------------------- - // 2. Run when breakpoint and mapSize change + // 2. Clear the evaluated flag when structural inputs change so the safe zone + // is not dispatched until useButtonStateEvaluator has completed a full + // pass with the new app/map state and set PLUGINS_EVALUATED. // -------------------------------- useLayoutEffect(() => { - requestAnimationFrame(() => { // Required for Preact - calculateLayout() + dispatch({ type: 'CLEAR_PLUGINS_EVALUATED' }) + }, [breakpoint, mapSize, isMapReady, appVisible, isFullscreen]) - // === Set safe zone inset === + // -------------------------------- + // 3. Once all plugin button props have been evaluated (arePluginsEvaluated), + // recalculate layout and dispatch the safe zone inset. + // RAF required to ensure browser layout is committed before measuring. + // -------------------------------- + useLayoutEffect(() => { + if (!arePluginsEvaluated) { + return + } + requestAnimationFrame(() => { + calculateLayout() const safeZoneInset = getSafeZoneInset(layoutRefs) - dispatch({ type: 'SET_SAFE_ZONE_INSET', payload: { safeZoneInset } }) + if (safeZoneInset) { + dispatch({ type: 'SET_SAFE_ZONE_INSET', payload: { safeZoneInset } }) + } }) - }, [breakpoint, mapSize, isMapReady]) + }, [arePluginsEvaluated]) // -------------------------------- - // 3. Recaluclate CSS vars when elements resize + // 4. Recalculate CSS vars whenever observed elements resize (panels, banner, + // actions buttons, etc.). Safe zone is intentionally not dispatched here — + // that is Effect 3's responsibility. // -------------------------------- - useResizeObserver([bannerRef, mainRef, topRef, topLeftColRef, topRightColRef, actionsRef, bottomRef, bottomRightRef, leftTopRef, leftBottomRef, rightTopRef, rightBottomRef], () => { + useResizeObserver([bannerRef, mainRef, topRef, topLeftColRef, topRightColRef, actionsRef, bottomRef, bottomRightRef, leftTopRef, leftBottomRef, rightTopRef, rightBottomRef, drawerRef], () => { requestAnimationFrame(() => { calculateLayout() }) diff --git a/src/App/hooks/useLayoutMeasurements.test.js b/src/App/hooks/useLayoutMeasurements.test.js index bc3dfcc8..c862bb8a 100644 --- a/src/App/hooks/useLayoutMeasurements.test.js +++ b/src/App/hooks/useLayoutMeasurements.test.js @@ -26,17 +26,19 @@ const refs = (o = {}) => ({ topRightColRef: { current: el({ offsetHeight: 40, offsetWidth: 180, ...o.topRightCol }) }, bottomRef: { current: o.bottom === null ? null : el({ offsetTop: 400, ...o.bottom }) }, bottomRightRef: { current: el({ offsetTop: 400, ...o.bottomRight }) }, - actionsRef: { current: el({ offsetTop: 450, ...o.actions }) }, leftTopRef: { current: el({ offsetHeight: 0, ...o.leftTop }) }, leftBottomRef: { current: el({ offsetHeight: 0, ...o.leftBottom }) }, rightTopRef: { current: el({ offsetHeight: 0, ...o.rightTop }) }, - rightBottomRef: { current: el({ offsetHeight: 0, ...o.rightBottom }) } + rightBottomRef: { current: el({ offsetHeight: 0, ...o.rightBottom }) }, + attributionsRef: { current: el({ offsetHeight: 16, ...o.attributions }) }, + drawerRef: { current: el(o.drawer) }, + actionsRef: { current: el({ offsetTop: 450, ...o.actions }) } }) const setup = (o = {}) => { const dispatch = jest.fn() const layoutRefs = refs(o.refs) - useApp.mockReturnValue({ dispatch, breakpoint: 'desktop', layoutRefs, ...o.app }) + useApp.mockReturnValue({ dispatch, breakpoint: 'desktop', layoutRefs, arePluginsEvaluated: true, ...o.app }) useMap.mockReturnValue({ mapSize: { width: 800, height: 600 }, isMapReady: true, ...o.map }) getSafeZoneInset.mockReturnValue({ top: 0, right: 0, bottom: 0, left: 0 }) return { dispatch, layoutRefs } @@ -115,6 +117,19 @@ describe('useLayoutMeasurements', () => { expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith('--right-offset-bottom', '116px') }) + test('uses bottomRight height when bottomRightHeight > 0', () => { + const { layoutRefs } = setup({ + refs: { + bottomRight: { offsetHeight: 20 } // 👈 triggers TRUE branch + } + }) + renderHook(() => useLayoutMeasurements()) + // bottomContainerPad = 500 - 400 - 0 = 100 + // expected = 100 + (20 + 8) = 128 + expect(layoutRefs.appContainerRef.current.style.setProperty) + .toHaveBeenCalledWith('--right-offset-bottom', '128px') + }) + test('uses 0 when sub-slot refs have null current', () => { const { layoutRefs } = setup() layoutRefs.leftTopRef.current = null @@ -127,31 +142,100 @@ describe('useLayoutMeasurements', () => { expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith('--right-bottom-panel-max-height', '342px') }) - test('dispatches safe zone inset', () => { - const { dispatch, layoutRefs } = setup() + test('dispatches safe zone inset on desktop (post-batch RAF read only)', () => { + const { dispatch, layoutRefs } = setup({ app: { breakpoint: 'desktop' } }) getSafeZoneInset.mockReturnValue({ top: 10, right: 5, bottom: 15, left: 5 }) renderHook(() => useLayoutMeasurements()) expect(getSafeZoneInset).toHaveBeenCalledWith(layoutRefs) expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SAFE_ZONE_INSET', payload: { safeZoneInset: { top: 10, right: 5, bottom: 15, left: 5 } } }) }) - test('recalculates on dependency changes', () => { + test('dispatches safe zone inset on mobile', () => { + const { dispatch } = setup({ app: { breakpoint: 'mobile' } }) + getSafeZoneInset.mockReturnValue({ top: 10, right: 5, bottom: 40, left: 5 }) + renderHook(() => useLayoutMeasurements()) + expect(dispatch).toHaveBeenCalledWith({ + type: 'SET_SAFE_ZONE_INSET', + payload: { safeZoneInset: { top: 10, right: 5, bottom: 40, left: 5 } } + }) + }) + + test('does not dispatch SET_SAFE_ZONE_INSET when getSafeZoneInset returns undefined', () => { + const { dispatch } = setup() + getSafeZoneInset.mockReturnValue(undefined) + renderHook(() => useLayoutMeasurements()) + expect(dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'SET_SAFE_ZONE_INSET' })) + }) + + test('does not dispatch safe zone when arePluginsEvaluated is false', () => { + const { dispatch } = setup({ app: { arePluginsEvaluated: false } }) + renderHook(() => useLayoutMeasurements()) + expect(dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'SET_SAFE_ZONE_INSET' })) + expect(layoutRefs => layoutRefs).toBeDefined() // no layout calculation + }) + + test('re-dispatches safe zone when arePluginsEvaluated becomes true', () => { + setup({ app: { arePluginsEvaluated: false } }) + const { rerender } = renderHook(() => useLayoutMeasurements()) + const { dispatch } = setup({ app: { arePluginsEvaluated: true } }) + getSafeZoneInset.mockReturnValue({ top: 5, right: 5, bottom: 60, left: 5 }) + rerender() + expect(dispatch).toHaveBeenCalledWith({ + type: 'SET_SAFE_ZONE_INSET', + payload: { safeZoneInset: { top: 5, right: 5, bottom: 60, left: 5 } } + }) + }) + + test('dispatches CLEAR_PLUGINS_EVALUATED when breakpoint changes', () => { + setup() + const { rerender } = renderHook(() => useLayoutMeasurements()) + const { dispatch } = setup({ app: { breakpoint: 'mobile' } }) + dispatch.mockClear() + rerender() + expect(dispatch).toHaveBeenCalledWith({ type: 'CLEAR_PLUGINS_EVALUATED' }) + }) + + test('dispatches CLEAR_PLUGINS_EVALUATED when isMapReady changes', () => { setup() const { rerender } = renderHook(() => useLayoutMeasurements()) - ;[{ app: { breakpoint: 'mobile' } }, { map: { mapSize: { width: 1000, height: 800 } } }, { map: { isMapReady: false } }] - .forEach(change => { - const { layoutRefs } = setup(change) - layoutRefs.appContainerRef.current.style.setProperty.mockClear() - rerender() - expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalled() - }) + const { dispatch } = setup({ map: { isMapReady: false } }) + dispatch.mockClear() + rerender() + expect(dispatch).toHaveBeenCalledWith({ type: 'CLEAR_PLUGINS_EVALUATED' }) + }) + + test('dispatches CLEAR_PLUGINS_EVALUATED when isFullscreen changes', () => { + setup({ app: { isFullscreen: false } }) + const { rerender } = renderHook(() => useLayoutMeasurements()) + const { dispatch } = setup({ app: { isFullscreen: true } }) + dispatch.mockClear() + rerender() + expect(dispatch).toHaveBeenCalledWith({ type: 'CLEAR_PLUGINS_EVALUATED' }) + }) + + test('dispatches CLEAR_PLUGINS_EVALUATED when appVisible changes', () => { + setup({ app: { appVisible: false } }) + const { rerender } = renderHook(() => useLayoutMeasurements()) + const { dispatch } = setup({ app: { appVisible: true } }) + dispatch.mockClear() + rerender() + expect(dispatch).toHaveBeenCalledWith({ type: 'CLEAR_PLUGINS_EVALUATED' }) + }) + + test('recalculates layout when arePluginsEvaluated becomes true', () => { + setup({ app: { arePluginsEvaluated: false } }) + const { rerender } = renderHook(() => useLayoutMeasurements()) + const { layoutRefs } = setup({ app: { arePluginsEvaluated: true } }) + layoutRefs.appContainerRef.current.style.setProperty.mockClear() + rerender() + expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalled() }) test('sets up resize observer', () => { const { layoutRefs } = setup() renderHook(() => useLayoutMeasurements()) expect(useResizeObserver).toHaveBeenCalledWith( - [layoutRefs.bannerRef, layoutRefs.mainRef, layoutRefs.topRef, layoutRefs.topLeftColRef, layoutRefs.topRightColRef, layoutRefs.actionsRef, layoutRefs.bottomRef, layoutRefs.bottomRightRef, layoutRefs.leftTopRef, layoutRefs.leftBottomRef, layoutRefs.rightTopRef, layoutRefs.rightBottomRef], + [layoutRefs.bannerRef, layoutRefs.mainRef, layoutRefs.topRef, layoutRefs.topLeftColRef, layoutRefs.topRightColRef, layoutRefs.actionsRef, layoutRefs.bottomRef, layoutRefs.bottomRightRef, layoutRefs.leftTopRef, layoutRefs.leftBottomRef, layoutRefs.rightTopRef, layoutRefs.rightBottomRef, layoutRefs.drawerRef], expect.any(Function) ) layoutRefs.appContainerRef.current.style.setProperty.mockClear() @@ -159,4 +243,19 @@ describe('useLayoutMeasurements', () => { expect(rafSpy).toHaveBeenCalled() expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalled() }) + + test('resize observer does not dispatch safe zone (safe zone is Effect 3 only)', () => { + const { dispatch } = setup() + renderHook(() => useLayoutMeasurements()) + dispatch.mockClear() + useResizeObserver.mock.calls[0][1]() + expect(dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'SET_SAFE_ZONE_INSET' })) + }) + + test('resize observer handles null mainRef without throwing', () => { + const { layoutRefs } = setup() + renderHook(() => useLayoutMeasurements()) + layoutRefs.mainRef.current = null + expect(() => useResizeObserver.mock.calls[0][1]()).not.toThrow() + }) }) diff --git a/src/App/layout/layout.module.scss b/src/App/layout/layout.module.scss index b7db879d..90fe11dc 100755 --- a/src/App/layout/layout.module.scss +++ b/src/App/layout/layout.module.scss @@ -158,10 +158,6 @@ & > *:empty { display: none; } - - @media (prefers-reduced-motion: no-preference) { - transition: bottom 0.15s ease; - } } .im-o-app__left-top { @@ -212,10 +208,6 @@ top: var(--right-offset-top); bottom: var(--right-offset-bottom); gap: var(--divider-gap); - - @media (prefers-reduced-motion: no-preference) { - transition: bottom 0.15s ease; - } } .im-o-app__right-top { diff --git a/src/App/renderer/HtmlElementHost.jsx b/src/App/renderer/HtmlElementHost.jsx index dedc339b..92d52dd8 100644 --- a/src/App/renderer/HtmlElementHost.jsx +++ b/src/App/renderer/HtmlElementHost.jsx @@ -19,7 +19,8 @@ export const getSlotRef = (slot, layoutRefs) => { middle: layoutRefs.middleRef, 'right-top': layoutRefs.rightTopRef, 'right-bottom': layoutRefs.rightBottomRef, - bottom: layoutRefs.bottomRef, + 'bottom-right': layoutRefs.bottomRightRef, + drawer: layoutRefs.drawerRef, actions: layoutRefs.actionsRef, modal: layoutRefs.modalRef } diff --git a/src/App/store/appActionsMap.js b/src/App/store/appActionsMap.js index 57afc570..15d985b3 100755 --- a/src/App/store/appActionsMap.js +++ b/src/App/store/appActionsMap.js @@ -152,6 +152,12 @@ const toggleHasExclusiveControl = (state, payload) => { } } +const setPluginsEvaluated = (state) => + state.arePluginsEvaluated ? state : { ...state, arePluginsEvaluated: true } + +const clearPluginsEvaluated = (state) => + state.arePluginsEvaluated ? { ...state, arePluginsEvaluated: false } : state + const setSafeZoneInset = (state, { safeZoneInset, syncMapPadding = true }) => { return shallowEqual(state.safeZoneInset, safeZoneInset) ? state @@ -163,6 +169,13 @@ const setSafeZoneInset = (state, { safeZoneInset, syncMapPadding = true }) => { } } +const toggleAppVisible = (state, payload) => { + return { + ...state, + appVisible: payload + } +} + const toggleButtonDisabled = (state, payload) => { const { id, isDisabled } = payload const updated = new Set(state.disabledButtons) @@ -358,12 +371,15 @@ export const actionsMap = { SET_HYBRID_FULLSCREEN: setHybridFullscreen, SET_INTERFACE_TYPE: setInterfaceType, SET_MODE: setMode, + PLUGINS_EVALUATED: setPluginsEvaluated, + CLEAR_PLUGINS_EVALUATED: clearPluginsEvaluated, SET_SAFE_ZONE_INSET: setSafeZoneInset, REVERT_MODE: revertMode, OPEN_PANEL: openPanel, CLOSE_PANEL: closePanel, CLOSE_ALL_PANELS: closeAllPanels, RESTORE_PREVIOUS_PANELS: restorePreviousPanels, + TOGGLE_APP_VISIBLE: toggleAppVisible, TOGGLE_HAS_EXCLUSIVE_CONTROL: toggleHasExclusiveControl, TOGGLE_BUTTON_DISABLED: toggleButtonDisabled, TOGGLE_BUTTON_HIDDEN: toggleButtonHidden, diff --git a/src/App/store/appActionsMap.test.js b/src/App/store/appActionsMap.test.js index 1a4e9a11..3c339d6e 100755 --- a/src/App/store/appActionsMap.test.js +++ b/src/App/store/appActionsMap.test.js @@ -128,6 +128,26 @@ describe('actionsMap full coverage', () => { expect(result.hasExclusiveControl).toBe(true) }) + test('PLUGINS_EVALUATED is no-op when arePluginsEvaluated already true', () => { + const s = { ...state, arePluginsEvaluated: true } + expect(actionsMap.PLUGINS_EVALUATED(s)).toBe(s) + }) + + test('PLUGINS_EVALUATED sets arePluginsEvaluated when false', () => { + const s = { ...state, arePluginsEvaluated: false } + expect(actionsMap.PLUGINS_EVALUATED(s).arePluginsEvaluated).toBe(true) + }) + + test('CLEAR_PLUGINS_EVALUATED clears arePluginsEvaluated when true', () => { + const s = { ...state, arePluginsEvaluated: true } + expect(actionsMap.CLEAR_PLUGINS_EVALUATED(s).arePluginsEvaluated).toBe(false) + }) + + test('CLEAR_PLUGINS_EVALUATED is no-op when arePluginsEvaluated already false', () => { + const s = { ...state, arePluginsEvaluated: false } + expect(actionsMap.CLEAR_PLUGINS_EVALUATED(s)).toBe(s) + }) + test('SET_SAFE_ZONE_INSET branch true/false', () => { shallowEqualModule.shallowEqual.mockReturnValueOnce(false) const res1 = actionsMap.SET_SAFE_ZONE_INSET(state, { safeZoneInset: { top: 10, bottom: 10 } }) @@ -152,6 +172,13 @@ describe('actionsMap full coverage', () => { expect(r2.hiddenButtons.has('btn3')).toBe(false) }) + test('TOGGLE_APP_VISIBLE sets appVisible to payload', () => { + const r1 = actionsMap.TOGGLE_APP_VISIBLE(state, true) + expect(r1.appVisible).toBe(true) + const r2 = actionsMap.TOGGLE_APP_VISIBLE(state, false) + expect(r2.appVisible).toBe(false) + }) + test('TOGGLE_BUTTON_PRESSED adds/removes button', () => { const r1 = actionsMap.TOGGLE_BUTTON_PRESSED(state, { id: 'btn6', isPressed: true }) expect(r1.pressedButtons.has('btn6')).toBe(true) diff --git a/src/App/store/appReducer.js b/src/App/store/appReducer.js index cdf9438e..39739274 100755 --- a/src/App/store/appReducer.js +++ b/src/App/store/appReducer.js @@ -29,7 +29,9 @@ export const initialState = (config) => { const openPanels = getInitialOpenPanels(panelConfig, initialBreakpoint) return { + appVisible: null, isLayoutReady: false, + arePluginsEvaluated: false, breakpoint: initialBreakpoint, interfaceType: initialInterfaceType, preferredColorScheme: autoColorScheme ? preferredColorScheme : appColorScheme, diff --git a/src/InteractiveMap/InteractiveMap.js b/src/InteractiveMap/InteractiveMap.js index fd7a1fb6..b51155a2 100755 --- a/src/InteractiveMap/InteractiveMap.js +++ b/src/InteractiveMap/InteractiveMap.js @@ -245,6 +245,8 @@ export default class InteractiveMap { if (parts.length > 1) { document.title = parts[parts.length - 1] } + + this.eventBus.emit(events.APP_HIDDEN) } /** @@ -262,6 +264,8 @@ export default class InteractiveMap { } updateDOMState(this) + + this.eventBus.emit(events.APP_VISIBLE) } /** diff --git a/src/config/appConfig.js b/src/config/appConfig.js index 7f81d52e..61743f7e 100755 --- a/src/config/appConfig.js +++ b/src/config/appConfig.js @@ -93,6 +93,9 @@ export const defaultAppConfig = { }, { id: 'minus', svgContent: '' + }, { + id: 'chevron', + svgContent: '' }] } diff --git a/src/config/events.js b/src/config/events.js index 57a95ba7..83d12d3f 100644 --- a/src/config/events.js +++ b/src/config/events.js @@ -61,6 +61,34 @@ export const EVENTS = { */ APP_READY: 'app:ready', + /** + * Emitted when the map application becomes visible after being hidden. + * + * This can occur in 'hybrid behaviour' responsive scenarios where the map is already initialized + * (e.g. initialized inline on desktop) but was hidden and then shown again + * (e.g. resizing to mobile and opening the map). + * + * @remarks + * - Only emitted when transitioning from hidden → visible. + * - Not fired on initial open. + * - The existing map state may be preserved depending on configuration. + */ + APP_VISIBLE: 'app:visible', + + /** + * Emitted when the map application becomes hidden. + * + * This can occur in 'hybrid behaviour' responsive scenarios where the map was initialized inline + * (e.g. visible on desktop) but then becomes hidden + * (e.g. resizing to mobile or closing the map view). + * + * @remarks + * - Only emitted when transitioning from visible → hidden. + * - Not fired on initial load if the map starts hidden. + * - The map state may be preserved depending on configuration. + */ + APP_HIDDEN: 'app:hidden', + /** * Emitted when a panel is opened. * Payload: { panelId: string } diff --git a/src/scss/main.scss b/src/scss/main.scss index b42069e7..2cd70b79 100755 --- a/src/scss/main.scss +++ b/src/scss/main.scss @@ -19,6 +19,7 @@ @use '../App/components/KeyboardHelp/KeyboardHelp.module'; @use '../App/components/MapButton/MapButton.module'; @use '../App/components/Panel/Panel.module'; +@use '../App/components/Icon/Icon.module'; @use '../App/components/Viewport/Viewport.module'; @use '../App/components/Tooltip/Tooltip.module'; @use '../App/components/PopupMenu/PopupMenu.module'; diff --git a/src/utils/getSafeZoneInset.js b/src/utils/getSafeZoneInset.js index ad76e5aa..dea03685 100755 --- a/src/utils/getSafeZoneInset.js +++ b/src/utils/getSafeZoneInset.js @@ -28,7 +28,6 @@ * @param {React.RefObject} refs.rightRef - Right button column. * @param {React.RefObject} refs.actionsRef - Bottom action bar. * @param {React.RefObject} refs.bottomRef - Bottom row (logo, copyright, etc). - * @param {React.RefObject} [refs.bottomRightRef] - Bottom-right button container (collapses margin when empty). * @param {React.RefObject} [refs.leftTopRef] - Top-left panel slot. * @param {React.RefObject} [refs.leftBottomRef] - Bottom-left panel slot. * @param {React.RefObject} [refs.rightTopRef] - Top-right panel slot. @@ -108,7 +107,7 @@ const computeRow = (leftW, rightW, leftH, rightH, wThreshold, baseInset, gap) => leftW + rightW > wThreshold ? baseInset + Math.max(leftH, rightH) + gap : 0 export const getSafeZoneInset = ({ - mainRef, leftRef, rightRef, actionsRef, bottomRef, bottomRightRef, + mainRef, leftRef, rightRef, actionsRef, bottomRef, leftTopRef, leftBottomRef, rightTopRef, rightBottomRef }) => { if ([mainRef, leftRef, rightRef, actionsRef, bottomRef].some(ref => !ref?.current)) { @@ -128,9 +127,9 @@ export const getSafeZoneInset = ({ const baseLeft = main.offsetLeft + left.offsetLeft + colWidth + gap const baseRight = left.offsetLeft + colWidth + gap const baseTop = left.offsetTop - const bottomRightHeight = bottomRightRef?.current?.offsetHeight ?? 0 const bottomContainerPad = main.offsetHeight - bottom.offsetTop - bottom.offsetHeight - const bottomInset = Math.max(bottomRightHeight, gap) + bottomContainerPad + gap // mirrors --right-offset-bottom CSS var + // Minimum: primary-gap above the bottom edge. Normally: divider-gap above the top of the bottom container. + const bottomInset = Math.max(bottomContainerPad, main.offsetHeight - bottom.offsetTop + gap) const baseBottom = Math.max(main.offsetHeight - actions.offsetTop + gap, bottomInset) const availableH = main.offsetHeight - baseTop - baseBottom diff --git a/src/utils/getSafeZoneInset.test.js b/src/utils/getSafeZoneInset.test.js index 47a0c3fe..07e2f811 100644 --- a/src/utils/getSafeZoneInset.test.js +++ b/src/utils/getSafeZoneInset.test.js @@ -31,7 +31,7 @@ const PANEL_H_TALL = 150 const PANEL_H_SHORT = 100 const ABOVE_CAP_TOP = 330 // 60+330+8=398 > CAP_HEIGHT ≈ 389.3 → capped -const BOTTOM_INSET = GAP + (MAIN_HEIGHT - BOTTOM_TOP) + GAP // 56: max(0,gap) + bottomContainerPad + gap +const BOTTOM_INSET = MAIN_HEIGHT - BOTTOM_TOP + GAP // 48: divider-gap above top of bottom container const ABOVE_CAP_BOTTOM = 342 // 48+342+8=398 > CAP_HEIGHT → capped const PANEL_W_STANDARD = 200 diff --git a/webpack.dev.mjs b/webpack.dev.mjs index 24b1a6f5..6bd2c2e5 100755 --- a/webpack.dev.mjs +++ b/webpack.dev.mjs @@ -18,7 +18,7 @@ export default { ], entry: { index: path.join(__dirname, 'demo/js/index.js'), - forms: path.join(__dirname, 'demo/js/forms.js'), + draw: path.join(__dirname, 'demo/js/draw.js'), farming: path.join(__dirname, 'demo/js/farming.js'), planning: path.join(__dirname, 'demo/js/planning.js') },