diff --git a/packages/devextreme-angular/src/ui/map/index.ts b/packages/devextreme-angular/src/ui/map/index.ts index b27d9beaf4b7..cd1788f8160a 100644 --- a/packages/devextreme-angular/src/ui/map/index.ts +++ b/packages/devextreme-angular/src/ui/map/index.ts @@ -22,7 +22,7 @@ import { } from '@angular/core'; -import type { ClickEvent, DisposingEvent, InitializedEvent, MarkerAddedEvent, MarkerRemovedEvent, OptionChangedEvent, ReadyEvent, RouteAddedEvent, RouteRemovedEvent, MapProvider, RouteMode, MapType } from 'devextreme/ui/map'; +import type { ClickEvent, DisposingEvent, InitializedEvent, MarkerAddedEvent, MarkerRemovedEvent, OptionChangedEvent, ReadyEvent, RouteAddedEvent, RouteRemovedEvent, MapProvider, OsmTileServer, RouteMode, MapType } from 'devextreme/ui/map'; import DxMap from 'devextreme/ui/map'; @@ -302,10 +302,10 @@ export class DxMapComponent extends DxComponent implements OnDestroy, OnChanges, */ @Input() - get providerConfig(): { mapId?: string, useAdvancedMarkers?: boolean } { + get providerConfig(): { mapId?: string, tileServer?: OsmTileServer | undefined, useAdvancedMarkers?: boolean } { return this._getOption('providerConfig'); } - set providerConfig(value: { mapId?: string, useAdvancedMarkers?: boolean }) { + set providerConfig(value: { mapId?: string, tileServer?: OsmTileServer | undefined, useAdvancedMarkers?: boolean }) { this._setOption('providerConfig', value); } @@ -582,7 +582,7 @@ export class DxMapComponent extends DxComponent implements OnDestroy, OnChanges, * This member supports the internal infrastructure and is not intended to be used directly from your code. */ - @Output() providerConfigChange: EventEmitter<{ mapId?: string, useAdvancedMarkers?: boolean }>; + @Output() providerConfigChange: EventEmitter<{ mapId?: string, tileServer?: OsmTileServer | undefined, useAdvancedMarkers?: boolean }>; /** diff --git a/packages/devextreme-angular/src/ui/map/nested/provider-config.ts b/packages/devextreme-angular/src/ui/map/nested/provider-config.ts index 0b75435ab9a5..2b828d8be463 100644 --- a/packages/devextreme-angular/src/ui/map/nested/provider-config.ts +++ b/packages/devextreme-angular/src/ui/map/nested/provider-config.ts @@ -14,6 +14,7 @@ import { +import type { OsmTileServer } from 'devextreme/ui/map'; import { DxIntegrationModule, @@ -38,6 +39,14 @@ export class DxoMapProviderConfigComponent extends NestedOption implements OnDes this._setOption('mapId', value); } + @Input() + get tileServer(): OsmTileServer | undefined { + return this._getOption('tileServer'); + } + set tileServer(value: OsmTileServer | undefined) { + this._setOption('tileServer', value); + } + @Input() get useAdvancedMarkers(): boolean { return this._getOption('useAdvancedMarkers'); diff --git a/packages/devextreme-angular/src/ui/nested/provider-config.ts b/packages/devextreme-angular/src/ui/nested/provider-config.ts index c4ef29aa4db1..a3139a520b04 100644 --- a/packages/devextreme-angular/src/ui/nested/provider-config.ts +++ b/packages/devextreme-angular/src/ui/nested/provider-config.ts @@ -14,6 +14,7 @@ import { +import type { OsmTileServer } from 'devextreme/ui/map'; import { DxIntegrationModule, @@ -38,6 +39,14 @@ export class DxoProviderConfigComponent extends NestedOption implements OnDestro this._setOption('mapId', value); } + @Input() + get tileServer(): OsmTileServer | undefined { + return this._getOption('tileServer'); + } + set tileServer(value: OsmTileServer | undefined) { + this._setOption('tileServer', value); + } + @Input() get useAdvancedMarkers(): boolean { return this._getOption('useAdvancedMarkers'); diff --git a/packages/devextreme-metadata/aspnet/enums.ts b/packages/devextreme-metadata/aspnet/enums.ts index 06c672dca88b..54680970fd01 100644 --- a/packages/devextreme-metadata/aspnet/enums.ts +++ b/packages/devextreme-metadata/aspnet/enums.ts @@ -44,7 +44,7 @@ export const enums = { Options: ['GaugeIndicator.type'], }, GeoMapProvider: { - Items: ['bing', 'google', 'googleStatic', 'azure'], + Items: ['bing', 'google', 'googleStatic', 'azure', 'osm'], }, SchedulerViewType: { Items: [ diff --git a/packages/devextreme-react/src/map.ts b/packages/devextreme-react/src/map.ts index 0a2d1462543d..66c1eebe12fe 100644 --- a/packages/devextreme-react/src/map.ts +++ b/packages/devextreme-react/src/map.ts @@ -8,7 +8,7 @@ import dxMap, { import { Component as BaseComponent, IHtmlOptions, ComponentRef, NestedComponentMeta } from "./core/component"; import NestedOption from "./core/nested-option"; -import type { ClickEvent, DisposingEvent, InitializedEvent, MarkerAddedEvent, MarkerRemovedEvent, ReadyEvent, RouteAddedEvent, RouteRemovedEvent, RouteMode } from "devextreme/ui/map"; +import type { ClickEvent, DisposingEvent, InitializedEvent, MarkerAddedEvent, MarkerRemovedEvent, ReadyEvent, RouteAddedEvent, RouteRemovedEvent, OsmTileServer, RouteMode } from "devextreme/ui/map"; type ReplaceFieldTypes = { [P in keyof TSource]: P extends keyof TReplacement ? TReplacement[P] : TSource[P]; @@ -183,6 +183,7 @@ const Marker = Object.assign(_comp // Map type IProviderConfigProps = React.PropsWithChildren<{ mapId?: string; + tileServer?: OsmTileServer | undefined; useAdvancedMarkers?: boolean; }> const _componentProviderConfig = (props: IProviderConfigProps) => { diff --git a/packages/devextreme-vue/src/map.ts b/packages/devextreme-vue/src/map.ts index 7e1c8fecdc49..900b55866382 100644 --- a/packages/devextreme-vue/src/map.ts +++ b/packages/devextreme-vue/src/map.ts @@ -14,6 +14,8 @@ import { RouteRemovedEvent, MapProvider, MapType, + OsmTileServer, + OsmTileServerConfig, RouteMode, } from "devextreme/ui/map"; import { prepareConfigurationComponentConfig } from "./core/index"; @@ -245,10 +247,12 @@ const DxProviderConfigConfig = { "update:isActive": null, "update:hoveredElement": null, "update:mapId": null, + "update:tileServer": null, "update:useAdvancedMarkers": null, }, props: { mapId: String, + tileServer: [Object, Function, String] as PropType string | OsmTileServerConfig | null | undefined)) | OsmTileServerConfig | string>, useAdvancedMarkers: Boolean } }; diff --git a/packages/devextreme/eslint.config.mjs b/packages/devextreme/eslint.config.mjs index 34aaa03b144d..267a53c25e32 100644 --- a/packages/devextreme/eslint.config.mjs +++ b/packages/devextreme/eslint.config.mjs @@ -51,6 +51,12 @@ export default [ ], }, ...spellCheckConfig, + { + files: ['js/ui/map_types.d.ts'], + rules: { + 'spellcheck/spell-checker': 'off', + }, + }, { plugins: { 'no-only-tests': noOnlyTests, diff --git a/packages/devextreme/js/__internal/ui/errors.ts b/packages/devextreme/js/__internal/ui/errors.ts index 572ce53435b2..4e53a9781f03 100644 --- a/packages/devextreme/js/__internal/ui/errors.ts +++ b/packages/devextreme/js/__internal/ui/errors.ts @@ -113,6 +113,8 @@ export default errorUtils(errors.ERROR_MESSAGES, { E1068: '\'aiIntegration\' is not configured for the AI Assistant.', + E1069: 'Could not find OpenLayers. Load the library before Map initialization.', + W1001: 'The "key" option cannot be modified after initialization', W1002: 'An item with the key \'{0}\' does not exist', @@ -160,4 +162,6 @@ export default errorUtils(errors.ERROR_MESSAGES, { W1027: 'A prompt should be specified for a custom command.', W1028: 'Nested/banded columns do not support the following properties: {0}.', W1029: '\'hiddenWeekDays\' must leave at least one weekday visible.', + W1030: '\'tileServer\' is not configured for the OSM map provider.', + W1032: '\'attribution\' is not configured for the OSM tile server.', }); diff --git a/packages/devextreme/js/__internal/ui/map/map.ts b/packages/devextreme/js/__internal/ui/map/map.ts index a57e5a94fa36..4b505036450a 100644 --- a/packages/devextreme/js/__internal/ui/map/map.ts +++ b/packages/devextreme/js/__internal/ui/map/map.ts @@ -21,6 +21,8 @@ import type { LocationOption } from './provider.dynamic'; import azure from './provider.dynamic.azure'; import bing from './provider.dynamic.bing'; import google from './provider.dynamic.google'; +// eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap provider identifier +import osm from './provider.dynamic.osm'; // NOTE external urls must have protocol explicitly specified // (because inside Cordova package the protocol is "file:") import googleStatic from './provider.google_static'; @@ -30,6 +32,8 @@ const PROVIDERS = { googleStatic, google, bing, + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap provider identifier + osm, }; const MAP_CLASS = 'dx-map'; @@ -54,7 +58,8 @@ class Map extends Widget { _lastAsyncAction!: Promise; - _provider!: azure | googleStatic | google | bing; + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap provider identifier + _provider!: azure | googleStatic | google | bing | osm; _asyncActionSuppressed?: boolean; diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.engine.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.engine.ts new file mode 100644 index 000000000000..05c191672521 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.engine.ts @@ -0,0 +1,26 @@ +import type { MapLocation } from '@js/ui/map'; + +export interface MapEngineTileLayerOptions { + attribution: string; + maxZoom: number; + // eslint-disable-next-line spellcheck/spell-checker -- tile server option name + subdomains?: string | string[]; + url: string; +} + +export interface MapEngineSetViewOptions { + center?: MapLocation; + zoom?: number; +} + +export interface MapEngineMap { + readonly originalMap: unknown; + dispose: () => void; + replaceTileLayer: (options: MapEngineTileLayerOptions) => void; + setView: (options: MapEngineSetViewOptions) => void; + updateDimensions: () => void; +} + +export interface MapEngine { + createMap: (container: Element, view?: MapEngineSetViewOptions) => MapEngineMap; +} diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts new file mode 100644 index 000000000000..399a7c2053ef --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts @@ -0,0 +1,189 @@ +import type { MapLocation } from '@js/ui/map'; + +import type { + MapEngine, + MapEngineMap, + MapEngineSetViewOptions, + MapEngineTileLayerOptions, +} from './provider.dynamic.osm.engine'; + +type Options = Record; +type Coordinate = [number, number]; + +interface ViewLike { + setCenter: (center: Coordinate) => void; + setZoom: (zoom: number) => void; +} + +interface TileLayerLike { + setSource: (source: unknown) => void; +} + +interface MapLike { + addLayer: (layer: unknown) => void; + getView: () => ViewLike; + removeLayer: (layer: unknown) => void; + setTarget: (target?: Element) => void; + updateSize: () => void; +} + +interface OpenLayersApi { + Map: new (options: Options) => MapLike; + View: new (options: Options) => ViewLike; + control: { + defaults: { + defaults: (options?: Options) => unknown; + }; + }; + interaction: { + defaults: { + defaults: (options?: Options) => unknown; + }; + }; + layer: { + Tile: new (options: Options) => TileLayerLike; + }; + proj: { + fromLonLat: (coordinate: Coordinate) => Coordinate; + }; + source: { + ImageTile: new (options: Options) => unknown; + }; +} + +const isRecord = (value: unknown): value is Record => ( + Boolean(value) && (typeof value === 'object' || typeof value === 'function') +); + +const hasFunction = (value: unknown, property: string): boolean => ( + isRecord(value) && typeof value[property] === 'function' +); + +const isOpenLayersApi = (api: unknown): api is OpenLayersApi => { + if (!isRecord(api)) { + return false; + } + + return typeof api.Map === 'function' + && typeof api.View === 'function' + && isRecord(api.control) + && isRecord(api.control.defaults) + && hasFunction(api.control.defaults, 'defaults') + && isRecord(api.interaction) + && isRecord(api.interaction.defaults) + && hasFunction(api.interaction.defaults, 'defaults') + && hasFunction(api.layer, 'Tile') + && hasFunction(api.proj, 'fromLonLat') + && hasFunction(api.source, 'ImageTile'); +}; + +const toCoordinate = (api: OpenLayersApi, location: MapLocation): Coordinate => ( + api.proj.fromLonLat([location.lng, location.lat]) +); + +const createTileUrlList = ( + url: string, + // eslint-disable-next-line spellcheck/spell-checker -- tile server option name + subdomains: MapEngineTileLayerOptions['subdomains'], +): string[] => { + // eslint-disable-next-line spellcheck/spell-checker -- tile server option name + const values = Array.isArray(subdomains) ? subdomains : [...(subdomains ?? '')]; + + return values.map((value) => url.replace('{s}', value)); +}; + +class OpenLayersMap implements MapEngineMap { + readonly originalMap: MapLike; + + private readonly _keyboardEventTarget: Element; + + private readonly _ownsKeyboardTabIndex: boolean; + + private _tileLayer?: TileLayerLike; + + private _disposed = false; + + constructor( + private readonly _api: OpenLayersApi, + container: Element, + view: MapEngineSetViewOptions = {}, + ) { + const rootNode = container.getRootNode(); + const ShadowRootConstructor = container.ownerDocument.defaultView?.ShadowRoot; + const shadowRoot = ShadowRootConstructor && rootNode instanceof ShadowRootConstructor + ? rootNode + : undefined; + + this._keyboardEventTarget = shadowRoot?.host ?? container; + this._ownsKeyboardTabIndex = !this._keyboardEventTarget.hasAttribute('tabindex'); + if (this._ownsKeyboardTabIndex) { + this._keyboardEventTarget.setAttribute('tabindex', '0'); + } + + this.originalMap = new _api.Map({ + controls: _api.control.defaults.defaults({ attribution: true, rotate: false, zoom: false }), + interactions: _api.interaction.defaults.defaults({ onFocusOnly: false }), + target: container, + view: new _api.View({ + center: toCoordinate(_api, view.center ?? { lat: 0, lng: 0 }), + zoom: view.zoom ?? 1, + }), + }); + } + + dispose(): void { + if (this._disposed) { + return; + } + + this._disposed = true; + if (this._tileLayer) { + this.originalMap.removeLayer(this._tileLayer); + this._tileLayer = undefined; + } + this.originalMap.setTarget(undefined); + if (this._ownsKeyboardTabIndex && this._keyboardEventTarget.getAttribute('tabindex') === '0') { + this._keyboardEventTarget.removeAttribute('tabindex'); + } + } + + replaceTileLayer(options: MapEngineTileLayerOptions): void { + const source = new this._api.source.ImageTile({ + attributions: options.attribution, + maxZoom: options.maxZoom, + url: options.url.includes('{s}') + // eslint-disable-next-line spellcheck/spell-checker -- tile server option name + ? createTileUrlList(options.url, options.subdomains) + : options.url, + }); + + if (this._tileLayer) { + this._tileLayer.setSource(source); + return; + } + + this._tileLayer = new this._api.layer.Tile({ source }); + this.originalMap.addLayer(this._tileLayer); + } + + setView(options: MapEngineSetViewOptions): void { + const view = this.originalMap.getView(); + + if (options.center) { + view.setCenter(toCoordinate(this._api, options.center)); + } + if (options.zoom !== undefined) { + view.setZoom(options.zoom); + } + } + + updateDimensions(): void { + this.originalMap.updateSize(); + } +} + +export const createOpenLayersEngine = (api: unknown): MapEngine | undefined => ( + isOpenLayersApi(api) + ? { createMap: (container, view) => new OpenLayersMap(api, container, view) } + : undefined +); diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts new file mode 100644 index 000000000000..5f88692186ae --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts @@ -0,0 +1,185 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ +import { getWindow } from '@js/core/utils/window'; +import type { + MapLocation, + MapType, + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap API identifier + OsmTileServerConfig as TileServerConfig, +} from '@js/ui/map'; +import errors from '@js/ui/widget/ui.errors'; + +import type { + LocationOption, + MarkerOptions, + RouteOptions, +} from './provider.dynamic'; +import DynamicProvider from './provider.dynamic'; +import type { + MapEngine, + MapEngineMap, + MapEngineTileLayerOptions, +} from './provider.dynamic.osm.engine'; +import { createOpenLayersEngine } from './provider.dynamic.osm.openlayers'; + +const DEFAULT_MAX_ZOOM = 19; +const DEFAULT_SUBDOMAINS = 'abc'; + +// eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap provider identifier +class OsmProvider extends DynamicProvider { + _engine?: MapEngine; + + _engineMap?: MapEngineMap; + + _currentTileType?: MapType; + + _loadImpl(): Promise { + const window = getWindow() as Window & { ol?: unknown }; + const engine = createOpenLayersEngine(window.ol); + + if (!engine) { + return Promise.reject(errors.Error('E1069')); + } + + this._engine = engine; + + return Promise.resolve(); + } + + _init(): Promise { + const engineMap = this._engine?.createMap(this._$container[0], { + center: this._resolveLocation(this._option('center')), + zoom: this._option('zoom') ?? 1, + }); + + if (!engineMap) { + return Promise.reject(errors.Error('E1069')); + } + + this._engineMap = engineMap; + this._map = engineMap.originalMap; + + return Promise.resolve(); + } + + _resolveTileConfig(type: MapType): TileServerConfig | undefined { + const option = this._option('providerConfig')?.tileServer; + const resolved = typeof option === 'function' ? option(type) : option; + + if (!resolved) { + return undefined; + } + + return typeof resolved === 'string' ? { url: resolved } : resolved; + } + + _resolveTileLayerOptions(type: MapType): MapEngineTileLayerOptions | undefined { + const config = this._resolveTileConfig(type); + + if (!config?.url) { + errors.log('W1030'); + return undefined; + } + + if (!config.attribution) { + errors.log('W1032'); + } + + const result: MapEngineTileLayerOptions = { + attribution: config.attribution ?? '', + maxZoom: config.maxZoom ?? DEFAULT_MAX_ZOOM, + url: config.url, + }; + + if (config.url.includes('{s}')) { + // eslint-disable-next-line spellcheck/spell-checker -- tile server option name + result.subdomains = config.subdomains?.length ? config.subdomains : DEFAULT_SUBDOMAINS; + } + + return result; + } + + _resolveLocation(location?: LocationOption | null): MapLocation { + return this._getLatLng(location) ?? { lat: 0, lng: 0 }; + } + + _attachHandlers(): void {} + + updateDimensions(): Promise { + this._engineMap?.updateDimensions(); + + return Promise.resolve(); + } + + updateMapType(): Promise { + const type = this._option('type') ?? 'roadmap'; + + if (type === this._currentTileType) { + return Promise.resolve(); + } + + const tileServer = this._option('providerConfig')?.tileServer; + if (this._currentTileType !== undefined && typeof tileServer !== 'function') { + this._currentTileType = type; + + return Promise.resolve(); + } + + const options = this._resolveTileLayerOptions(type); + if (options && this._engineMap) { + this._engineMap.replaceTileLayer(options); + this._currentTileType = type; + } + + return Promise.resolve(); + } + + updateCenter(): Promise { + this._engineMap?.setView({ + center: this._resolveLocation(this._option('center')), + }); + + return Promise.resolve(); + } + + updateZoom(): Promise { + this._engineMap?.setView({ zoom: this._option('zoom') ?? 1 }); + + return Promise.resolve(); + } + + updateDisabled(): Promise { + return Promise.resolve(); + } + + updateBounds(): Promise { + return Promise.resolve(); + } + + updateControls(): Promise { + return Promise.resolve(); + } + + adjustViewport(): Promise { + return Promise.resolve(); + } + + addMarkers(markers: MarkerOptions[]): Promise<[boolean, unknown[]]> { + return Promise.resolve([false, markers.map(() => undefined)]); + } + + addRoutes(routes: RouteOptions[]): Promise<[boolean, unknown[]]> { + return Promise.resolve([false, routes.map(() => undefined)]); + } + + clean(): Promise { + this._engineMap?.dispose(); + this._engineMap = undefined; + this._engine = undefined; + this._map = undefined; + + return Promise.resolve(); + } +} + +// eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap provider identifier +export default OsmProvider; diff --git a/packages/devextreme/js/__internal/ui/map/provider.ts b/packages/devextreme/js/__internal/ui/map/provider.ts index 7a34a8329f18..3daf5ce33f85 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.ts @@ -10,6 +10,8 @@ import type Map from './map'; import type { MapProperties } from './map'; import type { LocationOption, MarkerOptions, RouteOptions } from './provider.dynamic'; +type KeyedMapProvider = Exclude; + class Provider { _mapWidget!: Map; @@ -167,7 +169,7 @@ class Provider { return undefined; } - _keyOption(providerName: MapProvider): string { + _keyOption(providerName: KeyedMapProvider): string { const key = this._option('apiKey') ?? ''; if (typeof key === 'string') { return key; diff --git a/packages/devextreme/js/ui/map.d.ts b/packages/devextreme/js/ui/map.d.ts index 3114984ba144..6864f5e2cc01 100644 --- a/packages/devextreme/js/ui/map.d.ts +++ b/packages/devextreme/js/ui/map.d.ts @@ -14,12 +14,49 @@ import Widget, { } from './widget/ui.widget'; /** @public */ -export type MapProvider = 'azure' | 'bing' | 'google' | 'googleStatic'; +export type MapProvider = 'azure' | 'bing' | 'google' | 'googleStatic' | 'osm'; /** @public */ export type RouteMode = 'driving' | 'walking'; /** @public */ export type MapType = 'hybrid' | 'roadmap' | 'satellite'; +/** + * @docid + * @public + */ +// eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap API identifier +export interface OsmTileServerConfig { + /** + * @docid + */ + url: string; + /** + * @docid + * @default '' + */ + attribution?: string; + /** + * @docid + * @default 'abc' + */ + // eslint-disable-next-line spellcheck/spell-checker -- tile server option name + subdomains?: string | Array; + /** + * @docid + * @default 19 + */ + maxZoom?: number; +} + +/** + * @docid + * @public + */ +// eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap API identifiers +export type OsmTileServer = string | OsmTileServerConfig +// eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap API identifier +| ((type: MapType) => string | OsmTileServerConfig | null | undefined); + /** * @docid _ui_map_ClickEvent * @public @@ -336,6 +373,13 @@ export interface dxMapOptions extends WidgetOptions { * @deprecated */ useAdvancedMarkers?: boolean; + /** + * @docid + * @public + * @default undefined + */ + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap API identifier + tileServer?: OsmTileServer | undefined; }; /** * @docid diff --git a/packages/devextreme/js/ui/map_types.d.ts b/packages/devextreme/js/ui/map_types.d.ts index 2c1637b47f4d..f6ee3f73cff6 100644 --- a/packages/devextreme/js/ui/map_types.d.ts +++ b/packages/devextreme/js/ui/map_types.d.ts @@ -2,6 +2,8 @@ export { MapProvider, RouteMode, MapType, + OsmTileServerConfig, + OsmTileServer, ClickEvent, DisposingEvent, InitializedEvent, diff --git a/packages/devextreme/js/ui/widget/ui.errors.js b/packages/devextreme/js/ui/widget/ui.errors.js index 1efdfe91bc5e..4de3c5415ac4 100644 --- a/packages/devextreme/js/ui/widget/ui.errors.js +++ b/packages/devextreme/js/ui/widget/ui.errors.js @@ -164,6 +164,9 @@ /** * @name ErrorsUIWidgets.E1068 */ +/** + * @name ErrorsUIWidgets.E1069 + */ /** * @name ErrorsUIWidgets.W1001 */ @@ -242,6 +245,12 @@ /** * @name ErrorsUIWidgets.W1029 */ +/** + * @name ErrorsUIWidgets.W1030 + */ +/** + * @name ErrorsUIWidgets.W1032 + */ import errors from '../../__internal/ui/errors'; export default errors; diff --git a/packages/devextreme/testing/helpers/forMap/openLayersMock.js b/packages/devextreme/testing/helpers/forMap/openLayersMock.js new file mode 100644 index 000000000000..218bd4c59250 --- /dev/null +++ b/packages/devextreme/testing/helpers/forMap/openLayersMock.js @@ -0,0 +1,101 @@ +/* global window */ + +(() => { + const api = {}; + class MockView { + constructor(options) { + this.center = options.center; + this.zoom = options.zoom; + api.viewCenter = options.center; + api.viewOptions = options; + api.viewZoom = options.zoom; + } + setCenter(center) { + this.center = center; + api.viewCenter = center; + api.viewCenterSetCount += 1; + } + setZoom(zoom) { + this.zoom = zoom; + api.viewZoom = zoom; + } + } + class MockMap { + constructor(options) { + this.options = options; + this.view = options.view; + api.mapCreated = true; + api.mapInstance = this; + api.mapOptions = options; + } + addLayer(layer) { + api.tileLayer = layer; + api.addedTileLayers.push(layer); + } + getView() { + return this.view; + } + removeLayer(layer) { + api.removedLayers.push(layer); + } + setTarget(target) { + this.target = target; + api.mapTarget = target; + } + updateSize() { + api.mapResized = true; + } + } + class MockImageTile { + constructor(options) { + if(api.throwOnTileSource) { + throw new Error('Tile source creation failed'); + } + this.options = options; + api.tileSourceOptions = options; + } + } + class MockTileLayer { + constructor(options) { + this.source = options.source; + api.tileLayerOptions = options; + } + setSource(source) { + this.source = source; + api.tileSourceChanges.push(source); + } + } + Object.assign(api, { + Map: MockMap, + View: MockView, + control: { + defaults: { + defaults(options) { + api.controlOptions = options; + return []; + } + } + }, + interaction: { + defaults: { + defaults(options) { + api.interactionOptions = options; + return []; + } + } + }, + layer: { + Tile: MockTileLayer + }, + proj: { + fromLonLat(coordinate) { + api.projectedCoordinates.push([...coordinate]); + return coordinate; + } + }, + source: { + ImageTile: MockImageTile + } + }); + window.ol = api; +})(); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/map.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/map.tests.js index 6f93519a013d..949d5f68c868 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/map.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/map.tests.js @@ -14,3 +14,4 @@ import './mapParts/googleStaticTests.js'; import './mapParts/googleTests.js'; import './mapParts/bingTests.js'; import './mapParts/azureTests.js'; +import './mapParts/osmTests.js'; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js new file mode 100644 index 000000000000..8fa7e0977d04 --- /dev/null +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -0,0 +1,642 @@ +import $ from 'jquery'; + +// eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap provider identifier +import OsmProvider from '__internal/ui/map/provider.dynamic.osm'; +import { createOpenLayersEngine } from '__internal/ui/map/provider.dynamic.osm.openlayers'; +import errors from 'ui/widget/ui.errors'; + +import 'ui/map'; + +let openLayersMock; +const resetOpenLayersMock = () => { + Object.assign(openLayersMock, { + addedTileLayers: [], + controlOptions: null, + interactionOptions: null, + mapCreated: false, + mapInstance: null, + mapOptions: null, + mapResized: false, + mapTarget: null, + projectedCoordinates: [], + removedLayers: [], + throwOnTileSource: false, + tileLayer: null, + tileLayerOptions: null, + tileSourceChanges: [], + tileSourceOptions: null, + viewCenter: null, + viewCenterSetCount: 0, + viewOptions: null, + viewZoom: null + }); +}; +const moduleConfig = { + beforeEach(assert) { + const setup = () => { + window.ol = openLayersMock; + resetOpenLayersMock(); + }; + if(openLayersMock) { + setup(); + return; + } + const done = assert.async(); + $.getScript({ + url: '../../packages/devextreme/testing/helpers/forMap/openLayersMock.js', + scriptAttrs: { + nonce: 'qunit-test' + } + }).done(() => { + openLayersMock = window.ol; + setup(); + done(); + }).fail((_request, _status, error) => { + assert.ok(false, `failed to load OpenLayers mock: ${error}`); + done(); + }); + }, + afterEach() { + window.ol = openLayersMock; + } +}; +// eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap provider identifier +const createProvider = () => new OsmProvider({ + option: () => ({ + providerConfig: {} + }) +}, null); +QUnit.module('OSM: map loading', moduleConfig, () => { + QUnit.test('map initializes with OpenLayers from window.ol', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: ({ + originalMap + }) => { + assert.ok(openLayersMock.mapCreated, 'OpenLayers creates the map'); + assert.strictEqual(originalMap, openLayersMock.mapInstance, 'originalMap is returned'); + done(); + } + }); + }); + QUnit.test('OpenLayers map target is keyboard focusable', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const container = document.createElement('div'); + const engineMap = engine.createMap(container); + assert.strictEqual(container.getAttribute('tabindex'), '0', 'map target is focusable'); + assert.deepEqual(openLayersMock.interactionOptions, { + onFocusOnly: false + }, 'pointer interactions do not require focus'); + engineMap.dispose(); + assert.strictEqual(container.getAttribute('tabindex'), null, 'added tabindex is removed on dispose'); + }); + QUnit.test('OpenLayers controls are configured for the tiles stage', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const engineMap = engine.createMap(document.createElement('div')); + assert.deepEqual(openLayersMock.controlOptions, { + attribution: true, + rotate: false, + zoom: false + }, 'only the attribution control remains enabled'); + engineMap.dispose(); + }); + QUnit.test('OpenLayers Shadow DOM host is keyboard focusable', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const host = document.createElement('div'); + const shadowRoot = host.attachShadow({ + mode: 'open' + }); + const container = document.createElement('div'); + shadowRoot.appendChild(container); + const engineMap = engine.createMap(container); + assert.strictEqual(host.getAttribute('tabindex'), '0', 'Shadow DOM host is focusable'); + assert.strictEqual(container.getAttribute('tabindex'), null, 'nested map target does not add a second tab stop'); + engineMap.dispose(); + assert.strictEqual(host.getAttribute('tabindex'), null, 'added host tabindex is removed on dispose'); + }); + QUnit.test('existing tabindex is preserved on dispose', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const container = document.createElement('div'); + container.setAttribute('tabindex', '-1'); + const engineMap = engine.createMap(container); + engineMap.dispose(); + assert.strictEqual(container.getAttribute('tabindex'), '-1', 'client tabindex is preserved'); + }); + QUnit.test('tabindex changed after initialization is preserved on dispose', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const container = document.createElement('div'); + const engineMap = engine.createMap(container); + container.setAttribute('tabindex', '-1'); + engineMap.dispose(); + assert.strictEqual(container.getAttribute('tabindex'), '-1', 'updated client tabindex is preserved'); + }); + QUnit.test('engine map can be disposed more than once', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const engineMap = engine.createMap(document.createElement('div')); + engineMap.replaceTileLayer({ + attribution: 'Example attribution', + maxZoom: 19, + url: 'https://tiles.example.com/{z}/{x}/{y}.png' + }); + const tileLayer = openLayersMock.tileLayer; + engineMap.dispose(); + engineMap.dispose(); + assert.strictEqual(openLayersMock.removedLayers.filter(layer => layer === tileLayer).length, 1, 'tile layer is removed once'); + }); + QUnit.test('updateDimensions updates the OpenLayers map size', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const engineMap = engine.createMap(document.createElement('div')); + engineMap.updateDimensions(); + assert.ok(openLayersMock.mapResized, 'OpenLayers map size is updated'); + engineMap.dispose(); + }); + QUnit.test('load rejects with E1069 when OpenLayers is missing', function(assert) { + const done = assert.async(); + const provider = createProvider(); + delete window.ol; + provider._loadImpl().then(() => { + assert.ok(false, 'load should reject'); + done(); + }, error => { + assert.strictEqual(error.message, errors.Error('E1069').message, 'E1069 is returned'); + done(); + }); + }); + QUnit.test('load rejects with E1069 when OpenLayers API is incompatible', function(assert) { + const done = assert.async(); + const provider = createProvider(); + window.ol = {}; + provider._loadImpl().then(() => { + assert.ok(false, 'load should reject'); + done(); + }, error => { + assert.strictEqual(error.message, errors.Error('E1069').message, 'E1069 is returned'); + done(); + }); + }); + QUnit.test('dispose detaches the OpenLayers map and removes its tile layer', function(assert) { + const done = assert.async(); + const map = $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + const tileLayer = openLayersMock.tileLayer; + map.dispose(); + assert.strictEqual(openLayersMock.mapTarget, undefined, 'map target is cleared'); + assert.ok(openLayersMock.removedLayers.includes(tileLayer), 'tile layer is removed'); + done(); + } + }).dxMap('instance'); + }); +}); +QUnit.module('OSM: tile server', moduleConfig, () => { + QUnit.test('string tileServer creates a tile layer with defaults', function(assert) { + const done = assert.async(); + const log = sinon.stub(errors, 'log'); + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: 'https://tiles.example.com/{z}/{x}/{y}.png' + }, + onReady: () => { + assert.deepEqual(openLayersMock.tileSourceOptions, { + attributions: '', + maxZoom: 19, + url: 'https://tiles.example.com/{z}/{x}/{y}.png' + }, 'tile source defaults are applied'); + assert.ok(log.calledWith('W1032'), 'missing attribution warning is logged'); + log.restore(); + done(); + } + }); + }); + QUnit.test('tileServer config passes attribution and maxZoom', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution', + maxZoom: 17 + } + }, + onReady: () => { + assert.deepEqual(openLayersMock.tileSourceOptions, { + attributions: 'Example attribution', + maxZoom: 17, + url: 'https://tiles.example.com/{z}/{x}/{y}.png' + }, 'tile source config is passed'); + done(); + } + }); + }); + QUnit.test('tileServer callback receives the initial map type', function(assert) { + const done = assert.async(); + const tileServer = sinon.spy(() => ({ + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + })); + $('#map').dxMap({ + provider: 'osm', + type: 'satellite', + providerConfig: { + tileServer + }, + onReady: () => { + assert.ok(tileServer.calledOnceWithExactly('satellite'), 'map type is passed'); + done(); + } + }); + }); + QUnit.test('tileServer callback can return a URL string', function(assert) { + const done = assert.async(); + const log = sinon.stub(errors, 'log'); + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: () => 'https://tiles.example.com/{z}/{x}/{y}.png' + }, + onReady: () => { + assert.strictEqual(openLayersMock.tileSourceOptions.url, 'https://tiles.example.com/{z}/{x}/{y}.png', 'URL string is used'); + assert.ok(log.calledWith('W1032'), 'missing attribution warning is logged'); + log.restore(); + done(); + } + }); + }); + QUnit.test('tileServer callback can return null on initialization', function(assert) { + const done = assert.async(); + const log = sinon.stub(errors, 'log'); + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: () => null + }, + onReady: () => { + assert.ok(log.calledWith('W1030'), 'W1030 is logged'); + assert.strictEqual(openLayersMock.addedTileLayers.length, 0, 'tile layer is not created'); + log.restore(); + done(); + } + }); + }); + QUnit.test('subdomains string is expanded for OpenLayers', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://{s}.tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution', + // eslint-disable-next-line spellcheck/spell-checker -- tile server option name + subdomains: 'ab' + } + }, + onReady: () => { + assert.deepEqual(openLayersMock.tileSourceOptions.url, ['https://a.tiles.example.com/{z}/{x}/{y}.png', 'https://b.tiles.example.com/{z}/{x}/{y}.png'], 'subdomains are expanded'); + done(); + } + }); + }); + QUnit.test('default subdomains are expanded for OpenLayers', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://{s}.tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + assert.deepEqual(openLayersMock.tileSourceOptions.url, ['https://a.tiles.example.com/{z}/{x}/{y}.png', 'https://b.tiles.example.com/{z}/{x}/{y}.png', 'https://c.tiles.example.com/{z}/{x}/{y}.png'], 'default subdomains are expanded'); + done(); + } + }); + }); + QUnit.test('empty subdomains use the default value', function(assert) { + ['', []].forEach(value => { + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap provider identifier + const provider = new OsmProvider({ + option: () => ({ + providerConfig: { + tileServer: { + url: 'https://{s}.tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution', + // eslint-disable-next-line spellcheck/spell-checker -- tile server option name + subdomains: value + } + } + }) + }, null); + const options = provider._resolveTileLayerOptions('roadmap'); + const valueType = Array.isArray(value) ? 'array' : 'string'; + + // eslint-disable-next-line spellcheck/spell-checker -- tile server option name + assert.strictEqual(options.subdomains, 'abc', `empty ${valueType} uses default subdomains`); + }); + }); + QUnit.test('subdomains array is expanded for OpenLayers', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://{s}.tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution', + // eslint-disable-next-line spellcheck/spell-checker -- tile server option name + subdomains: ['first', 'second'] + } + }, + onReady: () => { + assert.deepEqual(openLayersMock.tileSourceOptions.url, ['https://first.tiles.example.com/{z}/{x}/{y}.png', 'https://second.tiles.example.com/{z}/{x}/{y}.png'], 'subdomains are expanded'); + done(); + } + }); + }); + QUnit.test('missing tileServer logs W1030 and keeps the map initialized', function(assert) { + const done = assert.async(); + const log = sinon.stub(errors, 'log'); + $('#map').dxMap({ + provider: 'osm', + onReady: () => { + assert.ok(log.calledWith('W1030'), 'W1030 is logged'); + assert.strictEqual(openLayersMock.addedTileLayers.length, 0, 'tile layer is not created'); + log.restore(); + done(); + } + }); + }); + QUnit.test('tileServer with an empty URL logs W1030 and keeps the map initialized', function(assert) { + const done = assert.async(); + const log = sinon.stub(errors, 'log'); + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: '' + } + }, + onReady: () => { + assert.ok(log.calledWith('W1030'), 'W1030 is logged'); + assert.strictEqual(openLayersMock.addedTileLayers.length, 0, 'tile layer is not created'); + log.restore(); + done(); + } + }); + }); + QUnit.test('missing attribution logs W1032 and keeps the tile layer', function(assert) { + const done = assert.async(); + const log = sinon.stub(errors, 'log'); + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: 'https://tiles.example.com/{z}/{x}/{y}.png' + }, + onReady: () => { + assert.ok(log.calledWith('W1032'), 'W1032 is logged'); + assert.strictEqual(openLayersMock.addedTileLayers.length, 1, 'tile layer is created'); + log.restore(); + done(); + } + }); + }); + QUnit.test('changing type replaces the tile source returned by the callback', function(assert) { + const done = assert.async(); + const initialized = $.Deferred(); + const map = $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: type => ({ + url: `https://${type}.example.com/{z}/{x}/{y}.png`, + attribution: 'Example attribution' + }) + }, + onReady: () => initialized.resolve() + }).dxMap('instance'); + initialized.done(() => { + map.option('onUpdated', () => { + assert.strictEqual(openLayersMock.tileSourceOptions.url, 'https://satellite.example.com/{z}/{x}/{y}.png', 'tile source is replaced'); + assert.strictEqual(openLayersMock.tileSourceChanges.length, 1, 'existing layer receives the new source'); + done(); + }); + map.option('type', 'satellite'); + }); + }); + QUnit.test('changing type preserves a fixed tile source', function(assert) { + const done = assert.async(); + const initialized = $.Deferred(); + const map = $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => initialized.resolve() + }).dxMap('instance'); + initialized.done(() => { + const originalSource = openLayersMock.tileLayer.source; + map.option('onUpdated', () => { + assert.strictEqual(openLayersMock.tileLayer.source, originalSource, 'fixed source is preserved'); + assert.strictEqual(openLayersMock.tileSourceChanges.length, 0, 'tile layer does not receive another source'); + done(); + }); + map.option('type', 'satellite'); + }); + }); + QUnit.test('missing config for a new type preserves the current tile source', function(assert) { + const done = assert.async(); + const initialized = $.Deferred(); + const log = sinon.stub(errors, 'log'); + const map = $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: type => type === 'roadmap' ? { + url: 'https://roadmap.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } : null + }, + onReady: () => initialized.resolve() + }).dxMap('instance'); + initialized.done(() => { + const originalSource = openLayersMock.tileLayer.source; + map.option('onUpdated', () => { + assert.strictEqual(openLayersMock.tileLayer.source, originalSource, 'current source is preserved'); + assert.ok(log.calledWith('W1030'), 'W1030 is logged'); + log.restore(); + done(); + }); + map.option('type', 'satellite'); + }); + }); + QUnit.test('tile source creation failure preserves the current source', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const engineMap = engine.createMap(document.createElement('div')); + const firstOptions = { + attribution: 'Example attribution', + maxZoom: 19, + url: 'https://roadmap.example.com/{z}/{x}/{y}.png' + }; + engineMap.replaceTileLayer(firstOptions); + const originalSource = openLayersMock.tileLayer.source; + openLayersMock.throwOnTileSource = true; + assert.throws(() => engineMap.replaceTileLayer({ + ...firstOptions, + url: 'https://satellite.example.com/{z}/{x}/{y}.png' + }), /Tile source creation failed/, 'source error is propagated'); + assert.strictEqual(openLayersMock.tileLayer.source, originalSource, 'current source is preserved'); + engineMap.dispose(); + }); + QUnit.test('changing providerConfig recreates the map with the new tile server', function(assert) { + const done = assert.async(); + const initialized = $.Deferred(); + let originalMap; + const map = $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://first.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: e => { + originalMap = e.originalMap; + initialized.resolve(); + } + }).dxMap('instance'); + initialized.done(() => { + map.option('onReady', e => { + assert.notStrictEqual(e.originalMap, originalMap, 'map is recreated'); + assert.strictEqual(openLayersMock.tileSourceOptions.url, 'https://second.example.com/{z}/{x}/{y}.png', 'new tile server is used'); + done(); + }); + map.option('providerConfig', { + tileServer: { + url: 'https://second.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }); + }); + }); +}); +QUnit.module('OSM: initial view', moduleConfig, () => { + QUnit.test('default center, zoom, and map type are applied', function(assert) { + const done = assert.async(); + const tileServer = sinon.spy(() => ({ + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + })); + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer + }, + onReady: () => { + assert.deepEqual(openLayersMock.viewCenter, [0, 0], 'default center is applied'); + assert.strictEqual(openLayersMock.viewZoom, 1, 'default zoom is applied'); + assert.ok(tileServer.calledOnceWithExactly('roadmap'), 'default map type is applied'); + done(); + } + }); + }); + QUnit.test('center and fractional zoom are applied to the OpenLayers view', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + center: { + lat: 40.74, + lng: -73.98 + }, + zoom: 12.5, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + const lastProjectedCoordinate = openLayersMock.projectedCoordinates[openLayersMock.projectedCoordinates.length - 1]; + assert.ok(openLayersMock.projectedCoordinates.length > 0, 'projection API is called'); + assert.deepEqual(lastProjectedCoordinate, [-73.98, 40.74], 'longitude and latitude are passed to the projection API'); + assert.deepEqual(openLayersMock.viewCenter, [-73.98, 40.74], 'projected center is applied'); + assert.strictEqual(openLayersMock.viewZoom, 12.5, 'fractional zoom is applied'); + done(); + } + }); + }); + QUnit.test('changing center preserves the current map zoom', function(assert) { + const done = assert.async(); + const initialized = $.Deferred(); + const map = $('#map').dxMap({ + provider: 'osm', + center: { + lat: 40.74, + lng: -73.98 + }, + zoom: 12, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => initialized.resolve() + }).dxMap('instance'); + initialized.done(() => { + openLayersMock.mapInstance.getView().setZoom(14); + map.option('onUpdated', () => { + assert.strictEqual(openLayersMock.viewZoom, 14, 'current zoom is preserved'); + done(); + }); + map.option('center', { + lat: 40.75, + lng: -73.97 + }); + }); + }); + QUnit.test('changing zoom applies a fractional value and preserves center', function(assert) { + const done = assert.async(); + const initialized = $.Deferred(); + const map = $('#map').dxMap({ + provider: 'osm', + center: { + lat: 40.74, + lng: -73.98 + }, + zoom: 12, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => initialized.resolve() + }).dxMap('instance'); + initialized.done(() => { + const center = [...openLayersMock.viewCenter]; + const centerSetCount = openLayersMock.viewCenterSetCount; + map.option('onUpdated', () => { + assert.strictEqual(openLayersMock.viewZoom, 12.5, 'fractional zoom is applied'); + assert.deepEqual(openLayersMock.viewCenter, center, 'center value is preserved'); + assert.strictEqual(openLayersMock.viewCenterSetCount, centerSetCount, 'center is not reapplied'); + done(); + }); + map.option('zoom', 12.5); + }); + }); +}); diff --git a/packages/devextreme/ts/dx.all.d.ts b/packages/devextreme/ts/dx.all.d.ts index 8b1e40dc981f..546de86106ae 100644 --- a/packages/devextreme/ts/dx.all.d.ts +++ b/packages/devextreme/ts/dx.all.d.ts @@ -23358,7 +23358,12 @@ declare module DevExpress.ui { */ export type InitializedEvent = DevExpress.common.core.events.InitializedEventInfo; - export type MapProvider = 'azure' | 'bing' | 'google' | 'googleStatic'; + export type MapProvider = + | 'azure' + | 'bing' + | 'google' + | 'googleStatic' + | 'osm'; export type MapType = 'hybrid' | 'roadmap' | 'satellite'; /** * [descr:_ui_map_MarkerAddedEvent] @@ -23390,6 +23395,34 @@ declare module DevExpress.ui { export type OptionChangedEvent = DevExpress.common.core.events.EventInfo & DevExpress.common.core.events.ChangedOptionInfo; + /** + * [descr:OsmTileServer] + */ + export type OsmTileServer = + | string + | OsmTileServerConfig + | ((type: MapType) => string | OsmTileServerConfig | null | undefined); + /** + * [descr:OsmTileServerConfig] + */ + export interface OsmTileServerConfig { + /** + * [descr:OsmTileServerConfig.url] + */ + url: string; + /** + * [descr:OsmTileServerConfig.attribution] + */ + attribution?: string; + /** + * [descr:OsmTileServerConfig.subdomains] + */ + subdomains?: string | Array; + /** + * [descr:OsmTileServerConfig.maxZoom] + */ + maxZoom?: number; + } export type Properties = dxMapOptions; /** * [descr:_ui_map_ReadyEvent] @@ -23561,6 +23594,10 @@ declare module DevExpress.ui { * @deprecated [depNote:dxMapOptions.providerConfig.useAdvancedMarkers] */ useAdvancedMarkers?: boolean; + /** + * [descr:dxMapOptions.providerConfig.tileServer] + */ + tileServer?: DevExpress.ui.dxMap.OsmTileServer | undefined; }; /** * [descr:dxMapOptions.routes]