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