diff --git a/core/frontend/src/public/images/google_on_white_hdpi.png b/core/frontend/src/public/images/google_on_white_hdpi.png new file mode 100644 index 000000000000..5424b56d5da6 Binary files /dev/null and b/core/frontend/src/public/images/google_on_white_hdpi.png differ diff --git a/test-apps/display-test-app/src/frontend/GoogleMapDecorator.ts b/test-apps/display-test-app/src/frontend/GoogleMapDecorator.ts new file mode 100644 index 000000000000..be048f2bb293 --- /dev/null +++ b/test-apps/display-test-app/src/frontend/GoogleMapDecorator.ts @@ -0,0 +1,67 @@ +import { CanvasDecoration, DecorateContext, Decorator, IconSprites, IModelApp, ScreenViewport, Sprite } from "@itwin/core-frontend"; +import { Point3d, XYAndZ } from "@itwin/core-geometry"; +import { GoogleMapsMapType } from "./GoogleMaps"; + +// Similar to 'SprintLocation' but uses a viewport pixel position instead of world position +class ImagePixelLocationDecoration implements CanvasDecoration { + private _viewport?: ScreenViewport; + private _sprite?: Sprite; + private _alpha?: number; + public readonly position = new Point3d(); + public get isActive(): boolean { return this._viewport !== undefined; } + + public activate(sprite: Sprite, viewport: ScreenViewport, position: XYAndZ): void { + this._sprite = sprite; + this._viewport = viewport; + this.position.setFrom(position); + sprite.loadPromise.then(() => { + if (this._viewport === viewport) // was this deactivated while we were loading? + viewport.invalidateDecorations(); + }).catch(() => this._viewport = undefined); // sprite was not loaded properly + } + + public deactivate() { + if (!this.isActive) + return; + this._viewport!.invalidateDecorations(); + this._viewport = undefined; + } + + /** Draw this sprite onto the supplied canvas. + * @see [[CanvasDecoration.drawDecoration]] + */ + public drawDecoration(ctx: CanvasRenderingContext2D): void { + const sprite = this._sprite!; + if (undefined === sprite.image) + return; + + if (undefined !== this._alpha) + ctx.globalAlpha = this._alpha; + + ctx.drawImage(sprite.image, -sprite.offset.x, -sprite.offset.y); + } + + /** If this SpriteLocation is active and the supplied DecorateContext is for its Viewport, add the Sprite to decorations. */ + public decorate(context: DecorateContext) { + if (context.viewport === this._viewport) + context.addCanvasDecoration(this); + } +} + +export class GoogleMapsDecorator implements Decorator { + public readonly logo = new ImagePixelLocationDecoration(); + private _sprite: Sprite|undefined; + public constructor() { + } + + public activate = (viewport: ScreenViewport, mapType: GoogleMapsMapType) => { + const vpHeight = viewport.parentDiv.clientHeight; + const imageName = mapType === "roadmap" ? "google_on_white" : "google_on_non_white"; + this._sprite = IconSprites.getSpriteFromUrl(`${IModelApp.publicPath}images/${imageName}.png`); + this.logo.activate(this._sprite, viewport, {x: 100, y: vpHeight - 20, z: 0}); + }; + + public decorate = (context: DecorateContext) => { + this.logo.decorate(context); + }; +} diff --git a/test-apps/display-test-app/src/frontend/GoogleMaps.ts b/test-apps/display-test-app/src/frontend/GoogleMaps.ts new file mode 100644 index 000000000000..079b343e0a6a --- /dev/null +++ b/test-apps/display-test-app/src/frontend/GoogleMaps.ts @@ -0,0 +1,116 @@ +import { BackgroundMapType, BaseMapLayerSettings } from "@itwin/core-common"; +import { IModelApp, MapCartoRectangle, Viewport } from "@itwin/core-frontend"; +import { GoogleMapsMapLayerFormat } from "./GoogleMapsImageryFormat"; +import { Angle } from "@itwin/core-geometry"; + +export const getGoogleMapsLayerCode = (bgMapType: BackgroundMapType) => { + let layer = "y"; // default to hybrid + switch (bgMapType) { + case BackgroundMapType.Aerial: + layer = "s"; + break; + case BackgroundMapType.Street: + layer = "m"; + break; + } + return layer; +}; + +export const enableLegacyGoogleMaps = (viewport: Viewport, bgMapType: BackgroundMapType) => { + const displayStyle = viewport.displayStyle; + const googleLayer = getGoogleMapsLayerCode(bgMapType); + displayStyle.backgroundMapBase = BaseMapLayerSettings.fromJSON({ + formatId: "TileURL", + url: `https://mt0.google.com/vt/lyrs=${googleLayer}&hl=en&x={column}&y={row}&z={level}`, + name: "google", + }); +}; + +export type GoogleMapsMapType = + "roadmap" // Roads, buildings, points of interest, and political boundaries + | "satellite" // Photographic imagery taken from space + | "terrain"; // A contour map that shows natural features such as vegetation + +export interface CreateGoogleMapsSessionOptions { + mapType: GoogleMapsMapType; + language: string; // https://en.wikipedia.org/wiki/IETF_language_tag (i.e. en-US) + region: string; // https://cldr.unicode.org/ (i.e. US) + orientation?: number; // 0 (the default), 90, 180, and 270 + layerTypes?: string[]; // i.e. ["layerRoadmap"] +}; + +export interface GoogleMapsSession { + expiry: number; + imageFormat: string; + session: string; + tileHeight: number; + tileWidth: number; +}; + +export interface GoogleMapsMaxZoomRect { + maxZoom: number; + north: number; + south: number; + east: number; + west: number; +} + +/** Indicate which areas of given viewport have imagery, and at which zoom levels. */ +export interface GoogleMapsViewportInfo { + /** Attribution string that you must display on your map when you display roadmap and satellite tiles. */ + copyright: string; + + /** Array of bounding rectangles that overlap with the current viewport. Also contains the maximum zoom level available within each rectangle.. */ + maxZoomRects: GoogleMapsMaxZoomRect[]; +} + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const GoogleMaps = { + + apiKey: "", + + createSession: async (apiKey: string, opts: CreateGoogleMapsSessionOptions): Promise => { + const url = `https://tile.googleapis.com/v1/createSession?key=${apiKey}`; + const request = new Request(url, {method: "POST", body: JSON.stringify(opts)}); + const response = await fetch (request); + if (!response.ok) { + throw new Error(`CreateSession request failed: ${response.status} - ${response.statusText}`); + } + return response.json(); + }, + + createBaseMapLayerSettings: (subLayerName: string, opts: CreateGoogleMapsSessionOptions) => { + const registry = IModelApp.mapLayerFormatRegistry; + if (!registry.isRegistered(GoogleMapsMapLayerFormat.formatId)) { + registry.register(GoogleMapsMapLayerFormat); + } + + const sessionOptsStr = JSON.stringify(opts); + const settings = BaseMapLayerSettings.fromJSON({ + formatId: "GoogleMaps", + url: `https://tile.googleapis.com/v1/2dtiles/{level}/{column}/{row}`, + // url: `https://tile.googleapis.com/v1/2dtiles/{level}/{column}/{row}?session=${session}&key=${key}`, + name: "GoogleMaps", + subLayers: (subLayerName !== undefined ? [{name: subLayerName, visible: true, title: sessionOptsStr}] : undefined), + }); + // settings.unsavedQueryParams = {session,key}; + return settings; + }, + + getViewportInfo: async (rectangle: MapCartoRectangle, zoom: number, session: string, key: string): Promise=> { + const north = Angle.radiansToDegrees(rectangle.north); + const south = Angle.radiansToDegrees(rectangle.south); + const east = Angle.radiansToDegrees(rectangle.east); + const west = Angle.radiansToDegrees(rectangle.west); + const url = `https://tile.googleapis.com/tile/v1/viewport?session=${session}&key=${key}&zoom=${zoom}&north=${north}&south=${south}&east=${east}&west=${west}`; + const request = new Request(url, {method: "GET"}); + const response = await fetch (request); + if (!response.ok) { + return undefined; + } + const json = await response.json(); + return json as GoogleMapsViewportInfo;; + }, + +}; + diff --git a/test-apps/display-test-app/src/frontend/GoogleMapsImageryFormat.ts b/test-apps/display-test-app/src/frontend/GoogleMapsImageryFormat.ts new file mode 100644 index 000000000000..6ee4a8e92a02 --- /dev/null +++ b/test-apps/display-test-app/src/frontend/GoogleMapsImageryFormat.ts @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Bentley Systems, Incorporated. All rights reserved. +* See LICENSE.md in the project root for license terms and full copyright notice. +*--------------------------------------------------------------------------------------------*/ + +import { ImageMapLayerSettings } from "@itwin/core-common"; +import { ImageryMapLayerFormat, MapLayerImageryProvider } from "@itwin/core-frontend"; +import { GoogleMapsImageryProvider } from "./GoogleMapsImageryProvider"; + +export class GoogleMapsMapLayerFormat extends ImageryMapLayerFormat { + public static override formatId = "GoogleMaps"; + public static override createImageryProvider(settings: ImageMapLayerSettings): MapLayerImageryProvider | undefined { + return new GoogleMapsImageryProvider(settings); + } +} diff --git a/test-apps/display-test-app/src/frontend/GoogleMapsImageryProvider.ts b/test-apps/display-test-app/src/frontend/GoogleMapsImageryProvider.ts new file mode 100644 index 000000000000..d269e2983d7d --- /dev/null +++ b/test-apps/display-test-app/src/frontend/GoogleMapsImageryProvider.ts @@ -0,0 +1,133 @@ +import { ImageMapLayerSettings, ImageSource } from "@itwin/core-common"; +import { IModelApp, MapCartoRectangle, MapLayerImageryProvider, MapLayerSourceStatus, MapLayerSourceValidation, MapTile, QuadId, ScreenViewport, Tile, TileUrlImageryProvider } from "@itwin/core-frontend"; +import { Angle } from "@itwin/core-geometry"; +import { GoogleMaps } from "./GoogleMaps"; + +const levelToken = "{level}"; +const rowToken = "{row}"; +const columnToken = "{column}"; + +/** Number of load tile requests before we have to refresh the attributions data */ +const attributionsRefreshCount = 40; + +export class GoogleMapsImageryProvider extends MapLayerImageryProvider { + + private static _sessions: {[layerName: string]: string} = {}; + private _attributions: string[]|undefined; + private _loadTileCounter = 0; + private _subLayerName = ""; + constructor(settings: ImageMapLayerSettings) { + super(settings, true); + } + public static validateUrlTemplate(template: string): MapLayerSourceValidation { + return { status: (template.indexOf(levelToken) > 0 && template.indexOf(columnToken) > 0 && template.indexOf(rowToken) > 0) ? MapLayerSourceStatus.Valid : MapLayerSourceStatus.InvalidUrl }; + } + + public override async initialize(): Promise { + + if (this._settings.subLayers.length === 0) { + return; + } + const subLayer = this._settings.subLayers[0]; + this._subLayerName = subLayer.name; + if (subLayer.title === undefined) { + console.log(`Missing subLayer title`); + return; + } + if (GoogleMapsImageryProvider._sessions[this._subLayerName] !== undefined) { + console.log(`Session already exists for layer ${this._subLayerName}`); + } + + const opts = JSON.parse(subLayer.title); + GoogleMapsImageryProvider._sessions[subLayer.name] = (await GoogleMaps.createSession(GoogleMaps.apiKey, opts)).session; + } + + // construct the Url from the desired Tile + public async constructUrl(row: number, column: number, level: number): Promise { + let url = this._settings.url; + if (TileUrlImageryProvider.validateUrlTemplate(url).status !== MapLayerSourceStatus.Valid) { + if (url.lastIndexOf("/") !== url.length - 1) + url = `${url}/`; + url = `${url}{level}/{column}/{row}.png`; + } + + const tmpUrl = url.replace(levelToken, level.toString()).replace(columnToken, column.toString()).replace(rowToken, row.toString()); + const obj = new URL(tmpUrl); + const sessionId = GoogleMapsImageryProvider._sessions[this._subLayerName]; + if (sessionId && GoogleMaps.apiKey ) { + obj.searchParams.append("session", sessionId); + obj.searchParams.append("key", GoogleMaps.apiKey); + } else { + console.log(`Missing apiKey or sessionId`); + } + + return this.appendCustomParams(obj.toString()); + } + + private async getAttributions(row: number, column: number, zoomLevel: number): Promise { + let attributions: string[] = []; + const queryParams = this._settings.collectQueryParams(); + + const key = GoogleMaps.apiKey; + const session = GoogleMapsImageryProvider._sessions[this._subLayerName]; + if (session === undefined || key === undefined) { + console.log(`Missing apiKey or sessionId`); + return attributions; + } + + const extent = this.getEPSG4326Extent(row, column, zoomLevel); + const range = MapCartoRectangle.fromDegrees(extent.longitudeLeft, extent.latitudeBottom, extent.longitudeRight, extent.latitudeTop); + + if (!session) { + + return attributions; + } + try { + const viewportInfo = await GoogleMaps.getViewportInfo(range, zoomLevel, session, key); + if (viewportInfo) { + attributions = viewportInfo.copyright.split(","); + } + } catch (err: any) { + + } + return attributions; + } + + public override async loadTile(row: number, column: number, zoomLevel: number): Promise { + // This is a hack until 'addLogoCards' is made async + if ((this._loadTileCounter++%attributionsRefreshCount === 0)) { + this._attributions = await this.getAttributions(row, column, zoomLevel); + } + + return super.loadTile(row, column, zoomLevel); + } + + public override addLogoCards(cards: HTMLTableElement, _vp: ScreenViewport): void { + // const tiles = IModelApp.tileAdmin.getTilesForUser(vp)?.selected; + // const matchingAttributions = this.getMatchingAttributions(tiles); + // const copyrights: string[] = []; + // for (const match of matchingAttributions) + // copyrights.push(match.copyrightMessage); + + // let copyrightMsg = ""; + // for (let i = 0; i < copyrights.length; ++i) { + // if (i > 0) + // copyrightMsg += "
"; + // copyrightMsg += copyrights[i]; + // } + // const tiles = IModelApp.tileAdmin.getTilesForUser(vp)?.selected; + // const attributions = await this.getAttributions(tiles); + if (!this._attributions) + return; + + const attributions = this._attributions; + let copyrightMsg = ""; + for (let i = 0; i < attributions.length; ++i) { + if (i > 0) + copyrightMsg += "
"; + copyrightMsg += attributions[i]; + } + + cards.appendChild(IModelApp.makeLogoCard({ iconSrc: `${IModelApp.publicPath}images/google_on_white_hdpi.png`, heading: "Google Maps", notice: copyrightMsg })); + } +} diff --git a/test-apps/display-test-app/src/frontend/Viewer.ts b/test-apps/display-test-app/src/frontend/Viewer.ts index 6ec4203e7502..d14a00fd437f 100644 --- a/test-apps/display-test-app/src/frontend/Viewer.ts +++ b/test-apps/display-test-app/src/frontend/Viewer.ts @@ -1,10 +1,11 @@ +/* eslint-disable no-console */ /*--------------------------------------------------------------------------------------------- * Copyright (c) Bentley Systems, Incorporated. All rights reserved. * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ import { Id64String } from "@itwin/core-bentley"; import { ClipPlane, ClipPrimitive, ClipVector, ConvexClipPlaneSet, Vector3d } from "@itwin/core-geometry"; -import { ModelClipGroup, ModelClipGroups } from "@itwin/core-common"; +import { BackgroundMapType, BaseMapLayerSettings, ModelClipGroup, ModelClipGroups } from "@itwin/core-common"; import { IModelApp, IModelConnection, MarginOptions, MarginPercent, NotifyMessageDetails, openImageDataUrlInNewWindow, OutputMessagePriority, PaddingPercent, ScreenViewport, Tool, Viewport, ViewState, @@ -29,7 +30,7 @@ import { Window } from "./Window"; import { openIModel, OpenIModelProps } from "./openIModel"; import { HubPicker } from "./HubPicker"; import { RealityModelSettingsPanel } from "./RealityModelDisplaySettingsWidget"; -import { ContoursPanel } from "./Contours"; +import { CreateGoogleMapsSessionOptions, GoogleMaps } from "./GoogleMaps"; // cspell:ignore savedata topdiv savedview viewtop @@ -176,6 +177,13 @@ export class Viewer extends Window { const views = await ViewList.create(props.iModel, props.defaultViewName); const view = await views.getDefaultView(props.iModel); const viewer = new Viewer(surface, view, views, props); + + const opts: CreateGoogleMapsSessionOptions = { + mapType: "satellite", + language: "en-US", + region: "US", + }; + return viewer; } @@ -218,6 +226,7 @@ export class Viewer extends Window { this.disableEdges = true === props.disableEdges; this._imodel = props.iModel; this.viewport = ScreenViewport.create(this.contentDiv, view); + this.views = views; this._maybeDisableEdges(); @@ -407,14 +416,72 @@ export class Viewer extends Window { tooltip: "Point cloud settings", }); - this.toolBar.addDropDown({ - iconUnicode: "\ue94b", - createDropDown: async (container: HTMLElement) => { - const panel = new ContoursPanel(this.viewport, container); - return panel; + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + const apiKey = "***INSERT YOUR API KEY HERE***"; + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + + click: async () => { + + IModelApp.viewManager.addDecorator(decorator); + + console.log("Google Maps"); + // enableLegacyGoogleMaps(this.viewport, BackgroundMapType.Aerial); + // const opts: CreateGoogleMapsSessionOptions = { + // mapType: "terrain", + // language: "en-US", + // region: "US", + // layerTypes: ["layerRoadmap"], + // }; + const opts: CreateGoogleMapsSessionOptions = { + mapType: "satellite", + language: "en-US", + region: "US", + }; + + try { + GoogleMaps.apiKey = apiKey; + this.viewport.displayStyle.backgroundMapBase = GoogleMaps.createBaseMapLayerSettings("satellite", opts); + console.log(`Session created successfully`); + } catch (e: any) { + console.log(e.message); + } + + }, + tooltip: "Google Maps", + })); + + this.toolBar.addItem(createToolButton({ + iconUnicode: "\ue9e8", + click: async () => { + const decorator = new GoogleMapsDecorator(); + + IModelApp.viewManager.addDecorator(decorator); + + console.log("Google Maps"); + // enableLegacyGoogleMaps(this.viewport, BackgroundMapType.Aerial); + // const opts: CreateGoogleMapsSessionOptions = { + // language: "en-US", + // region: "US", + // layerTypes: ["layerRoadmap"], + // }; + const opts: CreateGoogleMapsSessionOptions = { + mapType: "roadmap", + language: "en-US", + region: "US", + }; + + decorator.activate(this.viewport, opts.mapType); + try { + + // const session = await GoogleMaps.createSession(apiKey, opts); + GoogleMaps.apiKey = apiKey; + this.viewport.displayStyle.backgroundMapBase = GoogleMaps.createBaseMapLayerSettings("roadmap", opts); + console.log(`Session created successfully`); + } catch (e: any) { + console.log(e.message); + } + }, - tooltip: "Contour display", - }); this.updateTitle(); this.updateActiveSettings();