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);
|
||||
|
||||
+45
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user