feat: add device compass map orientation
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user