From ff8d55f1c07a7a8a92359d4da28a5bfabd8ce0d2 Mon Sep 17 00:00:00 2001 From: Joao Figueiredo Date: Wed, 22 Jul 2026 09:44:25 +0100 Subject: [PATCH] feat: add area zoom map control --- app.js | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ styles.css | 55 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/app.js b/app.js index 5437849..639ad9c 100644 --- a/app.js +++ b/app.js @@ -72,12 +72,21 @@ const parcelSearchLayer = new ol.layer.Vector({ }), }); +const areaZoomInteraction = new ol.interaction.DragBox({ + className: "ol-area-zoom-box", + condition: ol.events.condition.primaryAction, + minArea: 16, +}); +areaZoomInteraction.setActive(false); +let areaZoomButton; + const map = new ol.Map({ target: "map", controls: ol.control .defaults.defaults({ rotate: false }) .extend([ createVisibleLayersExtentControl(), + createAreaZoomControl(), new ol.control.Rotate({ autoHide: true, label: createNorthPointerIcon(), @@ -90,6 +99,7 @@ const map = new ol.Map({ zoom: 7, }), }); +map.addInteraction(areaZoomInteraction); let availableLayers = []; let selectedLayers = []; @@ -108,12 +118,25 @@ document.addEventListener("keydown", (event) => { if (event.key === "Escape") { if (!els.featureModal.hidden) { closeFeatureModal(); + } else if (areaZoomInteraction.getActive()) { + setAreaZoomMode(false); } else if (!els.appShell.classList.contains("sidebar-is-collapsed")) { setSidebarCollapsed(true); } } }); map.on("singleclick", showFeatureInformation); +areaZoomInteraction.on("boxend", zoomToSelectedArea); +areaZoomInteraction.on("boxcancel", () => { + // A click or a very short drag is not a selection. Exit on the next frame so + // cancelling a drag while disabling the interaction cannot recursively + // trigger another cancellation. + requestAnimationFrame(() => { + if (areaZoomInteraction.getActive()) { + setAreaZoomMode(false); + } + }); +}); loadLayers(); @@ -150,6 +173,57 @@ function createVisibleLayersExtentControl() { return new ol.control.Control({ element }); } +function createAreaZoomControl() { + const element = document.createElement("div"); + element.className = "ol-area-zoom ol-unselectable ol-control"; + + const button = document.createElement("button"); + button.type = "button"; + button.title = "Zoom to area"; + button.setAttribute("aria-label", "Zoom to area"); + button.setAttribute("aria-pressed", "false"); + + const icon = document.createElement("img"); + icon.className = "area-zoom-icon"; + icon.src = "./assets/icons/area-zoom.svg"; + icon.alt = ""; + button.append(icon); + + button.addEventListener("click", () => setAreaZoomMode(!areaZoomInteraction.getActive())); + element.append(button); + areaZoomButton = button; + + return new ol.control.Control({ element }); +} + +function setAreaZoomMode(active) { + areaZoomInteraction.setActive(active); + map.getViewport().classList.toggle("area-zoom-is-active", active); + areaZoomButton?.setAttribute("aria-pressed", String(active)); + areaZoomButton?.setAttribute( + "title", + active ? "Exit area zoom (Esc)" : "Zoom to area", + ); + + if (active) { + closeFeatureModal(); + } +} + +function zoomToSelectedArea() { + const extent = areaZoomInteraction.getGeometry()?.getExtent(); + + if (extent && extent.every(Number.isFinite)) { + map.getView().fit(extent, { + padding: [64, 64, 64, 64], + duration: 500, + maxZoom: 19, + }); + } + + setAreaZoomMode(false); +} + async function searchParcela(event) { event.preventDefault(); @@ -328,6 +402,10 @@ function syncSelectedLayers() { } async function showFeatureInformation(event) { + if (areaZoomInteraction.getActive()) { + return; + } + const queryLayers = availableLayers .map((layer) => layer.name) .filter( diff --git a/styles.css b/styles.css index b44fec2..66314e9 100644 --- a/styles.css +++ b/styles.css @@ -442,6 +442,56 @@ button:disabled { font-size: 1.3rem; } +.map-wrap .ol-area-zoom { + top: 140px; + right: 16px; + left: auto; + overflow: hidden; + background: var(--panel); + border: 1px solid var(--panel-border); + border-radius: 14px; + backdrop-filter: blur(16px); + box-shadow: var(--shadow); +} + +.map-wrap .ol-area-zoom button { + display: grid; + width: 2.0625em; + height: 2.0625em; + margin: 0; + padding: 0; + place-items: center; + background: linear-gradient(135deg, var(--accent) 0%, #2b9a79 100%); + color: #fffefb; + font-size: 1.3rem; + line-height: 1; +} + +.map-wrap .ol-area-zoom button:hover, +.map-wrap .ol-area-zoom button:focus, +.map-wrap .ol-area-zoom button[aria-pressed="true"] { + background: linear-gradient(135deg, var(--accent-strong) 0%, var(--accent) 100%); + color: #fff; + outline: 2px solid rgba(31, 122, 98, 0.35); + outline-offset: -2px; +} + +.area-zoom-icon { + display: block; + width: 1.2em; + height: 1.2em; + filter: brightness(0) invert(1); +} + +.map-wrap .ol-viewport.area-zoom-is-active { + cursor: crosshair; +} + +.map-wrap .ol-area-zoom-box { + border-color: var(--accent); + background: rgba(31, 122, 98, 0.16); +} + /* OpenLayers hides this control while the view is north-up. */ .map-wrap .ol-rotate { top: auto; @@ -497,6 +547,11 @@ button:disabled { right: 12px; } + .map-wrap .ol-area-zoom { + top: 136px; + right: 12px; + } + .map-wrap .ol-rotate { right: 12px; bottom: 12px;