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'); + }); +});