diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 4d43f8e..f29599d 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -3,7 +3,7 @@ module.exports = { parser: '@typescript-eslint/parser', plugins: ['@typescript-eslint'], root: true, - ignorePatterns: ["**/*.js"], + ignorePatterns: ["**/*.js", "src/platform-constants.ts"], overrides: [ { diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d6f905..f2e4dbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # MapTiler AR Control Changelog +## v1.2.0 +### New Features +- Possibility to add a marketting image on top of the 3D view +- Possibility to increase the resolution of the texture for non-iOS devices +- if AR is automatically ran, the intermediate 3D view no longer shows on iOS (at both begining and end of AR) +- The elevation padding of the 3D models now depends on zoom level and minimal elevation +- If possible and desired to go straight to AR mode, the control icon now features a box logo with brackets + +### Bug Fixes +- Very small extents at high zoom level are no longer possible as they were generating bad looking meshes. Min zoom: 16 + ## v1.1.0 ### New Features - Possibility to launch the AR mode automatically (when device allows) with the opt-in option `activateAR` set to `true` diff --git a/demos/auto-activate.html b/demos/auto-activate.html index d051deb..e212e67 100644 --- a/demos/auto-activate.html +++ b/demos/auto-activate.html @@ -31,7 +31,7 @@ width: 100vw; height: 100vh; background-color: #3e4c55; - z-index: 3; + z-index: 4; display: none; } diff --git a/package.json b/package.json index 523eb11..249974e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@maptiler/ar-control", - "version": "1.1.0", + "version": "1.2.0", "description": "AR Control for MapTiler SDK", "type": "module", "homepage": "https://docs.maptiler.com/sdk-js/modules/ar/", diff --git a/readme.md b/readme.md index 6e15f2a..31df6be 100644 --- a/readme.md +++ b/readme.md @@ -94,7 +94,7 @@ There are two events: The AR control performs some temporary changes to the map view, so these events are handy to hide those transformations behind a curtain or displaying a message. -In the [example](./examples/index.html), we show a fullscreen overlay with a waiting message at `computeStart` and hide it at `computeEnd`, just by dynamically updating the `.style.display` property of the overlay. +In the [example](./examples/index.html), we show a fullscreen overlay with a waiting message at `computeStart` and hides it at `computeEnd`, just by dynamically updating the `.style.display` property of the overlay. Keep in mind that the `z-index` CSS property of this overlay must be higher than the 3D model view, so greater than `3`. When using React, you may want to replace this logic by a change of state. # Options @@ -110,6 +110,7 @@ The constructor `MaptilerARControl` accepts an option object to customize the lo - `logoHeight`(number): the height of the logo in pixels (if any). Default: `60` - `logoClass` (string): CSS class to add to the class list of the `` element holding the logo (if any). If used, the `.logoHeight` as well as the default styling will no longer be applied. - `activateAR` (boolean): When the platform allows, setting this to `true` automatically activates the AR mode as soon as the data is ready. Quick Look on iOS is likely to allow this, while WebXR on Android is not likeley to. Default: `false` +- `highRes` (boolean): increases the resolution of the texture. Will most likely have no effect on iOS due to some format limitation. Default: `false`. ![](images/screenshot2.jpg) diff --git a/src/MaptilerARControl.ts b/src/MaptilerARControl.ts index 4064c0d..261e0e6 100644 --- a/src/MaptilerARControl.ts +++ b/src/MaptilerARControl.ts @@ -2,6 +2,7 @@ import { Map, LngLatBounds, LngLat, IControl } from "@maptiler/sdk"; import { ModelViewerElement } from "@google/model-viewer"; +import * as platformConstants from "./platform-constants.ts"; import EventEmitter from "events"; import * as THREE from "three"; @@ -34,6 +35,7 @@ function removeDomNode(node: HTMLElement) { const MIN_TERRAIN_ZOOM = 12; const TERRAIN_TILE_SIZE = 512; +const MAX_ZOOM = 16; function latLon2Tile( zoom: number, @@ -261,6 +263,12 @@ export type MaptilerARControlOptions = { * Default: `false` */ activateAR?: boolean; + + /** + * Generate a mesh with a higher resolution texture. + * Default: `false` + */ + highRes?: false; }; const defaultOptionValues: MaptilerARControlOptions = { @@ -288,6 +296,7 @@ const defaultOptionValues: MaptilerARControlOptions = { edgeColor: "#7b8487", logo: "", activateAR: false, + highRes: false, }; const defaultArButtonStyle = { @@ -389,12 +398,37 @@ export class MaptilerARControl extends EventEmitter implements IControl { iconSpan.className = "maplibregl-ctrl-icon"; iconSpan.setAttribute("aria-hidden", "true"); this.controlButton.appendChild(iconSpan); - iconSpan.innerHTML = ` - + const boxIcon = ` + `; + const boxIconWithBrackets = ` + + + + + + + + + + + + + + + + `; + + iconSpan.innerHTML = + this.options.activateAR && + (platformConstants.IS_AR_QUICKLOOK_CANDIDATE || + platformConstants.IS_WEBXR_AR_CANDIDATE) + ? boxIconWithBrackets + : boxIcon; + this.controlButton.type = "button"; this.controlButton.addEventListener("click", async () => { @@ -426,9 +460,8 @@ export class MaptilerARControl extends EventEmitter implements IControl { await this.buildMapModel(); - this.displayModal(); - // this.emit("computeEnd"); - // this.downloadUSDZ() + await this.displayModal(); + this.lock = false; } @@ -502,7 +535,7 @@ export class MaptilerARControl extends EventEmitter implements IControl { const topViewCameraSettings = { center: this.cameraSettings.center, - zoom: this.cameraSettings.zoom, + zoom: Math.min(this.cameraSettings.zoom, MAX_ZOOM), pitch: 0, bearing: 0, }; @@ -729,6 +762,12 @@ export class MaptilerARControl extends EventEmitter implements IControl { } private async computeTextures() { + const originalPixelRatio = this.map.getPixelRatio(); + + if (this.options.highRes) { + this.map.setPixelRatio(4); + } + // Set the view from top and axis-aligned this.enableTopView(); await this.computeColorData(); @@ -744,6 +783,10 @@ export class MaptilerARControl extends EventEmitter implements IControl { const distance = middleEast.distanceTo(middleWest); this.meterPerPixelCenter = distance / this.colorData?.width; + if (this.options.highRes) { + this.map.setPixelRatio(originalPixelRatio); + } + // Set the camera back to normal this.restoreMapSettings(); @@ -881,8 +924,9 @@ export class MaptilerARControl extends EventEmitter implements IControl { } } - // Flooring the minimum elevation to the lower hundred meter - minEle = Math.max(0, ~~(minEle / 100) * 100 - 100); + const almostMaxZoom = MAX_ZOOM - 1; + const z = Math.min(this.map.getZoom(), almostMaxZoom); + minEle = minEle - (50 * (z - almostMaxZoom) ** 2 + 30); for (let i = 0; i < positionBuf.length / 3; i += 1) { const r = this.terrainData.pixelData[i * 4]; @@ -980,7 +1024,7 @@ export class MaptilerARControl extends EventEmitter implements IControl { // error (err) => { - console.log("error:", err); + console.warn("error:", err); }, // options @@ -1048,7 +1092,6 @@ export class MaptilerARControl extends EventEmitter implements IControl { const container = this.map.getContainer(); const modelBlobGLB = await this.getModelBlobGLB(); const modelObjectURLGLB = URL.createObjectURL(modelBlobGLB); - this.emit("computeEnd"); this.modelViewer = new ModelViewerElement(); this.modelViewer.src = modelObjectURLGLB; @@ -1108,8 +1151,6 @@ export class MaptilerARControl extends EventEmitter implements IControl { this.modelViewer.appendChild(this.closeButton); - // this.modelViewer.activateAR(); - this.closeButton.addEventListener("click", async () => { this.close(); }); @@ -1138,14 +1179,40 @@ export class MaptilerARControl extends EventEmitter implements IControl { } // Automatically run the AR + let successfullyEnabledAR = false; if (this.options.activateAR) { // Wait for Model Viewer to be ready - this.modelViewer.addEventListener("load", () => { + this.modelViewer.addEventListener("load", async () => { if (this.modelViewer.canActivateAR) { - this.modelViewer.activateAR(); + try { + await this.modelViewer.activateAR(); + successfullyEnabledAR = true; + // Waiting a sec before fireing event because Quicklook takes some time to start + setTimeout(() => this.emit("computeEnd"), 1000); + } catch (e) { + console.warn("AR to be automatically activated but failed."); + this.emit("computeEnd"); + } + } else { + console.warn("AR cannot be automatically activated."); + this.emit("computeEnd"); } }); + } else { + this.emit("computeEnd"); } + + this.modelViewer.addEventListener( + "camera-change", + (e: CustomEvent) => { + // If AR was successfully enabled, then such event is fired when coming back to + // Mode Viewer 3D view, and auto activate AR also mean going back straight to SDK view + // without showing MV in between + if (successfullyEnabledAR && e.detail.source === "automatic") { + this.close(); + } + } + ); } close() { @@ -1153,7 +1220,10 @@ export class MaptilerARControl extends EventEmitter implements IControl { removeDomNode(this.arButton); removeDomNode(this.modelViewer); removeDomNode(this.closeButton); - removeDomNode(this.logoImgElement); + + if (this.logoImgElement) { + removeDomNode(this.logoImgElement); + } } /** diff --git a/src/maptiler-ar-control.ts b/src/maptiler-ar-control.ts index 81ddd63..c922995 100644 --- a/src/maptiler-ar-control.ts +++ b/src/maptiler-ar-control.ts @@ -1 +1,2 @@ export * from "./MaptilerARControl"; +export * from "./platform-constants"; diff --git a/src/platform-constants.ts b/src/platform-constants.ts new file mode 100644 index 0000000..cd98956 --- /dev/null +++ b/src/platform-constants.ts @@ -0,0 +1,123 @@ +/** + * This source is borrowed from Model-Viewer because the constants are not exposed + */ + +/* @license + * Copyright 2019 Google LLC. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +type SelfWithXR = Window & + typeof globalThis & { + XRSession?: XRSession; + opera?: any; + }; + +// NOTE(cdata): The HAS_WEBXR_* constants can be enabled in Chrome by turning on +// the appropriate flags. However, just because we have the API does not +// guarantee that AR will work. +export const HAS_WEBXR_DEVICE_API = + navigator.xr != null && + (self as SelfWithXR).XRSession != null && + navigator.xr.isSessionSupported != null; + +export const HAS_WEBXR_HIT_TEST_API = + HAS_WEBXR_DEVICE_API && + (self as SelfWithXR).XRSession && + (self as SelfWithXR).XRSession?.requestHitTestSource != null; + +export const HAS_RESIZE_OBSERVER = self.ResizeObserver != null; + +export const HAS_INTERSECTION_OBSERVER = self.IntersectionObserver != null; + +export const IS_WEBXR_AR_CANDIDATE = HAS_WEBXR_HIT_TEST_API; +export const IS_MOBILE = (() => { + const userAgent = + navigator.userAgent || navigator.vendor || (self as SelfWithXR).opera; + let check = false; + // eslint-disable-next-line + if ( + /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test( + userAgent + ) || + /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test( + userAgent.substr(0, 4) + ) + ) { + check = true; + } + return check; +})(); + +export const IS_CHROMEOS = /\bCrOS\b/.test(navigator.userAgent); + +export const IS_ANDROID = /android/i.test(navigator.userAgent); + +// Prior to iOS 13, detecting iOS Safari was relatively straight-forward. +// As of iOS 13, Safari on iPad (in its default configuration) reports the same +// user-agent string as Safari on desktop MacOS. Strictly speaking, we only care +// about iOS for the purposes if selecting for cases where Quick Look is known +// to be supported. However, for API correctness purposes, we must rely on +// known, detectable signals to distinguish iOS Safari from MacOS Safari. At the +// time of this writing, there are no non-iOS/iPadOS Apple devices with +// multi-touch displays. +// @see https://stackoverflow.com/questions/57765958/how-to-detect-ipad-and-ipad-os-version-in-ios-13-and-up +// @see https://forums.developer.apple.com/thread/119186 +// @see https://github.com/google/model-viewer/issues/758 +export const IS_IOS = + (/iPad|iPhone|iPod/.test(navigator.userAgent) && !(self as any).MSStream) || + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); + +// @see https://developer.chrome.com/multidevice/user-agent +export const IS_SAFARI = /Safari\//.test(navigator.userAgent); +export const IS_FIREFOX = /firefox/i.test(navigator.userAgent); +export const IS_OCULUS = /OculusBrowser/.test(navigator.userAgent); +export const IS_IOS_CHROME = IS_IOS && /CriOS\//.test(navigator.userAgent); +export const IS_IOS_SAFARI = IS_IOS && IS_SAFARI; + +export const IS_SCENEVIEWER_CANDIDATE = IS_ANDROID && !IS_FIREFOX && !IS_OCULUS; + +// Extend Window type with webkit property, +// required to check if iOS is running within a WKWebView browser instance. +declare global { + interface Window { + webkit?: any; + } +} + +export const IS_WKWEBVIEW = Boolean( + window.webkit && window.webkit.messageHandlers +); + +// If running in iOS Safari proper, and not within a WKWebView component instance, check for ARQL feature support. +// Otherwise, if running in a WKWebView instance, check for known ARQL compatible iOS browsers, including: +// Chrome (CriOS), Edge (EdgiOS), Firefox (FxiOS), Google App (GSA), DuckDuckGo (DuckDuckGo). +// All other iOS browsers / apps will fail by default. +export const IS_AR_QUICKLOOK_CANDIDATE = (() => { + if (IS_IOS) { + if (!IS_WKWEBVIEW) { + const tempAnchor = document.createElement("a"); + return Boolean( + tempAnchor.relList && + tempAnchor.relList.supports && + tempAnchor.relList.supports("ar") + ); + } else { + return Boolean( + /CriOS\/|EdgiOS\/|FxiOS\/|GSA\/|DuckDuckGo\//.test(navigator.userAgent) + ); + } + } else { + return false; + } +})();