From f4e55349ca775215726284ec6ccd346d0779c598 Mon Sep 17 00:00:00 2001 From: Joao Figueiredo Date: Wed, 22 Jul 2026 10:32:29 +0100 Subject: [PATCH] feat: add device compass map orientation --- app.js | 193 ++++++++++++++++++++++++++++++++++++++++++++++++++++- styles.css | 45 +++++++++++++ 2 files changed, 237 insertions(+), 1 deletion(-) diff --git a/app.js b/app.js index 639ad9c..11e6c22 100644 --- a/app.js +++ b/app.js @@ -3,6 +3,8 @@ const API_CAPABILITIES = "/api/capabilities"; const API_WMS = "/api/wms"; const PARCEL_LAYER_NAME = "xpro:parcelas"; const FEATURE_INFO_LAYER_NAMES = new Set(["parcelas", "predios"]); +const DEVICE_ORIENTATION_EVENTS = ["deviceorientationabsolute", "deviceorientation"]; +const COMPASS_HEADING_DEADBAND = 2; const els = { appShell: document.querySelector(".app-shell"), @@ -53,7 +55,9 @@ const wmsSource = new ol.source.ImageWMS({ FORMAT: "image/png", TRANSPARENT: true, }, - ratio: 1, + // Keep a slightly oversized image while the compass rotates the view so a + // small heading change does not require a new WMS image every time. + ratio: 1.5, crossOrigin: "anonymous", }); @@ -79,6 +83,14 @@ const areaZoomInteraction = new ol.interaction.DragBox({ }); areaZoomInteraction.setActive(false); let areaZoomButton; +let deviceCompassButton; +let deviceCompassStatus; +let deviceCompassActive = false; +let deviceCompassSource; +let lastCompassHeading; +let pendingCompassHeading; +let compassAnimationFrame; +let deviceCompassFallbackTimer; const map = new ol.Map({ target: "map", @@ -87,6 +99,7 @@ const map = new ol.Map({ .extend([ createVisibleLayersExtentControl(), createAreaZoomControl(), + ...(shouldShowDeviceCompassControl() ? [createDeviceCompassControl()] : []), new ol.control.Rotate({ autoHide: true, label: createNorthPointerIcon(), @@ -196,6 +209,184 @@ function createAreaZoomControl() { return new ol.control.Control({ element }); } +function createDeviceCompassControl() { + const element = document.createElement("div"); + element.className = "ol-device-compass ol-unselectable ol-control"; + + const button = document.createElement("button"); + button.type = "button"; + button.setAttribute("aria-pressed", "false"); + + const status = document.createElement("span"); + status.id = "device-compass-status"; + status.className = "visually-hidden"; + status.setAttribute("role", "status"); + button.setAttribute("aria-describedby", status.id); + + const icon = createNorthPointerIcon(); + icon.classList.add("device-compass-icon"); + button.append(icon); + button.addEventListener("click", toggleDeviceCompass); + + element.append(button, status); + deviceCompassButton = button; + deviceCompassStatus = status; + updateDeviceCompassButton(); + + return new ol.control.Control({ element }); +} + +function shouldShowDeviceCompassControl() { + return window.matchMedia?.("(any-pointer: coarse)").matches || navigator.maxTouchPoints > 0; +} + +async function toggleDeviceCompass() { + if (deviceCompassActive) { + stopDeviceCompass(); + return; + } + + if (!window.isSecureContext) { + updateDeviceCompassButton("Device compass requires HTTPS; HTTP works only on localhost."); + return; + } + + if (!("DeviceOrientationEvent" in window)) { + updateDeviceCompassButton("Device compass is unavailable on this device."); + return; + } + + try { + const permission = await requestDeviceOrientationPermission(); + if (permission !== "granted") { + updateDeviceCompassButton("Device compass permission was not granted."); + return; + } + + deviceCompassSource = null; + lastCompassHeading = null; + deviceCompassActive = true; + window.addEventListener(DEVICE_ORIENTATION_EVENTS[0], syncMapToDeviceCompass); + // Prefer an absolute event, but retain support for browsers (notably iOS) + // that expose only the standard orientation event. + deviceCompassFallbackTimer = window.setTimeout(() => { + if (!deviceCompassSource && deviceCompassActive) { + window.addEventListener(DEVICE_ORIENTATION_EVENTS[1], syncMapToDeviceCompass); + } + }, 300); + updateDeviceCompassButton("Device compass orientation is on."); + } catch (error) { + console.error(error); + updateDeviceCompassButton("Unable to start the device compass."); + } +} + +function requestDeviceOrientationPermission() { + if (typeof DeviceOrientationEvent.requestPermission !== "function") { + return Promise.resolve("granted"); + } + + // iOS requires this call to be made directly from the button interaction. + return DeviceOrientationEvent.requestPermission(); +} + +function stopDeviceCompass() { + DEVICE_ORIENTATION_EVENTS.forEach((eventName) => { + window.removeEventListener(eventName, syncMapToDeviceCompass); + }); + clearTimeout(deviceCompassFallbackTimer); + deviceCompassFallbackTimer = null; + if (compassAnimationFrame) { + cancelAnimationFrame(compassAnimationFrame); + } + compassAnimationFrame = null; + pendingCompassHeading = null; + deviceCompassSource = null; + lastCompassHeading = null; + deviceCompassActive = false; + updateDeviceCompassButton("Device compass orientation is off."); +} + +function syncMapToDeviceCompass(event) { + // Firefox and some Android browsers dispatch both streams. Once the + // absolute stream arrives, never let a relative update overwrite it. + if (event.type === "deviceorientationabsolute") { + deviceCompassSource = "absolute"; + clearTimeout(deviceCompassFallbackTimer); + window.removeEventListener(DEVICE_ORIENTATION_EVENTS[1], syncMapToDeviceCompass); + } else if (deviceCompassSource === "absolute") { + return; + } else { + deviceCompassSource = "standard"; + } + + const heading = getCompassHeading(event); + + if (heading == null || headingDifference(heading, lastCompassHeading) < COMPASS_HEADING_DEADBAND) { + return; + } + + pendingCompassHeading = heading; + if (compassAnimationFrame) { + return; + } + + compassAnimationFrame = requestAnimationFrame(() => { + compassAnimationFrame = null; + lastCompassHeading = pendingCompassHeading; + // OpenLayers applies a view rotation in the inverse direction of the map + // image. Match the device heading by rotating the view the other way. + map.getView().setRotation((-pendingCompassHeading * Math.PI) / 180); + }); +} + +function getCompassHeading(event) { + if (typeof event.webkitCompassHeading === "number" && Number.isFinite(event.webkitCompassHeading)) { + return normalizeDegrees(event.webkitCompassHeading); + } + + if (typeof event.alpha !== "number" || !Number.isFinite(event.alpha)) { + return null; + } + + // Standard orientation events report a counter-clockwise alpha angle. Add + // the current screen angle so the heading remains correct in landscape. + return normalizeDegrees(360 - event.alpha + getScreenOrientationAngle()); +} + +function getScreenOrientationAngle() { + const angle = window.screen?.orientation?.angle ?? window.orientation ?? 0; + return Number.isFinite(Number(angle)) ? Number(angle) : 0; +} + +function normalizeDegrees(degrees) { + return ((degrees % 360) + 360) % 360; +} + +function headingDifference(first, second) { + if (second == null) { + return Infinity; + } + + return Math.abs(normalizeDegrees(first - second + 180) - 180); +} + +function updateDeviceCompassButton(message) { + if (!deviceCompassButton) { + return; + } + + const label = deviceCompassActive + ? "Turn off device compass orientation" + : "Align map with device compass"; + deviceCompassButton.setAttribute("aria-pressed", String(deviceCompassActive)); + deviceCompassButton.setAttribute("aria-label", label); + deviceCompassButton.title = message || label; + if (message && deviceCompassStatus) { + deviceCompassStatus.textContent = message; + } +} + function setAreaZoomMode(active) { areaZoomInteraction.setActive(active); map.getViewport().classList.toggle("area-zoom-is-active", active); diff --git a/styles.css b/styles.css index 66314e9..368c5f8 100644 --- a/styles.css +++ b/styles.css @@ -492,6 +492,46 @@ button:disabled { background: rgba(31, 122, 98, 0.16); } +.map-wrap .ol-device-compass { + top: 184px; + 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-device-compass 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-device-compass button:hover, +.map-wrap .ol-device-compass button:focus, +.map-wrap .ol-device-compass 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; +} + +.device-compass-icon { + display: block; + width: 1.2em; + height: 1.2em; +} + /* OpenLayers hides this control while the view is north-up. */ .map-wrap .ol-rotate { top: auto; @@ -552,6 +592,11 @@ button:disabled { right: 12px; } + .map-wrap .ol-device-compass { + top: 180px; + right: 12px; + } + .map-wrap .ol-rotate { right: 12px; bottom: 12px;