diff --git a/common/api/core-bentley.api.md b/common/api/core-bentley.api.md index d44efa7fc986..c88ede68b3d4 100644 --- a/common/api/core-bentley.api.md +++ b/common/api/core-bentley.api.md @@ -224,6 +224,12 @@ export function compareNumbersOrUndefined(lhs?: number, rhs?: number): number; // @public (undocumented) export function comparePossiblyUndefined(compareDefined: (lhs: T, rhs: T) => number, lhs?: T, rhs?: T): number; +// @beta (undocumented) +export function compareSimpleArrays(lhs?: Array, rhs?: Array): number; + +// @beta (undocumented) +export function compareSimpleTypes(lhs: number | string | boolean, rhs: number | string | boolean): number; + // @public (undocumented) export function compareStrings(a: string, b: string): number; diff --git a/common/api/core-common.api.md b/common/api/core-common.api.md index 6a18569f620c..6d66cf0d0c2d 100644 --- a/common/api/core-common.api.md +++ b/common/api/core-common.api.md @@ -4786,6 +4786,8 @@ export interface ImageMapLayerProps extends CommonMapLayerProps { // @internal (undocumented) modelId?: never; // @beta + properties?: MapLayerProviderProperties; + // @beta queryParams?: { [key: string]: string; }; @@ -4820,6 +4822,8 @@ export class ImageMapLayerSettings extends MapLayerSettings { // (undocumented) password?: string; // @beta + readonly properties?: MapLayerProviderProperties; + // @beta savedQueryParams?: { [key: string]: string; }; @@ -5727,6 +5731,15 @@ export interface MapLayerKey { // @public export type MapLayerProps = ImageMapLayerProps | ModelMapLayerProps; +// @beta (undocumented) +export type MapLayerProviderArrayProperty = Array; + +// @beta (undocumented) +export interface MapLayerProviderProperties { + // (undocumented) + [key: string]: number | string | boolean | MapLayerProviderArrayProperty; +} + // @public export abstract class MapLayerSettings { // @internal diff --git a/common/api/core-frontend.api.md b/common/api/core-frontend.api.md index cf2f52cf04c7..fe733631e1a8 100644 --- a/common/api/core-frontend.api.md +++ b/common/api/core-frontend.api.md @@ -4675,6 +4675,8 @@ export class ImageryMapLayerTreeReference extends MapLayerTileTreeReference { export class ImageryMapTileTree extends RealityTileTree { constructor(params: RealityTileTreeParams, _imageryLoader: ImageryTileLoader); // (undocumented) + addAttributions(cards: HTMLTableElement, vp: ScreenViewport): Promise; + // @deprecated (undocumented) addLogoCards(cards: HTMLTableElement, vp: ScreenViewport): void; // (undocumented) cartoRectangleFromQuadId(quadId: QuadId): MapCartoRectangle; @@ -5658,6 +5660,8 @@ export type MapLayerFormatType = typeof MapLayerFormat; // @beta export abstract class MapLayerImageryProvider { constructor(_settings: ImageMapLayerSettings, _usesCachedTiles: boolean); + addAttributions(cards: HTMLTableElement, vp: ScreenViewport): Promise; + // @deprecated (undocumented) addLogoCards(_cards: HTMLTableElement, _viewport: ScreenViewport): void; // @internal protected appendCustomParams(url: string): string; @@ -5667,6 +5671,8 @@ export abstract class MapLayerImageryProvider { cartoRange?: MapCartoRectangle; // (undocumented) abstract constructUrl(row: number, column: number, zoomLevel: number): Promise; + // @internal (undocumented) + decorate(_context: DecorateContext): void; // @internal protected readonly defaultMaximumZoomLevel = 22; // @internal @@ -5906,6 +5912,8 @@ export abstract class MapLayerTileTreeReference extends TileTreeReference { // (undocumented) canSupplyToolTip(hit: HitDetail): boolean; // (undocumented) + decorate(_context: DecorateContext): void; + // (undocumented) getToolTip(hit: HitDetail): Promise; get imageryProvider(): MapLayerImageryProvider | undefined; // (undocumented) @@ -6161,6 +6169,8 @@ export class MapTileTree extends RealityTileTree { // @internal export class MapTileTreeReference extends TileTreeReference { constructor(settings: BackgroundMapSettings, _baseLayerSettings: BaseLayerSettings | undefined, _layerSettings: MapLayerSettings[], iModel: IModelConnection, tileUserId: number, isOverlay: boolean, _isDrape: boolean, _overrideTerrainDisplay?: CheckTerrainDisplayOverride | undefined); + addAttributions(cards: HTMLTableElement, vp: ScreenViewport): Promise; + // @deprecated (undocumented) addLogoCards(cards: HTMLTableElement, vp: ScreenViewport): void; addToScene(context: SceneContext): void; // (undocumented) @@ -6176,6 +6186,8 @@ export class MapTileTreeReference extends TileTreeReference { // (undocumented) protected _createGeometryTreeReference(): GeometryTileTreeReference | undefined; // (undocumented) + decorate(context: DecorateContext): void; + // (undocumented) discloseTileTrees(trees: DisclosedTileTreeSet): void; // (undocumented) forEachLayerTileTreeRef(func: (ref: TileTreeReference) => void): void; @@ -10149,6 +10161,8 @@ export class TerrainDisplayOverrides { // @public export abstract class TerrainMeshProvider { + addAttributions(cards: HTMLTableElement, vp: ScreenViewport): Promise; + // @deprecated (undocumented) addLogoCards(_cards: HTMLTableElement, _vp: ScreenViewport): void; forceTileLoad(_tile: MapTile): boolean; getChildHeightRange(quadId: QuadId, rectangle: MapCartoRectangle, parent: MapTile): Range1d | undefined; @@ -11044,6 +11058,9 @@ export interface TileTreeParams { export abstract class TileTreeReference { // (undocumented) accumulateTransformedRange(range: Range3d, matrix: Matrix4d, frustumPlanes?: FrustumPlanes): void; + // @beta + addAttributions(cards: HTMLTableElement, vp: ScreenViewport): Promise; + // @deprecated (undocumented) addLogoCards(_cards: HTMLTableElement, _vp: ScreenViewport): void; addToScene(context: SceneContext): void; canSupplyToolTip(_hit: HitDetail): boolean; diff --git a/common/api/map-layers-formats.api.md b/common/api/map-layers-formats.api.md index 6423ec7fa6cb..61f5c1894519 100644 --- a/common/api/map-layers-formats.api.md +++ b/common/api/map-layers-formats.api.md @@ -5,6 +5,7 @@ ```ts import { ArcGISImageryProvider } from '@itwin/core-frontend'; +import { BaseMapLayerSettings } from '@itwin/core-common'; import { BeButtonEvent } from '@itwin/core-frontend'; import { BeEvent } from '@itwin/core-bentley'; import { Cartographic } from '@itwin/core-common'; @@ -18,6 +19,7 @@ import { Listener } from '@itwin/core-bentley'; import { Localization } from '@itwin/core-common'; import { LocateFilterStatus } from '@itwin/core-frontend'; import { LocateResponse } from '@itwin/core-frontend'; +import { MapCartoRectangle } from '@itwin/core-frontend'; import { MapFeatureInfo } from '@itwin/core-frontend'; import { MapFeatureInfoOptions } from '@itwin/core-frontend'; import { MapLayerFeatureInfo } from '@itwin/core-frontend'; @@ -56,6 +58,17 @@ export class ArcGisFeatureProvider extends ArcGISImageryProvider { get tileSize(): number; } +// @beta +export interface CreateSessionOptions { + apiOptions?: string[]; + language: string; + layerTypes?: LayerTypes[]; + mapType: MapTypes; + overlay?: boolean; + region: string; + scale?: ScaleFactors; +} + // @internal (undocumented) export class DefaultArcGiSymbology implements FeatureDefaultSymbology { // (undocumented) @@ -72,6 +85,24 @@ export class DefaultArcGiSymbology implements FeatureDefaultSymbology { initialize(): Promise; } +// @beta +export const GoogleMaps: { + createMapLayerSettings: (name?: string, opts?: CreateSessionOptions) => ImageMapLayerSettings; + createBaseLayerSettings: (opts?: CreateSessionOptions) => BaseMapLayerSettings; +}; + +// @beta +export interface GoogleMapsSession { + expiry: number; + imageFormat: string; + session: string; + tileHeight: number; + tileWidth: number; +} + +// @beta (undocumented) +export type LayerTypes = "layerRoadmap" | "layerStreetview"; + // @beta export class MapFeatureInfoTool extends PrimitiveTool { // (undocumented) @@ -126,6 +157,40 @@ export interface MapLayersFormatsConfig { localization?: Localization; } +// @beta (undocumented) +export type MapTypes = "roadmap" | "satellite" | "terrain"; + +// @beta +export interface MaxZoomRectangle { + // (undocumented) + east: number; + // (undocumented) + maxZoom: number; + // (undocumented) + north: number; + // (undocumented) + south: number; + // (undocumented) + west: number; +} + +// @beta (undocumented) +export type ScaleFactors = "scaleFactor1x" | "scaleFactor2x" | "scaleFactor4x"; + +// @beta +export interface ViewportInfo { + copyright: string; + maxZoomRects: MaxZoomRectangle[]; +} + +// @beta +export interface ViewportInfoRequestParams { + key: string; + rectangle: MapCartoRectangle; + session: string; + zoom: number; +} + // (No @packageDocumentation comment for this package) ``` diff --git a/common/api/summary/core-bentley.exports.csv b/common/api/summary/core-bentley.exports.csv index 37cc23cb9cd6..5bfcdd72352f 100644 --- a/common/api/summary/core-bentley.exports.csv +++ b/common/api/summary/core-bentley.exports.csv @@ -25,6 +25,8 @@ public;function;compareBooleansOrUndefined public;function;compareNumbers public;function;compareNumbersOrUndefined public;function;comparePossiblyUndefined +beta;function;compareSimpleArrays +beta;function;compareSimpleTypes public;function;compareStrings public;function;compareStringsOrUndefined public;function;compareWithTolerance diff --git a/common/api/summary/core-common.exports.csv b/common/api/summary/core-common.exports.csv index 3d31e5754dcf..6180cff80c3f 100644 --- a/common/api/summary/core-common.exports.csv +++ b/common/api/summary/core-common.exports.csv @@ -491,6 +491,8 @@ public;interface;MapImageryProps public;class;MapImagerySettings public;interface;MapLayerKey public;type;MapLayerProps +beta;type;MapLayerProviderArrayProperty +beta;interface;MapLayerProviderProperties public;class;MapLayerSettings public;interface;MapSubLayerProps public;class;MapSubLayerSettings diff --git a/common/api/summary/map-layers-formats.exports.csv b/common/api/summary/map-layers-formats.exports.csv index d25ca026d468..13da2cd3ac83 100644 --- a/common/api/summary/map-layers-formats.exports.csv +++ b/common/api/summary/map-layers-formats.exports.csv @@ -1,8 +1,17 @@ sep=; Release Tag;API Item Type;API Item Name internal;class;ArcGisFeatureProvider +beta;interface;CreateSessionOptions internal;class;DefaultArcGiSymbology +beta;const;GoogleMaps +beta;interface;GoogleMapsSession +beta;type;LayerTypes beta;class;MapFeatureInfoTool beta;interface;MapFeatureInfoToolData beta;class;MapLayersFormats -beta;interface;MapLayersFormatsConfig \ No newline at end of file +beta;interface;MapLayersFormatsConfig +beta;type;MapTypes +beta;interface;MaxZoomRectangle +beta;type;ScaleFactors +beta;interface;ViewportInfo +beta;interface;ViewportInfoRequestParams \ No newline at end of file diff --git a/common/changes/@itwin/core-bentley/MichelD-GoogleMaps_2025-02-12-19-37.json b/common/changes/@itwin/core-bentley/MichelD-GoogleMaps_2025-02-12-19-37.json new file mode 100644 index 000000000000..7ffffe38456f --- /dev/null +++ b/common/changes/@itwin/core-bentley/MichelD-GoogleMaps_2025-02-12-19-37.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@itwin/core-bentley", + "comment": "Add `compareSimpleTypes` and `compareSimpleArrays` to compare simple data.", + "type": "none" + } + ], + "packageName": "@itwin/core-bentley" +} \ No newline at end of file diff --git a/common/changes/@itwin/core-common/MichelD-GoogleMaps_2025-02-12-19-37.json b/common/changes/@itwin/core-common/MichelD-GoogleMaps_2025-02-12-19-37.json new file mode 100644 index 000000000000..681cdde6a21e --- /dev/null +++ b/common/changes/@itwin/core-common/MichelD-GoogleMaps_2025-02-12-19-37.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@itwin/core-common", + "comment": "Add new `properties` property to `ImageMapLayerSettings` class.", + "type": "none" + } + ], + "packageName": "@itwin/core-common" +} \ No newline at end of file diff --git a/common/changes/@itwin/core-frontend/MichelD-GoogleMaps_2025-02-12-19-37.json b/common/changes/@itwin/core-frontend/MichelD-GoogleMaps_2025-02-12-19-37.json new file mode 100644 index 000000000000..97a1820815c9 --- /dev/null +++ b/common/changes/@itwin/core-frontend/MichelD-GoogleMaps_2025-02-12-19-37.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@itwin/core-frontend", + "comment": "Add new async `addAttributions` method on `TileTreeReference`. Also make sure ImageryTileTree / providers are included when decorating the view.", + "type": "none" + } + ], + "packageName": "@itwin/core-frontend" +} \ No newline at end of file diff --git a/common/changes/@itwin/map-layers-formats/MichelD-GoogleMaps_2025-02-12-19-37.json b/common/changes/@itwin/map-layers-formats/MichelD-GoogleMaps_2025-02-12-19-37.json new file mode 100644 index 000000000000..1ed639c09dab --- /dev/null +++ b/common/changes/@itwin/map-layers-formats/MichelD-GoogleMaps_2025-02-12-19-37.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@itwin/map-layers-formats", + "comment": "Added Google Maps 2D tiles support.", + "type": "none" + } + ], + "packageName": "@itwin/map-layers-formats" +} \ No newline at end of file diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 68167fa60342..87645e68269c 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -1795,6 +1795,9 @@ importers: '@itwin/itwins-client': specifier: ^1.2.0 version: 1.2.0 + '@itwin/map-layers-formats': + specifier: workspace:* + version: link:../../extensions/map-layers-formats '@itwin/service-authorization': specifier: ^1.0.0 version: 1.0.0(@itwin/core-bentley@..+core+bentley)(@itwin/core-geometry@..+core+geometry) diff --git a/core/bentley/src/Compare.ts b/core/bentley/src/Compare.ts index 4aed7c2132f1..d1c79c4df201 100644 --- a/core/bentley/src/Compare.ts +++ b/core/bentley/src/Compare.ts @@ -92,3 +92,62 @@ export function areEqualPossiblyUndefined(t: T | undefined, u: U | undefin else return areEqual(t, u); } + +/** + * Compare two simples types (number, string, boolean) + * This essentially wraps the existing type-specific comparison functions + * @beta */ +export function compareSimpleTypes(lhs: number | string | boolean, rhs: number | string | boolean): number { + let cmp = 0; + + // Make sure the types are the same + cmp = compareStrings(typeof lhs, typeof rhs); + if (cmp !== 0) { + return cmp; + } + + // Compare actual values + switch (typeof lhs) { + case "number": + cmp = compareNumbers(lhs, rhs as number); + break; + case "string": + cmp = compareStrings(lhs, rhs as string); + break; + case "boolean": + cmp = compareBooleans(lhs, rhs as boolean); + break; + } + return cmp; +} +/** + * An array of simple types (number, string, boolean) + * @beta + */ +export type SimpleTypesArray = number[] | string[] | boolean[]; + +/** + * Compare two arrays of simple types (number, string, boolean) + * @beta + */ + +export function compareSimpleArrays (lhs?: SimpleTypesArray, rhs?: SimpleTypesArray ) { + if (undefined === lhs) + return undefined === rhs ? 0 : -1; + else if (undefined === rhs) + return 1; + else if (lhs.length === 0 && rhs.length === 0) { + return 0; + } else if (lhs.length !== rhs.length) { + return lhs.length - rhs.length; + } + + let cmp = 0; + for (let i = 0; i < lhs.length; i++) { + cmp = compareSimpleTypes(lhs[i], rhs[i]); + if (cmp !== 0) { + break; + } + } + return cmp; +} \ No newline at end of file diff --git a/core/common/src/MapLayerSettings.ts b/core/common/src/MapLayerSettings.ts index 19c6123a552c..f981cf1bd002 100644 --- a/core/common/src/MapLayerSettings.ts +++ b/core/common/src/MapLayerSettings.ts @@ -19,6 +19,18 @@ export type ImageryMapLayerFormatId = "ArcGIS" | "BingMaps" | "MapboxImagery" | /** @public */ export type SubLayerId = string | number; +/** + * Type for map layer provider array property. + * @beta + */ +export type MapLayerProviderArrayProperty = number[] | string[] | boolean[]; + +/** + * Type for map layer provider properties. + * @beta + */ +export interface MapLayerProviderProperties { [key: string]: number | string | boolean | MapLayerProviderArrayProperty }; + /** JSON representation of the settings associated with a map sublayer included within a [[MapLayerProps]]. * A map sub layer represents a set of objects within the layer that can be controlled separately. These * are produced only from map servers that produce images on demand and are not supported by tiled (cached) servers. @@ -132,10 +144,12 @@ export interface CommonMapLayerProps { /** A user-friendly name for the layer. */ name: string; + /** A transparency value from 0.0 (fully opaque) to 1.0 (fully transparent) to apply to map graphics when drawing, * or false to indicate the transparency should not be overridden. * Default value: 0. */ + transparency?: number; /** True to indicate background is transparent. * Default: true. @@ -169,6 +183,11 @@ export interface ImageMapLayerProps extends CommonMapLayerProps { */ queryParams?: { [key: string]: string }; + /** Properties specific to the map layer provider. + * @beta + */ + properties?: MapLayerProviderProperties; + } /** JSON representation of a [[ModelMapLayerSettings]]. @@ -297,6 +316,12 @@ export class ImageMapLayerSettings extends MapLayerSettings { * @beta */ public unsavedQueryParams?: { [key: string]: string }; + + /** Properties specific to the map layer provider. + * @beta + */ + public readonly properties?: MapLayerProviderProperties; + public readonly subLayers: MapSubLayerSettings[]; public override get source(): string { return this.url; } @@ -311,6 +336,11 @@ export class ImageMapLayerSettings extends MapLayerSettings { if (props.queryParams) { this.savedQueryParams = {...props.queryParams}; } + + if (props.properties) { + this.properties = {...props.properties} + } + this.subLayers = []; if (!props.subLayers) return; @@ -338,6 +368,10 @@ export class ImageMapLayerSettings extends MapLayerSettings { if (this.savedQueryParams) props.queryParams = {...this.savedQueryParams}; + if (this.properties) { + props.properties = {...this.properties}; + } + return props; } @@ -351,7 +385,6 @@ export class ImageMapLayerSettings extends MapLayerSettings { // Clone members not part of MapLayerProps clone.userName = this.userName; clone.password = this.password; - clone.accessKey = this.accessKey; if (this.unsavedQueryParams) clone.unsavedQueryParams = {...this.unsavedQueryParams}; if (this.savedQueryParams) @@ -374,6 +407,11 @@ export class ImageMapLayerSettings extends MapLayerSettings { props.queryParams = {...this.savedQueryParams}; } + if (changedProps.properties) { + props.properties = {...changedProps.properties} + } else if (this.properties) { + props.properties = {...this.properties} + } return props; } diff --git a/core/frontend/src/Viewport.ts b/core/frontend/src/Viewport.ts index f5daf29ab949..e6e130283e90 100644 --- a/core/frontend/src/Viewport.ts +++ b/core/frontend/src/Viewport.ts @@ -3206,7 +3206,7 @@ export class ScreenViewport extends Viewport { logo.src = `${IModelApp.publicPath}images/imodeljs-icon.svg`; logo.alt = ""; - const showLogos = (ev: Event) => { + const showLogos = async (ev: Event) => { const aboutBox = IModelApp.makeModalDiv({ autoClose: true, width: 460, closeBox: true, rootDiv: this.vpDiv.ownerDocument.body }).modal; aboutBox.className += " imodeljs-about"; // only added so the CSS knows this is the about dialog const logos = IModelApp.makeHTMLElement("table", { parent: aboutBox, className: "logo-cards" }); @@ -3216,10 +3216,11 @@ export class ScreenViewport extends Viewport { } logos.appendChild(IModelApp.makeIModelJsLogoCard()); + const promises = new Array>(); for (const ref of this.getTileTreeRefs()) { - ref.addLogoCards(logos, this); + promises.push(ref.addAttributions(logos, this)); } - + await Promise.all(promises); ev.stopPropagation(); }; @@ -3461,7 +3462,7 @@ export class ScreenViewport extends Viewport { this._decorationCache.prohibitRemoval = true; context.addFromDecorator(this.view); - for (const ref of this.tiledGraphicsProviderRefs()) { + for (const ref of this.getTileTreeRefs()) { context.addFromDecorator(ref); } diff --git a/core/frontend/src/internal/tile/RealityModelTileTree.ts b/core/frontend/src/internal/tile/RealityModelTileTree.ts index 978a1c56e547..c667c8ec09a4 100644 --- a/core/frontend/src/internal/tile/RealityModelTileTree.ts +++ b/core/frontend/src/internal/tile/RealityModelTileTree.ts @@ -931,11 +931,17 @@ export class RealityTreeReference extends RealityModelTileTree.Reference { return div; } + /** @deprecated in 5.0 Use [addAttributions] instead. */ public override addLogoCards(cards: HTMLTableElement): void { if (this._rdSourceKey.provider === RealityDataProvider.CesiumIonAsset && !cards.dataset.openStreetMapLogoCard) { cards.dataset.openStreetMapLogoCard = "true"; cards.appendChild(IModelApp.makeLogoCard({ heading: "OpenStreetMap", notice: `©OpenStreetMap ${IModelApp.localization.getLocalizedString("iModelJs:BackgroundMap:OpenStreetMapContributors")}` })); } } + + public override async addAttributions(cards: HTMLTableElement): Promise { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return Promise.resolve(this.addLogoCards(cards)); + } } diff --git a/core/frontend/src/internal/tile/map/ImageryProviders/ArcGISMapLayerImageryProvider.ts b/core/frontend/src/internal/tile/map/ImageryProviders/ArcGISMapLayerImageryProvider.ts index bd8ea10c5f8a..cd82f36c6d25 100644 --- a/core/frontend/src/internal/tile/map/ImageryProviders/ArcGISMapLayerImageryProvider.ts +++ b/core/frontend/src/internal/tile/map/ImageryProviders/ArcGISMapLayerImageryProvider.ts @@ -16,6 +16,8 @@ import { PropertyValueFormat, StandardTypeNames } from "@itwin/appui-abstract"; import { Point2d, Range2d, Range2dProps, XYProps } from "@itwin/core-geometry"; import { IModelStatus, Logger } from "@itwin/core-bentley"; import { HitDetail } from "../../../../HitDetail"; +import { ScreenViewport } from "../../../../Viewport"; + const loggerCategory = "MapLayerImageryProvider.ArcGISMapLayerImageryProvider"; @@ -316,6 +318,7 @@ export class ArcGISMapLayerImageryProvider extends ArcGISImageryProvider { } + /** @deprecated in 5.0 Use [addAttributions] instead. */ public override addLogoCards(cards: HTMLTableElement): void { if (!cards.dataset.arcGisLogoCard) { cards.dataset.arcGisLogoCard = "true"; @@ -323,6 +326,11 @@ export class ArcGISMapLayerImageryProvider extends ArcGISImageryProvider { } } + public override async addAttributions(cards: HTMLTableElement, _vp: ScreenViewport): Promise { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return Promise.resolve(this.addLogoCards(cards)); + } + // Translates the provided Cartographic into a EPSG:3857 point, and retrieve information. // tolerance is in pixels private async getIdentifyData(quadId: QuadId, carto: Cartographic, tolerance: number, returnGeometry?: boolean, maxAllowableOffset?: number): Promise { diff --git a/core/frontend/src/internal/tile/map/ImageryProviders/AzureMapsLayerImageryProvider.ts b/core/frontend/src/internal/tile/map/ImageryProviders/AzureMapsLayerImageryProvider.ts index 9b49de16c634..baebac211bcd 100644 --- a/core/frontend/src/internal/tile/map/ImageryProviders/AzureMapsLayerImageryProvider.ts +++ b/core/frontend/src/internal/tile/map/ImageryProviders/AzureMapsLayerImageryProvider.ts @@ -9,6 +9,8 @@ import { ImageMapLayerSettings } from "@itwin/core-common"; import { IModelApp } from "../../../../IModelApp"; import { MapLayerImageryProvider } from "../../../../tile/internal"; +import { ScreenViewport } from "../../../../Viewport"; + export class AzureMapsLayerImageryProvider extends MapLayerImageryProvider { constructor(settings: ImageMapLayerSettings) { super(settings, true); } @@ -20,10 +22,16 @@ export class AzureMapsLayerImageryProvider extends MapLayerImageryProvider { return `${this._settings.url}&${this._settings.accessKey.key}=${this._settings.accessKey.value}&api-version=2.0&zoom=${zoom}&x=${x}&y=${y}`; } + /** @deprecated in 5.0 Use [addAttributions] instead. */ public override addLogoCards(cards: HTMLTableElement): void { if (!cards.dataset.azureMapsLogoCard) { cards.dataset.azureMapsLogoCard = "true"; cards.appendChild(IModelApp.makeLogoCard({ heading: "Azure Maps", notice: IModelApp.localization.getLocalizedString("iModelJs:BackgroundMap.AzureMapsCopyright") })); } } + + public override async addAttributions(cards: HTMLTableElement, _vp: ScreenViewport): Promise { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return Promise.resolve(this.addLogoCards(cards)); + } } diff --git a/core/frontend/src/internal/tile/map/ImageryProviders/BingImageryProvider.ts b/core/frontend/src/internal/tile/map/ImageryProviders/BingImageryProvider.ts index 44226f1ae66f..d284a4b975ca 100644 --- a/core/frontend/src/internal/tile/map/ImageryProviders/BingImageryProvider.ts +++ b/core/frontend/src/internal/tile/map/ImageryProviders/BingImageryProvider.ts @@ -145,7 +145,7 @@ export class BingMapsImageryLayerProvider extends MapLayerImageryProvider { } return matchingAttributions; } - + /** @deprecated in 5.0 Use [addAttributions] instead. */ public override addLogoCards(cards: HTMLTableElement, vp: ScreenViewport): void { const tiles = IModelApp.tileAdmin.getTilesForUser(vp)?.selected; const matchingAttributions = this.getMatchingAttributions(tiles); @@ -163,6 +163,11 @@ export class BingMapsImageryLayerProvider extends MapLayerImageryProvider { cards.appendChild(IModelApp.makeLogoCard({ iconSrc: `${IModelApp.publicPath}images/bing.svg`, heading: "Microsoft Bing", notice: copyrightMsg })); } + public override async addAttributions(cards: HTMLTableElement, vp: ScreenViewport): Promise { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return Promise.resolve(this.addLogoCards(cards, vp)); + } + // initializes the BingImageryProvider by reading the templateUrl, logo image, and attribution list. public override async initialize(): Promise { // get the template url diff --git a/core/frontend/src/internal/tile/map/ImageryProviders/MapBoxLayerImageryProvider.ts b/core/frontend/src/internal/tile/map/ImageryProviders/MapBoxLayerImageryProvider.ts index caa6bdd468f4..31a42e19b42f 100644 --- a/core/frontend/src/internal/tile/map/ImageryProviders/MapBoxLayerImageryProvider.ts +++ b/core/frontend/src/internal/tile/map/ImageryProviders/MapBoxLayerImageryProvider.ts @@ -9,6 +9,8 @@ import { ImageMapLayerSettings } from "@itwin/core-common"; import { IModelApp } from "../../../../IModelApp"; import { MapLayerImageryProvider } from "../../../../tile/internal"; +import { ScreenViewport } from "../../../../Viewport"; + /** Base class imagery map layer formats. Subclasses should override formatId and [[MapLayerFormat.createImageryProvider]]. */ @@ -44,6 +46,7 @@ export class MapBoxLayerImageryProvider extends MapLayerImageryProvider { return url; } + /** @deprecated in 5.0 Use [addAttributions] instead. */ public override addLogoCards(cards: HTMLTableElement): void { if (!cards.dataset.mapboxLogoCard) { cards.dataset.mapboxLogoCard = "true"; @@ -51,6 +54,11 @@ export class MapBoxLayerImageryProvider extends MapLayerImageryProvider { } } + public override async addAttributions (cards: HTMLTableElement, _vp: ScreenViewport): Promise { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return Promise.resolve(this.addLogoCards(cards)); + } + // no initialization needed for MapBoxImageryProvider. public override async initialize(): Promise { } } diff --git a/core/frontend/src/test/tile/map/ImageryTileTree.test.ts b/core/frontend/src/test/tile/map/ImageryTileTree.test.ts index ae09fb6ffbad..e93b0c604001 100644 --- a/core/frontend/src/test/tile/map/ImageryTileTree.test.ts +++ b/core/frontend/src/test/tile/map/ImageryTileTree.test.ts @@ -67,6 +67,15 @@ describe("ImageryTileTree", () => { {lhs: {...baseProps, formatId:"Custom2"}, rhs: {...baseProps}, expectSameTileTree:false}, {lhs: {...baseProps, subLayers: [{name: "sub0", visible: false}]}, rhs: {...baseProps}, expectSameTileTree:false}, {lhs: {...baseProps, subLayers: [{name: "sub1", visible: true}]}, rhs: {...baseProps}, expectSameTileTree:false}, + {lhs: {...baseProps, properties: {key: "value"}}, rhs: {...baseProps}, expectSameTileTree:false}, + {lhs: {...baseProps}, rhs: {...baseProps, properties: {key: "value"}}, expectSameTileTree:false}, + {lhs: {...baseProps, properties: {key: "value"}}, rhs: {...baseProps, properties: {key: "value"}}, expectSameTileTree:true}, + {lhs: {...baseProps, properties: {key: [1,2,3]}}, rhs: {...baseProps}, expectSameTileTree:false}, + {lhs: {...baseProps}, rhs: {...baseProps, properties: {key: [1,2,3]}}, expectSameTileTree:false}, + {lhs: {...baseProps, properties: {key: "value"}}, rhs: {...baseProps, properties: {key: [1,2,3]}}, expectSameTileTree:false}, + {lhs: {...baseProps, properties: {key: [1,2,3,4]}}, rhs: {...baseProps, properties: {key: [1,2,3]}}, expectSameTileTree:false}, + {lhs: {...baseProps, properties: {key: [1,2,3]}}, rhs: {...baseProps, properties: {key: [1,2,3,4]}}, expectSameTileTree:false}, + {lhs: {...baseProps, properties: {key: [1,2,3]}}, rhs: {...baseProps, properties: {key: [1,2,3]}}, expectSameTileTree:true}, ]; for (const entry of dataset) { const settingsLhs = ImageMapLayerSettings.fromJSON(entry.lhs); diff --git a/core/frontend/src/tile/TileTreeReference.ts b/core/frontend/src/tile/TileTreeReference.ts index 3eb98b4304a2..641964ca7b47 100644 --- a/core/frontend/src/tile/TileTreeReference.ts +++ b/core/frontend/src/tile/TileTreeReference.ts @@ -261,9 +261,17 @@ export abstract class TileTreeReference /* implements RenderMemory.Consumer */ { */ public get planarClipMaskPriority(): number { return PlanarClipMaskPriority.DesignModel; } - /** Add attribution logo cards for the tile tree source logo cards to the viewport's logo div. */ + /** @deprecated in 5.0 Use [addAttributions] instead. */ public addLogoCards(_cards: HTMLTableElement, _vp: ScreenViewport): void { } + /** Add attribution logo cards for the tile tree source logo cards to the viewport's logo div. + * @beta + */ + public async addAttributions(cards: HTMLTableElement, vp: ScreenViewport): Promise { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return Promise.resolve(this.addLogoCards(cards, vp)); + } + /** Create a tile tree reference equivalent to this one that also supplies an implementation of [[GeometryTileTreeReference.collectTileGeometry]]. * Return `undefined` if geometry collection is not supported. * @see [[createGeometryTreeReference]]. diff --git a/core/frontend/src/tile/map/CesiumTerrainProvider.ts b/core/frontend/src/tile/map/CesiumTerrainProvider.ts index f7afc80db3ca..a5136468df4d 100644 --- a/core/frontend/src/tile/map/CesiumTerrainProvider.ts +++ b/core/frontend/src/tile/map/CesiumTerrainProvider.ts @@ -18,6 +18,7 @@ import { GeographicTilingScheme, MapTile, MapTilingScheme, QuadId, ReadMeshArgs, RequestMeshDataArgs, TerrainMeshProvider, TerrainMeshProviderOptions, Tile, TileAvailability, } from "../internal"; +import { ScreenViewport } from "../../Viewport"; /** @internal */ enum QuantizedMeshExtensionIds { @@ -199,6 +200,7 @@ class CesiumTerrainProvider extends TerrainMeshProvider { this._tokenTimeOut = BeTimePoint.now().plus(CesiumTerrainProvider._tokenTimeoutInterval); } + /** @deprecated in 5.0 Use [addAttributions] instead. */ public override addLogoCards(cards: HTMLTableElement): void { if (cards.dataset.cesiumIonLogoCard) return; @@ -212,6 +214,12 @@ class CesiumTerrainProvider extends TerrainMeshProvider { cards.appendChild(card); } + public override async addAttributions(cards: HTMLTableElement, _vp: ScreenViewport): Promise { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return Promise.resolve(this.addLogoCards(cards)); + } + + public get maxDepth(): number { return this._maxDepth; } public get tilingScheme(): MapTilingScheme { return this._tilingScheme; } diff --git a/core/frontend/src/tile/map/ImageryTileTree.ts b/core/frontend/src/tile/map/ImageryTileTree.ts index baa092c6d3b1..a67b04c42ff3 100644 --- a/core/frontend/src/tile/map/ImageryTileTree.ts +++ b/core/frontend/src/tile/map/ImageryTileTree.ts @@ -6,9 +6,9 @@ * @module Tiles */ -import { assert, compareBooleans, compareNumbers, compareStrings, compareStringsOrUndefined, dispose, Logger } from "@itwin/core-bentley"; +import { assert, compareBooleans, compareNumbers, compareSimpleArrays, compareSimpleTypes, compareStrings, compareStringsOrUndefined, dispose, Logger,} from "@itwin/core-bentley"; import { Angle, Range3d, Transform } from "@itwin/core-geometry"; -import { Cartographic, ImageMapLayerSettings, ImageSource, MapLayerSettings, RenderTexture, ViewFlagOverrides } from "@itwin/core-common"; +import { Cartographic, ImageMapLayerSettings, ImageSource, MapLayerProviderArrayProperty, MapLayerSettings, RenderTexture, ViewFlagOverrides } from "@itwin/core-common"; import { IModelApp } from "../../IModelApp"; import { IModelConnection } from "../../IModelConnection"; import { RenderMemory } from "../../render/RenderMemory"; @@ -208,10 +208,17 @@ export class ImageryMapTileTree extends RealityTileTree { this._rootTile = new ImageryMapTile(params.rootTile, this, rootQuadId, this.getTileRectangle(rootQuadId)); } public get tilingScheme(): MapTilingScheme { return this._imageryLoader.imageryProvider.tilingScheme; } + + /** @deprecated in 5.0 Use [addAttributions] instead. */ public addLogoCards(cards: HTMLTableElement, vp: ScreenViewport): void { + // eslint-disable-next-line @typescript-eslint/no-deprecated this._imageryLoader.addLogoCards(cards, vp); } + public async addAttributions(cards: HTMLTableElement, vp: ScreenViewport): Promise { + return this._imageryLoader.addAttributions(cards, vp); + } + public getTileRectangle(quadId: QuadId): MapCartoRectangle { return this.tilingScheme.tileXYToRectangle(quadId.column, quadId.row, quadId.level); } @@ -258,10 +265,17 @@ class ImageryTileLoader extends RealityTileLoader { public get maxDepth(): number { return this._imageryProvider.maximumZoomLevel; } public get minDepth(): number { return this._imageryProvider.minimumZoomLevel; } public get priority(): TileLoadPriority { return TileLoadPriority.Map; } + + /** @deprecated in 5.0 Use [addAttributions] instead. */ public addLogoCards(cards: HTMLTableElement, vp: ScreenViewport): void { + // eslint-disable-next-line @typescript-eslint/no-deprecated this._imageryProvider.addLogoCards(cards, vp); } + public async addAttributions(cards: HTMLTableElement, vp: ScreenViewport): Promise { + await this._imageryProvider.addAttributions(cards, vp); + } + public get maximumScreenSize(): number { return this._imageryProvider.maximumScreenSize; } public get imageryProvider(): MapLayerImageryProvider { return this._imageryProvider; } public async getToolTip(strings: string[], quadId: QuadId, carto: Cartographic, tree: ImageryMapTileTree): Promise { await this._imageryProvider.getToolTip(strings, quadId, carto, tree); } @@ -333,12 +347,45 @@ class ImageryMapLayerTreeSupplier implements TileTreeSupplier { if (0 === cmp) { cmp = compareBooleans(lhs.settings.transparentBackground, rhs.settings.transparentBackground); if (0 === cmp) { - cmp = compareNumbers(lhs.settings.subLayers.length, rhs.settings.subLayers.length); + if (lhs.settings.properties || rhs.settings.properties) { + if (lhs.settings.properties && rhs.settings.properties) { + const lhsKeysLength = Object.keys(lhs.settings.properties).length; + const rhsKeysLength = Object.keys(rhs.settings.properties).length; + + if (lhsKeysLength !== rhsKeysLength) { + cmp = lhsKeysLength - rhsKeysLength; + } else { + for (const key of Object.keys(lhs.settings.properties)) { + const lhsProp = lhs.settings.properties[key]; + const rhsProp = rhs.settings.properties[key]; + cmp = compareStrings(typeof lhsProp, typeof rhsProp); + if (0 !== cmp) + break; + if (Array.isArray(lhsProp) || Array.isArray(rhsProp)) { + cmp = compareSimpleArrays(lhsProp as MapLayerProviderArrayProperty, rhsProp as MapLayerProviderArrayProperty); + if (0 !== cmp) + break; + } else { + cmp = compareSimpleTypes(lhsProp, rhsProp); + if (0 !== cmp) + break; + } + } + } + } else if (!lhs.settings.properties) { + cmp = 1; + } else { + cmp = -1; + } + } if (0 === cmp) { - for (let i = 0; i < lhs.settings.subLayers.length && 0 === cmp; i++) { - cmp = compareStrings(lhs.settings.subLayers[i].name, rhs.settings.subLayers[i].name); - if (0 === cmp) { - cmp = compareBooleans(lhs.settings.subLayers[i].visible, rhs.settings.subLayers[i].visible); + cmp = compareNumbers(lhs.settings.subLayers.length, rhs.settings.subLayers.length); + if (0 === cmp) { + for (let i = 0; i < lhs.settings.subLayers.length && 0 === cmp; i++) { + cmp = compareStrings(lhs.settings.subLayers[i].name, rhs.settings.subLayers[i].name); + if (0 === cmp) { + cmp = compareBooleans(lhs.settings.subLayers[i].visible, rhs.settings.subLayers[i].visible); + } } } } diff --git a/core/frontend/src/tile/map/MapLayerImageryProvider.ts b/core/frontend/src/tile/map/MapLayerImageryProvider.ts index 0e2229f683d9..cf15a547029a 100644 --- a/core/frontend/src/tile/map/MapLayerImageryProvider.ts +++ b/core/frontend/src/tile/map/MapLayerImageryProvider.ts @@ -15,6 +15,7 @@ import { ScreenViewport } from "../../Viewport"; import { appendQueryParams, GeographicTilingScheme, ImageryMapTile, ImageryMapTileTree, MapCartoRectangle, MapFeatureInfoOptions, MapLayerFeatureInfo, MapTilingScheme, QuadId, WebMercatorTilingScheme } from "../internal"; import { HitDetail } from "../../HitDetail"; import { headersIncludeAuthMethod, setBasicAuthorization, setRequestTimeout } from "../../request/utils"; +import { DecorateContext } from "../../ViewContext"; /** @internal */ const tileImageSize = 256, untiledImageSize = 256; @@ -131,13 +132,19 @@ export abstract class MapLayerImageryProvider { public get tilingScheme(): MapTilingScheme { return this.useGeographicTilingScheme ? this._geographicTilingScheme : this._mercatorTilingScheme; } + /** @deprecated in 5.0 Use [addAttributions] instead. */ + public addLogoCards(_cards: HTMLTableElement, _viewport: ScreenViewport): void { } + /** * Add attribution logo cards for the data supplied by this provider to the [[Viewport]]'s logo div. * @param _cards Logo cards HTML element that may contain custom data attributes. * @param _viewport Viewport to add logo cards to. * @beta */ - public addLogoCards(_cards: HTMLTableElement, _viewport: ScreenViewport): void { } + public async addAttributions(cards: HTMLTableElement, vp: ScreenViewport): Promise { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return Promise.resolve(this.addLogoCards(cards, vp)); + } /** @internal */ protected _missingTileData?: Uint8Array; @@ -195,6 +202,10 @@ export abstract class MapLayerImageryProvider { featureInfos.push({ layerName: this._settings.name }); } + /** @internal */ + public decorate(_context: DecorateContext): void { + } + /** @internal */ protected async getImageFromTileResponse(tileResponse: Response, zoomLevel: number) { const arrayBuffer = await tileResponse.arrayBuffer(); diff --git a/core/frontend/src/tile/map/MapLayerTileTreeReference.ts b/core/frontend/src/tile/map/MapLayerTileTreeReference.ts index ea8485efa313..78b7be1b1bac 100644 --- a/core/frontend/src/tile/map/MapLayerTileTreeReference.ts +++ b/core/frontend/src/tile/map/MapLayerTileTreeReference.ts @@ -12,6 +12,7 @@ import { HitDetail } from "../../HitDetail"; import { IModelApp } from "../../IModelApp"; import { IModelConnection } from "../../IModelConnection"; import { createModelMapLayerTileTreeReference, MapLayerImageryProvider, TileTreeReference } from "../internal"; +import { DecorateContext } from "../../ViewContext"; /** * A [[TileTreeReference]] to be used specifically for [[MapTileTree]]s. @@ -84,6 +85,10 @@ export abstract class MapLayerTileTreeReference extends TileTreeReference { div.innerHTML = strings.join("
"); return div; } + + public override decorate(_context: DecorateContext): void { + this.imageryProvider?.decorate(_context); + } } /** diff --git a/core/frontend/src/tile/map/MapTileTree.ts b/core/frontend/src/tile/map/MapTileTree.ts index b0e051a48811..4b18a46e2572 100644 --- a/core/frontend/src/tile/map/MapTileTree.ts +++ b/core/frontend/src/tile/map/MapTileTree.ts @@ -21,7 +21,7 @@ import { IModelApp } from "../../IModelApp"; import { PlanarClipMaskState } from "../../PlanarClipMaskState"; import { FeatureSymbology } from "../../render/FeatureSymbology"; import { RenderPlanarClassifier } from "../../internal/render/RenderPlanarClassifier"; -import { SceneContext } from "../../ViewContext"; +import { DecorateContext, SceneContext } from "../../ViewContext"; import { MapLayerScaleRangeVisibility, ScreenViewport } from "../../Viewport"; import { BingElevationProvider, createDefaultViewFlagOverrides, createMapLayerTreeReference, DisclosedTileTreeSet, EllipsoidTerrainProvider, GeometryTileTreeReference, @@ -1227,20 +1227,47 @@ export class MapTileTreeReference extends TileTreeReference { return info; } - /** Add logo cards to logo div. */ + /** @deprecated in 5.0 Use [addAttributions] instead. */ public override addLogoCards(cards: HTMLTableElement, vp: ScreenViewport): void { const tree = this.treeOwner.tileTree as MapTileTree; if (tree) { + // eslint-disable-next-line @typescript-eslint/no-deprecated tree.mapLoader.terrainProvider.addLogoCards(cards, vp); for (const imageryTreeRef of this._layerTrees) { if (imageryTreeRef?.layerSettings.visible) { const imageryTree = imageryTreeRef.treeOwner.tileTree; if (imageryTree instanceof ImageryMapTileTree) + // eslint-disable-next-line @typescript-eslint/no-deprecated imageryTree.addLogoCards(cards, vp); } } } } + + /** Add logo cards to logo div. */ + public override async addAttributions(cards: HTMLTableElement, vp: ScreenViewport): Promise { + const tree = this.treeOwner.tileTree as MapTileTree; + + if (tree) { + const promises = [tree.mapLoader.terrainProvider.addAttributions(cards, vp)]; + + for (const imageryTreeRef of this._layerTrees) { + if (imageryTreeRef?.layerSettings.visible) { + const imageryTree = imageryTreeRef.treeOwner.tileTree; + if (imageryTree instanceof ImageryMapTileTree) + promises.push(imageryTree.addAttributions(cards, vp)); + } + } + await Promise.all(promises); + } + } + + public override decorate(context: DecorateContext): void { + for (const layerTree of this._layerTrees) { + if (layerTree) + layerTree.decorate(context); + } + } } /** Returns whether a GCS converter is available. diff --git a/core/frontend/src/tile/map/TerrainMeshProvider.ts b/core/frontend/src/tile/map/TerrainMeshProvider.ts index 6749e7e6edb3..c5656572735e 100644 --- a/core/frontend/src/tile/map/TerrainMeshProvider.ts +++ b/core/frontend/src/tile/map/TerrainMeshProvider.ts @@ -89,11 +89,17 @@ export abstract class TerrainMeshProvider { */ public abstract readMesh(args: ReadMeshArgs): Promise; - /** Add attribution logo cards for the terrain data supplied by this provider to the [[Viewport]]'s logo div. + /** @deprecated in 5.0 Use [addAttributions] instead. */ + public addLogoCards(_cards: HTMLTableElement, _vp: ScreenViewport): void { } + + /** Add attribution logo cards for the terrain data supplied by this provider to the [[Viewport]]'s logo div. * For example, a provider that produces meshes from [Bing Maps](https://docs.microsoft.com/en-us/bingmaps/rest-services/elevations/) would be required to * disclose any copyrighted data used in the production of those meshes. */ - public addLogoCards(_cards: HTMLTableElement, _vp: ScreenViewport): void { } + public async addAttributions(cards: HTMLTableElement, vp: ScreenViewport): Promise { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return Promise.resolve(this.addLogoCards(cards, vp)); + } /** Return whether terrain data can be obtained for the [[MapTile]] specified by `quadId`. If it returns false, a terrain mesh will instead be produced for * that tile by up-sampling the terrain mesh provided by its parent tile. diff --git a/docs/changehistory/NextVersion.md b/docs/changehistory/NextVersion.md index b1a463e60c67..dc6da6c083fa 100644 --- a/docs/changehistory/NextVersion.md +++ b/docs/changehistory/NextVersion.md @@ -13,6 +13,7 @@ Table of contents: - [Polyface Traversal](#polyface-traversal) - [Presentation](#presentation) - [Unified selection move to `@itwin/unified-selection`](#unified-selection-move-to-itwinunified-selection) + - [Google Maps 2D tiles API](#google-maps-2d-tiles-api) - [Delete all transactions](#delete-all-transactions) - [API deprecations](#api-deprecations) - [@itwin/core-bentley](#itwincore-bentley) @@ -95,6 +96,30 @@ The Presentation system is moving towards a more modular approach, with smaller The unified selection system has been part of `@itwin/presentation-frontend` for a long time, providing a way for apps to have a single source of truth of what's selected. This system is now deprecated in favor of the new [@itwin/unified-selection](https://www.npmjs.com/package/@itwin/unified-selection) package. See the [migration guide](https://github.com/iTwin/presentation/blob/master/packages/unified-selection/learning/MigrationGuide.md) for migration details. +## Google Maps 2D tiles API + +The `itwin/map-layers-formats` package now includes an API for consuming Google Maps 2D tiles. + +To enable it as a base map, it's simple as: + + ```typescript +import { GoogleMaps } from "@itwin/map-layers-formats"; +const ds = IModelApp.viewManager.selectedView.displayStyle; +ds.backgroundMapBase = GoogleMaps.createBaseLayerSettings(); +``` + +Can also be attached as a map-layer: + +```ts +[[include:GoogleMaps_AttachMapLayerSimple]] +``` + + > ***IMPORTANT***: Make sure to configure your Google Cloud's API key in the `MapLayerOptions` when starting your IModelApp application: + +```ts +[[include:GoogleMaps_SetGoogleMapsApiKey]] +``` + ## Delete all transactions [BriefcaseDb.txns]($backend) keeps track of all unsaved and/or unpushed local changes made to a briefcase. After pushing your changes, the record of local changes is deleted. In some cases, a user may wish to abandon all of their accumulated changes and start fresh. [TxnManager.deleteAllTxns]($backend) deletes all local changes without pushing them. @@ -125,7 +150,7 @@ The unified selection system has been part of `@itwin/presentation-frontend` for } ``` - > Note that while public types with deterministic cleanup logic in iTwin.js will continue to implement _both_ `IDisposable` and `Disposable` until the former is fully removed in iTwin.js 7.0 (in accordance with our [API support policy](../learning/api-support-policies)), disposable objects should still only be disposed once - _either_ with [IDisposable.dispose]($core-bentley) _or_ `Symbol.dispose()` but not both! Where possible, prefer `using` declarations or the [dispose]($core-bentley) helper function over directly calling either method. + > Note that while public types with deterministic cleanup logic in iTwin.js will continue to implement *both* `IDisposable` and `Disposable` until the former is fully removed in iTwin.js 7.0 (in accordance with our [API support policy](../learning/api-support-policies)), disposable objects should still only be disposed once - *either* with [IDisposable.dispose]($core-bentley) *or* `Symbol.dispose()` but not both! Where possible, prefer `using` declarations or the [dispose]($core-bentley) helper function over directly calling either method. ### @itwin/core-common @@ -145,6 +170,10 @@ The unified selection system has been part of `@itwin/presentation-frontend` for - Deprecated [HiliteSet.setHilite]($core-frontend) - use `add`, `remove`, `replace` methods instead. +- Deprecated synchronous [addLogoCards]($core-frontend)-related APIs in favor of new asynchronous ones: + - `TileTreeReference.addLogoCard` : use `addAttributions` method instead + - `MapLayerImageryProvider.addLogoCard` : use `addAttributions` method instead + - [IModelConnection.fontMap]($frontend) caches potentially-stale mappings of [FontId]($common)s to font names. If you need access to font Ids on the front-end for some reason, implement an [Ipc method](../learning/IpcInterface.md) that uses [IModelDb.fonts]($backend). ### @itwin/ecschema-metadata @@ -528,7 +557,7 @@ Starting from version 5.x, iTwin.js has transitioned from using the merge method The merging process in this method follows these steps: -1. Initially, each incoming change is attempted to be applied using the _fast-forward_ method. If successful, the process is complete. +1. Initially, each incoming change is attempted to be applied using the *fast-forward* method. If successful, the process is complete. 2. If the fast-forward method fails for any incoming change, that changeset is abandoned and the rebase method is used instead. 3. The rebase process is executed as follows: - All local transactions are reversed. diff --git a/example-code/snippets/package.json b/example-code/snippets/package.json index e93137069bb0..5f911e9ae658 100644 --- a/example-code/snippets/package.json +++ b/example-code/snippets/package.json @@ -32,6 +32,7 @@ "@itwin/ecschema-metadata": "workspace:*", "@itwin/imodel-transformer": "^0.4.2", "@itwin/itwins-client": "^1.2.0", + "@itwin/map-layers-formats": "workspace:*", "@itwin/service-authorization": "^1.0.0", "@xmldom/xmldom": "~0.8.5", "azurite": "^3.33.0", diff --git a/example-code/snippets/src/frontend/GoogleMaps.ts b/example-code/snippets/src/frontend/GoogleMaps.ts new file mode 100644 index 000000000000..c906f21bc1f6 --- /dev/null +++ b/example-code/snippets/src/frontend/GoogleMaps.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Bentley Systems, Incorporated. All rights reserved. +* See LICENSE.md in the project root for license terms and full copyright notice. +*--------------------------------------------------------------------------------------------*/ + +import { IModelApp } from "@itwin/core-frontend"; +import {GoogleMaps} from "@itwin/map-layers-formats"; + +// __PUBLISH_EXTRACT_START__ GoogleMaps_BaseMapSimple +function setGoogleMapsBaseMap() { + const vp = IModelApp.viewManager.selectedView; + if (vp) { + const ds = vp.displayStyle; + ds.backgroundMapBase = GoogleMaps.createBaseLayerSettings(); + } + } +// __PUBLISH_EXTRACT_END__ +// __PUBLISH_EXTRACT_START__ GoogleMaps_BaseMapOpts +function setGoogleMapsBaseMapOpts() { + const vp = IModelApp.viewManager.selectedView; + if (vp) { + const ds = vp.displayStyle; + ds.backgroundMapBase = GoogleMaps.createBaseLayerSettings({ + mapType: "satellite", + layerTypes: ["layerRoadmap"], + language: "en-US", + region: "US" + }); + } +} +// __PUBLISH_EXTRACT_END__ +// __PUBLISH_EXTRACT_START__ GoogleMaps_AttachMapLayerSimple +function attachGoogleMapsMapLayerSimple() { + const vp = IModelApp.viewManager.selectedView; + if (vp) { + const ds = vp.displayStyle; + ds.attachMapLayer({ + mapLayerIndex: {index: 0, isOverlay: false}, + settings: GoogleMaps.createMapLayerSettings("GoogleMaps")}); + } +} +// __PUBLISH_EXTRACT_END__ +// __PUBLISH_EXTRACT_START__ GoogleMaps_AttachMapLayerSimple +function attachGoogleMapsMapLayerOpts() { + const vp = IModelApp.viewManager.selectedView; + if (vp) { + const ds = vp.displayStyle; + ds.attachMapLayer({ + mapLayerIndex: {index: 0, isOverlay: false}, + settings: GoogleMaps.createMapLayerSettings("GoogleMaps", { + mapType: "roadmap", + layerTypes: ["layerRoadmap"], + overlay: true, + language: "en-US", + region: "US" + } + )}); + } +} +// __PUBLISH_EXTRACT_END__ +// __PUBLISH_EXTRACT_START__ GoogleMaps_SetGoogleMapsApiKey +async function setGoogleMapsApiKey() { + await IModelApp.startup({ + applicationId: "myAppId", + mapLayerOptions: { + // eslint-disable-next-line @typescript-eslint/naming-convention + GoogleMaps: { + key: "key", value: "abc123" + } + } + }); +} +// __PUBLISH_EXTRACT_END__ +setGoogleMapsBaseMap(); +setGoogleMapsBaseMapOpts(); +attachGoogleMapsMapLayerSimple(); +attachGoogleMapsMapLayerOpts(); +setGoogleMapsApiKey().catch(() => {}); \ No newline at end of file diff --git a/extensions/map-layers-formats/src/GoogleMaps/GoogleMapDecorator.ts b/extensions/map-layers-formats/src/GoogleMaps/GoogleMapDecorator.ts new file mode 100644 index 000000000000..89670440bcb4 --- /dev/null +++ b/extensions/map-layers-formats/src/GoogleMaps/GoogleMapDecorator.ts @@ -0,0 +1,95 @@ +import { CanvasDecoration, DecorateContext, Decorator, IconSprites, IModelApp, Sprite } from "@itwin/core-frontend"; +import { Point3d } from "@itwin/core-geometry"; +import { GoogleMapsMapTypes } from "./GoogleMaps"; + + +/** A simple decorator that show logo at the a given screen position. + * @internal + */ +export class LogoDecoration implements CanvasDecoration { + private _sprite?: Sprite; + + /** The current position of the logo in view coordinates. */ + public readonly position = new Point3d(); + + private _offset: Point3d|undefined; + + public set offset(offset: Point3d|undefined) { + this._offset = offset; + } + + /** The logo offset in view coordinates.*/ + public get offset() { + return this._offset; + } + + /** Move the logo to the lower left corner of the screen. */ + public moveToLowerLeftCorner(context: DecorateContext) : boolean{ + if (!this._sprite || !this._sprite.isLoaded) + return false; + + this.position.x = this._offset?.x ?? 0; + this.position.y = context.viewport.parentDiv.clientHeight - this._sprite.size.y; + if (this._offset?.y) + this.position.y -= this._offset.y; + return true; + } + + /* TODO: Add other move methods as needed */ + + /** Indicate if the logo is loaded and ready to be drawn. */ + public get isLoaded() { return this._sprite?.isLoaded ?? false; } + + public async activate(sprite: Sprite): Promise { + this._sprite = sprite; + return new Promise((resolve, _reject) => { + sprite.loadPromise.then(() => { + resolve(true); + }).catch(() => { + resolve (false); + }); + }); + } + + /** Draw this sprite onto the supplied canvas. + * @see [[CanvasDecoration.drawDecoration]] + */ + public drawDecoration(ctx: CanvasRenderingContext2D): void { + if (this.isLoaded) { + // Draw image with an origin at the top left corner + ctx.drawImage(this._sprite!.image!, 0, 0); + } + } + + public decorate(context: DecorateContext) { + context.addCanvasDecoration(this); + } +} + +/** A decorator that adds the Google Maps logo to the lower left corner of the screen. + * @internal +*/ +export class GoogleMapsDecorator implements Decorator { + public readonly logo = new LogoDecoration(); + + /** Activate the logo based on the given map type. */ + public async activate(mapType: GoogleMapsMapTypes): Promise { + // Pick the logo that is the most visible on the background map + const imageName = mapType === "roadmap" ? + "google_on_white_hdpi" : + "google_on_non_white_hdpi"; + + // We need to move the logo right after the 'i.js' button + this.logo.offset = new Point3d(45, 5); + + return this.logo.activate(IconSprites.getSpriteFromUrl(`${IModelApp.publicPath}images/${imageName}.png`)); + }; + + /** Decorate implementation */ + public decorate = (context: DecorateContext) => { + if (!this.logo.isLoaded) + return; + this.logo.moveToLowerLeftCorner(context); + this.logo.decorate(context); + }; +} diff --git a/extensions/map-layers-formats/src/GoogleMaps/GoogleMaps.ts b/extensions/map-layers-formats/src/GoogleMaps/GoogleMaps.ts new file mode 100644 index 000000000000..287823f3d826 --- /dev/null +++ b/extensions/map-layers-formats/src/GoogleMaps/GoogleMaps.ts @@ -0,0 +1,182 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Bentley Systems, Incorporated. All rights reserved. +* See LICENSE.md in the project root for license terms and full copyright notice. +*--------------------------------------------------------------------------------------------*/ +/** @packageDocumentation + * @module MapLayersFormats + */ + +import { BaseMapLayerSettings, ImageMapLayerSettings } from "@itwin/core-common"; +import { MapCartoRectangle } from "@itwin/core-frontend"; +import { GoogleMapsUtils } from "../internal/GoogleMapsUtils"; + + +/** @beta*/ +export type GoogleMapsLayerTypes = "layerRoadmap" | "layerStreetview"; +/** @beta*/ +export type GoogleMapsMapTypes = "roadmap"|"satellite"|"terrain"; +/** @beta*/ +export type GoogleMapsScaleFactors = "scaleFactor1x" | "scaleFactor2x" | "scaleFactor4x"; + +/** +* Represents the options to create a Google Maps session. +* @beta +*/ +export interface GoogleMapsCreateSessionOptions { + /** + * The type of base map. + * + * `roadmap`: The standard Google Maps painted map tiles. + * + * `satellite`: Satellite imagery. + * + * `terrain`: Terrain imagery. When selecting `terrain` as the map type, you must also include the `layerRoadmap` layer type. + * @beta + * */ + mapType: GoogleMapsMapTypes, + /** + * An {@link https://en.wikipedia.org/wiki/IETF_language_tag | IETF language tag} that specifies the language used to display information on the tiles. For example, `en-US` specifies the English language as spoken in the United States. + */ + language: string; + /** + * A {@link https://cldr.unicode.org/ | Common Locale Data Repository} region identifier (two uppercase letters) that represents the physical location of the user. For example, `US`. + */ + region: string; + + /** + * An array of values that specifies the layer types added to the map. + * + * `layerRoadmap`: Required if you specify terrain as the map type. Can also be optionally overlaid on the satellite map type. Has no effect on roadmap tiles. + * + * `layerStreetview`: Shows Street View-enabled streets and locations using blue outlines on the map. + * + * @beta + * */ + layerTypes?: GoogleMapsLayerTypes[]; + + /** + * Scales-up the size of map elements (such as road labels), while retaining the tile size and coverage area of the default tile. + * Increasing the scale also reduces the number of labels on the map, which reduces clutter. + * + * `scaleFactor1x`: The default. + * + * `scaleFactor2x`: Doubles label size and removes minor feature labels. + * + * `scaleFactor4x`: Quadruples label size and removes minor feature labels. + * @beta + * */ + scale?: GoogleMapsScaleFactors + + /** + * A boolean value that specifies whether layerTypes should be rendered as a separate overlay, or combined with the base imagery. + * When true, the base map isn't displayed. If you haven't defined any layerTypes, then this value is ignored. + * Default is false. + * @beta + * */ + overlay? : boolean; + + /** + * An array of values specifying additional options to apply. + * @beta + * */ + apiOptions?: string[]; +}; + +/** +* Structure representing a Google Maps session. +* @beta +*/ +export interface GoogleMapsSession { + /** A session token value that you must include in all of your Map Tiles API requests. */ + session: string; + + /** string that contains the time (in seconds since the epoch) at which the token expires. A session token is valid for two weeks from its creation time, but this policy might change without notice. */ + expiry: number; + + /** The width of the tiles measured in pixels. */ + tileWidth: number; + + /** he height of the tiles measured in pixels. */ + tileHeight: number; + + /** The image format, which can be either `png` or `jpeg`. */ + imageFormat: string; +}; + +/** + * Represents the maximum zoom level available within a bounding rectangle. +* @beta +*/ +export interface MaxZoomRectangle { + maxZoom: number; + north: number; + south: number; + east: number; + west: number; +} + +/** + * Indicate which areas of given viewport have imagery, and at which zoom levels. + * @beta +*/ +export interface ViewportInfo { + /** 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: MaxZoomRectangle[]; +} + +/** + * Request parameters for the getViewportInfo method. + * @beta +*/ +export interface ViewportInfoRequestParams { + /** Bounding rectangle */ + rectangle: MapCartoRectangle; + + /** Session token */ + session: string; + + /** The Google Cloud API key */ + key: string; + + /** Zoom level of the viewport */ + zoom: number; +} + + +/** + * Google Maps API + * @beta +*/ +// eslint-disable-next-line @typescript-eslint/naming-convention +export const GoogleMaps = { +/** + * Creates Google Maps map-layer settings. + * @param name Name of the layer (Defaults to "GoogleMaps") + * @param opts Options to create the session (Defaults to satellite map type, English language, US region, and roadmap layer type) + * @example + * const ds = IModelApp.viewManager.selectedView.displayStyle; + * ds.attachMapLayer({ + * mapLayerIndex: {index: 0, isOverlay: false}, + * settings: GoogleMaps.createMapLayerSettings("GoogleMaps")}); + * @beta +*/ + createMapLayerSettings: (name?: string, opts?: GoogleMapsCreateSessionOptions) => { + return ImageMapLayerSettings.fromJSON(GoogleMapsUtils.createMapLayerProps(name, opts)); + }, + +/** + * Creates Google Maps base layer settings. + * @param name Name of the layer (Defaults to "GoogleMaps") + * @param opts Options to create the session (Defaults to satellite map type, English language, US region, and roadmap layer type) + * @example + * const ds = IModelApp.viewManager.selectedView.displayStyle; + * ds.backgroundMapBase = GoogleMaps.createBaseLayerSettings(); + * @beta +*/ + createBaseLayerSettings: (opts?: GoogleMapsCreateSessionOptions) => { + return BaseMapLayerSettings.fromJSON(GoogleMapsUtils.createMapLayerProps("GoogleMaps", opts)); + } +}; \ No newline at end of file diff --git a/extensions/map-layers-formats/src/GoogleMaps/GoogleMapsImageryFormat.ts b/extensions/map-layers-formats/src/GoogleMaps/GoogleMapsImageryFormat.ts new file mode 100644 index 000000000000..f99374fe68fe --- /dev/null +++ b/extensions/map-layers-formats/src/GoogleMaps/GoogleMapsImageryFormat.ts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Bentley Systems, Incorporated. All rights reserved. +* See LICENSE.md in the project root for license terms and full copyright notice. +*--------------------------------------------------------------------------------------------*/ +/** @packageDocumentation + * @module MapLayersFormats + */ + +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/extensions/map-layers-formats/src/GoogleMaps/GoogleMapsImageryProvider.ts b/extensions/map-layers-formats/src/GoogleMaps/GoogleMapsImageryProvider.ts new file mode 100644 index 000000000000..2c82053a285b --- /dev/null +++ b/extensions/map-layers-formats/src/GoogleMaps/GoogleMapsImageryProvider.ts @@ -0,0 +1,247 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Bentley Systems, Incorporated. All rights reserved. +* See LICENSE.md in the project root for license terms and full copyright notice. +*--------------------------------------------------------------------------------------------*/ +/** @packageDocumentation + * @module MapLayersFormats + */ + +import { ImageMapLayerSettings, ImageSource } from "@itwin/core-common"; +import { DecorateContext, IModelApp, MapCartoRectangle, MapLayerImageryProvider, MapLayerSourceStatus, MapLayerSourceValidation, MapTile, ScreenViewport, Tile } from "@itwin/core-frontend"; +import { GoogleMapsCreateSessionOptions, GoogleMapsLayerTypes, GoogleMapsMapTypes, GoogleMapsScaleFactors, GoogleMapsSession } from "./GoogleMaps"; +import { BentleyError, BentleyStatus, Logger } from "@itwin/core-bentley"; +import { GoogleMapsDecorator } from "./GoogleMapDecorator"; +import { GoogleMapsUtils } from "../internal/GoogleMapsUtils"; +const loggerCategory = "MapLayersFormats.GoogleMaps"; +const levelToken = "{level}"; +const rowToken = "{row}"; +const columnToken = "{column}"; + +const urlTemplate = `https://tile.googleapis.com/v1/2dtiles/${levelToken}/${columnToken}/${rowToken}`; + +/* +* Google Maps imagery provider +* @internal +*/ +export class GoogleMapsImageryProvider extends MapLayerImageryProvider { + + private _decorator: GoogleMapsDecorator; + private _hadUnrecoverableError = false; + private _tileSize = 256 + constructor(settings: ImageMapLayerSettings) { + super(settings, true); + this._decorator = new GoogleMapsDecorator(); + } + public static validateUrlTemplate(template: string): MapLayerSourceValidation { + return { status: (template.indexOf(levelToken) > 0 && template.indexOf(columnToken) > 0 && template.indexOf(rowToken) > 0) ? MapLayerSourceStatus.Valid : MapLayerSourceStatus.InvalidUrl }; + } + + protected async createSession() : Promise { + const sessionOptions = this.createCreateSessionOptions(); + if (this._settings.accessKey ) { + // Create session and store in query parameters + const sessionObj = await GoogleMapsUtils.createSession(this._settings.accessKey.value, sessionOptions); + this._settings.unsavedQueryParams = {session: sessionObj.session}; + return sessionObj; + } else { + Logger.logError(loggerCategory, `Missing GoogleMaps api key`); + return undefined; + } + } + public override get tileSize(): number { return this._tileSize; } + + public override async initialize(): Promise { + + const layerPropertyKeys = this._settings.properties ? Object.keys(this._settings.properties) : undefined; + if (layerPropertyKeys === undefined || + !layerPropertyKeys.includes("mapType") || + !layerPropertyKeys.includes("language") || + !layerPropertyKeys.includes("region")) { + const msg = "Missing session options"; + Logger.logError(loggerCategory, msg); + throw new BentleyError(BentleyStatus.ERROR, msg); + } + + const session = await this.createSession(); + if (!session) { + const msg = `Failed to create session`; + Logger.logError(loggerCategory, msg); + throw new BentleyError(BentleyStatus.ERROR, msg); + } + this._tileSize = session.tileWidth; // assuming here tiles are square + + const isActivated = await this._decorator.activate(this._settings.properties!.mapType as GoogleMapsMapTypes); + if (!isActivated) { + const msg = `Failed to activate decorator`; + Logger.logError(loggerCategory, msg); + throw new BentleyError(BentleyStatus.ERROR, msg); + } + } + + private createCreateSessionOptions(): GoogleMapsCreateSessionOptions { + const layerPropertyKeys = this._settings.properties ? Object.keys(this._settings.properties) : undefined; + if (layerPropertyKeys === undefined || + !layerPropertyKeys.includes("mapType") || + !layerPropertyKeys.includes("language") || + !layerPropertyKeys.includes("region")) { + const msg = "Missing session options"; + Logger.logError(loggerCategory, msg); + throw new BentleyError(BentleyStatus.ERROR, msg); + } + + const createSessionOptions: GoogleMapsCreateSessionOptions = { + mapType: this._settings.properties!.mapType as GoogleMapsMapTypes, + region: this._settings.properties!.region as string, + language: this._settings.properties!.language as string, + } + + if (this._settings.properties?.layerTypes !== undefined) { + createSessionOptions.layerTypes = this._settings.properties.layerTypes as GoogleMapsLayerTypes[]; + } + + if (this._settings.properties?.scale !== undefined) { + createSessionOptions.scale = this._settings.properties.scale as GoogleMapsScaleFactors; + } + + if (this._settings.properties?.overlay !== undefined) { + createSessionOptions.overlay = this._settings.properties.overlay as boolean; + } + + if (this._settings.properties?.apiOptions !== undefined) { + createSessionOptions.apiOptions = this._settings.properties.apiOptions as string[]; + } + return createSessionOptions; + } + + // construct the Url from the desired Tile + public async constructUrl(row: number, column: number, level: number): Promise { + const tmpUrl = urlTemplate.replace(levelToken, level.toString()).replace(columnToken, column.toString()).replace(rowToken, row.toString()); + const obj = new URL(tmpUrl); + if (this._settings.accessKey ) { + obj.searchParams.append("key", this._settings.accessKey.value); + } + + // We assume the 'session' param to be already part of the query parameters (checked in initialize) + return this.appendCustomParams(obj.toString()); + } + + private async fetchAttributions(tiles: Set): Promise { + const zooms = new Set(); + const matchingAttributions: string[] = []; + + // Viewport info requests must be made for a specific zoom level + tiles.forEach((tile) => zooms.add(tile.depth)); + + for (const zoom of zooms) { + let cartoRect: MapCartoRectangle|undefined; + for (const tile of tiles) { + if (tile.depth === zoom && tile instanceof MapTile) { + const extent = this.getEPSG4326Extent(tile.quadId.row, tile.quadId.column, tile.depth); + const rect = MapCartoRectangle.fromDegrees(extent.longitudeLeft, extent.latitudeBottom, extent.longitudeRight, extent.latitudeTop) + if (cartoRect) + cartoRect.union(rect); + else + cartoRect = rect; + } + } + if (cartoRect) { + try { + const viewportInfo = await GoogleMapsUtils.getViewportInfo({ + rectangle: cartoRect, + session: this._settings.collectQueryParams().session, + key: this._settings.accessKey!.value, + zoom}); + if (viewportInfo?.copyright) { + matchingAttributions.push(viewportInfo.copyright); + } + } catch (error:any) { + Logger.logError(loggerCategory, `Error while loading viewport info: ${error?.message??"Unknown error"}`); + } + } + } + + return matchingAttributions; + } + private async logJsonError(tileResponse: Response) { + try { + const error = await tileResponse.json(); + Logger.logError(loggerCategory, `Error while loading tile: ${error?.message}`); + } catch { + Logger.logError(loggerCategory, `Error while loading tile: ${tileResponse.statusText}`); + } + } + + public override async loadTile(row: number, column: number, zoomLevel: number): Promise { + if (this._hadUnrecoverableError) + return undefined; + + try { + let tileUrl: string = await this.constructUrl(row, column, zoomLevel); + let tileResponse: Response = await this.makeTileRequest(tileUrl); + if (!tileResponse.ok) { + if (tileResponse.headers.get("content-type")?.includes("application/json")) { + // Session might have expired, lets try to re-new it. + const isSessionCreated = await this.createSession(); + if (isSessionCreated) { + tileUrl = await this.constructUrl(row, column, zoomLevel); + tileResponse = await this.makeTileRequest(tileUrl); + if (!tileResponse.ok) { + if (tileResponse.headers.get("content-type")?.includes("application/json")) { + await this.logJsonError(tileResponse); + } else { + Logger.logError(loggerCategory, `Error while loading tile: ${tileResponse.statusText}`); + } + this._hadUnrecoverableError = true; // Prevent from doing more invalid requests + return undefined; + } + } else { + await this.logJsonError(tileResponse); + } + } else { + Logger.logError(loggerCategory, `Error while loading tile: ${tileResponse.statusText}`); + return undefined; + } + } + return await this.getImageFromTileResponse(tileResponse, zoomLevel); + } catch (error: any) { + if (error?.code === 401) { + Logger.logError(loggerCategory, `Authorize to load tile: ${error.message}`); + } else { + Logger.logError(loggerCategory, `Error while loading tile: ${error.message}`); + } + return undefined; + } + } + + public override decorate(context: DecorateContext): void { + this._decorator.decorate(context); + } + + private getSelectedTiles(vp: ScreenViewport) { + return IModelApp.tileAdmin.getTilesForUser(vp)?.selected + } + + public override async addAttributions (cards: HTMLTableElement, vp: ScreenViewport): Promise { + let copyrightMsg = ""; + const tiles = this.getSelectedTiles(vp); + if (tiles) { + try { + const attrList = await this.fetchAttributions(tiles); + for (const attr of attrList) { + attr.split(",").forEach((line) => { + copyrightMsg += `${copyrightMsg.length===0 ? "": " => { + 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}`); + } + Logger.logInfo(loggerCategory, `Session created successfully`); + return response.json(); + }, + + /** + * Register the google maps format if it is not already registered. + * @internal + */ + registerFormatIfNeeded: () => { + if (!IModelApp.mapLayerFormatRegistry.isRegistered(GoogleMapsMapLayerFormat.formatId)) { + IModelApp.mapLayerFormatRegistry.register(GoogleMapsMapLayerFormat); + } + }, + + /** + * Creates a Google Maps layer props. + * @param name Name of the layer (Defaults to "GoogleMaps") + * @param opts Options to create the session (Defaults to satellite map type, English language, US region, and roadmap layer type) + * @internal +*/ + createMapLayerProps: (name: string = "GoogleMaps", opts?: GoogleMapsCreateSessionOptions): ImageMapLayerProps => { + GoogleMapsUtils.registerFormatIfNeeded(); + + return { + formatId: GoogleMapsMapLayerFormat.formatId, + url: "", + name, + properties: GoogleMapsUtils.createPropertiesFromSessionOptions(opts ?? {mapType: "satellite", language: "en-US", region: "US:", layerTypes: ["layerRoadmap"]}), + }; + }, + + /** + * Retrieves the maximum zoom level available within a bounding rectangle. + * @param rectangle The bounding rectangle + * @returns The maximum zoom level available within the bounding rectangle. + * @internal + */ + getViewportInfo: async (params: ViewportInfoRequestParams): Promise=> { + const {rectangle, session, key, zoom} = params; + 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 ViewportInfo;; + }, + + /** + * Converts the session options to provider properties + * @param opts Options to create the session + * @internal + */ + createPropertiesFromSessionOptions: (opts: GoogleMapsCreateSessionOptions): MapLayerProviderProperties => { + const properties: MapLayerProviderProperties = { + mapType: opts.mapType, + language: opts.language, + region: opts.region, + } + + if (opts.layerTypes !== undefined) { + properties.layerTypes = [...opts.layerTypes]; + } + + if (opts.scale !== undefined) { + properties.scale = opts.scale; + } + + if (opts.overlay !== undefined) { + properties.overlay = opts.overlay; + } + + if (opts.apiOptions !== undefined) { + properties.apiOptions = [...opts.apiOptions]; + } + + return properties; + }, +} diff --git a/extensions/map-layers-formats/src/map-layers-formats.ts b/extensions/map-layers-formats/src/map-layers-formats.ts index a4d05f36a888..79b89448206e 100644 --- a/extensions/map-layers-formats/src/map-layers-formats.ts +++ b/extensions/map-layers-formats/src/map-layers-formats.ts @@ -6,6 +6,7 @@ export * from "./mapLayersFormats"; export * from "./ArcGisFeature/ArcGisFeatureProvider"; export * from "./Tools/MapFeatureInfoTool"; +export * from "./GoogleMaps/GoogleMaps"; /** @docs-package-description * This package provides support for additional map layer formats that are not included in the @itwin/core-frontend package. diff --git a/extensions/map-layers-formats/src/mapLayersFormats.ts b/extensions/map-layers-formats/src/mapLayersFormats.ts index 25568d7f38b3..6a6fdbf8f3e4 100644 --- a/extensions/map-layers-formats/src/mapLayersFormats.ts +++ b/extensions/map-layers-formats/src/mapLayersFormats.ts @@ -12,6 +12,7 @@ import { ArcGisFeatureMapLayerFormat } from "./ArcGisFeature/ArcGisFeatureFormat import { MapFeatureInfoTool } from "./Tools/MapFeatureInfoTool"; import { Localization } from "@itwin/core-common"; import { OgcApiFeaturesMapLayerFormat } from "./OgcApiFeatures/OgcApiFeaturesFormat"; +import { GoogleMapsMapLayerFormat } from "./GoogleMaps/GoogleMapsImageryFormat"; /** Configuration options. * @beta @@ -37,6 +38,7 @@ export class MapLayersFormats { if (IModelApp.initialized) { IModelApp.mapLayerFormatRegistry.register(ArcGisFeatureMapLayerFormat); IModelApp.mapLayerFormatRegistry.register(OgcApiFeaturesMapLayerFormat); + IModelApp.mapLayerFormatRegistry.register(GoogleMapsMapLayerFormat); } // register namespace containing localized strings for this package diff --git a/extensions/map-layers-formats/src/public/images/google_on_non_white_hdpi.png b/extensions/map-layers-formats/src/public/images/google_on_non_white_hdpi.png new file mode 100644 index 000000000000..393d03005d5b Binary files /dev/null and b/extensions/map-layers-formats/src/public/images/google_on_non_white_hdpi.png differ diff --git a/extensions/map-layers-formats/src/public/images/google_on_white_hdpi.png b/extensions/map-layers-formats/src/public/images/google_on_white_hdpi.png new file mode 100644 index 000000000000..5424b56d5da6 Binary files /dev/null and b/extensions/map-layers-formats/src/public/images/google_on_white_hdpi.png differ diff --git a/extensions/map-layers-formats/src/test/GoogleMaps/GoogleMaps.test.ts b/extensions/map-layers-formats/src/test/GoogleMaps/GoogleMaps.test.ts new file mode 100644 index 000000000000..345862462339 --- /dev/null +++ b/extensions/map-layers-formats/src/test/GoogleMaps/GoogleMaps.test.ts @@ -0,0 +1,210 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Bentley Systems, Incorporated. All rights reserved. +* See LICENSE.md in the project root for license terms and full copyright notice. +*--------------------------------------------------------------------------------------------*/ + +import * as sinon from "sinon"; +import { Frustum, ImageMapLayerSettings } from "@itwin/core-common"; +import { expect } from "chai"; +import { GoogleMapsImageryProvider } from "../../GoogleMaps/GoogleMapsImageryProvider"; +import { GoogleMaps, GoogleMapsCreateSessionOptions, GoogleMapsSession, ViewportInfoRequestParams } from "../../map-layers-formats"; +import { GoogleMapsUtils } from "../../internal/GoogleMapsUtils"; + +import { fakeJsonFetch } from "../TestUtils"; +import { LogoDecoration } from "../../GoogleMaps/GoogleMapDecorator"; +import { DecorateContext, Decorations, IconSprites, IModelApp, MapCartoRectangle, MapTile, MapTileTree, QuadId, ScreenViewport, Sprite } from "@itwin/core-frontend"; +import { Range3d } from "@itwin/core-geometry"; +import { TilePatch } from "@itwin/core-frontend/lib/cjs/tile/internal"; + + +class FakeMapTile extends MapTile { + public override depth: number; + constructor(contentId: string) { + super({contentId, range:Range3d.createNull(), maximumSize: 256}, + {} as MapTileTree, + QuadId.createFromContentId(contentId), + {} as TilePatch, + MapCartoRectangle.createXY(0, 0), + undefined, + []); + this.depth = this.quadId.level; + } +} + +const createProvider = (settings: ImageMapLayerSettings) => { + settings.accessKey = {key: "key", value: "dummyKey"}; + return new GoogleMapsImageryProvider(settings); +} + +const stubCreateSession = (sandbox:sinon.SinonSandbox, session: GoogleMapsSession) => sandbox.stub(GoogleMapsUtils, "createSession").callsFake(async function _(_apiKey: string, _opts: GoogleMapsCreateSessionOptions) { + return session; +}); + +const minCreateSessionOptions: GoogleMapsCreateSessionOptions = {mapType: "satellite", language: "en-US", region: "US"} +const createSessionOptions2: GoogleMapsCreateSessionOptions = {...minCreateSessionOptions, layerTypes: ["layerRoadmap"]}; + +const defaultPngSession = {tileWidth: 256, tileHeight: 256, imageFormat: "image/png", expiry: 0, session: "dummySession"}; +describe("GoogleMapsProvider", () => { + const sandbox = sinon.createSandbox(); + + beforeEach(async () => { + sandbox.stub(LogoDecoration.prototype, "activate").callsFake(async function _(_sprite: Sprite) { + return Promise.resolve(true); + }); + sandbox.stub(GoogleMapsUtils, "registerFormatIfNeeded"); + }); + + afterEach(async () => { + sandbox.restore(); + }); + + it("Provider properties round-trips through JSON", async () => { + const settings = GoogleMaps.createMapLayerSettings("", createSessionOptions2); + const json = settings.toJSON(); + const deserializedSettings = ImageMapLayerSettings.fromJSON(json); + expect(deserializedSettings.properties).to.deep.eq(settings.properties); + }); + + it("should not initialize with no properties provided", async () => { + const settings = ImageMapLayerSettings.fromJSON({name: "test", formatId: "GoogleMaps", url: ""}); + const createSessionSub = stubCreateSession(sandbox, defaultPngSession); + const provider = createProvider(settings); + await expect(provider.initialize()).to.be.rejectedWith("Missing session options"); + expect(createSessionSub.called).to.be.false; + }); + + it("should initialize with required properties", async () => { + + fakeJsonFetch(sandbox, defaultPngSession); + const settings = GoogleMaps.createBaseLayerSettings(minCreateSessionOptions); + + const createSessionSub = stubCreateSession(sandbox, defaultPngSession); + const provider = createProvider(settings); + + await expect(provider.initialize()).to.be.fulfilled; + expect(createSessionSub.called).to.be.true; + expect(createSessionSub.firstCall.args[1]).to.deep.eq(minCreateSessionOptions); + }); + + it("should initialize with properties", async () => { + + fakeJsonFetch(sandbox, defaultPngSession); + const settings = GoogleMaps.createBaseLayerSettings(createSessionOptions2); + const createSessionSub = stubCreateSession(sandbox, defaultPngSession); + const provider = createProvider(settings); + + await expect(provider.initialize()).to.be.fulfilled; + expect(createSessionSub.called).to.be.true; + expect(createSessionSub.firstCall.args[1]).to.deep.eq(createSessionOptions2); + expect(provider.tileSize).to.eq(256); + expect(settings.unsavedQueryParams).to.deep.eq({session: "dummySession"}); + }); + + it("should create proper tile url", async () => { + + fakeJsonFetch(sandbox, defaultPngSession); + const settings = GoogleMaps.createBaseLayerSettings(createSessionOptions2); + + const makeTileRequestStub = sandbox.stub(GoogleMapsImageryProvider.prototype, "makeTileRequest").callsFake(async function _(_url: string, _timeoutMs?: number ) { + const obj = { + headers: { "content-type": "image/jpeg" }, + arrayBuffer: async () => { + return Promise.resolve(new Uint8Array(100)); + }, + status: 200, + } as unknown; // By using unknown type, I can define parts of Response I really need + return (obj as Response); + }); + + stubCreateSession(sandbox, defaultPngSession); + const provider = createProvider(settings); + + await provider.initialize(); + await provider.loadTile(49592, 37981, 17); + expect(makeTileRequestStub.called).to.be.true; + expect(makeTileRequestStub.firstCall.args[0]).to.eq("https://tile.googleapis.com/v1/2dtiles/17/37981/49592?key=dummyKey&session=dummySession"); + }); + + it("should add attributions", async () => { + + fakeJsonFetch(sandbox, defaultPngSession); + const settings = GoogleMaps.createBaseLayerSettings(createSessionOptions2); + + sandbox.stub(GoogleMapsImageryProvider.prototype as any, "getSelectedTiles").callsFake(function _(_vp: unknown) { + const set = new Set(); + set.add(new FakeMapTile("17_37981_49592")); + return set; + }); + + const getViewportInfoStub = sandbox.stub(GoogleMapsUtils, "getViewportInfo").callsFake(async function _(_params: ViewportInfoRequestParams) { + return {copyright: "fake copyright", maxZoomRects: []}; + }); + + sinon.stub(IModelApp, 'publicPath').get(() => 'public/'); + + const provider = createProvider(settings); + + await provider.initialize(); + const table = document.createElement('table'); + await provider.addAttributions(table, {} as ScreenViewport); + + expect(getViewportInfoStub.called).to.be.true; + // Important : When satellite base layer is used, the logo should be white + expect(table.innerHTML).to.includes(``); + expect(table.innerHTML).to.includes(`

fake copyright

`); + + // Now re-do the test with roadmap base layer + const settings2 = GoogleMaps.createBaseLayerSettings({mapType: "roadmap", language: "en-US", region: "US"}); + const provider2 = createProvider(settings2); + await provider2.initialize(); + const table2 = document.createElement('table'); + await provider2.addAttributions(table2, {} as ScreenViewport); + expect(table2.innerHTML).to.includes(``); + expect(table2.innerHTML).to.includes(`

fake copyright

`); + }); + + it("logo should be activated with the 'on non-white' logo", async () => { + + fakeJsonFetch(sandbox, defaultPngSession); + const getSpriteStub = sandbox.stub(IconSprites, "getSpriteFromUrl").callsFake(function _(_url: string) { + return {} as Sprite; + }); + const settings = GoogleMaps.createBaseLayerSettings({mapType: "satellite", language: "en-US", region: "US"}); + const provider = createProvider(settings); + await provider.initialize(); + + expect(getSpriteStub.firstCall.args[0]).to.eq("public/images/google_on_non_white_hdpi.png"); + }); + + it("logo should be activated with the 'on white' logo", async () => { + + fakeJsonFetch(sandbox, defaultPngSession); + const getSpriteStub = sandbox.stub(IconSprites, "getSpriteFromUrl").callsFake(function _(_url: string) { + return {} as Sprite; + }); + const settings = GoogleMaps.createBaseLayerSettings({mapType: "roadmap", language: "en-US", region: "US"}); + const provider = createProvider(settings); + await provider.initialize(); + + expect(getSpriteStub.firstCall.args[0]).to.eq("public/images/google_on_white_hdpi.png"); + }); + + it("should decorate", async () => { + fakeJsonFetch(sandbox, defaultPngSession); + const settings = GoogleMaps.createBaseLayerSettings(minCreateSessionOptions); + + const provider = createProvider(settings); + + await provider.initialize(); + + const addCanvasDecorationStub = sinon.stub(DecorateContext.prototype, "addCanvasDecoration"); + sinon.stub(LogoDecoration.prototype, "isLoaded").get(() => true); + const context = DecorateContext.create({ viewport: {getFrustum: ()=>new Frustum()} as ScreenViewport, output: new Decorations() }); + + provider.decorate(context); + + expect(addCanvasDecorationStub.called).to.be.true; + }); + + +}); diff --git a/extensions/map-layers-formats/src/test/TestUtils.ts b/extensions/map-layers-formats/src/test/TestUtils.ts new file mode 100644 index 000000000000..4636f53d5d8d --- /dev/null +++ b/extensions/map-layers-formats/src/test/TestUtils.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Bentley Systems, Incorporated. All rights reserved. +* See LICENSE.md in the project root for license terms and full copyright notice. +*--------------------------------------------------------------------------------------------*/ +import { ByteStream } from "@itwin/core-bentley"; + +export const createFakeTileResponse = (contentType: string, data?: Uint8Array) => { + const test = { + headers: new Headers( { "content-type" : contentType}), + arrayBuffer: async () => { + return Promise.resolve(data ? ByteStream.fromUint8Array(data).arrayBuffer : undefined); + }, + status: 200, + } as unknown; // By using unknown type, I can define parts of Response I really need + return (test as Response ); +}; + +export const fakeTextFetch = (sandbox: sinon.SinonSandbox, text: string) => { + return sandbox.stub(globalThis, "fetch").callsFake(async function (_input: RequestInfo | URL, _init?: RequestInit) { + return Promise.resolve((({ + text: async () => text, + ok: true, + status: 200, + } as unknown) as Response)); + }); +}; + +export const fakeJsonFetch = (sandbox: sinon.SinonSandbox, data: any) => { + return sandbox.stub(globalThis, "fetch").callsFake(async function (_input: RequestInfo | URL, _init?: RequestInit) { + return Promise.resolve((({ + json: async () => data, + ok: true, + status: 200, + } as unknown) as Response)); + }); +}; + + +export const indexedArrayFromUrlParams = (urlParams: URLSearchParams): {[key: string]: string} => { + const array: {[key: string]: string} = {}; + urlParams.forEach((value: string, key: string) => { + array[key] = value; + }); + return array; +}; diff --git a/test-apps/display-test-app/src/common/DtaConfiguration.ts b/test-apps/display-test-app/src/common/DtaConfiguration.ts index 3d3699151c55..0542aa85ec9f 100644 --- a/test-apps/display-test-app/src/common/DtaConfiguration.ts +++ b/test-apps/display-test-app/src/common/DtaConfiguration.ts @@ -34,6 +34,7 @@ export interface DtaBooleanConfiguration { ignoreCache?: boolean; // default is undefined, set to true to delete a cached version of a remote imodel before opening it. noElectronAuth?: boolean; // if true, don't initialize auth client. It currently has a bug that produces an exception on every attempt to obtain access token, i.e., every RPC call. noImdlWorker?: boolean; // if true, parse iMdl content on main thread instead of web worker (easier to debug). + googleMapsUi?: boolean; // if set, a Google Maps toolbar icon will be displayed in the UI } export interface DtaStringConfiguration { @@ -46,6 +47,7 @@ export interface DtaStringConfiguration { iTwinId?: GuidString; // default is undefined, used by spatial classification to query reality data from context share, and by iModel download mapBoxKey?: string; // default undefined bingMapsKey?: string; // default undefined + googleMapsKey?: string; // default undefined cesiumIonKey?: string; // default undefined logLevel?: string; // default undefined windowSize?: string; // default undefined @@ -141,6 +143,9 @@ export const getConfig = (): DtaConfiguration => { if (undefined !== process.env.IMJS_BING_MAPS_KEY) configuration.bingMapsKey = process.env.IMJS_BING_MAPS_KEY; + if (undefined !== process.env.IMJS_GOOGLE_MAPS_KEY) + configuration.googleMapsKey = process.env.IMJS_GOOGLE_MAPS_KEY; + if (undefined !== process.env.IMJS_MAPBOX_KEY) configuration.mapBoxKey = process.env.IMJS_MAPBOX_KEY; @@ -153,6 +158,9 @@ export const getConfig = (): DtaConfiguration => { if (undefined !== process.env.IMJS_WINDOW_SIZE) configuration.windowSize = process.env.IMJS_WINDOW_SIZE; + if (undefined !== process.env.IMJS_GOOGLEMAPS_UI) + configuration.googleMapsUi = !!process.env.IMJS_GOOGLEMAPS_UI; + configuration.devTools = undefined === process.env.IMJS_NO_DEV_TOOLS; configuration.cacheTileMetadata = undefined !== process.env.IMJS_CACHE_TILE_METADATA; configuration.useProjectExtents = undefined === process.env.IMJS_NO_USE_PROJECT_EXTENTS; diff --git a/test-apps/display-test-app/src/frontend/App.ts b/test-apps/display-test-app/src/frontend/App.ts index 035c0ae00184..3a08f3390a18 100644 --- a/test-apps/display-test-app/src/frontend/App.ts +++ b/test-apps/display-test-app/src/frontend/App.ts @@ -271,6 +271,9 @@ export class DisplayTestApp { BingMaps: configuration.bingMapsKey ? { key: "key", value: configuration.bingMapsKey } : undefined, + GoogleMaps: configuration.googleMapsKey + ? { key: "key", value: configuration.googleMapsKey } + : undefined, }, /* eslint-enable @typescript-eslint/naming-convention */ hubAccess: createHubAccess(configuration), diff --git a/test-apps/display-test-app/src/frontend/DisplayTestApp.ts b/test-apps/display-test-app/src/frontend/DisplayTestApp.ts index f3b96b426040..7435662acf4c 100644 --- a/test-apps/display-test-app/src/frontend/DisplayTestApp.ts +++ b/test-apps/display-test-app/src/frontend/DisplayTestApp.ts @@ -323,7 +323,7 @@ async function initView(iModel: IModelConnection | undefined) { input: document.getElementById("browserFileSelector") as HTMLInputElement, } : undefined; - DisplayTestApp.surface = new Surface(document.getElementById("app-surface")!, document.getElementById("toolBar")!, fileSelector, configuration.openReadWrite ?? false); + DisplayTestApp.surface = new Surface(configuration, document.getElementById("app-surface")!, document.getElementById("toolBar")!, fileSelector, configuration.openReadWrite ?? false); // We need layout to complete so that the div we want to stick our viewport into has non-zero dimensions. // Consistently reproducible for some folks, not others... @@ -334,6 +334,7 @@ async function initView(iModel: IModelConnection | undefined) { iModel, defaultViewName: configuration.viewName, disableEdges: true === configuration.disableEdges, + configuration }); viewer.dock(Dock.Full); 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..b571d05beb0c --- /dev/null +++ b/test-apps/display-test-app/src/frontend/GoogleMaps.ts @@ -0,0 +1,339 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Bentley Systems, Incorporated. All rights reserved. +* See LICENSE.md in the project root for license terms and full copyright notice. +*--------------------------------------------------------------------------------------------*/ + +import { assert, dispose } from "@itwin/core-bentley"; +import { + CheckBox, + ComboBox, ComboBoxEntry, createButton, createCheckBox, createComboBox, + createTextBox + + , +} from "@itwin/frontend-devtools"; +import { BackgroundMapType, BaseMapLayerSettings, ContourGroupProps, ImageMapLayerSettings } from "@itwin/core-common"; +import { Viewport } from "@itwin/core-frontend"; +import { ToolBarDropDown } from "./ToolBar"; +import { GoogleMaps, GoogleMapsCreateSessionOptions, GoogleMapsLayerTypes, GoogleMapsMapTypes, GoogleMapsScaleFactors } from "@itwin/map-layers-formats"; + +// size of widget or panel +const winSize = { top: 0, left: 0, width: 318, height: 300 }; + +export class GoogleMapsSettings implements Disposable { + private readonly _vp: Viewport; + private readonly _parent: HTMLElement; + private readonly _element: HTMLElement; + private _currentTerrainProps: ContourGroupProps = {}; + private _enabled: boolean = false; + private _overlay: boolean = false; + private _newStyle: boolean = false; + private _mapTypesCombobox: ComboBox; + private _mapType: GoogleMapsMapTypes; + private _scaleFactor: GoogleMapsScaleFactors = "scaleFactor1x"; + private _layerTypes: GoogleMapsLayerTypes[] = []; + private _lang = "en-US"; + + private _roadmapLayerCheckox: CheckBox|undefined; + + public constructor(vp: Viewport, parent: HTMLElement) { + this._currentTerrainProps.contourDef = {}; + this._currentTerrainProps.subCategories = ""; + + this._vp = vp; + this._parent = parent; + this._mapType = "satellite"; + this._enabled = false; + + this._element = document.createElement("div"); + this._element.className = "toolMenu"; + this._element.style.display = "block"; + this._element.style.overflowX = "none"; + this._element.style.overflowY = "none"; + const width = winSize.width * 0.98; + this._element.style.width = `${width}px`; + const firstLayer = this._vp.displayStyle.getMapLayers(false).length === 1 && this._vp.displayStyle.getMapLayers(false)[0] ? this._vp.displayStyle.getMapLayers(false)[0] : undefined; + const googleLayer = firstLayer instanceof ImageMapLayerSettings && firstLayer.formatId === "GoogleMaps" ? firstLayer : undefined; + + const isGoogleBase = vp.displayStyle.backgroundMapBase instanceof BaseMapLayerSettings && vp.displayStyle.backgroundMapBase.formatId === "GoogleMaps"; + this._enabled = isGoogleBase || googleLayer !== undefined; + this._overlay = googleLayer !== undefined; + if (googleLayer) { + const opts = googleLayer.properties; + if (opts) { + this._mapType = opts.mapType as GoogleMapsMapTypes; + this._layerTypes = opts.layerTypes as GoogleMapsLayerTypes[] ?? []; + this._lang = opts.language as string ?? "en-US"; + this._scaleFactor = opts.scale as GoogleMapsScaleFactors ?? "scaleFactor1x"; + } + } + if (isGoogleBase) { + const baseSettings = vp.displayStyle.backgroundMapBase as BaseMapLayerSettings; + const properties = baseSettings.properties; + this._mapType = (properties?.mapType??"") as GoogleMapsMapTypes; + + this._layerTypes = (properties?.layerTypes ?? []) as GoogleMapsLayerTypes[]; + this._lang = (properties?.language??"") as string; + + if (properties?.scale) + this._scaleFactor = properties.scale as GoogleMapsScaleFactors; + + if (properties?.apiOptions){ + const apiOptions = properties.apiOptions as string[]; + + this._newStyle = apiOptions.includes("MCYJ5E517XR2JC"); + } + } + + + createCheckBox({ + parent: this._element, + name: "Enable google maps", + id: "cbx_toggle_google_maps", + isChecked: this._enabled, + handler: (checkbox) => { + assert(this._vp.view.is3d()); + this._enabled = checkbox.checked + this.sync(); + }, + }); + this._element.appendChild(document.createElement("br")); + + //////////////// + // Map types + const mapTypes: ComboBoxEntry[] = [ + { name: "roadmap", value: "roadmap" }, + { name: "satellite", value: "satellite" }, + { name: "terrain", value: "terrain" }, + ]; + this._mapTypesCombobox = createComboBox({ + parent: this._element, + name: "map type: ", + entries: mapTypes, + id: "google_map_type_cbx", + value: this._mapType, + handler: (cbx) => { + this._mapType = cbx.value as GoogleMapsMapTypes; + if (this._mapType === "terrain" && !this._layerTypes.includes("layerRoadmap")) { + this._layerTypes.push("layerRoadmap"); + } + + // Force roadmap layer to be enabled if terrain is selected + if (this._roadmapLayerCheckox) { + if (this._mapType === "terrain") { + this._roadmapLayerCheckox.checkbox.checked = true; + this._roadmapLayerCheckox.checkbox.disabled = true; + } else { + this._roadmapLayerCheckox.checkbox.disabled = false; + } + } + + }, + }); + this._element.appendChild(document.createElement("br")); + + + //////////////// + // Layer types + const layerTypesDiv = document.createElement("div"); + const layerTypesLabel = document.createElement("label"); + layerTypesLabel.innerText = "Layer types:"; + layerTypesLabel.style.fontWeight = "bold"; + layerTypesLabel.style.display = "inline"; + layerTypesDiv.appendChild(layerTypesLabel); + + this._roadmapLayerCheckox = createCheckBox({ + parent: layerTypesDiv, + name: "Roadmap", + id: "google_layertype_roadmap", + isChecked: this._layerTypes.includes("layerRoadmap"), + handler: (cb) => { + if (cb.checked) { + this._layerTypes.push("layerRoadmap"); + } else { + this._layerTypes = this._layerTypes.filter((layerType) => layerType !== "layerRoadmap"); + } + }, + }); + this._roadmapLayerCheckox.checkbox.disabled = this._mapType === "terrain"; + + createCheckBox({ + parent: layerTypesDiv, + name: "Streetview", + id: "google_layertype_streetview", + isChecked: this._layerTypes.includes("layerStreetview"), + handler: (cb) => { + if (cb.checked) { + this._layerTypes.push("layerStreetview"); + } else { + this._layerTypes = this._layerTypes.filter((layerType) => layerType !== "layerStreetview"); + } + }, + }); + this._element.appendChild(layerTypesDiv); + this._element.appendChild(document.createElement("br")); + + ///////////////// + // Language + const langDiv = document.createElement("div"); + const langLabel = document.createElement("label"); + langLabel.innerText = "Language:"; + langLabel.style.fontWeight = "bold"; + langLabel.style.display = "inline"; + langDiv.appendChild(langLabel); + const langTbox = createTextBox({ + id: "txt_lang", + parent: langDiv, + tooltip: "lang", + handler: (tb) => {this._lang = tb.value.trim()}, + }); + langTbox.textbox.style.display = "inline"; + langTbox.textbox.value = this._lang; + this._element.appendChild(langDiv); + this._element.appendChild(document.createElement("br")); + + + + //////////////// + // Scale factors + const scaleFactors: ComboBoxEntry[] = [ + { name: "1x", value: "scaleFactor1x" }, + { name: "2x", value: "scaleFactor2x" }, + { name: "4x", value: "scaleFactor4x" }, + ]; + createComboBox({ + parent: this._element, + name: "scale factor: ", + entries: scaleFactors, + id: "google_scale_factors_cbx", + value: this._scaleFactor, + handler: (cbx) => { + this._scaleFactor = cbx.value as GoogleMapsScaleFactors; + }, + }); + this._element.appendChild(document.createElement("br")); + + createCheckBox({ + parent: this._element, + name: "Overlay mode", + id: "cbx_toggle_overlay", + isChecked: this._overlay, + handler: (checkbox) => { + this._overlay = checkbox.checked; + this.sync(); + }, + }); + this._element.appendChild(document.createElement("br")); + + createCheckBox({ + parent: this._element, + name: "New 2025 style", + id: "cbx_toggle_newStyle", + isChecked: this._newStyle, + handler: (checkbox) => { + this._newStyle = checkbox.checked; + this.sync(); + }, + }); + this._element.appendChild(document.createElement("br")); + + + /////////// + // Buttons + this._mapTypesCombobox.label!.style.fontWeight = "bold"; + const buttonDiv = document.createElement("div"); + buttonDiv.style.textAlign = "center"; + createButton({ + value: "Apply", + handler: () => { this.apply(); }, + parent: buttonDiv, + inline: true, + tooltip: "Apply contour settings for this definition", + }); + + this._element.appendChild(buttonDiv); + this._element.appendChild(document.createElement("br")); + parent.appendChild(this._element); + + } + + public [Symbol.dispose](): void { + this._parent.removeChild(this._element); + } + + private sync(): void { + + this._vp.synchWithView(); + } + + private apply() { + const removeExistingMapLayer = (isOverlay: boolean) => { + const mapLayers = this._vp.displayStyle.getMapLayers(isOverlay); + if (mapLayers.length > 0) + this._vp.displayStyle.detachMapLayerByIndex({index: 0, isOverlay}); + } + if (!this._enabled) { + removeExistingMapLayer(false); + this._vp.view.displayStyle.changeBackgroundMapProvider({ name: "BingProvider", type: BackgroundMapType.Hybrid }); + return; + } + + + if (this._overlay) { + this._vp.displayStyle.backgroundMapBase = BaseMapLayerSettings.fromJSON({ + formatId: "ArcGIS", + url: "https://services.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer", + name: "ESRI World Imagery" + }); + const opts: GoogleMapsCreateSessionOptions = { + mapType: "satellite", + language: "en-US", + region: "US", + overlay: true, + layerTypes: this._layerTypes, + }; + removeExistingMapLayer(false); + this._vp.displayStyle.attachMapLayer({mapLayerIndex: {index: 0, isOverlay: false}, settings: GoogleMaps.createMapLayerSettings("GoogleMaps", opts)}); + } else { + removeExistingMapLayer(false); + const opts: GoogleMapsCreateSessionOptions = { + mapType: this._mapType, + layerTypes: this._layerTypes, + language: this._lang, + region: "US", + scale: this._scaleFactor, + apiOptions: this._newStyle ? ["MCYJ5E517XR2JC"] : undefined, + }; + try { + this._vp.displayStyle.backgroundMapBase = GoogleMaps.createBaseLayerSettings(opts); + } catch (e: any) { + // eslint-disable-next-line no-console + console.log(e.message); + } + + } + + this.sync(); + } + +} + +export class GoogleMapsPanel extends ToolBarDropDown { + private readonly _vp: Viewport; + private readonly _parent: HTMLElement; + private _settings?: GoogleMapsSettings; + + public constructor(vp: Viewport, parent: HTMLElement) { + super(); + this._vp = vp; + this._parent = parent; + this.open(); + } + + public override get onViewChanged(): Promise { + return Promise.resolve(); + } + + protected _open(): void { this._settings = new GoogleMapsSettings(this._vp, this._parent); } + protected _close(): void { this._settings = dispose(this._settings); } + public get isOpen(): boolean { return undefined !== this._settings; } +} diff --git a/test-apps/display-test-app/src/frontend/Surface.ts b/test-apps/display-test-app/src/frontend/Surface.ts index 1731111201d2..7a4a362cff0d 100644 --- a/test-apps/display-test-app/src/frontend/Surface.ts +++ b/test-apps/display-test-app/src/frontend/Surface.ts @@ -19,6 +19,7 @@ import { openIModel, OpenIModelProps } from "./openIModel"; import { setTitle } from "./Title"; import { openAnalysisStyleExample } from "./AnalysisStyleExample"; import { openDecorationGeometryExample } from "./DecorationGeometryExample"; +import { DtaConfiguration } from "../common/DtaConfiguration"; // cspell:ignore textbox topdiv @@ -32,13 +33,15 @@ export class Surface { private readonly _toolbar: ToolBar; public readonly browserFileSelector?: BrowserFileSelector; public readonly openReadWrite: boolean; + public readonly configuration: DtaConfiguration; public static get instance() { return DisplayTestApp.surface; } - public constructor(surfaceDiv: HTMLElement, toolbarDiv: HTMLElement, browserFileSelector: BrowserFileSelector | undefined, openReadWrite: boolean) { + public constructor(configuration: DtaConfiguration, surfaceDiv: HTMLElement, toolbarDiv: HTMLElement, browserFileSelector: BrowserFileSelector | undefined, openReadWrite: boolean) { // Ensure iModel gets closed on page close/reload window.onbeforeunload = () => this.closeAllViewers(); + this.configuration = configuration; this.element = surfaceDiv; this.openReadWrite = openReadWrite; this.browserFileSelector = browserFileSelector; @@ -171,7 +174,7 @@ export class Surface { name: props?.name ?? "blank connection test", }); - const viewer = await this.createViewer({ iModel }); + const viewer = await this.createViewer({ iModel, configuration: this.configuration }); viewer.dock(Dock.Full); return viewer; } @@ -190,7 +193,7 @@ export class Surface { try { const iModel = await openIModel(props); setTitle(iModel); - const viewer = await this.createViewer({ iModel }); + const viewer = await this.createViewer({ iModel, configuration: this.configuration }); viewer.dock(Dock.Full); } catch (err: any) { alert(`Error opening iModel: ${err.toString()}`); diff --git a/test-apps/display-test-app/src/frontend/Viewer.ts b/test-apps/display-test-app/src/frontend/Viewer.ts index 58b473388e72..b4c305ccc51b 100644 --- a/test-apps/display-test-app/src/frontend/Viewer.ts +++ b/test-apps/display-test-app/src/frontend/Viewer.ts @@ -1,3 +1,4 @@ + /*--------------------------------------------------------------------------------------------- * Copyright (c) Bentley Systems, Incorporated. All rights reserved. * See LICENSE.md in the project root for license terms and full copyright notice. @@ -30,6 +31,9 @@ import { openIModel, OpenIModelProps } from "./openIModel"; import { HubPicker } from "./HubPicker"; import { RealityModelSettingsPanel } from "./RealityModelDisplaySettingsWidget"; import { ContoursPanel } from "./Contours"; +import { GoogleMapsPanel } from "./GoogleMaps"; +import { DtaConfiguration } from "../common/DtaConfiguration"; + // cspell:ignore savedata topdiv savedview viewtop @@ -159,6 +163,7 @@ export interface ViewerProps { iModel: IModelConnection; defaultViewName?: string; disableEdges?: boolean; + configuration: DtaConfiguration; } export class Viewer extends Window { @@ -171,6 +176,7 @@ export class Viewer extends Window { private readonly _3dOnly: HTMLElement[] = []; private _isSavedView = false; private _debugWindow?: DebugWindow; + private _configuration: DtaConfiguration; public static async create(surface: Surface, props: ViewerProps): Promise { const views = await ViewList.create(props.iModel, props.defaultViewName); @@ -184,6 +190,7 @@ export class Viewer extends Window { const viewer = new Viewer(Surface.instance, view, this.views, { iModel: view.iModel, disableEdges: this.disableEdges, + configuration: this._configuration }); if (!this.isDocked) { @@ -210,6 +217,8 @@ export class Viewer extends Window { private constructor(surface: Surface, view: ViewState, views: ViewList, props: ViewerProps) { super(surface, { scrollbars: true }); + this._configuration = props.configuration; + // Allow HTMLElements beneath viewport to be visible if background color has transparency. this.contentDiv.style.backgroundColor = "transparent"; this.container.style.backgroundColor = "transparent"; @@ -416,6 +425,17 @@ export class Viewer extends Window { tooltip: "Contour display", }); + if(this._configuration.googleMapsUi) { + this.toolBar.addDropDown({ + iconUnicode: "\ue9e8", + createDropDown: async (container: HTMLElement) => { + const panel = new GoogleMapsPanel(this.viewport, container); + return panel; + }, + tooltip: "Google Maps", + }); + } + this.updateTitle(); this.updateActiveSettings(); }