feat: add device compass map orientation

This commit is contained in:
2026-07-22 10:32:29 +01:00
parent ff8d55f1c0
commit f4e55349ca
2 changed files with 237 additions and 1 deletions
+192 -1
View File
@@ -3,6 +3,8 @@ const API_CAPABILITIES = "/api/capabilities";
const API_WMS = "/api/wms"; const API_WMS = "/api/wms";
const PARCEL_LAYER_NAME = "xpro:parcelas"; const PARCEL_LAYER_NAME = "xpro:parcelas";
const FEATURE_INFO_LAYER_NAMES = new Set(["parcelas", "predios"]); const FEATURE_INFO_LAYER_NAMES = new Set(["parcelas", "predios"]);
const DEVICE_ORIENTATION_EVENTS = ["deviceorientationabsolute", "deviceorientation"];
const COMPASS_HEADING_DEADBAND = 2;
const els = { const els = {
appShell: document.querySelector(".app-shell"), appShell: document.querySelector(".app-shell"),
@@ -53,7 +55,9 @@ const wmsSource = new ol.source.ImageWMS({
FORMAT: "image/png", FORMAT: "image/png",
TRANSPARENT: true, 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", crossOrigin: "anonymous",
}); });
@@ -79,6 +83,14 @@ const areaZoomInteraction = new ol.interaction.DragBox({
}); });
areaZoomInteraction.setActive(false); areaZoomInteraction.setActive(false);
let areaZoomButton; let areaZoomButton;
let deviceCompassButton;
let deviceCompassStatus;
let deviceCompassActive = false;
let deviceCompassSource;
let lastCompassHeading;
let pendingCompassHeading;
let compassAnimationFrame;
let deviceCompassFallbackTimer;
const map = new ol.Map({ const map = new ol.Map({
target: "map", target: "map",
@@ -87,6 +99,7 @@ const map = new ol.Map({
.extend([ .extend([
createVisibleLayersExtentControl(), createVisibleLayersExtentControl(),
createAreaZoomControl(), createAreaZoomControl(),
...(shouldShowDeviceCompassControl() ? [createDeviceCompassControl()] : []),
new ol.control.Rotate({ new ol.control.Rotate({
autoHide: true, autoHide: true,
label: createNorthPointerIcon(), label: createNorthPointerIcon(),
@@ -196,6 +209,184 @@ function createAreaZoomControl() {
return new ol.control.Control({ element }); 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) { function setAreaZoomMode(active) {
areaZoomInteraction.setActive(active); areaZoomInteraction.setActive(active);
map.getViewport().classList.toggle("area-zoom-is-active", active); map.getViewport().classList.toggle("area-zoom-is-active", active);
+45
View File
@@ -492,6 +492,46 @@ button:disabled {
background: rgba(31, 122, 98, 0.16); 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. */ /* OpenLayers hides this control while the view is north-up. */
.map-wrap .ol-rotate { .map-wrap .ol-rotate {
top: auto; top: auto;
@@ -552,6 +592,11 @@ button:disabled {
right: 12px; right: 12px;
} }
.map-wrap .ol-device-compass {
top: 180px;
right: 12px;
}
.map-wrap .ol-rotate { .map-wrap .ol-rotate {
right: 12px; right: 12px;
bottom: 12px; bottom: 12px;