diff --git a/apps/react-storybook/.storybook/preview-head.html b/apps/react-storybook/.storybook/preview-head.html new file mode 100644 index 000000000000..94c21df03e35 --- /dev/null +++ b/apps/react-storybook/.storybook/preview-head.html @@ -0,0 +1,12 @@ + + + diff --git a/apps/react-storybook/stories/assets/images/osm-map-marker.svg b/apps/react-storybook/stories/assets/images/osm-map-marker.svg new file mode 100644 index 000000000000..8bd7b6b81b21 --- /dev/null +++ b/apps/react-storybook/stories/assets/images/osm-map-marker.svg @@ -0,0 +1,4 @@ + + + + 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..0c8ad8b6bb14 --- /dev/null +++ b/apps/react-storybook/stories/map/OSMMap.stories.tsx @@ -0,0 +1,309 @@ +import type { Meta, StoryObj } from '@storybook/react-webpack5'; + +import React, { useMemo, useRef } from 'react'; +import Button from 'devextreme-react/button'; +import Map, { type MapRef } from 'devextreme-react/map'; +import type { OsmGetRouteFunction } from 'devextreme/ui/map'; + +// OpenStreetMap (OSM) provider for the DevExtreme Map — powered by client-owned Leaflet. +// This Storybook loads Leaflet in `.storybook/preview-head.html`, which demonstrates the global +// `window.L` setup. Applications can instead pass an imported API through `providerConfig.mapEngine`. +// The provider needs no API key of its own, but it does not bundle a tile/geocoding/routing service: +// you supply them via `providerConfig` (tileServer / geocodeLocation / getRoute). +// +// This story lets you switch between several commercial OSM-based tile providers and paste your +// own key for each (the "Tile provider" controls). The public OpenStreetMap tile service is +// best-effort and subject to the OSM Tile Usage Policy, so it is intentionally not offered here. +// Routing uses the public OSRM demo server (evaluation only). +// +// NOTE: there is deliberately no "self-hosted" option in this published Storybook — a localhost +// URL would point at the viewer's own machine, not a shared server. For the fully free, no-key, +// self-hosted setup (tiles + routing + geocoding), run the OSM_SelfHosted_Server Docker stack +// locally (see the devextreme-how-to-use-openstreetmap example repo). +const OSM_ATTR = '© OpenStreetMap contributors'; +const OPENMAPTILES_ATTR = '© OpenMapTiles'; +const STADIA_ATTR = `© Stadia Maps ${OPENMAPTILES_ATTR} ${OSM_ATTR}`; +const STADIA_SATELLITE_ATTR = `${STADIA_ATTR} © CNES, Distribution Airbus DS, © Airbus DS, © PlanetObserver (Contains Copernicus Data)`; +const markerUrl = 'images/osm-map-marker.svg'; + +type TileProvider = 'MapTiler' | 'Thunderforest' | 'Stadia Maps'; +type MapType = 'roadmap' | 'satellite' | 'hybrid'; + +interface ProviderKeys { + maptiler: string; + thunderforest: string; + stadia: string; +} + +// Resolve a Leaflet tile-layer config for the selected provider and map type. Each provider is a +// function of the type so switching the "Map type" control re-resolves the tiles. +const buildTileServer = (provider: TileProvider, type: string, keys: ProviderKeys) => { + switch (provider) { + case 'Thunderforest': { + // Thunderforest has no satellite/aerial imagery, so the type slots map to distinct + // cartographic styles to demonstrate type switching. + const style = { roadmap: 'atlas', satellite: 'landscape', hybrid: 'outdoors' }[type] ?? 'atlas'; + return { + url: `https://{s}.tile.thunderforest.com/${style}/{z}/{x}/{y}.png?apikey=${keys.thunderforest}`, + attribution: `Maps © Thunderforest, ${OSM_ATTR}`, + subdomains: 'abc', + maxZoom: 22, + }; + } + case 'Stadia Maps': { + const style = { roadmap: 'alidade_smooth', satellite: 'alidade_satellite', hybrid: 'alidade_satellite' }[type] ?? 'alidade_smooth'; + return { + url: `https://tiles.stadiamaps.com/tiles/${style}/{z}/{x}/{y}.png?api_key=${keys.stadia}`, + attribution: style === 'alidade_satellite' ? STADIA_SATELLITE_ATTR : STADIA_ATTR, + maxZoom: 20, + }; + } + case 'MapTiler': + default: { + const style = { roadmap: 'streets-v2', satellite: 'satellite', hybrid: 'hybrid' }[type] ?? 'streets-v2'; + return { + url: `https://api.maptiler.com/maps/${style}/{z}/{x}/{y}.png?key=${keys.maptiler}`, + attribution: `© MapTiler ${OSM_ATTR}`, + maxZoom: 20, + }; + } + } +}; + +// Real road routing via the public OSRM demo server (evaluation only; host your own in production). +const getRoute: OsmGetRouteFunction = ({ locations }) => { + const coords = locations.map((l) => `${l.lng},${l.lat}`).join(';'); + return fetch(`https://router.project-osrm.org/route/v1/driving/${coords}?overview=full&geometries=geojson`) + .then((r) => r.json()) + .then((res) => res.routes[0].geometry); +}; + +const markersData = [ + { location: { lat: 40.755833, lng: -73.986389 }, tooltip: { text: 'Times Square' } }, + { location: { lat: 40.7825, lng: -73.966111 }, tooltip: { text: 'Central Park' } }, + { location: { lat: 40.753889, lng: -73.981389 }, tooltip: { text: 'Fifth Avenue' } }, + { location: { lat: 40.705748, lng: -73.996299 }, tooltip: { text: 'Brooklyn Bridge' } }, +]; + +const routeWaypoints: [number, number][] = [ + [40.7825, -73.966111], + [40.755833, -73.986389], + [40.753889, -73.981389], + [40.705748, -73.996299], +]; + +const addedMarkerLocation = { lat: 40.748817, lng: -73.985428 }; +const addedRouteWaypoints: [number, number][] = [ + [40.758896, -73.98513], + [40.748817, -73.985428], + [40.74106, -73.989699], +]; + +const centers: Record = { + 'New York': { lat: 40.74, lng: -73.985 }, + London: { lat: 51.5074, lng: -0.1278 }, + Tokyo: { lat: 35.6762, lng: 139.7649 }, +}; + +// Custom args (not native Map props) used to drive the story controls. +interface OsmStoryArgs { + tileProvider: TileProvider; + maptilerKey: string; + thunderforestKey: string; + stadiaKey: string; + type: MapType; + center: keyof typeof centers; + zoom: number; + controls: boolean; + disabled: boolean; + autoAdjust: boolean; + customMarkerIcons: boolean; + showMarkers: boolean; + showRoutes: boolean; + routeColor: string; + height: number; + width: string; +} + +const meta: Meta = { + title: 'Map/OSM Provider', + component: Map, + parameters: { layout: 'fullscreen' }, + argTypes: { + // --- Tile provider --- + tileProvider: { + control: 'select', + options: ['MapTiler', 'Thunderforest', 'Stadia Maps'], + table: { category: 'Tile provider' }, + description: 'Which commercial OSM tile provider to render.', + }, + maptilerKey: { + control: 'text', + table: { category: 'Tile provider' }, + description: 'Your MapTiler API key (https://cloud.maptiler.com). Required to see MapTiler tiles.', + }, + thunderforestKey: { + control: 'text', + table: { category: 'Tile provider' }, + description: 'Your Thunderforest API key (https://www.thunderforest.com).', + }, + stadiaKey: { + control: 'text', + table: { category: 'Tile provider' }, + description: 'Your Stadia Maps API key (https://stadiamaps.com).', + }, + // --- Map --- + type: { control: 'select', options: ['roadmap', 'satellite', 'hybrid'], table: { category: 'Map' } }, + center: { + control: 'select', options: Object.keys(centers), table: { category: 'Map' }, + description: 'Initial center — the map can be freely panned afterwards.', + }, + zoom: { + control: { type: 'number', min: 1, max: 19 }, table: { category: 'Map' }, + description: 'Initial zoom — the map can be freely zoomed afterwards.', + }, + controls: { control: 'boolean', table: { category: 'Map' } }, + disabled: { control: 'boolean', table: { category: 'Map' } }, + autoAdjust: { + control: 'boolean', + table: { category: 'Map' }, + description: 'Auto-fit the viewport to the markers/routes.', + }, + // --- Markers --- + showMarkers: { control: 'boolean', table: { category: 'Markers' } }, + customMarkerIcons: { + control: 'boolean', + table: { category: 'Markers' }, + description: 'Use a custom pushpin image instead of the default Leaflet marker.', + }, + // --- Routes --- + showRoutes: { control: 'boolean', table: { category: 'Routes' } }, + routeColor: { + control: 'select', + options: ['blue', 'green', 'red', 'purple', 'orange'], + table: { category: 'Routes' }, + }, + // --- Layout --- + height: { control: 'number', table: { category: 'Layout' } }, + width: { control: 'text', table: { category: 'Layout' } }, + }, +}; + +export default meta; + +type Story = StoryObj; + +const render: Story['render'] = (args) => { + const mapRef = useRef(null); + const { + tileProvider, maptilerKey, thunderforestKey, stadiaKey, + type, center, zoom, controls, disabled, autoAdjust, + customMarkerIcons, showMarkers, showRoutes, routeColor, height, width, + } = args; + + // providerConfig identity changes only when the provider or a key changes, so the map provider + // is reinitialized then — not on every unrelated control change. + const providerConfig = useMemo(() => ({ + tileServer: (t: string) => buildTileServer(tileProvider, t, { + maptiler: maptilerKey, thunderforest: thunderforestKey, stadia: stadiaKey, + }), + getRoute, + }), [tileProvider, maptilerKey, thunderforestKey, stadiaKey]); + + const markers = useMemo(() => (showMarkers ? markersData : []), [showMarkers]); + const routes = useMemo(() => (showRoutes + ? [{ weight: 6, opacity: 0.6, color: routeColor, locations: routeWaypoints }] + : []), [showRoutes, routeColor]); + + const addMarker = () => { + mapRef.current?.instance().addMarker({ + location: addedMarkerLocation, + tooltip: { text: 'Empire State Building' }, + }); + }; + + const addRoute = () => { + mapRef.current?.instance().addRoute({ + weight: 6, + opacity: 0.7, + color: routeColor, + locations: addedRouteWaypoints, + }); + }; + + const showAllTooltips = () => { + const map = mapRef.current?.instance(); + const currentMarkers = map?.option('markers') ?? []; + + map?.option('markers', currentMarkers.map((marker) => ({ + ...marker, + tooltip: { + ...((marker.tooltip ?? {}) as Record), + isShown: true, + }, + }))); + }; + + return ( + <> +
+
+
+ + {tileProvider === 'MapTiler' && ( + + MapTiler logo + + )} +
+ + ); +}; + +export const Default: Story = { + args: { + tileProvider: 'MapTiler', + // Paste your own keys here in the controls panel to see tiles render. + maptilerKey: 'YOUR_MAPTILER_KEY', + thunderforestKey: 'YOUR_THUNDERFOREST_KEY', + stadiaKey: 'YOUR_STADIA_KEY', + type: 'roadmap', + center: 'New York', + zoom: 12, + controls: true, + disabled: false, + autoAdjust: false, + showMarkers: true, + customMarkerIcons: true, + showRoutes: true, + routeColor: 'blue', + height: 520, + width: '100%', + }, + render, +}; diff --git a/packages/devextreme-angular/src/ui/map/index.ts b/packages/devextreme-angular/src/ui/map/index.ts index b27d9beaf4b7..f5fbd4f5ccb6 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, OsmGetRouteParams, 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(): { geocodeLocation?: ((query: string) => any) | undefined, getRoute?: ((params: OsmGetRouteParams) => any) | undefined, mapEngine?: Record | undefined, mapId?: string, tileServer?: OsmTileServer | undefined, useAdvancedMarkers?: boolean } { return this._getOption('providerConfig'); } - set providerConfig(value: { mapId?: string, useAdvancedMarkers?: boolean }) { + set providerConfig(value: { geocodeLocation?: ((query: string) => any) | undefined, getRoute?: ((params: OsmGetRouteParams) => any) | undefined, mapEngine?: Record | undefined, 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<{ geocodeLocation?: ((query: string) => any) | undefined, getRoute?: ((params: OsmGetRouteParams) => any) | undefined, mapEngine?: Record | undefined, 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..2e78b9345e38 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 { OsmGetRouteParams, OsmTileServer } from 'devextreme/ui/map'; import { DxIntegrationModule, @@ -30,6 +31,30 @@ import { NestedOption } from 'devextreme-angular/core'; providers: [NestedOptionHost] }) export class DxoMapProviderConfigComponent extends NestedOption implements OnDestroy, OnInit { + @Input() + get geocodeLocation(): ((query: string) => any) | undefined { + return this._getOption('geocodeLocation'); + } + set geocodeLocation(value: ((query: string) => any) | undefined) { + this._setOption('geocodeLocation', value); + } + + @Input() + get getRoute(): ((params: OsmGetRouteParams) => any) | undefined { + return this._getOption('getRoute'); + } + set getRoute(value: ((params: OsmGetRouteParams) => any) | undefined) { + this._setOption('getRoute', value); + } + + @Input() + get mapEngine(): Record | undefined { + return this._getOption('mapEngine'); + } + set mapEngine(value: Record | undefined) { + this._setOption('mapEngine', value); + } + @Input() get mapId(): string { return this._getOption('mapId'); @@ -38,6 +63,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..69894acb6ccb 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, @@ -30,6 +31,30 @@ import { NestedOption } from 'devextreme-angular/core'; providers: [NestedOptionHost] }) export class DxoProviderConfigComponent extends NestedOption implements OnDestroy, OnInit { + @Input() + get geocodeLocation(): Function | undefined { + return this._getOption('geocodeLocation'); + } + set geocodeLocation(value: Function | undefined) { + this._setOption('geocodeLocation', value); + } + + @Input() + get getRoute(): Function | undefined { + return this._getOption('getRoute'); + } + set getRoute(value: Function | undefined) { + this._setOption('getRoute', value); + } + + @Input() + get mapEngine(): any | undefined { + return this._getOption('mapEngine'); + } + set mapEngine(value: any | undefined) { + this._setOption('mapEngine', value); + } + @Input() get mapId(): string { return this._getOption('mapId'); @@ -38,6 +63,14 @@ export class DxoProviderConfigComponent extends NestedOption implements OnDestro this._setOption('mapId', value); } + @Input() + get tileServer(): OsmTileServer | undefined { + return this._getOption('tileServer'); + } + set tileServer(value: OsmTileServer | undefined) { + this._setOption('tileServer', value); + } + @Input() get useAdvancedMarkers(): boolean { return this._getOption('useAdvancedMarkers'); diff --git a/packages/devextreme-metadata/aspnet/enums.ts b/packages/devextreme-metadata/aspnet/enums.ts index 06c672dca88b..54680970fd01 100644 --- a/packages/devextreme-metadata/aspnet/enums.ts +++ b/packages/devextreme-metadata/aspnet/enums.ts @@ -44,7 +44,7 @@ export const enums = { Options: ['GaugeIndicator.type'], }, GeoMapProvider: { - Items: ['bing', 'google', 'googleStatic', 'azure'], + Items: ['bing', 'google', 'googleStatic', 'azure', 'osm'], }, SchedulerViewType: { Items: [ diff --git a/packages/devextreme-react/src/map.ts b/packages/devextreme-react/src/map.ts index dfaee1be9225..14510fecba58 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, OsmGetRouteParams, OsmTileServer, RouteMode } from "devextreme/ui/map"; type ReplaceFieldTypes = { [P in keyof TSource]: P extends keyof TReplacement ? TReplacement[P] : TSource[P]; @@ -182,7 +182,11 @@ const Marker = Object.assign(_comp // owners: // Map type IProviderConfigProps = React.PropsWithChildren<{ + geocodeLocation?: ((query: string) => any) | undefined; + getRoute?: ((params: OsmGetRouteParams) => any) | undefined; + mapEngine?: Record | undefined; 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..f6a5f4623242 100644 --- a/packages/devextreme-vue/src/map.ts +++ b/packages/devextreme-vue/src/map.ts @@ -14,6 +14,9 @@ import { RouteRemovedEvent, MapProvider, MapType, + OsmGetRouteParams, + OsmTileServer, + OsmTileServerConfig, RouteMode, } from "devextreme/ui/map"; import { prepareConfigurationComponentConfig } from "./core/index"; @@ -244,11 +247,19 @@ const DxProviderConfigConfig = { emits: { "update:isActive": null, "update:hoveredElement": null, + "update:geocodeLocation": null, + "update:getRoute": null, + "update:mapEngine": null, "update:mapId": null, + "update:tileServer": null, "update:useAdvancedMarkers": null, }, props: { + geocodeLocation: Function as PropType<((query: string) => any)>, + getRoute: Function as PropType<((params: OsmGetRouteParams) => any)>, + mapEngine: Object as PropType>, mapId: String, + tileServer: [Object, Function, String] as PropType string | OsmTileServerConfig | null | undefined)) | OsmTileServerConfig | string>, useAdvancedMarkers: Boolean } }; diff --git a/packages/devextreme/eslint.config.mjs b/packages/devextreme/eslint.config.mjs index 79b71b34ca13..c0341cfef912 100644 --- a/packages/devextreme/eslint.config.mjs +++ b/packages/devextreme/eslint.config.mjs @@ -401,6 +401,13 @@ export default [ }], }, }, + // Rules for generated Map public API re-exports + { + files: ['js/ui/map_types.d.ts'], + rules: { + 'spellcheck/spell-checker': 'off', + }, + }, // Rules for build folder ...compat.extends('plugin:n/recommended').map(config => ({ ...config, diff --git a/packages/devextreme/js/__internal/ui/map/map.ts b/packages/devextreme/js/__internal/ui/map/map.ts index 8b987349f994..dc857ba70bf3 100644 --- a/packages/devextreme/js/__internal/ui/map/map.ts +++ b/packages/devextreme/js/__internal/ui/map/map.ts @@ -21,6 +21,8 @@ import type { LocationOption } from './provider.dynamic'; import azure from './provider.dynamic.azure'; import bing from './provider.dynamic.bing'; import google from './provider.dynamic.google'; +// eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap provider identifier +import osm from './provider.dynamic.osm'; // NOTE external urls must have protocol explicitly specified // (because inside Cordova package the protocol is "file:") import googleStatic from './provider.google_static'; @@ -30,6 +32,8 @@ const PROVIDERS = { googleStatic, google, bing, + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap provider identifier + osm, }; const MAP_CLASS = 'dx-map'; @@ -54,7 +58,8 @@ class Map extends Widget { _lastAsyncAction!: Promise; - _provider!: azure | googleStatic | google | bing; + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap provider identifier + _provider!: azure | googleStatic | google | bing | osm; _asyncActionSuppressed?: boolean; diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts new file mode 100644 index 000000000000..6615dad2a1c5 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts @@ -0,0 +1,570 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ +import Color from '@js/color'; +import { isDefined } from '@js/core/utils/type'; +import { getWindow } from '@js/core/utils/window'; +import type { + MapType, + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap API identifier + OsmRouteResult as RouteResult, + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap API identifier + OsmTileServerConfig as TileServerConfig, + RouteMode, +} from '@js/ui/map'; +import errors from '@js/ui/widget/ui.errors'; + +import type { + LocationOption, + MarkerObject, MarkerOptions, PlainLocation, RouteObject, RouteOptions, +} from './provider.dynamic'; +import DynamicProvider from './provider.dynamic'; + +const window = getWindow(); + +const DEFAULT_MAX_ZOOM = 19; +const DEFAULT_SUBDOMAINS = 'abc'; +const MARKER_ICON_SIZE = [25, 41]; +const MARKER_ICON_ANCHOR = [12, 41]; +const MARKER_POPUP_ANCHOR = [1, -34]; +const GEOCODING_FAILED = new Error('Geocoding failed'); +const REQUIRED_ENGINE_METHODS = [ + 'divIcon', + 'icon', + 'latLng', + 'latLngBounds', + 'map', + 'marker', + 'polyline', + 'popup', + 'tileLayer', +]; + +// Leaflet is an optional client-owned dependency. +// Its public types must not leak into DevExtreme. +// eslint-disable-next-line @typescript-eslint/no-explicit-any, spellcheck/spell-checker -- OSM API +type OsmMapEngine = Record; + +interface TileLayer { + addTo: (map: unknown) => unknown; +} + +const normalizeRouteResult = (result: RouteResult): [number, number][] => { + if (Array.isArray(result)) { + return result; + } + + if (result?.type === 'LineString' && Array.isArray(result.coordinates)) { + return result.coordinates.map(([lng, lat]) => [lat, lng]); + } + + throw new Error('Unsupported route result'); +}; + +// eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap API identifier +export type OsmLocation = PlainLocation; + +// eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap API identifier +const isMapEngine = (engine: unknown): engine is OsmMapEngine => { + if (!engine || typeof engine !== 'object') { + return false; + } + + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap API identifier + const candidate = engine as OsmMapEngine; + + return REQUIRED_ENGINE_METHODS.every((method) => typeof candidate[method] === 'function') + && typeof candidate.control?.zoom === 'function'; +}; + +// eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap API identifier +class OsmProvider extends DynamicProvider { + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap API identifier + _mapEngine!: OsmMapEngine; + + _tileLayer?: TileLayer; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + _zoomControl?: any; + + _currentTileType!: MapType; + + // eslint-disable-next-line spellcheck/spell-checker -- Leaflet/OpenStreetMap API identifiers + _clickHandler?: (e: { latlng: OsmLocation; originalEvent: Event }) => void; + + _viewChangeHandler?: () => void; + + _preventZoomChangeEvent?: boolean; + + _movementMode(type: RouteMode | string = ''): string { + return type || 'driving'; + } + + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap API identifier + _resolveLocation(location?: LocationOption | null): Promise { + return new Promise((resolve) => { + const latLng = this._getLatLng(location); + if (latLng) { + resolve(this._mapEngine.latLng(latLng.lat, latLng.lng)); + } else { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this._geocodeLocation(location as string).then((geocodedLocation) => { + resolve(geocodedLocation); + }); + } + }); + } + + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap API identifier + _geocodeLocation(location: string): Promise { + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap API identifier + const cachedLocation = this._geocodedLocations[location] as OsmLocation | undefined; + + if (cachedLocation) { + return Promise.resolve(cachedLocation); + } + + return this._geocodeLocationImpl(location) + .then((geocodedLocation) => { + this._geocodedLocations[location] = geocodedLocation; + + return geocodedLocation; + }) + .catch(() => this._mapEngine.latLng(0, 0) as PlainLocation); + } + + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap API identifier + _geocodeLocationImpl(location: string): Promise { + if (!isDefined(location)) { + return Promise.reject(GEOCODING_FAILED); + } + + const geocodeFn = this._option('providerConfig')?.geocodeLocation as ((query: string) => Promise<{ lat: number; lng: number } | null | undefined>) | undefined; + + if (!geocodeFn) { + errors.log('W1031', location); + + return Promise.reject(GEOCODING_FAILED); + } + + return Promise.resolve() + .then(() => geocodeFn(location)) + .then((result) => { + if (result?.lat != null && result?.lng != null) { + return this._mapEngine.latLng(result.lat, result.lng) as PlainLocation; + } + + return Promise.reject(GEOCODING_FAILED); + }); + } + + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap API identifier + _normalizeLocation(location: OsmLocation): { lat: number; lng: number } { + return { + lat: location.lat, + lng: location.lng, + }; + } + + _loadImpl(): Promise { + const configuredEngine = this._option('providerConfig')?.mapEngine; + const globalEngine = (window as Window & { L?: unknown }).L; + const engine = configuredEngine ?? globalEngine; + + if (!isMapEngine(engine)) { + return Promise.reject(errors.Error('E1069')); + } + + this._mapEngine = engine; + + return Promise.resolve(); + } + + _init(): Promise { + return this._resolveLocation(this._option('center')).then((center) => { + const type = this._option('type') ?? 'roadmap'; + const interactionsEnabled = !this._option('disabled'); + + this._map = this._mapEngine.map(this._$container[0], { + center, + zoom: this._option('zoom'), + zoomControl: false, + attributionControl: true, + dragging: interactionsEnabled, + touchZoom: interactionsEnabled, + doubleClickZoom: interactionsEnabled, + scrollWheelZoom: interactionsEnabled, + boxZoom: interactionsEnabled, + keyboard: interactionsEnabled, + }); + + this._zoomControl = this._mapEngine.control.zoom(); + if (this._option('controls')) { + this._zoomControl.addTo(this._map); + } + + this._currentTileType = type; + this._tileLayer = this._buildTileLayer(type); + this._tileLayer?.addTo(this._map); + + this._option('center', this._normalizeLocation(center)); + }); + } + + _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; + } + + _buildTileLayer(type: MapType): TileLayer | undefined { + const config = this._resolveTileConfig(type); + + if (!config?.url) { + errors.log('W1030'); + return undefined; + } + + if (!config.attribution) { + errors.log('W1032'); + } + + const options: Record = { + attribution: config.attribution ?? '', + maxZoom: config.maxZoom ?? DEFAULT_MAX_ZOOM, + }; + + if (config.url.includes('{s}')) { + // eslint-disable-next-line spellcheck/spell-checker -- Leaflet tile server option name + options.subdomains = config.subdomains ?? DEFAULT_SUBDOMAINS; + } + + return this._mapEngine.tileLayer(config.url, options) as TileLayer; + } + + _attachHandlers(): void { + this._viewChangeHandler = this._onViewChange.bind(this); + this._clickHandler = this._onMapClick.bind(this); + + this._map.on('moveend', this._viewChangeHandler); + this._map.on('zoomend', this._viewChangeHandler); + this._map.on('click', this._clickHandler); + } + + _onViewChange(): void { + const bounds = this._map.getBounds(); + this._option('bounds', { + northEast: { lat: bounds.getNorthEast().lat, lng: bounds.getNorthEast().lng }, + southWest: { lat: bounds.getSouthWest().lat, lng: bounds.getSouthWest().lng }, + }); + + const center = this._normalizeLocation(this._map.getCenter()); + const currentCenter = this._getLatLng(this._option('center')); + if (currentCenter?.lat !== center.lat || currentCenter?.lng !== center.lng) { + this._option('center', center); + } + + if (!this._preventZoomChangeEvent) { + this._option('zoom', this._map.getZoom()); + } + } + + // eslint-disable-next-line spellcheck/spell-checker -- Leaflet/OpenStreetMap API identifiers + _onMapClick(e: { latlng: OsmLocation; originalEvent: Event }): void { + this._fireClickAction({ + // eslint-disable-next-line spellcheck/spell-checker -- Leaflet event field name + location: this._normalizeLocation(e.latlng), + event: e.originalEvent, + }); + } + + updateDimensions(): Promise { + this._map.invalidateSize(); + + return Promise.resolve(); + } + + updateMapType(): Promise { + const type = this._option('type') ?? this._currentTileType; + + if (type !== this._currentTileType) { + this._rebuildTileLayer(type); + this._currentTileType = type; + } + + return Promise.resolve(); + } + + _rebuildTileLayer(type: MapType): void { + const tileLayer = this._buildTileLayer(type); + tileLayer?.addTo(this._map); + + if (this._tileLayer) { + this._map.removeLayer(this._tileLayer); + } + + this._tileLayer = tileLayer; + } + + updateDisabled(): Promise { + const disabled = this._option('disabled'); + const handlers = [ + this._map.dragging, + this._map.touchZoom, + this._map.doubleClickZoom, + this._map.scrollWheelZoom, + this._map.boxZoom, + this._map.keyboard, + ]; + + handlers.forEach((handler) => { + if (handler) { + if (disabled) { + handler.disable(); + } else { + handler.enable(); + } + } + }); + + return Promise.resolve(); + } + + updateBounds(): Promise { + const bounds = this._option('bounds'); + + return Promise.all([ + this._resolveLocation(bounds?.northEast), + this._resolveLocation(bounds?.southWest), + ]).then((result) => { + this._map.fitBounds(this._mapEngine.latLngBounds(result[1], result[0])); + }); + } + + updateCenter(): Promise { + return this._resolveLocation(this._option('center')).then((center) => { + this._map.setView(center, this._option('zoom')); + this._option('center', this._normalizeLocation(center)); + }); + } + + updateZoom(): Promise { + this._map.setZoom(this._option('zoom')); + + return Promise.resolve(); + } + + updateControls(): Promise { + const controls = this._option('controls'); + + if (controls) { + this._zoomControl.addTo(this._map); + } else { + this._zoomControl.remove(); + } + + return Promise.resolve(); + } + + _renderMarker(options: MarkerOptions): Promise { + return this._resolveLocation(options.location).then((location) => { + // eslint-disable-next-line @typescript-eslint/init-declarations + let marker; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const icon = options.iconSrc || this._option('markerIconSrc'); + + if (options.html) { + const divIcon = this._mapEngine.divIcon({ + html: options.html, + className: '', + iconAnchor: options.htmlOffset + ? [-options.htmlOffset.left, -options.htmlOffset.top] + : [0, 0], + }); + marker = this._mapEngine.marker(location, { icon: divIcon }); + } else if (icon) { + const customIcon = this._mapEngine.icon({ + iconUrl: icon, + className: 'dx-map-marker', + iconSize: MARKER_ICON_SIZE, + iconAnchor: MARKER_ICON_ANCHOR, + popupAnchor: MARKER_POPUP_ANCHOR, + }); + marker = this._mapEngine.marker(location, { icon: customIcon }); + } else { + marker = this._mapEngine.marker(location); + } + + marker.addTo(this._map); + + const popup = this._renderTooltip(marker, options.tooltip); + + // eslint-disable-next-line @typescript-eslint/init-declarations + let clickHandler: (() => void) | undefined; + if (options.onClick) { + const markerClickAction = this._mapWidget._createAction(options.onClick); + const normalizedLocation = this._normalizeLocation(location); + + clickHandler = (): void => { + markerClickAction({ location: normalizedLocation }); + }; + + marker.on('click', clickHandler); + } + + return { + location, + marker, + popup, + handler: clickHandler, + }; + }); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + _renderTooltip(marker: unknown, options: MarkerOptions['tooltip']): any { + if (!options) { + return undefined; + } + + const parsedOptions = this._parseTooltipOptions(options); + const popup = this._mapEngine.popup({ autoClose: false, closeOnClick: false }) + .setContent(parsedOptions.text); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const leafletMarker = marker as any; + leafletMarker.bindPopup(popup); + + if (parsedOptions.visible) { + leafletMarker.openPopup(); + } + + return popup; + } + + _destroyMarker(markerObj: { + marker: { off: (event: string, fn: unknown) => void; remove: () => void }; + handler?: () => void; + }): void { + if (markerObj.handler) { + markerObj.marker.off('click', markerObj.handler); + } + markerObj.marker.remove(); + } + + _renderRoute(options: RouteOptions): Promise { + const locations = options.locations ?? []; + + return Promise.all(locations.map((point) => this._resolveLocation(point))) + .then((resolvedLocations) => { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const color = new Color(options.color || this._defaultRouteColor()).toHex(); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const opacity = options.opacity || this._defaultRouteOpacity(); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const weight = options.weight || this._defaultRouteWeight(); + const mode = this._movementMode(options.mode); + + const polylineOptions = { color, opacity, weight }; + const normalizedLocations = resolvedLocations.map((loc) => this._normalizeLocation(loc)); + + const getRouteFn = this._option('providerConfig')?.getRoute; + + const drawPolyline = (coords: [number, number][]): RouteObject => { + const polyline = this._mapEngine.polyline(coords, polylineOptions).addTo(this._map); + const routeBounds = polyline.getBounds(); + const northEast = routeBounds.getNorthEast(); + const southWest = routeBounds.getSouthWest(); + const routeObject: RouteObject = { instance: polyline }; + + if (northEast && southWest) { + routeObject.northEast = [northEast.lat, northEast.lng]; + routeObject.southWest = [southWest.lat, southWest.lng]; + } + + return routeObject; + }; + + if (!getRouteFn) { + errors.log('W1033'); + return drawPolyline( + resolvedLocations.map((loc) => [loc.lat, loc.lng] as [number, number]), + ); + } + + return Promise.resolve() + .then(() => getRouteFn({ locations: normalizedLocations, mode })) + .then((result) => drawPolyline(normalizeRouteResult(result))) + .catch((e: unknown) => { + errors.log('W1006', e); + + return drawPolyline( + resolvedLocations.map((loc) => [loc.lat, loc.lng] as [number, number]), + ); + }); + }); + } + + _destroyRoute(routeObj: { instance: { remove: () => void } }): void { + routeObj.instance.remove(); + } + + _fitBounds(): Promise { + this._updateBounds(); + + if (this._bounds && this._option('autoAdjust')) { + const zoomBeforeFitting = this._map.getZoom(); + this._preventZoomChangeEvent = true; + + this._map.fitBounds(this._bounds, { animate: false }); + + const zoomAfterFitting = this._map.getZoom(); + if (zoomBeforeFitting < zoomAfterFitting) { + this._map.setZoom(zoomBeforeFitting); + } else { + this._option('zoom', zoomAfterFitting); + } + + delete this._preventZoomChangeEvent; + } + + return Promise.resolve(); + } + + _extendBounds(location: unknown): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const loc = location as any; + const latLng = Array.isArray(loc) + ? this._mapEngine.latLng(loc[0], loc[1]) + : this._mapEngine.latLng(loc.lat, loc.lng); + + if (this._bounds) { + this._bounds.extend(latLng); + } else { + this._bounds = this._mapEngine.latLngBounds(latLng, latLng); + } + } + + clean(): Promise { + if (this._map) { + this._map.off('moveend', this._viewChangeHandler); + this._map.off('zoomend', this._viewChangeHandler); + this._map.off('click', this._clickHandler); + + this._clearMarkers(); + this._clearRoutes(); + + this._map.remove(); + this._map = undefined; + this._zoomControl = undefined; + } + + return Promise.resolve(); + } +} + +// eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap API identifier +export default OsmProvider; diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.ts index f873b4043a2f..bfb0b76998de 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.ts @@ -20,6 +20,11 @@ export interface LocationCoords { lng: number; } +export interface PlainLocation { + lat: number; + lng: number; +} + export interface MarkerOptions { iconSrc?: string; location?: LocationCoords; @@ -64,7 +69,7 @@ class DynamicProvider extends Provider { _routes!: (RouteObject & { options: RouteOptions })[]; - _geocodedLocations: Record; + _geocodedLocations: Record; _mapsLoader?: Promise; @@ -76,7 +81,7 @@ class DynamicProvider extends Provider { _geocodeLocation( location: string, - ): Promise { + ): Promise { return new Promise((resolve) => { const cache = this._geocodedLocations; const cachedLocation = cache[location]; @@ -282,7 +287,7 @@ class DynamicProvider extends Provider { _geocodeLocationImpl( // eslint-disable-next-line @typescript-eslint/no-unused-vars location: string, - ): Promise { + ): Promise { return Promise.resolve([0, 0]); } 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 547e62a96f0f..8305c03bf87d 100644 --- a/packages/devextreme/js/ui/map.d.ts +++ b/packages/devextreme/js/ui/map.d.ts @@ -14,9 +14,89 @@ 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'; + +/** + * @docid + * @public + */ +// eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap API identifier +export type OsmGeocodeFunction = (query: string) => Promise; + +/** + * @docid + * @public + */ +// eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap API identifier +export interface OsmGetRouteParams { + locations: Array; + mode: RouteMode | string; +} + +/** + * @docid + * @public + */ +// eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap and GeoJSON API identifiers +export interface OsmGeoJsonLineString { + type: 'LineString'; + coordinates: Array>; +} + +/** + * @docid + * @type Array>|OsmGeoJsonLineString + * @public + */ +// eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap API identifiers +export type OsmRouteResult = Array<[number, number]> | OsmGeoJsonLineString; + +/** + * @docid + * @public + */ +// eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap API identifiers +export type OsmGetRouteFunction = (params: OsmGetRouteParams) => Promise; + +/** + * @docid + * @public + */ +// eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap API identifier +export interface OsmTileServerConfig { + /** + * @docid + */ + url: string; + /** + * @docid + * @default '' + */ + attribution?: string; + /** + * @docid + * @default 'abc' + */ + // eslint-disable-next-line spellcheck/spell-checker -- Leaflet tile server option name + subdomains?: string | Array; + /** + * @docid + * @default 19 + */ + maxZoom?: number; +} + +/** + * @docid + * @public + */ +// eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap API identifiers +export type OsmTileServer = string | OsmTileServerConfig +// eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap API identifier +| ((type: MapType) => string | OsmTileServerConfig | null | undefined); + /** @public */ export type MapType = 'hybrid' | 'roadmap' | 'satellite'; @@ -336,6 +416,32 @@ export interface dxMapOptions extends WidgetOptions { * @deprecated */ useAdvancedMarkers?: boolean; + /** + * @docid + * @public + * @default undefined + */ + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap API identifier + tileServer?: OsmTileServer | undefined; + /** + * @docid + * @public + * @default undefined + */ + geocodeLocation?: ((query: string) => Promise) | undefined; + /** + * @docid + * @public + * @default undefined + */ + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap API identifiers + getRoute?: ((params: OsmGetRouteParams) => Promise) | undefined; + /** + * @docid + * @public + * @default undefined + */ + mapEngine?: object | undefined; }; /** * @docid diff --git a/packages/devextreme/js/ui/map_types.d.ts b/packages/devextreme/js/ui/map_types.d.ts index 2c1637b47f4d..82315c2e3de5 100644 --- a/packages/devextreme/js/ui/map_types.d.ts +++ b/packages/devextreme/js/ui/map_types.d.ts @@ -1,6 +1,13 @@ export { MapProvider, RouteMode, + OsmGeocodeFunction, + OsmGetRouteParams, + OsmGeoJsonLineString, + OsmRouteResult, + OsmGetRouteFunction, + OsmTileServerConfig, + OsmTileServer, MapType, ClickEvent, DisposingEvent, diff --git a/packages/devextreme/js/ui/widget/ui.errors.js b/packages/devextreme/js/ui/widget/ui.errors.js index 29438d4d7e31..26389244066d 100644 --- a/packages/devextreme/js/ui/widget/ui.errors.js +++ b/packages/devextreme/js/ui/widget/ui.errors.js @@ -281,6 +281,11 @@ export default errorUtils(errors.ERROR_MESSAGES, { */ E1068: '\'aiIntegration\' is not configured for the AI Assistant.', + /** + * @name ErrorsUIWidgets.E1069 + */ + E1069: 'The OSM map provider requires Leaflet. Configure "providerConfig.mapEngine" or load Leaflet as "window.L".', + /** * @name ErrorsUIWidgets.W1001 */ @@ -406,4 +411,20 @@ export default errorUtils(errors.ERROR_MESSAGES, { * @name ErrorsUIWidgets.W1029 */ W1029: '\'hiddenWeekDays\' must leave at least one weekday visible.', + /** + * @name ErrorsUIWidgets.W1030 + */ + W1030: 'No tile server is configured for the OSM map provider. Specify the "providerConfig.tileServer" option.', + /** + * @name ErrorsUIWidgets.W1031 + */ + W1031: 'No geocoding service is configured for the OSM map provider. Specify the "providerConfig.geocodeLocation" option.', + /** + * @name ErrorsUIWidgets.W1032 + */ + W1032: 'The OSM map provider tile server is configured without an "attribution". Attribution is required when displaying OpenStreetMap data; set the "attribution" field of the "providerConfig.tileServer" option (for example, "© OpenStreetMap contributors").', + /** + * @name ErrorsUIWidgets.W1033 + */ + W1033: 'No routing service is configured for the OSM map provider. Configure "providerConfig.getRoute" to draw routed paths.', }); diff --git a/packages/devextreme/testing/helpers/forMap/leafletMock.js b/packages/devextreme/testing/helpers/forMap/leafletMock.js new file mode 100644 index 000000000000..74dedcbb4e94 --- /dev/null +++ b/packages/devextreme/testing/helpers/forMap/leafletMock.js @@ -0,0 +1,295 @@ +(() => { + const makeLatLng = (lat, lng) => ({ + lat: typeof lat === 'object' ? lat.lat : lat, + lng: typeof lat === 'object' ? lat.lng : lng, + }); + + const makeBounds = (sw, ne) => { + const _sw = makeLatLng(sw.lat ?? sw[0], sw.lng ?? sw[1]); + const _ne = makeLatLng(ne.lat ?? ne[0], ne.lng ?? ne[1]); + + const bounds = { + _sw, + _ne, + extend(latLng) { + const loc = makeLatLng(latLng.lat, latLng.lng); + this._sw = { lat: Math.min(this._sw.lat, loc.lat), lng: Math.min(this._sw.lng, loc.lng) }; + this._ne = { lat: Math.max(this._ne.lat, loc.lat), lng: Math.max(this._ne.lng, loc.lng) }; + return this; + }, + getNorthEast() { return this._ne; }, + getSouthWest() { return this._sw; }, + }; + return bounds; + }; + + window.L = { + // --- Map --- + map: function(container, options) { + L.mapOptions = options; + L.mapCreated = true; + + const map = { + _center: options?.center ?? { lat: 0, lng: 0 }, + _zoom: options?.zoom ?? 1, + _eventHandlers: {}, + + on(event, handler) { + L.addedEvents = L.addedEvents || []; + L.addedEvents.push(event); + this._eventHandlers[event] = this._eventHandlers[event] || []; + this._eventHandlers[event].push(handler); + + if(event === 'click') { L.mapClickCallback = handler; } + if(event === 'moveend') { L.mapMoveEndCallback = handler; } + if(event === 'zoomend') { L.mapZoomEndCallback = handler; } + }, + off(event, handler) { + L.removedEvents = L.removedEvents || []; + L.removedEvents.push(event); + if(this._eventHandlers[event]) { + this._eventHandlers[event] = this._eventHandlers[event].filter(h => h !== handler); + } + }, + setView(center, zoom) { + L.setViewArgs = { center, zoom }; + this._center = center; + this._zoom = zoom ?? this._zoom; + }, + setZoom(zoom) { + L.setZoomArg = zoom; + this._zoom = zoom; + }, + getZoom() { return L.mockZoom ?? this._zoom; }, + getCenter() { return L.mockCenter ?? this._center; }, + getBounds() { + return L.mockBounds ?? makeBounds( + { lat: (this._center.lat ?? 0) - 1, lng: (this._center.lng ?? 0) - 1 }, + { lat: (this._center.lat ?? 0) + 1, lng: (this._center.lng ?? 0) + 1 } + ); + }, + fitBounds(bounds, options) { + L.fitBoundsArg = bounds; + L.fitBoundsOptions = options; + if(bounds && bounds.getNorthEast) { + this._center = { + lat: (bounds.getNorthEast().lat + bounds.getSouthWest().lat) / 2, + lng: (bounds.getNorthEast().lng + bounds.getSouthWest().lng) / 2, + }; + } + if(L.fitBoundsCallback) { + L.fitBoundsCallback(); + } + }, + invalidateSize() { L.mapResized = true; }, + removeLayer(layer) { + L.removedLayers = L.removedLayers || []; + L.removedLayers.push(layer); + }, + remove() { L.mapDisposed = true; }, + zoomControl: { + addTo() { L.zoomControlAdded = true; }, + remove() { L.zoomControlAdded = false; }, + }, + dragging: { enable() {}, disable() {} }, + touchZoom: { enable() {}, disable() {} }, + doubleClickZoom: { enable() {}, disable() {} }, + scrollWheelZoom: { enable() {}, disable() {} }, + boxZoom: { enable() {}, disable() {} }, + keyboard: { enable() {}, disable() {} }, + attributionControl: {}, + }; + + L.mapInstance = map; + return map; + }, + + // --- Tile Layer --- + tileLayer: function(url, options) { + L.tileLayerUrl = url; + L.tileLayerOptions = options; + const layer = { + addTo(map) { + L.addedTileLayers = L.addedTileLayers || []; + L.addedTileLayers.push({ url, options }); + return layer; + }, + }; + return layer; + }, + + // --- LatLng --- + latLng: function(lat, lng) { + if(typeof lat === 'object' && lat !== null && !Array.isArray(lat)) { + return makeLatLng(lat.lat, lat.lng); + } + return makeLatLng(lat, lng); + }, + + // --- LatLngBounds --- + latLngBounds: function(sw, ne) { + return makeBounds(sw, ne ?? sw); + }, + + // --- Point --- + point: function(x, y) { + return { x, y }; + }, + + // --- Marker --- + marker: function(latLng, options) { + L.markerOptions = options; + L.lastMarkerLatLng = latLng; + + const marker = { + _latLng: latLng, + _popup: null, + _popupOpen: false, + _handlers: {}, + options: options || {}, + + addTo(map) { + L.addedMarkers = L.addedMarkers || []; + L.addedMarkers.push(marker); + return marker; + }, + remove() { + L.removedMarkers = L.removedMarkers || []; + L.removedMarkers.push(marker); + }, + on(event, handler) { + this._handlers[event] = this._handlers[event] || []; + this._handlers[event].push(handler); + if(event === 'click') { + L.markerClickCallback = (args) => { + this._handlers.click.slice().forEach((clickHandler) => { clickHandler(args); }); + }; + } + return marker; + }, + off(event, handler) { + if(handler && this._handlers[event]) { + this._handlers[event] = this._handlers[event] + .filter((eventHandler) => eventHandler !== handler); + } else { + delete this._handlers[event]; + } + return marker; + }, + bindPopup(popup) { + this._popup = popup; + L.boundPopup = popup; + this.on('click', () => { + if(this.isPopupOpen()) { + this.closePopup(); + } else { + this.openPopup(); + } + }); + return marker; + }, + openPopup() { + this._popupOpen = true; + L.popupOpened = true; + return marker; + }, + closePopup() { + this._popupOpen = false; + L.popupOpened = false; + return marker; + }, + isPopupOpen() { + return this._popupOpen; + }, + }; + + return marker; + }, + + // --- DivIcon --- + divIcon: function(options) { + L.divIconOptions = options; + return { isDivIcon: true, options }; + }, + + // --- Icon --- + icon: function(options) { + L.iconOptions = options; + return { isIcon: true, options }; + }, + + // --- Popup --- + popup: function(options) { + L.popupOptions = options; + const popup = { + options: { offset: L.point(0, 7), ...options }, + _content: '', + setContent(text) { + this._content = text; + L.popupContent = text; + return popup; + }, + }; + return popup; + }, + + // --- Polyline --- + polyline: function(coords, options) { + L.polylineCoords = coords; + L.polylineOptions = options; + + const polyline = { + _coords: coords, + addTo(map) { + // eslint-disable-next-line spellcheck/spell-checker -- Leaflet mock state identifier + L.addedPolylines = L.addedPolylines || []; + // eslint-disable-next-line spellcheck/spell-checker -- Leaflet mock state identifier + L.addedPolylines.push(polyline); + return polyline; + }, + remove() { + // eslint-disable-next-line spellcheck/spell-checker -- Leaflet mock state identifier + L.removedPolylines = L.removedPolylines || []; + // eslint-disable-next-line spellcheck/spell-checker -- Leaflet mock state identifier + L.removedPolylines.push(polyline); + }, + getBounds() { + if(!coords || coords.length === 0) { + return { + getNorthEast: () => undefined, + getSouthWest: () => undefined, + }; + } + const lats = coords.map(c => c[0]); + // eslint-disable-next-line spellcheck/spell-checker -- Longitude collection identifier + const lngs = coords.map(c => c[1]); + return makeBounds( + // eslint-disable-next-line spellcheck/spell-checker -- Longitude collection identifier + { lat: Math.min(...lats), lng: Math.min(...lngs) }, + // eslint-disable-next-line spellcheck/spell-checker -- Longitude collection identifier + { lat: Math.max(...lats), lng: Math.max(...lngs) } + ); + }, + }; + return polyline; + }, + + // --- Control --- + control: { + zoom: function() { + const ctrl = { + addTo(map) { L.zoomControlAdded = true; return ctrl; }, + remove() { L.zoomControlAdded = false; return ctrl; }, + }; + return ctrl; + }, + }, + + // --- Icon.Default --- + Icon: { + Default: { + imagePath: null, + } + }, + }; +})(); 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..e98850ddea27 --- /dev/null +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -0,0 +1,1693 @@ +/* global L */ + +import $ from 'jquery'; +import { MARKERS, ROUTES } from './utils.js'; +// eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap provider identifier +import OsmProvider from '__internal/ui/map/provider.dynamic.osm'; +import errors from 'ui/widget/ui.errors'; + +import 'ui/map'; + +let leafletMock; + +const prepareTestingOsmProvider = () => { + L.mapCreated = false; + L.mapDisposed = false; + L.mapResized = false; + L.mapOptions = null; + L.setViewArgs = null; + L.setZoomArg = null; + L.fitBoundsArg = null; + L.fitBoundsOptions = null; + L.fitBoundsCallback = null; + L.addedMarkers = []; + L.removedMarkers = []; + // eslint-disable-next-line spellcheck/spell-checker -- Leaflet mock state identifier + L.addedPolylines = []; + // eslint-disable-next-line spellcheck/spell-checker -- Leaflet mock state identifier + L.removedPolylines = []; + L.addedTileLayers = []; + L.removedLayers = []; + L.addedEvents = []; + L.removedEvents = []; + L.tileLayerUrl = null; + L.tileLayerOptions = null; + L.zoomControlAdded = false; + L.popupOpened = false; + L.markerClickCallback = null; + L.mapClickCallback = null; + L.mapMoveEndCallback = null; + L.mapZoomEndCallback = null; + L.mockZoom = null; + L.mockCenter = null; + L.mockBounds = null; + L.mapInstance = null; + L.iconOptions = null; + L.boundPopup = null; + L.popupOptions = null; + L.popupContent = null; +}; + +const moduleConfig = { + beforeEach: function(assert) { + const setup = () => { + window.L = leafletMock; + prepareTestingOsmProvider(); + + this.geocodedLatLng = { lat: 10, lng: 20 }; + this.routePolylineCoords = [[20, 10], [30, 20], [40, 30]]; + + // User-supplied callbacks (replicate what end users must provide) + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap callback identifier + this.osmGeocodeLocation = () => Promise.resolve(this.geocodedLatLng); + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap callback identifier + this.osmGetRoute = () => Promise.resolve(this.routePolylineCoords); + }; + + if(leafletMock) { + setup(); + return; + } + + const done = assert.async(); + $.getScript({ + url: '../../packages/devextreme/testing/helpers/forMap/leafletMock.js', + scriptAttrs: { nonce: 'qunit-test' }, + }).done(() => { + leafletMock = window.L; + setup(); + done(); + }).fail((_request, _status, error) => { + assert.ok(false, `failed to load Leaflet mock: ${error}`); + done(); + }); + }, + afterEach: function() { + window.L = leafletMock; + } +}; + + +QUnit.module('OSM: real Leaflet integration', () => { + // TODO: Enable after agreeing on real-Leaflet smoke coverage, or remove before merge. + QUnit.skip('initializes with real Leaflet and local service substitutes', function(assert) { + const done = assert.async(); + const $map = $('
').css({ width: 400, height: 300 }).appendTo('#qunit-fixture'); + const realLeaflet = window.realLeaflet; + + const map = $map.dxMap({ + provider: 'osm', + providerConfig: { + mapEngine: realLeaflet, + tileServer: { + url: '/packages/devextreme/testing/content/LightBlueSky.jpg', + attribution: 'Local test tiles', + }, + geocodeLocation: () => Promise.resolve({ lat: 40.74, lng: -73.98 }), + getRoute: ({ locations }) => Promise.resolve(locations.map(({ lat, lng }) => [lat, lng])), + }, + center: 'New York', + zoom: 10, + markers: [{ location: { lat: 40.74, lng: -73.98 }, tooltip: 'Marker' }], + routes: [{ locations: [[40.74, -73.98], [40.75, -73.97]] }], + onReady: ({ originalMap }) => { + assert.ok(realLeaflet && realLeaflet.map, 'real Leaflet is provided by the test host'); + assert.ok(originalMap instanceof realLeaflet.Map, 'originalMap is a real Leaflet map'); + assert.strictEqual($map.find('.leaflet-container').length, 1, 'Leaflet initializes the map container'); + assert.deepEqual( + { lat: originalMap.getCenter().lat, lng: originalMap.getCenter().lng }, + { lat: 40.745, lng: -73.975 }, + 'local route participates in auto-adjusting the viewport' + ); + + map.dispose(); + done(); + }, + }).dxMap('instance'); + }); +}); + + +QUnit.module('OSM: map loading', moduleConfig, () => { + QUnit.test('map initializes with Leaflet from window.L', function(assert) { + const done = assert.async(); + + $('#map').dxMap({ + provider: 'osm', + onReady: ({ originalMap }) => { + assert.ok(L.mapCreated, 'global Leaflet created the map'); + assert.strictEqual(originalMap, L.mapInstance, 'originalMap comes from window.L'); + done(); + } + }); + }); + + QUnit.test('providerConfig.mapEngine takes priority over window.L', function(assert) { + const done = assert.async(); + const injectedEngine = Object.create(leafletMock); + injectedEngine.map = sinon.spy((...args) => leafletMock.map(...args)); + window.L = { map: () => { assert.ok(false, 'window.L should not create the map'); } }; + + $('#map').dxMap({ + provider: 'osm', + providerConfig: { mapEngine: injectedEngine }, + onReady: ({ originalMap }) => { + assert.ok(injectedEngine.map.calledOnce, 'configured map engine created the map'); + assert.strictEqual( + originalMap, + injectedEngine.map.returnValues[0], + 'configured engine map is returned' + ); + done(); + } + }); + }); + + QUnit.test('changing providerConfig.mapEngine recreates the map with the new engine', function(assert) { + const done = assert.async(); + const firstEngine = Object.create(leafletMock); + const secondEngine = Object.create(leafletMock); + let firstMap; + let firstMapRemoveSpy; + let readyCount = 0; + + firstEngine.map = sinon.spy((...args) => leafletMock.map(...args)); + secondEngine.map = sinon.spy((...args) => leafletMock.map(...args)); + + const map = $('#map').dxMap({ + provider: 'osm', + providerConfig: { mapEngine: firstEngine }, + onReady: ({ originalMap }) => { + readyCount++; + + if(readyCount === 1) { + firstMap = originalMap; + firstMapRemoveSpy = sinon.spy(firstMap, 'remove'); + + map.option('providerConfig.mapEngine', secondEngine); + return; + } + + assert.strictEqual(readyCount, 2, 'map becomes ready after one reinitialization'); + assert.ok(firstEngine.map.calledOnce, 'first engine created the initial map'); + assert.ok(secondEngine.map.calledOnce, 'second engine created the reinitialized map'); + assert.ok(firstMapRemoveSpy.calledOnce, 'initial map is disposed'); + assert.notStrictEqual(originalMap, firstMap, 'a new map instance is returned'); + assert.strictEqual(map._provider._mapEngine, secondEngine, 'new provider uses the second engine'); + + firstMapRemoveSpy.restore(); + done(); + } + }).dxMap('instance'); + }); + + QUnit.test('load rejects with E1069 when no map engine is configured or global', function(assert) { + const done = assert.async(); + const mapWidget = { option: () => ({ providerConfig: {} }) }; + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap provider identifier + const provider = new OsmProvider(mapWidget, null); + + delete window.L; + + 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('invalid providerConfig.mapEngine rejects instead of falling back to window.L', function(assert) { + const done = assert.async(); + const mapWidget = { option: () => ({ providerConfig: { mapEngine: {} } }) }; + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap provider identifier + const provider = new OsmProvider(mapWidget, null); + + provider._loadImpl().then( + () => { + assert.ok(false, 'load should reject'); + done(); + }, + (error) => { + assert.strictEqual(error.message, errors.Error('E1069').message, 'E1069 is returned'); + assert.notStrictEqual(provider._mapEngine, leafletMock, 'global engine was not used'); + done(); + } + ); + }); +}); + + +QUnit.module('OSM: basic options', moduleConfig, () => { + QUnit.test('zoom option is passed on init', function(assert) { + const done = assert.async(); + + $('#map').dxMap({ + provider: 'osm', + zoom: 10, + onReady: () => { + assert.strictEqual(L.mapOptions.zoom, 10, 'zoom passed to L.map'); + done(); + } + }); + }); + + QUnit.test('zoom option is updated at runtime', function(assert) { + const done = assert.async(); + const d = $.Deferred(); + + const map = $('#map').dxMap({ + provider: 'osm', + zoom: 5, + onReady: () => { d.resolve(); } + }).dxMap('instance'); + + d.done(() => { + map.option('onUpdated', () => { + assert.strictEqual(L.setZoomArg, 8, 'setZoom called with new zoom'); + done(); + }); + map.option('zoom', 8); + }); + }); + + QUnit.test('center option is passed as lat/lng on init', function(assert) { + const done = assert.async(); + + $('#map').dxMap({ + provider: 'osm', + center: { lat: 10, lng: 20 }, + onReady: () => { + assert.deepEqual(L.mapOptions.center, { lat: 10, lng: 20 }, 'center passed to L.map'); + done(); + } + }); + }); + + QUnit.test('center option accepts an array on init', function(assert) { + const done = assert.async(); + + $('#map').dxMap({ + provider: 'osm', + center: [10, 20], + onReady: () => { + assert.deepEqual(L.mapOptions.center, { lat: 10, lng: 20 }, 'array center is passed to L.map'); + done(); + } + }); + }); + + QUnit.test('center option accepts a numeric string without geocoding', function(assert) { + const done = assert.async(); + const geocodeLocation = sinon.spy(() => Promise.resolve({ lat: 0, lng: 0 })); + + $('#map').dxMap({ + provider: 'osm', + providerConfig: { geocodeLocation }, + center: '10, 20', + onReady: () => { + assert.deepEqual(L.mapOptions.center, { lat: 10, lng: 20 }, 'numeric string center is passed to L.map'); + assert.notOk(geocodeLocation.called, 'numeric string center is not geocoded'); + done(); + } + }); + }); + + QUnit.test('center option is updated at runtime', function(assert) { + const done = assert.async(); + const d = $.Deferred(); + + const map = $('#map').dxMap({ + provider: 'osm', + center: { lat: 0, lng: 0 }, + onReady: () => { d.resolve(); } + }).dxMap('instance'); + + d.done(() => { + map.option('onUpdated', () => { + assert.deepEqual(L.setViewArgs.center, { lat: 5, lng: 5 }, 'setView called with new center'); + done(); + }); + map.option('center', { lat: 5, lng: 5 }); + }); + }); + + QUnit.test('string center is geocoded via osmGeocodeLocation callback', function(assert) { + const done = assert.async(); + + $('#map').dxMap({ + provider: 'osm', + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap callback identifier + providerConfig: { geocodeLocation: this.osmGeocodeLocation }, + center: 'Austin, TX', + onReady: () => { + assert.deepEqual( + L.mapOptions.center, + this.geocodedLatLng, + 'center is geocoded via user-supplied callback' + ); + done(); + } + }); + }); + + QUnit.test('osmGeocodeLocation callback receives the raw query string', function(assert) { + const done = assert.async(); + let capturedQuery; + + $('#map').dxMap({ + provider: 'osm', + providerConfig: { geocodeLocation: (query) => { + capturedQuery = query; + return Promise.resolve({ lat: 10, lng: 20 }); + } }, + center: 'Austin, TX', + onReady: () => { + assert.strictEqual(capturedQuery, 'Austin, TX', 'raw query string passed to callback'); + done(); + } + }); + }); + + QUnit.test('geocoded locations are cached', function(assert) { + const done = assert.async(); + const d1 = $.Deferred(); + const d2 = $.Deferred(); + const center = 'Austin, TX'; + + const map = $('#map').dxMap({ + provider: 'osm', + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap callback identifier + providerConfig: { geocodeLocation: this.osmGeocodeLocation }, + onReady: () => { d1.resolve(); } + }).dxMap('instance'); + + const spy = sinon.spy(map._provider, '_geocodeLocationImpl'); + + d1.done(() => { + map.option('onUpdated', () => { + assert.strictEqual(spy.callCount, 1, 'geocoded once'); + d2.resolve(); + }); + map.option('center', center); + }); + + d2.done(() => { + map.option('onUpdated', () => { + assert.strictEqual(spy.callCount, 1, 'not geocoded again for same string'); + spy.restore(); + done(); + }); + map.option('center', center); + }); + }); + + QUnit.test('failed geocoding results are not cached', function(assert) { + const done = assert.async(); + const ready = $.Deferred(); + const center = 'Austin, TX'; + const geocodeLocation = sinon.stub(); + + geocodeLocation.onFirstCall().returns(Promise.reject(new Error('geocoding unavailable'))); + geocodeLocation.onSecondCall().returns(Promise.resolve({ lat: 10, lng: 20 })); + + const map = $('#map').dxMap({ + provider: 'osm', + providerConfig: { geocodeLocation }, + center, + onReady: () => { ready.resolve(); }, + }).dxMap('instance'); + + ready.done(() => { + assert.deepEqual(map.option('center'), { lat: 0, lng: 0 }, 'failed request falls back to (0,0)'); + assert.ok(geocodeLocation.calledOnce, 'geocoding was requested once'); + + map.option('onUpdated', () => { + assert.ok(geocodeLocation.calledTwice, 'geocoding is requested again'); + assert.deepEqual(map.option('center'), { lat: 10, lng: 20 }, 'later successful result is applied'); + done(); + }); + map.option('center', center); + }); + }); + + QUnit.test('string center resolves to (0,0) and logs W1031 when osmGeocodeLocation is not provided', function(assert) { + const done = assert.async(); + const errorStub = sinon.stub(errors, 'log'); + + $('#map').dxMap({ + provider: 'osm', + center: 'Austin, TX', + onReady: () => { + assert.deepEqual( + L.mapOptions.center, + { lat: 0, lng: 0 }, + 'falls back to (0,0) with no geocoding callback' + ); + assert.ok(errorStub.withArgs('W1031').called, 'W1031 warning logged'); + assert.strictEqual(errorStub.withArgs('W1031').firstCall.args[1], 'Austin, TX', 'the unresolved string is passed to the warning'); + errorStub.restore(); + done(); + } + }); + }); + + QUnit.test('string center resolves to (0,0) when osmGeocodeLocation rejects', function(assert) { + const done = assert.async(); + + $('#map').dxMap({ + provider: 'osm', + providerConfig: { geocodeLocation: () => Promise.reject(new Error('geocoding unavailable')) }, + center: 'Austin, TX', + onReady: () => { + assert.deepEqual(L.mapOptions.center, { lat: 0, lng: 0 }, 'rejected geocoding falls back to (0,0)'); + done(); + } + }); + }); + + QUnit.test('string center resolves to (0,0) when osmGeocodeLocation throws', function(assert) { + const done = assert.async(); + + $('#map').dxMap({ + provider: 'osm', + providerConfig: { geocodeLocation: () => { throw new Error('geocoding unavailable'); } }, + center: 'Austin, TX', + onReady: () => { + assert.deepEqual(L.mapOptions.center, { lat: 0, lng: 0 }, 'thrown geocoding error falls back to (0,0)'); + done(); + } + }); + }); + + QUnit.test('string center resolves to (0,0) for an incomplete osmGeocodeLocation result', function(assert) { + const done = assert.async(); + + $('#map').dxMap({ + provider: 'osm', + providerConfig: { geocodeLocation: () => Promise.resolve({ lat: 10 }) }, + center: 'Austin, TX', + onReady: () => { + assert.deepEqual(L.mapOptions.center, { lat: 0, lng: 0 }, 'incomplete geocoding falls back to (0,0)'); + done(); + } + }); + }); + + QUnit.test('bounds option takes priority over center', function(assert) { + const done = assert.async(); + + $('#map').dxMap({ + provider: 'osm', + bounds: { + northEast: { lat: 10, lng: 10 }, + southWest: { lat: -10, lng: -10 }, + }, + center: { lat: 50, lng: 50 }, + onReady: () => { + assert.ok(L.fitBoundsArg, 'fitBounds was called instead of setView'); + assert.notOk(L.setViewArgs, 'setView was not called'); + done(); + } + }); + }); + + QUnit.test('width/height change triggers invalidateSize', function(assert) { + const done = assert.async(); + const d = $.Deferred(); + + const map = $('#map').dxMap({ + provider: 'osm', + onReady: () => { d.resolve(); } + }).dxMap('instance'); + + d.done(() => { + map.option('onUpdated', () => { + assert.strictEqual(L.mapResized, true, 'invalidateSize was called'); + done(); + }); + map.option('width', 400); + }); + }); + + QUnit.test('controls: true adds zoom control', function(assert) { + const done = assert.async(); + + $('#map').dxMap({ + provider: 'osm', + controls: true, + onReady: () => { + assert.strictEqual(L.zoomControlAdded, true, 'zoom control added'); + done(); + } + }); + }); + + QUnit.test('controls can be toggled at runtime', function(assert) { + const done = assert.async(); + const d = $.Deferred(); + + const map = $('#map').dxMap({ + provider: 'osm', + controls: true, + onReady: () => { d.resolve(); } + }).dxMap('instance'); + + d.done(() => { + map.option('onUpdated', () => { + assert.strictEqual(L.zoomControlAdded, false, 'zoom control removed'); + done(); + }); + map.option('controls', false); + }); + }); + + QUnit.test('onClick fires with location and event', function(assert) { + const done = assert.async(); + let clickFired = 0; + const fakeEvent = new PointerEvent('click'); + + $('#map').dxMap({ + provider: 'osm', + onClick: (e) => { + assert.deepEqual(e.location, { lat: 5, lng: 10 }, 'click location is correct'); + assert.strictEqual(e.event, fakeEvent, 'original event is passed'); + clickFired++; + }, + onReady: () => { + // eslint-disable-next-line spellcheck/spell-checker -- Leaflet event field name + L.mapClickCallback({ latlng: { lat: 5, lng: 10 }, originalEvent: fakeEvent }); + assert.strictEqual(clickFired, 1, 'onClick fired once'); + done(); + } + }); + }); + + QUnit.test('moveend event updates center, bounds and zoom options', function(assert) { + const done = assert.async(); + + L.mockCenter = { lat: 3, lng: 7 }; + L.mockZoom = 9; + L.mockBounds = { + getNorthEast: () => ({ lat: 4, lng: 8 }), + getSouthWest: () => ({ lat: 2, lng: 6 }), + }; + + const map = $('#map').dxMap({ + provider: 'osm', + onReady: () => { + L.mapMoveEndCallback(); + + assert.deepEqual(map.option('center'), { lat: 3, lng: 7 }, 'center updated'); + assert.deepEqual(map.option('zoom'), 9, 'zoom updated'); + assert.deepEqual(map.option('bounds'), { + northEast: { lat: 4, lng: 8 }, + southWest: { lat: 2, lng: 6 }, + }, 'bounds updated'); + + done(); + } + }).dxMap('instance'); + }); + + QUnit.test('moveend does not write center when it has not changed', function(assert) { + const done = assert.async(); + const center = { lat: 3, lng: 7 }; + + const map = $('#map').dxMap({ + provider: 'osm', + center, + onReady: () => { + const setOptionSilentSpy = sinon.spy(map, 'setOptionSilent'); + L.mockCenter = center; + + L.mapMoveEndCallback(); + + assert.notOk(setOptionSilentSpy.withArgs('center').called, 'center is not written back'); + setOptionSilentSpy.restore(); + done(); + } + }).dxMap('instance'); + }); + + QUnit.test('disabled option toggles all Leaflet interaction handlers at runtime', function(assert) { + const done = assert.async(); + const ready = $.Deferred(); + + const map = $('#map').dxMap({ + provider: 'osm', + onReady: () => { ready.resolve(); } + }).dxMap('instance'); + + ready.done(() => { + const handlers = [ + L.mapInstance.dragging, + L.mapInstance.touchZoom, + L.mapInstance.doubleClickZoom, + L.mapInstance.scrollWheelZoom, + L.mapInstance.boxZoom, + L.mapInstance.keyboard, + ]; + const disableSpies = handlers.map((handler) => sinon.spy(handler, 'disable')); + const enableSpies = handlers.map((handler) => sinon.spy(handler, 'enable')); + const restoreSpies = () => { + disableSpies.concat(enableSpies).forEach((spy) => { spy.restore(); }); + }; + + map.option('disabled', true); + + map._lastAsyncAction.then(() => { + assert.ok(disableSpies.every((spy) => spy.calledOnce), 'all interaction handlers are disabled'); + map.option('disabled', false); + + return map._lastAsyncAction; + }).then(() => { + assert.ok(enableSpies.every((spy) => spy.calledOnce), 'all interaction handlers are enabled'); + restoreSpies(); + done(); + }, (error) => { + assert.ok(false, `updating disabled failed: ${error.message}`); + restoreSpies(); + done(); + }); + }); + }); + + QUnit.test('disabled option disables Leaflet interaction handlers on init', function(assert) { + const done = assert.async(); + + const map = $('#map').dxMap({ + provider: 'osm', + disabled: true, + }).dxMap('instance'); + + map._lastAsyncAction.then(() => { + [ + 'dragging', + 'touchZoom', + 'doubleClickZoom', + 'scrollWheelZoom', + 'boxZoom', + 'keyboard', + ].forEach((option) => { + assert.strictEqual(L.mapOptions[option], false, `${option} is disabled on init`); + }); + done(); + }, (error) => { + assert.ok(false, `map initialization failed: ${error.message}`); + done(); + }); + }); + + QUnit.test('click and moveend/zoomend handlers are attached', function(assert) { + const done = assert.async(); + + $('#map').dxMap({ + provider: 'osm', + onReady: () => { + assert.ok(L.addedEvents.includes('click'), 'click event added'); + assert.ok(L.addedEvents.includes('moveend'), 'moveend event added'); + assert.ok(L.addedEvents.includes('zoomend'), 'zoomend event added'); + done(); + } + }); + }); +}); + + +QUnit.module('OSM: tile server', moduleConfig, () => { + QUnit.test('osmTileServer as a string is used as the tile URL', function(assert) { + const done = assert.async(); + const url = 'https://tiles.example.com/{z}/{x}/{y}.png'; + + $('#map').dxMap({ + provider: 'osm', + providerConfig: { tileServer: url }, + onReady: () => { + assert.strictEqual(L.tileLayerUrl, url, 'string tile URL is used'); + done(); + } + }); + }); + + QUnit.test('osmTileServer as an object passes url, 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: 'My attribution', + maxZoom: 17, + } }, + onReady: () => { + assert.strictEqual(L.tileLayerUrl, 'https://tiles.example.com/{z}/{x}/{y}.png', 'url used'); + assert.strictEqual(L.tileLayerOptions.attribution, 'My attribution', 'attribution passed'); + assert.strictEqual(L.tileLayerOptions.maxZoom, 17, 'maxZoom passed'); + done(); + } + }); + }); + + QUnit.test('W1032 is logged when the tile server has no attribution, and not when it does', function(assert) { + const done = assert.async(); + const errorStub = sinon.stub(errors, 'log'); + + $('#map').dxMap({ + provider: 'osm', + providerConfig: { tileServer: 'https://tiles.example.com/{z}/{x}/{y}.png' }, + onReady: () => { + assert.ok(errorStub.withArgs('W1032').called, 'W1032 logged when attribution is missing'); + errorStub.restore(); + done(); + } + }); + }); + + QUnit.test('W1032 is not logged when the tile server provides attribution', function(assert) { + const done = assert.async(); + const errorStub = sinon.stub(errors, 'log'); + + $('#map').dxMap({ + provider: 'osm', + providerConfig: { tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: '© OpenStreetMap contributors', + } }, + onReady: () => { + assert.notOk(errorStub.withArgs('W1032').called, 'W1032 not logged when attribution is present'); + errorStub.restore(); + done(); + } + }); + }); + + QUnit.test('subdomains are passed when the URL contains the {s} placeholder', function(assert) { + const done = assert.async(); + + $('#map').dxMap({ + provider: 'osm', + // eslint-disable-next-line spellcheck/spell-checker -- Leaflet tile server option name + providerConfig: { tileServer: { url: 'https://{s}.tiles.example.com/{z}/{x}/{y}.png', subdomains: '1234' } }, + onReady: () => { + // eslint-disable-next-line spellcheck/spell-checker -- Leaflet tile server option name + assert.strictEqual(L.tileLayerOptions.subdomains, '1234', 'subdomains passed when {s} present'); + done(); + } + }); + }); + + QUnit.test('subdomains are omitted when the URL does not contain the {s} placeholder', function(assert) { + const done = assert.async(); + + $('#map').dxMap({ + provider: 'osm', + // eslint-disable-next-line spellcheck/spell-checker -- Leaflet tile server option name + providerConfig: { tileServer: { url: 'https://tiles.example.com/{z}/{x}/{y}.png', subdomains: '1234' } }, + onReady: () => { + assert.notOk('subdomains' in L.tileLayerOptions, 'subdomains omitted when {s} absent'); + done(); + } + }); + }); + + QUnit.test('osmTileServer as a function resolves per map type', function(assert) { + const done = assert.async(); + const d = $.Deferred(); + + const map = $('#map').dxMap({ + provider: 'osm', + type: 'roadmap', + providerConfig: { tileServer: (type) => `https://tiles.example.com/${type}/{z}/{x}/{y}.png` }, + onReady: () => { d.resolve(); } + }).dxMap('instance'); + + d.done(() => { + assert.strictEqual(L.tileLayerUrl, 'https://tiles.example.com/roadmap/{z}/{x}/{y}.png', 'roadmap url resolved'); + + map.option('onUpdated', () => { + assert.ok(L.removedLayers.length > 0, 'old tile layer removed'); + assert.strictEqual(L.tileLayerUrl, 'https://tiles.example.com/satellite/{z}/{x}/{y}.png', 'satellite url resolved on type change'); + done(); + }); + map.option('type', 'satellite'); + }); + }); + + QUnit.test('type change preserves the current tile layer when the new layer cannot be created', function(assert) { + const done = assert.async(); + const tileServerError = new Error('tile server failed'); + const previousTileLayer = {}; + const mapEngine = { + ...L, + tileLayer: sinon.spy(L.tileLayer), + }; + const map = { + removeLayer: sinon.spy(), + }; + let shouldFail = true; + const mapWidget = { + option: () => ({ + type: 'satellite', + providerConfig: { + tileServer: () => { + if(shouldFail) { + shouldFail = false; + throw tileServerError; + } + + return { + url: 'https://tiles.example.com/satellite/{z}/{x}/{y}.png', + attribution: 'Test attribution', + }; + }, + }, + }), + }; + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap provider identifier + const provider = new OsmProvider(mapWidget, null); + + provider._mapEngine = mapEngine; + provider._map = map; + provider._tileLayer = previousTileLayer; + provider._currentTileType = 'roadmap'; + + assert.throws(() => provider.updateMapType(), tileServerError, 'tile server error is propagated'); + assert.notOk(map.removeLayer.called, 'current tile layer is not removed after the error'); + assert.strictEqual(provider._tileLayer, previousTileLayer, 'current tile layer is preserved'); + assert.strictEqual(provider._currentTileType, 'roadmap', 'current tile type is preserved'); + + provider.updateMapType().then(() => { + assert.ok(mapEngine.tileLayer.calledOnce, 'new tile layer is created on retry'); + assert.ok(map.removeLayer.calledOnceWith(previousTileLayer), 'previous tile layer is removed after the retry succeeds'); + assert.notStrictEqual(provider._tileLayer, previousTileLayer, 'new tile layer is stored'); + assert.strictEqual(provider._currentTileType, 'satellite', 'new tile type is stored'); + done(); + }); + }); + + QUnit.test('no tile layer is created and W1030 is logged when osmTileServer is not provided', function(assert) { + const done = assert.async(); + const errorStub = sinon.stub(errors, 'log'); + + $('#map').dxMap({ + provider: 'osm', + onReady: () => { + assert.strictEqual(L.addedTileLayers.length, 0, 'no tile layer added without a tile server'); + assert.ok(errorStub.withArgs('W1030').calledOnce, 'W1030 warning logged'); + errorStub.restore(); + done(); + } + }); + }); + + QUnit.test('provider is reinitialized when osmTileServer changes', function(assert) { + const done = assert.async(); + const d = $.Deferred(); + let initialMap; + + const map = $('#map').dxMap({ + provider: 'osm', + providerConfig: { tileServer: 'https://a.example.com/{z}/{x}/{y}.png' }, + onReady: ({ originalMap }) => { + initialMap = originalMap; + d.resolve(); + } + }).dxMap('instance'); + + d.done(() => { + map.option('onReady', ({ originalMap }) => { + assert.notStrictEqual(originalMap, initialMap, 'Leaflet map is recreated'); + assert.strictEqual(L.tileLayerUrl, 'https://b.example.com/{z}/{x}/{y}.png', 'new tile URL used'); + done(); + }); + map.option('providerConfig', { tileServer: 'https://b.example.com/{z}/{x}/{y}.png' }); + }); + }); +}); + + +QUnit.module('OSM: markers', moduleConfig, () => { + QUnit.test('marker is added on init', function(assert) { + const done = assert.async(); + + $('#map').dxMap({ + provider: 'osm', + markers: [{ location: [33, 33] }], + onReady: () => { + assert.strictEqual(L.addedMarkers.length, 1, 'one marker added'); + assert.deepEqual(L.lastMarkerLatLng, { lat: 33, lng: 33 }, 'marker at correct position'); + done(); + } + }); + }); + + QUnit.test('marker is added at runtime', function(assert) { + const done = assert.async(); + const d = $.Deferred(); + + const map = $('#map').dxMap({ + provider: 'osm', + onReady: () => { d.resolve(); } + }).dxMap('instance'); + + d.done(() => { + map.option('onUpdated', () => { + assert.strictEqual(L.addedMarkers.length, 1, 'marker added'); + assert.deepEqual(L.lastMarkerLatLng, { lat: 10, lng: 20 }, 'marker position correct'); + done(); + }); + map.option('markers', [{ location: { lat: 10, lng: 20 } }]); + }); + }); + + QUnit.test('marker uses custom icon from markerIconSrc', function(assert) { + const done = assert.async(); + const markerIconSrc = 'customMarker.png'; + + $('#map').dxMap({ + provider: 'osm', + markers: [{ location: [10, 20] }], + markerIconSrc, + onReady: () => { + assert.ok(L.iconOptions, 'L.icon was called'); + assert.strictEqual(L.iconOptions.iconUrl, markerIconSrc, 'custom icon URL is passed'); + done(); + } + }); + }); + + QUnit.test('marker uses custom icon from marker.iconSrc', function(assert) { + const done = assert.async(); + const iconSrc = 'markerPin.png'; + + $('#map').dxMap({ + provider: 'osm', + markers: [{ location: [10, 20], iconSrc }], + onReady: () => { + assert.ok(L.iconOptions, 'L.icon was called'); + assert.strictEqual(L.iconOptions.iconUrl, iconSrc, 'marker iconSrc is passed'); + done(); + } + }); + }); + + QUnit.test('default marker uses the Leaflet default icon', function(assert) { + const done = assert.async(); + + $('#map').dxMap({ + provider: 'osm', + markers: [{ location: [10, 20], tooltip: 'Default marker' }], + onReady: () => { + assert.strictEqual(L.markerOptions, undefined, 'no custom icon is passed to Leaflet'); + done(); + } + }); + }); + + QUnit.test('custom marker uses the supported Leaflet icon size and anchors', function(assert) { + const done = assert.async(); + + $('#map').dxMap({ + provider: 'osm', + markers: [{ location: [10, 20], iconSrc: 'customMarker.png', tooltip: 'Custom marker' }], + onReady: () => { + assert.deepEqual(L.iconOptions.iconSize, [25, 41], 'Leaflet receives the supported icon size'); + assert.deepEqual(L.iconOptions.iconAnchor, [12, 41], 'Leaflet receives the bottom-center icon anchor'); + assert.deepEqual(L.iconOptions.popupAnchor, [1, -34], 'Leaflet receives the standard popup anchor'); + assert.deepEqual(L.boundPopup.options.offset, { x: 0, y: 7 }, 'Leaflet default popup offset is preserved'); + done(); + } + }); + }); + + QUnit.test('marker uses divIcon when html option is set', function(assert) { + const done = assert.async(); + const html = 'Custom'; + + $('#map').dxMap({ + provider: 'osm', + markers: [{ location: [10, 20], html }], + onReady: () => { + assert.ok(L.divIconOptions, 'L.divIcon was called'); + assert.strictEqual(L.divIconOptions.html, html, 'html content is passed'); + done(); + } + }); + }); + + QUnit.test('tooltip creates a popup bound to the marker', function(assert) { + const done = assert.async(); + + $('#map').dxMap({ + provider: 'osm', + markers: [{ location: [10, 20], tooltip: 'Austin, TX' }], + onReady: () => { + assert.ok(L.boundPopup, 'popup was bound to marker'); + assert.strictEqual(L.popupContent, 'Austin, TX', 'popup has correct text'); + done(); + } + }); + }); + + QUnit.test('tooltip with isShown is open when the map is ready', function(assert) { + const done = assert.async(); + + $('#map').dxMap({ + provider: 'osm', + markers: [{ + location: [10, 20], + tooltip: { text: 'Austin, TX', isShown: true }, + }], + onReady: () => { + assert.strictEqual(L.popupOpened, true, 'popup is opened before onReady'); + done(); + } + }); + }); + + QUnit.test('click on marker closes an initially shown tooltip', function(assert) { + const done = assert.async(); + + $('#map').dxMap({ + provider: 'osm', + markers: [{ + location: [10, 20], + tooltip: { text: 'Austin, TX', isShown: true }, + }], + onReady: () => { + assert.strictEqual(L.popupOpened, true, 'popup is initially open'); + L.markerClickCallback(); + assert.strictEqual(L.popupOpened, false, 'popup is closed by marker click'); + done(); + } + }); + }); + + QUnit.test('click on marker toggles popup and fires onClick', function(assert) { + const done = assert.async(); + let clickFired = 0; + + $('#map').dxMap({ + provider: 'osm', + markers: [{ + location: [10, 20], + tooltip: 'Test', + onClick: (e) => { + assert.deepEqual(e.location, { lat: 10, lng: 20 }, 'click location correct'); + clickFired++; + } + }], + onReady: () => { + L.markerClickCallback(); + assert.strictEqual(clickFired, 1, 'onClick fired once after the first click'); + assert.strictEqual(L.popupOpened, true, 'popup opened on first click'); + + L.markerClickCallback(); + assert.strictEqual(clickFired, 2, 'onClick fired once after the second click'); + assert.strictEqual(L.popupOpened, false, 'popup closed on second click'); + done(); + } + }); + }); + + QUnit.test('click on marker toggles popup without an onClick handler', function(assert) { + const done = assert.async(); + + $('#map').dxMap({ + provider: 'osm', + markers: [{ location: [10, 20], tooltip: 'Test' }], + onReady: () => { + L.markerClickCallback(); + assert.strictEqual(L.popupOpened, true, 'popup opened on first click'); + + L.markerClickCallback(); + assert.strictEqual(L.popupOpened, false, 'popup closed on second click'); + done(); + } + }); + }); + + QUnit.test('addMarker method works', function(assert) { + const done = assert.async(); + const d = $.Deferred(); + + const map = $('#map').dxMap({ + provider: 'osm', + markers: [MARKERS[0]], + onReady: () => { d.resolve(); } + }).dxMap('instance'); + + d.done(() => { + map.addMarker({ location: [7, 7] }).done((instance) => { + assert.strictEqual(L.addedMarkers.length, 2, 'second marker added'); + assert.ok(instance, 'marker instance returned'); + done(); + }); + }); + }); + + QUnit.test('removeMarker method works', function(assert) { + const done = assert.async(); + const d = $.Deferred(); + + const map = $('#map').dxMap({ + provider: 'osm', + markers: [MARKERS[0]], + onReady: () => { d.resolve(); } + }).dxMap('instance'); + + d.done(() => { + map.removeMarker(0).done(() => { + assert.strictEqual(L.removedMarkers.length, 1, 'marker removed'); + done(); + }); + }); + }); + + QUnit.test('onMarkerAdded fires with options and originalMarker', function(assert) { + const done = assert.async(); + let markerAddedFired = false; + + $('#map').dxMap({ + provider: 'osm', + markers: [MARKERS[0]], + onReady: () => { + assert.strictEqual(markerAddedFired, true, 'onMarkerAdded fired'); + done(); + }, + onMarkerAdded: ({ options, originalMarker }) => { + markerAddedFired = true; + assert.deepEqual(options, MARKERS[0], 'options are correct'); + assert.ok(originalMarker, 'originalMarker provided'); + }, + }); + }); + + QUnit.test('onMarkerRemoved fires', function(assert) { + const done = assert.async(); + const d = $.Deferred(); + let markerRemovedFired = false; + + const map = $('#map').dxMap({ + provider: 'osm', + markers: [MARKERS[0]], + onReady: () => { d.resolve(); }, + onMarkerRemoved: ({ options }) => { + markerRemovedFired = true; + assert.deepEqual(options, MARKERS[0], 'options passed on remove'); + }, + }).dxMap('instance'); + + d.done(() => { + map.option('onUpdated', () => { + assert.strictEqual(markerRemovedFired, true, 'onMarkerRemoved fired'); + done(); + }); + map.option('markers', []); + }); + }); + + QUnit.test('autoAdjust fits bounds to markers', function(assert) { + const done = assert.async(); + + $('#map').dxMap({ + provider: 'osm', + autoAdjust: true, + center: [30, 30], + markers: [{ location: [20, 20] }], + onReady: () => { + assert.ok(L.fitBoundsArg, 'fitBounds was called'); + done(); + } + }); + }); + + QUnit.test('autoAdjust: false does not call fitBounds for markers', function(assert) { + const done = assert.async(); + + $('#map').dxMap({ + provider: 'osm', + autoAdjust: false, + center: [30, 30], + markers: [MARKERS[0], MARKERS[1]], + onReady: () => { + assert.notOk(L.fitBoundsArg, 'fitBounds not called'); + done(); + } + }); + }); + + [ + { initialZoom: 5, fittedZoom: 10, expectedZoom: 5, expectedSetZoom: 5 }, + { initialZoom: 10, fittedZoom: 5, expectedZoom: 5, expectedSetZoom: null }, + ].forEach(({ initialZoom, fittedZoom, expectedZoom, expectedSetZoom }) => { + QUnit.test(`autoAdjust keeps zoom at ${expectedZoom} when fitBounds changes it from ${initialZoom} to ${fittedZoom}`, function(assert) { + const done = assert.async(); + const ready = $.Deferred(); + + const map = $('#map').dxMap({ + provider: 'osm', + autoAdjust: false, + zoom: initialZoom, + markers: [MARKERS[0]], + onReady: () => { ready.resolve(); } + }).dxMap('instance'); + + ready.done(() => { + L.mockZoom = initialZoom; + L.fitBoundsCallback = () => { + L.mockZoom = fittedZoom; + L.mapZoomEndCallback(); + }; + + map.option('onUpdated', () => { + assert.deepEqual(L.fitBoundsOptions, { animate: false }, 'fitBounds animation is disabled'); + assert.strictEqual(map.option('zoom'), expectedZoom, 'zoom option has the expected value'); + assert.strictEqual(L.setZoomArg, expectedSetZoom, 'Leaflet zoom is restored only when fitBounds increases it'); + + L.mockZoom = 7; + L.mapZoomEndCallback(); + assert.strictEqual(map.option('zoom'), 7, 'zoom events are processed after autoAdjust completes'); + + L.fitBoundsCallback = null; + done(); + }); + + map.option('autoAdjust', true); + }); + }); + }); +}); + + +QUnit.module('OSM: routes', moduleConfig, () => { + QUnit.test('route is added on init via osmGetRoute callback', function(assert) { + const done = assert.async(); + + $('#map').dxMap({ + provider: 'osm', + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap callback identifier + providerConfig: { getRoute: this.osmGetRoute }, + routes: [ROUTES[0]], + onReady: () => { + // eslint-disable-next-line spellcheck/spell-checker -- Leaflet mock state identifier + assert.strictEqual(L.addedPolylines.length, 1, 'polyline added'); + assert.deepEqual(L.polylineCoords, this.routePolylineCoords, 'route coordinates correct'); + done(); + } + }); + }); + + QUnit.test('route is added at runtime', function(assert) { + const done = assert.async(); + const d = $.Deferred(); + + const map = $('#map').dxMap({ + provider: 'osm', + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap callback identifier + providerConfig: { getRoute: this.osmGetRoute }, + onReady: () => { d.resolve(); } + }).dxMap('instance'); + + d.done(() => { + map.option('onUpdated', () => { + // eslint-disable-next-line spellcheck/spell-checker -- Leaflet mock state identifier + assert.strictEqual(L.addedPolylines.length, 1, 'polyline added at runtime'); + done(); + }); + map.option('routes', [ROUTES[0]]); + }); + }); + + QUnit.test('addRoute method works', function(assert) { + const done = assert.async(); + const d = $.Deferred(); + + const map = $('#map').dxMap({ + provider: 'osm', + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap callback identifier + providerConfig: { getRoute: this.osmGetRoute }, + onReady: () => { d.resolve(); } + }).dxMap('instance'); + + d.done(() => { + map.addRoute(ROUTES[0]).done((instance) => { + // eslint-disable-next-line spellcheck/spell-checker -- Leaflet mock state identifier + assert.strictEqual(L.addedPolylines.length, 1, 'polyline added'); + assert.ok(instance, 'route instance returned'); + done(); + }); + }); + }); + + QUnit.test('removeRoute method works', function(assert) { + const done = assert.async(); + const d = $.Deferred(); + + const map = $('#map').dxMap({ + provider: 'osm', + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap callback identifier + providerConfig: { getRoute: this.osmGetRoute }, + routes: [ROUTES[0]], + onReady: () => { d.resolve(); } + }).dxMap('instance'); + + d.done(() => { + map.removeRoute(0).done(() => { + // eslint-disable-next-line spellcheck/spell-checker -- Leaflet mock state identifier + assert.strictEqual(L.removedPolylines.length, 1, 'polyline removed'); + done(); + }); + }); + }); + + QUnit.test('route color, weight, opacity are applied', function(assert) { + const done = assert.async(); + + $('#map').dxMap({ + provider: 'osm', + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap callback identifier + providerConfig: { getRoute: this.osmGetRoute }, + routes: [{ + color: 'red', + weight: 4, + opacity: 0.3, + locations: [[10, 10], [20, 20]], + }], + onReady: () => { + assert.strictEqual(L.polylineOptions.color, '#ff0000', 'color applied'); + assert.strictEqual(L.polylineOptions.weight, 4, 'weight applied'); + assert.strictEqual(L.polylineOptions.opacity, 0.3, 'opacity applied'); + done(); + } + }); + }); + + QUnit.test('osmGetRoute callback receives locations and mode', function(assert) { + const done = assert.async(); + let capturedParams; + + $('#map').dxMap({ + provider: 'osm', + providerConfig: { getRoute: (params) => { + capturedParams = params; + return Promise.resolve([[10, 20], [30, 40]]); + } }, + routes: [{ + locations: [{ lat: 10, lng: 20 }, { lat: 30, lng: 40 }], + mode: 'cycling', + }], + onReady: () => { + assert.ok(capturedParams, 'callback was called'); + assert.strictEqual(capturedParams.mode, 'cycling', 'custom mode passed to callback without changes'); + assert.deepEqual(capturedParams.locations, [{ lat: 10, lng: 20 }, { lat: 30, lng: 40 }], 'locations passed to callback'); + done(); + } + }); + }); + + QUnit.test('GeoJSON LineString result is converted from longitude-latitude order', function(assert) { + const done = assert.async(); + + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + getRoute: () => Promise.resolve({ + type: 'LineString', + coordinates: [[-73.99, 40.74], [-73.98, 40.75]], + }), + }, + routes: [ROUTES[0]], + onReady: () => { + assert.deepEqual(L.polylineCoords, [[40.74, -73.99], [40.75, -73.98]], 'coordinates converted to latitude-longitude order'); + done(); + }, + }); + }); + + QUnit.test('unsupported GeoJSON result falls back to a straight polyline', function(assert) { + const done = assert.async(); + const errorStub = sinon.stub(errors, 'log'); + + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + getRoute: () => Promise.resolve({ + type: 'MultiLineString', + coordinates: [], + }), + }, + routes: [{ locations: [[10, 20], [30, 40]] }], + onReady: () => { + assert.deepEqual(L.polylineCoords, [[10, 20], [30, 40]], 'unsupported result is not interpreted as a route'); + assert.ok(errorStub.withArgs('W1006').calledOnce, 'W1006 warning logged'); + + errorStub.restore(); + done(); + }, + }); + }); + + QUnit.test('straight-line polyline is drawn and W1033 is logged when osmGetRoute is not provided', function(assert) { + const done = assert.async(); + const errorStub = sinon.stub(errors, 'log'); + + $('#map').dxMap({ + provider: 'osm', + routes: [ROUTES[0]], + onReady: () => { + // eslint-disable-next-line spellcheck/spell-checker -- Leaflet mock state identifier + assert.strictEqual(L.addedPolylines.length, 1, 'straight-line polyline added'); + assert.ok(errorStub.withArgs('W1033').calledOnce, 'W1033 warning logged'); + + errorStub.restore(); + done(); + } + }); + }); + + QUnit.test('empty route locations create a route without adjusting viewport', function(assert) { + const done = assert.async(); + + $('#map').dxMap({ + provider: 'osm', + routes: [{ locations: [] }], + onReady: () => { + // eslint-disable-next-line spellcheck/spell-checker -- Leaflet mock state identifier + assert.strictEqual(L.addedPolylines.length, 1, 'empty polyline added'); + assert.deepEqual(L.polylineCoords, [], 'empty coordinates passed'); + assert.notOk(L.fitBoundsArg, 'empty route does not adjust viewport'); + done(); + }, + }); + }); + + QUnit.test('empty osmGetRoute result creates a route without adjusting viewport', function(assert) { + const done = assert.async(); + + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + getRoute: () => Promise.resolve([]), + }, + routes: [ROUTES[0]], + onReady: () => { + // eslint-disable-next-line spellcheck/spell-checker -- Leaflet mock state identifier + assert.strictEqual(L.addedPolylines.length, 1, 'empty polyline added'); + assert.deepEqual(L.polylineCoords, [], 'empty coordinates passed'); + assert.notOk(L.fitBoundsArg, 'empty route does not adjust viewport'); + done(); + }, + }); + }); + + QUnit.test('falls back to straight polyline when osmGetRoute callback rejects', function(assert) { + const done = assert.async(); + const d = $.Deferred(); + + const errorStub = sinon.stub(errors, 'log'); + + const map = $('#map').dxMap({ + provider: 'osm', + providerConfig: { getRoute: () => Promise.reject(new Error('routing unavailable')) }, + onReady: () => { d.resolve(); }, + }).dxMap('instance'); + + d.done(() => { + map.addRoute(ROUTES[0]).done(() => { + // eslint-disable-next-line spellcheck/spell-checker -- Leaflet mock state identifier + assert.strictEqual(L.addedPolylines.length, 1, 'fallback polyline added'); + assert.ok(errorStub.withArgs('W1006').calledOnce, 'W1006 warning logged'); + assert.deepEqual(L.fitBoundsArg.getNorthEast(), { lat: 40.752946, lng: -73.987384 }, 'fallback route extends north-east autoAdjust bounds'); + assert.deepEqual(L.fitBoundsArg.getSouthWest(), { lat: 40.737102, lng: -73.990318 }, 'fallback route extends south-west autoAdjust bounds'); + + errorStub.restore(); + done(); + }); + }); + }); + + QUnit.test('falls back to straight polyline when osmGetRoute callback throws', function(assert) { + const done = assert.async(); + const d = $.Deferred(); + const routingError = new Error('routing unavailable'); + const errorStub = sinon.stub(errors, 'log'); + + const map = $('#map').dxMap({ + provider: 'osm', + providerConfig: { getRoute: () => { throw routingError; } }, + onReady: () => { d.resolve(); }, + }).dxMap('instance'); + + d.done(() => { + map.addRoute(ROUTES[0]).done(() => { + // eslint-disable-next-line spellcheck/spell-checker -- Leaflet mock state identifier + assert.strictEqual(L.addedPolylines.length, 1, 'fallback polyline added'); + assert.ok(errorStub.withArgs('W1006', routingError).calledOnce, 'W1006 warning logged with the thrown error'); + + errorStub.restore(); + done(); + }); + }); + }); + + QUnit.test('route rendering rejects when the fallback polyline cannot be created', function(assert) { + const done = assert.async(); + const polylineError = new Error('polyline rendering failed'); + const polylineStub = sinon.stub(L, 'polyline').throws(polylineError); + const errorStub = sinon.stub(errors, 'log'); + const mapWidget = { + option: () => ({ + providerConfig: { getRoute: () => Promise.resolve([[10, 20], [30, 40]]) }, + }), + }; + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap provider identifier + const provider = new OsmProvider(mapWidget, null); + + provider._mapEngine = L; + provider._map = {}; + + provider._renderRoute({ locations: [[10, 20], [30, 40]] }).then( + () => { + polylineStub.restore(); + errorStub.restore(); + assert.ok(false, 'route rendering should reject'); + done(); + }, + (error) => { + assert.strictEqual(error, polylineError, 'fallback rendering error is propagated'); + assert.ok(polylineStub.calledTwice, 'route and fallback polylines were attempted'); + assert.ok(errorStub.withArgs('W1006', polylineError).calledOnce, 'initial rendering error is logged'); + polylineStub.restore(); + errorStub.restore(); + done(); + } + ); + }); + + QUnit.test('onRouteAdded fires with options and originalRoute', function(assert) { + const done = assert.async(); + let routeAddedFired = false; + + $('#map').dxMap({ + provider: 'osm', + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap callback identifier + providerConfig: { getRoute: this.osmGetRoute }, + routes: [ROUTES[0]], + onReady: () => { + assert.strictEqual(routeAddedFired, true, 'onRouteAdded fired'); + done(); + }, + onRouteAdded: ({ options, originalRoute }) => { + routeAddedFired = true; + assert.deepEqual(options, ROUTES[0], 'route options passed'); + assert.ok(originalRoute, 'originalRoute provided'); + }, + }); + }); + + QUnit.test('onRouteRemoved fires', function(assert) { + const done = assert.async(); + const d = $.Deferred(); + let routeRemovedFired = false; + + const map = $('#map').dxMap({ + provider: 'osm', + // eslint-disable-next-line spellcheck/spell-checker -- OpenStreetMap callback identifier + providerConfig: { getRoute: this.osmGetRoute }, + routes: [ROUTES[0]], + onReady: () => { d.resolve(); }, + onRouteRemoved: ({ options }) => { + routeRemovedFired = true; + assert.deepEqual(options, ROUTES[0], 'route options passed on remove'); + }, + }).dxMap('instance'); + + d.done(() => { + map.option('onUpdated', () => { + assert.strictEqual(routeRemovedFired, true, 'onRouteRemoved fired'); + done(); + }); + map.option('routes', []); + }); + }); + + [ + { routeMode: 'driving', expectedMode: 'driving' }, + { routeMode: 'walking', expectedMode: 'walking' }, + ].forEach(({ routeMode, expectedMode }) => { + QUnit.test(`movement mode "${routeMode}" is passed to osmGetRoute as "${expectedMode}"`, function(assert) { + const done = assert.async(); + + const map = $('#map').dxMap({ + provider: 'osm', + onReady: () => { + assert.strictEqual(map._provider._movementMode(routeMode), expectedMode); + done(); + } + }).dxMap('instance'); + }); + }); + + QUnit.test('undefined/empty movement mode defaults to "driving"', function(assert) { + const done = assert.async(); + + const map = $('#map').dxMap({ + provider: 'osm', + onReady: () => { + assert.strictEqual(map._provider._movementMode(undefined), 'driving'); + assert.strictEqual(map._provider._movementMode(''), 'driving'); + done(); + } + }).dxMap('instance'); + }); +}); + + +QUnit.module('OSM: cleanup', moduleConfig, () => { + QUnit.test('clean() disposes map and removes event handlers', function(assert) { + const done = assert.async(); + const d = $.Deferred(); + + const map = $('#map').dxMap({ + provider: 'osm', + onReady: () => { d.resolve(); } + }).dxMap('instance'); + + d.done(() => { + map.option('provider', 'google'); // triggers _clean → provider.clean() + setTimeout(() => { + assert.strictEqual(L.mapDisposed, true, 'map.remove() called'); + assert.ok(L.removedEvents.includes('click'), 'click handler removed'); + assert.ok(L.removedEvents.includes('moveend'), 'moveend handler removed'); + assert.ok(L.removedEvents.includes('zoomend'), 'zoomend handler removed'); + done(); + }, 50); + }); + }); +}); diff --git a/packages/devextreme/ts/dx.all.d.ts b/packages/devextreme/ts/dx.all.d.ts index d915f281adee..0c0108e9bcd8 100644 --- a/packages/devextreme/ts/dx.all.d.ts +++ b/packages/devextreme/ts/dx.all.d.ts @@ -23247,7 +23247,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] @@ -23279,6 +23284,64 @@ declare module DevExpress.ui { export type OptionChangedEvent = DevExpress.common.core.events.EventInfo & DevExpress.common.core.events.ChangedOptionInfo; + /** + * [descr:OsmGeocodeFunction] + */ + export type OsmGeocodeFunction = ( + query: string + ) => Promise; + /** + * [descr:OsmGeoJsonLineString] + */ + export interface OsmGeoJsonLineString { + type: 'LineString'; + coordinates: Array>; + } + /** + * [descr:OsmGetRouteFunction] + */ + export type OsmGetRouteFunction = ( + params: OsmGetRouteParams + ) => Promise; + /** + * [descr:OsmGetRouteParams] + */ + export interface OsmGetRouteParams { + locations: Array; + mode: RouteMode | string; + } + /** + * [descr:OsmRouteResult] + */ + export type OsmRouteResult = Array<[number, number]> | OsmGeoJsonLineString; + /** + * [descr:OsmTileServer] + */ + export type OsmTileServer = + | string + | OsmTileServerConfig + | ((type: MapType) => string | OsmTileServerConfig | null | undefined); + /** + * [descr:OsmTileServerConfig] + */ + export interface OsmTileServerConfig { + /** + * [descr:OsmTileServerConfig.url] + */ + url: string; + /** + * [descr:OsmTileServerConfig.attribution] + */ + attribution?: string; + /** + * [descr:OsmTileServerConfig.subdomains] + */ + subdomains?: string | Array; + /** + * [descr:OsmTileServerConfig.maxZoom] + */ + maxZoom?: number; + } export type Properties = dxMapOptions; /** * [descr:_ui_map_ReadyEvent] @@ -23442,6 +23505,28 @@ declare module DevExpress.ui { * @deprecated [depNote:dxMapOptions.providerConfig.useAdvancedMarkers] */ useAdvancedMarkers?: boolean; + /** + * [descr:dxMapOptions.providerConfig.tileServer] + */ + tileServer?: DevExpress.ui.dxMap.OsmTileServer | undefined; + /** + * [descr:dxMapOptions.providerConfig.geocodeLocation] + */ + geocodeLocation?: + | ((query: string) => Promise) + | undefined; + /** + * [descr:dxMapOptions.providerConfig.getRoute] + */ + getRoute?: + | (( + params: DevExpress.ui.dxMap.OsmGetRouteParams + ) => Promise) + | undefined; + /** + * [descr:dxMapOptions.providerConfig.mapEngine] + */ + mapEngine?: object | undefined; }; /** * [descr:dxMapOptions.routes]