From 85a8c5be0f06a3282bc173e92e41529f63b0665d Mon Sep 17 00:00:00 2001 From: Alisher Amonulloev Date: Thu, 20 Aug 2026 10:22:01 +0300 Subject: [PATCH 01/28] Map: OSM provider - Render tile maps with OpenLayers (#34769) Signed-off-by: Alisher Amonulloev Co-authored-by: Arman Boyakhchyan Co-authored-by: ilya.kharchenko <14272298+IlyaKhD@users.noreply.github.com> --- .../devextreme-angular/src/ui/map/index.ts | 8 +- .../src/ui/map/nested/provider-config.ts | 9 + .../src/ui/nested/provider-config.ts | 9 + packages/devextreme-metadata/aspnet/enums.ts | 2 +- .../make-aspnet-metadata.ts | 2 + packages/devextreme-react/src/map.ts | 3 +- packages/devextreme-vue/src/map.ts | 4 + .../devextreme/js/__internal/ui/errors.ts | 4 + .../devextreme/js/__internal/ui/map/map.ts | 4 +- .../ui/map/provider.dynamic.osm.engine.ts | 25 + .../ui/map/provider.dynamic.osm.openlayers.ts | 191 +++++ .../__internal/ui/map/provider.dynamic.osm.ts | 184 +++++ .../js/__internal/ui/map/provider.ts | 4 +- packages/devextreme/js/ui/map.d.ts | 38 +- packages/devextreme/js/ui/map_types.d.ts | 2 + packages/devextreme/js/ui/widget/ui.errors.js | 9 + .../testing/helpers/forMap/openLayersMock.js | 101 +++ .../tests/DevExpress.ui.widgets/map.tests.js | 1 + .../mapParts/osmTests.js | 665 ++++++++++++++++++ packages/devextreme/ts/dx.all.d.ts | 39 +- 20 files changed, 1294 insertions(+), 10 deletions(-) create mode 100644 packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.engine.ts create mode 100644 packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts create mode 100644 packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts create mode 100644 packages/devextreme/testing/helpers/forMap/openLayersMock.js create mode 100644 packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js 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-metadata/make-aspnet-metadata.ts b/packages/devextreme-metadata/make-aspnet-metadata.ts index ea99c30f8259..0cf18565d975 100644 --- a/packages/devextreme-metadata/make-aspnet-metadata.ts +++ b/packages/devextreme-metadata/make-aspnet-metadata.ts @@ -160,6 +160,8 @@ AspNet.makeMetadata({ ['any[]'], ), + replaceTypes('ui/map:dxMapOptions.providerConfig.tileServer', ['*'], ['string']), + ...replaceTypesMutations, [ 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..c5b214fcdd7d 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 | undefined)) | OsmTileServerConfig | string>, useAdvancedMarkers: Boolean } }; diff --git a/packages/devextreme/js/__internal/ui/errors.ts b/packages/devextreme/js/__internal/ui/errors.ts index 572ce53435b2..fff8421e5a00 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 a compatible version of OpenLayers. Load a supported version 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..6053075d8867 100644 --- a/packages/devextreme/js/__internal/ui/map/map.ts +++ b/packages/devextreme/js/__internal/ui/map/map.ts @@ -21,6 +21,7 @@ import type { LocationOption } from './provider.dynamic'; import azure from './provider.dynamic.azure'; import bing from './provider.dynamic.bing'; import google from './provider.dynamic.google'; +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 +31,7 @@ const PROVIDERS = { googleStatic, google, bing, + osm, }; const MAP_CLASS = 'dx-map'; @@ -54,7 +56,7 @@ class Map extends Widget { _lastAsyncAction!: Promise; - _provider!: azure | googleStatic | google | bing; + _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..5c40c16f547d --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.engine.ts @@ -0,0 +1,25 @@ +import type { MapLocation } from '@js/ui/map'; + +export interface MapEngineTileLayerOptions { + attribution?: string; + maxZoom: number; + 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..d27f2169c52d --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts @@ -0,0 +1,191 @@ +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, + subdomains: MapEngineTileLayerOptions['subdomains'], +): string[] => { + 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 sourceOptions: Options = { + maxZoom: options.maxZoom, + url: options.url.includes('{s}') + ? createTileUrlList(options.url, options.subdomains) + : options.url, + }; + + if (options.attribution !== undefined) { + sourceOptions.attributions = options.attribution; + } + + const source = new this._api.source.ImageTile(sourceOptions); + + 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..81e2f6a7c407 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts @@ -0,0 +1,184 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ +import { getWindow } from '@js/core/utils/window'; +import type { + MapLocation, + MapType, + 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'; + +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 = { + maxZoom: config.maxZoom ?? DEFAULT_MAX_ZOOM, + url: config.url, + }; + + if (config.attribution !== undefined) { + result.attribution = config.attribution; + } + + if (config.url.includes('{s}')) { + 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(); + } +} + +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..4cd551295342 100644 --- a/packages/devextreme/js/ui/map.d.ts +++ b/packages/devextreme/js/ui/map.d.ts @@ -14,12 +14,42 @@ 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 + */ +export type OsmTileServerConfig = { + /** + * @docid + */ + url: string; + /** + * @docid + */ + attribution?: string; + /** + * @docid + */ + subdomains?: string | Array; + /** + * @docid + */ + maxZoom?: number; +}; + +/** + * @docid + * @public + */ +export type OsmTileServer = string | OsmTileServerConfig +| ((type: MapType) => string | OsmTileServerConfig | undefined); + /** * @docid _ui_map_ClickEvent * @public @@ -336,6 +366,12 @@ export interface dxMapOptions extends WidgetOptions { * @deprecated */ useAdvancedMarkers?: boolean; + /** + * @docid + * @public + * @default undefined + */ + 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..592868e58b16 --- /dev/null +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -0,0 +1,665 @@ +import $ from 'jquery'; + +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; + } +}; +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('map initializes after OpenLayers is loaded and repaint is called', function(assert) { + const done = assert.async(); + delete window.ol; + const map = $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + } + }).dxMap('instance'); + + map._lastAsyncAction.then(() => { + assert.ok(false, 'initialization should reject'); + done(); + }, error => { + assert.strictEqual(error.message, errors.Error('E1069').message, 'E1069 is returned'); + + window.ol = openLayersMock; + map.repaint(); + + map._lastAsyncAction.then(() => { + assert.ok(openLayersMock.mapCreated, 'OpenLayers creates the map after repaint'); + done(); + }, repaintError => { + assert.ok(false, `repaint failed: ${repaintError.message}`); + done(); + }); + }); + }); + QUnit.test('load rejects with E1069 when the OpenLayers ImageTile API is missing', function(assert) { + const done = assert.async(); + const provider = createProvider(); + window.ol = Object.assign({}, openLayersMock, { source: {} }); + 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, { + 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 undefined on initialization', function(assert) { + const done = assert.async(); + const log = sinon.stub(errors, 'log'); + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: () => undefined + }, + 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', + 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 => { + const provider = new OsmProvider({ + option: () => ({ + providerConfig: { + tileServer: { + url: 'https://{s}.tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution', + subdomains: value + } + } + }) + }, null); + const options = provider._resolveTileLayerOptions('roadmap'); + const valueType = Array.isArray(value) ? 'array' : 'string'; + + 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', + 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' + } : undefined + }, + 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..1fbe8ff6e131 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 | undefined); + /** + * [descr:OsmTileServerConfig] + */ + export type 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] From 5a31a480c20412d753485287457de11883722897 Mon Sep 17 00:00:00 2001 From: Alisher Amonulloev Date: Tue, 25 Aug 2026 11:47:59 +0300 Subject: [PATCH 02/28] =?UTF-8?q?Map:=20OSM=20provider=20=E2=80=94=20Defin?= =?UTF-8?q?e=20the=20public=20API=20contract=20(#34858)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devextreme-angular/src/ui/map/index.ts | 8 ++--- .../src/ui/map/nested/provider-config.ts | 18 +++++++++- .../src/ui/nested/provider-config.ts | 16 +++++++++ packages/devextreme-react/src/map.ts | 4 ++- packages/devextreme-vue/src/map.ts | 5 +++ .../devextreme/js/__internal/ui/errors.ts | 2 ++ packages/devextreme/js/ui/map.d.ts | 35 +++++++++++++++++++ packages/devextreme/js/ui/map_types.d.ts | 3 ++ packages/devextreme/js/ui/widget/ui.errors.js | 6 ++++ packages/devextreme/ts/dx.all.d.ts | 30 ++++++++++++++++ 10 files changed, 121 insertions(+), 6 deletions(-) diff --git a/packages/devextreme-angular/src/ui/map/index.ts b/packages/devextreme-angular/src/ui/map/index.ts index cd1788f8160a..0928a2a783c8 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, OsmTileServer, RouteMode, MapType } from 'devextreme/ui/map'; +import type { ClickEvent, DisposingEvent, InitializedEvent, MarkerAddedEvent, MarkerRemovedEvent, OptionChangedEvent, ReadyEvent, RouteAddedEvent, RouteRemovedEvent, MapProvider, CalculateOsmRouteInfo, 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, tileServer?: OsmTileServer | undefined, useAdvancedMarkers?: boolean } { + get providerConfig(): { calculateLocation?: ((query: string) => any), calculateRoute?: ((params: CalculateOsmRouteInfo) => any), mapId?: string, tileServer?: OsmTileServer | undefined, useAdvancedMarkers?: boolean } { return this._getOption('providerConfig'); } - set providerConfig(value: { mapId?: string, tileServer?: OsmTileServer | undefined, useAdvancedMarkers?: boolean }) { + set providerConfig(value: { calculateLocation?: ((query: string) => any), calculateRoute?: ((params: CalculateOsmRouteInfo) => any), 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, tileServer?: OsmTileServer | undefined, useAdvancedMarkers?: boolean }>; + @Output() providerConfigChange: EventEmitter<{ calculateLocation?: ((query: string) => any), calculateRoute?: ((params: CalculateOsmRouteInfo) => any), 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 2b828d8be463..f85a8b130256 100644 --- a/packages/devextreme-angular/src/ui/map/nested/provider-config.ts +++ b/packages/devextreme-angular/src/ui/map/nested/provider-config.ts @@ -14,7 +14,7 @@ import { -import type { OsmTileServer } from 'devextreme/ui/map'; +import type { CalculateOsmRouteInfo, OsmTileServer } from 'devextreme/ui/map'; import { DxIntegrationModule, @@ -31,6 +31,22 @@ import { NestedOption } from 'devextreme-angular/core'; providers: [NestedOptionHost] }) export class DxoMapProviderConfigComponent extends NestedOption implements OnDestroy, OnInit { + @Input() + get calculateLocation(): ((query: string) => any) { + return this._getOption('calculateLocation'); + } + set calculateLocation(value: ((query: string) => any)) { + this._setOption('calculateLocation', value); + } + + @Input() + get calculateRoute(): ((params: CalculateOsmRouteInfo) => any) { + return this._getOption('calculateRoute'); + } + set calculateRoute(value: ((params: CalculateOsmRouteInfo) => any)) { + this._setOption('calculateRoute', value); + } + @Input() get mapId(): string { return this._getOption('mapId'); diff --git a/packages/devextreme-angular/src/ui/nested/provider-config.ts b/packages/devextreme-angular/src/ui/nested/provider-config.ts index a3139a520b04..b305b398b301 100644 --- a/packages/devextreme-angular/src/ui/nested/provider-config.ts +++ b/packages/devextreme-angular/src/ui/nested/provider-config.ts @@ -31,6 +31,22 @@ import { NestedOption } from 'devextreme-angular/core'; providers: [NestedOptionHost] }) export class DxoProviderConfigComponent extends NestedOption implements OnDestroy, OnInit { + @Input() + get calculateLocation(): Function { + return this._getOption('calculateLocation'); + } + set calculateLocation(value: Function) { + this._setOption('calculateLocation', value); + } + + @Input() + get calculateRoute(): Function { + return this._getOption('calculateRoute'); + } + set calculateRoute(value: Function) { + this._setOption('calculateRoute', value); + } + @Input() get mapId(): string { return this._getOption('mapId'); diff --git a/packages/devextreme-react/src/map.ts b/packages/devextreme-react/src/map.ts index 66c1eebe12fe..d0add4f2b3b1 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, OsmTileServer, RouteMode } from "devextreme/ui/map"; +import type { ClickEvent, DisposingEvent, InitializedEvent, MarkerAddedEvent, MarkerRemovedEvent, ReadyEvent, RouteAddedEvent, RouteRemovedEvent, CalculateOsmRouteInfo, OsmTileServer, RouteMode } from "devextreme/ui/map"; type ReplaceFieldTypes = { [P in keyof TSource]: P extends keyof TReplacement ? TReplacement[P] : TSource[P]; @@ -182,6 +182,8 @@ const Marker = Object.assign(_comp // owners: // Map type IProviderConfigProps = React.PropsWithChildren<{ + calculateLocation?: ((query: string) => any); + calculateRoute?: ((params: CalculateOsmRouteInfo) => any); mapId?: string; tileServer?: OsmTileServer | undefined; useAdvancedMarkers?: boolean; diff --git a/packages/devextreme-vue/src/map.ts b/packages/devextreme-vue/src/map.ts index c5b214fcdd7d..691d073fb15b 100644 --- a/packages/devextreme-vue/src/map.ts +++ b/packages/devextreme-vue/src/map.ts @@ -14,6 +14,7 @@ import { RouteRemovedEvent, MapProvider, MapType, + CalculateOsmRouteInfo, OsmTileServer, OsmTileServerConfig, RouteMode, @@ -246,11 +247,15 @@ const DxProviderConfigConfig = { emits: { "update:isActive": null, "update:hoveredElement": null, + "update:calculateLocation": null, + "update:calculateRoute": null, "update:mapId": null, "update:tileServer": null, "update:useAdvancedMarkers": null, }, props: { + calculateLocation: Function as PropType<((query: string) => any)>, + calculateRoute: Function as PropType<((params: CalculateOsmRouteInfo) => any)>, mapId: String, tileServer: [Object, Function, String] as PropType string | OsmTileServerConfig | undefined)) | OsmTileServerConfig | string>, useAdvancedMarkers: Boolean diff --git a/packages/devextreme/js/__internal/ui/errors.ts b/packages/devextreme/js/__internal/ui/errors.ts index fff8421e5a00..38a6d9787f28 100644 --- a/packages/devextreme/js/__internal/ui/errors.ts +++ b/packages/devextreme/js/__internal/ui/errors.ts @@ -163,5 +163,7 @@ export default errorUtils(errors.ERROR_MESSAGES, { 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.', + W1031: '\'calculateLocation\' is not configured for the OSM map provider.', W1032: '\'attribution\' is not configured for the OSM tile server.', + W1033: '\'calculateRoute\' is not configured for the OSM map provider.', }); diff --git a/packages/devextreme/js/ui/map.d.ts b/packages/devextreme/js/ui/map.d.ts index 4cd551295342..5a75330c485e 100644 --- a/packages/devextreme/js/ui/map.d.ts +++ b/packages/devextreme/js/ui/map.d.ts @@ -20,6 +20,31 @@ export type RouteMode = 'driving' | 'walking'; /** @public */ export type MapType = 'hybrid' | 'roadmap' | 'satellite'; +/** + * @docid + * @public + */ +export type CalculateOsmRouteInfo = { + locations: Array; + mode: RouteMode | string; +}; + +/** + * @docid + * @public + */ +export type OsmGeoJsonLineString = { + type: 'LineString'; + coordinates: Array>; +}; + +/** + * @docid + * @type Array>|OsmGeoJsonLineString + * @public + */ +export type OsmRouteResult = Array<[number, number]> | OsmGeoJsonLineString; + /** * @docid * @public @@ -372,6 +397,16 @@ export interface dxMapOptions extends WidgetOptions { * @default undefined */ tileServer?: OsmTileServer | undefined; + /** + * @docid + * @public + */ + calculateLocation?: (query: string) => PromiseLike; + /** + * @docid + * @public + */ + calculateRoute?: (params: CalculateOsmRouteInfo) => PromiseLike; }; /** * @docid diff --git a/packages/devextreme/js/ui/map_types.d.ts b/packages/devextreme/js/ui/map_types.d.ts index f6ee3f73cff6..b5287439b4fc 100644 --- a/packages/devextreme/js/ui/map_types.d.ts +++ b/packages/devextreme/js/ui/map_types.d.ts @@ -2,6 +2,9 @@ export { MapProvider, RouteMode, MapType, + CalculateOsmRouteInfo, + OsmGeoJsonLineString, + OsmRouteResult, OsmTileServerConfig, OsmTileServer, ClickEvent, diff --git a/packages/devextreme/js/ui/widget/ui.errors.js b/packages/devextreme/js/ui/widget/ui.errors.js index 4de3c5415ac4..c83ad408bbbe 100644 --- a/packages/devextreme/js/ui/widget/ui.errors.js +++ b/packages/devextreme/js/ui/widget/ui.errors.js @@ -248,9 +248,15 @@ /** * @name ErrorsUIWidgets.W1030 */ +/** + * @name ErrorsUIWidgets.W1031 + */ /** * @name ErrorsUIWidgets.W1032 */ +/** + * @name ErrorsUIWidgets.W1033 + */ import errors from '../../__internal/ui/errors'; export default errors; diff --git a/packages/devextreme/ts/dx.all.d.ts b/packages/devextreme/ts/dx.all.d.ts index 1fbe8ff6e131..123c8d66bb46 100644 --- a/packages/devextreme/ts/dx.all.d.ts +++ b/packages/devextreme/ts/dx.all.d.ts @@ -23337,6 +23337,13 @@ declare module DevExpress.ui { ): DevExpress.core.utils.DxPromise; } module dxMap { + /** + * [descr:CalculateOsmRouteInfo] + */ + export type CalculateOsmRouteInfo = { + locations: Array; + mode: RouteMode | string; + }; /** * [descr:_ui_map_ClickEvent] */ @@ -23395,6 +23402,17 @@ declare module DevExpress.ui { export type OptionChangedEvent = DevExpress.common.core.events.EventInfo & DevExpress.common.core.events.ChangedOptionInfo; + /** + * [descr:OsmGeoJsonLineString] + */ + export type OsmGeoJsonLineString = { + type: 'LineString'; + coordinates: Array>; + }; + /** + * [descr:OsmRouteResult] + */ + export type OsmRouteResult = Array<[number, number]> | OsmGeoJsonLineString; /** * [descr:OsmTileServer] */ @@ -23598,6 +23616,18 @@ declare module DevExpress.ui { * [descr:dxMapOptions.providerConfig.tileServer] */ tileServer?: DevExpress.ui.dxMap.OsmTileServer | undefined; + /** + * [descr:dxMapOptions.providerConfig.calculateLocation] + */ + calculateLocation?: ( + query: string + ) => PromiseLike; + /** + * [descr:dxMapOptions.providerConfig.calculateRoute] + */ + calculateRoute?: ( + params: DevExpress.ui.dxMap.CalculateOsmRouteInfo + ) => PromiseLike; }; /** * [descr:dxMapOptions.routes] From 1a3951886d5fe1a41569e8ecab69bbf60b35d7bc Mon Sep 17 00:00:00 2001 From: Alisher Amonulloev Date: Wed, 26 Aug 2026 15:06:43 +0300 Subject: [PATCH 03/28] =?UTF-8?q?Map:=20OSM=20provider=20=E2=80=94=20Add?= =?UTF-8?q?=20OpenLayers=20ESM=20integration=20(+=20Storybook=20showcase)?= =?UTF-8?q?=20(#34920)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/react-storybook/package.json | 1 + .../stories/map/OSMMap.stories.tsx | 86 ++++ .../ui/map/provider.dynamic.osm.engine.ts | 8 + .../__internal/ui/map/provider.dynamic.osm.ts | 3 +- packages/devextreme/js/ui/map/openlayers.js | 1 + .../js/ui/map/openlayers.register.js | 34 ++ packages/devextreme/project.json | 1 + .../mapParts/osmTests.js | 34 ++ pnpm-lock.yaml | 464 ++++++++++++++++++ 9 files changed, 631 insertions(+), 1 deletion(-) create mode 100644 apps/react-storybook/stories/map/OSMMap.stories.tsx create mode 100644 packages/devextreme/js/ui/map/openlayers.js create mode 100644 packages/devextreme/js/ui/map/openlayers.register.js diff --git a/apps/react-storybook/package.json b/apps/react-storybook/package.json index a80d44488e2b..84540767497c 100644 --- a/apps/react-storybook/package.json +++ b/apps/react-storybook/package.json @@ -26,6 +26,7 @@ "@types/react": "18.3.28", "@types/react-dom": "18.3.7", "http-server": "14.1.1", + "ol": "10.10.0", "prop-types": "15.8.1", "react": "18.3.1", "react-dom": "18.3.1", diff --git a/apps/react-storybook/stories/map/OSMMap.stories.tsx b/apps/react-storybook/stories/map/OSMMap.stories.tsx new file mode 100644 index 000000000000..e96a51a62e69 --- /dev/null +++ b/apps/react-storybook/stories/map/OSMMap.stories.tsx @@ -0,0 +1,86 @@ +import type { Meta, StoryObj } from '@storybook/react-webpack5'; + +import Attribution from 'ol/control/Attribution.js'; +import type OpenLayersMap from 'ol/Map.js'; +import 'ol/ol.css'; +import { fromLonLat, transformExtent } from 'ol/proj.js'; +import View from 'ol/View.js'; +import React from 'react'; +import Map from 'devextreme-react/map'; +import type { ReadyEvent } from 'devextreme/ui/map'; +import 'devextreme/ui/map/openlayers'; + +const CENTER = { lat: 40.7484, lng: -73.9857 }; +const EXTENT: [number, number, number, number] = [-74.08, 40.67, -73.85, 40.88]; +const TILE_SERVER = { + url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + attribution: '© OpenStreetMap contributors', + maxZoom: 19, +}; +const PROVIDER_CONFIG = { tileServer: TILE_SERVER }; + +interface OsmStoryArgs { + zoom: number; +} + +const configureMap = ( + { originalMap }: ReadyEvent, + zoom: number, +): void => { + const map = originalMap as OpenLayersMap; + const attribution = map.getControls().getArray() + .find((control) => control instanceof Attribution) as Attribution | undefined; + + attribution?.setCollapsed(false); + attribution?.setCollapsible(false); + map.setView(new View({ + center: fromLonLat([CENTER.lng, CENTER.lat]), + extent: transformExtent(EXTENT, 'EPSG:4326', 'EPSG:3857'), + maxZoom: 16, + minZoom: 14, + showFullExtent: false, + smoothExtentConstraint: false, + zoom, + })); +}; + +const OsmMapStory = ({ zoom }: OsmStoryArgs): React.ReactElement => ( + configureMap(event, zoom)} + /> +); + +const meta: Meta = { + title: 'Components/Map/OSM Provider', + component: OsmMapStory, + tags: ['!test'], + parameters: { + layout: 'fullscreen', + }, + argTypes: { + zoom: { + control: { + type: 'number', + min: 14, + max: 16, + step: 0.25, + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + zoom: 15, + }, +}; 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 index 5c40c16f547d..f64d86162337 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.engine.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.engine.ts @@ -23,3 +23,11 @@ export interface MapEngineMap { export interface MapEngine { createMap: (container: Element, view?: MapEngineSetViewOptions) => MapEngineMap; } + +const registry: { mapEngine?: MapEngine } = {}; + +export const getRegisteredMapEngine = (): MapEngine | undefined => registry.mapEngine; + +export const setRegisteredMapEngine = (engine: MapEngine | undefined): void => { + registry.mapEngine = engine; +}; diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts index 81e2f6a7c407..6b138f8b8882 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts @@ -18,6 +18,7 @@ import type { MapEngineMap, MapEngineTileLayerOptions, } from './provider.dynamic.osm.engine'; +import { getRegisteredMapEngine } from './provider.dynamic.osm.engine'; import { createOpenLayersEngine } from './provider.dynamic.osm.openlayers'; const DEFAULT_MAX_ZOOM = 19; @@ -32,7 +33,7 @@ class OsmProvider extends DynamicProvider { _loadImpl(): Promise { const window = getWindow() as Window & { ol?: unknown }; - const engine = createOpenLayersEngine(window.ol); + const engine = getRegisteredMapEngine() ?? createOpenLayersEngine(window.ol); if (!engine) { return Promise.reject(errors.Error('E1069')); diff --git a/packages/devextreme/js/ui/map/openlayers.js b/packages/devextreme/js/ui/map/openlayers.js new file mode 100644 index 000000000000..8483a42b2539 --- /dev/null +++ b/packages/devextreme/js/ui/map/openlayers.js @@ -0,0 +1 @@ +import './openlayers.register'; diff --git a/packages/devextreme/js/ui/map/openlayers.register.js b/packages/devextreme/js/ui/map/openlayers.register.js new file mode 100644 index 000000000000..4e6ec3b8c209 --- /dev/null +++ b/packages/devextreme/js/ui/map/openlayers.register.js @@ -0,0 +1,34 @@ +import { defaults as defaultControls } from 'ol/control/defaults.js'; +import { defaults as defaultInteractions } from 'ol/interaction/defaults.js'; +import TileLayer from 'ol/layer/Tile.js'; +import Map from 'ol/Map.js'; +import { fromLonLat } from 'ol/proj.js'; +import ImageTile from 'ol/source/ImageTile.js'; +import View from 'ol/View.js'; + +import { setRegisteredMapEngine } from '../../__internal/ui/map/provider.dynamic.osm.engine'; +import { createOpenLayersEngine } from '../../__internal/ui/map/provider.dynamic.osm.openlayers'; + +setRegisteredMapEngine(createOpenLayersEngine({ + Map, + View, + control: { + defaults: { + defaults: defaultControls, + }, + }, + interaction: { + defaults: { + defaults: defaultInteractions, + }, + }, + layer: { + Tile: TileLayer, + }, + proj: { + fromLonLat, + }, + source: { + ImageTile, + }, +})); diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index 19efa18de2d0..a66341026d88 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -469,6 +469,7 @@ "srcDir": "./js", "excludePatterns": [ "__internal/**/*", + "ui/map/openlayers.register.js", "viz/docs/**/*", "bundles/**/*" ], diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js index 592868e58b16..3845170a299e 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import OsmProvider from '__internal/ui/map/provider.dynamic.osm'; +import { setRegisteredMapEngine } from '__internal/ui/map/provider.dynamic.osm.engine'; import { createOpenLayersEngine } from '__internal/ui/map/provider.dynamic.osm.openlayers'; import errors from 'ui/widget/ui.errors'; @@ -33,6 +34,7 @@ const resetOpenLayersMock = () => { const moduleConfig = { beforeEach(assert) { const setup = () => { + setRegisteredMapEngine(undefined); window.ol = openLayersMock; resetOpenLayersMock(); }; @@ -56,6 +58,7 @@ const moduleConfig = { }); }, afterEach() { + setRegisteredMapEngine(undefined); window.ol = openLayersMock; } }; @@ -65,6 +68,37 @@ const createProvider = () => new OsmProvider({ }) }, null); QUnit.module('OSM: map loading', moduleConfig, () => { + QUnit.test('registered OpenLayers engine takes priority over window.ol', function(assert) { + const done = assert.async(); + const engine = createOpenLayersEngine(openLayersMock); + const provider = createProvider(); + setRegisteredMapEngine(engine); + provider._loadImpl().then(() => { + assert.strictEqual(provider._engine, engine, 'registered engine is selected'); + done(); + }); + }); + QUnit.test('map initializes with a registered OpenLayers engine when window.ol is missing', function(assert) { + const done = assert.async(); + setRegisteredMapEngine(createOpenLayersEngine(openLayersMock)); + delete window.ol; + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: ({ + originalMap + }) => { + assert.ok(openLayersMock.mapCreated, 'registered OpenLayers engine creates the map'); + assert.strictEqual(originalMap, openLayersMock.mapInstance, 'originalMap is returned'); + done(); + } + }); + }); QUnit.test('map initializes with OpenLayers from window.ol', function(assert) { const done = assert.async(); $('#map').dxMap({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1f3b7d6d665..d30629dee8ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -884,6 +884,9 @@ importers: http-server: specifier: 14.1.1 version: 14.1.1(supports-color@7.2.0) + ol: + specifier: 10.10.0 + version: 10.10.0 prop-types: specifier: 15.8.1 version: 15.8.1 @@ -4500,6 +4503,9 @@ packages: '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@emnapi/core@1.11.2': resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} @@ -4509,6 +4515,9 @@ packages: '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/runtime@1.11.2': resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} @@ -6575,6 +6584,9 @@ packages: resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} engines: {node: '>=20.0.0'} + '@petamoriken/float16@3.9.3': + resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==} + '@phenomnomnominal/tsquery@6.2.0': resolution: {integrity: sha512-Vo9nkhfZxDB/sBiqIY3pjDC4mOSyure+AFlEW5hcy/tRE82MqCXjRN4InnVNMldinRt0dLYqg4HAU2XPq5e1LA==} peerDependencies: @@ -6615,6 +6627,18 @@ packages: cpu: [arm64] os: [android] + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-android-arm64@1.2.0': + resolution: {integrity: sha512-9yB1l95IrJuNGDFdOYe79vdApdz6WWBCObE+rQ2LUliYUlcyFwSYIb2xb5/Ifw7dAtMy2ZqNyd8QTSOc7duAKw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@rolldown/binding-darwin-arm64@1.0.3': resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6639,18 +6663,54 @@ packages: cpu: [x64] os: [darwin] + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.0': + resolution: {integrity: sha512-NqKYaq0355ZmNMG4QGpxtEDxsc7tGDhjhCm4PpE0cwnBW+5Il95LJyq414niEiaKLVjnVHBEjSo1wngKxJNiFw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@rolldown/binding-freebsd-x64@1.0.3': resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-freebsd-x64@1.2.0': + resolution: {integrity: sha512-3vPoHzh6eBTz9IbB0/qZdSr0Qeks2echn+I4cHu2joV74VriPDdldswksEDzrl1mBB+oPRi+67+3Ib59paxIPQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.0': + resolution: {integrity: sha512-E6NNefZ1bUVmKJq2tJkf45J4Zyczj7qm9rUT7NY+Xo2474Y13qWAwc2tvBt0BAVbmtXR1llkxXg0Ou1jbDf2SQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.3': resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6658,6 +6718,20 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-gnu@1.2.0': + resolution: {integrity: sha512-D+TgkdgM1vu+7/Fpf8+v0ARW+RXEP9Ccazgm8zQ4JFFd9Q7SrYQ2TakU5S5ihazQDgpKyAgZDOcIFsvoHmTZ8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-arm64-musl@1.0.3': resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6665,6 +6739,20 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-arm64-musl@1.2.0': + resolution: {integrity: sha512-wUqdwJBbAv0APN87GecstdMUtLjjNTs0hBALpxETD73mccFxdmt/XeizXDtN5RAlBwNKmI+Tg+blect2G+8IeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@rolldown/binding-linux-ppc64-gnu@1.0.3': resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6672,6 +6760,20 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-ppc64-gnu@1.2.0': + resolution: {integrity: sha512-9DtF35qR9/NrfhM4oxLplCzVVjE+KKm8Pjemi0i/sdhAWkUasjmSo8WTTubNJClhSHCfyk2yeyoXDQEDPtDAAw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.0.3': resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6679,6 +6781,20 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.0': + resolution: {integrity: sha512-RzuHrBh8X8Hntd2N4VR02QGEciq/9JhcZoTpR/Cee6otRrlILGCf3cg2ygHuih+ZebUnWmMrDX6ITI85btO6rQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.0.3': resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6686,6 +6802,20 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.0': + resolution: {integrity: sha512-MK7L0018jjh1jR3mh21G2j1zAVcpscJBlPo2z19pRjv2XOYGRhaV4LyiD8HO6nCDdZln9IFgCMIV5yt4E3klGQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-musl@1.0.3': resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6693,29 +6823,89 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-x64-musl@1.2.0': + resolution: {integrity: sha512-gyrxLQ9NfGb/9LoVnC4kb9miUghw1mghnkfYvNHSnVIXriabnfgGPUP4RLcJm87q3KgYz4FYUG8IDiWUT+CpSw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@rolldown/binding-openharmony-arm64@1.0.3': resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-openharmony-arm64@1.2.0': + resolution: {integrity: sha512-/6VFMQGRmrhP77KXDC+StIxGzcNp5JOIyYtw0CQ8gPlzhpiIRucYfoM5FaFamHd5BJYIdH86yfP46l1p3WdrFA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@rolldown/binding-wasm32-wasi@1.0.3': resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-wasm32-wasi@1.2.0': + resolution: {integrity: sha512-rwdbUL465kisF24WEJLvP3JrEG6E5GRuIHt5wpMwHGERtHe4Wm2CIvtf5gTBgr2tGOHKh5NdKEAFS2VkOPE91g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + '@rolldown/binding-win32-arm64-msvc@1.0.3': resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-arm64-msvc@1.2.0': + resolution: {integrity: sha512-+5suHwRiKGmhwyUaNT8a5QbrBvLFh2DbO910TEmGRH1aSxwrCezodvGQnulv4uiWEIv1Kq4ypRsJ5+O+ry1DiA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.3': resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.0': + resolution: {integrity: sha512-WfFv6/qGufotqBSBzBYwgpCkJBk8Nj7697LL9vTz/XWc67e0r3oewu8iMRwQj3AUL45GVD7wVsPjCsAAtW66Wg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -7520,6 +7710,9 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/rbush@4.0.0': + resolution: {integrity: sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==} + '@types/react-dom@17.0.26': resolution: {integrity: sha512-Z+2VcYXJwOqQ79HreLU/1fyQ88eXSSFh6I3JdrEHQIfYSI0kCQpTGvOrbE6jFGGYXKsHuwY9tBa/w5Uo6KzrEg==} peerDependencies: @@ -8276,6 +8469,9 @@ packages: '@yarnpkg/lockfile@1.1.0': resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==} + '@zarrita/storage@0.2.0': + resolution: {integrity: sha512-855ZXqtnds7spnT8vNvD+MXa3QExP1m2GqShe8yt7uZXHnQLgJHgkpVwFjE1B0KDDRO0ki09hmk6OboTaIfPsQ==} + '@zkochan/js-yaml@0.0.7': resolution: {integrity: sha512-nrUSn7hzt7J6JWgWGz78ZYI8wj+gdIJdk0Ynjpp8l+trkn58Uqsf6RYrYkEK+3X18EX+TNdtJI0WxAtc+L84SQ==} hasBin: true @@ -10262,6 +10458,9 @@ packages: dx-systemjs-vue-browser@1.1.2: resolution: {integrity: sha512-Z6ibEOrWKd0ta/rZqDMVax3gScK3C4w5GzzeAor4GNaUJfhV6SjLRmpgdecYRtZTPvCvjlFHxF2Br6cXqa0Jig==} + earcut@3.2.3: + resolution: {integrity: sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -11203,6 +11402,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + geotiff@3.1.0-beta.0: + resolution: {integrity: sha512-wbVwOaQYuN+ywly5NoMXn37nHslwkQg5EFHqMeUTtjDACrSag0EEVlFsc+2DK3Pzmmp6XzJW2RTZOamPsBM6ng==} + engines: {node: '>=10.19'} + get-amd-module-type@5.0.1: resolution: {integrity: sha512-jb65zDeHyDjFR1loOVk0HQGM5WNwoGB8aLWy3LKCieMKol0/ProHkhO2X1JxojuN10vbz1qNn09MJ7tNp7qMzw==} engines: {node: '>=14'} @@ -12840,6 +13043,9 @@ packages: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} + lerc@3.0.0: + resolution: {integrity: sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==} + less-loader@12.3.0: resolution: {integrity: sha512-0M6+uYulvYIWs52y0LqN4+QM9TqWAohYSNTo4htE8Z7Cn3G/qQMEmktfHmyJT23k+20kU9zHH2wrfFXkxNLtVw==} engines: {node: '>= 18.12.0'} @@ -13894,6 +14100,9 @@ packages: nullthrows@1.1.1: resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} + numcodecs@0.3.2: + resolution: {integrity: sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==} + nwsapi@2.2.24: resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} @@ -13955,6 +14164,9 @@ packages: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} + ol@10.10.0: + resolution: {integrity: sha512-tLPKn6zl+6uWdPufYlqG/lQzuVUTVmfwahQqVr5+wZNyZecyAtIhMTyOtKpu7ooNDLY2sEjKZNXw9HL+sOjC1A==} + on-finished@2.3.0: resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} engines: {node: '>= 0.8'} @@ -14151,6 +14363,9 @@ packages: parse-color@1.0.0: resolution: {integrity: sha512-fuDHYgFHJGbpGMgw9skY/bj3HL/Jrn4l/5rSspy00DoT4RyLnDcRvPxdZ+r6OFwIsgAuhDh4I09tAId4mI12bw==} + parse-headers@2.0.6: + resolution: {integrity: sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==} + parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} @@ -14254,6 +14469,10 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pbf@5.1.2: + resolution: {integrity: sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==} + hasBin: true + pbkdf2@3.1.5: resolution: {integrity: sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ==} engines: {node: '>= 0.10'} @@ -14572,6 +14791,9 @@ packages: proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + protocol-buffers-schema@3.6.1: + resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -14675,6 +14897,13 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + quick-lru@6.1.2: + resolution: {integrity: sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==} + engines: {node: '>=12'} + + quickselect@3.0.0: + resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} + quill-delta@5.1.0: resolution: {integrity: sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==} engines: {node: '>= 12.0.0'} @@ -14712,6 +14941,9 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + rbush@4.0.1: + resolution: {integrity: sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==} + react-docgen-typescript@2.4.0: resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==} peerDependencies: @@ -14854,6 +15086,9 @@ packages: resolution: {integrity: sha512-tYkDkVVtYkSVhuQ4zBgfvciymHaeuel+zFKXShfDnFP5SyVEP7qo70Rf1jTOTCx3vGNAbnEi/xFkcfQVMIBWag==} engines: {node: '>=12'} + reference-spec-reader@0.2.0: + resolution: {integrity: sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==} + reflect-metadata@0.1.13: resolution: {integrity: sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==} @@ -15004,6 +15239,9 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve-protobuf-schema@2.1.0: + resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} + resolve-url-loader@5.0.0: resolution: {integrity: sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==} engines: {node: '>=12'} @@ -16603,6 +16841,10 @@ packages: unrs-resolver@1.12.2: resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} + unzipit@2.0.0: + resolution: {integrity: sha512-DVeVIWUZCAQPNzm5sB0hpsG1GygTTdBnzNtYYEpInkttx5evkyqRgZi6rTczoySqp8hO5jHVKzrH0f23X8FZLg==} + engines: {node: '>=18'} + unzipper@0.12.5: resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==} @@ -16950,6 +17192,9 @@ packages: resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} engines: {node: '>= 14'} + web-worker@1.5.0: + resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} + webdriver-bidi-protocol@0.4.2: resolution: {integrity: sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==} @@ -17296,6 +17541,9 @@ packages: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} + xml-utils@1.10.2: + resolution: {integrity: sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==} + xmlbuilder@12.0.0: resolution: {integrity: sha512-lMo8DJ8u6JRWp0/Y4XLa/atVDr75H9litKlb2E5j3V3MesoL50EBgZDWoLT3F/LztVnG67GjPXLZpqcky/UMnQ==} engines: {node: '>=6.0'} @@ -17382,6 +17630,9 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + zarrita@0.7.4: + resolution: {integrity: sha512-NChufj86SceFj45z4CYjndmI98dphaG7hU8Wj0UGpMyT21o0V3HTY6zo87aSobYWts1tNFivSXzH2AR1AkaWjA==} + zip-stream@7.0.5: resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==} engines: {node: '>=18'} @@ -17420,6 +17671,9 @@ packages: zone.js@0.16.2: resolution: {integrity: sha512-Eky7p2Z1Ig3NnbfodSPoARCjKBSTFMnE/ACsP1L/XJEfY4SdOFce19BsUCWVwL6K5ABZFy5J3bjcMWffX+YM3Q==} + zstddec@0.2.0: + resolution: {integrity: sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -20734,6 +20988,12 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + '@emnapi/core@1.11.2': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -20749,6 +21009,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.11.2': dependencies: tslib: 2.8.1 @@ -22670,6 +22935,13 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': dependencies: '@emnapi/core': 1.11.2 @@ -23771,6 +24043,8 @@ snapshots: tslib: 2.8.1 tsyringe: 4.10.0 + '@petamoriken/float16@3.9.3': {} + '@phenomnomnominal/tsquery@6.2.0(typescript@5.9.3)': dependencies: '@types/esquery': 1.5.4 @@ -23799,6 +24073,12 @@ snapshots: '@rolldown/binding-android-arm64@1.0.3': optional: true + '@rolldown/binding-android-arm64@1.1.5': + optional: true + + '@rolldown/binding-android-arm64@1.2.0': + optional: true + '@rolldown/binding-darwin-arm64@1.0.3': optional: true @@ -23811,33 +24091,93 @@ snapshots: '@rolldown/binding-darwin-x64@1.0.3': optional: true + '@rolldown/binding-darwin-x64@1.1.5': + optional: true + + '@rolldown/binding-darwin-x64@1.2.0': + optional: true + '@rolldown/binding-freebsd-x64@1.0.3': optional: true + '@rolldown/binding-freebsd-x64@1.1.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.0': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.0': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.3': optional: true + '@rolldown/binding-linux-arm64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.0': + optional: true + '@rolldown/binding-linux-arm64-musl@1.0.3': optional: true + '@rolldown/binding-linux-arm64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.0': + optional: true + '@rolldown/binding-linux-ppc64-gnu@1.0.3': optional: true + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.0': + optional: true + '@rolldown/binding-linux-s390x-gnu@1.0.3': optional: true + '@rolldown/binding-linux-s390x-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.0': + optional: true + '@rolldown/binding-linux-x64-gnu@1.0.3': optional: true + '@rolldown/binding-linux-x64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.0': + optional: true + '@rolldown/binding-linux-x64-musl@1.0.3': optional: true + '@rolldown/binding-linux-x64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.0': + optional: true + '@rolldown/binding-openharmony-arm64@1.0.3': optional: true + '@rolldown/binding-openharmony-arm64@1.1.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.0': + optional: true + '@rolldown/binding-wasm32-wasi@1.0.3': dependencies: '@emnapi/core': 1.10.0 @@ -23845,12 +24185,38 @@ snapshots: '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true + '@rolldown/binding-wasm32-wasi@1.1.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-wasm32-wasi@1.2.0': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.3': optional: true + '@rolldown/binding-win32-arm64-msvc@1.1.5': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.0': + optional: true + '@rolldown/binding-win32-x64-msvc@1.0.3': optional: true + '@rolldown/binding-win32-x64-msvc@1.1.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.0': + optional: true + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rolldown/pluginutils@1.0.1': {} @@ -24698,6 +25064,8 @@ snapshots: '@types/range-parser@1.2.7': {} + '@types/rbush@4.0.0': {} + '@types/react-dom@17.0.26(@types/react@17.0.91)': dependencies: '@types/react': 17.0.91 @@ -26049,6 +26417,11 @@ snapshots: '@yarnpkg/lockfile@1.1.0': {} + '@zarrita/storage@0.2.0': + dependencies: + reference-spec-reader: 0.2.0 + unzipit: 2.0.0 + '@zkochan/js-yaml@0.0.7': dependencies: argparse: 2.0.1 @@ -28502,6 +28875,8 @@ snapshots: dx-systemjs-vue-browser@1.1.2: {} + earcut@3.2.3: {} + eastasianwidth@0.2.0: {} editorconfig@1.0.7: @@ -30221,6 +30596,17 @@ snapshots: gensync@1.0.0-beta.2: {} + geotiff@3.1.0-beta.0: + dependencies: + '@petamoriken/float16': 3.9.3 + lerc: 3.0.0 + pako: 2.1.0 + parse-headers: 2.0.6 + quick-lru: 6.1.2 + web-worker: 1.5.0 + xml-utils: 1.10.2 + zstddec: 0.2.0 + get-amd-module-type@5.0.1: dependencies: ast-module-types: 5.0.0 @@ -33328,6 +33714,8 @@ snapshots: dependencies: readable-stream: 2.3.8 + lerc@3.0.0: {} + less-loader@12.3.0(less@4.8.0)(webpack@5.105.0(@swc/core@1.15.30(@swc/helpers@0.5.21))(esbuild@0.28.1)): dependencies: less: 4.8.0 @@ -34567,6 +34955,10 @@ snapshots: nullthrows@1.1.1: {} + numcodecs@0.3.2: + dependencies: + fflate: 0.8.2 + nwsapi@2.2.24: {} nx@23.0.2(@swc/core@1.15.30(@swc/helpers@0.5.21)): @@ -34752,6 +35144,15 @@ snapshots: obug@2.1.4: {} + ol@10.10.0: + dependencies: + '@types/rbush': 4.0.0 + earcut: 3.2.3 + geotiff: 3.1.0-beta.0 + pbf: 5.1.2 + rbush: 4.0.1 + zarrita: 0.7.4 + on-finished@2.3.0: dependencies: ee-first: 1.1.1 @@ -35041,6 +35442,8 @@ snapshots: dependencies: color-convert: 0.5.3 + parse-headers@2.0.6: {} + parse-json@5.2.0: dependencies: '@babel/code-frame': 7.29.7 @@ -35136,6 +35539,10 @@ snapshots: pathval@2.0.1: {} + pbf@5.1.2: + dependencies: + resolve-protobuf-schema: 2.1.0 + pbkdf2@3.1.5: dependencies: create-hash: 1.2.0 @@ -35432,6 +35839,8 @@ snapshots: proto-list@1.2.4: {} + protocol-buffers-schema@3.6.1: {} + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -35530,6 +35939,10 @@ snapshots: quick-lru@5.1.1: {} + quick-lru@6.1.2: {} + + quickselect@3.0.0: {} + quill-delta@5.1.0: dependencies: fast-diff: 1.3.0 @@ -35575,6 +35988,10 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + rbush@4.0.1: + dependencies: + quickselect: 3.0.0 + react-docgen-typescript@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -35753,6 +36170,8 @@ snapshots: indent-string: 5.0.0 strip-indent: 4.1.1 + reference-spec-reader@0.2.0: {} + reflect-metadata@0.1.13: {} reflect-metadata@0.2.2: {} @@ -35920,6 +36339,10 @@ snapshots: resolve-pkg-maps@1.0.0: {} + resolve-protobuf-schema@2.1.0: + dependencies: + protocol-buffers-schema: 3.6.1 + resolve-url-loader@5.0.0: dependencies: adjust-sourcemap-loader: 4.0.0 @@ -36016,14 +36439,42 @@ snapshots: '@oxc-project/types': 0.139.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.5 '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 rolldown@1.2.0: dependencies: '@oxc-project/types': 0.140.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.0 '@rolldown/binding-darwin-arm64': 1.2.0 + '@rolldown/binding-darwin-x64': 1.2.0 + '@rolldown/binding-freebsd-x64': 1.2.0 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.0 + '@rolldown/binding-linux-arm64-gnu': 1.2.0 + '@rolldown/binding-linux-arm64-musl': 1.2.0 + '@rolldown/binding-linux-ppc64-gnu': 1.2.0 + '@rolldown/binding-linux-s390x-gnu': 1.2.0 + '@rolldown/binding-linux-x64-gnu': 1.2.0 + '@rolldown/binding-linux-x64-musl': 1.2.0 + '@rolldown/binding-openharmony-arm64': 1.2.0 + '@rolldown/binding-wasm32-wasi': 1.2.0 + '@rolldown/binding-win32-arm64-msvc': 1.2.0 + '@rolldown/binding-win32-x64-msvc': 1.2.0 rollup-plugin-dts@6.4.1(rollup@4.59.0)(typescript@4.9.5): dependencies: @@ -38468,6 +38919,8 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 + unzipit@2.0.0: {} + unzipper@0.12.5: dependencies: bluebird: 3.7.2 @@ -38883,6 +39336,8 @@ snapshots: web-streams-polyfill@4.0.0-beta.3: {} + web-worker@1.5.0: {} + webdriver-bidi-protocol@0.4.2: {} webidl-conversions@3.0.1: {} @@ -39598,6 +40053,8 @@ snapshots: xml-name-validator@4.0.0: {} + xml-utils@1.10.2: {} + xmlbuilder@12.0.0: {} xmlchars@2.2.0: {} @@ -39676,6 +40133,11 @@ snapshots: yoctocolors@2.1.2: {} + zarrita@0.7.4: + dependencies: + '@zarrita/storage': 0.2.0 + numcodecs: 0.3.2 + zip-stream@7.0.5: dependencies: compress-commons: 7.0.1 @@ -39710,4 +40172,6 @@ snapshots: zone.js@0.16.2: {} + zstddec@0.2.0: {} + zwitch@2.0.4: {} From b83e180b383e103b930faacd64a963f79d87f540 Mon Sep 17 00:00:00 2001 From: Alisher Amonulloev Date: Fri, 28 Aug 2026 18:52:48 +0300 Subject: [PATCH 04/28] =?UTF-8?q?Map:=20OSM=20provider=20=E2=80=94=20Synch?= =?UTF-8?q?ronize=20viewport=20and=20interactions=20(#34951)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stories/map/OSMMap.stories.tsx | 109 +++- .../devextreme/js/__internal/ui/map/map.ts | 6 + .../ui/map/provider.dynamic.osm.engine.ts | 26 + .../ui/map/provider.dynamic.osm.openlayers.ts | 328 +++++++---- ...vider.dynamic.osm.openlayers.utils.test.ts | 106 ++++ .../provider.dynamic.osm.openlayers.utils.ts | 149 +++++ .../__internal/ui/map/provider.dynamic.osm.ts | 79 ++- .../js/__internal/ui/map/provider.ts | 4 + .../js/ui/map/openlayers.register.js | 14 +- .../testing/helpers/forMap/openLayersMock.js | 160 +++++- .../mapParts/osmTests.js | 509 +++++++++++++++++- 11 files changed, 1345 insertions(+), 145 deletions(-) create mode 100644 packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.test.ts create mode 100644 packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.ts diff --git a/apps/react-storybook/stories/map/OSMMap.stories.tsx b/apps/react-storybook/stories/map/OSMMap.stories.tsx index e96a51a62e69..865b6130cd0e 100644 --- a/apps/react-storybook/stories/map/OSMMap.stories.tsx +++ b/apps/react-storybook/stories/map/OSMMap.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import { useArgs } from 'storybook/preview-api'; import Attribution from 'ol/control/Attribution.js'; import type OpenLayersMap from 'ol/Map.js'; @@ -6,25 +7,41 @@ import 'ol/ol.css'; import { fromLonLat, transformExtent } from 'ol/proj.js'; import View from 'ol/View.js'; import React from 'react'; -import Map from 'devextreme-react/map'; -import type { ReadyEvent } from 'devextreme/ui/map'; +import Map, { type MapRef } from 'devextreme-react/map'; +import type { + MapLocation, + MapType, + ReadyEvent, +} from 'devextreme/ui/map'; import 'devextreme/ui/map/openlayers'; const CENTER = { lat: 40.7484, lng: -73.9857 }; +const CENTRAL_PARK_CENTER = { lat: 40.7829, lng: -73.9654 }; const EXTENT: [number, number, number, number] = [-74.08, 40.67, -73.85, 40.88]; const TILE_SERVER = { url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', attribution: '© OpenStreetMap contributors', maxZoom: 19, }; -const PROVIDER_CONFIG = { tileServer: TILE_SERVER }; +const PROVIDER_CONFIG = { tileServer: () => TILE_SERVER }; interface OsmStoryArgs { + centerOnCentralPark: boolean; + controls: boolean; + disabled: boolean; + focusStateEnabled: boolean; + rtlEnabled: boolean; + type: MapType; zoom: number; } -const configureMap = ( +interface OsmMapStoryProps extends OsmStoryArgs { + updateArgs: (args: Partial) => void; +} + +const configureOpenLayersMap = ( { originalMap }: ReadyEvent, + center: MapLocation, zoom: number, ): void => { const map = originalMap as OpenLayersMap; @@ -34,36 +51,86 @@ const configureMap = ( attribution?.setCollapsed(false); attribution?.setCollapsible(false); map.setView(new View({ - center: fromLonLat([CENTER.lng, CENTER.lat]), + center: fromLonLat([center.lng, center.lat]), extent: transformExtent(EXTENT, 'EPSG:4326', 'EPSG:3857'), maxZoom: 16, minZoom: 14, - showFullExtent: false, smoothExtentConstraint: false, zoom, })); }; -const OsmMapStory = ({ zoom }: OsmStoryArgs): React.ReactElement => ( - configureMap(event, zoom)} - /> -); +const OsmMapStory = ({ + centerOnCentralPark, + controls, + disabled, + focusStateEnabled, + rtlEnabled, + type, + updateArgs, + zoom, +}: OsmMapStoryProps): React.ReactElement => { + const mapRef = React.useRef(null); + const center = centerOnCentralPark ? CENTRAL_PARK_CENTER : CENTER; + + React.useEffect(() => { + mapRef.current?.instance()?.option('center', center); + }, [center]); + + return ( + configureOpenLayersMap(event, center, zoom)} + onZoomChange={(value) => updateArgs({ zoom: value })} + /> + ); +}; const meta: Meta = { title: 'Components/Map/OSM Provider', - component: OsmMapStory, tags: ['!test'], + render: function Render() { + const [args, updateArgs] = useArgs(); + + return ; + }, parameters: { layout: 'fullscreen', }, argTypes: { + centerOnCentralPark: { + control: 'boolean', + description: 'Switches the map center between the default New York location and Central Park.', + }, + controls: { + control: 'boolean', + }, + disabled: { + control: 'boolean', + }, + focusStateEnabled: { + control: 'boolean', + }, + rtlEnabled: { + control: 'boolean', + }, + type: { + control: 'select', + options: ['roadmap', 'satellite', 'hybrid'], + description: 'The public OSM tile server provides one style, so this control exercises ' + + 'type changes without changing the map appearance.', + }, zoom: { control: { type: 'number', @@ -81,6 +148,12 @@ type Story = StoryObj; export const Default: Story = { args: { + centerOnCentralPark: false, + controls: true, + disabled: false, + focusStateEnabled: true, + rtlEnabled: false, + type: 'roadmap', zoom: 15, }, }; diff --git a/packages/devextreme/js/__internal/ui/map/map.ts b/packages/devextreme/js/__internal/ui/map/map.ts index 6053075d8867..3d2adb2f057a 100644 --- a/packages/devextreme/js/__internal/ui/map/map.ts +++ b/packages/devextreme/js/__internal/ui/map/map.ts @@ -267,6 +267,12 @@ class Map extends Widget { // eslint-disable-next-line @typescript-eslint/no-floating-promises this._queueAsyncAction('updateDisabled'); break; + case 'focusStateEnabled': + case 'tabIndex': + super._optionChanged(args); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this._queueAsyncAction('updateFocus'); + break; case 'width': case 'height': super._optionChanged(args); 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 index f64d86162337..eb011504792e 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.engine.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.engine.ts @@ -1,5 +1,7 @@ import type { MapLocation } from '@js/ui/map'; +export const SUBDOMAIN_PLACEHOLDER = '{s}'; + export interface MapEngineTileLayerOptions { attribution?: string; maxZoom: number; @@ -12,10 +14,34 @@ export interface MapEngineSetViewOptions { zoom?: number; } +export interface MapEngineBounds { + northEast: MapLocation; + southWest: MapLocation; +} + +export interface MapEngineViewState extends MapEngineSetViewOptions { + bounds?: MapEngineBounds; +} + +export interface MapEngineClickEvent { + event?: Event; + location: MapLocation; +} + +export interface MapEngineEventHandlers { + click: (event: MapEngineClickEvent) => void; + viewChange: (view: MapEngineViewState) => void; +} + export interface MapEngineMap { readonly originalMap: unknown; + attachHandlers: (handlers: MapEngineEventHandlers) => void; dispose: () => void; + fitBounds: (bounds: MapEngineBounds) => void; replaceTileLayer: (options: MapEngineTileLayerOptions) => void; + setControls: (visible: boolean) => void; + setDisabled: (disabled: boolean) => void; + setFocus: (enabled: boolean, tabIndex: number) => void; setView: (options: MapEngineSetViewOptions) => void; updateDimensions: () => void; } 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 index d27f2169c52d..c33e0d8cea10 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts @@ -1,101 +1,63 @@ -import type { MapLocation } from '@js/ui/map'; - import type { MapEngine, + MapEngineBounds, + MapEngineClickEvent, + MapEngineEventHandlers, MapEngineMap, MapEngineSetViewOptions, MapEngineTileLayerOptions, + MapEngineViewState, } from './provider.dynamic.osm.engine'; - -type Options = Record; -type Coordinate = [number, number]; - -interface ViewLike { - setCenter: (center: Coordinate) => void; - setZoom: (zoom: number) => void; +import { SUBDOMAIN_PLACEHOLDER } from './provider.dynamic.osm.engine'; +import type { + ControlLike, + Coordinate, + Extent, + InteractionLike, + MapLike, + OpenLayersApi, + Options, + TileLayerLike, +} from './provider.dynamic.osm.openlayers.utils'; +import { + areCoordinatesEqual, + createTileUrlList, + DEFAULT_VIEW_PROJECTION, + GEOGRAPHIC_PROJECTION, + getCoordinateProjection, + isOpenLayersApi, + toCoordinate, + toLocation, +} from './provider.dynamic.osm.openlayers.utils'; + +interface MapBrowserEventLike { + coordinate?: Coordinate; + originalEvent?: Event; } -interface TileLayerLike { - setSource: (source: unknown) => void; -} +class OpenLayersMap implements MapEngineMap { + readonly originalMap: MapLike; -interface MapLike { - addLayer: (layer: unknown) => void; - getView: () => ViewLike; - removeLayer: (layer: unknown) => void; - setTarget: (target?: Element) => void; - updateSize: () => void; -} + private readonly _container: Element; -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') -); + private _focusEnabled = true; -const hasFunction = (value: unknown, property: string): boolean => ( - isRecord(value) && typeof value[property] === 'function' -); + private _tabIndex = 0; -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]) -); + private readonly _zoomControl: ControlLike; -const createTileUrlList = ( - url: string, - subdomains: MapEngineTileLayerOptions['subdomains'], -): string[] => { - const values = Array.isArray(subdomains) ? subdomains : [...(subdomains ?? '')]; + private _controlsVisible = false; - return values.map((value) => url.replace('{s}', value)); -}; + private _disabled = false; -class OpenLayersMap implements MapEngineMap { - readonly originalMap: MapLike; + private _ownsDisabledInert = false; - private readonly _keyboardEventTarget: Element; + private readonly _interactionStates = new Map(); - private readonly _ownsKeyboardTabIndex: boolean; + private _eventHandlers?: { + click: (event: unknown) => void; + moveEnd: (event: unknown) => void; + }; private _tileLayer?: TileLayerLike; @@ -106,27 +68,95 @@ class OpenLayersMap implements MapEngineMap { 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._container = container; + this._syncKeyboardTabIndex(); this.originalMap = new _api.Map({ controls: _api.control.defaults.defaults({ attribution: true, rotate: false, zoom: false }), - interactions: _api.interaction.defaults.defaults({ onFocusOnly: false }), + interactions: _api.interaction.defaults.defaults({ + altShiftDragRotate: false, + onFocusOnly: false, + pinchRotate: false, + }), + keyboardEventTarget: container, target: container, view: new _api.View({ - center: toCoordinate(_api, view.center ?? { lat: 0, lng: 0 }), + center: toCoordinate( + _api, + view.center ?? { lat: 0, lng: 0 }, + DEFAULT_VIEW_PROJECTION, + ), + projection: DEFAULT_VIEW_PROJECTION, zoom: view.zoom ?? 1, }), }); + this._zoomControl = new _api.control.Zoom(); + } + + attachHandlers(handlers: MapEngineEventHandlers): void { + this._detachHandlers(); + this._initHandlers(handlers); + } + + private _initHandlers(handlers: MapEngineEventHandlers): void { + const click = (event: unknown): void => { + const { coordinate, originalEvent } = event as MapBrowserEventLike; + if (!coordinate) { + return; + } + + const clickEvent: MapEngineClickEvent = { + location: toLocation( + this._api, + coordinate, + this.originalMap.getView().getProjection(), + ), + }; + if (originalEvent) { + clickEvent.event = originalEvent; + } + handlers.click(clickEvent); + }; + const moveEnd = (): void => { + handlers.viewChange(this._getViewState()); + }; + + this._eventHandlers = { click, moveEnd }; + this.originalMap.on('click', click); + this.originalMap.on('moveend', moveEnd); + } + + private _detachHandlers(): void { + if (!this._eventHandlers) { + return; + } + + this.originalMap.un('click', this._eventHandlers.click); + this.originalMap.un('moveend', this._eventHandlers.moveEnd); + this._eventHandlers = undefined; + } + + private _getViewState(): MapEngineViewState { + const view = this.originalMap.getView(); + const center = view.getCenter(); + const zoom = view.getZoom(); + const extent = view.calculateExtent(); + + const result: MapEngineViewState = { + bounds: { + northEast: toLocation(this._api, [extent[2], extent[3]], view.getProjection()), + southWest: toLocation(this._api, [extent[0], extent[1]], view.getProjection()), + }, + }; + + if (center) { + result.center = toLocation(this._api, center, view.getProjection()); + } + if (zoom !== undefined) { + result.zoom = zoom; + } + + return result; } dispose(): void { @@ -135,20 +165,41 @@ class OpenLayersMap implements MapEngineMap { } this._disposed = true; + this._detachHandlers(); + this._removeOwnedInert(); + this.setControls(false); 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'); - } + this._container.removeAttribute('tabindex'); + } + + fitBounds(bounds: MapEngineBounds): void { + const west = bounds.southWest.lng; + const east = bounds.northEast.lng < west + ? bounds.northEast.lng + 360 + : bounds.northEast.lng; + const geographicExtent: Extent = [ + west, + Math.min(bounds.northEast.lat, bounds.southWest.lat), + east, + Math.max(bounds.northEast.lat, bounds.southWest.lat), + ]; + const view = this.originalMap.getView(); + const extent = this._api.proj.transformExtent( + geographicExtent, + GEOGRAPHIC_PROJECTION, + getCoordinateProjection(this._api, view.getProjection()), + ); + view.fit(extent); } replaceTileLayer(options: MapEngineTileLayerOptions): void { const sourceOptions: Options = { maxZoom: options.maxZoom, - url: options.url.includes('{s}') + url: options.url.includes(SUBDOMAIN_PLACEHOLDER) ? createTileUrlList(options.url, options.subdomains) : options.url, }; @@ -168,13 +219,90 @@ class OpenLayersMap implements MapEngineMap { this.originalMap.addLayer(this._tileLayer); } + setControls(visible: boolean): void { + if (visible === this._controlsVisible) { + return; + } + + this._controlsVisible = visible; + if (visible) { + this.originalMap.addControl(this._zoomControl); + } else { + this.originalMap.removeControl(this._zoomControl); + } + } + + setDisabled(disabled: boolean): void { + if (disabled === this._disabled) { + return; + } + + this._disabled = disabled; + const interactions = this.originalMap.getInteractions(); + + if (disabled) { + this._disableKeyboardAccess(); + this._interactionStates.clear(); + interactions.forEach((interaction) => { + this._interactionStates.set(interaction, interaction.getActive()); + interaction.setActive(false); + }); + } else { + this._restoreKeyboardAccess(); + interactions.forEach((interaction) => { + const active = this._interactionStates.get(interaction); + if (active !== undefined) { + interaction.setActive(active); + } + }); + this._interactionStates.clear(); + } + } + + private _disableKeyboardAccess(): void { + if (!this._container.hasAttribute('inert')) { + this._container.setAttribute('inert', ''); + this._ownsDisabledInert = true; + } + this._syncKeyboardTabIndex(); + } + + private _restoreKeyboardAccess(): void { + this._removeOwnedInert(); + this._syncKeyboardTabIndex(); + } + + private _removeOwnedInert(): void { + if (this._ownsDisabledInert) { + this._container.removeAttribute('inert'); + this._ownsDisabledInert = false; + } + } + + setFocus(enabled: boolean, tabIndex: number): void { + this._focusEnabled = enabled; + this._tabIndex = tabIndex; + this._syncKeyboardTabIndex(); + } + + private _syncKeyboardTabIndex(): void { + if (this._focusEnabled && !this._disabled) { + this._container.setAttribute('tabindex', String(this._tabIndex)); + } else { + this._container.removeAttribute('tabindex'); + } + } + setView(options: MapEngineSetViewOptions): void { const view = this.originalMap.getView(); if (options.center) { - view.setCenter(toCoordinate(this._api, options.center)); + const center = toCoordinate(this._api, options.center, view.getProjection()); + if (!areCoordinatesEqual(view.getCenter(), center)) { + view.setCenter(center); + } } - if (options.zoom !== undefined) { + if (options.zoom !== undefined && view.getZoom() !== options.zoom) { view.setZoom(options.zoom); } } diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.test.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.test.ts new file mode 100644 index 000000000000..d817293727c6 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.test.ts @@ -0,0 +1,106 @@ +import { + describe, expect, it, jest, +} from '@jest/globals'; + +import type { OpenLayersApi } from './provider.dynamic.osm.openlayers.utils'; +import { + areCoordinatesEqual, + createTileUrlList, + isOpenLayersApi, + toCoordinate, + toLocation, +} from './provider.dynamic.osm.openlayers.utils'; + +const createApi = (): unknown => ({ + Map: jest.fn(), + View: jest.fn(), + control: { + Zoom: jest.fn(), + defaults: { defaults: () => [] }, + }, + interaction: { + defaults: { defaults: () => [] }, + }, + layer: { Tile: jest.fn() }, + proj: { + getUserProjection: () => null, + toLonLat: () => [0, 0], + transform: () => [0, 0], + transformExtent: () => [0, 0, 0, 0], + }, + source: { ImageTile: jest.fn() }, +}); + +describe('OpenLayers utils', () => { + describe('isOpenLayersApi', () => { + it('returns true for a complete OpenLayers API', () => { + expect(isOpenLayersApi(createApi())).toBe(true); + }); + + it('returns false when a required API member is missing', () => { + const api = createApi() as Record; + + delete api.View; + + expect(isOpenLayersApi(api)).toBe(false); + }); + }); + + describe('coordinate conversion', () => { + it('converts a Map location to the active OpenLayers projection', () => { + const transform = jest.fn(() => [100, 200] as [number, number]); + const api = { + proj: { + getUserProjection: () => 'user-projection', + transform, + }, + } as unknown as OpenLayersApi; + + expect(toCoordinate(api, { lat: 40.7, lng: -74 }, 'view-projection')) + .toEqual([100, 200]); + expect(transform).toHaveBeenCalledWith( + [-74, 40.7], + 'EPSG:4326', + 'user-projection', + ); + }); + + it('converts an OpenLayers coordinate to a Map location', () => { + const toLonLat = jest.fn(() => [-74, 40.7] as [number, number]); + const api = { + proj: { + getUserProjection: () => null, + toLonLat, + }, + } as unknown as OpenLayersApi; + + expect(toLocation(api, [100, 200], 'view-projection')) + .toEqual({ lat: 40.7, lng: -74 }); + expect(toLonLat).toHaveBeenCalledWith([100, 200], 'view-projection'); + }); + }); + + describe('areCoordinatesEqual', () => { + it('compares both coordinate values', () => { + expect(areCoordinatesEqual([1, 2], [1, 2])).toBe(true); + expect(areCoordinatesEqual([1, 2], [1, 3])).toBe(false); + expect(areCoordinatesEqual(undefined, [1, 2])).toBe(false); + }); + }); + + describe('createTileUrlList', () => { + it('expands string subdomains and replaces every placeholder', () => { + expect(createTileUrlList('https://{s}.example.com/{s}/{z}', 'ab')).toEqual([ + 'https://a.example.com/a/{z}', + 'https://b.example.com/b/{z}', + ]); + }); + + it('expands array subdomains', () => { + expect(createTileUrlList('https://{s}.example.com/{z}', ['one', 'two'])).toEqual([ + 'https://one.example.com/{z}', + 'https://two.example.com/{z}', + ]); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.ts new file mode 100644 index 000000000000..dabd818ceecf --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.ts @@ -0,0 +1,149 @@ +import type { MapLocation } from '@js/ui/map'; + +import type { MapEngineTileLayerOptions } from './provider.dynamic.osm.engine'; +import { SUBDOMAIN_PLACEHOLDER } from './provider.dynamic.osm.engine'; + +export type Options = Record; +export type Coordinate = [number, number]; +export type Extent = [number, number, number, number]; + +export const GEOGRAPHIC_PROJECTION = 'EPSG:4326'; +export const DEFAULT_VIEW_PROJECTION = 'EPSG:3857'; + +export type ControlLike = object; + +export interface InteractionLike { + getActive: () => boolean; + setActive: (active: boolean) => void; +} + +export interface CollectionLike { + forEach: (callback: (item: T) => void) => void; +} + +export interface ViewLike { + calculateExtent: () => Extent; + fit: (extent: Extent) => void; + getCenter: () => Coordinate | undefined; + getProjection: () => unknown; + getZoom: () => number | undefined; + setCenter: (center: Coordinate) => void; + setZoom: (zoom: number) => void; +} + +export interface TileLayerLike { + setSource: (source: unknown) => void; +} + +export interface MapLike { + addControl: (control: ControlLike) => void; + addLayer: (layer: unknown) => void; + getInteractions: () => CollectionLike; + getView: () => ViewLike; + on: (type: string, listener: (event: unknown) => void) => void; + removeControl: (control: ControlLike) => void; + removeLayer: (layer: unknown) => void; + setTarget: (target?: Element) => void; + un: (type: string, listener: (event: unknown) => void) => void; + updateSize: () => void; +} + +export interface OpenLayersApi { + Map: new (options: Options) => MapLike; + View: new (options: Options) => ViewLike; + control: { + Zoom: new () => ControlLike; + defaults: { + defaults: (options?: Options) => unknown; + }; + }; + interaction: { + defaults: { + defaults: (options?: Options) => unknown; + }; + }; + layer: { + Tile: new (options: Options) => TileLayerLike; + }; + proj: { + getUserProjection: () => unknown | null; + toLonLat: (coordinate: Coordinate, projection?: unknown) => Coordinate; + transform: (coordinate: Coordinate, source: unknown, destination: unknown) => Coordinate; + transformExtent: (extent: Extent, source: unknown, destination: unknown) => Extent; + }; + 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' +); + +export const isOpenLayersApi = (api: unknown): api is OpenLayersApi => { + if (!isRecord(api)) { + return false; + } + + return typeof api.Map === 'function' + && typeof api.View === 'function' + && isRecord(api.control) + && hasFunction(api.control, 'Zoom') + && 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, 'getUserProjection') + && hasFunction(api.proj, 'toLonLat') + && hasFunction(api.proj, 'transform') + && hasFunction(api.proj, 'transformExtent') + && hasFunction(api.source, 'ImageTile'); +}; + +export const getCoordinateProjection = ( + api: OpenLayersApi, + viewProjection: unknown, +): unknown => api.proj.getUserProjection() ?? viewProjection; + +export const areCoordinatesEqual = ( + first: Coordinate | undefined, + second: Coordinate, +): boolean => first?.[0] === second[0] && first[1] === second[1]; + +export const toCoordinate = ( + api: OpenLayersApi, + location: MapLocation, + viewProjection: unknown, +): Coordinate => api.proj.transform( + [location.lng, location.lat], + GEOGRAPHIC_PROJECTION, + getCoordinateProjection(api, viewProjection), +); + +export const toLocation = ( + api: OpenLayersApi, + coordinate: Coordinate, + viewProjection: unknown, +): MapLocation => { + const [lng, lat] = api.proj.toLonLat( + coordinate, + getCoordinateProjection(api, viewProjection), + ); + + return { lat, lng }; +}; + +export const createTileUrlList = ( + url: string, + subdomains: MapEngineTileLayerOptions['subdomains'], +): string[] => { + const values = Array.isArray(subdomains) ? subdomains : [...(subdomains ?? '')]; + + return values.map((value) => url.split(SUBDOMAIN_PLACEHOLDER).join(value)); +}; diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts index 6b138f8b8882..16752dd3fee6 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts @@ -15,14 +15,28 @@ import type { import DynamicProvider from './provider.dynamic'; import type { MapEngine, + MapEngineBounds, + MapEngineClickEvent, MapEngineMap, MapEngineTileLayerOptions, + MapEngineViewState, +} from './provider.dynamic.osm.engine'; +import { + getRegisteredMapEngine, + SUBDOMAIN_PLACEHOLDER, } from './provider.dynamic.osm.engine'; -import { getRegisteredMapEngine } from './provider.dynamic.osm.engine'; import { createOpenLayersEngine } from './provider.dynamic.osm.openlayers'; const DEFAULT_MAX_ZOOM = 19; const DEFAULT_SUBDOMAINS = 'abc'; +const LOCATION_EPSILON = 1e-10; + +const areLocationsEqual = ( + first: MapLocation | null, + second: MapLocation, +): boolean => first !== null + && Math.abs(first.lat - second.lat) < LOCATION_EPSILON + && Math.abs(first.lng - second.lng) < LOCATION_EPSILON; class OsmProvider extends DynamicProvider { _engine?: MapEngine; @@ -56,6 +70,12 @@ class OsmProvider extends DynamicProvider { this._engineMap = engineMap; this._map = engineMap.originalMap; + engineMap.setControls(Boolean(this._option('controls'))); + engineMap.setFocus( + Boolean(this._option('focusStateEnabled')), + this._option('tabIndex') ?? 0, + ); + engineMap.setDisabled(Boolean(this._option('disabled'))); return Promise.resolve(); } @@ -92,7 +112,7 @@ class OsmProvider extends DynamicProvider { result.attribution = config.attribution; } - if (config.url.includes('{s}')) { + if (config.url.includes(SUBDOMAIN_PLACEHOLDER)) { result.subdomains = config.subdomains?.length ? config.subdomains : DEFAULT_SUBDOMAINS; } @@ -103,7 +123,38 @@ class OsmProvider extends DynamicProvider { return this._getLatLng(location) ?? { lat: 0, lng: 0 }; } - _attachHandlers(): void {} + _attachHandlers(): void { + this._engineMap?.attachHandlers({ + click: (event) => this._clickActionHandler(event), + viewChange: (view) => this._viewChangeHandler(view), + }); + } + + _clickActionHandler(event: MapEngineClickEvent): void { + this._fireClickAction(event); + } + + _viewChangeHandler(view: MapEngineViewState): void { + const { bounds, center, zoom } = view; + + if (bounds) { + const currentBounds = this._option('bounds'); + const currentNorthEast = this._getLatLng(currentBounds?.northEast); + const currentSouthWest = this._getLatLng(currentBounds?.southWest); + if (!areLocationsEqual(currentNorthEast, bounds.northEast) + || !areLocationsEqual(currentSouthWest, bounds.southWest)) { + this._option('bounds', bounds); + } + } + + if (center && !areLocationsEqual(this._getLatLng(this._option('center')), center)) { + this._option('center', center); + } + + if (zoom !== undefined && zoom !== this._option('zoom')) { + this._option('zoom', zoom); + } + } updateDimensions(): Promise { this._engineMap?.updateDimensions(); @@ -149,14 +200,36 @@ class OsmProvider extends DynamicProvider { } updateDisabled(): Promise { + this._engineMap?.setDisabled(Boolean(this._option('disabled'))); + + return Promise.resolve(); + } + + updateFocus(): Promise { + this._engineMap?.setFocus( + Boolean(this._option('focusStateEnabled')), + this._option('tabIndex') ?? 0, + ); + return Promise.resolve(); } updateBounds(): Promise { + const bounds = this._option('bounds'); + const northEast = this._getLatLng(bounds?.northEast); + const southWest = this._getLatLng(bounds?.southWest); + + if (northEast && southWest) { + const engineBounds: MapEngineBounds = { northEast, southWest }; + this._engineMap?.fitBounds(engineBounds); + } + return Promise.resolve(); } updateControls(): Promise { + this._engineMap?.setControls(Boolean(this._option('controls'))); + return Promise.resolve(); } diff --git a/packages/devextreme/js/__internal/ui/map/provider.ts b/packages/devextreme/js/__internal/ui/map/provider.ts index 3daf5ce33f85..2a6f6dde734b 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.ts @@ -63,6 +63,10 @@ class Provider { Class.abstract(); } + updateFocus(): Promise { + return Promise.resolve(); + } + updateBounds(): void { Class.abstract(); } diff --git a/packages/devextreme/js/ui/map/openlayers.register.js b/packages/devextreme/js/ui/map/openlayers.register.js index 4e6ec3b8c209..3e66488c1b75 100644 --- a/packages/devextreme/js/ui/map/openlayers.register.js +++ b/packages/devextreme/js/ui/map/openlayers.register.js @@ -1,8 +1,14 @@ import { defaults as defaultControls } from 'ol/control/defaults.js'; +import Zoom from 'ol/control/Zoom.js'; import { defaults as defaultInteractions } from 'ol/interaction/defaults.js'; import TileLayer from 'ol/layer/Tile.js'; import Map from 'ol/Map.js'; -import { fromLonLat } from 'ol/proj.js'; +import { + getUserProjection, + toLonLat, + transform, + transformExtent, +} from 'ol/proj.js'; import ImageTile from 'ol/source/ImageTile.js'; import View from 'ol/View.js'; @@ -13,6 +19,7 @@ setRegisteredMapEngine(createOpenLayersEngine({ Map, View, control: { + Zoom, defaults: { defaults: defaultControls, }, @@ -26,7 +33,10 @@ setRegisteredMapEngine(createOpenLayersEngine({ Tile: TileLayer, }, proj: { - fromLonLat, + getUserProjection, + toLonLat, + transform, + transformExtent, }, source: { ImageTile, diff --git a/packages/devextreme/testing/helpers/forMap/openLayersMock.js b/packages/devextreme/testing/helpers/forMap/openLayersMock.js index 218bd4c59250..64104646129c 100644 --- a/packages/devextreme/testing/helpers/forMap/openLayersMock.js +++ b/packages/devextreme/testing/helpers/forMap/openLayersMock.js @@ -2,50 +2,165 @@ (() => { const api = {}; + const GEOGRAPHIC_PROJECTION = 'EPSG:4326'; + const WEB_MERCATOR_PROJECTION = 'EPSG:3857'; + const PROJECTION_SCALE = 1000; + + const transformCoordinate = (coordinate, source, destination) => { + if(source === destination) { + return [...coordinate]; + } + if(source === GEOGRAPHIC_PROJECTION && destination === WEB_MERCATOR_PROJECTION) { + return coordinate.map((value) => value * PROJECTION_SCALE); + } + if(source === WEB_MERCATOR_PROJECTION && destination === GEOGRAPHIC_PROJECTION) { + return coordinate.map((value) => value / PROJECTION_SCALE); + } + + return [...coordinate]; + }; + + class MockCollection { + constructor(items) { + this.items = items; + } + + forEach(callback) { + this.items.forEach(callback); + } + + getArray() { + return this.items; + } + } + + class MockInteraction { + constructor(active) { + this.active = active; + } + + getActive() { + return this.active; + } + + setActive(active) { + this.active = active; + api.interactionStateChanges.push({ interaction: this, active }); + if(api.onInteractionStateChanged) { + api.onInteractionStateChanged(); + } + } + } + + class MockZoom { + constructor() { + api.zoomControlCreatedCount += 1; + } + } + class MockView { constructor(options) { this.center = options.center; + this.projection = options.projection || WEB_MERCATOR_PROJECTION; this.zoom = options.zoom; api.viewCenter = options.center; api.viewOptions = options; api.viewZoom = options.zoom; } + + calculateExtent() { + return api.viewExtent; + } + + fit(extent, options) { + api.fittedExtent = extent; + api.fitOptions = options; + } + + getCenter() { + return this.center; + } + + getProjection() { + return this.projection; + } + + getZoom() { + return this.zoom; + } + setCenter(center) { this.center = center; api.viewCenter = center; api.viewCenterSetCount += 1; } + setZoom(zoom) { this.zoom = zoom; api.viewZoom = zoom; + api.viewZoomSetCount += 1; } } + class MockMap { constructor(options) { this.options = options; this.view = options.view; + this.eventHandlers = {}; api.mapCreated = true; api.mapInstance = this; api.mapOptions = options; } + + addControl(control) { + api.addedControls.push(control); + } + addLayer(layer) { api.tileLayer = layer; api.addedTileLayers.push(layer); } + + getInteractions() { + return this.options.interactions; + } + getView() { return this.view; } + + on(type, listener) { + this.eventHandlers[type] = this.eventHandlers[type] || []; + this.eventHandlers[type].push(listener); + } + + removeControl(control) { + api.removedControls.push(control); + } + removeLayer(layer) { api.removedLayers.push(layer); } + setTarget(target) { this.target = target; api.mapTarget = target; } + updateSize() { api.mapResized = true; } + + un(type, listener) { + const handlers = this.eventHandlers[type] || []; + this.eventHandlers[type] = handlers.filter((handler) => handler !== listener); + } + + trigger(type, event) { + (this.eventHandlers[type] || []).slice().forEach((handler) => handler(event || {})); + } } + class MockImageTile { constructor(options) { if(api.throwOnTileSource) { @@ -55,24 +170,28 @@ 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: { + Zoom: MockZoom, defaults: { defaults(options) { api.controlOptions = options; - return []; + return new MockCollection([]); } } }, @@ -80,7 +199,8 @@ defaults: { defaults(options) { api.interactionOptions = options; - return []; + api.interactions = [new MockInteraction(true), new MockInteraction(false)]; + return new MockCollection(api.interactions); } } }, @@ -88,9 +208,39 @@ Tile: MockTileLayer }, proj: { - fromLonLat(coordinate) { - api.projectedCoordinates.push([...coordinate]); - return coordinate; + getUserProjection() { + return api.userProjection; + }, + toLonLat(coordinate, projection) { + const result = transformCoordinate(coordinate, projection, GEOGRAPHIC_PROJECTION); + if(result[0] < -180 || result[0] > 180) { + result[0] = ((result[0] + 180) % 360 + 360) % 360 - 180; + } + + return result; + }, + transform(coordinate, source, destination) { + api.transformedCoordinates.push({ + coordinate: [...coordinate], + destination, + source + }); + if(source === GEOGRAPHIC_PROJECTION) { + api.projectedCoordinates.push([...coordinate]); + } + + return transformCoordinate(coordinate, source, destination); + }, + transformExtent(extent, source, destination) { + api.transformedExtents.push({ + destination, + extent: [...extent], + source + }); + const southWest = transformCoordinate([extent[0], extent[1]], source, destination); + const northEast = transformCoordinate([extent[2], extent[3]], source, destination); + + return [southWest[0], southWest[1], northEast[0], northEast[1]]; } }, source: { diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js index 3845170a299e..52dd179dd79c 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -10,27 +10,52 @@ import 'ui/map'; let openLayersMock; const resetOpenLayersMock = () => { Object.assign(openLayersMock, { + addedControls: [], addedTileLayers: [], controlOptions: null, + fitOptions: null, + fittedExtent: null, interactionOptions: null, + interactions: [], + interactionStateChanges: [], mapCreated: false, mapInstance: null, mapOptions: null, mapResized: false, mapTarget: null, + onInteractionStateChanged: null, projectedCoordinates: [], + removedControls: [], removedLayers: [], throwOnTileSource: false, tileLayer: null, tileLayerOptions: null, tileSourceChanges: [], tileSourceOptions: null, + transformedCoordinates: [], + transformedExtents: [], + userProjection: null, viewCenter: null, viewCenterSetCount: 0, + viewExtent: [-74100, 40600, -73800, 40900], viewOptions: null, - viewZoom: null + viewZoom: null, + viewZoomSetCount: 0, + zoomControlCreatedCount: 0 }); }; +const onInteractionStates = (expectedStates, callback) => { + openLayersMock.onInteractionStateChanged = () => { + const actualStates = openLayersMock.interactions.map(interaction => interaction.getActive()); + const stateMatches = actualStates.length === expectedStates.length + && actualStates.every((state, index) => state === expectedStates[index]); + + if(stateMatches) { + openLayersMock.onInteractionStateChanged = null; + callback(); + } + }; +}; const moduleConfig = { beforeEach(assert) { const setup = () => { @@ -67,6 +92,8 @@ const createProvider = () => new OsmProvider({ providerConfig: {} }) }, null); +const getOpenLayersKeyboardTarget = () => openLayersMock.mapOptions.keyboardEventTarget; +const getOpenLayersMapTarget = () => openLayersMock.mapOptions.target; QUnit.module('OSM: map loading', moduleConfig, () => { QUnit.test('registered OpenLayers engine takes priority over window.ol', function(assert) { const done = assert.async(); @@ -123,12 +150,20 @@ QUnit.module('OSM: map loading', moduleConfig, () => { 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'); + assert.strictEqual(openLayersMock.mapOptions.keyboardEventTarget, container, 'map target receives keyboard events'); engineMap.dispose(); assert.strictEqual(container.getAttribute('tabindex'), null, 'added tabindex is removed on dispose'); }); + QUnit.test('OpenLayers interactions are configured for dxMap', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const engineMap = engine.createMap(document.createElement('div')); + assert.deepEqual(openLayersMock.interactionOptions, { + altShiftDragRotate: false, + onFocusOnly: false, + pinchRotate: false + }, 'pointer interactions work without focus and cannot rotate the map'); + engineMap.dispose(); + }); QUnit.test('OpenLayers controls are configured for the tiles stage', function(assert) { const engine = createOpenLayersEngine(openLayersMock); const engineMap = engine.createMap(document.createElement('div')); @@ -137,9 +172,10 @@ QUnit.module('OSM: map loading', moduleConfig, () => { rotate: false, zoom: false }, 'only the attribution control remains enabled'); + assert.strictEqual(openLayersMock.addedControls.length, 0, 'zoom control is not added by default'); engineMap.dispose(); }); - QUnit.test('OpenLayers Shadow DOM host is keyboard focusable', function(assert) { + QUnit.test('OpenLayers map target is the keyboard target in Shadow DOM', function(assert) { const engine = createOpenLayersEngine(openLayersMock); const host = document.createElement('div'); const shadowRoot = host.attachShadow({ @@ -148,26 +184,51 @@ QUnit.module('OSM: map loading', moduleConfig, () => { 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'); + assert.strictEqual(host.getAttribute('tabindex'), null, 'Shadow DOM host is unchanged'); + assert.strictEqual(container.getAttribute('tabindex'), '0', 'map target is focusable'); + assert.strictEqual(openLayersMock.mapOptions.keyboardEventTarget, container, 'map target receives keyboard events'); engineMap.dispose(); - assert.strictEqual(host.getAttribute('tabindex'), null, 'added host tabindex is removed on dispose'); + assert.strictEqual(container.getAttribute('tabindex'), null, 'added map target tabindex is removed on dispose'); }); - QUnit.test('existing tabindex is preserved on dispose', function(assert) { + QUnit.test('owned inert attribute is removed on dispose', function(assert) { const engine = createOpenLayersEngine(openLayersMock); const container = document.createElement('div'); - container.setAttribute('tabindex', '-1'); const engineMap = engine.createMap(container); + engineMap.setDisabled(true); + assert.ok(container.hasAttribute('inert'), 'map target is inert while disabled'); engineMap.dispose(); - assert.strictEqual(container.getAttribute('tabindex'), '-1', 'client tabindex is preserved'); + assert.notOk(container.hasAttribute('inert'), 'owned inert attribute is removed'); }); - QUnit.test('tabindex changed after initialization is preserved on dispose', function(assert) { + QUnit.test('pre-existing inert attribute is preserved', function(assert) { const engine = createOpenLayersEngine(openLayersMock); const container = document.createElement('div'); + container.setAttribute('inert', ''); const engineMap = engine.createMap(container); - container.setAttribute('tabindex', '-1'); + + engineMap.setDisabled(true); + engineMap.setDisabled(false); + assert.ok(container.hasAttribute('inert'), 'pre-existing inert attribute is preserved after enabling'); + + engineMap.dispose(); + assert.ok(container.hasAttribute('inert'), 'pre-existing inert attribute is preserved on dispose'); + }); + QUnit.test('disabled map does not make its Shadow DOM host inert', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const host = document.createElement('div'); + const shadowRoot = host.attachShadow({ mode: 'open' }); + const container = document.createElement('div'); + const sibling = document.createElement('button'); + shadowRoot.append(container, sibling); + const engineMap = engine.createMap(container); + engineMap.setDisabled(true); + assert.ok(container.hasAttribute('inert'), 'map container is inert'); + assert.notOk(host.hasAttribute('inert'), 'Shadow DOM host remains interactive'); + assert.notOk(sibling.hasAttribute('inert'), 'sibling remains interactive'); + assert.strictEqual(container.getAttribute('tabindex'), null, 'owned map target tabindex is removed'); + engineMap.setDisabled(false); + assert.notOk(container.hasAttribute('inert'), 'map container becomes interactive'); + assert.strictEqual(container.getAttribute('tabindex'), '0', 'owned map target tabindex is restored'); 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); @@ -244,6 +305,22 @@ QUnit.module('OSM: map loading', moduleConfig, () => { done(); }); }); + ['getUserProjection', 'toLonLat', 'transform', 'transformExtent'].forEach(apiName => { + QUnit.test(`load rejects with E1069 when the OpenLayers ${apiName} API is missing`, function(assert) { + const done = assert.async(); + const provider = createProvider(); + const projectionApi = Object.assign({}, openLayersMock.proj); + delete projectionApi[apiName]; + window.ol = Object.assign({}, openLayersMock, { proj: projectionApi }); + 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({ @@ -361,13 +438,13 @@ QUnit.module('OSM: tile server', moduleConfig, () => { provider: 'osm', providerConfig: { tileServer: { - url: 'https://{s}.tiles.example.com/{z}/{x}/{y}.png', + url: 'https://{s}.tiles.example.com/{z}/{x}/{y}.png?mirror={s}', attribution: 'Example attribution', 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'); + assert.deepEqual(openLayersMock.tileSourceOptions.url, ['https://a.tiles.example.com/{z}/{x}/{y}.png?mirror=a', 'https://b.tiles.example.com/{z}/{x}/{y}.png?mirror=b'], 'all subdomain placeholders are expanded'); done(); } }); @@ -630,8 +707,10 @@ QUnit.module('OSM: initial view', moduleConfig, () => { 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.deepEqual(openLayersMock.viewCenter, [-73980, 40740], 'projected center is applied'); assert.strictEqual(openLayersMock.viewZoom, 12.5, 'fractional zoom is applied'); + assert.strictEqual(openLayersMock.viewCenterSetCount, 0, 'initial center is not applied twice'); + assert.strictEqual(openLayersMock.viewZoomSetCount, 0, 'initial zoom is not applied twice'); done(); } }); @@ -697,3 +776,399 @@ QUnit.module('OSM: initial view', moduleConfig, () => { }); }); }); +QUnit.module('OSM: viewport and interactions', moduleConfig, () => { + QUnit.test('focus options are applied to the OpenLayers keyboard target', function(assert) { + const done = assert.async(); + const map = $('#map').dxMap({ + provider: 'osm', + focusStateEnabled: false, + tabIndex: 5, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + const target = getOpenLayersKeyboardTarget(); + assert.strictEqual(target.getAttribute('tabindex'), null, 'focus is disabled on initialization'); + map.option('onUpdated', () => { + assert.strictEqual(target.getAttribute('tabindex'), '5', 'configured tabIndex is applied'); + map.option('onUpdated', () => { + assert.strictEqual(target.getAttribute('tabindex'), '-1', 'runtime tabIndex is applied'); + map.option('onUpdated', () => { + assert.strictEqual(target.getAttribute('tabindex'), null, 'runtime focus disabling is applied'); + done(); + }); + map.option('focusStateEnabled', false); + }); + map.option('tabIndex', -1); + }); + map.option('focusStateEnabled', true); + } + }).dxMap('instance'); + }); + QUnit.test('OpenLayers view changes update center, fractional zoom, and bounds', 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 view = openLayersMock.mapInstance.getView(); + view.setCenter([-73980, 40740]); + view.setZoom(12.5); + const centerSetCount = openLayersMock.viewCenterSetCount; + const zoomSetCount = openLayersMock.viewZoomSetCount; + openLayersMock.viewExtent = [-74100, 40600, -73800, 40900]; + openLayersMock.mapInstance.trigger('moveend'); + assert.deepEqual(map.option('center'), { + lat: 40.74, + lng: -73.98 + }, 'center is synchronized'); + assert.strictEqual(map.option('zoom'), 12.5, 'fractional zoom is synchronized'); + assert.deepEqual(map.option('bounds'), { + northEast: { + lat: 40.9, + lng: -73.8 + }, + southWest: { + lat: 40.6, + lng: -74.1 + } + }, 'bounds are synchronized'); + assert.strictEqual(openLayersMock.viewCenterSetCount, centerSetCount, 'center is not written back to OpenLayers'); + assert.strictEqual(openLayersMock.viewZoomSetCount, zoomSetCount, 'zoom is not written back to OpenLayers'); + done(); + } + }).dxMap('instance'); + }); + QUnit.test('OpenLayers click fires onClick with location and original event', function(assert) { + const done = assert.async(); + const originalEvent = new PointerEvent('click'); + const map = $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onClick: event => { + assert.strictEqual(event.component, map, 'component is passed'); + assert.deepEqual(event.location, { + lat: 40.74, + lng: -73.98 + }, 'location is normalized'); + assert.strictEqual(event.event, originalEvent, 'original event is passed'); + done(); + }, + onReady: () => { + openLayersMock.mapInstance.trigger('click', { + coordinate: [-73980, 40740], + originalEvent + }); + } + }).dxMap('instance'); + }); + QUnit.test('OpenLayers click without a coordinate is ignored', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const engineMap = engine.createMap(document.createElement('div')); + const click = sinon.spy(); + engineMap.attachHandlers({ click, viewChange: sinon.spy() }); + openLayersMock.mapInstance.trigger('click', { + originalEvent: new PointerEvent('click') + }); + assert.ok(click.notCalled, 'click handler is not called without a location'); + engineMap.dispose(); + }); + QUnit.test('OpenLayers user projection is honored for the initial view, synchronization, and bounds', function(assert) { + const done = assert.async(); + openLayersMock.userProjection = 'EPSG:4326'; + const map = $('#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 view = openLayersMock.mapInstance.getView(); + assert.deepEqual(openLayersMock.viewCenter, [-73.98, 40.74], 'initial center uses the user projection'); + view.setCenter([-73.97, 40.75]); + openLayersMock.viewExtent = [-74.1, 40.6, -73.8, 40.9]; + openLayersMock.mapInstance.trigger('moveend'); + assert.deepEqual(map.option('center'), { + lat: 40.75, + lng: -73.97 + }, 'center is synchronized from the user projection'); + assert.deepEqual(map.option('bounds'), { + northEast: { + lat: 40.9, + lng: -73.8 + }, + southWest: { + lat: 40.6, + lng: -74.1 + } + }, 'bounds are synchronized from the user projection'); + map.option('onUpdated', () => { + assert.deepEqual(openLayersMock.fittedExtent, [-74, 40.7, -73.9, 40.8], 'bounds are fitted in the user projection'); + done(); + }); + map.option('bounds', { + northEast: { + lat: 40.8, + lng: -73.9 + }, + southWest: { + lat: 40.7, + lng: -74 + } + }); + } + }).dxMap('instance'); + }); + QUnit.test('bounds crossing the antimeridian use the shorter wrapped extent', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + bounds: { + northEast: { + lat: 10, + lng: -170 + }, + southWest: { + lat: -10, + lng: 170 + } + }, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + assert.deepEqual(openLayersMock.fittedExtent, [170000, -10000, 190000, 10000], 'the wrapped 20 degree extent is fitted'); + done(); + } + }); + }); + QUnit.test('OpenLayers wrapped view coordinates are normalized on synchronization', 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: () => { + openLayersMock.mapInstance.getView().setCenter([190000, 0]); + openLayersMock.viewExtent = [170000, -10000, 190000, 10000]; + openLayersMock.mapInstance.trigger('moveend'); + assert.deepEqual(map.option('center'), { + lat: 0, + lng: -170 + }, 'center longitude is normalized'); + assert.deepEqual(map.option('bounds'), { + northEast: { + lat: 10, + lng: -170 + }, + southWest: { + lat: -10, + lng: 170 + } + }, 'bounds preserve the antimeridian crossing'); + done(); + } + }).dxMap('instance'); + }); + QUnit.test('controls option toggles the OpenLayers zoom control', function(assert) { + const done = assert.async(); + const map = $('#map').dxMap({ + provider: 'osm', + controls: true, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + assert.strictEqual(openLayersMock.addedControls.length, 1, 'zoom control is added on initialization'); + map.option('onUpdated', () => { + assert.strictEqual(openLayersMock.removedControls.length, 1, 'zoom control is removed'); + map.option('onUpdated', () => { + assert.strictEqual(openLayersMock.addedControls.length, 2, 'zoom control is added again'); + done(); + }); + map.option('controls', true); + }); + map.option('controls', false); + } + }).dxMap('instance'); + }); + QUnit.test('disabled option restores the previous interaction states', function(assert) { + const done = assert.async(); + const map = $('#map').dxMap({ + provider: 'osm', + controls: true, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + assert.strictEqual(openLayersMock.addedControls.length, 1, 'zoom control is present'); + assert.deepEqual(openLayersMock.interactions.map(interaction => interaction.getActive()), [true, false], 'initial states are preserved'); + assert.notOk(getOpenLayersMapTarget().hasAttribute('inert'), 'map target is keyboard accessible'); + onInteractionStates([false, false], () => { + assert.deepEqual(openLayersMock.interactions.map(interaction => interaction.getActive()), [false, false], 'all interactions are disabled'); + assert.ok(getOpenLayersMapTarget().hasAttribute('inert'), 'map target and controls are removed from keyboard navigation'); + assert.strictEqual(getOpenLayersKeyboardTarget().getAttribute('tabindex'), null, 'owned keyboard target tabindex is removed'); + onInteractionStates([true, false], () => { + assert.deepEqual(openLayersMock.interactions.map(interaction => interaction.getActive()), [true, false], 'previous states are restored'); + assert.notOk(getOpenLayersMapTarget().hasAttribute('inert'), 'map target and controls return to keyboard navigation'); + assert.strictEqual(getOpenLayersKeyboardTarget().getAttribute('tabindex'), '0', 'owned keyboard target tabindex is restored'); + done(); + }); + map.option('disabled', false); + }); + map.option('disabled', true); + } + }).dxMap('instance'); + }); + QUnit.test('disabled option is applied on initialization', function(assert) { + const done = assert.async(); + onInteractionStates([false, false], () => { + assert.strictEqual(openLayersMock.addedControls.length, 1, 'zoom control is present'); + assert.deepEqual(openLayersMock.interactions.map(interaction => interaction.getActive()), [false, false], 'all interactions are disabled'); + assert.ok(getOpenLayersMapTarget().hasAttribute('inert'), 'map target and controls are removed from keyboard navigation'); + assert.strictEqual(getOpenLayersKeyboardTarget().getAttribute('tabindex'), null, 'owned keyboard target tabindex is removed'); + map.option('onUpdated', done); + map.option('disabled', false); + }); + const map = $('#map').dxMap({ + provider: 'osm', + controls: true, + disabled: true, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + } + }).dxMap('instance'); + }); + QUnit.test('bounds option fits the OpenLayers view on initialization and at runtime', function(assert) { + const done = assert.async(); + const map = $('#map').dxMap({ + provider: 'osm', + bounds: { + northEast: { + lat: 40.8, + lng: -73.9 + }, + southWest: { + lat: 40.7, + lng: -74 + } + }, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + assert.deepEqual(openLayersMock.fittedExtent, [-74000, 40700, -73900, 40800], 'initial bounds are fitted'); + assert.strictEqual(openLayersMock.fitOptions, undefined, 'OpenLayers selects the viewport size'); + map.option('onUpdated', () => { + assert.deepEqual(openLayersMock.fittedExtent, [-74100, 40600, -73800, 40900], 'runtime bounds are fitted'); + done(); + }); + map.option('bounds', { + northEast: [40.9, -73.8], + southWest: [40.6, -74.1] + }); + } + }).dxMap('instance'); + }); + QUnit.test('incomplete bounds do not change the OpenLayers view', 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: () => { + map.option('onUpdated', () => { + assert.strictEqual(openLayersMock.fittedExtent, null, 'view is not fitted without both bounds'); + done(); + }); + map.option('bounds', { + northEast: { + lat: 40.8, + lng: -73.9 + }, + southWest: null + }); + } + }).dxMap('instance'); + }); + QUnit.test('RTL mode preserves viewport and keyboard behavior', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + center: { + lat: 40.74, + lng: -73.98 + }, + controls: true, + rtlEnabled: true, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + assert.ok($('#map').hasClass('dx-rtl'), 'RTL mode is applied to the widget'); + assert.deepEqual(openLayersMock.viewCenter, [-73980, 40740], 'center coordinates are not mirrored'); + assert.strictEqual(openLayersMock.addedControls.length, 1, 'zoom control remains available'); + assert.strictEqual(getOpenLayersKeyboardTarget().getAttribute('tabindex'), '0', 'map remains keyboard focusable'); + done(); + } + }); + }); + QUnit.test('dispose detaches OpenLayers event handlers', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const engineMap = engine.createMap(document.createElement('div')); + const click = sinon.spy(); + const viewChange = sinon.spy(); + engineMap.attachHandlers({ click, viewChange }); + engineMap.dispose(); + openLayersMock.mapInstance.trigger('click', { coordinate: [-73980, 40740] }); + openLayersMock.mapInstance.trigger('moveend'); + assert.ok(click.notCalled, 'click handler is detached'); + assert.ok(viewChange.notCalled, 'view change handler is detached'); + }); +}); From 67aa3cc04c69f44158650b7c96cb83a22674e8f9 Mon Sep 17 00:00:00 2001 From: Alisher Amonulloev Date: Tue, 8 Sep 2026 19:36:26 +0300 Subject: [PATCH 05/28] Map: OSM provider - Add markers and location calculation (#34994) Signed-off-by: Alisher Amonulloev Co-authored-by: Arman Boyakhchyan --- apps/react-storybook/.storybook/main.ts | 6 +- .../stories/map/OSMMap.stories.tsx | 119 +- .../scss/widgets/base/_map.scss | 16 + .../scss/widgets/fluent/map/_colors.scss | 2 + .../scss/widgets/fluent/map/_index.scss | 10 + .../scss/widgets/generic/map/_colors.scss | 2 + .../scss/widgets/generic/map/_index.scss | 10 + .../scss/widgets/material/map/_colors.scss | 2 + .../scss/widgets/material/map/_index.scss | 10 + .../devextreme/js/__internal/ui/map/map.ts | 15 +- .../ui/map/provider.dynamic.azure.ts | 4 +- .../ui/map/provider.dynamic.bing.ts | 4 +- .../ui/map/provider.dynamic.google.ts | 4 +- .../ui/map/provider.dynamic.osm.engine.ts | 29 +- .../provider.dynamic.osm.openlayers.marker.ts | 106 ++ .../ui/map/provider.dynamic.osm.openlayers.ts | 401 +++++- ...vider.dynamic.osm.openlayers.utils.test.ts | 3 +- .../provider.dynamic.osm.openlayers.utils.ts | 15 +- .../ui/map/provider.dynamic.osm.test.ts | 106 ++ .../__internal/ui/map/provider.dynamic.osm.ts | 275 +++- .../js/__internal/ui/map/provider.dynamic.ts | 64 +- .../js/localization/messages/ar.json | 2 + .../js/localization/messages/bg.json | 2 + .../js/localization/messages/ca.json | 2 + .../js/localization/messages/cs.json | 2 + .../js/localization/messages/da.json | 2 + .../js/localization/messages/de.json | 2 + .../js/localization/messages/el.json | 2 + .../js/localization/messages/en.json | 2 + .../js/localization/messages/es.json | 2 + .../js/localization/messages/fa.json | 2 + .../js/localization/messages/fi.json | 2 + .../js/localization/messages/fr.json | 2 + .../js/localization/messages/hi.json | 2 + .../js/localization/messages/hu.json | 2 + .../js/localization/messages/it.json | 2 + .../js/localization/messages/ja.json | 2 + .../js/localization/messages/ko.json | 2 + .../js/localization/messages/lt.json | 2 + .../js/localization/messages/lv.json | 2 + .../js/localization/messages/nb.json | 2 + .../js/localization/messages/nl.json | 2 + .../js/localization/messages/pl.json | 2 + .../js/localization/messages/pt-BR.json | 2 + .../js/localization/messages/pt-PT.json | 2 + .../js/localization/messages/pt.json | 2 + .../js/localization/messages/ro.json | 2 + .../js/localization/messages/ru.json | 2 + .../js/localization/messages/sl.json | 2 + .../js/localization/messages/sv.json | 2 + .../js/localization/messages/tr.json | 2 + .../js/localization/messages/uk.json | 2 + .../js/localization/messages/vi.json | 2 + .../js/localization/messages/zh-CN.json | 2 + .../js/localization/messages/zh-Hans.json | 2 + .../js/localization/messages/zh-tw.json | 2 + .../js/localization/messages/zh.json | 2 + .../js/ui/map/openlayers.register.js | 2 + .../testing/helpers/forMap/openLayersMock.js | 85 +- .../mapParts/commonTests.js | 52 + .../mapParts/osmTests.js | 1244 ++++++++++++++++- 61 files changed, 2550 insertions(+), 108 deletions(-) create mode 100644 packages/devextreme-scss/scss/widgets/fluent/map/_colors.scss create mode 100644 packages/devextreme-scss/scss/widgets/generic/map/_colors.scss create mode 100644 packages/devextreme-scss/scss/widgets/material/map/_colors.scss create mode 100644 packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.marker.ts create mode 100644 packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.test.ts diff --git a/apps/react-storybook/.storybook/main.ts b/apps/react-storybook/.storybook/main.ts index cb34725f0099..214137d65ce0 100644 --- a/apps/react-storybook/.storybook/main.ts +++ b/apps/react-storybook/.storybook/main.ts @@ -28,7 +28,11 @@ const config: StorybookConfig = { docs: {}, - staticDirs: ['../stories/assets', '../node_modules/devextreme/dist'], + staticDirs: [ + '../stories/assets', + '../node_modules/devextreme/dist', + { from: '../../demos/images/Maps', to: '/images/maps' }, + ], typescript: { reactDocgen: "react-docgen-typescript" diff --git a/apps/react-storybook/stories/map/OSMMap.stories.tsx b/apps/react-storybook/stories/map/OSMMap.stories.tsx index 865b6130cd0e..433c67c296a5 100644 --- a/apps/react-storybook/stories/map/OSMMap.stories.tsx +++ b/apps/react-storybook/stories/map/OSMMap.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-webpack5'; import { useArgs } from 'storybook/preview-api'; +import { fn } from 'storybook/test'; import Attribution from 'ol/control/Attribution.js'; import type OpenLayersMap from 'ol/Map.js'; @@ -7,6 +8,7 @@ import 'ol/ol.css'; import { fromLonLat, transformExtent } from 'ol/proj.js'; import View from 'ol/View.js'; import React from 'react'; +import Button from 'devextreme-react/button'; import Map, { type MapRef } from 'devextreme-react/map'; import type { MapLocation, @@ -17,15 +19,53 @@ import 'devextreme/ui/map/openlayers'; const CENTER = { lat: 40.7484, lng: -73.9857 }; const CENTRAL_PARK_CENTER = { lat: 40.7829, lng: -73.9654 }; +const DEFAULT_MARKER_LOCATION = 'Empire State Building'; +const CUSTOM_MARKER_LOCATION = 'Bryant Park'; +const ADDED_MARKER_LOCATION = 'Times Square'; const EXTENT: [number, number, number, number] = [-74.08, 40.67, -73.85, 40.88]; const TILE_SERVER = { url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', attribution: '© OpenStreetMap contributors', maxZoom: 19, }; -const PROVIDER_CONFIG = { tileServer: () => TILE_SERVER }; +const MARKER_LOCATIONS: Record = { + [DEFAULT_MARKER_LOCATION]: CENTER, + [CUSTOM_MARKER_LOCATION]: { lat: 40.7536, lng: -73.9832 }, + [ADDED_MARKER_LOCATION]: { lat: 40.758, lng: -73.9855 }, +}; +const PROVIDER_CONFIG = { + calculateLocation: (query: string): Promise => ( + Promise.resolve(MARKER_LOCATIONS[query]) + ), + tileServer: () => TILE_SERVER, +}; +const handleMarkerClick = fn(); +const DEFAULT_MARKER = { + location: DEFAULT_MARKER_LOCATION, + onClick: handleMarkerClick, +}; +const CUSTOM_MARKER = { + location: CUSTOM_MARKER_LOCATION, + iconSrc: 'images/maps/map-marker.png', + onClick: handleMarkerClick, +}; +const ADDED_MARKER = { + location: ADDED_MARKER_LOCATION, + onClick: handleMarkerClick, +}; +const STORY_STYLE: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: 8, + padding: 8, +}; +const TOOLBAR_STYLE: React.CSSProperties = { + display: 'flex', + gap: 8, +}; interface OsmStoryArgs { + autoAdjust: boolean; centerOnCentralPark: boolean; controls: boolean; disabled: boolean; @@ -61,6 +101,7 @@ const configureOpenLayersMap = ( }; const OsmMapStory = ({ + autoAdjust, centerOnCentralPark, controls, disabled, @@ -71,29 +112,68 @@ const OsmMapStory = ({ zoom, }: OsmMapStoryProps): React.ReactElement => { const mapRef = React.useRef(null); + const [markerAdded, setMarkerAdded] = React.useState(false); + const markers = React.useMemo(() => [DEFAULT_MARKER, CUSTOM_MARKER], []); const center = centerOnCentralPark ? CENTRAL_PARK_CENTER : CENTER; React.useEffect(() => { mapRef.current?.instance()?.option('center', center); }, [center]); + const addMarker = (): void => { + const map = mapRef.current?.instance(); + if (!map || markerAdded) { + return; + } + + setMarkerAdded(true); + void map.addMarker(ADDED_MARKER).then(undefined, () => setMarkerAdded(false)); + }; + + const removeMarker = (): void => { + const map = mapRef.current?.instance(); + if (!map || !markerAdded) { + return; + } + + setMarkerAdded(false); + void map.removeMarker(ADDED_MARKER).then(undefined, () => setMarkerAdded(true)); + }; + return ( - configureOpenLayersMap(event, center, zoom)} - onZoomChange={(value) => updateArgs({ zoom: value })} - /> +
+
+
+ configureOpenLayersMap(event, center, zoom)} + onZoomChange={(value) => updateArgs({ zoom: value })} + /> +
); }; @@ -109,6 +189,10 @@ const meta: Meta = { layout: 'fullscreen', }, argTypes: { + autoAdjust: { + control: 'boolean', + description: 'Automatically adjusts the map viewport when markers change.', + }, centerOnCentralPark: { control: 'boolean', description: 'Switches the map center between the default New York location and Central Park.', @@ -148,6 +232,7 @@ type Story = StoryObj; export const Default: Story = { args: { + autoAdjust: false, centerOnCentralPark: false, controls: true, disabled: false, diff --git a/packages/devextreme-scss/scss/widgets/base/_map.scss b/packages/devextreme-scss/scss/widgets/base/_map.scss index 0f61d97cd58d..04b6e856aab6 100644 --- a/packages/devextreme-scss/scss/widgets/base/_map.scss +++ b/packages/devextreme-scss/scss/widgets/base/_map.scss @@ -18,6 +18,22 @@ user-select: none; } +.dx-map-marker-default { + display: flex; + align-items: flex-end; + justify-content: center; + width: 44px; + height: 44px; +} + +.dx-map-marker-default-icon { + display: block; + overflow: visible; + filter: + drop-shadow(0 0 0.5px rgba(255, 255, 255, 0.85)) + drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.35)); +} + .dx-map-marker-tooltip { margin: 10px; } diff --git a/packages/devextreme-scss/scss/widgets/fluent/map/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent/map/_colors.scss new file mode 100644 index 000000000000..dff74c02caf4 --- /dev/null +++ b/packages/devextreme-scss/scss/widgets/fluent/map/_colors.scss @@ -0,0 +1,2 @@ +$map-marker-color: #0f6cbd !default; +$map-marker-accent-color: #fafafa !default; diff --git a/packages/devextreme-scss/scss/widgets/fluent/map/_index.scss b/packages/devextreme-scss/scss/widgets/fluent/map/_index.scss index 112b5a2f4acb..cf9069b40a88 100644 --- a/packages/devextreme-scss/scss/widgets/fluent/map/_index.scss +++ b/packages/devextreme-scss/scss/widgets/fluent/map/_index.scss @@ -1 +1,11 @@ +@use "colors" as *; @use "../../base/map"; + +.dx-map-marker-default-body { + fill: $map-marker-color; + stroke: $map-marker-accent-color; +} + +.dx-map-marker-default-center { + fill: $map-marker-accent-color; +} diff --git a/packages/devextreme-scss/scss/widgets/generic/map/_colors.scss b/packages/devextreme-scss/scss/widgets/generic/map/_colors.scss new file mode 100644 index 000000000000..33ec08f572f7 --- /dev/null +++ b/packages/devextreme-scss/scss/widgets/generic/map/_colors.scss @@ -0,0 +1,2 @@ +$map-marker-color: #337ab7 !default; +$map-marker-accent-color: #fff !default; diff --git a/packages/devextreme-scss/scss/widgets/generic/map/_index.scss b/packages/devextreme-scss/scss/widgets/generic/map/_index.scss index 112b5a2f4acb..cf9069b40a88 100644 --- a/packages/devextreme-scss/scss/widgets/generic/map/_index.scss +++ b/packages/devextreme-scss/scss/widgets/generic/map/_index.scss @@ -1 +1,11 @@ +@use "colors" as *; @use "../../base/map"; + +.dx-map-marker-default-body { + fill: $map-marker-color; + stroke: $map-marker-accent-color; +} + +.dx-map-marker-default-center { + fill: $map-marker-accent-color; +} diff --git a/packages/devextreme-scss/scss/widgets/material/map/_colors.scss b/packages/devextreme-scss/scss/widgets/material/map/_colors.scss new file mode 100644 index 000000000000..15323124a4e6 --- /dev/null +++ b/packages/devextreme-scss/scss/widgets/material/map/_colors.scss @@ -0,0 +1,2 @@ +$map-marker-color: #1564c0 !default; +$map-marker-accent-color: #fff !default; diff --git a/packages/devextreme-scss/scss/widgets/material/map/_index.scss b/packages/devextreme-scss/scss/widgets/material/map/_index.scss index 112b5a2f4acb..cf9069b40a88 100644 --- a/packages/devextreme-scss/scss/widgets/material/map/_index.scss +++ b/packages/devextreme-scss/scss/widgets/material/map/_index.scss @@ -1 +1,11 @@ +@use "colors" as *; @use "../../base/map"; + +.dx-map-marker-default-body { + fill: $map-marker-color; + stroke: $map-marker-accent-color; +} + +.dx-map-marker-default-center { + fill: $map-marker-accent-color; +} diff --git a/packages/devextreme/js/__internal/ui/map/map.ts b/packages/devextreme/js/__internal/ui/map/map.ts index 3d2adb2f057a..57e5ef0de457 100644 --- a/packages/devextreme/js/__internal/ui/map/map.ts +++ b/packages/devextreme/js/__internal/ui/map/map.ts @@ -369,9 +369,10 @@ class Map extends Widget { ): Promise { const markerAndRoutes = [markers, routes].filter(Boolean); const isActionSuppressed = this._suppressAsyncAction; + const provider = this._provider; this._lastAsyncAction = this._lastAsyncAction.then(() => { - if (!this._provider || isActionSuppressed) { + if (!provider || provider !== this._provider || isActionSuppressed) { /// #DEBUG this._asyncActionSuppressed = true; /// #ENDDEBUG @@ -379,7 +380,11 @@ class Map extends Widget { } // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return this._provider[name](...markerAndRoutes).then((result) => { + return provider[name](...markerAndRoutes).then((result) => { + if (provider !== this._provider || this._disposed) { + return undefined; + } + const arrayResult = wrapToArray(result); const mapRefreshed = arrayResult[0]; @@ -394,6 +399,12 @@ class Map extends Widget { // eslint-disable-next-line @typescript-eslint/no-unsafe-return return arrayResult[1]; + }, (error) => { + if (provider !== this._provider || this._disposed) { + return undefined; + } + + return Promise.reject(error); }); }); diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.azure.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.azure.ts index 4f4814a3ad4f..0ca4bef3a1fd 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.azure.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.azure.ts @@ -35,7 +35,7 @@ export type AzureLocation = [number, number]; // eslint-disable-next-line @typescript-eslint/init-declarations let azureMapsLoader; -class AzureProvider extends DynamicProvider { +class AzureProvider extends DynamicProvider { _preventZoomChangeEvent?: boolean; _mapReadyPromise!: Promise; @@ -75,7 +75,7 @@ class AzureProvider extends DynamicProvider { } else { // eslint-disable-next-line @typescript-eslint/no-floating-promises this._geocodeLocation(location as string).then((geocodedLocation) => { - resolve(geocodedLocation as AzureLocation); + resolve(geocodedLocation); }); } }); diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.bing.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.bing.ts index 685ff0214b46..a0b6b6a5ebb7 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.bing.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.bing.ts @@ -38,7 +38,7 @@ export interface BingLocation { // eslint-disable-next-line @typescript-eslint/init-declarations let msMapsLoader; -class BingProvider extends DynamicProvider { +class BingProvider extends DynamicProvider { _providerClickHandler?: (e: { targetType: string; location: BingLocation }) => void; _providerViewChangeHandler?: () => void; @@ -75,7 +75,7 @@ class BingProvider extends DynamicProvider { } else { // eslint-disable-next-line @typescript-eslint/no-floating-promises this._geocodeLocation(location as string).then((geocodedLocation) => { - resolve(geocodedLocation as BingLocation); + resolve(geocodedLocation); }); } }); diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.google.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.google.ts index c963174eaff8..273fc3ac4936 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.google.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.google.ts @@ -90,7 +90,7 @@ const googleMapsLoaded = (): boolean => Boolean(window.google?.maps); // eslint-disable-next-line @typescript-eslint/init-declarations let googleMapsLoader; -class GoogleProvider extends DynamicProvider { +class GoogleProvider extends DynamicProvider { _clickListener?: (e: { latLng: GoogleLocation; domEvent: Event }) => void; _preventZoomChangeEvent?: boolean; @@ -132,7 +132,7 @@ class GoogleProvider extends DynamicProvider { } else { // eslint-disable-next-line @typescript-eslint/no-floating-promises this._geocodeLocation(location as string).then((geocodedLocation) => { - resolve(geocodedLocation as GoogleLocation); + resolve(geocodedLocation); }); } }); 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 index eb011504792e..715f20da84ec 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.engine.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.engine.ts @@ -19,6 +19,10 @@ export interface MapEngineBounds { southWest: MapLocation; } +export interface MapEngineFitBoundsOptions { + includeMarkerPadding?: boolean; +} + export interface MapEngineViewState extends MapEngineSetViewOptions { bounds?: MapEngineBounds; } @@ -28,22 +32,43 @@ export interface MapEngineClickEvent { location: MapLocation; } +export interface MapEngineMarkerOptions { + html?: string; + htmlOffset?: { top: number; left: number }; + iconSrc?: string; + location: MapLocation; + onClick?: () => void; + rtlEnabled?: boolean; +} + +export interface MapEngineUpdateDimensionsResult { + needsViewportRefit: boolean; +} + +export interface MapEngineMarker { + readonly originalMarker: unknown; + dispose: () => void; +} + export interface MapEngineEventHandlers { click: (event: MapEngineClickEvent) => void; + markerSizeChange: () => void; viewChange: (view: MapEngineViewState) => void; } export interface MapEngineMap { readonly originalMap: unknown; + addMarker: (options: MapEngineMarkerOptions) => MapEngineMarker; attachHandlers: (handlers: MapEngineEventHandlers) => void; dispose: () => void; - fitBounds: (bounds: MapEngineBounds) => void; + fitBounds: (bounds: MapEngineBounds, options?: MapEngineFitBoundsOptions) => void; + getZoom: () => number | undefined; replaceTileLayer: (options: MapEngineTileLayerOptions) => void; setControls: (visible: boolean) => void; setDisabled: (disabled: boolean) => void; setFocus: (enabled: boolean, tabIndex: number) => void; setView: (options: MapEngineSetViewOptions) => void; - updateDimensions: () => void; + updateDimensions: () => MapEngineUpdateDimensionsResult; } export interface MapEngine { diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.marker.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.marker.ts new file mode 100644 index 000000000000..8713f22e6d61 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.marker.ts @@ -0,0 +1,106 @@ +import messageLocalization from '@js/common/core/localization/message'; + +import type { MapEngineMarkerOptions } from './provider.dynamic.osm.engine'; + +export const MARKER_FALLBACK_WIDTH = 25; +export const MARKER_FALLBACK_HEIGHT = 41; +// NOTE: Keep this value in sync with .dx-map-marker-default in widgets/base/_map.scss. +export const DEFAULT_MARKER_SIZE = 44; + +const MARKER_CLASS = 'dx-map-marker'; +export const DEFAULT_MARKER_CLASS = `${MARKER_CLASS}-default`; +const DEFAULT_MARKER_ICON_CLASS = `${DEFAULT_MARKER_CLASS}-icon`; +const DEFAULT_MARKER_BODY_CLASS = `${DEFAULT_MARKER_CLASS}-body`; +const DEFAULT_MARKER_CENTER_CLASS = `${DEFAULT_MARKER_CLASS}-center`; +const IMAGE_MARKER_CLASS = `${MARKER_CLASS}-image`; +const DEFAULT_MARKER_WIDTH = 24.5; +const DEFAULT_MARKER_HEIGHT = 36.5; +const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; +const DEFAULT_MARKER_VIEW_BOX = '5 2 14 20'; +const DEFAULT_MARKER_BODY_PATH = [ + 'M12 2c-3.85 0-7 3.176-7 7.059', + ' 0 5.294 7 12.941 7 12.941', + 's7-7.765 7-12.941', + 'c0-3.882-3.15-7.059-7-7.059z', +].join(''); +const DEFAULT_MARKER_CENTER_PATH = [ + 'M12 6.706c-1.283 0-2.333 1.059-2.333 2.353', + 's1.05 2.353 2.333 2.353', + ' 2.333-1.059 2.333-2.353', + 'c0-1.294-1.05-2.353-2.333-2.353z', +].join(''); + +export type MarkerKind = 'default' | 'html' | 'image'; + +export interface MarkerElementInfo { + element: HTMLElement; + kind: MarkerKind; + offset: number[]; + positioning: string; +} + +const createDefaultMarkerElement = ( + ownerDocument: Document, +): HTMLElement => { + const element = ownerDocument.createElement('div'); + const svg = ownerDocument.createElementNS(SVG_NAMESPACE, 'svg'); + const body = ownerDocument.createElementNS(SVG_NAMESPACE, 'path'); + const center = ownerDocument.createElementNS(SVG_NAMESPACE, 'path'); + + element.className = `${MARKER_CLASS} ${DEFAULT_MARKER_CLASS}`; + svg.setAttribute('class', DEFAULT_MARKER_ICON_CLASS); + svg.setAttribute('viewBox', DEFAULT_MARKER_VIEW_BOX); + svg.setAttribute('width', `${DEFAULT_MARKER_WIDTH}`); + svg.setAttribute('height', `${DEFAULT_MARKER_HEIGHT}`); + svg.setAttribute('aria-hidden', 'true'); + svg.setAttribute('focusable', 'false'); + body.setAttribute('class', DEFAULT_MARKER_BODY_CLASS); + body.setAttribute('d', DEFAULT_MARKER_BODY_PATH); + body.setAttribute('stroke-width', '0.5'); + center.setAttribute('class', DEFAULT_MARKER_CENTER_CLASS); + center.setAttribute('d', DEFAULT_MARKER_CENTER_PATH); + svg.appendChild(body); + svg.appendChild(center); + element.appendChild(svg); + + return element; +}; + +export const createMarkerElement = ( + ownerDocument: Document, + options: MapEngineMarkerOptions, +): MarkerElementInfo => { + if (options.html) { + const element = ownerDocument.createElement('div'); + element.innerHTML = options.html; + + return { + element, + kind: 'html', + offset: [options.htmlOffset?.left ?? 0, options.htmlOffset?.top ?? 0], + positioning: 'top-left', + }; + } + + if (options.iconSrc) { + const element = ownerDocument.createElement('img'); + element.className = `${MARKER_CLASS} ${IMAGE_MARKER_CLASS}`; + element.src = options.iconSrc; + element.alt = messageLocalization.format('dxMap-markerAriaLabel'); + element.draggable = false; + + return { + element, + kind: 'image', + offset: [0, 0], + positioning: 'bottom-center', + }; + } + + return { + element: createDefaultMarkerElement(ownerDocument), + kind: 'default', + offset: [0, 0], + positioning: 'bottom-center', + }; +}; 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 index c33e0d8cea10..3286d2d3539b 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts @@ -1,14 +1,29 @@ +import messageLocalization from '@js/common/core/localization/message'; +import resizeObserverSingleton from '@js/core/resize_observer'; +import { ALL_FOCUSABLE_ELEMENTS_SELECTOR } from '@ts/core/utils/m_selectors'; + import type { MapEngine, MapEngineBounds, MapEngineClickEvent, MapEngineEventHandlers, + MapEngineFitBoundsOptions, MapEngineMap, + MapEngineMarker, + MapEngineMarkerOptions, MapEngineSetViewOptions, MapEngineTileLayerOptions, + MapEngineUpdateDimensionsResult, MapEngineViewState, } from './provider.dynamic.osm.engine'; import { SUBDOMAIN_PLACEHOLDER } from './provider.dynamic.osm.engine'; +import { + createMarkerElement, + DEFAULT_MARKER_SIZE, + MARKER_FALLBACK_HEIGHT, + MARKER_FALLBACK_WIDTH, + type MarkerKind, +} from './provider.dynamic.osm.openlayers.marker'; import type { ControlLike, Coordinate, @@ -17,7 +32,9 @@ import type { MapLike, OpenLayersApi, Options, + OverlayLike, TileLayerLike, + ViewLike, } from './provider.dynamic.osm.openlayers.utils'; import { areCoordinatesEqual, @@ -35,6 +52,28 @@ interface MapBrowserEventLike { originalEvent?: Event; } +const KEY_RELEASE_EVENT = 'keyup'; + +interface MarkerFocusTarget { + element: HTMLElement; + tabIndex: string | null; +} + +interface MarkerElementBinding { + focusTargets: MarkerFocusTarget[]; + detach: () => void; +} + +interface OpenLayersMarker extends MapEngineMarker { + element: HTMLElement; + focusTargets: MarkerFocusTarget[]; + kind: MarkerKind; + location: MapEngineMarkerOptions['location']; + offset: number[]; + overlay: OverlayLike; + positioning: string; +} + class OpenLayersMap implements MapEngineMap { readonly originalMap: MapLike; @@ -54,15 +93,26 @@ class OpenLayersMap implements MapEngineMap { private readonly _interactionStates = new Map(); + private readonly _markers = new Set(); + private _eventHandlers?: { click: (event: unknown) => void; + markerSizeChange: () => void; moveEnd: (event: unknown) => void; + userKeyDown: (event: Event) => void; + userViewChange: () => void; }; private _tileLayer?: TileLayerLike; private _disposed = false; + private _markerFitNeedsLayout = false; + + private _markerSizeRefitEnabled = true; + + private _subscribedView: ViewLike; + constructor( private readonly _api: OpenLayersApi, container: Element, @@ -90,18 +140,305 @@ class OpenLayersMap implements MapEngineMap { zoom: view.zoom ?? 1, }), }); + this.originalMap.getOverlayContainer().setAttribute('dir', 'ltr'); + this.originalMap.getOverlayContainerStopEvent().setAttribute('dir', 'ltr'); + this._subscribedView = this.originalMap.getView(); + this._subscribedView.on('change:center', this._viewCenterChangeHandler); + this.originalMap.on('change:view', this._viewChangeHandler); this._zoomControl = new _api.control.Zoom(); } + private readonly _viewCenterChangeHandler = (): void => { + this._syncMarkerPositions(); + }; + + private readonly _viewChangeHandler = (): void => { + const view = this.originalMap.getView(); + + if (view === this._subscribedView) { + return; + } + + this._subscribedView.un('change:center', this._viewCenterChangeHandler); + this._subscribedView = view; + this._subscribedView.on('change:center', this._viewCenterChangeHandler); + this._syncMarkerPositions(); + this._syncMarkerTabIndexes(); + }; + attachHandlers(handlers: MapEngineEventHandlers): void { this._detachHandlers(); this._initHandlers(handlers); } + private _attachMarkerElementHandlers( + element: HTMLElement, + onClick: MapEngineMarkerOptions['onClick'], + ): MarkerElementBinding { + const keyboardInteractive = Boolean(onClick) + && !element.querySelector(ALL_FOCUSABLE_ELEMENTS_SELECTOR); + const focusTargets: MarkerFocusTarget[] = keyboardInteractive + ? [{ element, tabIndex: '0' }] + : Array.from(element.querySelectorAll(ALL_FOCUSABLE_ELEMENTS_SELECTOR)) + .map((focusTarget) => ({ + element: focusTarget, + tabIndex: focusTarget.getAttribute('tabindex'), + })); + const clickHandler: EventListener | undefined = onClick + ? (event): void => { + event.stopPropagation(); + onClick(); + } + : undefined; + const keydownHandler: EventListener | undefined = focusTargets.length + ? (event): void => { + event.stopPropagation(); + + if (!keyboardInteractive) { + return; + } + + const keyboardEvent = event as KeyboardEvent; + if (keyboardEvent.key !== 'Enter' && keyboardEvent.key !== ' ') { + return; + } + + event.preventDefault(); + if (keyboardEvent.key === 'Enter' && !keyboardEvent.repeat) { + element.click(); + } + } + : undefined; + const keyReleaseHandler: EventListener | undefined = keyboardInteractive + ? (event): void => { + const keyboardEvent = event as KeyboardEvent; + if (keyboardEvent.key !== ' ') { + return; + } + + event.preventDefault(); + event.stopPropagation(); + element.click(); + } + : undefined; + let { height, width } = element.getBoundingClientRect(); + const resizeHandler = (): void => { + const rect = element.getBoundingClientRect(); + if (rect.height === height && rect.width === width) { + return; + } + + height = rect.height; + width = rect.width; + if (this._markerSizeRefitEnabled) { + this._eventHandlers?.markerSizeChange(); + } + }; + + if (clickHandler) { + element.addEventListener('click', clickHandler); + } + if (keyboardInteractive) { + element.setAttribute('role', 'button'); + if (element.tagName !== 'IMG' + && !element.textContent?.trim() + && !element.getAttribute('alt')) { + element.setAttribute( + 'aria-label', + messageLocalization.format('dxMap-markerAriaLabel'), + ); + } + } + if (keydownHandler) { + element.addEventListener('keydown', keydownHandler); + } + if (keyReleaseHandler) { + element.addEventListener(KEY_RELEASE_EVENT, keyReleaseHandler); + } + resizeObserverSingleton.observe(element, resizeHandler); + + return { + focusTargets, + detach: (): void => { + if (clickHandler) { + element.removeEventListener('click', clickHandler); + } + if (keydownHandler) { + element.removeEventListener('keydown', keydownHandler); + } + if (keyReleaseHandler) { + element.removeEventListener(KEY_RELEASE_EVENT, keyReleaseHandler); + } + resizeObserverSingleton.unobserve(element); + }, + }; + } + + addMarker(options: MapEngineMarkerOptions): MapEngineMarker { + const { + element, kind, offset, positioning, + } = createMarkerElement( + this._container.ownerDocument, + options, + ); + element.setAttribute('dir', options.rtlEnabled ? 'rtl' : 'ltr'); + const marker = new this._api.Overlay({ + element, + insertFirst: false, + offset, + position: this._getMarkerPosition(options.location), + positioning, + stopEvent: false, + }); + this.originalMap.addOverlay(marker); + const markerElementBinding = this._attachMarkerElementHandlers(element, options.onClick); + this._markerSizeRefitEnabled = true; + + let disposed = false; + const handle: OpenLayersMarker = { + element, + focusTargets: markerElementBinding.focusTargets, + kind, + location: { ...options.location }, + offset, + overlay: marker, + positioning, + originalMarker: marker, + dispose: (): void => { + if (disposed) { + return; + } + + disposed = true; + markerElementBinding.detach(); + this.originalMap.removeOverlay(marker); + this._markers.delete(handle); + }, + }; + + this._markers.add(handle); + this._syncMarkerTabIndex(handle); + + return handle; + } + + private _getMarkerPosition(location: MapEngineMarkerOptions['location']): Coordinate { + const view = this.originalMap.getView(); + const projection = view.getProjection(); + const coordinate = toCoordinate(this._api, location, projection); + const center = view.getCenter(); + + if (!center) { + return coordinate; + } + + const west = toCoordinate(this._api, { lat: 0, lng: -180 }, projection)[0]; + const east = toCoordinate(this._api, { lat: 0, lng: 180 }, projection)[0]; + const worldWidth = Math.abs(east - west); + + if (Number.isFinite(worldWidth) && worldWidth > 0) { + coordinate[0] += Math.round((center[0] - coordinate[0]) / worldWidth) * worldWidth; + } + + return coordinate; + } + + private _syncMarkerPositions(): void { + this._markers.forEach((marker) => { + const position = this._getMarkerPosition(marker.location); + if (!areCoordinatesEqual(marker.overlay.getPosition(), position)) { + marker.overlay.setPosition(position); + } + }); + } + + private _syncMarkerTabIndex(marker: OpenLayersMarker, viewExtent?: Extent): void { + const extent = viewExtent ?? this.originalMap.getView().calculateExtent(); + const isVisible = this._isMarkerVisible(marker, extent); + + marker.focusTargets.forEach(({ element, tabIndex }) => { + if (!this._focusEnabled || this._disabled || !isVisible) { + element.setAttribute('tabindex', '-1'); + } else if (tabIndex === null) { + element.removeAttribute('tabindex'); + } else { + element.setAttribute('tabindex', tabIndex); + } + }); + + if (this._focusEnabled && !this._disabled && !isVisible) { + this._moveMarkerFocusToMap(marker); + } + } + + private _syncMarkerTabIndexes(): void { + const viewExtent = this.originalMap.getView().calculateExtent(); + + this._markers.forEach((marker) => this._syncMarkerTabIndex(marker, viewExtent)); + } + + private _isMarkerVisible(marker: OpenLayersMarker, viewExtent: Extent): boolean { + const position = marker.overlay.getPosition(); + if (!position) { + return false; + } + + const [minX, minY, maxX, maxY] = viewExtent; + const [x, y] = position; + + return x >= minX + && x <= maxX + && y >= minY + && y <= maxY; + } + + private _moveMarkerFocusToMap(marker: OpenLayersMarker): void { + const markerRoot = marker.element.getRootNode() as Document | ShadowRoot; + const { activeElement } = markerRoot; + const markerHasFocus = marker.focusTargets.some(({ element }) => element === activeElement); + const container = this._container as HTMLElement; + + if (markerHasFocus && typeof container.focus === 'function') { + container.focus({ preventScroll: true }); + } + } + + private _getMarkerFitPadding(): { padding: number[]; needsLayout: boolean } { + const padding = [0, 0, 0, 0]; + let needsLayout = false; + + this._markers.forEach(({ + element, kind, offset, positioning, + }) => { + const isDefault = kind === 'default'; + const rect = isDefault ? undefined : element.getBoundingClientRect(); + needsLayout ||= !isDefault && (!rect?.width || !rect.height); + const defaultSize = isDefault + ? DEFAULT_MARKER_SIZE + : undefined; + const width = Math.max(rect?.width ?? 0, defaultSize ?? MARKER_FALLBACK_WIDTH); + const height = Math.max(rect?.height ?? 0, defaultSize ?? MARKER_FALLBACK_HEIGHT); + let left = offset[0]; + let top = offset[1]; + + if (positioning === 'bottom-center') { + left -= width / 2; + top -= height; + } + + padding[0] = Math.max(padding[0], Math.ceil(Math.max(0, -top))); + padding[1] = Math.max(padding[1], Math.ceil(Math.max(0, left + width))); + padding[2] = Math.max(padding[2], Math.ceil(Math.max(0, top + height))); + padding[3] = Math.max(padding[3], Math.ceil(Math.max(0, -left))); + }); + + return { needsLayout, padding }; + } + private _initHandlers(handlers: MapEngineEventHandlers): void { const click = (event: unknown): void => { const { coordinate, originalEvent } = event as MapBrowserEventLike; - if (!coordinate) { + if (!coordinate || this._isMarkerEvent(originalEvent)) { return; } @@ -118,12 +455,42 @@ class OpenLayersMap implements MapEngineMap { handlers.click(clickEvent); }; const moveEnd = (): void => { + this._syncMarkerTabIndexes(); handlers.viewChange(this._getViewState()); }; + const userViewChange = (): void => { + this._markerSizeRefitEnabled = false; + }; + const userKeyDown = (event: Event): void => { + const { key } = event as KeyboardEvent; + if (['+', '-', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'Enter', ' '].includes(key)) { + userViewChange(); + } + }; - this._eventHandlers = { click, moveEnd }; + this._eventHandlers = { + click, + markerSizeChange: handlers.markerSizeChange, + moveEnd, + userKeyDown, + userViewChange, + }; this.originalMap.on('click', click); this.originalMap.on('moveend', moveEnd); + this._container.addEventListener('pointerdown', userViewChange); + this._container.addEventListener('keydown', userKeyDown); + this._container.addEventListener('wheel', userViewChange); + } + + private _isMarkerEvent(event?: Event): boolean { + const eventTarget = event?.target; + const NodeConstructor = this._container.ownerDocument.defaultView?.Node; + + if (!eventTarget || !NodeConstructor || !(eventTarget instanceof NodeConstructor)) { + return false; + } + + return [...this._markers].some(({ element }) => element.contains(eventTarget)); } private _detachHandlers(): void { @@ -133,6 +500,9 @@ class OpenLayersMap implements MapEngineMap { this.originalMap.un('click', this._eventHandlers.click); this.originalMap.un('moveend', this._eventHandlers.moveEnd); + this._container.removeEventListener('pointerdown', this._eventHandlers.userViewChange); + this._container.removeEventListener('keydown', this._eventHandlers.userKeyDown); + this._container.removeEventListener('wheel', this._eventHandlers.userViewChange); this._eventHandlers = undefined; } @@ -166,7 +536,10 @@ class OpenLayersMap implements MapEngineMap { this._disposed = true; this._detachHandlers(); + this.originalMap.un('change:view', this._viewChangeHandler); + this._subscribedView.un('change:center', this._viewCenterChangeHandler); this._removeOwnedInert(); + [...this._markers].forEach((marker) => marker.dispose()); this.setControls(false); if (this._tileLayer) { this.originalMap.removeLayer(this._tileLayer); @@ -176,7 +549,8 @@ class OpenLayersMap implements MapEngineMap { this._container.removeAttribute('tabindex'); } - fitBounds(bounds: MapEngineBounds): void { + fitBounds(bounds: MapEngineBounds, options?: MapEngineFitBoundsOptions): void { + this._markerSizeRefitEnabled = Boolean(options?.includeMarkerPadding); const west = bounds.southWest.lng; const east = bounds.northEast.lng < west ? bounds.northEast.lng + 360 @@ -193,7 +567,16 @@ class OpenLayersMap implements MapEngineMap { GEOGRAPHIC_PROJECTION, getCoordinateProjection(this._api, view.getProjection()), ); - view.fit(extent); + const markerFit = options?.includeMarkerPadding + ? this._getMarkerFitPadding() + : undefined; + this._markerFitNeedsLayout = Boolean(markerFit?.needsLayout); + view.fit(extent, markerFit ? { padding: markerFit.padding } : undefined); + this._syncMarkerTabIndexes(); + } + + getZoom(): number | undefined { + return this.originalMap.getView().getZoom(); } replaceTileLayer(options: MapEngineTileLayerOptions): void { @@ -291,6 +674,7 @@ class OpenLayersMap implements MapEngineMap { } else { this._container.removeAttribute('tabindex'); } + this._markers.forEach((marker) => this._syncMarkerTabIndex(marker)); } setView(options: MapEngineSetViewOptions): void { @@ -305,10 +689,17 @@ class OpenLayersMap implements MapEngineMap { if (options.zoom !== undefined && view.getZoom() !== options.zoom) { view.setZoom(options.zoom); } + + this._syncMarkerTabIndexes(); } - updateDimensions(): void { + updateDimensions(): MapEngineUpdateDimensionsResult { this.originalMap.updateSize(); + this._syncMarkerTabIndexes(); + const needsViewportAdjustment = this._markerFitNeedsLayout; + this._markerFitNeedsLayout = false; + + return { needsViewportRefit: needsViewportAdjustment }; } } diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.test.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.test.ts index d817293727c6..efbd952ccc0a 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.test.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.test.ts @@ -13,6 +13,7 @@ import { const createApi = (): unknown => ({ Map: jest.fn(), + Overlay: jest.fn(), View: jest.fn(), control: { Zoom: jest.fn(), @@ -40,7 +41,7 @@ describe('OpenLayers utils', () => { it('returns false when a required API member is missing', () => { const api = createApi() as Record; - delete api.View; + delete api.Overlay; expect(isOpenLayersApi(api)).toBe(false); }); diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.ts index dabd818ceecf..12b9fa2ec38d 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.ts @@ -23,26 +23,37 @@ export interface CollectionLike { export interface ViewLike { calculateExtent: () => Extent; - fit: (extent: Extent) => void; + fit: (extent: Extent, options?: Options) => void; getCenter: () => Coordinate | undefined; getProjection: () => unknown; getZoom: () => number | undefined; + on: (type: string, listener: () => void) => void; setCenter: (center: Coordinate) => void; setZoom: (zoom: number) => void; + un: (type: string, listener: () => void) => void; } export interface TileLayerLike { setSource: (source: unknown) => void; } +export interface OverlayLike { + getPosition: () => Coordinate | undefined; + setPosition: (position: Coordinate) => void; +} + export interface MapLike { addControl: (control: ControlLike) => void; addLayer: (layer: unknown) => void; + addOverlay: (overlay: unknown) => void; getInteractions: () => CollectionLike; + getOverlayContainer: () => HTMLElement; + getOverlayContainerStopEvent: () => HTMLElement; getView: () => ViewLike; on: (type: string, listener: (event: unknown) => void) => void; removeControl: (control: ControlLike) => void; removeLayer: (layer: unknown) => void; + removeOverlay: (overlay: unknown) => void; setTarget: (target?: Element) => void; un: (type: string, listener: (event: unknown) => void) => void; updateSize: () => void; @@ -50,6 +61,7 @@ export interface MapLike { export interface OpenLayersApi { Map: new (options: Options) => MapLike; + Overlay: new (options: Options) => OverlayLike; View: new (options: Options) => ViewLike; control: { Zoom: new () => ControlLike; @@ -90,6 +102,7 @@ export const isOpenLayersApi = (api: unknown): api is OpenLayersApi => { } return typeof api.Map === 'function' + && typeof api.Overlay === 'function' && typeof api.View === 'function' && isRecord(api.control) && hasFunction(api.control, 'Zoom') diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.test.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.test.ts new file mode 100644 index 000000000000..9c0bcd76e22e --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from '@jest/globals'; + +import { createBounds, normalizeLongitude } from './provider.dynamic.osm'; + +describe('OSM bounds', () => { + describe('normalizeLongitude', () => { + it.each([-180, -74.25, 0, 120.5, 180])('preserves longitude %s inside the geographic range', (longitude) => { + expect(normalizeLongitude(longitude)).toBe(longitude); + }); + + it.each([ + [181, -179], + [-181, 179], + [360, 0], + [-360, 0], + [540, -180], + [-540, -180], + [1081, 1], + [-1081, -1], + [720.25, 0.25], + [-720.25, -0.25], + ])('wraps longitude %s to %s', (longitude, expected) => { + expect(normalizeLongitude(longitude)).toBe(expected); + }); + }); + + describe('createBounds', () => { + it('returns no bounds for an empty location list', () => { + expect(createBounds([])).toBeUndefined(); + }); + + it.each([-74, 286])('creates zero-sized bounds for a single location at longitude %s', (lng) => { + expect(createBounds([{ lat: 40.7, lng }])).toEqual({ + northEast: { lat: 40.7, lng: -74 }, + southWest: { lat: 40.7, lng: -74 }, + }); + }); + + it('finds latitude and longitude limits in an unordered location list', () => { + expect(createBounds([ + { lat: 40.7, lng: -74 }, + { lat: -12, lng: 20 }, + { lat: 52, lng: 15 }, + ])).toEqual({ + northEast: { lat: 52, lng: 20 }, + southWest: { lat: -12, lng: -74 }, + }); + }); + + it('uses the shorter interval across the antimeridian', () => { + expect(createBounds([ + { lat: 10, lng: 179 }, + { lat: 20, lng: -179 }, + { lat: 15, lng: 178 }, + ])).toEqual({ + northEast: { lat: 20, lng: -179 }, + southWest: { lat: 10, lng: 178 }, + }); + }); + + it('normalizes wrapped locations before selecting the shorter interval', () => { + expect(createBounds([ + { lat: 10, lng: 539 }, + { lat: 20, lng: -539 }, + ])).toEqual({ + northEast: { lat: 20, lng: -179 }, + southWest: { lat: 10, lng: 179 }, + }); + }); + + it('does not expand bounds for duplicate locations in different world copies', () => { + expect(createBounds([ + { lat: 40.7, lng: -74 }, + { lat: 40.7, lng: 286 }, + { lat: 40.7, lng: -434 }, + ])).toEqual({ + northEast: { lat: 40.7, lng: -74 }, + southWest: { lat: 40.7, lng: -74 }, + }); + }); + + it('treats both antimeridian endpoints as the same longitude', () => { + expect(createBounds([ + { lat: 10, lng: -180 }, + { lat: 20, lng: 180 }, + ])).toEqual({ + northEast: { lat: 20, lng: -180 }, + southWest: { lat: 10, lng: 180 }, + }); + }); + + it.each([ + [[-120, 0, 120]], + [[0, 120, -120]], + [[120, -120, 0]], + ])('selects a minimal interval for evenly spaced longitudes %j', (longitudes) => { + const bounds = createBounds(longitudes.map((lng) => ({ lat: 0, lng }))); + + expect([ + { northEast: { lat: 0, lng: -120 }, southWest: { lat: 0, lng: 0 } }, + { northEast: { lat: 0, lng: 0 }, southWest: { lat: 0, lng: 120 } }, + { northEast: { lat: 0, lng: 120 }, southWest: { lat: 0, lng: -120 } }, + ]).toContainEqual(bounds); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts index 16752dd3fee6..a1b5394ce924 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts @@ -9,6 +9,7 @@ import errors from '@js/ui/widget/ui.errors'; import type { LocationOption, + MarkerObject, MarkerOptions, RouteOptions, } from './provider.dynamic'; @@ -18,6 +19,7 @@ import type { MapEngineBounds, MapEngineClickEvent, MapEngineMap, + MapEngineMarker, MapEngineTileLayerOptions, MapEngineViewState, } from './provider.dynamic.osm.engine'; @@ -30,6 +32,82 @@ import { createOpenLayersEngine } from './provider.dynamic.osm.openlayers'; const DEFAULT_MAX_ZOOM = 19; const DEFAULT_SUBDOMAINS = 'abc'; const LOCATION_EPSILON = 1e-10; +const FULL_CIRCLE_DEGREES = 360; +const HALF_CIRCLE_DEGREES = 180; + +const getDefaultLocation = (): MapLocation => ({ lat: 0, lng: 0 }); + +const getLocationOptionKey = (location?: LocationOption | null): string => { + if (typeof location === 'string') { + return `string:${location}`; + } + if (Array.isArray(location)) { + return `array:${location[0]},${location[1]}`; + } + if (location) { + return `object:${location.lat},${location.lng}`; + } + + return String(location); +}; + +export const normalizeLongitude = (longitude: number): number => { + if (longitude >= -HALF_CIRCLE_DEGREES && longitude <= HALF_CIRCLE_DEGREES) { + return longitude; + } + + const shifted = longitude + HALF_CIRCLE_DEGREES; + const positive = (shifted % FULL_CIRCLE_DEGREES) + FULL_CIRCLE_DEGREES; + + return (positive % FULL_CIRCLE_DEGREES) - HALF_CIRCLE_DEGREES; +}; + +export const createBounds = (locations: MapLocation[]): MapEngineBounds | undefined => { + if (!locations.length) { + return undefined; + } + + const longitudes: number[] = []; + let north = locations[0].lat; + let south = locations[0].lat; + + locations.forEach(({ lat, lng }) => { + longitudes.push(normalizeLongitude(lng)); + north = Math.max(north, lat); + south = Math.min(south, lat); + }); + longitudes.sort((first, second) => first - second); + let largestGap = -1; + let westIndex = 0; + + longitudes.forEach((longitude, index) => { + const nextLongitude = index === longitudes.length - 1 + ? longitudes[0] + FULL_CIRCLE_DEGREES + : longitudes[index + 1]; + const gap = nextLongitude - longitude; + + if (gap > largestGap) { + largestGap = gap; + westIndex = (index + 1) % longitudes.length; + } + }); + + return { + northEast: { + lat: north, + lng: longitudes[(westIndex + longitudes.length - 1) % longitudes.length], + }, + southWest: { + lat: south, + lng: longitudes[westIndex], + }, + }; +}; + +interface EngineMarkerObject extends MarkerObject { + engineMarker: MapEngineMarker; + location: MapLocation; +} const areLocationsEqual = ( first: MapLocation | null, @@ -38,13 +116,17 @@ const areLocationsEqual = ( && Math.abs(first.lat - second.lat) < LOCATION_EPSILON && Math.abs(first.lng - second.lng) < LOCATION_EPSILON; -class OsmProvider extends DynamicProvider { +class OsmProvider extends DynamicProvider { _engine?: MapEngine; _engineMap?: MapEngineMap; _currentTileType?: MapType; + _boundLocations: MapLocation[] = []; + + _calculateLocationWarningLogged = false; + _loadImpl(): Promise { const window = getWindow() as Window & { ol?: unknown }; const engine = getRegisteredMapEngine() ?? createOpenLayersEngine(window.ol); @@ -59,8 +141,14 @@ class OsmProvider extends DynamicProvider { } _init(): Promise { + const optionCenter = this._getLatLng(this._option('center')); + const center = optionCenter + && Number.isFinite(optionCenter.lat) + && Number.isFinite(optionCenter.lng) + ? optionCenter + : getDefaultLocation(); const engineMap = this._engine?.createMap(this._$container[0], { - center: this._resolveLocation(this._option('center')), + center, zoom: this._option('zoom') ?? 1, }); @@ -119,13 +207,61 @@ class OsmProvider extends DynamicProvider { return result; } - _resolveLocation(location?: LocationOption | null): MapLocation { - return this._getLatLng(location) ?? { lat: 0, lng: 0 }; + _resolveLocation(location?: LocationOption | null): Promise { + const resolvedLocation = this._getLatLng(location); + + if (resolvedLocation + && Number.isFinite(resolvedLocation.lat) + && Number.isFinite(resolvedLocation.lng)) { + return Promise.resolve(resolvedLocation); + } + + return typeof location === 'string' + ? this._calculateLocation(location) + : Promise.resolve(getDefaultLocation()); + } + + _calculateLocation(query: string): Promise { + return this._geocodeLocation(query).then((location) => location ?? getDefaultLocation()); + } + + _geocodeLocationImpl(query: string): Promise { + const calculateLocation = this._option('providerConfig')?.calculateLocation; + if (!calculateLocation) { + if (!this._calculateLocationWarningLogged) { + errors.log('W1031'); + this._calculateLocationWarningLogged = true; + } + + return Promise.resolve(undefined); + } + + return Promise.resolve() + .then(() => calculateLocation(query)) + .then((location) => { + if (location + && Number.isFinite(location.lat) + && Number.isFinite(location.lng)) { + return { lat: location.lat, lng: location.lng }; + } + + errors.log('W1006', 'calculateLocation returned an invalid result.'); + + return undefined; + }, (error) => { + errors.log('W1006', error); + + return undefined; + }); } _attachHandlers(): void { this._engineMap?.attachHandlers({ click: (event) => this._clickActionHandler(event), + markerSizeChange: () => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this._fitBounds(); + }, viewChange: (view) => this._viewChangeHandler(view), }); } @@ -157,7 +293,10 @@ class OsmProvider extends DynamicProvider { } updateDimensions(): Promise { - this._engineMap?.updateDimensions(); + const result = this._engineMap?.updateDimensions(); + if (result?.needsViewportRefit) { + return this._fitBounds(); + } return Promise.resolve(); } @@ -186,11 +325,19 @@ class OsmProvider extends DynamicProvider { } updateCenter(): Promise { - this._engineMap?.setView({ - center: this._resolveLocation(this._option('center')), - }); + const engineMap = this._engineMap; + const centerOption = this._option('center'); + const centerOptionKey = getLocationOptionKey(centerOption); + + return this._resolveLocation(centerOption).then((center) => { + if (engineMap !== this._engineMap + || centerOptionKey !== getLocationOptionKey(this._option('center'))) { + return; + } - return Promise.resolve(); + engineMap?.setView({ center }); + this._option('center', center); + }); } updateZoom(): Promise { @@ -215,16 +362,28 @@ class OsmProvider extends DynamicProvider { } updateBounds(): Promise { - const bounds = this._option('bounds'); - const northEast = this._getLatLng(bounds?.northEast); - const southWest = this._getLatLng(bounds?.southWest); - - if (northEast && southWest) { - const engineBounds: MapEngineBounds = { northEast, southWest }; - this._engineMap?.fitBounds(engineBounds); + if (!this._areBoundsSet()) { + return Promise.resolve(); } - return Promise.resolve(); + const bounds = this._option('bounds'); + const engineMap = this._engineMap; + const northEastOption = bounds?.northEast; + const southWestOption = bounds?.southWest; + const northEastOptionKey = getLocationOptionKey(northEastOption); + const southWestOptionKey = getLocationOptionKey(southWestOption); + + return Promise.all([ + this._resolveLocation(northEastOption), + this._resolveLocation(southWestOption), + ]).then(([northEast, southWest]) => { + const currentBounds = this._option('bounds'); + if (engineMap === this._engineMap + && northEastOptionKey === getLocationOptionKey(currentBounds?.northEast) + && southWestOptionKey === getLocationOptionKey(currentBounds?.southWest)) { + engineMap?.fitBounds({ northEast, southWest }); + } + }); } updateControls(): Promise { @@ -234,11 +393,86 @@ class OsmProvider extends DynamicProvider { } adjustViewport(): Promise { + return this._fitBounds(); + } + + _renderMarker(options: MarkerOptions): Promise { + const engineMap = this._engineMap; + if (!engineMap) { + return Promise.reject(errors.Error('E1069')); + } + + return this._resolveLocation(options.location).then((location) => { + if (engineMap !== this._engineMap) { + return Promise.reject(new Error('The map was disposed or replaced during marker creation.')); + } + + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const iconSrc = options.iconSrc || this._option('markerIconSrc'); + const markerClickAction = options.onClick + ? this._mapWidget._createAction(options.onClick) + : undefined; + const engineMarker = engineMap.addMarker({ + html: options.html, + htmlOffset: options.htmlOffset, + iconSrc, + location, + onClick: markerClickAction + ? (): void => markerClickAction({ location }) + : undefined, + rtlEnabled: Boolean(this._option('rtlEnabled')), + }); + + return { + engineMarker, + location, + marker: engineMarker.originalMarker, + }; + }); + } + + _destroyMarker(marker: EngineMarkerObject): void { + marker.engineMarker.dispose(); + } + + _fitBounds(): Promise { + this._updateBounds(); + this._bounds = createBounds(this._boundLocations) ?? null; + + const engineMap = this._engineMap; + if (!engineMap || !this._bounds || !this._option('autoAdjust')) { + return Promise.resolve(); + } + + const zoomBeforeFitting = engineMap.getZoom(); + engineMap.fitBounds(this._bounds as MapEngineBounds, { includeMarkerPadding: true }); + const zoomAfterFitting = engineMap.getZoom(); + + if (zoomBeforeFitting !== undefined && zoomAfterFitting !== undefined) { + if (zoomBeforeFitting < zoomAfterFitting) { + engineMap.setView({ zoom: zoomBeforeFitting }); + } else { + this._option('zoom', zoomAfterFitting); + } + } + return Promise.resolve(); } - addMarkers(markers: MarkerOptions[]): Promise<[boolean, unknown[]]> { - return Promise.resolve([false, markers.map(() => undefined)]); + _extendBounds(location: unknown): void { + const resolvedLocation = this._getLatLng(location as LocationOption); + if (!resolvedLocation + || !Number.isFinite(resolvedLocation.lat) + || !Number.isFinite(resolvedLocation.lng)) { + return; + } + + this._boundLocations.push(resolvedLocation); + } + + _clearBounds(): void { + super._clearBounds(); + this._boundLocations = []; } addRoutes(routes: RouteOptions[]): Promise<[boolean, unknown[]]> { @@ -246,6 +480,9 @@ class OsmProvider extends DynamicProvider { } clean(): Promise { + if (this._engineMap) { + this._clearMarkers(); + } this._engineMap?.dispose(); this._engineMap = undefined; this._engine = undefined; diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.ts index f873b4043a2f..053bb0560824 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.ts @@ -1,11 +1,9 @@ import Class from '@js/core/class'; -import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import { extend } from '@js/core/utils/extend'; import type { DxEvent } from '@js/events'; import type { RouteMode } from '@js/ui/map'; -import type Map from './map'; import Provider from './provider'; import type { AzureLocation } from './provider.dynamic.azure'; import type { BingLocation } from './provider.dynamic.bing'; @@ -56,7 +54,7 @@ export interface RouteObject { southWest?: [number, number]; } -class DynamicProvider extends Provider { +class DynamicProvider extends Provider { // eslint-disable-next-line @typescript-eslint/no-explicit-any _bounds?: any; @@ -64,33 +62,47 @@ class DynamicProvider extends Provider { _routes!: (RouteObject & { options: RouteOptions })[]; - _geocodedLocations: Record; + _geocodedLocations = new Map(); + + _pendingGeocodedLocations = new Map>(); _mapsLoader?: Promise; - constructor(map: Map, $container: dxElementWrapper) { - super(map, $container); + _geocodeLocation( + location: string, + ): Promise { + if (this._geocodedLocations.has(location)) { + return Promise.resolve(this._geocodedLocations.get(location) as TLocation); + } + + const pendingLocation = this._pendingGeocodedLocations.get(location); + if (pendingLocation) { + return pendingLocation; + } + + const geocoding = this._geocodeLocationImpl(location).then( + (geocodedLocation) => { + this._pendingGeocodedLocations.delete(location); + if (this._isGeocodedLocationValid(geocodedLocation)) { + this._geocodedLocations.set(location, geocodedLocation); + } - this._geocodedLocations = {}; + return geocodedLocation; + }, + (error) => { + this._pendingGeocodedLocations.delete(location); + + return Promise.reject(error); + }, + ); + + this._pendingGeocodedLocations.set(location, geocoding); + + return geocoding; } - _geocodeLocation( - location: string, - ): Promise { - return new Promise((resolve) => { - const cache = this._geocodedLocations; - const cachedLocation = cache[location]; - if (cachedLocation) { - resolve(cachedLocation); - } else { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this._geocodeLocationImpl(location) - .then((geocodedLocation) => { - cache[location] = geocodedLocation; - resolve(geocodedLocation); - }); - } - }); + _isGeocodedLocationValid(location: TLocation): boolean { + return location !== undefined && location !== null; } _renderImpl(): Promise { @@ -282,8 +294,8 @@ class DynamicProvider extends Provider { _geocodeLocationImpl( // eslint-disable-next-line @typescript-eslint/no-unused-vars location: string, - ): Promise { - return Promise.resolve([0, 0]); + ): Promise { + return Promise.resolve([0, 0] as TLocation); } _clearRoutes(): void { diff --git a/packages/devextreme/js/localization/messages/ar.json b/packages/devextreme/js/localization/messages/ar.json index 5481e4698ad8..7b9c58586079 100644 --- a/packages/devextreme/js/localization/messages/ar.json +++ b/packages/devextreme/js/localization/messages/ar.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "يجب أن يكون تاريخ البداية بعد {0}", "dxGantt-dialogEndDateValidation": "يجب أن يكون تاريخ الانتهاء بعد {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "عنصر المعرض", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/bg.json b/packages/devextreme/js/localization/messages/bg.json index ed237704ffed..f3db558b5121 100644 --- a/packages/devextreme/js/localization/messages/bg.json +++ b/packages/devextreme/js/localization/messages/bg.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Началната дата трябва да е след {0}", "dxGantt-dialogEndDateValidation": "Крайната дата трябва да е след {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Елемент от галерия", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/ca.json b/packages/devextreme/js/localization/messages/ca.json index 36d3fc31030c..064740f0439d 100644 --- a/packages/devextreme/js/localization/messages/ca.json +++ b/packages/devextreme/js/localization/messages/ca.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "La data d'inici ha de ser després de {0}", "dxGantt-dialogEndDateValidation": "La data de finalització ha de ser després de {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Element de galeria", "dxMultiView-elementAriaRoleDescription": "Multivista", diff --git a/packages/devextreme/js/localization/messages/cs.json b/packages/devextreme/js/localization/messages/cs.json index 67335ccae6c3..952c01f17d3b 100644 --- a/packages/devextreme/js/localization/messages/cs.json +++ b/packages/devextreme/js/localization/messages/cs.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Datum zahájení musí být po {0}", "dxGantt-dialogEndDateValidation": "Datum ukončení musí být po {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Položka galerie", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/da.json b/packages/devextreme/js/localization/messages/da.json index f40d9f640af9..0b88345e43b2 100644 --- a/packages/devextreme/js/localization/messages/da.json +++ b/packages/devextreme/js/localization/messages/da.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Startdatoen skal være efter {0}", "dxGantt-dialogEndDateValidation": "Slutdatoen skal være efter {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Gallerielement", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/de.json b/packages/devextreme/js/localization/messages/de.json index 03ca59bd785c..d2be739bb2f6 100644 --- a/packages/devextreme/js/localization/messages/de.json +++ b/packages/devextreme/js/localization/messages/de.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Startdatum muss später sein als {0}", "dxGantt-dialogEndDateValidation": "Enddatum muss später sein als {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Galerieelement", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/el.json b/packages/devextreme/js/localization/messages/el.json index a7d5a18060a9..633cf9b2aeee 100644 --- a/packages/devextreme/js/localization/messages/el.json +++ b/packages/devextreme/js/localization/messages/el.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Η ημερομηνία έναρξης πρέπει να είναι μετά την {0}", "dxGantt-dialogEndDateValidation": "Η ημερομηνία λήξης πρέπει να είναι μετά την {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Στοιχείο γκαλερί", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/en.json b/packages/devextreme/js/localization/messages/en.json index b351d7dca066..a26b1ddbca67 100644 --- a/packages/devextreme/js/localization/messages/en.json +++ b/packages/devextreme/js/localization/messages/en.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Start date must be after {0}", "dxGantt-dialogEndDateValidation": "End date must be after {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Gallery item", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/es.json b/packages/devextreme/js/localization/messages/es.json index c21f7180c064..173bca273676 100644 --- a/packages/devextreme/js/localization/messages/es.json +++ b/packages/devextreme/js/localization/messages/es.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "La fecha de inicio debe ser anterior {0}", "dxGantt-dialogEndDateValidation": "La fecha de finalización debe ser posterior {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Elemento de galería", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/fa.json b/packages/devextreme/js/localization/messages/fa.json index c7a8f14662d9..849e82e4bf0b 100644 --- a/packages/devextreme/js/localization/messages/fa.json +++ b/packages/devextreme/js/localization/messages/fa.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "تاریخ شروع باید بعد از {0} باشد", "dxGantt-dialogEndDateValidation": "تاریخ پایان باید بعد از {0} باشد", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "مورد گالری", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/fi.json b/packages/devextreme/js/localization/messages/fi.json index 85428935b970..cab37bb08889 100644 --- a/packages/devextreme/js/localization/messages/fi.json +++ b/packages/devextreme/js/localization/messages/fi.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Aloituspäivän on oltava {0} jälkeen", "dxGantt-dialogEndDateValidation": "Lopetuspäivän on oltava {0} jälkeen", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Galleriakohde", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/fr.json b/packages/devextreme/js/localization/messages/fr.json index 75e73508a044..8a71d72a49c6 100644 --- a/packages/devextreme/js/localization/messages/fr.json +++ b/packages/devextreme/js/localization/messages/fr.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "La date de début doit être postérieure au {0}", "dxGantt-dialogEndDateValidation": "La date de fin doit être postérieure au {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Élément de la galerie", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/hi.json b/packages/devextreme/js/localization/messages/hi.json index 44809ab806c5..6a915e7c9c7b 100644 --- a/packages/devextreme/js/localization/messages/hi.json +++ b/packages/devextreme/js/localization/messages/hi.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "आरंभ तिथि {0} के बाद होनी चाहिए", "dxGantt-dialogEndDateValidation": "समाप्ति तिथि {0} के बाद होनी चाहिए", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "गैलरी आइटम", "dxMultiView-elementAriaRoleDescription": "मल्टीव्यू", diff --git a/packages/devextreme/js/localization/messages/hu.json b/packages/devextreme/js/localization/messages/hu.json index 62b96ecfa12a..937ee057e431 100644 --- a/packages/devextreme/js/localization/messages/hu.json +++ b/packages/devextreme/js/localization/messages/hu.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "A kezdő dátumnak {0} után kell lennie", "dxGantt-dialogEndDateValidation": "A befejezési dátumnak {0} után kell lennie", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Galéria elem", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/it.json b/packages/devextreme/js/localization/messages/it.json index 77b233d386bc..54ab1b147e15 100644 --- a/packages/devextreme/js/localization/messages/it.json +++ b/packages/devextreme/js/localization/messages/it.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "La data di inizio deve essere successiva al {0}", "dxGantt-dialogEndDateValidation": "La data di fine deve essere successiva al {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Elemento galleria", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/ja.json b/packages/devextreme/js/localization/messages/ja.json index 8ba9516e581e..057a4907d3a3 100644 --- a/packages/devextreme/js/localization/messages/ja.json +++ b/packages/devextreme/js/localization/messages/ja.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "開始日を {0} 以降にする必要があります", "dxGantt-dialogEndDateValidation": "終了日は {0} より後にする必要があります", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "ギャラリー アイテム", "dxMultiView-elementAriaRoleDescription": "マルチビュー", diff --git a/packages/devextreme/js/localization/messages/ko.json b/packages/devextreme/js/localization/messages/ko.json index 108dc23332a7..73ae4172bc76 100644 --- a/packages/devextreme/js/localization/messages/ko.json +++ b/packages/devextreme/js/localization/messages/ko.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "시작 날짜는 {0} 이후여야 합니다", "dxGantt-dialogEndDateValidation": "종료 날짜는 {0} 이후여야 합니다", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "갤러리 항목", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/lt.json b/packages/devextreme/js/localization/messages/lt.json index ad1bb8a3bf1a..4dcc7dd4e1cd 100644 --- a/packages/devextreme/js/localization/messages/lt.json +++ b/packages/devextreme/js/localization/messages/lt.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Pradžios data turi būti po {0}", "dxGantt-dialogEndDateValidation": "Pabaigos data turi būti po {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Galerijos elementas", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/lv.json b/packages/devextreme/js/localization/messages/lv.json index 7e590c0f1fc0..cf47a8fd310f 100644 --- a/packages/devextreme/js/localization/messages/lv.json +++ b/packages/devextreme/js/localization/messages/lv.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Sākuma datumam jābūt vēlākam par {0}", "dxGantt-dialogEndDateValidation": "Beigu datumam jābūt vēlākam par {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Galerijas elements", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/nb.json b/packages/devextreme/js/localization/messages/nb.json index 2bbf0909e3f8..e64af859a5bb 100644 --- a/packages/devextreme/js/localization/messages/nb.json +++ b/packages/devextreme/js/localization/messages/nb.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Startdato må være etter {0}", "dxGantt-dialogEndDateValidation": "Sluttdato må være etter {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Gallerielement", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/nl.json b/packages/devextreme/js/localization/messages/nl.json index 2a8f64fcc2a1..b5746aa9cc4c 100644 --- a/packages/devextreme/js/localization/messages/nl.json +++ b/packages/devextreme/js/localization/messages/nl.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Startdatum moet na {0} vallen", "dxGantt-dialogEndDateValidation": "Einddatum moet na {0} vallen", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Galerij-item", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/pl.json b/packages/devextreme/js/localization/messages/pl.json index 75dd6186c99f..ccfe943344d3 100644 --- a/packages/devextreme/js/localization/messages/pl.json +++ b/packages/devextreme/js/localization/messages/pl.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Data rozpoczęcia musi być późniejsza niż {0}", "dxGantt-dialogEndDateValidation": "Data zakończenia musi być późniejsza niż {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Element galerii", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/pt-BR.json b/packages/devextreme/js/localization/messages/pt-BR.json index 014c96fc3145..0458be85bfdf 100644 --- a/packages/devextreme/js/localization/messages/pt-BR.json +++ b/packages/devextreme/js/localization/messages/pt-BR.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "A data de início deve ser após {0}", "dxGantt-dialogEndDateValidation": "A data de término deve ser posterior a {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Item da galeria", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/pt-PT.json b/packages/devextreme/js/localization/messages/pt-PT.json index 466365cb4fea..f9ee5d624072 100644 --- a/packages/devextreme/js/localization/messages/pt-PT.json +++ b/packages/devextreme/js/localization/messages/pt-PT.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "A data de início deve ser após {0}", "dxGantt-dialogEndDateValidation": "A data de fim deve ser posterior a {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Item da galeria", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/pt.json b/packages/devextreme/js/localization/messages/pt.json index 6b598ae29762..e83c902d75d1 100644 --- a/packages/devextreme/js/localization/messages/pt.json +++ b/packages/devextreme/js/localization/messages/pt.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "A data de início deve ser após {0}", "dxGantt-dialogEndDateValidation": "A data de término deve ser posterior a {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Item da galeria", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/ro.json b/packages/devextreme/js/localization/messages/ro.json index 4db5191d535c..011aafa233da 100644 --- a/packages/devextreme/js/localization/messages/ro.json +++ b/packages/devextreme/js/localization/messages/ro.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Data de început trebuie să fie după {0}", "dxGantt-dialogEndDateValidation": "Data de sfârșit trebuie să fie după {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Element galerie", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/ru.json b/packages/devextreme/js/localization/messages/ru.json index 8e2e2c24e948..fec24bfeb685 100644 --- a/packages/devextreme/js/localization/messages/ru.json +++ b/packages/devextreme/js/localization/messages/ru.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Начальная дата должна быть позднее {0}", "dxGantt-dialogEndDateValidation": "Конечная дата должна быть позднее {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Элемент галереи", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/sl.json b/packages/devextreme/js/localization/messages/sl.json index c49db2f4affc..f9279afa7718 100644 --- a/packages/devextreme/js/localization/messages/sl.json +++ b/packages/devextreme/js/localization/messages/sl.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Datum začetka mora biti po {0}", "dxGantt-dialogEndDateValidation": "Datum konca mora biti po {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Element galerije", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/sv.json b/packages/devextreme/js/localization/messages/sv.json index 409cd11b3169..27c837abc3ea 100644 --- a/packages/devextreme/js/localization/messages/sv.json +++ b/packages/devextreme/js/localization/messages/sv.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Startdatum måste vara efter {0}", "dxGantt-dialogEndDateValidation": "Slutdatum måste vara efter {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Gallerielement", "dxMultiView-elementAriaRoleDescription": "Vyväxlare", diff --git a/packages/devextreme/js/localization/messages/tr.json b/packages/devextreme/js/localization/messages/tr.json index ec6da10e1603..f40c1c4dcb35 100644 --- a/packages/devextreme/js/localization/messages/tr.json +++ b/packages/devextreme/js/localization/messages/tr.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Başlangıç tarihi {0} tarihinden sonra olmalıdır", "dxGantt-dialogEndDateValidation": "Bitiş tarihi {0} tarihinden sonra olmalıdır", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Galeri öğesi", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/uk.json b/packages/devextreme/js/localization/messages/uk.json index fedee25990ac..218c7b7b5cb4 100644 --- a/packages/devextreme/js/localization/messages/uk.json +++ b/packages/devextreme/js/localization/messages/uk.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Дата початку має бути після {0}", "dxGantt-dialogEndDateValidation": "Дата завершення має бути після {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Елемент галереї", "dxMultiView-elementAriaRoleDescription": "Кілька представлень", diff --git a/packages/devextreme/js/localization/messages/vi.json b/packages/devextreme/js/localization/messages/vi.json index bf311b55f912..53dd93d94af1 100644 --- a/packages/devextreme/js/localization/messages/vi.json +++ b/packages/devextreme/js/localization/messages/vi.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Ngày bắt đầu phải sau {0}", "dxGantt-dialogEndDateValidation": "Ngày kết thúc phải sau {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Mục thư viện", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/zh-CN.json b/packages/devextreme/js/localization/messages/zh-CN.json index be5acebd67e0..0f42cbe76b22 100644 --- a/packages/devextreme/js/localization/messages/zh-CN.json +++ b/packages/devextreme/js/localization/messages/zh-CN.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "开始日期必须晚于 {0}", "dxGantt-dialogEndDateValidation": "结束日期必须晚于 {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "图库项目", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/zh-Hans.json b/packages/devextreme/js/localization/messages/zh-Hans.json index 84f31725a97d..039d1c0e3f32 100644 --- a/packages/devextreme/js/localization/messages/zh-Hans.json +++ b/packages/devextreme/js/localization/messages/zh-Hans.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "开始日期必须晚于 {0}", "dxGantt-dialogEndDateValidation": "结束日期必须晚于 {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "图库项目", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/zh-tw.json b/packages/devextreme/js/localization/messages/zh-tw.json index fa266a611d61..982d2f446c77 100644 --- a/packages/devextreme/js/localization/messages/zh-tw.json +++ b/packages/devextreme/js/localization/messages/zh-tw.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "開始日期必須晚於 {0}", "dxGantt-dialogEndDateValidation": "結束日期必須晚於 {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "圖庫項目", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/zh.json b/packages/devextreme/js/localization/messages/zh.json index 08beff591d81..2de183c44423 100644 --- a/packages/devextreme/js/localization/messages/zh.json +++ b/packages/devextreme/js/localization/messages/zh.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "开始日期必须晚于 {0}", "dxGantt-dialogEndDateValidation": "结束日期必须晚于 {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "图库项目", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/ui/map/openlayers.register.js b/packages/devextreme/js/ui/map/openlayers.register.js index 3e66488c1b75..4289f50b51cb 100644 --- a/packages/devextreme/js/ui/map/openlayers.register.js +++ b/packages/devextreme/js/ui/map/openlayers.register.js @@ -3,6 +3,7 @@ import Zoom from 'ol/control/Zoom.js'; import { defaults as defaultInteractions } from 'ol/interaction/defaults.js'; import TileLayer from 'ol/layer/Tile.js'; import Map from 'ol/Map.js'; +import Overlay from 'ol/Overlay.js'; import { getUserProjection, toLonLat, @@ -17,6 +18,7 @@ import { createOpenLayersEngine } from '../../__internal/ui/map/provider.dynamic setRegisteredMapEngine(createOpenLayersEngine({ Map, + Overlay, View, control: { Zoom, diff --git a/packages/devextreme/testing/helpers/forMap/openLayersMock.js b/packages/devextreme/testing/helpers/forMap/openLayersMock.js index 64104646129c..ed9903c3f773 100644 --- a/packages/devextreme/testing/helpers/forMap/openLayersMock.js +++ b/packages/devextreme/testing/helpers/forMap/openLayersMock.js @@ -16,10 +16,8 @@ if(source === WEB_MERCATOR_PROJECTION && destination === GEOGRAPHIC_PROJECTION) { return coordinate.map((value) => value / PROJECTION_SCALE); } - return [...coordinate]; }; - class MockCollection { constructor(items) { this.items = items; @@ -42,10 +40,12 @@ getActive() { return this.active; } - setActive(active) { this.active = active; - api.interactionStateChanges.push({ interaction: this, active }); + api.interactionStateChanges.push({ + interaction: this, + active + }); if(api.onInteractionStateChanged) { api.onInteractionStateChanged(); } @@ -57,10 +57,23 @@ api.zoomControlCreatedCount += 1; } } - + class MockOverlay { + constructor(options) { + this.options = options; + api.overlayOptions.push(options); + } + getPosition() { + return this.options.position; + } + setPosition(position) { + this.options.position = position; + api.overlayPositionChanges.push(position); + } + } class MockView { constructor(options) { this.center = options.center; + this.eventHandlers = {}; this.projection = options.projection || WEB_MERCATOR_PROJECTION; this.zoom = options.zoom; api.viewCenter = options.center; @@ -73,10 +86,17 @@ } fit(extent, options) { + api.fitCallCount += 1; api.fittedExtent = extent; api.fitOptions = options; + this.center = [(extent[0] + extent[2]) / 2, (extent[1] + extent[3]) / 2]; + api.viewCenter = this.center; + if(api.fitZoom !== undefined) { + this.zoom = api.fitZoom; + api.viewZoom = api.fitZoom; + } + this.trigger('change:center'); } - getCenter() { return this.center; } @@ -88,30 +108,42 @@ getZoom() { return this.zoom; } - + on(type, listener) { + this.eventHandlers[type] = this.eventHandlers[type] || []; + this.eventHandlers[type].push(listener); + } setCenter(center) { this.center = center; api.viewCenter = center; api.viewCenterSetCount += 1; + this.trigger('change:center'); } - setZoom(zoom) { this.zoom = zoom; api.viewZoom = zoom; api.viewZoomSetCount += 1; } + trigger(type) { + (this.eventHandlers[type] || []).slice().forEach((handler) => handler()); + } + un(type, listener) { + const handlers = this.eventHandlers[type] || []; + this.eventHandlers[type] = handlers.filter((handler) => handler !== listener); + } } - class MockMap { constructor(options) { this.options = options; this.view = options.view; this.eventHandlers = {}; + this.overlayContainer = document.createElement('div'); + this.overlayContainerStopEvent = document.createElement('div'); api.mapCreated = true; api.mapInstance = this; api.mapOptions = options; + api.overlayContainer = this.overlayContainer; + api.overlayContainerStopEvent = this.overlayContainerStopEvent; } - addControl(control) { api.addedControls.push(control); } @@ -120,11 +152,26 @@ api.tileLayer = layer; api.addedTileLayers.push(layer); } - + addOverlay(overlay) { + api.addedOverlays.push(overlay); + if(api.getOverlayRect) { + overlay.options.element.getBoundingClientRect = api.getOverlayRect; + } + if(overlay.options.insertFirst) { + this.options.target.prepend(overlay.options.element); + } else { + this.options.target.appendChild(overlay.options.element); + } + } getInteractions() { return this.options.interactions; } - + getOverlayContainer() { + return this.overlayContainer; + } + getOverlayContainerStopEvent() { + return this.overlayContainerStopEvent; + } getView() { return this.view; } @@ -141,26 +188,30 @@ removeLayer(layer) { api.removedLayers.push(layer); } - + removeOverlay(overlay) { + api.removedOverlays.push(overlay); + overlay.options.element.remove(); + } setTarget(target) { this.target = target; api.mapTarget = target; } + setView(view) { + this.view = view; + this.trigger('change:view'); + } updateSize() { api.mapResized = true; } - un(type, listener) { const handlers = this.eventHandlers[type] || []; this.eventHandlers[type] = handlers.filter((handler) => handler !== listener); } - trigger(type, event) { (this.eventHandlers[type] || []).slice().forEach((handler) => handler(event || {})); } } - class MockImageTile { constructor(options) { if(api.throwOnTileSource) { @@ -182,9 +233,9 @@ api.tileSourceChanges.push(source); } } - Object.assign(api, { Map: MockMap, + Overlay: MockOverlay, View: MockView, control: { Zoom: MockZoom, diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/commonTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/commonTests.js index 9d3f32f59dcb..a9c147f9737c 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/commonTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/commonTests.js @@ -833,6 +833,58 @@ QUnit.test('change provider and async options', function(assert) { }); }); +['resolve', 'reject'].forEach((completion) => { + QUnit.test(`async ${completion} from a replaced provider is ignored`, function(assert) { + let completeAction; + const actionResult = new Promise((resolve, reject) => { + completeAction = completion === 'resolve' ? resolve : reject; + }); + const replacedProvider = { + updateZoom: sinon.stub().returns(actionResult) + }; + const map = { + _provider: replacedProvider, + _lastAsyncAction: Promise.resolve(), + _triggerReadyAction: sinon.spy(), + _triggerUpdateAction: sinon.spy(), + }; + const action = Map.prototype._queueAsyncAction.call(map, 'updateZoom'); + + return Promise.resolve().then(() => { + assert.ok(replacedProvider.updateZoom.calledOnce, 'action starts on the captured provider'); + map._provider = {}; + completeAction(completion === 'resolve' ? true : new Error('stale provider')); + + return action; + }).then(() => { + assert.ok(map._triggerReadyAction.notCalled, 'stale action does not raise onReady'); + assert.ok(map._triggerUpdateAction.notCalled, 'stale action does not raise onUpdated'); + }); + }); +}); + +QUnit.test('queued action is not started after its provider is replaced', function(assert) { + let continueQueue; + const replacedProvider = { + updateZoom: sinon.spy() + }; + const map = { + _provider: replacedProvider, + _lastAsyncAction: new Promise(resolve => { + continueQueue = resolve; + }), + _triggerReadyAction: sinon.spy(), + _triggerUpdateAction: sinon.spy(), + }; + const action = Map.prototype._queueAsyncAction.call(map, 'updateZoom'); + map._provider = {}; + continueQueue(); + + return action.then(() => { + assert.ok(replacedProvider.updateZoom.notCalled, 'action captured for the old provider is skipped'); + }); +}); + QUnit.module('disposed widget', { beforeEach: function() { const fakeURL = '/fakeGoogleUrl?'; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js index 52dd179dd79c..dc151a3b2295 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -3,18 +3,32 @@ import $ from 'jquery'; import OsmProvider from '__internal/ui/map/provider.dynamic.osm'; import { setRegisteredMapEngine } from '__internal/ui/map/provider.dynamic.osm.engine'; import { createOpenLayersEngine } from '__internal/ui/map/provider.dynamic.osm.openlayers'; +import resizeObserverSingleton from 'core/resize_observer'; +import localization from 'localization'; import errors from 'ui/widget/ui.errors'; import 'ui/map'; let openLayersMock; +let resizeObserverCallbacks; +const triggerResize = (element) => { + const callback = resizeObserverCallbacks.get(element); + + if(callback) { + callback(); + } +}; const resetOpenLayersMock = () => { Object.assign(openLayersMock, { addedControls: [], + addedOverlays: [], addedTileLayers: [], controlOptions: null, + fitCallCount: 0, + fitZoom: undefined, fitOptions: null, fittedExtent: null, + getOverlayRect: null, interactionOptions: null, interactions: [], interactionStateChanges: [], @@ -24,9 +38,14 @@ const resetOpenLayersMock = () => { mapResized: false, mapTarget: null, onInteractionStateChanged: null, + overlayOptions: [], + overlayContainer: null, + overlayContainerStopEvent: null, + overlayPositionChanges: [], projectedCoordinates: [], removedControls: [], removedLayers: [], + removedOverlays: [], throwOnTileSource: false, tileLayer: null, tileLayerOptions: null, @@ -47,9 +66,7 @@ const resetOpenLayersMock = () => { const onInteractionStates = (expectedStates, callback) => { openLayersMock.onInteractionStateChanged = () => { const actualStates = openLayersMock.interactions.map(interaction => interaction.getActive()); - const stateMatches = actualStates.length === expectedStates.length - && actualStates.every((state, index) => state === expectedStates[index]); - + const stateMatches = actualStates.length === expectedStates.length && actualStates.every((state, index) => state === expectedStates[index]); if(stateMatches) { openLayersMock.onInteractionStateChanged = null; callback(); @@ -58,6 +75,13 @@ const onInteractionStates = (expectedStates, callback) => { }; const moduleConfig = { beforeEach(assert) { + resizeObserverCallbacks = new Map(); + sinon.stub(resizeObserverSingleton, 'observe').callsFake((element, callback) => { + resizeObserverCallbacks.set(element, callback); + }); + sinon.stub(resizeObserverSingleton, 'unobserve').callsFake(element => { + resizeObserverCallbacks.delete(element); + }); const setup = () => { setRegisteredMapEngine(undefined); window.ol = openLayersMock; @@ -83,6 +107,8 @@ const moduleConfig = { }); }, afterEach() { + resizeObserverSingleton.observe.restore(); + resizeObserverSingleton.unobserve.restore(); setRegisteredMapEngine(undefined); window.ol = openLayersMock; } @@ -215,7 +241,9 @@ QUnit.module('OSM: map loading', moduleConfig, () => { QUnit.test('disabled map does not make its Shadow DOM host inert', function(assert) { const engine = createOpenLayersEngine(openLayersMock); const host = document.createElement('div'); - const shadowRoot = host.attachShadow({ mode: 'open' }); + const shadowRoot = host.attachShadow({ + mode: 'open' + }); const container = document.createElement('div'); const sibling = document.createElement('button'); shadowRoot.append(container, sibling); @@ -246,8 +274,11 @@ QUnit.module('OSM: map loading', moduleConfig, () => { QUnit.test('updateDimensions updates the OpenLayers map size', function(assert) { const engine = createOpenLayersEngine(openLayersMock); const engineMap = engine.createMap(document.createElement('div')); - engineMap.updateDimensions(); + const result = engineMap.updateDimensions(); assert.ok(openLayersMock.mapResized, 'OpenLayers map size is updated'); + assert.deepEqual(result, { + needsViewportRefit: false + }, 'viewport refit requirement is named'); engineMap.dispose(); }); QUnit.test('load rejects with E1069 when OpenLayers is missing', function(assert) { @@ -296,7 +327,22 @@ QUnit.module('OSM: map loading', moduleConfig, () => { QUnit.test('load rejects with E1069 when the OpenLayers ImageTile API is missing', function(assert) { const done = assert.async(); const provider = createProvider(); - window.ol = Object.assign({}, openLayersMock, { source: {} }); + window.ol = Object.assign({}, openLayersMock, { + source: {} + }); + 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 the OpenLayers Overlay API is missing', function(assert) { + const done = assert.async(); + const provider = createProvider(); + window.ol = Object.assign({}, openLayersMock); + delete window.ol.Overlay; provider._loadImpl().then(() => { assert.ok(false, 'load should reject'); done(); @@ -311,7 +357,9 @@ QUnit.module('OSM: map loading', moduleConfig, () => { const provider = createProvider(); const projectionApi = Object.assign({}, openLayersMock.proj); delete projectionApi[apiName]; - window.ol = Object.assign({}, openLayersMock, { proj: projectionApi }); + window.ol = Object.assign({}, openLayersMock, { + proj: projectionApi + }); provider._loadImpl().then(() => { assert.ok(false, 'load should reject'); done(); @@ -776,13 +824,1138 @@ QUnit.module('OSM: initial view', moduleConfig, () => { }); }); }); +QUnit.module('OSM: location calculation', moduleConfig, () => { + const tileServer = { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + }; + QUnit.test('calculateLocation resolves a string center', function(assert) { + const done = assert.async(); + const calculateLocation = sinon.spy(query => Promise.resolve({ + lat: 40.74, + lng: -73.98 + })); + $('#map').dxMap({ + provider: 'osm', + center: 'New York', + providerConfig: { + tileServer, + calculateLocation + }, + onReady: () => { + assert.ok(calculateLocation.calledOnceWithExactly('New York'), 'raw query is passed to the callback'); + assert.deepEqual(openLayersMock.viewCenter, [-73980, 40740], 'calculated center is applied'); + done(); + } + }); + }); + QUnit.test('numeric string center does not call calculateLocation', function(assert) { + const done = assert.async(); + const calculateLocation = sinon.spy(() => Promise.resolve({ + lat: 0, + lng: 0 + })); + $('#map').dxMap({ + provider: 'osm', + center: '40.74, -73.98', + providerConfig: { + tileServer, + calculateLocation + }, + onReady: () => { + assert.ok(calculateLocation.notCalled, 'coordinate string is resolved locally'); + assert.deepEqual(openLayersMock.viewCenter, [-73980, 40740], 'coordinate string is applied'); + done(); + } + }); + }); + QUnit.test('missing calculateLocation logs W1031 once and uses the default location', function(assert) { + const done = assert.async(); + const log = sinon.stub(errors, 'log'); + const provider = createProvider(); + provider._resolveLocation('Unknown place').then(location => { + assert.deepEqual(location, { + lat: 0, + lng: 0 + }, 'default location is returned'); + return provider._resolveLocation('Another place'); + }).then(location => { + assert.deepEqual(location, { + lat: 0, + lng: 0 + }, 'default location is returned for subsequent queries'); + assert.ok(log.calledOnceWithExactly('W1031'), 'W1031 is logged'); + log.restore(); + done(); + }); + }); + QUnit.test('successful calculated locations are cached', function(assert) { + const done = assert.async(); + const calculateLocation = sinon.spy(() => Promise.resolve({ + lat: 40.74, + lng: -73.98 + })); + const provider = new OsmProvider({ + option: () => ({ + providerConfig: { + calculateLocation + } + }) + }, null); + provider._resolveLocation('New York').then(() => provider._resolveLocation('New York')).then(location => { + assert.deepEqual(location, { + lat: 40.74, + lng: -73.98 + }, 'cached location is returned'); + assert.ok(calculateLocation.calledOnce, 'callback is called once'); + done(); + }); + }); + QUnit.test('concurrent calculations for the same location share one request', function(assert) { + const done = assert.async(); + let resolveLocation; + const locationPromise = new Promise(resolve => { + resolveLocation = resolve; + }); + const calculateLocation = sinon.spy(() => locationPromise); + const provider = new OsmProvider({ + option: () => ({ + providerConfig: { + calculateLocation + } + }) + }, null); + const first = provider._resolveLocation('New York'); + const second = provider._resolveLocation('New York'); + resolveLocation({ + lat: 40.74, + lng: -73.98 + }); + Promise.all([first, second]).then(locations => { + assert.deepEqual(locations, [{ + lat: 40.74, + lng: -73.98 + }, { + lat: 40.74, + lng: -73.98 + }], 'both callers receive the calculated location'); + assert.ok(calculateLocation.calledOnce, 'callback is called once'); + done(); + }); + }); + QUnit.test('an invalid callback result is not cached', function(assert) { + const done = assert.async(); + const log = sinon.stub(errors, 'log'); + const calculateLocation = sinon.stub(); + calculateLocation.onFirstCall().returns(Promise.resolve(undefined)); + calculateLocation.onSecondCall().returns(Promise.resolve({ + lat: 40.74, + lng: -73.98 + })); + const provider = new OsmProvider({ + option: () => ({ + providerConfig: { + calculateLocation + } + }) + }, null); + provider._resolveLocation('New York').then(firstLocation => { + assert.deepEqual(firstLocation, { + lat: 0, + lng: 0 + }, 'invalid result uses the default location'); + return provider._resolveLocation('New York'); + }).then(secondLocation => { + assert.deepEqual(secondLocation, { + lat: 40.74, + lng: -73.98 + }, 'callback is retried'); + assert.ok(calculateLocation.calledTwice, 'invalid result is not cached'); + assert.ok(log.calledOnceWithExactly('W1006', 'calculateLocation returned an invalid result.'), 'invalid result is reported'); + log.restore(); + done(); + }); + }); + QUnit.test('a rejected callback result is not cached', function(assert) { + const done = assert.async(); + const log = sinon.stub(errors, 'log'); + const rejection = new Error('service unavailable'); + const calculateLocation = sinon.stub(); + calculateLocation.onFirstCall().returns(Promise.reject(rejection)); + calculateLocation.onSecondCall().returns(Promise.resolve({ + lat: 40.74, + lng: -73.98 + })); + const provider = new OsmProvider({ + option: () => ({ + providerConfig: { + calculateLocation + } + }) + }, null); + provider._resolveLocation('New York').then(firstLocation => { + assert.deepEqual(firstLocation, { + lat: 0, + lng: 0 + }, 'rejection uses the default location'); + return provider._resolveLocation('New York'); + }).then(secondLocation => { + assert.deepEqual(secondLocation, { + lat: 40.74, + lng: -73.98 + }, 'callback is retried'); + assert.ok(calculateLocation.calledTwice, 'rejected result is not cached'); + assert.ok(log.calledOnceWithExactly('W1006', rejection), 'service rejection is reported'); + log.restore(); + done(); + }); + }); + QUnit.test('a pending center calculation does not update a cleaned provider', function(assert) { + const done = assert.async(); + let resolveLocation; + const locationPromise = new Promise(resolve => { + resolveLocation = resolve; + }); + const setOptionSilent = sinon.spy(); + const setView = sinon.spy(); + const provider = new OsmProvider({ + option: () => ({ + center: 'New York', + providerConfig: { + calculateLocation: () => locationPromise + } + }), + setOptionSilent + }, null); + provider._engineMap = { + dispose: sinon.spy(), + setView + }; + provider._markers = []; + const update = provider.updateCenter(); + provider.clean(); + resolveLocation({ + lat: 40.74, + lng: -73.98 + }); + update.then(() => { + assert.ok(setView.notCalled, 'the disposed engine map is not updated'); + assert.ok(setOptionSilent.notCalled, 'the stale center is not written to the component'); + done(); + }); + }); + QUnit.test('an older center calculation does not overwrite a newer center', function(assert) { + const done = assert.async(); + let resolveFirst; + let resolveSecond; + const firstLocation = new Promise(resolve => { + resolveFirst = resolve; + }); + const secondLocation = new Promise(resolve => { + resolveSecond = resolve; + }); + const options = { + center: 'First', + providerConfig: { + calculateLocation: query => query === 'First' ? firstLocation : secondLocation + } + }; + const setView = sinon.spy(); + const setOptionSilent = sinon.spy((name, value) => { + options[name] = value; + }); + const provider = new OsmProvider({ + option: () => options, + setOptionSilent + }, null); + provider._engineMap = { + setView + }; + const firstUpdate = provider.updateCenter(); + options.center = 'Second'; + resolveFirst({ + lat: 1, + lng: 2 + }); + firstUpdate.then(() => { + assert.ok(setView.notCalled, 'superseded center is not applied'); + assert.ok(setOptionSilent.notCalled, 'superseded center is not written to the component'); + const secondUpdate = provider.updateCenter(); + resolveSecond({ + lat: 3, + lng: 4 + }); + return secondUpdate; + }).then(() => { + assert.ok(setView.calledOnceWithExactly({ + center: { + lat: 3, + lng: 4 + } + }), 'latest center is applied'); + assert.ok(setOptionSilent.calledOnceWithExactly('center', { + lat: 3, + lng: 4 + }), 'latest center is written to the component'); + done(); + }); + }); + QUnit.test('older calculated bounds are not applied after the option changes', function(assert) { + const done = assert.async(); + let resolveFirst; + const firstLocation = new Promise(resolve => { + resolveFirst = resolve; + }); + const firstBounds = { + northEast: 'First', + southWest: [40, -74] + }; + const options = { + bounds: firstBounds, + providerConfig: { + calculateLocation: () => firstLocation + } + }; + const fitBounds = sinon.spy(); + const provider = new OsmProvider({ + option: () => options + }, null); + provider._engineMap = { + fitBounds + }; + const firstUpdate = provider.updateBounds(); + firstBounds.northEast = [41, -73]; + resolveFirst({ + lat: 42, + lng: -72 + }); + firstUpdate.then(() => { + assert.ok(fitBounds.notCalled, 'superseded bounds are not applied'); + return provider.updateBounds(); + }).then(() => { + assert.ok(fitBounds.calledOnceWithExactly({ + northEast: { + lat: 41, + lng: -73 + }, + southWest: { + lat: 40, + lng: -74 + } + }), 'latest bounds are applied'); + done(); + }); + }); + QUnit.test('a pending marker calculation does not add a marker after cleanup', function(assert) { + const done = assert.async(); + let resolveLocation; + const locationPromise = new Promise(resolve => { + resolveLocation = resolve; + }); + const addMarker = sinon.spy(); + const provider = new OsmProvider({ + option: () => ({ + autoAdjust: false, + providerConfig: { + calculateLocation: () => locationPromise + } + }) + }, null); + provider._engineMap = { + addMarker, + dispose: sinon.spy() + }; + provider._markers = []; + const add = provider.addMarkers([{ + location: 'New York' + }]); + provider.clean(); + resolveLocation({ + lat: 40.74, + lng: -73.98 + }); + add.then(() => { + assert.ok(false, 'the stale marker operation should reject'); + done(); + }, error => { + assert.ok(addMarker.notCalled, 'no marker is added to the disposed engine map'); + assert.ok(error instanceof Error, 'the stale operation rejects with an Error'); + assert.strictEqual(error.message, 'The map was disposed or replaced during marker creation.', 'the rejection explains why marker creation was cancelled'); + done(); + }); + }); +}); +QUnit.module('OSM: markers', moduleConfig, () => { + const tileServer = { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + }; + QUnit.test('initial marker uses an OpenLayers overlay', function(assert) { + const done = assert.async(); + const marker = { + location: { + lat: 40.74, + lng: -73.98 + } + }; + let markerAddedEvent; + $('#map').dxMap({ + provider: 'osm', + autoAdjust: false, + markers: [marker], + providerConfig: { + tileServer + }, + onMarkerAdded: e => { + markerAddedEvent = e; + }, + onReady: () => { + const overlay = openLayersMock.addedOverlays[0]; + const element = overlay.options.element; + assert.strictEqual(openLayersMock.addedOverlays.length, 1, 'one overlay is added'); + assert.deepEqual(overlay.options.position, [-73980, 40740], 'marker location is projected'); + assert.strictEqual(overlay.options.positioning, 'bottom-center', 'marker tip is anchored to its location'); + assert.strictEqual(overlay.options.stopEvent, false, 'map interactions remain available over the marker'); + assert.ok(element.classList.contains('dx-map-marker-default'), 'default marker is rendered'); + assert.strictEqual(getComputedStyle(element).width, '44px', 'default marker keeps a sufficiently large hit area'); + assert.strictEqual(getComputedStyle(element).height, '44px', 'default marker keeps a sufficiently large hit area'); + const markerSvg = element.querySelector('.dx-map-marker-default-icon'); + assert.ok(markerSvg, 'default marker SVG is rendered'); + assert.strictEqual(markerSvg.getAttribute('viewBox'), '5 2 14 20', 'existing pinmap geometry is fitted to the marker'); + assert.strictEqual(markerSvg.getAttribute('width'), '24.5', 'default marker width matches the standard marker size'); + assert.strictEqual(markerSvg.getAttribute('height'), '36.5', 'default marker height matches the standard marker size'); + const markerBody = element.querySelector('.dx-map-marker-default-body'); + const markerCenter = element.querySelector('.dx-map-marker-default-center'); + assert.ok(markerBody, 'marker body is rendered'); + assert.notOk(markerBody.hasAttribute('fill'), 'marker body color is defined by the theme stylesheet'); + assert.notOk(markerBody.hasAttribute('stroke'), 'marker outline color is defined by the theme stylesheet'); + assert.strictEqual(markerBody.getAttribute('stroke-width'), '0.5', 'marker outline does not obscure its body'); + assert.ok(markerCenter, 'marker center is rendered'); + assert.notOk(markerCenter.hasAttribute('fill'), 'marker center color is defined by the theme stylesheet'); + assert.strictEqual(markerAddedEvent.options, marker, 'marker options are passed to onMarkerAdded'); + assert.strictEqual(markerAddedEvent.originalMarker, overlay, 'OpenLayers overlay is exposed as originalMarker'); + done(); + } + }); + }); + QUnit.test('marker position synchronization follows a replaced OpenLayers view', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const engineMap = engine.createMap(document.createElement('div')); + const map = openLayersMock.mapInstance; + const initialView = map.getView(); + engineMap.addMarker({ + location: { + lat: 10, + lng: -179 + } + }); + const replacementView = new openLayersMock.View({ + center: [179000, 0], + projection: 'EPSG:3857', + zoom: 1 + }); + + map.setView(replacementView); + + assert.deepEqual(openLayersMock.addedOverlays[0].options.position, [181000, 10000], 'marker moves into the replacement view world'); + assert.strictEqual(initialView.eventHandlers['change:center'].length, 0, 'old view listener is removed'); + assert.strictEqual(replacementView.eventHandlers['change:center'].length, 1, 'replacement view listener is added'); + + const synchronizedPositionCount = openLayersMock.overlayPositionChanges.length; + replacementView.setCenter([179000, 0]); + assert.strictEqual(openLayersMock.overlayPositionChanges.length, synchronizedPositionCount, 'unchanged marker position is not written again'); + + const positionChangeCount = openLayersMock.overlayPositionChanges.length; + initialView.setCenter([-179000, 0]); + assert.strictEqual(openLayersMock.overlayPositionChanges.length, positionChangeCount, 'old view no longer updates marker positions'); + + replacementView.setCenter([-179000, 0]); + assert.deepEqual(openLayersMock.addedOverlays[0].options.position, [-179000, 10000], 'replacement view updates marker positions'); + + engineMap.dispose(); + + assert.strictEqual(map.eventHandlers['change:view'].length, 0, 'view replacement listener is removed on dispose'); + assert.strictEqual(replacementView.eventHandlers['change:center'].length, 0, 'replacement view listener is removed on dispose'); + }); + QUnit.test('marker iconSrc takes priority over markerIconSrc', function(assert) { + const done = assert.async(); + const defaultLocale = localization.locale(); + const markerAriaLabel = 'Localized map marker'; + localization.loadMessages({ + 'test': { + 'dxMap-markerAriaLabel': markerAriaLabel + } + }); + localization.locale('test'); + $('#map').dxMap({ + provider: 'osm', + autoAdjust: false, + markerIconSrc: 'global-marker.png', + markers: [{ + location: [40.74, -73.98] + }, { + location: [40.75, -73.97], + iconSrc: 'local-marker.png', + onClick: () => {} + }], + providerConfig: { + tileServer + }, + onReady: () => { + try { + const globalIcon = openLayersMock.addedOverlays[0].options.element; + const localIcon = openLayersMock.addedOverlays[1].options.element; + assert.strictEqual(globalIcon.getAttribute('src'), 'global-marker.png', 'global marker icon is applied'); + assert.strictEqual(globalIcon.getAttribute('alt'), markerAriaLabel, 'non-interactive custom marker has a localized alternative'); + assert.strictEqual(localIcon.getAttribute('src'), 'local-marker.png', 'marker icon overrides the global icon'); + assert.notOk(localIcon.hasAttribute('width'), 'custom marker keeps its natural width'); + assert.notOk(localIcon.hasAttribute('height'), 'custom marker keeps its natural height'); + assert.strictEqual(localIcon.getAttribute('alt'), markerAriaLabel, 'interactive custom marker has a localized alternative'); + assert.notOk(localIcon.hasAttribute('aria-label'), 'image marker does not duplicate its accessible name'); + assert.strictEqual(localIcon.draggable, false, 'custom marker does not start native image dragging'); + } finally { + localization.locale(defaultLocale); + done(); + } + } + }); + }); + QUnit.test('HTML marker and offset are passed to OpenLayers', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + autoAdjust: false, + markers: [{ + location: [40.74, -73.98], + html: 'A', + htmlOffset: { + left: 5, + top: 7 + } + }], + providerConfig: { + tileServer + }, + onReady: () => { + const options = openLayersMock.addedOverlays[0].options; + assert.strictEqual(options.element.firstElementChild.className, 'custom-marker', 'custom HTML is rendered'); + assert.deepEqual(options.offset, [5, 7], 'HTML offset is applied'); + assert.strictEqual(options.positioning, 'top-left', 'HTML offset starts at the marker location'); + done(); + } + }); + }); + QUnit.test('marker click calls onClick with the resolved location', function(assert) { + const done = assert.async(); + const onClick = sinon.spy(); + const onMapClick = sinon.spy(); + $('#map').dxMap({ + provider: 'osm', + autoAdjust: false, + markers: [{ + location: [40.74, -73.98], + onClick + }], + providerConfig: { + tileServer + }, + onClick: onMapClick, + onReady: () => { + const overlayOptions = openLayersMock.addedOverlays[0].options; + const element = overlayOptions.element; + const parentClick = sinon.spy(); + element.parentElement.addEventListener('click', parentClick); + assert.strictEqual(element.getAttribute('role'), 'button', 'clickable marker has button semantics'); + assert.strictEqual(element.getAttribute('aria-label'), 'Map marker', 'clickable marker has an accessible name'); + assert.strictEqual(element.getAttribute('tabindex'), '0', 'clickable marker is keyboard-focusable'); + assert.strictEqual(overlayOptions.stopEvent, false, 'map wheel and drag interactions remain available over a clickable marker'); + element.click(); + assert.ok(onClick.calledOnce, 'marker click action is fired'); + assert.ok(parentClick.notCalled, 'marker click does not bubble to the map container'); + assert.deepEqual(onClick.firstCall.args[0].location, { + lat: 40.74, + lng: -73.98 + }, 'resolved location is passed'); + const markerPointerEvent = new PointerEvent('pointerup', { + bubbles: true + }); + element.dispatchEvent(markerPointerEvent); + openLayersMock.mapInstance.trigger('click', { + coordinate: [-73980, 40740], + originalEvent: markerPointerEvent + }); + assert.ok(onMapClick.notCalled, 'marker pointer event does not fire the map click action'); + element.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true + })); + assert.ok(onClick.calledTwice, 'marker can be activated from the keyboard'); + done(); + } + }); + }); + QUnit.test('clickable plain HTML marker is keyboard-accessible', function(assert) { + const done = assert.async(); + const onClick = sinon.spy(); + $('#map').dxMap({ + provider: 'osm', + autoAdjust: false, + markers: [{ + location: [40.74, -73.98], + html: 'Custom marker', + onClick + }], + providerConfig: { + tileServer + }, + onReady: () => { + const element = openLayersMock.addedOverlays[0].options.element; + assert.strictEqual(element.getAttribute('role'), 'button', 'wrapper has button semantics'); + assert.strictEqual(element.getAttribute('tabindex'), '0', 'wrapper is keyboard-focusable'); + element.dispatchEvent(new KeyboardEvent('keydown', { + key: ' ', + bubbles: true, + repeat: true + })); + assert.ok(onClick.notCalled, 'repeated Space keydown does not activate the marker'); + element.dispatchEvent(new KeyboardEvent('keyup', { + key: ' ', + bubbles: true + })); + assert.ok(onClick.calledOnce, 'HTML marker can be activated from the keyboard'); + done(); + } + }); + }); + QUnit.test('interactive HTML marker content does not forward keyboard commands to the map', function(assert) { + const done = assert.async(); + const onClick = sinon.spy(); + $('#map').dxMap({ + provider: 'osm', + autoAdjust: false, + markers: [{ + location: [40.74, -73.98], + html: '', + onClick + }], + providerConfig: { + tileServer + }, + onReady: () => { + const keyboardTarget = getOpenLayersKeyboardTarget(); + const mapKeydown = sinon.spy(); + const button = openLayersMock.addedOverlays[0].options.element.querySelector('button'); + keyboardTarget.addEventListener('keydown', mapKeydown); + button.dispatchEvent(new KeyboardEvent('keydown', { + key: 'ArrowRight', + bubbles: true + })); + assert.ok(mapKeydown.notCalled, 'the map does not receive the marker control keydown'); + button.click(); + assert.ok(onClick.calledOnce, 'the marker control keeps its click behavior'); + keyboardTarget.removeEventListener('keydown', mapKeydown); + done(); + } + }); + }); + QUnit.test('addMarker and removeMarker manage the OpenLayers overlay and events', function(assert) { + const done = assert.async(); + const marker = { + location: [40.74, -73.98] + }; + const onMarkerAdded = sinon.spy(); + const onMarkerRemoved = sinon.spy(); + const map = $('#map').dxMap({ + provider: 'osm', + autoAdjust: false, + providerConfig: { + tileServer + }, + onMarkerAdded, + onMarkerRemoved, + onReady: () => { + map.addMarker(marker).done(originalMarker => { + const markerElement = openLayersMock.addedOverlays[0].options.element; + assert.strictEqual(originalMarker, openLayersMock.addedOverlays[0], 'addMarker returns the overlay'); + assert.ok(onMarkerAdded.calledOnce, 'onMarkerAdded is fired'); + assert.ok(resizeObserverCallbacks.has(markerElement), 'marker size is observed'); + map.removeMarker(marker).done(() => { + assert.strictEqual(openLayersMock.removedOverlays[0], originalMarker, 'overlay is removed'); + assert.ok(onMarkerRemoved.calledOnce, 'onMarkerRemoved is fired'); + assert.notOk(resizeObserverCallbacks.has(markerElement), 'marker size observation is removed'); + done(); + }); + }); + } + }).dxMap('instance'); + }); + QUnit.test('autoAdjust fits the view to markers', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + markers: [{ + location: [40.7, -74] + }, { + location: [40.8, -73.9] + }], + providerConfig: { + tileServer + }, + onReady: () => { + assert.deepEqual(openLayersMock.fittedExtent, [-74000, 40700, -73900, 40800], 'marker bounds are fitted'); + assert.deepEqual(openLayersMock.fitOptions.padding, [44, 22, 0, 22], 'marker size is included in fit padding'); + done(); + } + }); + }); + QUnit.test('autoAdjust measures HTML marker padding', function(assert) { + const done = assert.async(); + openLayersMock.getOverlayRect = () => ({ + height: 60, + width: 80 + }); + $('#map').dxMap({ + provider: 'osm', + markers: [{ + location: [40.7, -74], + html: 'A', + htmlOffset: { + left: 5, + top: 7 + } + }], + providerConfig: { + tileServer + }, + onReady: () => { + assert.deepEqual(openLayersMock.fitOptions.padding, [0, 85, 67, 0], 'HTML size and offset are included in fit padding'); + done(); + } + }); + }); + QUnit.test('autoAdjust refits the view after a custom marker image loads', function(assert) { + const done = assert.async(); + let imageLoaded = false; + openLayersMock.getOverlayRect = () => imageLoaded ? { + height: 60, + width: 80 + } : { + height: 0, + width: 0 + }; + $('#map').dxMap({ + provider: 'osm', + markers: [{ + location: [40.7, -74], + iconSrc: 'custom-marker.png' + }], + providerConfig: { + tileServer + }, + onReady: () => { + assert.deepEqual(openLayersMock.fitOptions.padding, [41, 13, 0, 13], 'fallback size is used while the image loads'); + imageLoaded = true; + triggerResize(openLayersMock.addedOverlays[0].options.element); + assert.deepEqual(openLayersMock.fitOptions.padding, [60, 40, 0, 40], 'loaded image size is included in fit padding'); + done(); + } + }); + }); + [true, false].forEach(isHtml => { + const markerType = isHtml ? 'HTML' : 'image'; + QUnit.test(`autoAdjust refits after successive ${markerType} marker size changes`, function(assert) { + const done = assert.async(); + let markerSize = { height: 0, width: 0 }; + openLayersMock.getOverlayRect = () => markerSize; + $('#map').dxMap({ + provider: 'osm', + markers: [{ + location: [40.7, -74], + ...(isHtml ? { html: '' } : { iconSrc: 'custom-marker.png' }) + }], + providerConfig: { + tileServer + }, + onReady: () => { + const markerElement = openLayersMock.addedOverlays[0].options.element; + const initialFitCallCount = openLayersMock.fitCallCount; + assert.deepEqual(openLayersMock.fitOptions.padding, + isHtml ? [0, 25, 41, 0] : [41, 13, 0, 13], 'fallback size is used before layout'); + + markerSize = { height: 60, width: 80 }; + triggerResize(markerElement); + assert.strictEqual(openLayersMock.fitCallCount, initialFitCallCount + 1, 'first size change refits the view'); + assert.deepEqual(openLayersMock.fitOptions.padding, + isHtml ? [0, 80, 60, 0] : [60, 40, 0, 40], 'first measured size is included in padding'); + + triggerResize(markerElement); + assert.strictEqual(openLayersMock.fitCallCount, initialFitCallCount + 1, 'unchanged size does not refit'); + + markerSize = { height: 90, width: 120 }; + triggerResize(markerElement); + assert.strictEqual(openLayersMock.fitCallCount, initialFitCallCount + 2, 'second size change refits the view'); + assert.deepEqual(openLayersMock.fitOptions.padding, + isHtml ? [0, 120, 90, 0] : [90, 60, 0, 60], 'padding follows the second size change'); + + triggerResize(markerElement); + assert.strictEqual(openLayersMock.fitCallCount, initialFitCallCount + 2, 'repeated notification still does not refit'); + done(); + } + }); + }); + }); + [ + { type: 'pointerdown' }, + { type: 'pointerdown', onControl: true }, + { type: 'wheel' }, + ...['+', '-', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp'].map(key => ({ type: 'keydown', key })), + { type: 'keydown', key: 'Enter', onControl: true }, + { type: 'keydown', key: ' ', onControl: true } + ].forEach(({ type, key, onControl }) => { + QUnit.test(`marker size refit stops after ${type} ${key || ''} on ${onControl ? 'a control' : 'the map'}`, function(assert) { + let markerSizeChanged = 0; + let markerWidth = 20; + const engine = createOpenLayersEngine(openLayersMock); + const container = document.createElement('div'); + const engineMap = engine.createMap(container); + const eventTarget = onControl ? document.createElement('button') : container; + if(onControl) { + container.appendChild(eventTarget); + } + const bounds = { + northEast: { + lat: 40.7, + lng: -74 + }, + southWest: { + lat: 40.7, + lng: -74 + } + }; + openLayersMock.getOverlayRect = () => ({ + height: markerWidth, + width: markerWidth + }); + engineMap.attachHandlers({ + click: () => {}, + markerSizeChange: () => { + markerSizeChanged += 1; + engineMap.fitBounds(bounds, { includeMarkerPadding: true }); + }, + viewChange: () => {} + }); + engineMap.addMarker({ + iconSrc: 'custom-marker.png', + location: { + lat: 40.7, + lng: -74 + } + }); + const markerElement = openLayersMock.addedOverlays[0].options.element; + engineMap.fitBounds(bounds, { includeMarkerPadding: true }); + + markerWidth = 25; + triggerResize(markerElement); + assert.strictEqual(markerSizeChanged, 1, 'marker size refits before user movement'); + + eventTarget.dispatchEvent(key + ? new KeyboardEvent(type, { bubbles: true, key }) + : new Event(type, { bubbles: true })); + const fitCallCountAfterUserMove = openLayersMock.fitCallCount; + markerWidth = 30; + triggerResize(markerElement); + assert.strictEqual(markerSizeChanged, 1, 'user movement prevents an automatic marker-size refit'); + assert.strictEqual(openLayersMock.fitCallCount, fitCallCountAfterUserMove, 'view is not fitted after user movement'); + + engineMap.fitBounds(bounds, { includeMarkerPadding: true }); + const fitCallCountBeforeResize = openLayersMock.fitCallCount; + markerWidth = 44; + triggerResize(markerElement); + assert.strictEqual(markerSizeChanged, 2, 'autoAdjust fitting enables marker-size refit again'); + assert.strictEqual(openLayersMock.fitCallCount, fitCallCountBeforeResize + 1, 'view is fitted after marker layout changes'); + assert.deepEqual(openLayersMock.fitOptions.padding, [44, 22, 0, 22], 'resumed refit uses the latest marker size'); + + engineMap.dispose(); + }); + }); + [false, true].forEach(afterUserInteraction => { + QUnit.test(`explicit bounds are not refitted after a marker resize (after user interaction: ${afterUserInteraction})`, function(assert) { + let markerWidth = 20; + const container = document.createElement('div'); + const engineMap = createOpenLayersEngine(openLayersMock).createMap(container); + const markerSizeChange = sinon.spy(); + const location = { lat: 40.7, lng: -74 }; + openLayersMock.getOverlayRect = () => ({ height: markerWidth, width: markerWidth }); + engineMap.attachHandlers({ click: () => {}, markerSizeChange, viewChange: () => {} }); + engineMap.addMarker({ iconSrc: 'custom-marker.png', location }); + engineMap.fitBounds({ northEast: location, southWest: location }, { includeMarkerPadding: true }); + + if(afterUserInteraction) { + container.dispatchEvent(new Event('pointerdown', { bubbles: true })); + } + engineMap.fitBounds({ + northEast: { lat: 41, lng: -73 }, + southWest: { lat: 40, lng: -75 } + }); + const fitCallCount = openLayersMock.fitCallCount; + + markerWidth = 44; + triggerResize(openLayersMock.addedOverlays[0].options.element); + + assert.ok(markerSizeChange.notCalled, 'marker resize does not request an automatic refit'); + assert.strictEqual(openLayersMock.fitCallCount, fitCallCount, 'view is not fitted again'); + assert.deepEqual(openLayersMock.fittedExtent, [-75000, 40000, -73000, 41000], 'explicit bounds remain applied'); + + engineMap.dispose(); + }); + }); + QUnit.test('newer marker overlays are rendered above older markers', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const container = document.createElement('div'); + const engineMap = engine.createMap(container); + engineMap.addMarker({ + location: { + lat: 40.7, + lng: -74 + } + }); + engineMap.addMarker({ + location: { + lat: 40.8, + lng: -73.9 + } + }); + + assert.strictEqual(openLayersMock.overlayOptions[0].insertFirst, false, 'first marker uses append order'); + assert.strictEqual(openLayersMock.overlayOptions[1].insertFirst, false, 'second marker uses append order'); + assert.strictEqual(container.lastElementChild, openLayersMock.addedOverlays[1].options.element, 'newer marker is the last overlay element'); + + engineMap.dispose(); + }); + QUnit.test('HTML marker padding is measured after a hidden map becomes visible', function(assert) { + const done = assert.async(); + const $map = $('#map').css({ + display: 'none', + height: '300px', + width: '500px' + }); + const map = $map.dxMap({ + provider: 'osm', + autoAdjust: true, + markers: [{ + html: 'A', + htmlOffset: { + left: 5, + top: 7 + }, + location: [40.7, -74] + }], + providerConfig: { + tileServer + }, + onReady: () => { + const markerElement = openLayersMock.addedOverlays[0].options.element; + markerElement.style.height = '60px'; + markerElement.style.width = '80px'; + assert.deepEqual(openLayersMock.fitOptions.padding, [0, 30, 48, 0], 'fallback padding is used without layout'); + + $map.css('display', 'block'); + map.option('onUpdated', () => { + assert.deepEqual(openLayersMock.fitOptions.padding, [0, 85, 67, 0], 'visible marker dimensions are measured without a layout stub'); + done(); + }); + map._visibilityChanged(true); + } + }).dxMap('instance'); + }); + QUnit.test('autoAdjust keeps the current zoom when fitting would zoom in', function(assert) { + const done = assert.async(); + openLayersMock.fitZoom = 15; + $('#map').dxMap({ + provider: 'osm', + zoom: 12, + markers: [{ + location: [40.7, -74] + }], + providerConfig: { + tileServer + }, + onReady: () => { + assert.strictEqual(openLayersMock.viewZoom, 12, 'zoom is restored after fitting'); + assert.strictEqual(openLayersMock.viewZoomSetCount, 1, 'zoom is restored through the view API'); + done(); + } + }); + }); + QUnit.test('autoAdjust updates the option when fitting zooms out', function(assert) { + const done = assert.async(); + openLayersMock.fitZoom = 8; + const map = $('#map').dxMap({ + provider: 'osm', + zoom: 12, + markers: [{ + location: [40.7, -74] + }, { + location: [41.7, -73] + }], + providerConfig: { + tileServer + }, + onReady: () => { + assert.strictEqual(map.option('zoom'), 8, 'fitted zoom is synchronized with the component'); + done(); + } + }).dxMap('instance'); + }); + QUnit.test('autoAdjust uses the shortest extent across the antimeridian', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + markers: [{ + location: [10, 179] + }, { + location: [20, -179] + }], + providerConfig: { + tileServer + }, + onReady: () => { + assert.deepEqual(openLayersMock.fittedExtent, [179000, 10000, 181000, 20000], 'wrapped marker bounds are fitted'); + assert.deepEqual(openLayersMock.addedOverlays[1].options.position, [181000, 20000], 'wrapped marker is moved into the fitted world'); + openLayersMock.mapInstance.getView().setCenter([-179000, 15000]); + assert.deepEqual(openLayersMock.addedOverlays.map(({ + options + }) => options.position), [[-181000, 10000], [-179000, 20000]], 'markers follow the view into an adjacent world'); + done(); + } + }); + }); + QUnit.test('autoAdjust selects the shortest extent for three markers', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + markers: [{ + location: [10, 0] + }, { + location: [20, -160] + }, { + location: [30, 100] + }], + providerConfig: { + tileServer + }, + onReady: () => { + assert.deepEqual(openLayersMock.fittedExtent, [0, 10000, 200000, 30000], 'largest circular gap is excluded from the fitted extent'); + assert.deepEqual(openLayersMock.addedOverlays.map(({ + options + }) => options.position), [[0, 10000], [200000, 20000], [100000, 30000]], 'all markers use the fitted world'); + done(); + } + }); + }); + QUnit.test('autoAdjust false preserves the current view', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + autoAdjust: false, + markers: [{ + location: [40.7, -74] + }, { + location: [40.8, -73.9] + }], + providerConfig: { + tileServer + }, + onReady: () => { + assert.strictEqual(openLayersMock.fittedExtent, null, 'marker bounds are not fitted'); + done(); + } + }); + }); +}); QUnit.module('OSM: viewport and interactions', moduleConfig, () => { + const tileServer = { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + }; + + QUnit.test('only visible markers participate in sequential keyboard navigation', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + autoAdjust: false, + markers: [{ + location: [40.74, -73.98], + onClick: () => {} + }, { + location: [50, 10], + onClick: () => {} + }], + providerConfig: { + tileServer + }, + onReady: () => { + const visibleMarker = openLayersMock.addedOverlays[0].options.element; + const hiddenMarker = openLayersMock.addedOverlays[1].options.element; + assert.strictEqual(visibleMarker.getAttribute('tabindex'), '0', 'visible marker is keyboard-focusable'); + assert.strictEqual(hiddenMarker.getAttribute('tabindex'), '-1', 'offscreen marker is excluded from the tab order'); + + visibleMarker.focus(); + openLayersMock.viewExtent = [9000, 49900, 10100, 50100]; + openLayersMock.mapInstance.trigger('moveend'); + + assert.strictEqual(visibleMarker.getAttribute('tabindex'), '-1', 'marker leaving the viewport is excluded from the tab order'); + assert.strictEqual(hiddenMarker.getAttribute('tabindex'), '0', 'marker entering the viewport returns to the tab order'); + assert.strictEqual(document.activeElement, getOpenLayersMapTarget(), 'focus returns to the map without panning'); + done(); + } + }); + }); + QUnit.test('marker focus is managed inside Shadow DOM', 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); + $('#qunit-fixture').append(host); + const engineMap = engine.createMap(container); + engineMap.attachHandlers({ + click: () => {}, + markerSizeChange: () => {}, + viewChange: () => {} + }); + engineMap.addMarker({ + location: { + lat: 40.74, + lng: -73.98 + }, + onClick: () => {} + }); + const markerElement = openLayersMock.addedOverlays[0].options.element; + markerElement.focus(); + assert.strictEqual(shadowRoot.activeElement, markerElement, 'visible marker receives focus inside Shadow DOM'); + + openLayersMock.viewExtent = [0, 0, 100, 100]; + openLayersMock.mapInstance.trigger('moveend'); + assert.strictEqual(markerElement.getAttribute('tabindex'), '-1', 'offscreen marker leaves the tab order'); + assert.strictEqual(shadowRoot.activeElement, container, 'focus returns to the Shadow DOM map target'); + + engineMap.dispose(); + }); QUnit.test('focus options are applied to the OpenLayers keyboard target', function(assert) { const done = assert.async(); const map = $('#map').dxMap({ provider: 'osm', focusStateEnabled: false, tabIndex: 5, + markers: [{ + location: [40.74, -73.98], + onClick: () => {} + }, { + location: [40.75, -73.97], + html: '', + onClick: () => {} + }], providerConfig: { tileServer: { url: 'https://tiles.example.com/{z}/{x}/{y}.png', @@ -791,13 +1964,21 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { }, onReady: () => { const target = getOpenLayersKeyboardTarget(); + const markerElement = openLayersMock.addedOverlays[0].options.element; + const customButton = openLayersMock.addedOverlays[1].options.element.querySelector('button'); assert.strictEqual(target.getAttribute('tabindex'), null, 'focus is disabled on initialization'); + assert.strictEqual(markerElement.getAttribute('tabindex'), '-1', 'marker is removed from the tab order on initialization'); + assert.strictEqual(customButton.getAttribute('tabindex'), '-1', 'custom interactive content is removed from the tab order'); map.option('onUpdated', () => { assert.strictEqual(target.getAttribute('tabindex'), '5', 'configured tabIndex is applied'); + assert.strictEqual(markerElement.getAttribute('tabindex'), '0', 'marker focus is enabled'); + assert.strictEqual(customButton.getAttribute('tabindex'), null, 'custom interactive content returns to the tab order'); map.option('onUpdated', () => { assert.strictEqual(target.getAttribute('tabindex'), '-1', 'runtime tabIndex is applied'); map.option('onUpdated', () => { assert.strictEqual(target.getAttribute('tabindex'), null, 'runtime focus disabling is applied'); + assert.strictEqual(markerElement.getAttribute('tabindex'), '-1', 'marker is removed from the tab order at runtime'); + assert.strictEqual(customButton.getAttribute('tabindex'), '-1', 'custom interactive content leaves the tab order'); done(); }); map.option('focusStateEnabled', false); @@ -879,7 +2060,10 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { const engine = createOpenLayersEngine(openLayersMock); const engineMap = engine.createMap(document.createElement('div')); const click = sinon.spy(); - engineMap.attachHandlers({ click, viewChange: sinon.spy() }); + engineMap.attachHandlers({ + click, + viewChange: sinon.spy() + }); openLayersMock.mapInstance.trigger('click', { originalEvent: new PointerEvent('click') }); @@ -1075,6 +2259,26 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { } }).dxMap('instance'); }); + QUnit.test('disabled state removes clickable markers from keyboard navigation', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const engineMap = engine.createMap(document.createElement('div')); + engineMap.addMarker({ + location: { + lat: 40.74, + lng: -73.98 + }, + onClick: () => {} + }); + const markerElement = openLayersMock.addedOverlays[0].options.element; + + assert.strictEqual(markerElement.getAttribute('tabindex'), '0', 'clickable marker starts in the tab order'); + engineMap.setDisabled(true); + assert.strictEqual(markerElement.getAttribute('tabindex'), '-1', 'disabled marker leaves the tab order'); + engineMap.setDisabled(false); + assert.strictEqual(markerElement.getAttribute('tabindex'), '0', 'marker returns to the tab order'); + + engineMap.dispose(); + }); QUnit.test('bounds option fits the OpenLayers view on initialization and at runtime', function(assert) { const done = assert.async(); const map = $('#map').dxMap({ @@ -1134,7 +2338,7 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { } }).dxMap('instance'); }); - QUnit.test('RTL mode preserves viewport and keyboard behavior', function(assert) { + QUnit.test('RTL mode preserves viewport, overlay positioning, and keyboard behavior', function(assert) { const done = assert.async(); $('#map').dxMap({ provider: 'osm', @@ -1143,6 +2347,13 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { lng: -73.98 }, controls: true, + markers: [{ + location: { + lat: 40.74, + lng: -73.98 + }, + html: 'Marker text' + }], rtlEnabled: true, providerConfig: { tileServer: { @@ -1153,6 +2364,12 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { onReady: () => { assert.ok($('#map').hasClass('dx-rtl'), 'RTL mode is applied to the widget'); assert.deepEqual(openLayersMock.viewCenter, [-73980, 40740], 'center coordinates are not mirrored'); + assert.strictEqual(openLayersMock.addedOverlays.length, 1, 'marker overlay remains available'); + const markerElement = openLayersMock.addedOverlays[0].options.element; + assert.strictEqual(markerElement.getAttribute('dir'), 'rtl', 'HTML marker uses the widget text direction'); + assert.strictEqual(getComputedStyle(markerElement.querySelector('.rtl-marker-text')).direction, 'rtl', 'HTML marker text inherits RTL direction'); + assert.strictEqual(openLayersMock.overlayContainer.getAttribute('dir'), 'ltr', 'regular overlays use LTR coordinates'); + assert.strictEqual(openLayersMock.overlayContainerStopEvent.getAttribute('dir'), 'ltr', 'interactive overlays use LTR coordinates'); assert.strictEqual(openLayersMock.addedControls.length, 1, 'zoom control remains available'); assert.strictEqual(getOpenLayersKeyboardTarget().getAttribute('tabindex'), '0', 'map remains keyboard focusable'); done(); @@ -1164,9 +2381,14 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { const engineMap = engine.createMap(document.createElement('div')); const click = sinon.spy(); const viewChange = sinon.spy(); - engineMap.attachHandlers({ click, viewChange }); + engineMap.attachHandlers({ + click, + viewChange + }); engineMap.dispose(); - openLayersMock.mapInstance.trigger('click', { coordinate: [-73980, 40740] }); + openLayersMock.mapInstance.trigger('click', { + coordinate: [-73980, 40740] + }); openLayersMock.mapInstance.trigger('moveend'); assert.ok(click.notCalled, 'click handler is detached'); assert.ok(viewChange.notCalled, 'view change handler is detached'); From 538f332d1c9ddd4bb4d7b1929e0cc40394ca6f97 Mon Sep 17 00:00:00 2001 From: Alisher Amonulloev Date: Thu, 10 Sep 2026 16:06:59 +0300 Subject: [PATCH 06/28] Map: OSM provider - Add route calculation and rendering (#35108) --- .../stories/map/OSMMap.stories.tsx | 160 ++++- apps/react-storybook/stories/map/routes.ts | 254 +++++++ .../ui/map/provider.dynamic.osm.engine.ts | 13 + .../ui/map/provider.dynamic.osm.openlayers.ts | 54 ++ ...vider.dynamic.osm.openlayers.utils.test.ts | 7 +- .../provider.dynamic.osm.openlayers.utils.ts | 36 +- .../ui/map/provider.dynamic.osm.route.test.ts | 89 +++ .../ui/map/provider.dynamic.osm.route.ts | 65 ++ .../ui/map/provider.dynamic.osm.test.ts | 31 + .../__internal/ui/map/provider.dynamic.osm.ts | 137 +++- .../js/__internal/ui/map/provider.ts | 28 +- .../js/ui/map/openlayers.register.js | 11 + .../testing/helpers/forMap/openLayersMock.js | 85 ++- .../mapParts/commonTests.js | 54 ++ .../mapParts/googleStaticTests.js | 22 + .../mapParts/osmTests.js | 679 ++++++++++++++++++ 16 files changed, 1661 insertions(+), 64 deletions(-) create mode 100644 apps/react-storybook/stories/map/routes.ts create mode 100644 packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.route.test.ts create mode 100644 packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.route.ts diff --git a/apps/react-storybook/stories/map/OSMMap.stories.tsx b/apps/react-storybook/stories/map/OSMMap.stories.tsx index 433c67c296a5..a6ca9ad15817 100644 --- a/apps/react-storybook/stories/map/OSMMap.stories.tsx +++ b/apps/react-storybook/stories/map/OSMMap.stories.tsx @@ -11,17 +11,17 @@ import React from 'react'; import Button from 'devextreme-react/button'; import Map, { type MapRef } from 'devextreme-react/map'; import type { + CalculateOsmRouteInfo, MapLocation, MapType, ReadyEvent, + OsmRouteResult, } from 'devextreme/ui/map'; import 'devextreme/ui/map/openlayers'; -const CENTER = { lat: 40.7484, lng: -73.9857 }; +import { ROUTE_PATHS } from './routes'; + const CENTRAL_PARK_CENTER = { lat: 40.7829, lng: -73.9654 }; -const DEFAULT_MARKER_LOCATION = 'Empire State Building'; -const CUSTOM_MARKER_LOCATION = 'Bryant Park'; -const ADDED_MARKER_LOCATION = 'Times Square'; const EXTENT: [number, number, number, number] = [-74.08, 40.67, -73.85, 40.88]; const TILE_SERVER = { url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', @@ -29,30 +29,76 @@ const TILE_SERVER = { maxZoom: 19, }; const MARKER_LOCATIONS: Record = { - [DEFAULT_MARKER_LOCATION]: CENTER, - [CUSTOM_MARKER_LOCATION]: { lat: 40.7536, lng: -73.9832 }, - [ADDED_MARKER_LOCATION]: { lat: 40.758, lng: -73.9855 }, + 'Columbus Circle': { lat: 40.768161, lng: -73.981906 }, + 'Belvedere Castle': { lat: 40.779316, lng: -73.968882 }, + 'Great Hill': { lat: 40.797269, lng: -73.958993 }, + 'Dana Discovery Center': { lat: 40.797064, lng: -73.951349 }, + 'Conservatory Garden': { lat: 40.793621, lng: -73.95261 }, + 'Bethesda Fountain': { lat: 40.774498, lng: -73.970867 }, +}; +const ROUTE_PRESETS = { + centralParkRun: { + mode: 'walking', + title: 'Central Park Run — approx. 10.1 km', + markerDescription: 'Blue: Columbus Circle start / finish. Red: Dana Discovery Center.', + center: { lat: 40.7827, lng: -73.9666 }, + zoom: 14, + locations: [ + 'Columbus Circle', + 'Belvedere Castle', + 'Great Hill', + 'Dana Discovery Center', + 'Conservatory Garden', + 'Bethesda Fountain', + 'Columbus Circle', + ], + markers: [{ + location: 'Columbus Circle', + }, { + location: 'Dana Discovery Center', + iconSrc: 'images/maps/map-marker.png', + }], + extraMarker: MARKER_LOCATIONS['Belvedere Castle'], + }, + manhattanDrive: { + mode: 'driving', + title: 'Manhattan Drive — official Routes demo waypoints, approx. 11 km', + markerDescription: 'Four markers: coordinate string, arrays and an object. Red: custom icon.', + center: { lat: 40.75, lng: -73.986 }, + zoom: 14, + locations: [ + [40.7825, -73.966111], + [40.755833, -73.986389], + [40.753889, -73.981389], + [40.713474, -74.005536], + ], + markers: [{ + location: '40.7825, -73.966111', + }, { + location: [40.755833, -73.986389], + iconSrc: 'images/maps/map-marker.png', + }, { + location: { lat: 40.753889, lng: -73.981389 }, + }, { + location: [40.713474, -74.005536], + }], + extraMarker: { lat: 40.748441, lng: -73.985664 }, + }, }; const PROVIDER_CONFIG = { calculateLocation: (query: string): Promise => ( Promise.resolve(MARKER_LOCATIONS[query]) ), + calculateRoute: ({ mode }: CalculateOsmRouteInfo): Promise => ( + Promise.resolve(mode === 'walking' + ? ROUTE_PATHS.walking + : ROUTE_PATHS.driving.coordinates.map(([lng, lat]) => [lat, lng])) + ), tileServer: () => TILE_SERVER, }; const handleMarkerClick = fn(); -const DEFAULT_MARKER = { - location: DEFAULT_MARKER_LOCATION, - onClick: handleMarkerClick, -}; -const CUSTOM_MARKER = { - location: CUSTOM_MARKER_LOCATION, - iconSrc: 'images/maps/map-marker.png', - onClick: handleMarkerClick, -}; -const ADDED_MARKER = { - location: ADDED_MARKER_LOCATION, - onClick: handleMarkerClick, -}; +const handleRouteAdded = fn(); +const handleRouteRemoved = fn(); const STORY_STYLE: React.CSSProperties = { display: 'flex', flexDirection: 'column', @@ -71,6 +117,11 @@ interface OsmStoryArgs { disabled: boolean; focusStateEnabled: boolean; rtlEnabled: boolean; + showRoute: boolean; + routeColor: string; + routePreset: keyof typeof ROUTE_PRESETS; + routeOpacity: number; + routeWeight: number; type: MapType; zoom: number; } @@ -107,14 +158,39 @@ const OsmMapStory = ({ disabled, focusStateEnabled, rtlEnabled, + showRoute, + routeColor, + routePreset, + routeOpacity, + routeWeight, type, updateArgs, zoom, }: OsmMapStoryProps): React.ReactElement => { const mapRef = React.useRef(null); const [markerAdded, setMarkerAdded] = React.useState(false); - const markers = React.useMemo(() => [DEFAULT_MARKER, CUSTOM_MARKER], []); - const center = centerOnCentralPark ? CENTRAL_PARK_CENTER : CENTER; + const preset = ROUTE_PRESETS[routePreset]; + const markers = React.useMemo(() => preset.markers.map((marker) => ({ + ...marker, + onClick: handleMarkerClick, + })), [preset]); + const addedMarker = React.useMemo(() => ({ + location: preset.extraMarker, + onClick: handleMarkerClick, + }), [preset]); + const routes = React.useMemo(() => showRoute ? [{ + locations: preset.locations, + color: routeColor, + mode: preset.mode, + opacity: routeOpacity, + weight: routeWeight, + }] : [], [preset, showRoute, routeColor, routeOpacity, routeWeight]); + const center = centerOnCentralPark ? CENTRAL_PARK_CENTER : preset.center; + + React.useEffect(() => { + setMarkerAdded(false); + mapRef.current?.instance()?.option('zoom', preset.zoom); + }, [preset]); React.useEffect(() => { mapRef.current?.instance()?.option('center', center); @@ -127,7 +203,7 @@ const OsmMapStory = ({ } setMarkerAdded(true); - void map.addMarker(ADDED_MARKER).then(undefined, () => setMarkerAdded(false)); + void map.addMarker(addedMarker).then(undefined, () => setMarkerAdded(false)); }; const removeMarker = (): void => { @@ -137,11 +213,12 @@ const OsmMapStory = ({ } setMarkerAdded(false); - void map.removeMarker(ADDED_MARKER).then(undefined, () => setMarkerAdded(true)); + void map.removeMarker(addedMarker).then(undefined, () => setMarkerAdded(true)); }; return (
+
{preset.title}. {preset.markerDescription}
', + tooltip: 'Details' + }] }); + positionMarker(); + const marker = getMarker(); + const buttons = marker.querySelectorAll('button'); + buttons[0].click(); + const popup = getContent(getTooltip()).parentElement; + assert.strictEqual(buttons[0].getAttribute('aria-describedby'), `existing-description ${popup.id}`, 'first button keeps its existing description'); + assert.strictEqual(buttons[1].getAttribute('aria-describedby'), popup.id, 'second button is also described'); + assert.notOk(marker.hasAttribute('aria-describedby'), 'the non-focusable wrapper is not described'); + assert.strictEqual(getTooltip().option('position').of, marker, 'position remains relative to the entire marker'); + }); + + QUnit.test('Escape closes a nested SelectBox before its marker tooltip', async function(assert) { + await createMap({ markers: [{ location, tooltip: '
' }] }); + positionMarker(); + getMarker().click(); + const tooltip = getTooltip(); + const selectBox = new SelectBox(getContent(tooltip).querySelector('.nested-select-box'), { + items: ['First', 'Second'], + value: 'First', + dropDownOptions: { animation: undefined } + }); + try { + selectBox.focus(); + selectBox.open(); + const input = getContent(tooltip).querySelector('.dx-texteditor-input'); + assert.ok(selectBox.option('opened'), 'the nested list is open'); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + assert.notOk(selectBox.option('opened'), 'the first Escape closes the nested list'); + assert.ok(tooltip.option('visible'), 'the tooltip remains open'); + assert.strictEqual(document.activeElement, input, 'the editor retains focus'); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + assert.notOk(tooltip.option('visible'), 'the next Escape closes the tooltip'); + } finally { + selectBox.dispose(); + } + }); + + QUnit.test('onShowing can cancel showing without changing the marker click location', async function(assert) { + let clickLocation; + await createMap({ markers: [{ + location, + tooltip: 'Start', + onClick: (event) => { + clickLocation = event.location; + event.tooltip.option('onShowing', e => { e.cancel = true; }); + } + }] }); + positionMarker(); + getMarker().click(); + assert.deepEqual(clickLocation, location, 'sidebar can use the coordinates'); + assert.notOk(getTooltip().option('visible'), 'onShowing cancels the popup'); + }); + + QUnit.test('public contentTemplate and hide support a user-provided Close button', async function(assert) { + await createMap({ markers: [{ location, tooltip: 'Start' }] }); + positionMarker(); + const tooltip = getTooltip(); + tooltip.option('contentTemplate', () => $('' }] }); + positionMarker(); + const marker = getMarker(); + const tooltip = getTooltip(); + if(shownBefore) { + marker.click(); + await Promise.resolve(); + await tooltip.hide(); + } + const popup = getContent(tooltip).parentElement; + const position = sinon.spy(tooltip, '_renderPosition'); + const boundaryRect = sinon.spy(getOpenLayersMapTarget(), 'getBoundingClientRect'); + const markerRect = sinon.spy(marker, 'getBoundingClientRect'); + const popupRect = sinon.spy(popup, 'getBoundingClientRect'); + const focusTargets = sinon.spy(popup, 'querySelectorAll'); + + openLayersMock.mapInstance.trigger('postrender'); + + assert.notOk(tooltip.option('visible'), 'tooltip remains hidden'); + assert.ok(position.notCalled, 'position is not recalculated'); + assert.ok(boundaryRect.notCalled, 'map bounds are not measured'); + assert.ok(markerRect.notCalled, 'marker bounds are not measured'); + assert.ok(popupRect.notCalled, 'popup bounds are not measured'); + assert.ok(focusTargets.notCalled, 'focusable content is not queried'); + }); + }); + + QUnit.test('postrender updates the position without repainting or recalculating dimensions', async function(assert) { + await createMap({ markers: [{ location, tooltip: { text: 'Start', isShown: true } }] }); + const tooltip = getTooltip(); + const position = sinon.spy(tooltip, '_renderPosition'); + const dimensions = sinon.spy(tooltip, '_renderDimensions'); + const repaint = sinon.spy(tooltip, 'repaint'); + openLayersMock.mapInstance.trigger('postrender'); + assert.ok(position.calledOnceWithExactly(false), 'position only'); + assert.ok(dimensions.notCalled, 'dimensions are not recomputed'); + assert.ok(repaint.notCalled, 'no full repaint'); + }); + + QUnit.test('tooltip position follows a resized image marker', async function(assert) { + await createMap({ markers: [{ + location, + iconSrc: 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=', + tooltip: 'Start' + }] }); + positionMarker(); + const marker = getMarker(); + $(marker).css({ width: 25, height: 40, transform: 'translateY(-100%)' }); + marker.click(); + const tooltip = getTooltip(); + const popup = getContent(tooltip).parentElement; + const initialTop = popup.getBoundingClientRect().top; + $(marker).css('height', 70); + triggerResize(marker); + assert.strictEqual(popup.getBoundingClientRect().top, initialTop - 30, 'popup follows the changed image bounds'); + assert.strictEqual(tooltip.option('position').of, marker, 'the image remains the position target'); + }); + + QUnit.test('tooltip positioning follows its marker into a wrapped world', async function(assert) { + await createMap({ markers: [{ location, tooltip: 'Start' }] }); + positionMarker(); + getMarker().click(); + const tooltip = getTooltip(); + const position = sinon.spy(tooltip, '_renderPosition'); + openLayersMock.viewExtent = [285900, 40600, 286200, 40900]; + openLayersMock.mapInstance.getView().setCenter([286020, 40740]); + assert.deepEqual(openLayersMock.addedOverlays[0].options.position, [286020, 40740], 'marker moves to the adjacent world'); + assert.ok(position.calledWithExactly(false), 'popup position is updated without resizing'); + assert.strictEqual(tooltip.option('target'), getMarker(), 'the same marker remains the target'); + }); + + QUnit.test('an open offscreen popover stays open but its controls cannot scroll the map on focus', async function(assert) { + await createMap({ markers: [{ location, tooltip: '' }] }); + positionMarker(); + getMarker().click(); + const tooltip = getTooltip(); + const popup = getContent(tooltip).parentElement; + const button = popup.querySelector('button'); + button.focus(); + $(getMarker()).css('top', -1000); + openLayersMock.mapInstance.trigger('postrender'); + assert.ok(tooltip.option('visible'), 'remains logically open'); + assert.ok(popup.inert, 'offscreen popup is inert'); + assert.strictEqual(document.activeElement, getOpenLayersMapTarget(), 'focus moves to the map without scrolling'); + positionMarker(); + openLayersMock.mapInstance.trigger('postrender'); + assert.notOk(popup.inert, 'returning content can receive focus'); + }); + + QUnit.test('a marker with a closed offscreen tooltip becomes interactive after returning to the viewport', async function(assert) { + await createMap({ markers: [{ location, tooltip: '' }] }); + positionMarker(); + const marker = getMarker(); + const tooltip = getTooltip(); + marker.click(); + await Promise.resolve(); + positionMarker(marker, -1000); + openLayersMock.mapInstance.trigger('postrender'); + assert.ok(marker.inert, 'offscreen marker cannot receive focus'); + await tooltip.hide(); + + positionMarker(); + openLayersMock.mapInstance.trigger('postrender'); + openLayersMock.mapInstance.trigger('moveend'); + + assert.notOk(tooltip.option('visible'), 'moving the map does not reopen the tooltip'); + assert.notOk(marker.inert, 'marker accessibility is restored at the end of movement'); + assert.strictEqual(marker.tabIndex, 0, 'marker returns to the tab order'); + marker.focus(); + assert.strictEqual(document.activeElement, marker, 'marker can receive focus'); + marker.click(); + assert.ok(tooltip.option('visible'), 'tooltip can be reopened'); + assert.notOk(getContent(tooltip).parentElement.inert, 'reopened content is interactive'); + }); + + QUnit.test('disabled and focusStateEnabled cover content added with public options', async function(assert) { + const map = await createMap({ markers: [{ location, tooltip: 'Start' }] }); + positionMarker(); + const tooltip = getTooltip(); + tooltip.option('contentTemplate', () => $('' }], onClick }); + positionMarker(); + getMarker().click(); + const popup = getContent(getTooltip()).parentElement; + const mapTarget = getOpenLayersMapTarget(); + const wheel = sinon.spy(); + const click = sinon.spy(); + mapTarget.addEventListener('wheel', wheel); + mapTarget.addEventListener('click', click); + popup.querySelector('button').click(); + popup.dispatchEvent(new WheelEvent('wheel', { bubbles: true })); + openLayersMock.mapInstance.trigger('click', { coordinate: [-73980, 40740], originalEvent: { target: popup } }); + assert.ok(onClick.notCalled, 'engine event is filtered'); + assert.ok(click.notCalled, 'DOM click is stopped'); + assert.ok(wheel.calledOnce, 'wheel reaches the map'); + mapTarget.removeEventListener('wheel', wheel); + mapTarget.removeEventListener('click', click); + }); + + [false, true].forEach(rtlEnabled => { + QUnit.test(`tooltip keyboard activation and cleanup work in Shadow DOM (RTL: ${rtlEnabled})`, function(assert) { + const host = document.createElement('div'); + document.getElementById('qunit-fixture').appendChild(host); + const shadow = host.attachShadow({ mode: 'open' }); + const container = document.createElement('div'); + Object.assign(container.style, { width: '600px', height: '400px' }); + container.tabIndex = 0; + shadow.appendChild(container); + const engine = createOpenLayersEngine(openLayersMock); + const engineMap = engine.createMap(container, { center: location, zoom: 12 }); + try { + const marker = engineMap.addMarker({ location, tooltip: { text: 'Start', visible: false }, rtlEnabled }); + const element = marker.originalMarker.options.element; + positionMarker(element); + element.focus(); + element.click(); + const [tooltip] = getPopovers(shadow); + assert.ok(tooltip.option('visible'), 'popover opens'); + assert.strictEqual(shadow.activeElement, element, 'focus stays on the marker'); + assert.strictEqual(tooltip.option('rtlEnabled'), rtlEnabled, 'direction is forwarded'); + element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, composed: true })); + assert.notOk(tooltip.option('visible'), 'Escape reaches the stock component'); + marker.dispose(); + assert.strictEqual(getPopovers(shadow).length, 0, 'removal cleans up the widget'); + } finally { + engineMap.dispose(); + host.remove(); + } + }); + }); + + QUnit.test('removing a focused tooltip focuses the map and detaches render synchronization', async function(assert) { + const options = { location, tooltip: '' }; + const map = await createMap({ markers: [options] }); + positionMarker(); + getMarker().click(); + const tooltip = getTooltip(); + getContent(tooltip).querySelector('button').focus(); + const position = sinon.spy(tooltip, '_renderPosition'); + await map.removeMarker(options); + position.resetHistory(); + openLayersMock.mapInstance.trigger('postrender'); + assert.strictEqual(document.activeElement, getOpenLayersMapTarget(), 'removal restores focus'); + assert.ok(position.notCalled, 'render subscription is removed'); + assert.strictEqual(getPopovers().length, 0, 'widget host is removed'); + }); + + QUnit.test('runtime tooltip updates and map disposal do not retain old popovers', async function(assert) { + const map = await createMap({ markers: [{ location, tooltip: 'Old' }] }); + const dispose = sinon.spy(getTooltip(), 'dispose'); + map.option('markers[0].tooltip', { text: 'New', isShown: true }); + await map._lastAsyncAction; + assert.ok(dispose.calledOnce, 'old component is disposed'); + assert.strictEqual(getPopovers().length, 1, 'one current component'); + assert.strictEqual(getContent(getTooltip()).textContent, 'New', 'new content is rendered'); + map.dispose(); + assert.strictEqual(getPopovers().length, 0, 'no popover remains after disposal'); + }); +}); + QUnit.module('OSM: routes', moduleConfig, () => { const tileServer = { url: 'https://tiles.example.com/{z}/{x}/{y}.png', diff --git a/packages/devextreme/ts/dx.all.d.ts b/packages/devextreme/ts/dx.all.d.ts index 123c8d66bb46..ebe46ac27770 100644 --- a/packages/devextreme/ts/dx.all.d.ts +++ b/packages/devextreme/ts/dx.all.d.ts @@ -23386,6 +23386,20 @@ declare module DevExpress.ui { */ originalMarker: any; }; + /** + * [descr:_ui_map_MarkerClickEvent] + */ + export type MarkerClickEvent = + DevExpress.common.core.events.EventInfo & { + /** + * [descr:_ui_map_MarkerClickEvent.location] + */ + location: MapLocation; + /** + * [descr:_ui_map_MarkerClickEvent.tooltip] + */ + tooltip?: dxPopover; + }; /** * [descr:_ui_map_MarkerRemovedEvent] */ @@ -23546,7 +23560,7 @@ declare module DevExpress.ui { /** * [descr:dxMapOptions.markers.onClick] */ - onClick?: Function; + onClick?: (e: DevExpress.ui.dxMap.MarkerClickEvent) => void; /** * [descr:dxMapOptions.markers.tooltip] */ From 89424181b93351eaaa0ed7227e3c5ddc8e82a914 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Thu, 8 Oct 2026 14:31:42 +0300 Subject: [PATCH 08/28] Map: fix OpenLayers projection mock types --- .../ui/map/provider.dynamic.osm.openlayers.utils.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.test.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.test.ts index 775305e209a5..fdf89916ef3a 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.test.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.test.ts @@ -52,7 +52,7 @@ describe('OpenLayers utils', () => { describe('coordinate conversion', () => { it('converts a Map location to the active OpenLayers projection', () => { - const transform = jest.fn(() => [100, 200] as [number, number]); + const transform = jest.fn().mockReturnValue([100, 200]); const api = { proj: { getUserProjection: () => 'user-projection', @@ -70,7 +70,7 @@ describe('OpenLayers utils', () => { }); it('converts an OpenLayers coordinate to a Map location', () => { - const toLonLat = jest.fn(() => [-74, 40.7] as [number, number]); + const toLonLat = jest.fn().mockReturnValue([-74, 40.7]); const api = { proj: { getUserProjection: () => null, From ebb01a2d681199b3eafd15a79db07e4d4f58554d Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Sat, 10 Oct 2026 09:31:29 +0300 Subject: [PATCH 09/28] Map: update OSM selector imports after module rename --- .../ui/map/provider.dynamic.osm.openlayers.tooltip.ts | 2 +- .../js/__internal/ui/map/provider.dynamic.osm.openlayers.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts index e27cf6623743..58ea172553e8 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts @@ -3,7 +3,7 @@ import domAdapter from '@js/core/dom_adapter'; import $ from '@js/core/renderer'; import type { Properties } from '@js/ui/popover'; import Popover from '@js/ui/popover'; -import { ALL_FOCUSABLE_ELEMENTS_SELECTOR } from '@ts/core/utils/m_selectors'; +import { ALL_FOCUSABLE_ELEMENTS_SELECTOR } from '@ts/core/utils/selectors'; import type InternalPopover from '@ts/ui/popover/popover'; import { DEFAULT_MARKER_CLASS } from './provider.dynamic.osm.openlayers.marker'; 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 index f8d4bf355ce1..f25a59cc188a 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts @@ -2,7 +2,7 @@ import Color from '@js/color'; import messageLocalization from '@js/common/core/localization/message'; import domAdapter from '@js/core/dom_adapter'; import resizeObserverSingleton from '@js/core/resize_observer'; -import { ALL_FOCUSABLE_ELEMENTS_SELECTOR } from '@ts/core/utils/m_selectors'; +import { ALL_FOCUSABLE_ELEMENTS_SELECTOR } from '@ts/core/utils/selectors'; import type { MapEngine, From 59f00d5282992aad560290cbd60a36fc52eb02c3 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Wed, 14 Oct 2026 16:16:52 +0300 Subject: [PATCH 10/28] Map: revert OSM changes in legacy Angular nested components --- .../src/ui/nested/marker-dxi.ts | 5 ++-- .../src/ui/nested/provider-config.ts | 25 ------------------- 2 files changed, 2 insertions(+), 28 deletions(-) diff --git a/packages/devextreme-angular/src/ui/nested/marker-dxi.ts b/packages/devextreme-angular/src/ui/nested/marker-dxi.ts index 4b7b11f282f9..e055d78fa6da 100644 --- a/packages/devextreme-angular/src/ui/nested/marker-dxi.ts +++ b/packages/devextreme-angular/src/ui/nested/marker-dxi.ts @@ -8,7 +8,6 @@ import { ContentChildren, QueryList } from '@angular/core'; -import type { MarkerClickEvent } from 'devextreme/ui/map'; import { DxIntegrationModule, @@ -58,10 +57,10 @@ export class DxiMarkerComponent extends CollectionNestedOption { } @Input() - get onClick(): ((e: MarkerClickEvent) => void) { + get onClick(): Function { return this._getOption('onClick'); } - set onClick(value: ((e: MarkerClickEvent) => void)) { + set onClick(value: Function) { this._setOption('onClick', value); } diff --git a/packages/devextreme-angular/src/ui/nested/provider-config.ts b/packages/devextreme-angular/src/ui/nested/provider-config.ts index d3113c76dcb3..072dee05f2cf 100644 --- a/packages/devextreme-angular/src/ui/nested/provider-config.ts +++ b/packages/devextreme-angular/src/ui/nested/provider-config.ts @@ -8,7 +8,6 @@ import { SkipSelf, Input } from '@angular/core'; -import type { OsmTileServer } from 'devextreme/ui/map'; import { DxIntegrationModule, @@ -24,22 +23,6 @@ import { NestedOption } from 'devextreme-angular/core'; providers: [NestedOptionHost] }) export class DxoProviderConfigComponent extends NestedOption implements OnDestroy, OnInit { - @Input() - get calculateLocation(): Function { - return this._getOption('calculateLocation'); - } - set calculateLocation(value: Function) { - this._setOption('calculateLocation', value); - } - - @Input() - get calculateRoute(): Function { - return this._getOption('calculateRoute'); - } - set calculateRoute(value: Function) { - this._setOption('calculateRoute', value); - } - @Input() get mapId(): string { return this._getOption('mapId'); @@ -48,14 +31,6 @@ 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'); From 4dde8661261c5b145ce909b59aa0ae5c05c8df03 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Wed, 14 Oct 2026 16:17:04 +0300 Subject: [PATCH 11/28] Map: use Stadia Alidade Bright in the OSM Storybook example --- .../stories/map/OSMMap.stories.tsx | 33 ++++++------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/apps/react-storybook/stories/map/OSMMap.stories.tsx b/apps/react-storybook/stories/map/OSMMap.stories.tsx index 566feb0a4cb1..72526ae7a9de 100644 --- a/apps/react-storybook/stories/map/OSMMap.stories.tsx +++ b/apps/react-storybook/stories/map/OSMMap.stories.tsx @@ -5,8 +5,6 @@ import { fn } from 'storybook/test'; import Attribution from 'ol/control/Attribution.js'; import type OpenLayersMap from 'ol/Map.js'; import 'ol/ol.css'; -import { fromLonLat, transformExtent } from 'ol/proj.js'; -import View from 'ol/View.js'; import React from 'react'; import Button from 'devextreme-react/button'; import Map, { type MapRef } from 'devextreme-react/map'; @@ -22,11 +20,14 @@ import 'devextreme/ui/map/openlayers'; import { ROUTE_PATHS } from './routes'; const CENTRAL_PARK_CENTER = { lat: 40.7829, lng: -73.9654 }; -const EXTENT: [number, number, number, number] = [-74.08, 40.67, -73.85, 40.88]; +// Demo only: tiles are served under Developer Express Inc's Stadia Maps subscription via domain-based authentication. +// This configuration will not work on your domain. Configure your own tile provider here. const TILE_SERVER = { - url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - attribution: '© OpenStreetMap contributors', - maxZoom: 19, + url: 'https://tiles-eu.stadiamaps.com/tiles/alidade_bright/{z}/{x}/{y}.png', + attribution: '© Stadia Maps ' + + '© OpenMapTiles ' + + '© OpenStreetMap contributors', + maxZoom: 20, }; const MARKER_LOCATIONS: Record = { 'Columbus Circle': { lat: 40.768161, lng: -73.981906 }, @@ -132,25 +133,13 @@ interface OsmMapStoryProps extends OsmStoryArgs { updateArgs: (args: Partial) => void; } -const configureOpenLayersMap = ( - { originalMap }: ReadyEvent, - center: MapLocation, - zoom: number, -): void => { +const configureAttribution = ({ originalMap }: ReadyEvent): void => { const map = originalMap as OpenLayersMap; const attribution = map.getControls().getArray() .find((control) => control instanceof Attribution) as Attribution | undefined; attribution?.setCollapsed(false); attribution?.setCollapsible(false); - map.setView(new View({ - center: fromLonLat([center.lng, center.lat]), - extent: transformExtent(EXTENT, 'EPSG:4326', 'EPSG:3857'), - maxZoom: 16, - minZoom: 14, - smoothExtentConstraint: false, - zoom, - })); }; const OsmMapStory = ({ @@ -263,7 +252,7 @@ const OsmMapStory = ({ zoom={zoom} height={520} width="100%" - onReady={(event) => configureOpenLayersMap(event, center, zoom)} + onReady={configureAttribution} onZoomChange={(value) => updateArgs({ zoom: value })} />
@@ -329,14 +318,12 @@ const meta: Meta = { type: { control: 'select', options: ['roadmap', 'satellite', 'hybrid'], - description: 'The public OSM tile server provides one style, so this control exercises ' + description: 'The Stadia Maps Alidade Bright tile source provides one style, so this control exercises ' + 'type changes without changing the map appearance.', }, zoom: { control: { type: 'number', - min: 14, - max: 16, step: 0.25, }, }, From 1180a906e78897cc0e8dbf50a814a44be43d7aca Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Mon, 19 Oct 2026 16:11:46 +0300 Subject: [PATCH 12/28] Map: preserve OSM callback result types in wrappers --- packages/devextreme-angular/src/ui/map/index.ts | 8 ++++---- .../src/ui/map/nested/provider-config.ts | 10 +++++----- packages/devextreme-react/src/map.ts | 6 +++--- packages/devextreme-vue/src/map.ts | 6 ++++-- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/devextreme-angular/src/ui/map/index.ts b/packages/devextreme-angular/src/ui/map/index.ts index e00102a0cd5a..d053c8af9ac6 100644 --- a/packages/devextreme-angular/src/ui/map/index.ts +++ b/packages/devextreme-angular/src/ui/map/index.ts @@ -19,7 +19,7 @@ import { } from '@angular/core'; -import type { MarkerClickEvent, ClickEvent, DisposingEvent, InitializedEvent, MarkerAddedEvent, MarkerRemovedEvent, OptionChangedEvent, ReadyEvent, RouteAddedEvent, RouteRemovedEvent, MapProvider, CalculateOsmRouteInfo, OsmTileServer, RouteMode, MapType } from 'devextreme/ui/map'; +import type { MarkerClickEvent, ClickEvent, DisposingEvent, InitializedEvent, MarkerAddedEvent, MarkerRemovedEvent, OptionChangedEvent, ReadyEvent, RouteAddedEvent, RouteRemovedEvent, MapProvider, CalculateOsmRouteInfo, MapLocation, OsmRouteResult, OsmTileServer, RouteMode, MapType } from 'devextreme/ui/map'; import DxMap from 'devextreme/ui/map'; @@ -266,10 +266,10 @@ export class DxMapComponent extends DxComponent implements OnDestroy, OnChanges, * [descr:dxMapOptions.providerConfig] */ @Input() - get providerConfig(): { calculateLocation?: ((query: string) => any), calculateRoute?: ((params: CalculateOsmRouteInfo) => any), mapId?: string, tileServer?: OsmTileServer | undefined, useAdvancedMarkers?: boolean } { + get providerConfig(): { calculateLocation?: ((query: string) => PromiseLike), calculateRoute?: ((params: CalculateOsmRouteInfo) => PromiseLike), mapId?: string, tileServer?: OsmTileServer | undefined, useAdvancedMarkers?: boolean } { return this._getOption('providerConfig'); } - set providerConfig(value: { calculateLocation?: ((query: string) => any), calculateRoute?: ((params: CalculateOsmRouteInfo) => any), mapId?: string, tileServer?: OsmTileServer | undefined, useAdvancedMarkers?: boolean }) { + set providerConfig(value: { calculateLocation?: ((query: string) => PromiseLike), calculateRoute?: ((params: CalculateOsmRouteInfo) => PromiseLike), mapId?: string, tileServer?: OsmTileServer | undefined, useAdvancedMarkers?: boolean }) { this._setOption('providerConfig', value); } @@ -473,7 +473,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<{ calculateLocation?: ((query: string) => any), calculateRoute?: ((params: CalculateOsmRouteInfo) => any), mapId?: string, tileServer?: OsmTileServer | undefined, useAdvancedMarkers?: boolean }>; + @Output() providerConfigChange: EventEmitter<{ calculateLocation?: ((query: string) => PromiseLike), calculateRoute?: ((params: CalculateOsmRouteInfo) => PromiseLike), mapId?: string, tileServer?: OsmTileServer | undefined, useAdvancedMarkers?: boolean }>; /** * This member supports the internal infrastructure and is not intended to be used directly from your code. 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 3cf1b3bf3361..3e27beed95f6 100644 --- a/packages/devextreme-angular/src/ui/map/nested/provider-config.ts +++ b/packages/devextreme-angular/src/ui/map/nested/provider-config.ts @@ -8,7 +8,7 @@ import { SkipSelf, Input } from '@angular/core'; -import type { CalculateOsmRouteInfo, OsmTileServer } from 'devextreme/ui/map'; +import type { CalculateOsmRouteInfo, MapLocation, OsmRouteResult, OsmTileServer } from 'devextreme/ui/map'; import { DxIntegrationModule, @@ -25,18 +25,18 @@ import { NestedOption } from 'devextreme-angular/core'; }) export class DxoMapProviderConfigComponent extends NestedOption implements OnDestroy, OnInit { @Input() - get calculateLocation(): ((query: string) => any) { + get calculateLocation(): ((query: string) => PromiseLike) { return this._getOption('calculateLocation'); } - set calculateLocation(value: ((query: string) => any)) { + set calculateLocation(value: ((query: string) => PromiseLike)) { this._setOption('calculateLocation', value); } @Input() - get calculateRoute(): ((params: CalculateOsmRouteInfo) => any) { + get calculateRoute(): ((params: CalculateOsmRouteInfo) => PromiseLike) { return this._getOption('calculateRoute'); } - set calculateRoute(value: ((params: CalculateOsmRouteInfo) => any)) { + set calculateRoute(value: ((params: CalculateOsmRouteInfo) => PromiseLike)) { this._setOption('calculateRoute', value); } diff --git a/packages/devextreme-react/src/map.ts b/packages/devextreme-react/src/map.ts index 548646107812..8db49c3b1da8 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, MarkerClickEvent, CalculateOsmRouteInfo, OsmTileServer, RouteMode } from "devextreme/ui/map"; +import type { ClickEvent, DisposingEvent, InitializedEvent, MarkerAddedEvent, MarkerRemovedEvent, ReadyEvent, RouteAddedEvent, RouteRemovedEvent, MarkerClickEvent, CalculateOsmRouteInfo, MapLocation, OsmRouteResult, OsmTileServer, RouteMode } from "devextreme/ui/map"; type ReplaceFieldTypes = { [P in keyof TSource]: P extends keyof TReplacement ? TReplacement[P] : TSource[P]; @@ -182,8 +182,8 @@ const Marker = Object.assign(_comp // owners: // Map type IProviderConfigProps = React.PropsWithChildren<{ - calculateLocation?: ((query: string) => any); - calculateRoute?: ((params: CalculateOsmRouteInfo) => any); + calculateLocation?: ((query: string) => PromiseLike); + calculateRoute?: ((params: CalculateOsmRouteInfo) => PromiseLike); mapId?: string; tileServer?: OsmTileServer | undefined; useAdvancedMarkers?: boolean; diff --git a/packages/devextreme-vue/src/map.ts b/packages/devextreme-vue/src/map.ts index e0705f13e0ac..3f5522c90524 100644 --- a/packages/devextreme-vue/src/map.ts +++ b/packages/devextreme-vue/src/map.ts @@ -16,6 +16,8 @@ import { MapType, MarkerClickEvent, CalculateOsmRouteInfo, + MapLocation, + OsmRouteResult, OsmTileServer, OsmTileServerConfig, RouteMode, @@ -255,8 +257,8 @@ const DxProviderConfigConfig = { "update:useAdvancedMarkers": null, }, props: { - calculateLocation: Function as PropType<((query: string) => any)>, - calculateRoute: Function as PropType<((params: CalculateOsmRouteInfo) => any)>, + calculateLocation: Function as PropType<((query: string) => PromiseLike)>, + calculateRoute: Function as PropType<((params: CalculateOsmRouteInfo) => PromiseLike)>, mapId: String, tileServer: [Object, Function, String] as PropType string | OsmTileServerConfig | undefined)) | OsmTileServerConfig | string>, useAdvancedMarkers: Boolean From 3d7f2a3ae0f95fb07415f28daca7f0bbf92438b5 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Mon, 19 Oct 2026 16:32:44 +0300 Subject: [PATCH 13/28] Map: preserve full-world OSM viewport bounds --- .../ui/map/provider.dynamic.osm.openlayers.ts | 20 ++++++++--- .../mapParts/osmTests.js | 33 +++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) 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 index f25a59cc188a..e6d545f4ee6e 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts @@ -620,16 +620,26 @@ class OpenLayersMap implements MapEngineMap { const center = view.getCenter(); const zoom = view.getZoom(); const extent = view.calculateExtent(); + const projection = view.getProjection(); + const geographicExtent = this._api.proj.transformExtent( + extent, + getCoordinateProjection(this._api, projection), + GEOGRAPHIC_PROJECTION, + ); + const northEast = toLocation(this._api, [extent[2], extent[3]], projection); + const southWest = toLocation(this._api, [extent[0], extent[1]], projection); + + if (geographicExtent[2] - geographicExtent[0] >= 360) { + northEast.lng = 180; + southWest.lng = -180; + } const result: MapEngineViewState = { - bounds: { - northEast: toLocation(this._api, [extent[2], extent[3]], view.getProjection()), - southWest: toLocation(this._api, [extent[0], extent[1]], view.getProjection()), - }, + bounds: { northEast, southWest }, }; if (center) { - result.center = toLocation(this._api, center, view.getProjection()); + result.center = toLocation(this._api, center, projection); } if (zoom !== undefined) { result.zoom = zoom; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js index 30ccc34810da..972f3dff5a48 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -3483,6 +3483,39 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { } }).dxMap('instance'); }); + [null, 'EPSG:4326'].forEach(userProjection => { + [ + [-180, 180, -180, 180], + [0, 360, -180, 180], + [-540, -180, -180, 180], + [-360, 360, -180, 180], + [-200, 200, -180, 180], + [170, 190, 170, -170], + [-179.99, 179.99, -179.99, 179.99] + ].forEach(([west, east, expectedWest, expectedEast]) => { + QUnit.test(`OpenLayers viewport bounds for ${west}..${east} with ${userProjection ? 'user' : 'view'} projection`, function(assert) { + openLayersMock.userProjection = userProjection; + const scale = userProjection ? 1 : 1000; + openLayersMock.viewExtent = [west * scale, -10 * scale, east * scale, 10 * scale]; + const engine = createOpenLayersEngine(openLayersMock); + const engineMap = engine.createMap(document.createElement('div')); + const viewChange = sinon.spy(); + engineMap.attachHandlers({ + click: sinon.spy(), + markerSizeChange: sinon.spy(), + viewChange + }); + + openLayersMock.mapInstance.trigger('moveend'); + + assert.deepEqual(viewChange.lastCall.args[0].bounds, { + northEast: { lat: 10, lng: expectedEast }, + southWest: { lat: -10, lng: expectedWest } + }, 'full-world coverage is preserved without expanding narrower bounds'); + engineMap.dispose(); + }); + }); + }); QUnit.test('controls option toggles the OpenLayers zoom control', function(assert) { const done = assert.async(); const map = $('#map').dxMap({ From ea9ba0fc1d0d17f426205ccdaa52925681378d28 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Mon, 19 Oct 2026 16:44:25 +0300 Subject: [PATCH 14/28] Map: restore focus when removing OSM markers --- .../ui/map/provider.dynamic.osm.openlayers.ts | 3 +- .../mapParts/osmTests.js | 86 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) 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 index e6d545f4ee6e..5a511934e9c2 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts @@ -364,7 +364,8 @@ class OpenLayersMap implements MapEngineMap { disposed = true; if (restoreFocus && !this._disposed - && tooltip?.element.contains(domAdapter.getActiveElement(tooltip.element))) { + && (element.contains(domAdapter.getActiveElement(element)) + || tooltip?.element.contains(domAdapter.getActiveElement(tooltip.element)))) { (this._container as HTMLElement).focus({ preventScroll: true }); } tooltip?.dispose(); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js index 972f3dff5a48..cda50a3cf788 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -2498,6 +2498,92 @@ QUnit.module('OSM: marker tooltips', moduleConfig, () => { }); }); + [false, true].forEach(withTooltip => { + ['remove', 'update'].forEach(action => { + QUnit.test(`marker removal restores focus on ${action} (tooltip: ${withTooltip})`, async function(assert) { + const options = { location, ...(withTooltip ? { tooltip: 'Start' } : { onClick: sinon.spy() }) }; + const map = await createMap({ markers: [options] }); + positionMarker(); + getMarker().focus(); + assert.strictEqual(document.activeElement, getMarker(), 'marker is initially focused'); + const target = getOpenLayersMapTarget(); + const focus = sinon.spy(target, 'focus'); + + if(action === 'remove') { + await map.removeMarker(options); + } else { + map.option('markers', [{ ...options }]); + await map._lastAsyncAction; + } + + assert.strictEqual(document.activeElement, target, 'focus moves to the map'); + assert.ok(focus.calledOnceWithExactly({ preventScroll: true }), 'focus is restored once without scrolling'); + }); + }); + }); + + [false, true].forEach(focusOutsideMap => { + QUnit.test(`marker removal preserves focus elsewhere (outside map: ${focusOutsideMap})`, async function(assert) { + const options = { location, tooltip: 'First' }; + const map = await createMap({ markers: [options, { location, tooltip: 'Second' }] }); + const otherMarker = openLayersMock.addedOverlays[1].options.element; + positionMarker(otherMarker); + const focusTarget = focusOutsideMap ? $('' }; const map = await createMap({ markers: [options] }); From 42adb6c6cb12074d998c60cdc00d61aa5bb9e336 Mon Sep 17 00:00:00 2001 From: Alisher Amonulloev Date: Wed, 21 Oct 2026 07:33:46 +0300 Subject: [PATCH 15/28] Map: adapt OSM marker colors to Fluent Next (#35768) --- .../scss/widgets/fluent-next/map/_colors.scss | 4 ++++ .../scss/widgets/fluent-next/map/_index.scss | 9 ++++++++ .../scss/widgets/fluent-next/map/_public.scss | 3 +++ .../devextreme-scss/tests/map-marker.test.ts | 23 +++++++++++++++++++ .../tests/public-surface.baseline.json | 2 ++ .../tools/naming/derive-registries.ts | 2 +- .../tools/naming/registries.json | 1 + .../tools/review/nested-scopes.json | 4 ++++ 8 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 packages/devextreme-scss/scss/widgets/fluent-next/map/_colors.scss create mode 100644 packages/devextreme-scss/tests/map-marker.test.ts diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/map/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent-next/map/_colors.scss new file mode 100644 index 000000000000..1902f608f6b8 --- /dev/null +++ b/packages/devextreme-scss/scss/widgets/fluent-next/map/_colors.scss @@ -0,0 +1,4 @@ +@use "../../../_design-system/variables/ds" as ds; + +$map-marker-bg: ds.$color-bg-primary !default; +$map-marker-content: ds.$color-content-static-dark !default; diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/map/_index.scss b/packages/devextreme-scss/scss/widgets/fluent-next/map/_index.scss index fd466edbcf7c..54d8653c6d26 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/map/_index.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/map/_index.scss @@ -2,3 +2,12 @@ @use "../../base/map" with ( $map-marker-tooltip-margin: mapSizes.$map-marker-tooltip-margin, ); + +.dx-map-marker-default-body { + fill: var(--dx-map-marker-bg); + stroke: var(--dx-map-marker-content); +} + +.dx-map-marker-default-center { + fill: var(--dx-map-marker-content); +} diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/map/_public.scss b/packages/devextreme-scss/scss/widgets/fluent-next/map/_public.scss index f43b68150422..d8c458e0d461 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/map/_public.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/map/_public.scss @@ -1,6 +1,9 @@ // GENERATED by tools/naming/publish.ts +@use "colors" as *; @use "sizes" as *; @mixin publish { + --dx-map-marker-bg: #{$map-marker-bg}; + --dx-map-marker-content: #{$map-marker-content}; --dx-map-marker-tooltip-margin: #{$map-marker-tooltip-margin}; } diff --git a/packages/devextreme-scss/tests/map-marker.test.ts b/packages/devextreme-scss/tests/map-marker.test.ts new file mode 100644 index 000000000000..337ee7c81b5c --- /dev/null +++ b/packages/devextreme-scss/tests/map-marker.test.ts @@ -0,0 +1,23 @@ +import postcss, { Root } from 'postcss'; +import { compileBundle } from './compiled-bundles'; + +jest.setTimeout(300000); + +const declarations = (root: Root, selector: string, property: string): string[] => { + const values: string[] = []; + root.walkRules(selector, (rule) => { + rule.walkDecls(property, ({ value }) => { values.push(value); }); + }); + return values; +}; + +test.each(['light', 'dark'])('Fluent Next Map markers use component colors in %s mode', async (mode) => { + const root = postcss.parse(await compileBundle(['fluent-next', 'default', 'blue', mode])); + + expect(declarations(root, '.dx-map-marker-default-body', 'fill')) + .toEqual(['var(--dx-map-marker-bg)']); + expect(declarations(root, '.dx-map-marker-default-body', 'stroke')) + .toEqual(['var(--dx-map-marker-content)']); + expect(declarations(root, '.dx-map-marker-default-center', 'fill')) + .toEqual(['var(--dx-map-marker-content)']); +}); diff --git a/packages/devextreme-scss/tests/public-surface.baseline.json b/packages/devextreme-scss/tests/public-surface.baseline.json index b0824c633357..e6a3d9d7b9de 100644 --- a/packages/devextreme-scss/tests/public-surface.baseline.json +++ b/packages/devextreme-scss/tests/public-surface.baseline.json @@ -1393,6 +1393,8 @@ "--dx-lookup-search-padding-block", "--dx-lookup-search-padding-block-start", "--dx-lookup-search-padding-inline", + "--dx-map-marker-bg", + "--dx-map-marker-content", "--dx-map-marker-tooltip-margin", "--dx-mention-bg", "--dx-mention-border-radius", diff --git a/packages/devextreme-scss/tools/naming/derive-registries.ts b/packages/devextreme-scss/tools/naming/derive-registries.ts index d9292f319069..2bcf2398fbf2 100644 --- a/packages/devextreme-scss/tools/naming/derive-registries.ts +++ b/packages/devextreme-scss/tools/naming/derive-registries.ts @@ -695,7 +695,7 @@ const OVERRIDES: Overrides = { 'icon-container', 'cards', 'divider', 'allowance', 'prohibition', 'link', 'icon', 'caption', 'text', ], - map: ['marker-tooltip'], + map: ['marker', 'marker-tooltip'], toast: ['content', 'icon', 'item', 'stack'], // .dx-icon and .dx-button-text are real boxes inside a button; `icon` is also a part, which // is allowed — the grammar resolves it by position (see assertParseable). diff --git a/packages/devextreme-scss/tools/naming/registries.json b/packages/devextreme-scss/tools/naming/registries.json index e43b87d18bba..f67e0f729385 100644 --- a/packages/devextreme-scss/tools/naming/registries.json +++ b/packages/devextreme-scss/tools/naming/registries.json @@ -924,6 +924,7 @@ "text" ], "map": [ + "marker", "marker-tooltip" ], "toast": [ diff --git a/packages/devextreme-scss/tools/review/nested-scopes.json b/packages/devextreme-scss/tools/review/nested-scopes.json index 9c6b4b53728c..697dc764fb17 100644 --- a/packages/devextreme-scss/tools/review/nested-scopes.json +++ b/packages/devextreme-scss/tools/review/nested-scopes.json @@ -377,6 +377,10 @@ "dx-lookup-popup-search", "dx-lookup-search-wrapper" ], + "map": [ + "dx-map-marker-default-body", + "dx-map-marker-default-center" + ], "menu": [ "dx-context-menu", "dx-datagrid-filter-row", From 20772edb97064670cc01c352cce114802817361d Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Wed, 21 Oct 2026 09:29:07 +0300 Subject: [PATCH 16/28] Map: expose non-interactive OSM markers to assistive technology --- .../provider.dynamic.osm.openlayers.marker.ts | 2 ++ .../mapParts/osmTests.js | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.marker.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.marker.ts index 8713f22e6d61..ea37e5017ffd 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.marker.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.marker.ts @@ -48,6 +48,8 @@ const createDefaultMarkerElement = ( const center = ownerDocument.createElementNS(SVG_NAMESPACE, 'path'); element.className = `${MARKER_CLASS} ${DEFAULT_MARKER_CLASS}`; + element.setAttribute('role', 'img'); + element.setAttribute('aria-label', messageLocalization.format('dxMap-markerAriaLabel')); svg.setAttribute('class', DEFAULT_MARKER_ICON_CLASS); svg.setAttribute('viewBox', DEFAULT_MARKER_VIEW_BOX); svg.setAttribute('width', `${DEFAULT_MARKER_WIDTH}`); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js index cda50a3cf788..d4c544438069 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -1251,10 +1251,14 @@ QUnit.module('OSM: markers', moduleConfig, () => { assert.strictEqual(overlay.options.positioning, 'bottom-center', 'marker tip is anchored to its location'); assert.strictEqual(overlay.options.stopEvent, false, 'map interactions remain available over the marker'); assert.ok(element.classList.contains('dx-map-marker-default'), 'default marker is rendered'); + assert.strictEqual(element.getAttribute('role'), 'img', 'non-interactive marker has image semantics'); + assert.strictEqual(element.getAttribute('aria-label'), 'Map marker', 'non-interactive marker has an accessible name'); + assert.notOk(element.hasAttribute('tabindex'), 'non-interactive marker is not added to the tab order'); assert.strictEqual(getComputedStyle(element).width, '44px', 'default marker keeps a sufficiently large hit area'); assert.strictEqual(getComputedStyle(element).height, '44px', 'default marker keeps a sufficiently large hit area'); const markerSvg = element.querySelector('.dx-map-marker-default-icon'); assert.ok(markerSvg, 'default marker SVG is rendered'); + assert.strictEqual(markerSvg.getAttribute('aria-hidden'), 'true', 'the decorative SVG is not announced separately'); assert.strictEqual(markerSvg.getAttribute('viewBox'), '5 2 14 20', 'existing pinmap geometry is fitted to the marker'); assert.strictEqual(markerSvg.getAttribute('width'), '24.5', 'default marker width matches the standard marker size'); assert.strictEqual(markerSvg.getAttribute('height'), '36.5', 'default marker height matches the standard marker size'); @@ -1311,6 +1315,25 @@ QUnit.module('OSM: markers', moduleConfig, () => { assert.strictEqual(map.eventHandlers['change:view'].length, 0, 'view replacement listener is removed on dispose'); assert.strictEqual(replacementView.eventHandlers['change:center'].length, 0, 'replacement view listener is removed on dispose'); }); + QUnit.test('default marker accessible names are localized', function(assert) { + const defaultLocale = localization.locale(); + const markerAriaLabel = 'Localized map marker'; + localization.loadMessages({ test: { 'dxMap-markerAriaLabel': markerAriaLabel } }); + localization.locale('test'); + const engineMap = createOpenLayersEngine(openLayersMock).createMap(document.createElement('div')); + try { + [undefined, () => {}].forEach(onClick => { + engineMap.addMarker({ location: { lat: 40.74, lng: -73.98 }, onClick }); + const element = openLayersMock.addedOverlays.at(-1).options.element; + assert.strictEqual(element.getAttribute('aria-label'), markerAriaLabel, 'the existing localized label is used'); + assert.strictEqual(element.getAttribute('role'), onClick ? 'button' : 'img', 'the role reflects marker interactivity'); + assert.strictEqual(element.getAttribute('tabindex'), onClick ? '0' : null, 'only interactive markers join the tab order'); + }); + } finally { + engineMap.dispose(); + localization.locale(defaultLocale); + } + }); QUnit.test('marker iconSrc takes priority over markerIconSrc', function(assert) { const done = assert.async(); const defaultLocale = localization.locale(); From de2580220f6d41b44bfeb221e82f6a0d88499ea6 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Wed, 21 Oct 2026 09:29:39 +0300 Subject: [PATCH 17/28] Map: apply focusStateEnabled to OpenLayers controls --- .../ui/map/provider.dynamic.osm.openlayers.ts | 46 ++++++++- .../testing/helpers/forMap/openLayersMock.js | 15 ++- .../mapParts/osmTests.js | 94 +++++++++++++++++++ 3 files changed, 153 insertions(+), 2 deletions(-) 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 index 5a511934e9c2..46a656e366bc 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts @@ -61,6 +61,7 @@ interface MapBrowserEventLike { } const KEY_RELEASE_EVENT = 'keyup'; +const CONTROL_SELECTOR = '.ol-control'; interface MarkerFocusTarget { element: HTMLElement; @@ -94,6 +95,10 @@ class OpenLayersMap implements MapEngineMap { private readonly _zoomControl: ControlLike; + private readonly _controlFocusObserver: MutationObserver; + + private readonly _controlTabIndexes = new Map(); + private _controlsVisible = false; private _disabled = false; @@ -132,7 +137,6 @@ class OpenLayersMap implements MapEngineMap { view: MapEngineSetViewOptions = {}, ) { this._container = container; - this._syncKeyboardTabIndex(); this.originalMap = new _api.Map({ controls: _api.control.defaults.defaults({ attribution: true, rotate: false, zoom: false }), @@ -160,6 +164,8 @@ class OpenLayersMap implements MapEngineMap { this._subscribedView.on('change:center', this._viewCenterChangeHandler); this.originalMap.on('change:view', this._viewChangeHandler); this._zoomControl = new _api.control.Zoom(); + this._controlFocusObserver = new MutationObserver(this._syncControlTabIndexes); + this._syncKeyboardTabIndex(); } private readonly _viewCenterChangeHandler = (): void => { @@ -655,6 +661,8 @@ class OpenLayersMap implements MapEngineMap { } this._disposed = true; + this._controlFocusObserver.disconnect(); + this._syncControlTabIndexes(); this._detachHandlers(); this.originalMap.un('change:view', this._viewChangeHandler); this._subscribedView.un('change:center', this._viewCenterChangeHandler); @@ -739,6 +747,7 @@ class OpenLayersMap implements MapEngineMap { } else { this.originalMap.removeControl(this._zoomControl); } + this._syncControlTabIndexes(); } setDisabled(disabled: boolean): void { @@ -791,9 +800,43 @@ class OpenLayersMap implements MapEngineMap { setFocus(enabled: boolean, tabIndex: number): void { this._focusEnabled = enabled; this._tabIndex = tabIndex; + this._controlFocusObserver.disconnect(); + if (!enabled) { + this._controlFocusObserver.observe(this.originalMap.getOverlayContainerStopEvent(), { + childList: true, + subtree: true, + }); + } this._syncKeyboardTabIndex(); } + private readonly _syncControlTabIndexes = (): void => { + const container = this.originalMap.getOverlayContainerStopEvent(); + const restore = this._focusEnabled || this._disposed; + this._controlTabIndexes.forEach((tabIndex, element) => { + if (restore || !container.contains(element)) { + if (tabIndex === null) { + element.removeAttribute('tabindex'); + } else { + element.setAttribute('tabindex', tabIndex); + } + this._controlTabIndexes.delete(element); + } + }); + if (restore) { + return; + } + + container.querySelectorAll(CONTROL_SELECTOR).forEach((control) => { + control.querySelectorAll(ALL_FOCUSABLE_ELEMENTS_SELECTOR).forEach((element) => { + if (!this._controlTabIndexes.has(element)) { + this._controlTabIndexes.set(element, element.getAttribute('tabindex')); + } + element.setAttribute('tabindex', '-1'); + }); + }); + }; + private _syncKeyboardTabIndex(): void { if (this._focusEnabled && !this._disabled) { this._container.setAttribute('tabindex', String(this._tabIndex)); @@ -801,6 +844,7 @@ class OpenLayersMap implements MapEngineMap { this._container.removeAttribute('tabindex'); } this._markers.forEach((marker) => this._syncMarkerTabIndex(marker)); + this._syncControlTabIndexes(); } setView(options: MapEngineSetViewOptions): void { diff --git a/packages/devextreme/testing/helpers/forMap/openLayersMock.js b/packages/devextreme/testing/helpers/forMap/openLayersMock.js index b9166fac9197..c799f94cf77e 100644 --- a/packages/devextreme/testing/helpers/forMap/openLayersMock.js +++ b/packages/devextreme/testing/helpers/forMap/openLayersMock.js @@ -55,6 +55,16 @@ class MockZoom { constructor() { api.zoomControlCreatedCount += 1; + this.element = document.createElement('div'); + this.element.className = 'ol-zoom ol-control'; + this.element.innerHTML = ''; + } + } + class MockAttribution { + constructor() { + this.element = document.createElement('div'); + this.element.className = 'ol-attribution ol-control'; + this.element.innerHTML = ''; } } class MockOverlay { @@ -146,6 +156,7 @@ }); this.overlayContainerStopEvent = document.createElement('div'); options.target.append(this.overlayContainer, this.overlayContainerStopEvent); + options.controls.forEach(control => this.overlayContainerStopEvent.appendChild(control.element)); api.mapCreated = true; api.mapInstance = this; api.mapOptions = options; @@ -154,6 +165,7 @@ } addControl(control) { api.addedControls.push(control); + this.overlayContainerStopEvent.appendChild(control.element); } addLayer(layer) { @@ -195,6 +207,7 @@ removeControl(control) { api.removedControls.push(control); + control.element.remove(); } removeLayer(layer) { @@ -326,7 +339,7 @@ defaults: { defaults(options) { api.controlOptions = options; - return new MockCollection([]); + return new MockCollection(options.attribution ? [new MockAttribution()] : []); } } }, diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js index d4c544438069..0542b35d02ed 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -3358,6 +3358,7 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { const map = $('#map').dxMap({ provider: 'osm', focusStateEnabled: false, + controls: true, tabIndex: 5, markers: [{ location: [40.74, -73.98], @@ -3377,6 +3378,9 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { const target = getOpenLayersKeyboardTarget(); const markerElement = openLayersMock.addedOverlays[0].options.element; const customButton = openLayersMock.addedOverlays[1].options.element.querySelector('button'); + const controls = Array.from(target.querySelectorAll('.ol-control button, .ol-control a')); + assert.strictEqual(controls.length, 4, 'zoom and attribution controls are rendered'); + assert.ok(controls.every(element => element.tabIndex === -1), 'controls are excluded on initialization'); assert.strictEqual(target.getAttribute('tabindex'), null, 'focus is disabled on initialization'); assert.strictEqual(markerElement.getAttribute('tabindex'), '-1', 'marker is removed from the tab order on initialization'); assert.strictEqual(customButton.getAttribute('tabindex'), '-1', 'custom interactive content is removed from the tab order'); @@ -3384,12 +3388,14 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { assert.strictEqual(target.getAttribute('tabindex'), '5', 'configured tabIndex is applied'); assert.strictEqual(markerElement.getAttribute('tabindex'), '0', 'marker focus is enabled'); assert.strictEqual(customButton.getAttribute('tabindex'), null, 'custom interactive content returns to the tab order'); + assert.ok(controls.every(element => element.getAttribute('tabindex') === null), 'native control tab indexes are restored'); map.option('onUpdated', () => { assert.strictEqual(target.getAttribute('tabindex'), '-1', 'runtime tabIndex is applied'); map.option('onUpdated', () => { assert.strictEqual(target.getAttribute('tabindex'), null, 'runtime focus disabling is applied'); assert.strictEqual(markerElement.getAttribute('tabindex'), '-1', 'marker is removed from the tab order at runtime'); assert.strictEqual(customButton.getAttribute('tabindex'), '-1', 'custom interactive content leaves the tab order'); + assert.ok(controls.every(element => element.tabIndex === -1), 'controls leave the tab order at runtime'); done(); }); map.option('focusStateEnabled', false); @@ -3625,6 +3631,94 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { }); }); }); + [false, true].forEach(inShadowDom => { + QUnit.test(`control focus preserves tab indexes and pointer access (Shadow DOM: ${inShadowDom})`, function(assert) { + const host = document.createElement('div'); + const root = inShadowDom ? host.attachShadow({ mode: 'open' }) : host; + const container = document.createElement('div'); + const sibling = document.createElement('button'); + root.append(container, sibling); + const engineMap = createOpenLayersEngine(openLayersMock).createMap(container); + engineMap.setControls(true); + const buttons = container.querySelectorAll('.ol-zoom button'); + const attributionButton = container.querySelector('.ol-attribution button'); + const attributionLink = container.querySelector('.ol-attribution a'); + buttons[0].setAttribute('tabindex', '3'); + buttons[1].setAttribute('tabindex', '-2'); + attributionLink.setAttribute('tabindex', '0'); + const onClick = sinon.spy(); + buttons[0].addEventListener('click', onClick); + + engineMap.setFocus(false, 5); + engineMap.setFocus(false, 5); + assert.strictEqual(buttons[0].tabIndex, -1, 'zoom is excluded from the tab order'); + assert.strictEqual(buttons[1].tabIndex, -2, 'an existing negative tab index is preserved'); + assert.strictEqual(attributionButton.tabIndex, -1, 'the attribution button is excluded'); + assert.strictEqual(attributionLink.tabIndex, -1, 'the attribution link is excluded'); + assert.notOk(container.hasAttribute('inert'), 'pointer access is not disabled'); + buttons[0].click(); + assert.ok(onClick.calledOnce, 'control clicks are still handled'); + assert.strictEqual(sibling.getAttribute('tabindex'), null, 'elements outside the map are unchanged'); + + engineMap.setFocus(true, 5); + assert.strictEqual(buttons[0].getAttribute('tabindex'), '3', 'the explicit tab index is restored'); + assert.strictEqual(buttons[1].getAttribute('tabindex'), '-2', 'the original negative tab index is unchanged'); + assert.strictEqual(attributionButton.getAttribute('tabindex'), null, 'the native tab index is restored'); + assert.strictEqual(attributionLink.getAttribute('tabindex'), '0', 'an explicit zero is preserved'); + engineMap.dispose(); + }); + }); + QUnit.test('control focus follows new controls and attribution content', async function(assert) { + const container = document.createElement('div'); + const engineMap = createOpenLayersEngine(openLayersMock).createMap(container); + engineMap.setFocus(false, 0); + engineMap.setControls(true); + const zoomButton = container.querySelector('.ol-zoom button'); + assert.strictEqual(zoomButton.tabIndex, -1, 'zoom added after disabling focus is excluded immediately'); + engineMap.setControls(false); + assert.strictEqual(zoomButton.getAttribute('tabindex'), null, 'removing the control restores its original tab index'); + engineMap.setControls(true); + assert.strictEqual(zoomButton.tabIndex, -1, 're-added zoom is excluded again'); + + const attribution = container.querySelector('.ol-attribution'); + const oldLink = attribution.querySelector('a'); + const newLink = document.createElement('a'); + newLink.href = '#updated-credits'; + oldLink.replaceWith(newLink); + await Promise.resolve(); + assert.strictEqual(newLink.tabIndex, -1, 'asynchronously updated attribution is excluded'); + assert.strictEqual(oldLink.getAttribute('tabindex'), null, 'detached attribution is restored'); + + const overlayButton = document.createElement('button'); + openLayersMock.overlayContainerStopEvent.appendChild(overlayButton); + await Promise.resolve(); + assert.strictEqual(overlayButton.getAttribute('tabindex'), null, 'unrelated overlay content is not changed'); + + engineMap.dispose(); + assert.strictEqual(newLink.getAttribute('tabindex'), null, 'dispose restores modified controls'); + const lateLink = document.createElement('a'); + lateLink.href = '#late-credits'; + attribution.appendChild(lateLink); + await Promise.resolve(); + assert.strictEqual(lateLink.getAttribute('tabindex'), null, 'dispose stops observing control content'); + }); + QUnit.test('control focus is synchronized after the disabled state changes', function(assert) { + const container = document.createElement('div'); + const engineMap = createOpenLayersEngine(openLayersMock).createMap(container); + engineMap.setDisabled(true); + engineMap.setFocus(false, 0); + engineMap.setControls(true); + const zoomButton = container.querySelector('.ol-zoom button'); + engineMap.setDisabled(false); + assert.strictEqual(zoomButton.tabIndex, -1, 'enabling the map keeps control focus disabled'); + + engineMap.setDisabled(true); + engineMap.setFocus(true, 0); + assert.ok(container.hasAttribute('inert'), 'enabling focus does not enable a disabled map'); + engineMap.setDisabled(false); + assert.strictEqual(zoomButton.getAttribute('tabindex'), null, 'enabling the map restores native control focus'); + engineMap.dispose(); + }); QUnit.test('controls option toggles the OpenLayers zoom control', function(assert) { const done = assert.async(); const map = $('#map').dxMap({ From af2aeeabceda5333106886c4014e993eeb7fc13f Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Wed, 21 Oct 2026 09:30:06 +0300 Subject: [PATCH 18/28] Storybook: calculate OSM routes with Stadia Maps --- .../stories/map/OSMMap.stories.tsx | 61 +++-- apps/react-storybook/stories/map/routes.ts | 254 ------------------ 2 files changed, 45 insertions(+), 270 deletions(-) delete mode 100644 apps/react-storybook/stories/map/routes.ts diff --git a/apps/react-storybook/stories/map/OSMMap.stories.tsx b/apps/react-storybook/stories/map/OSMMap.stories.tsx index 72526ae7a9de..ddd5f873326e 100644 --- a/apps/react-storybook/stories/map/OSMMap.stories.tsx +++ b/apps/react-storybook/stories/map/OSMMap.stories.tsx @@ -3,21 +3,59 @@ import { useArgs } from 'storybook/preview-api'; import { fn } from 'storybook/test'; import Attribution from 'ol/control/Attribution.js'; +import Polyline from 'ol/format/Polyline.js'; +import type LineString from 'ol/geom/LineString.js'; import type OpenLayersMap from 'ol/Map.js'; import 'ol/ol.css'; import React from 'react'; import Button from 'devextreme-react/button'; -import Map, { type MapRef } from 'devextreme-react/map'; +import MapComponent, { type MapRef } from 'devextreme-react/map'; import type { CalculateOsmRouteInfo, MapLocation, MapType, ReadyEvent, - OsmRouteResult, + OsmGeoJsonLineString, } from 'devextreme/ui/map'; import 'devextreme/ui/map/openlayers'; -import { ROUTE_PATHS } from './routes'; +const routeCache = new Map(); + +async function calculateRoute({ locations, mode }: CalculateOsmRouteInfo): Promise { + const body = JSON.stringify({ + locations: locations.map(({ lat, lng }) => ({ lat, lon: lng })), + costing: mode === 'walking' ? 'pedestrian' : 'auto', + directions_type: 'none', + }); + const cachedRoute = routeCache.get(body); + if (cachedRoute) { + return cachedRoute; + } + + const response = await fetch('https://api-eu.stadiamaps.com/route/v1', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + referrerPolicy: 'strict-origin-when-cross-origin', + body, + }); + if (!response.ok) { + throw new Error(`Stadia Maps routing failed: HTTP ${response.status}`); + } + + const { trip } = await response.json(); + if (!trip?.legs?.length) { + throw new Error('Stadia Maps returned no route.'); + } + const format = new Polyline({ factor: 1e6 }); + const route: OsmGeoJsonLineString = { + type: 'LineString', + coordinates: trip.legs.flatMap((leg: { shape: string }) => ( + (format.readGeometry(leg.shape) as LineString).getCoordinates() + )), + }; + routeCache.set(body, route); + return route; +} const CENTRAL_PARK_CENTER = { lat: 40.7829, lng: -73.9654 }; // Demo only: tiles are served under Developer Express Inc's Stadia Maps subscription via domain-based authentication. @@ -40,8 +78,6 @@ const MARKER_LOCATIONS: Record = { const ROUTE_PRESETS = { centralParkRun: { mode: 'walking', - title: 'Central Park Run — approx. 10.1 km', - markerDescription: 'Blue: Columbus Circle start / finish. Red: Dana Discovery Center.', center: { lat: 40.7827, lng: -73.9666 }, zoom: 14, locations: [ @@ -63,8 +99,6 @@ const ROUTE_PRESETS = { }, manhattanDrive: { mode: 'driving', - title: 'Manhattan Drive — official Routes demo waypoints, approx. 11 km', - markerDescription: 'Four markers: coordinate string, arrays and an object. Red: custom icon.', center: { lat: 40.75, lng: -73.986 }, zoom: 14, locations: [ @@ -90,11 +124,7 @@ const PROVIDER_CONFIG = { calculateLocation: (query: string): Promise => ( Promise.resolve(MARKER_LOCATIONS[query]) ), - calculateRoute: ({ mode }: CalculateOsmRouteInfo): Promise => ( - Promise.resolve(mode === 'walking' - ? ROUTE_PATHS.walking - : ROUTE_PATHS.driving.coordinates.map(([lng, lat]) => [lat, lng])) - ), + calculateRoute, tileServer: () => TILE_SERVER, }; const handleMarkerClick = fn(); @@ -220,7 +250,6 @@ const OsmMapStory = ({ return (
-
{preset.title}. {preset.markerDescription}
- = { labels: { manhattanDrive: 'Manhattan Drive', centralParkRun: 'Central Park Run' }, }, options: ['manhattanDrive', 'centralParkRun'], - description: 'Saved OSRM routes: official Routes demo waypoints or an independently calculated Central Park loop. ' - + 'Driving returns coordinate tuples; walking returns a GeoJSON LineString. No routing service is called.', + description: 'Stadia Maps calculates driving or walking routes between predefined waypoints. ' + + 'Results are cached so appearance changes do not repeat routing requests.', }, routeOpacity: { control: { type: 'range', min: 0, max: 1, step: 0.1 } }, routeWeight: { control: { type: 'number', min: 0, max: 20 } }, diff --git a/apps/react-storybook/stories/map/routes.ts b/apps/react-storybook/stories/map/routes.ts deleted file mode 100644 index 05b0f0dd0d40..000000000000 --- a/apps/react-storybook/stories/map/routes.ts +++ /dev/null @@ -1,254 +0,0 @@ -import type { OsmGeoJsonLineString, RouteMode } from 'devextreme/ui/map'; - -// Map data © OpenStreetMap contributors, ODbL: https://www.openstreetmap.org/copyright -export const ROUTE_PATHS: Record = { - walking: { - type: 'LineString', - coordinates: [ - [-73.981906, 40.768161], - [-73.982066, 40.768505], - [-73.98184, 40.768682], - [-73.981677, 40.768615], - [-73.978822, 40.772524], - [-73.978519, 40.772473], - [-73.97589, 40.776092], - [-73.975594, 40.77598], - [-73.975533, 40.776043], - [-73.975262, 40.776105], - [-73.974836, 40.7765], - [-73.974396, 40.777094], - [-73.974122, 40.777293], - [-73.974092, 40.77739], - [-73.973962, 40.777337], - [-73.973847, 40.777582], - [-73.973618, 40.777862], - [-73.972432, 40.778575], - [-73.971955, 40.778806], - [-73.971833, 40.778893], - [-73.971589, 40.778611], - [-73.970899, 40.778687], - [-73.970623, 40.778813], - [-73.970476, 40.778819], - [-73.970168, 40.778775], - [-73.970029, 40.778963], - [-73.969982, 40.778972], - [-73.969936, 40.778936], - [-73.969668, 40.778982], - [-73.969587, 40.778906], - [-73.96927, 40.779115], - [-73.969208, 40.779119], - [-73.969089, 40.779299], - [-73.969044, 40.77928], - [-73.969013, 40.779322], - [-73.968909, 40.779281], - [-73.968882, 40.779316], - [-73.968896, 40.779298], - [-73.969131, 40.779397], - [-73.96911, 40.779425], - [-73.96919, 40.779459], - [-73.969119, 40.779556], - [-73.96929, 40.779639], - [-73.969529, 40.779822], - [-73.969447, 40.780027], - [-73.969477, 40.780164], - [-73.969203, 40.780582], - [-73.968952, 40.780786], - [-73.968816, 40.780842], - [-73.968178, 40.7818], - [-73.967872, 40.782174], - [-73.968028, 40.782231], - [-73.967687, 40.7827], - [-73.967596, 40.783046], - [-73.96766, 40.783534], - [-73.967719, 40.783637], - [-73.967851, 40.783743], - [-73.967298, 40.784376], - [-73.967173, 40.7846], - [-73.967112, 40.784859], - [-73.967159, 40.7852], - [-73.967522, 40.786121], - [-73.967554, 40.786396], - [-73.967533, 40.786645], - [-73.967382, 40.787076], - [-73.967084, 40.787508], - [-73.966869, 40.787737], - [-73.966879, 40.787844], - [-73.96672, 40.787974], - [-73.967111, 40.788114], - [-73.962644, 40.794231], - [-73.962567, 40.794209], - [-73.962385, 40.794261], - [-73.962193, 40.794368], - [-73.962124, 40.794589], - [-73.961901, 40.794794], - [-73.961794, 40.795029], - [-73.961601, 40.795183], - [-73.961407, 40.795626], - [-73.961211, 40.795814], - [-73.961029, 40.795884], - [-73.960867, 40.795905], - [-73.960748, 40.796247], - [-73.960579, 40.796257], - [-73.960442, 40.79631], - [-73.960352, 40.796456], - [-73.960025, 40.796627], - [-73.959901, 40.796754], - [-73.959653, 40.796814], - [-73.959631, 40.797027], - [-73.959562, 40.797118], - [-73.959481, 40.797158], - [-73.958723, 40.797311], - [-73.958341, 40.797274], - [-73.958229, 40.79719], - [-73.957864, 40.797486], - [-73.957578, 40.797552], - [-73.95738, 40.797516], - [-73.957182, 40.797401], - [-73.957137, 40.797361], - [-73.957103, 40.797235], - [-73.956926, 40.797268], - [-73.956624, 40.797495], - [-73.956247, 40.797628], - [-73.956004, 40.797836], - [-73.955787, 40.7979], - [-73.955611, 40.797886], - [-73.955467, 40.797833], - [-73.955292, 40.797637], - [-73.955195, 40.797603], - [-73.95487, 40.797732], - [-73.954585, 40.797917], - [-73.95459, 40.79773], - [-73.954394, 40.797707], - [-73.954184, 40.797614], - [-73.954028, 40.797748], - [-73.953743, 40.797917], - [-73.95355, 40.797834], - [-73.95327, 40.797807], - [-73.953077, 40.797724], - [-73.952295, 40.797529], - [-73.951738, 40.797258], - [-73.951246, 40.797171], - [-73.951349, 40.797064], - [-73.951283, 40.797153], - [-73.951182, 40.797161], - [-73.950583, 40.796913], - [-73.950265, 40.796915], - [-73.949935, 40.796785], - [-73.949898, 40.796739], - [-73.949906, 40.796668], - [-73.950105, 40.796271], - [-73.950541, 40.795783], - [-73.950822, 40.795253], - [-73.951215, 40.794803], - [-73.951409, 40.794432], - [-73.95215, 40.793429], - [-73.95261, 40.793621], - [-73.95215, 40.793429], - [-73.952208, 40.79334], - [-73.95245, 40.792999], - [-73.952521, 40.792936], - [-73.952656, 40.792882], - [-73.952772, 40.792778], - [-73.952821, 40.792681], - [-73.952961, 40.792627], - [-73.953224, 40.792598], - [-73.953273, 40.79257], - [-73.953375, 40.792481], - [-73.953443, 40.792304], - [-73.953581, 40.792199], - [-73.953455, 40.792047], - [-73.953042, 40.791825], - [-73.963945, 40.776917], - [-73.96428, 40.776997], - [-73.964382, 40.77698], - [-73.964661, 40.776889], - [-73.964927, 40.776752], - [-73.965036, 40.776602], - [-73.965362, 40.776531], - [-73.966131, 40.776484], - [-73.966498, 40.776368], - [-73.966829, 40.776152], - [-73.967053, 40.775771], - [-73.967352, 40.775401], - [-73.967619, 40.77524], - [-73.968228, 40.774961], - [-73.968414, 40.775124], - [-73.969138, 40.774727], - [-73.969693, 40.774665], - [-73.970063, 40.774444], - [-73.970514, 40.774291], - [-73.970584, 40.7743], - [-73.970625, 40.774418], - [-73.970752, 40.774492], - [-73.970913, 40.774493], - [-73.970984, 40.774464], - [-73.971076, 40.77436], - [-73.97107, 40.774253], - [-73.971031, 40.774197], - [-73.970894, 40.77413], - [-73.970946, 40.774049], - [-73.971068, 40.774079], - [-73.971169, 40.774016], - [-73.971402, 40.773435], - [-73.971611, 40.773472], - [-73.971664, 40.773324], - [-73.971797, 40.773213], - [-73.97186, 40.773043], - [-73.972022, 40.772806], - [-73.972172, 40.77275], - [-73.972508, 40.772435], - [-73.973084, 40.7727], - [-73.973616, 40.772757], - [-73.973913, 40.772861], - [-73.974175, 40.77291], - [-73.975086, 40.772919], - [-73.975704, 40.772885], - [-73.976258, 40.772815], - [-73.976569, 40.772871], - [-73.976807, 40.772282], - [-73.977187, 40.771745], - [-73.977547, 40.77143], - [-73.978471, 40.770812], - [-73.97912, 40.770273], - [-73.979519, 40.769875], - [-73.979721, 40.769622], - [-73.979958, 40.769151], - [-73.980064, 40.769142], - [-73.980267, 40.769068], - [-73.981199, 40.768511], - [-73.981396, 40.76846], - [-73.981415, 40.768513], - [-73.98184, 40.768682], - [-73.982066, 40.768505], - [-73.981906, 40.768161], - ], - }, - driving: { - type: 'LineString', - coordinates: [ - [-73.965345, 40.78364], - [-73.965267, 40.783767], - [-73.96632, 40.784071], - [-73.96668, 40.784201], - [-73.967778, 40.784793], - [-73.968277, 40.784946], - [-73.968356, 40.785006], - [-73.968809, 40.785133], - [-73.96921, 40.78529], - [-73.969344, 40.785297], - [-73.972181, 40.786497], - [-73.992646, 40.758433], - [-73.976055, 40.751439], - [-73.982913, 40.742036], - [-73.984596, 40.742743], - [-73.990007, 40.735312], - [-73.990179, 40.735136], - [-73.990652, 40.734863], - [-73.990791, 40.734728], - [-73.991424, 40.731859], - [-73.991488, 40.731704], - [-74.006304, 40.714173], - [-74.005334, 40.713723], - ], - }, -}; From b7faa5991aedf043b945f84e405b4cde3890a19d Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Wed, 21 Oct 2026 16:27:18 +0300 Subject: [PATCH 19/28] Map: simplify Vue tileServer prop type --- packages/devextreme-vue/src/map.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/devextreme-vue/src/map.ts b/packages/devextreme-vue/src/map.ts index 3f5522c90524..1915f18e8e5c 100644 --- a/packages/devextreme-vue/src/map.ts +++ b/packages/devextreme-vue/src/map.ts @@ -19,7 +19,6 @@ import { MapLocation, OsmRouteResult, OsmTileServer, - OsmTileServerConfig, RouteMode, } from "devextreme/ui/map"; import { prepareConfigurationComponentConfig } from "./core/index"; @@ -260,7 +259,7 @@ const DxProviderConfigConfig = { calculateLocation: Function as PropType<((query: string) => PromiseLike)>, calculateRoute: Function as PropType<((params: CalculateOsmRouteInfo) => PromiseLike)>, mapId: String, - tileServer: [Object, Function, String] as PropType string | OsmTileServerConfig | undefined)) | OsmTileServerConfig | string>, + tileServer: [Object, Function, String] as PropType, useAdvancedMarkers: Boolean } }; From fe524ba264be59f759fd13f4817f0b054efafeba Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Wed, 21 Oct 2026 16:38:05 +0300 Subject: [PATCH 20/28] Map: restore focus after OSM marker removal when focus is disabled --- .../ui/map/provider.dynamic.osm.openlayers.ts | 6 ++- .../mapParts/osmTests.js | 47 +++++++++++-------- 2 files changed, 32 insertions(+), 21 deletions(-) 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 index 46a656e366bc..2c72df062833 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts @@ -372,7 +372,11 @@ class OpenLayersMap implements MapEngineMap { if (restoreFocus && !this._disposed && (element.contains(domAdapter.getActiveElement(element)) || tooltip?.element.contains(domAdapter.getActiveElement(tooltip.element)))) { - (this._container as HTMLElement).focus({ preventScroll: true }); + const container = this._container as HTMLElement; + if (!container.hasAttribute('tabindex')) { + container.setAttribute('tabindex', '-1'); + } + container.focus({ preventScroll: true }); } tooltip?.dispose(); markerElementBinding.detach(); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js index 0542b35d02ed..e0ae6a46de7a 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -2521,26 +2521,30 @@ QUnit.module('OSM: marker tooltips', moduleConfig, () => { }); }); - [false, true].forEach(withTooltip => { - ['remove', 'update'].forEach(action => { - QUnit.test(`marker removal restores focus on ${action} (tooltip: ${withTooltip})`, async function(assert) { - const options = { location, ...(withTooltip ? { tooltip: 'Start' } : { onClick: sinon.spy() }) }; - const map = await createMap({ markers: [options] }); - positionMarker(); - getMarker().focus(); - assert.strictEqual(document.activeElement, getMarker(), 'marker is initially focused'); - const target = getOpenLayersMapTarget(); - const focus = sinon.spy(target, 'focus'); - - if(action === 'remove') { - await map.removeMarker(options); - } else { - map.option('markers', [{ ...options }]); - await map._lastAsyncAction; - } + [false, true].forEach(focusStateEnabled => { + [false, true].forEach(withTooltip => { + ['remove', 'update'].forEach(action => { + QUnit.test(`marker removal restores focus on ${action} (tooltip: ${withTooltip}, focusStateEnabled: ${focusStateEnabled})`, async function(assert) { + const options = { location, ...(withTooltip ? { tooltip: 'Start' } : { onClick: sinon.spy() }) }; + const map = await createMap({ focusStateEnabled, tabIndex: 5, markers: [options] }); + positionMarker(); + getMarker().focus(); + assert.strictEqual(document.activeElement, getMarker(), 'marker is initially focused'); + const target = getOpenLayersMapTarget(); + assert.strictEqual(target.getAttribute('tabindex'), focusStateEnabled ? '5' : null, 'initial container tab index respects the focus setting'); + const focus = sinon.spy(target, 'focus'); + + if(action === 'remove') { + await map.removeMarker(options); + } else { + map.option('markers', [{ ...options }]); + await map._lastAsyncAction; + } - assert.strictEqual(document.activeElement, target, 'focus moves to the map'); - assert.ok(focus.calledOnceWithExactly({ preventScroll: true }), 'focus is restored once without scrolling'); + assert.strictEqual(document.activeElement, target, 'focus moves to the map'); + assert.strictEqual(target.getAttribute('tabindex'), focusStateEnabled ? '5' : '-1', 'restoring focus preserves the configured tab order'); + assert.ok(focus.calledOnceWithExactly({ preventScroll: true }), 'focus is restored once without scrolling'); + }); }); }); }); @@ -2576,7 +2580,7 @@ QUnit.module('OSM: marker tooltips', moduleConfig, () => { }); [false, true].forEach(restoreFocus => { - QUnit.test(`marker removal in Shadow DOM respects restoreFocus: ${restoreFocus}`, function(assert) { + QUnit.test(`marker removal in Shadow DOM with disabled focus respects restoreFocus: ${restoreFocus}`, function(assert) { const host = document.createElement('div'); document.getElementById('qunit-fixture').appendChild(host); const shadow = host.attachShadow({ mode: 'open' }); @@ -2584,6 +2588,7 @@ QUnit.module('OSM: marker tooltips', moduleConfig, () => { Object.assign(container.style, { width: '600px', height: '400px' }); shadow.appendChild(container); const engineMap = createOpenLayersEngine(openLayersMock).createMap(container, { center: location, zoom: 12 }); + engineMap.setFocus(false, 0); try { const marker = engineMap.addMarker({ location, onClick: sinon.spy() }); const element = marker.originalMarker.options.element; @@ -2596,9 +2601,11 @@ QUnit.module('OSM: marker tooltips', moduleConfig, () => { if(restoreFocus) { assert.strictEqual(shadow.activeElement, container, 'focus moves to the map inside Shadow DOM'); + assert.strictEqual(container.getAttribute('tabindex'), '-1', 'the container stays outside the tab order'); assert.ok(focus.calledOnceWithExactly({ preventScroll: true }), 'focus is restored without scrolling'); } else { assert.ok(focus.notCalled, 'cleanup can suppress focus restoration'); + assert.notOk(container.hasAttribute('tabindex'), 'cleanup does not change container focusability'); } } finally { engineMap.dispose(); From b1c27659cd82550f51bc2d107ccc23732449b056 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Wed, 21 Oct 2026 16:45:04 +0300 Subject: [PATCH 21/28] Map: warn about missing OSM attribution once per provider --- .../__internal/ui/map/provider.dynamic.osm.ts | 5 +- .../mapParts/osmTests.js | 62 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts index 4a2b700aec1f..0d71148592f7 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts @@ -154,6 +154,8 @@ class OsmProvider extends DynamicProvider { _boundLocations: MapLocation[] = []; + _attributionWarningLogged = false; + _calculateLocationWarningLogged = false; _calculateRouteWarningLogged = false; @@ -218,8 +220,9 @@ class OsmProvider extends DynamicProvider { return undefined; } - if (!config.attribution) { + if (!config.attribution && !this._attributionWarningLogged) { errors.log('W1032'); + this._attributionWarningLogged = true; } const result: MapEngineTileLayerOptions = { diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js index e0ae6a46de7a..6bdcf04c7d46 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -600,6 +600,68 @@ QUnit.module('OSM: tile server', moduleConfig, () => { } }); }); + [false, true].forEach(initialAttribution => { + QUnit.test(`missing attribution logs W1032 once across map types (initial attribution: ${initialAttribution})`, async function(assert) { + const log = sinon.stub(errors, 'log'); + try { + const map = await new Promise(resolve => { + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: type => ({ + url: `https://${type}.example.com/{z}/{x}/{y}.png`, + attribution: initialAttribution && type === 'roadmap' ? 'Example attribution' : undefined + }) + }, + onReady: ({ component }) => resolve(component) + }); + }); + assert.strictEqual(log.callCount, initialAttribution ? 0 : 1, 'initial warning depends on attribution'); + + const types = ['satellite', 'hybrid', 'roadmap', 'satellite']; + for(let index = 0; index < types.length; index++) { + const type = types[index]; + map.option('type', type); + await map._lastAsyncAction; + + assert.ok(log.calledOnceWithExactly('W1032'), 'missing attribution is reported only once'); + assert.strictEqual(openLayersMock.tileSourceOptions.url, `https://${type}.example.com/{z}/{x}/{y}.png`, 'tile source is still updated'); + } + } finally { + log.restore(); + } + }); + }); + QUnit.test('missing attribution logs W1032 again for a new provider', async function(assert) { + const log = sinon.stub(errors, 'log'); + try { + const map = await new Promise(resolve => { + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: 'https://first.example.com/{z}/{x}/{y}.png' + }, + onReady: ({ component }) => resolve(component) + }); + }); + assert.ok(log.calledOnceWithExactly('W1032'), 'initial provider reports missing attribution'); + const originalProvider = map._provider; + + await new Promise(resolve => { + map.option('onReady', resolve); + map.option('providerConfig', { + tileServer: 'https://second.example.com/{z}/{x}/{y}.png' + }); + }); + + assert.notStrictEqual(map._provider, originalProvider, 'provider is recreated'); + assert.ok(log.calledTwice, 'new provider has its own warning'); + assert.ok(log.alwaysCalledWithExactly('W1032'), 'only missing attribution is reported'); + assert.strictEqual(openLayersMock.tileSourceOptions.url, 'https://second.example.com/{z}/{x}/{y}.png', 'new tile server is used'); + } finally { + log.restore(); + } + }); QUnit.test('changing type replaces the tile source returned by the callback', function(assert) { const done = assert.async(); const initialized = $.Deferred(); From bc0ba36c248da621ee006989dd98f983773b3853 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Wed, 21 Oct 2026 17:56:48 +0300 Subject: [PATCH 22/28] Map: cache OSM marker world width per projection --- .../ui/map/provider.dynamic.osm.openlayers.ts | 16 +++-- .../mapParts/osmTests.js | 65 +++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) 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 index 2c72df062833..42485f5bacf3 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts @@ -109,6 +109,10 @@ class OpenLayersMap implements MapEngineMap { private readonly _markers = new Set(); + private _worldWidth?: number; + + private _worldWidthProjection: unknown; + private _eventHandlers?: { click: (event: unknown) => void; markerSizeChange: () => void; @@ -439,7 +443,7 @@ class OpenLayersMap implements MapEngineMap { private _getMarkerPosition(location: MapEngineMarkerOptions['location']): Coordinate { const view = this.originalMap.getView(); - const projection = view.getProjection(); + const projection = getCoordinateProjection(this._api, view.getProjection()); const coordinate = toCoordinate(this._api, location, projection); const center = view.getCenter(); @@ -447,9 +451,13 @@ class OpenLayersMap implements MapEngineMap { return coordinate; } - const west = toCoordinate(this._api, { lat: 0, lng: -180 }, projection)[0]; - const east = toCoordinate(this._api, { lat: 0, lng: 180 }, projection)[0]; - const worldWidth = Math.abs(east - west); + if (this._worldWidth === undefined || this._worldWidthProjection !== projection) { + const west = toCoordinate(this._api, { lat: 0, lng: -180 }, projection)[0]; + const east = toCoordinate(this._api, { lat: 0, lng: 180 }, projection)[0]; + this._worldWidth = Math.abs(east - west); + this._worldWidthProjection = projection; + } + const worldWidth = this._worldWidth; if (Number.isFinite(worldWidth) && worldWidth > 0) { coordinate[0] += Math.round((center[0] - coordinate[0]) / worldWidth) * worldWidth; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js index 6bdcf04c7d46..ef726a3f79ef 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -1377,6 +1377,71 @@ QUnit.module('OSM: markers', moduleConfig, () => { assert.strictEqual(map.eventHandlers['change:view'].length, 0, 'view replacement listener is removed on dispose'); assert.strictEqual(replacementView.eventHandlers['change:center'].length, 0, 'replacement view listener is removed on dispose'); }); + [null, 'EPSG:4326'].forEach(userProjection => { + QUnit.test(`marker world width is reused with ${userProjection ? 'user' : 'view'} projection`, function(assert) { + openLayersMock.userProjection = userProjection; + const scale = userProjection ? 1 : 1000; + const engineMap = createOpenLayersEngine(openLayersMock).createMap(document.createElement('div')); + const map = openLayersMock.mapInstance; + const worldTransforms = () => openLayersMock.transformedCoordinates.filter(({ coordinate }) => Math.abs(coordinate[0]) === 180 && coordinate[1] === 0); + try { + engineMap.addMarker({ location: { lat: 10, lng: -179 } }); + engineMap.addMarker({ location: { lat: 20, lng: 179 } }); + assert.strictEqual(worldTransforms().length, 2, 'world edges are transformed once for multiple markers'); + + [179, -179, 539].forEach(longitude => map.getView().setCenter([longitude * scale, 0])); + assert.strictEqual(worldTransforms().length, 2, 'panning reuses the world width'); + assert.deepEqual(openLayersMock.addedOverlays.map(overlay => overlay.getPosition()), [[541 * scale, 10 * scale], [539 * scale, 20 * scale]], 'markers follow the nearest world'); + + engineMap.addMarker({ location: { lat: 30, lng: -178 } }); + assert.strictEqual(worldTransforms().length, 2, 'new markers reuse the world width'); + + map.setView(new openLayersMock.View({ + center: [-179 * scale, 0], + projection: userProjection || 'EPSG:3857', + zoom: 1 + })); + assert.strictEqual(worldTransforms().length, 2, 'view replacement with the same effective projection reuses the world width'); + assert.deepEqual(openLayersMock.addedOverlays.map(overlay => overlay.getPosition()), [[-179 * scale, 10 * scale], [-181 * scale, 20 * scale], [-178 * scale, 30 * scale]], 'replacement view updates marker positions'); + } finally { + engineMap.dispose(); + } + }); + }); + ['view', 'user'].forEach(projectionSource => { + QUnit.test(`marker world width is updated when the ${projectionSource} projection changes`, function(assert) { + const engineMap = createOpenLayersEngine(openLayersMock).createMap(document.createElement('div'), { center: { lat: 0, lng: 179 } }); + const map = openLayersMock.mapInstance; + const worldTransforms = () => openLayersMock.transformedCoordinates.filter(({ coordinate }) => Math.abs(coordinate[0]) === 180 && coordinate[1] === 0); + const setProjection = (projection, center) => { + if(projectionSource === 'user') { + openLayersMock.userProjection = projection === 'EPSG:4326' ? projection : null; + map.getView().setCenter(center); + } else { + map.setView(new openLayersMock.View({ center, projection, zoom: 1 })); + } + }; + try { + engineMap.addMarker({ location: { lat: 10, lng: -179 } }); + engineMap.addMarker({ location: { lat: 20, lng: 179 } }); + assert.deepEqual(openLayersMock.addedOverlays.map(overlay => overlay.getPosition()), [[181000, 10000], [179000, 20000]], 'initial positions use the view projection'); + + setProjection('EPSG:4326', [179, 0]); + assert.strictEqual(worldTransforms().length, 4, 'world edges are transformed once for the new projection'); + assert.deepEqual(openLayersMock.addedOverlays.map(overlay => overlay.getPosition()), [[181, 10], [179, 20]], 'markers wrap using the new world width'); + + map.getView().setCenter([539, 0]); + assert.strictEqual(worldTransforms().length, 4, 'panning reuses the updated world width'); + assert.deepEqual(openLayersMock.addedOverlays.map(overlay => overlay.getPosition()), [[541, 10], [539, 20]], 'markers follow the new projection into an adjacent world'); + + setProjection('EPSG:3857', [-179000, 0]); + assert.strictEqual(worldTransforms().length, 6, 'restoring the projection recalculates the world width once'); + assert.deepEqual(openLayersMock.addedOverlays.map(overlay => overlay.getPosition()), [[-179000, 10000], [-181000, 20000]], 'restored projection does not reuse the geographic world width'); + } finally { + engineMap.dispose(); + } + }); + }); QUnit.test('default marker accessible names are localized', function(assert) { const defaultLocale = localization.locale(); const markerAriaLabel = 'Localized map marker'; From d439f3f8c8b72062cf7f2e2c59f0f0591d7c3901 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Wed, 21 Oct 2026 17:57:12 +0300 Subject: [PATCH 23/28] Map: avoid duplicate OSM tooltip focus checks during positioning --- .../provider.dynamic.osm.openlayers.tooltip.ts | 4 ++++ .../DevExpress.ui.widgets/mapParts/osmTests.js | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts index 58ea172553e8..3a5507e7d2bb 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts @@ -156,6 +156,10 @@ export class OpenLayersMarkerTooltip { }; private readonly _syncFocusState = (): void => { + if (this._positioning) { + return; + } + this._inertElements.forEach((element) => { element.inert = false; }); this._inertElements.clear(); if (this._focusEnabled) { diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js index ef726a3f79ef..0b274471f2fa 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -2492,6 +2492,24 @@ QUnit.module('OSM: marker tooltips', moduleConfig, () => { assert.ok(repaint.notCalled, 'no full repaint'); }); + QUnit.test('postrender checks tooltip focus once after positioning', async function(assert) { + await createMap({ markers: [{ location, tooltip: '' }] }); + positionMarker(); + getMarker().click(); + await Promise.resolve(); + const tooltip = getTooltip(); + const popup = getContent(tooltip).parentElement; + const focusTargets = sinon.spy(popup, 'querySelectorAll'); + const queriesDuringPositioning = []; + tooltip.on('positioned', () => queriesDuringPositioning.push(focusTargets.callCount)); + + openLayersMock.mapInstance.trigger('postrender'); + + assert.ok(queriesDuringPositioning.length, 'positioned events are raised'); + assert.ok(queriesDuringPositioning.every(count => count === 0), 'focus is not checked during positioning'); + assert.strictEqual(focusTargets.callCount, 1, 'focus is checked once after positioning'); + }); + QUnit.test('tooltip position follows a resized image marker', async function(assert) { await createMap({ markers: [{ location, From 41e8cc241060fae4e6a33fd87a3c81738ffaecc3 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Wed, 21 Oct 2026 18:34:52 +0300 Subject: [PATCH 24/28] Map: warn about missing OSM tile config once per map type --- .../__internal/ui/map/provider.dynamic.osm.ts | 8 +- .../mapParts/osmTests.js | 104 ++++++++++++++---- 2 files changed, 91 insertions(+), 21 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts index 0d71148592f7..f53daf4dc7de 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts @@ -154,6 +154,8 @@ class OsmProvider extends DynamicProvider { _boundLocations: MapLocation[] = []; + private readonly _tileServerWarningTypes = new Set(); + _attributionWarningLogged = false; _calculateLocationWarningLogged = false; @@ -216,7 +218,11 @@ class OsmProvider extends DynamicProvider { const config = this._resolveTileConfig(type); if (!config?.url) { - errors.log('W1030'); + if (!this._tileServerWarningTypes.has(type)) { + errors.log('W1030'); + this._tileServerWarningTypes.add(type); + } + return undefined; } diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js index 0b274471f2fa..51272f7cb5d7 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -707,30 +707,94 @@ QUnit.module('OSM: tile server', moduleConfig, () => { 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(); + QUnit.test('missing tile config logs W1030 once per type and preserves the current source', async function(assert) { 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' - } : undefined - }, - onReady: () => initialized.resolve() - }).dxMap('instance'); - initialized.done(() => { + try { + const map = await new Promise(resolve => { + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: type => type === 'roadmap' ? { + url: 'https://roadmap.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } : undefined + }, + onReady: ({ component }) => resolve(component) + }); + }); const originalSource = openLayersMock.tileLayer.source; - map.option('onUpdated', () => { + const changes = [['satellite', 1], ['hybrid', 2], ['roadmap', 2], ['satellite', 2], ['hybrid', 2]]; + + for(let index = 0; index < changes.length; index++) { + const [type, warningCount] = changes[index]; + map.option('type', type); + await map._lastAsyncAction; + assert.strictEqual(openLayersMock.tileLayer.source, originalSource, 'current source is preserved'); - assert.ok(log.calledWith('W1030'), 'W1030 is logged'); - log.restore(); - done(); + assert.strictEqual(log.callCount, warningCount, `missing config is reported once for ${type}`); + assert.ok(log.alwaysCalledWithExactly('W1030'), 'only missing tile config is reported'); + } + } finally { + log.restore(); + } + }); + QUnit.test('missing tile config can be resolved again without repeating W1030', async function(assert) { + const log = sinon.stub(errors, 'log'); + let config; + const tileServer = sinon.spy(() => config); + try { + const map = await new Promise(resolve => { + $('#map').dxMap({ + provider: 'osm', + type: 'satellite', + providerConfig: { tileServer }, + onReady: ({ component }) => resolve(component) + }); }); - map.option('type', 'satellite'); - }); + assert.strictEqual(openLayersMock.addedTileLayers.length, 0, 'initial missing config leaves the map without tiles'); + + await map._provider.updateMapType(); + assert.ok(tileServer.calledTwice, 'an unresolved map type is checked again'); + assert.ok(log.calledOnceWithExactly('W1030'), 'the repeated lookup does not repeat the warning'); + + config = { + url: 'https://satellite.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + }; + await map._provider.updateMapType(); + + assert.strictEqual(tileServer.callCount, 3, 'the callback is still evaluated'); + assert.strictEqual(openLayersMock.addedTileLayers.length, 1, 'available config creates the tile layer'); + assert.strictEqual(openLayersMock.tileSourceOptions.url, config.url, 'the new source is applied'); + assert.ok(log.calledOnceWithExactly('W1030'), 'no additional warning is logged'); + } finally { + log.restore(); + } + }); + QUnit.test('missing tile config logs W1030 again for a new provider', async function(assert) { + const log = sinon.stub(errors, 'log'); + try { + const map = await new Promise(resolve => { + $('#map').dxMap({ + provider: 'osm', + providerConfig: { tileServer: () => undefined }, + onReady: ({ component }) => resolve(component) + }); + }); + assert.ok(log.calledOnceWithExactly('W1030'), 'initial provider reports missing tile config'); + const originalProvider = map._provider; + + await new Promise(resolve => { + map.option('onReady', resolve); + map.option('providerConfig', { tileServer: () => undefined }); + }); + + assert.notStrictEqual(map._provider, originalProvider, 'provider is recreated'); + assert.ok(log.calledTwice, 'new provider has its own warning'); + assert.ok(log.alwaysCalledWithExactly('W1030'), 'only missing tile config is reported'); + } finally { + log.restore(); + } }); QUnit.test('tile source creation failure preserves the current source', function(assert) { const engine = createOpenLayersEngine(openLayersMock); From a642ddce31810b3ffccbbe34347e07414de17248 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Wed, 21 Oct 2026 18:54:40 +0300 Subject: [PATCH 25/28] Map: recover async action queue and log errors once --- .../devextreme/js/__internal/ui/map/map.ts | 62 +++++++----- .../mapParts/commonTests.js | 94 ++++++++++++++++++ .../mapParts/osmTests.js | 97 +++++++++++++++++++ 3 files changed, 229 insertions(+), 24 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/map/map.ts b/packages/devextreme/js/__internal/ui/map/map.ts index 16d151ffc777..372904644df4 100644 --- a/packages/devextreme/js/__internal/ui/map/map.ts +++ b/packages/devextreme/js/__internal/ui/map/map.ts @@ -7,6 +7,7 @@ import devices from '@js/core/devices'; import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import { wrapToArray } from '@js/core/utils/array'; +import { logger } from '@js/core/utils/console'; import { extend } from '@js/core/utils/extend'; import { titleize } from '@js/core/utils/inflector'; import { isNumeric } from '@js/core/utils/type'; @@ -58,6 +59,8 @@ class Map extends Widget { _provider!: azure | googleStatic | google | bing | osm; + private _providerRenderFailed = false; + _asyncActionSuppressed?: boolean; _suppressAsyncAction?: boolean; @@ -218,6 +221,7 @@ class Map extends Widget { const Provider = PROVIDERS[provider]; this._provider = new Provider(this, this._$container); + this._providerRenderFailed = false; // eslint-disable-next-line @typescript-eslint/no-floating-promises this._queueAsyncAction('render', this._rendered.markers, this._rendered.routes); @@ -369,42 +373,52 @@ class Map extends Widget { const isActionSuppressed = this._suppressAsyncAction; const provider = this._provider; - this._lastAsyncAction = this._lastAsyncAction.then(() => { - if (!provider || provider !== this._provider || isActionSuppressed) { + const executeAction = async (): Promise => { + if (!provider || provider !== this._provider || isActionSuppressed + || this._providerRenderFailed) { /// #DEBUG this._asyncActionSuppressed = true; /// #ENDDEBUG - return Promise.resolve(); + return undefined; } - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return provider[name](...markerAndRoutes).then((result) => { + let result = null; + try { + result = await provider[name](...markerAndRoutes); + } catch (error) { if (provider !== this._provider || this._disposed) { return undefined; } - const arrayResult = wrapToArray(result); - - const mapRefreshed = arrayResult[0]; - if (mapRefreshed && !this._disposed) { - this._triggerReadyAction(); - } - /// #DEBUG - if (!mapRefreshed && name !== 'clean' && !this._disposed) { - this._triggerUpdateAction(); + if (name === 'render') { + this._providerRenderFailed = true; } - /// #ENDDEBUG - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return arrayResult[1]; - }, (error) => { - if (provider !== this._provider || this._disposed) { - return undefined; - } + throw error; + } - return Promise.reject(error); - }); - }); + if (provider !== this._provider || this._disposed) { + return undefined; + } + + const arrayResult = wrapToArray(result); + const mapRefreshed = arrayResult[0]; + if (mapRefreshed) { + this._triggerReadyAction(); + } + /// #DEBUG + if (!mapRefreshed && name !== 'clean') { + this._triggerUpdateAction(); + } + /// #ENDDEBUG + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return arrayResult[1]; + }; + + this._lastAsyncAction = this._lastAsyncAction.then(executeAction, executeAction); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this._lastAsyncAction.catch((error) => { logger.error(error); }); return this._lastAsyncAction; } diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/commonTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/commonTests.js index 9a1b90b59b45..ee5d91f7b2c2 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/commonTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/commonTests.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import { MARKERS, ROUTES } from './utils.js'; import Map from 'ui/map'; +import { logger } from 'core/utils/console'; import Provider from '__internal/ui/map/provider'; import GoogleStaticProvider from '__internal/ui/map/provider.google_static'; import ajaxMock from '../../../helpers/ajaxMock.js'; @@ -781,6 +782,7 @@ QUnit.test('routes option change should not render incorrect routes', function(a QUnit.module('Change provider', { beforeEach: function() { + this.logError = sinon.stub(logger, 'error'); const fakeURL = '/fakeGoogleUrl?'; GoogleStaticProvider.remapConstant(fakeURL); @@ -791,6 +793,7 @@ QUnit.module('Change provider', { }); }, afterEach: function() { + this.logError.restore(); ajaxMock.clear(); } }); @@ -831,6 +834,76 @@ QUnit.test('change provider and async options', function(assert) { }); }); +['reject', 'throw'].forEach(failure => { + [false, true].forEach(queuedBeforeFailure => { + QUnit.test(`async queue continues after ${failure} (next action already queued: ${queuedBeforeFailure})`, async function(assert) { + const reason = new Error('Provider operation failed'); + const provider = { + updateZoom: sinon.stub().callsFake(() => { + if(failure === 'throw') { + throw reason; + } + return Promise.reject(reason); + }), + updateCenter: sinon.stub().returns(Promise.resolve()) + }; + const map = { + _provider: provider, + _lastAsyncAction: Promise.resolve(), + _triggerReadyAction: sinon.spy(), + _triggerUpdateAction: sinon.spy() + }; + const failedAction = Map.prototype._queueAsyncAction.call(map, 'updateZoom'); + let nextAction; + if(queuedBeforeFailure) { + nextAction = Map.prototype._queueAsyncAction.call(map, 'updateCenter'); + nextAction.catch(() => {}); + } + + await assert.rejects(failedAction, reason, 'the failed action keeps its original error'); + if(!queuedBeforeFailure) { + nextAction = Map.prototype._queueAsyncAction.call(map, 'updateCenter'); + } + await nextAction; + + assert.ok(provider.updateZoom.calledOnce, 'failed action is not retried'); + assert.ok(provider.updateCenter.calledOnce, 'the next action is executed'); + assert.ok(map._triggerReadyAction.notCalled, 'no ready event is raised'); + assert.ok(map._triggerUpdateAction.calledOnce, 'only the successful action raises an update'); + assert.ok(this.logError.calledOnceWithExactly(reason), 'the original error is logged once'); + }); + }); + QUnit.test(`failed render skips queued provider updates after ${failure}`, async function(assert) { + const reason = new Error('Provider initialization failed'); + const provider = { + render: sinon.stub().callsFake(() => { + if(failure === 'throw') { + throw reason; + } + return Promise.reject(reason); + }), + updateZoom: sinon.stub().returns(Promise.resolve()) + }; + const map = { + _provider: provider, + _lastAsyncAction: Promise.resolve(), + _triggerReadyAction: sinon.spy(), + _triggerUpdateAction: sinon.spy() + }; + const render = Map.prototype._queueAsyncAction.call(map, 'render'); + const update = Map.prototype._queueAsyncAction.call(map, 'updateZoom'); + update.catch(() => {}); + + await assert.rejects(render, reason, 'render keeps its original error'); + await update; + + assert.ok(provider.updateZoom.notCalled, 'an uninitialized provider is not updated'); + assert.ok(map._triggerReadyAction.notCalled, 'failed render does not raise ready'); + assert.ok(map._triggerUpdateAction.notCalled, 'skipped update does not raise updated'); + assert.ok(this.logError.calledOnceWithExactly(reason), 'skipped updates do not log the render error again'); + }); +}); + ['resolve', 'reject'].forEach((completion) => { QUnit.test(`async ${completion} from a replaced provider is ignored`, function(assert) { let completeAction; @@ -857,10 +930,31 @@ QUnit.test('change provider and async options', function(assert) { }).then(() => { assert.ok(map._triggerReadyAction.notCalled, 'stale action does not raise onReady'); assert.ok(map._triggerUpdateAction.notCalled, 'stale action does not raise onUpdated'); + assert.ok(this.logError.notCalled, 'a replaced provider does not log stale errors'); }); }); }); +QUnit.test('a rejection after disposal is not logged', async function(assert) { + let rejectAction; + const provider = { + updateZoom: () => new Promise((_resolve, reject) => { + rejectAction = reject; + }) + }; + const map = { + _provider: provider, + _lastAsyncAction: Promise.resolve() + }; + const action = Map.prototype._queueAsyncAction.call(map, 'updateZoom'); + await Promise.resolve(); + map._disposed = true; + rejectAction(new Error('Disposed provider')); + await action; + + assert.ok(this.logError.notCalled, 'a disposed map does not log stale errors'); +}); + QUnit.test('queued action is not started after its provider is replaced', function(assert) { let continueQueue; const replacedProvider = { diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js index 51272f7cb5d7..73e02e12a1e7 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -5,6 +5,7 @@ import { setRegisteredMapEngine } from '__internal/ui/map/provider.dynamic.osm.e import { createOpenLayersEngine } from '__internal/ui/map/provider.dynamic.osm.openlayers'; import coreErrors from 'core/errors'; import resizeObserverSingleton from 'core/resize_observer'; +import { logger } from 'core/utils/console'; import localization from 'localization'; import Popover from 'ui/popover'; import SelectBox from 'ui/select_box'; @@ -328,6 +329,102 @@ QUnit.module('OSM: map loading', moduleConfig, () => { }); }); }); + QUnit.test('failed initialization skips queued updates until repaint', async function(assert) { + delete window.ol; + const onReady = sinon.spy(); + const onUpdated = sinon.spy(); + const map = $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady, + onUpdated + }).dxMap('instance'); + const render = map._lastAsyncAction; + const updateZoom = sinon.spy(map._provider, 'updateZoom'); + map.option('zoom', 5); + + await assert.rejects(render, /E1069/, 'initialization keeps its diagnostic error'); + await map._lastAsyncAction; + assert.ok(updateZoom.notCalled, 'queued update does not use an uninitialized provider'); + assert.ok(onReady.notCalled, 'failed render does not raise ready'); + assert.ok(onUpdated.notCalled, 'skipped update does not raise updated'); + + window.ol = openLayersMock; + map.repaint(); + await map._lastAsyncAction; + assert.ok(onReady.calledOnce, 'repaint initializes a new provider'); + assert.strictEqual(openLayersMock.viewZoom, 5, 'the latest option is applied on repaint'); + + map.option('zoom', 6); + await map._lastAsyncAction; + assert.strictEqual(openLayersMock.viewZoom, 6, 'the new provider processes later updates'); + }); + QUnit.test('missing OpenLayers is logged once without observing the render promise', async function(assert) { + const logError = sinon.stub(logger, 'error'); + const unhandledRejection = sinon.spy(); + window.addEventListener('unhandledrejection', unhandledRejection); + delete window.ol; + + try { + const map = $('#map').dxMap({ provider: 'osm' }).dxMap('instance'); + await new Promise(resolve => setTimeout(resolve, 50)); + + assert.ok(logError.calledOnce, 'initialization logs one error without a promise observer'); + const error = logError.firstCall?.args[0]; + assert.ok(error instanceof Error, 'the diagnostic is an Error object'); + assert.strictEqual(error?.message, errors.Error('E1069').message, 'E1069 retains its message and documentation link'); + assert.ok(unhandledRejection.notCalled, 'the browser does not report the same error as unhandled'); + + map.option('zoom', 5); + await map._lastAsyncAction; + map.option('zoom', 6); + await map._lastAsyncAction; + + assert.ok(logError.calledOnce, 'subsequent updates do not repeat the initialization error'); + } finally { + window.removeEventListener('unhandledrejection', unhandledRejection); + logError.restore(); + } + }); + QUnit.test('a throwing tileServer callback does not block later option updates', async function(assert) { + const reason = new Error('Tile configuration failed'); + const onUpdated = sinon.spy(); + const map = await new Promise(resolve => { + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: type => { + if(type === 'satellite') { + throw reason; + } + return { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + }; + } + }, + onReady: ({ component }) => resolve(component), + onUpdated + }); + }); + const originalSource = openLayersMock.tileLayer.source; + onUpdated.resetHistory(); + map.option('type', 'satellite'); + const failedAction = map._lastAsyncAction; + map.option('zoom', 8); + + await assert.rejects(failedAction, reason, 'the callback error remains available'); + await map._lastAsyncAction; + + assert.strictEqual(openLayersMock.tileLayer.source, originalSource, 'the existing tiles are preserved'); + assert.strictEqual(openLayersMock.viewZoom, 8, 'the queued zoom change is applied'); + assert.ok(onUpdated.calledOnce, 'only the successful update raises updated'); + }); QUnit.test('load rejects with E1069 when the OpenLayers ImageTile API is missing', function(assert) { const done = assert.async(); const provider = createProvider(); From 18ea2e87ba5250e2c57982d288860397c566bbfb Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Wed, 21 Oct 2026 19:47:22 +0300 Subject: [PATCH 26/28] Map: test internal Popover contract for OSM tooltips --- .../DevExpress.ui.widgets/popover.tests.js | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/popover.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/popover.tests.js index 5beb8f10e63d..276f63e40b2a 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/popover.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/popover.tests.js @@ -76,6 +76,104 @@ const getElementsPositionAndSize = function($popover, $target) { }; }; +QUnit.module('Map marker tooltip internal contract', { + beforeEach() { + fixtures.customBoundary.create(); + this.$target = $('#where').css({ width: 24, height: 36, left: 140, top: 150 }); + this.instance = new Popover($('#what'), { + target: this.$target, + container: '#boundary', + width: 180, + height: 120, + animation: null, + visible: true, + showTitle: false, + showCloseButton: false, + hideOnOutsideClick: false, + hideOnParentScroll: false, + contentTemplate: () => $('
').html('First line
Second line'), + position: { + my: 'bottom center', + at: 'top center', + of: this.$target, + collision: 'flip', + boundary: '#boundary' + } + }); + }, + afterEach() { + fixtures.customBoundary.drop(); + } +}, () => { + QUnit.test('_renderPosition(false) follows the target and flips without shrinking content', function(assert) { + const $content = this.instance.$overlayContent(); + const initialWidth = getOuterWidth($content); + const initialHeight = getOuterHeight($content); + + this.$target.css('top', 0); + this.instance._renderPosition(false); + + assert.ok($content.offset().top >= this.$target.offset().top + getOuterHeight(this.$target), 'flips below the target'); + + this.$target.css('top', -140); + this.instance._renderPosition(false); + + assert.strictEqual(getOuterWidth($content), initialWidth, 'width is preserved'); + assert.strictEqual(getOuterHeight($content), initialHeight, 'height is preserved'); + assert.ok($content.offset().top < $('#boundary').offset().top, 'popover can follow the target outside the boundary'); + }); + + [120, 'auto'].forEach(height => { + QUnit.test(`_renderDimensions and _setContentHeight(true) restore size after boundary adjustment (height: ${height})`, function(assert) { + this.instance.option('height', height); + const $content = this.instance.$overlayContent(); + const initialWidth = getOuterWidth($content); + const initialHeight = getOuterHeight($content); + const initialContentHeight = getHeight(this.instance.content()); + + this.$target.css('top', -getOuterHeight(this.$target) - initialHeight / 2); + this.instance._renderPosition(); + + assert.ok(getOuterHeight($content) < initialHeight, 'normal positioning reduces the height at the boundary'); + + this.instance._renderDimensions(); + this.instance._setContentHeight(true); + this.instance._renderPosition(false); + + assert.strictEqual(getOuterWidth($content), initialWidth, 'configured width is restored'); + assert.strictEqual(getOuterHeight($content), initialHeight, 'configured height is restored'); + assert.strictEqual(getHeight(this.instance.content()), initialContentHeight, 'inner content height is restored'); + }); + }); + + QUnit.test('effective role and _preventDialogContainerFocus support tooltip customization', function(assert) { + const instance = this.instance; + assert.strictEqual(instance._getEffectiveAriaRole(), 'tooltip', 'default role is available to the map'); + + instance.option({ + _preventDialogContainerFocus: true, + focusStateEnabled: false, + tabFocusLoopEnabled: false, + showTitle: true, + showCloseButton: true + }); + + assert.strictEqual(instance._getEffectiveAriaRole(), 'dialog', 'title and Close button switch the effective role'); + assert.strictEqual(instance.option('focusStateEnabled'), false, 'role change does not enable focus handling'); + assert.strictEqual(instance.option('tabFocusLoopEnabled'), false, 'role change does not enable the focus loop'); + + instance.hide(); + this.$target.attr('tabindex', 0).trigger('focus'); + instance.show(); + + assert.strictEqual(document.activeElement, this.$target.get(0), 'showing the dialog does not steal focus'); + + instance.option('_popoverContentRole', 'tooltip'); + + assert.strictEqual(instance._getEffectiveAriaRole(), 'tooltip', 'an explicit role overrides the dialog configuration'); + }); +}); + QUnit.module('render', () => { QUnit.test('render', function(assert) { fixtures.simple.create(); From 24f760b667112cc3bbee25b9dfab3be3aabfe3f4 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Wed, 21 Oct 2026 20:00:13 +0300 Subject: [PATCH 27/28] Map: wait for failed batches before continuing the action queue --- .../devextreme/js/__internal/ui/map/map.ts | 2 +- .../js/__internal/ui/map/provider.dynamic.ts | 16 ++-- .../js/__internal/ui/map/provider.ts | 11 ++- .../mapParts/commonTests.js | 66 ++++++++++++++++ .../mapParts/osmTests.js | 77 +++++++++++++++++++ 5 files changed, 162 insertions(+), 10 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/map/map.ts b/packages/devextreme/js/__internal/ui/map/map.ts index 372904644df4..ea91d7ff3b95 100644 --- a/packages/devextreme/js/__internal/ui/map/map.ts +++ b/packages/devextreme/js/__internal/ui/map/map.ts @@ -329,7 +329,7 @@ class Map extends Widget { if (changeBag) { changeBag.resolve(result); } - }); + }, () => {}); break; } case 'markerIconSrc': diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.ts index 9bb56bdd268a..37f0359ebdde 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.ts @@ -107,7 +107,7 @@ abstract class DynamicProvider< } _renderImpl(): Promise { - return this._load().then(() => this._init()).then(() => Promise.all([ + return this._load().then(() => this._init()).then(() => this._waitForAll([ this.updateMapType(), this._areBoundsSet() ? this.updateBounds() : this.updateCenter(), ])).then(() => { @@ -146,8 +146,7 @@ abstract class DynamicProvider< abstract _attachHandlers(): void; addMarkers(markers: MarkerOptions[]): Promise<[boolean, unknown[]]> { - return Promise - .all(markers.map((options) => this._addMarker(options))) + return this._waitForAll(markers.map(async (options) => this._addMarker(options))) .then((markerObjects: MarkerObject[]) => { // eslint-disable-next-line @typescript-eslint/no-floating-promises this._fitBounds(); @@ -225,12 +224,13 @@ abstract class DynamicProvider< } addRoutes(routes: RouteOptions[]): Promise<[boolean, unknown[]]> { - return Promise.all(routes.map((options) => this._addRoute(options))).then((routeObjects) => { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this._fitBounds(); + return this._waitForAll(routes.map(async (options) => this._addRoute(options))) + .then((routeObjects) => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this._fitBounds(); - return [false, routeObjects.map((routeObject) => routeObject.instance)]; - }); + return [false, routeObjects.map((routeObject) => routeObject.instance)]; + }); } _addRoute(options: RouteOptions): Promise { diff --git a/packages/devextreme/js/__internal/ui/map/provider.ts b/packages/devextreme/js/__internal/ui/map/provider.ts index 873106a2a4ac..9bad93b12294 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.ts @@ -36,11 +36,20 @@ abstract class Provider { return '#0000FF'; } + protected async _waitForAll(promises: Promise[]): Promise { + try { + return await Promise.all(promises); + } catch (error) { + await Promise.allSettled(promises); + throw error; + } + } + render( markerOptions: MarkerOptions[], routeOptions: RouteOptions[], ): Promise { - return this._renderImpl().then(() => Promise.all([ + return this._renderImpl().then(() => this._waitForAll([ this._applyFunctionIfNeeded('addMarkers', markerOptions), this._applyFunctionIfNeeded('addRoutes', routeOptions), ]).then(() => true)); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/commonTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/commonTests.js index ee5d91f7b2c2..c9679c7a71f2 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/commonTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/commonTests.js @@ -3,6 +3,7 @@ import { MARKERS, ROUTES } from './utils.js'; import Map from 'ui/map'; import { logger } from 'core/utils/console'; import Provider from '__internal/ui/map/provider'; +import DynamicProvider from '__internal/ui/map/provider.dynamic'; import GoogleStaticProvider from '__internal/ui/map/provider.google_static'; import ajaxMock from '../../../helpers/ajaxMock.js'; @@ -780,6 +781,71 @@ QUnit.test('routes option change should not render incorrect routes', function(a }); +QUnit.module('Dynamic provider batch errors', () => { + ['Markers', 'Routes'].forEach(failedItems => { + QUnit.test(`render waits for both collections after add${failedItems} fails`, async function(assert) { + const reason = new Error('Collection failed'); + let finishPending; + const pending = new Promise(resolve => { finishPending = resolve; }); + const provider = { + _renderImpl: () => Promise.resolve(), + _waitForAll: Provider.prototype._waitForAll, + _applyFunctionIfNeeded: Provider.prototype._applyFunctionIfNeeded, + addMarkers: () => failedItems === 'Markers' ? Promise.reject(reason) : pending, + addRoutes: () => failedItems === 'Routes' ? Promise.reject(reason) : pending + }; + let settled = false; + const render = Provider.prototype.render.call(provider, [{}], [{}]); + const rejection = assert.rejects(render, reason, 'render preserves the collection error'); + render.then(() => { settled = true; }, () => { settled = true; }); + + await new Promise(resolve => setTimeout(resolve)); + + assert.notOk(settled, 'render waits for the other collection'); + finishPending(); + await rejection; + }); + }); + + ['Markers', 'Routes'].forEach(items => { + ['reject', 'throw'].forEach(failure => { + QUnit.test(`add${items} waits for pending items after ${failure}`, async function(assert) { + const reason = new Error('First item failed'); + const laterReason = new Error('Second item failed'); + let rejectPending; + const pending = new Promise((_resolve, reject) => { rejectPending = reject; }); + pending.catch(() => {}); + const addItem = sinon.stub().callsFake(options => { + if(options.first) { + if(failure === 'throw') { + throw reason; + } + return Promise.reject(reason); + } + return pending; + }); + const provider = { + [`_add${items === 'Markers' ? 'Marker' : 'Route'}`]: addItem, + _waitForAll: Provider.prototype._waitForAll, + _fitBounds: sinon.spy() + }; + let settled = false; + const batch = Promise.resolve().then(() => DynamicProvider.prototype[`add${items}`].call(provider, [{ first: true }, {}])); + const rejection = assert.rejects(batch, reason, 'the first error is preserved'); + batch.then(() => { settled = true; }, () => { settled = true; }); + + await new Promise(resolve => setTimeout(resolve)); + + assert.ok(addItem.calledTwice, 'both items are processed'); + assert.notOk(settled, 'the batch waits for the pending item'); + rejectPending(laterReason); + await rejection; + assert.ok(provider._fitBounds.notCalled, 'a failed batch does not adjust the viewport'); + }); + }); + }); +}); + QUnit.module('Change provider', { beforeEach: function() { this.logError = sinon.stub(logger, 'error'); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js index 73e02e12a1e7..db7c2dcb40bb 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -490,6 +490,83 @@ QUnit.module('OSM: map loading', moduleConfig, () => { }).dxMap('instance'); }); }); +QUnit.module('OSM: async batch recovery', moduleConfig, () => { + ['markers', 'routes'].forEach(collection => { + QUnit.test(`clearing ${collection} waits for a failed batch to finish`, async function(assert) { + const reason = new Error('Added callback failed'); + const logError = sinon.stub(logger, 'error'); + const onUpdated = sinon.spy(); + const onRemoved = sinon.spy(); + let firstAdded; + const added = new Promise(resolve => { firstAdded = resolve; }); + let resolvePending; + const pending = new Promise(resolve => { resolvePending = resolve; }); + const isMarkers = collection === 'markers'; + const first = isMarkers + ? { location: [10, 20] } + : { locations: [[10, 20], [11, 21]] }; + const second = isMarkers + ? { location: 'Pending location' } + : { locations: [[30, 40], [31, 41]] }; + const onAdded = sinon.spy(({ options }) => { + if(options === first) { + firstAdded(); + throw reason; + } + }); + try { + const map = $('#map').dxMap({ + provider: 'osm', + autoAdjust: false, + providerConfig: { + tileServer: { url: 'https://tiles.example.com/{z}/{x}/{y}.png', attribution: 'Example attribution' }, + calculateLocation: () => pending, + calculateRoute: ({ locations }) => locations[0].lat === 10 + ? Promise.resolve([[10, 20], [11, 21]]) + : pending + }, + [isMarkers ? 'onMarkerAdded' : 'onRouteAdded']: onAdded, + [isMarkers ? 'onMarkerRemoved' : 'onRouteRemoved']: onRemoved, + onUpdated + }).dxMap('instance'); + await map._lastAsyncAction; + onUpdated.resetHistory(); + + map.option(collection, [first, second]); + const batch = map._lastAsyncAction; + const rejection = assert.rejects(batch, reason, 'the original callback error is preserved'); + await added; + map.option(collection, []); + const clear = map._lastAsyncAction; + let cleared = false; + clear.then(() => { cleared = true; }); + + await new Promise(resolve => setTimeout(resolve)); + + assert.notOk(cleared, 'the next operation waits for the whole batch'); + assert.ok(onRemoved.notCalled, 'removal does not overlap with pending additions'); + resolvePending(isMarkers ? { lat: 30, lng: 40 } : [[30, 40], [31, 41]]); + await rejection; + await clear; + await new Promise(resolve => setTimeout(resolve)); + + assert.strictEqual(map.option(collection).length, 0, 'the collection option is empty'); + assert.strictEqual(map._provider[`_${collection}`].length, 0, 'no items remain in the provider'); + if(isMarkers) { + assert.strictEqual(openLayersMock.addedOverlays.length, openLayersMock.removedOverlays.length, 'all marker overlays are removed'); + } else { + assert.strictEqual(openLayersMock.addedVectorLayers[0].getSource().getFeatures().length, 0, 'all route features are removed'); + } + assert.ok(onAdded.calledTwice, 'both additions finish before removal'); + assert.ok(onRemoved.calledTwice, 'both items are removed'); + assert.ok(onUpdated.calledOnce, 'only the successful clear raises updated'); + assert.ok(logError.calledOnceWithExactly(reason), 'the callback error is logged once'); + } finally { + logError.restore(); + } + }); + }); +}); QUnit.module('OSM: tile server', moduleConfig, () => { QUnit.test('string tileServer creates a tile layer with defaults', function(assert) { const done = assert.async(); From bafd4c880ffb2ad17019255c2d1da7b0be61ca58 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Fri, 23 Oct 2026 20:34:25 +0300 Subject: [PATCH 28/28] Map: clean up legacy Angular metadata generation Co-authored-by: Mikhail Preyskurantov <5574159+mpreyskurantov@users.noreply.github.com> --- packages/devextreme-angular/src/ui/nested/marker-dxi.ts | 4 ++-- packages/devextreme-metadata/make-angular-metadata.ts | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/devextreme-angular/src/ui/nested/marker-dxi.ts b/packages/devextreme-angular/src/ui/nested/marker-dxi.ts index e055d78fa6da..4e6ef83f55ba 100644 --- a/packages/devextreme-angular/src/ui/nested/marker-dxi.ts +++ b/packages/devextreme-angular/src/ui/nested/marker-dxi.ts @@ -57,10 +57,10 @@ export class DxiMarkerComponent extends CollectionNestedOption { } @Input() - get onClick(): Function { + get onClick(): any { return this._getOption('onClick'); } - set onClick(value: Function) { + set onClick(value: any) { this._setOption('onClick', value); } diff --git a/packages/devextreme-metadata/make-angular-metadata.ts b/packages/devextreme-metadata/make-angular-metadata.ts index 791b014255aa..cd927121b998 100644 --- a/packages/devextreme-metadata/make-angular-metadata.ts +++ b/packages/devextreme-metadata/make-angular-metadata.ts @@ -44,6 +44,8 @@ Ng.makeMetadata({ ['ui/popup:dxPopupOptions', 'ui/card_view:Editing.popup'], ].map((pair) => replaceTypes(pair[1], ['*'], [types.uidRef(pair[0], true)])), + replaceTypes('ui/map:dxMapOptions.markers.onClick', ['*'], ['object']), + ...replaceTypesMutations, removeMembers(/\/localization:FormatObject\.locale/), @@ -63,6 +65,9 @@ Ng.makeMetadata({ removeMembers(/\/form:FormPredefinedButtonItem/), removeMembers(/\/drop_down_editor\/ui.drop_down_editor:FieldAddons/), removeMembers(/\/load_panel:dxLoadPanelOptions.indicatorOptions/), + removeMembers( + /\/map:dxMapOptions.providerConfig\.(calculateLocation|calculateRoute|tileServer)/, + ), removeMembers(/\/grids:LoadPanel.indicatorOptions/), removeMembers(/\/pivot_grid:dxPivotGridOptions.loadPanel.indicatorOptions/), removeMembers(/\/popup:dxPopupOptions.tabFocusLoopEnabled/),