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..3d21286ffdd4
--- /dev/null
+++ b/apps/react-storybook/stories/map/OSMMap.stories.tsx
@@ -0,0 +1,250 @@
+import type { Meta, StoryObj } from '@storybook/react-webpack5';
+
+import React, { useMemo } from 'react';
+import Map from 'devextreme-react/map';
+
+// OpenStreetMap (OSM) provider for the DevExtreme Map — powered by Leaflet.
+// 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 server
+// (tile.openstreetmap.org) MUST NOT be used in production per 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 markerUrl = 'https://js.devexpress.com/Demos/WidgetsGallery/JSDemos/images/maps/map-marker.png';
+
+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: `© Stadia Maps ${OSM_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 = ({ locations }: { locations: { lat: number; lng: number }[] }): Promise<[number, number][]> => {
+ 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.coordinates as [number, number][])
+ .map(([lng, lat]) => [lat, lng] as [number, number]));
+};
+
+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 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 {
+ 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 rebuilds
+ // its tile layer 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]);
+
+ return (
+
+ );
+};
+
+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..1365d3c659b2 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), getRoute?: ((params: OsmGetRouteParams) => any), mapId?: string, tileServer?: OsmTileServer, useAdvancedMarkers?: boolean } {
return this._getOption('providerConfig');
}
- set providerConfig(value: { mapId?: string, useAdvancedMarkers?: boolean }) {
+ set providerConfig(value: { geocodeLocation?: ((query: string) => any), getRoute?: ((params: OsmGetRouteParams) => any), mapId?: string, tileServer?: OsmTileServer, 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), getRoute?: ((params: OsmGetRouteParams) => any), mapId?: string, tileServer?: OsmTileServer, 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..52b79cd66b22 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,22 @@ import { NestedOption } from 'devextreme-angular/core';
providers: [NestedOptionHost]
})
export class DxoMapProviderConfigComponent extends NestedOption implements OnDestroy, OnInit {
+ @Input()
+ get geocodeLocation(): ((query: string) => any) {
+ return this._getOption('geocodeLocation');
+ }
+ set geocodeLocation(value: ((query: string) => any)) {
+ this._setOption('geocodeLocation', value);
+ }
+
+ @Input()
+ get getRoute(): ((params: OsmGetRouteParams) => any) {
+ return this._getOption('getRoute');
+ }
+ set getRoute(value: ((params: OsmGetRouteParams) => any)) {
+ this._setOption('getRoute', value);
+ }
+
@Input()
get mapId(): string {
return this._getOption('mapId');
@@ -38,6 +55,14 @@ export class DxoMapProviderConfigComponent extends NestedOption implements OnDes
this._setOption('mapId', value);
}
+ @Input()
+ get tileServer(): OsmTileServer {
+ return this._getOption('tileServer');
+ }
+ set tileServer(value: OsmTileServer) {
+ 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..4a8b10676c60 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,22 @@ import { NestedOption } from 'devextreme-angular/core';
providers: [NestedOptionHost]
})
export class DxoProviderConfigComponent extends NestedOption implements OnDestroy, OnInit {
+ @Input()
+ get geocodeLocation(): Function {
+ return this._getOption('geocodeLocation');
+ }
+ set geocodeLocation(value: Function) {
+ this._setOption('geocodeLocation', value);
+ }
+
+ @Input()
+ get getRoute(): Function {
+ return this._getOption('getRoute');
+ }
+ set getRoute(value: Function) {
+ this._setOption('getRoute', value);
+ }
+
@Input()
get mapId(): string {
return this._getOption('mapId');
@@ -38,6 +55,14 @@ export class DxoProviderConfigComponent extends NestedOption implements OnDestro
this._setOption('mapId', value);
}
+ @Input()
+ get tileServer(): OsmTileServer {
+ return this._getOption('tileServer');
+ }
+ set tileServer(value: OsmTileServer) {
+ 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..36def26068ae 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,10 @@ const Marker = Object.assign(_comp
// owners:
// Map
type IProviderConfigProps = React.PropsWithChildren<{
+ geocodeLocation?: ((query: string) => any);
+ getRoute?: ((params: OsmGetRouteParams) => any);
mapId?: string;
+ tileServer?: OsmTileServer;
useAdvancedMarkers?: boolean;
}>
const _componentProviderConfig = (props: IProviderConfigProps) => {
diff --git a/packages/devextreme-vue/src/map.ts b/packages/devextreme-vue/src/map.ts
index 7e1c8fecdc49..b1cb9ff4059b 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,17 @@ const DxProviderConfigConfig = {
emits: {
"update:isActive": null,
"update:hoveredElement": null,
+ "update:geocodeLocation": null,
+ "update:getRoute": 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)>,
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 bffa402e3518..cb20e11bbbe5 100644
--- a/packages/devextreme/eslint.config.mjs
+++ b/packages/devextreme/eslint.config.mjs
@@ -27,6 +27,16 @@ const compat = new FlatCompat({
allConfig: js.configs.all
});
+// Allow OSM/Leaflet domain identifiers used by the Map's OpenStreetMap provider
+// (feature/library names that cannot be renamed: the `osm` provider, Leaflet's `latlng`
+// event field, and the `subdomains` tile-layer option).
+const spellCheckerRule = spellCheckConfig
+ .map((config) => config?.rules?.['spellcheck/spell-checker'])
+ .find((rule) => Array.isArray(rule) && Array.isArray(rule[1]?.skipWords));
+if (spellCheckerRule) {
+ spellCheckerRule[1].skipWords.push('osm', 'latlng', 'subdomains');
+}
+
export default [
{
ignores: [
diff --git a/packages/devextreme/js/__internal/ui/map/map.ts b/packages/devextreme/js/__internal/ui/map/map.ts
index 8b987349f994..613b8a214490 100644
--- a/packages/devextreme/js/__internal/ui/map/map.ts
+++ b/packages/devextreme/js/__internal/ui/map/map.ts
@@ -21,6 +21,7 @@ import type { LocationOption } from './provider.dynamic';
import azure from './provider.dynamic.azure';
import bing from './provider.dynamic.bing';
import google from './provider.dynamic.google';
+import osm from './provider.dynamic.osm';
// NOTE external urls must have protocol explicitly specified
// (because inside Cordova package the protocol is "file:")
import googleStatic from './provider.google_static';
@@ -30,6 +31,7 @@ const PROVIDERS = {
googleStatic,
google,
bing,
+ osm,
};
const MAP_CLASS = 'dx-map';
@@ -54,7 +56,7 @@ class Map extends Widget {
_lastAsyncAction!: Promise;
- _provider!: azure | googleStatic | google | bing;
+ _provider!: azure | googleStatic | google | bing | osm;
_asyncActionSuppressed?: boolean;
@@ -262,7 +264,7 @@ class Map extends Widget {
}
_optionChanged(args: OptionChanged): void {
- const { name, value } = args;
+ const { name, fullName, value } = args;
const changeBag = this._optionChangeBag;
this._optionChangeBag = null;
@@ -336,8 +338,15 @@ class Map extends Widget {
this._queueAsyncAction('updateMarkers', this._rendered.markers, this._rendered.markers);
break;
case 'providerConfig':
- this._suppressAsyncAction = true;
- this._invalidate();
+ // The OSM tile server can be swapped at runtime without recreating the map.
+ // Any other providerConfig change requires a full provider re-initialization.
+ if (fullName === 'providerConfig.tileServer') {
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ this._queueAsyncAction('updateTileServer');
+ } else {
+ this._suppressAsyncAction = true;
+ this._invalidate();
+ }
break;
case 'onReady':
case 'onUpdated':
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..c3d6b55de296
--- /dev/null
+++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts
@@ -0,0 +1,593 @@
+/* eslint-disable @typescript-eslint/no-misused-promises */
+import Color from '@js/color';
+import { noop } from '@js/core/utils/common';
+import { isDefined } from '@js/core/utils/type';
+import { getWindow } from '@js/core/utils/window';
+import type { 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';
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+declare let L: any;
+
+const window = getWindow();
+
+let LEAFLET_JS_URL = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
+let LEAFLET_CSS_URL = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
+
+const DEFAULT_MAX_ZOOM = 19;
+const DEFAULT_SUBDOMAINS = 'abc';
+
+interface OsmTileServerConfig {
+ url: string;
+ attribution?: string;
+ subdomains?: string | string[];
+ maxZoom?: number;
+}
+
+type OsmTileServerObject = OsmTileServerConfig
+ | ((type: string) => string | OsmTileServerConfig | null | undefined);
+type OsmTileServerOption = string | OsmTileServerObject;
+
+export type OsmLocation = PlainLocation;
+
+// @ts-expect-error ts-error
+const osmMapsLoaded = (): boolean => Boolean(window.L?.map);
+
+// eslint-disable-next-line @typescript-eslint/init-declarations
+let osmMapsLoader: Promise | undefined;
+
+class OsmProvider extends DynamicProvider {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ _tileLayer?: any;
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ _zoomControl?: any;
+
+ _currentTileType!: string;
+
+ _clickHandler?: (e: { latlng: OsmLocation; originalEvent: Event }) => void;
+
+ _viewChangeHandler?: () => void;
+
+ _preventZoomChangeEvent?: boolean;
+
+ _movementMode(type: RouteMode | string = ''): string {
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ return type || 'driving';
+ }
+
+ _resolveLocation(location?: LocationOption | null): Promise {
+ return new Promise((resolve) => {
+ const latLng = this._getLatLng(location);
+ if (latLng) {
+ resolve(L.latLng(latLng.lat, latLng.lng));
+ } else {
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ this._geocodeLocation(location as string).then((geocodedLocation) => {
+ resolve(geocodedLocation as OsmLocation);
+ });
+ }
+ });
+ }
+
+ _geocodeLocationImpl(location: string): Promise {
+ return new Promise((resolve) => {
+ if (!isDefined(location)) {
+ resolve(L.latLng(0, 0));
+ return;
+ }
+
+ const geocodeFn = this._option('providerConfig')?.geocodeLocation as ((query: string) => Promise<{ lat: number; lng: number } | null | undefined>) | undefined;
+
+ if (!geocodeFn) {
+ errors.log('W1031', location);
+ resolve(L.latLng(0, 0));
+ return;
+ }
+
+ geocodeFn(location).then((result) => {
+ if (result?.lat != null && result?.lng != null) {
+ resolve(L.latLng(result.lat, result.lng));
+ } else {
+ resolve(L.latLng(0, 0));
+ }
+ }).catch(() => {
+ resolve(L.latLng(0, 0));
+ });
+ });
+ }
+
+ _normalizeLocation(location: OsmLocation): { lat: number; lng: number } {
+ return {
+ lat: location.lat,
+ lng: location.lng,
+ };
+ }
+
+ _loadImpl(): Promise {
+ return new Promise((resolve, reject) => {
+ if (osmMapsLoaded()) {
+ resolve();
+ return;
+ }
+
+ const fail = (): void => { reject(errors.Error('W1033')); };
+
+ osmMapsLoader ??= this._loadMapResources();
+
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ osmMapsLoader.then(() => {
+ if (osmMapsLoaded()) {
+ resolve();
+ return;
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ this._loadMapResources().then(() => {
+ if (osmMapsLoaded()) {
+ resolve();
+ } else {
+ fail();
+ }
+ }, fail);
+ }, fail);
+ });
+ }
+
+ _loadMapResources(): Promise {
+ return Promise.all([
+ this._loadMapScript(),
+ this._loadMapStyles(),
+ ]).then(() => {});
+ }
+
+ _loadMapScript(): Promise {
+ return new Promise((resolve, reject) => {
+ const script = window.document.createElement('script');
+ script.async = true;
+ script.onload = (): void => { resolve(); };
+ script.onerror = (): void => { reject(); };
+ script.src = LEAFLET_JS_URL;
+ window.document.head.appendChild(script);
+ });
+ }
+
+ _loadMapStyles(): Promise {
+ return new Promise((resolve, reject) => {
+ const link = window.document.createElement('link');
+ link.rel = 'stylesheet';
+ link.onload = (): void => { resolve(); };
+ link.onerror = (): void => { reject(); };
+ link.href = LEAFLET_CSS_URL;
+ window.document.head.appendChild(link);
+ });
+ }
+
+ _init(): Promise {
+ return this._resolveLocation(this._option('center')).then((center) => {
+ const type = this._option('type') ?? 'roadmap';
+
+ this._map = L.map(this._$container[0], {
+ center,
+ zoom: this._option('zoom'),
+ zoomControl: false,
+ attributionControl: true,
+ });
+
+ this._zoomControl = L.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: string): OsmTileServerConfig | undefined {
+ const option = this._option('providerConfig')?.tileServer as OsmTileServerOption | null | undefined;
+
+ const resolved = typeof option === 'function' ? option(type) : option;
+
+ if (!resolved) {
+ return undefined;
+ }
+
+ return typeof resolved === 'string' ? { url: resolved } : resolved;
+ }
+
+ _buildTileLayer(type: string): unknown {
+ 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}')) {
+ options.subdomains = config.subdomains ?? DEFAULT_SUBDOMAINS;
+ }
+
+ return L.tileLayer(config.url, options);
+ }
+
+ _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 || currentCenter.lat !== center.lat || currentCenter.lng !== center.lng) {
+ this._option('center', center);
+ }
+
+ if (!this._preventZoomChangeEvent) {
+ this._option('zoom', this._map.getZoom());
+ }
+ }
+
+ _onMapClick(e: { latlng: OsmLocation; originalEvent: Event }): void {
+ this._fireClickAction({
+ 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._currentTileType = type;
+ this._rebuildTileLayer(type);
+ }
+
+ return Promise.resolve();
+ }
+
+ updateTileServer(): Promise {
+ this._rebuildTileLayer(this._currentTileType);
+
+ return Promise.resolve();
+ }
+
+ _rebuildTileLayer(type: string): void {
+ if (this._tileLayer) {
+ this._map.removeLayer(this._tileLayer);
+ }
+ this._tileLayer = this._buildTileLayer(type);
+ this._tileLayer?.addTo(this._map);
+ }
+
+ 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(L.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();
+ }
+
+ _anchorIconBottomCenter(marker: unknown): void {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const leafletMarker = marker as any;
+ const iconElement = leafletMarker.getElement?.() ?? leafletMarker._icon;
+ if (!iconElement || typeof iconElement.addEventListener !== 'function') {
+ return;
+ }
+ const applyAnchor = (): void => {
+ const iconWidth = iconElement.naturalWidth;
+ const iconHeight = iconElement.naturalHeight;
+ if (!iconWidth || !iconHeight) {
+ return;
+ }
+ iconElement.style.marginLeft = `${-iconWidth / 2}px`;
+ iconElement.style.marginTop = `${-iconHeight}px`;
+
+ const popup = leafletMarker.getPopup?.();
+ if (popup) {
+ popup.options.offset = L.point(0, -iconHeight);
+ if (leafletMarker.isPopupOpen()) {
+ leafletMarker.openPopup();
+ }
+ }
+ };
+ if (iconElement.complete && iconElement.naturalWidth) {
+ applyAnchor();
+ } else {
+ iconElement.addEventListener('load', applyAnchor, { once: true });
+ }
+ }
+
+ _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 = L.divIcon({
+ html: options.html,
+ className: '',
+ iconAnchor: options.htmlOffset
+ ? [-options.htmlOffset.left, -options.htmlOffset.top]
+ : [0, 0],
+ });
+ marker = L.marker(location, { icon: divIcon });
+ } else if (icon) {
+ const customIcon = L.icon({
+ iconUrl: icon,
+ className: 'dx-map-marker',
+ });
+ marker = L.marker(location, { icon: customIcon });
+ } else {
+ marker = L.marker(location);
+ }
+
+ marker.addTo(this._map);
+
+ const popup = this._renderTooltip(marker, options.tooltip);
+
+ if (icon && !options.html) {
+ this._anchorIconBottomCenter(marker);
+ }
+
+ // eslint-disable-next-line @typescript-eslint/init-declarations
+ let clickHandler: (() => void) | undefined;
+ if (options.onClick || options.tooltip) {
+ const markerClickAction = this._mapWidget._createAction(options.onClick ?? noop);
+ const normalizedLocation = this._normalizeLocation(location);
+
+ clickHandler = (): void => {
+ markerClickAction({ location: normalizedLocation });
+
+ if (popup) {
+ if (marker.isPopupOpen()) {
+ marker.closePopup();
+ } else {
+ marker.openPopup();
+ }
+ }
+ };
+
+ 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 = L.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) {
+ // eslint-disable-next-line no-restricted-globals
+ setTimeout(() => { leafletMarker.openPopup(); }, 0);
+ }
+
+ 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) => new Promise((resolve) => {
+ // 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 as ((params: { locations: { lat: number; lng: number }[]; mode: string }) => Promise<[number, number][]>) | undefined;
+
+ const drawPolyline = (coords: [number, number][]): void => {
+ const polyline = L.polyline(coords, polylineOptions).addTo(this._map);
+ const routeBounds = polyline.getBounds();
+
+ resolve({
+ instance: polyline,
+ northEast: [routeBounds.getNorthEast().lat, routeBounds.getNorthEast().lng],
+ southWest: [routeBounds.getSouthWest().lat, routeBounds.getSouthWest().lng],
+ });
+ };
+
+ if (!getRouteFn) {
+ drawPolyline(resolvedLocations.map((loc) => [loc.lat, loc.lng] as [number, number]));
+ return;
+ }
+
+ getRouteFn({ locations: normalizedLocations, mode }).then((coords) => {
+ drawPolyline(coords);
+ }).catch((e: unknown) => {
+ errors.log('W1006', e);
+
+ 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);
+
+ 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)
+ ? L.latLng(loc[0], loc[1])
+ : L.latLng(loc.lat, loc.lng);
+
+ if (this._bounds) {
+ this._bounds.extend(latLng);
+ } else {
+ this._bounds = L.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();
+ }
+}
+
+/// #DEBUG
+// @ts-expect-error ts-error
+OsmProvider.remapConstant = (newValue: string): void => {
+ LEAFLET_JS_URL = newValue;
+ LEAFLET_CSS_URL = newValue;
+};
+
+/// #ENDDEBUG
+
+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..c5b11850b4ba 100644
--- a/packages/devextreme/js/__internal/ui/map/provider.ts
+++ b/packages/devextreme/js/__internal/ui/map/provider.ts
@@ -57,6 +57,10 @@ class Provider {
Class.abstract();
}
+ updateTileServer(): Promise {
+ return Promise.resolve();
+ }
+
updateDisabled(): void {
Class.abstract();
}
@@ -173,7 +177,9 @@ class Provider {
return key;
}
if (isPlainObject(key)) {
- return key[providerName] ?? '';
+ // apiKey only carries keyed providers (azure/bing/google/googleStatic); other providers
+ // such as 'osm' simply have no entry, so index through a string-keyed view.
+ return (key as Record)[providerName] ?? '';
}
return '';
}
diff --git a/packages/devextreme/js/ui/map.d.ts b/packages/devextreme/js/ui/map.d.ts
index 547e62a96f0f..4c73c12a0248 100644
--- a/packages/devextreme/js/ui/map.d.ts
+++ b/packages/devextreme/js/ui/map.d.ts
@@ -14,9 +14,64 @@ 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
+ */
+export type OsmGeocodeFunction = (query: string) => Promise;
+
+/**
+ * @docid
+ * @public
+ */
+export interface OsmGetRouteParams {
+ locations: Array;
+ mode: RouteMode | string;
+}
+
+/**
+ * @docid
+ * @public
+ */
+export type OsmGetRouteFunction = (params: OsmGetRouteParams) => Promise>;
+
+/**
+ * @docid
+ * @public
+ */
+export interface OsmTileServerConfig {
+ /**
+ * @docid
+ */
+ url: string;
+ /**
+ * @docid
+ * @default ''
+ */
+ attribution?: string;
+ /**
+ * @docid
+ * @default 'abc'
+ */
+ subdomains?: string | Array;
+ /**
+ * @docid
+ * @default 19
+ */
+ maxZoom?: number;
+}
+
+/**
+ * @docid
+ * @public
+ */
+export type OsmTileServer = string | OsmTileServerConfig
+| ((type: MapType | string) => string | OsmTileServerConfig | null | undefined);
+
/** @public */
export type MapType = 'hybrid' | 'roadmap' | 'satellite';
@@ -336,6 +391,24 @@ export interface dxMapOptions extends WidgetOptions {
* @deprecated
*/
useAdvancedMarkers?: boolean;
+ /**
+ * @docid
+ * @public
+ * @default undefined
+ */
+ tileServer?: OsmTileServer;
+ /**
+ * @docid
+ * @public
+ * @default undefined
+ */
+ geocodeLocation?: ((query: string) => Promise);
+ /**
+ * @docid
+ * @public
+ * @default undefined
+ */
+ getRoute?: ((params: OsmGetRouteParams) => Promise>);
};
/**
* @docid
diff --git a/packages/devextreme/js/ui/map_types.d.ts b/packages/devextreme/js/ui/map_types.d.ts
index 2c1637b47f4d..a4a397278a97 100644
--- a/packages/devextreme/js/ui/map_types.d.ts
+++ b/packages/devextreme/js/ui/map_types.d.ts
@@ -1,6 +1,11 @@
export {
MapProvider,
RouteMode,
+ OsmGeocodeFunction,
+ OsmGetRouteParams,
+ 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..ca958fe3296f 100644
--- a/packages/devextreme/js/ui/widget/ui.errors.js
+++ b/packages/devextreme/js/ui/widget/ui.errors.js
@@ -406,4 +406,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: 'The OSM map provider failed to load the Leaflet library. Make sure the Leaflet script and stylesheet URLs are reachable.',
});
diff --git a/packages/devextreme/testing/helpers/forMap/leafletMock.js b/packages/devextreme/testing/helpers/forMap/leafletMock.js
new file mode 100644
index 000000000000..2c6665e5b072
--- /dev/null
+++ b/packages/devextreme/testing/helpers/forMap/leafletMock.js
@@ -0,0 +1,258 @@
+/* eslint-disable spellcheck/spell-checker -- Leaflet/geo domain terms in the test mock (e.g. Polylines, lngs) */
+(() => {
+ 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) {
+ L.fitBoundsArg = bounds;
+ if(bounds && bounds.getNorthEast) {
+ this._center = {
+ lat: (bounds.getNorthEast().lat + bounds.getSouthWest().lat) / 2,
+ lng: (bounds.getNorthEast().lng + bounds.getSouthWest().lng) / 2,
+ };
+ }
+ },
+ 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);
+ },
+
+ // --- Marker ---
+ marker: function(latLng, options) {
+ L.markerOptions = options;
+ L.lastMarkerLatLng = latLng;
+
+ const marker = {
+ _latLng: latLng,
+ _popup: null,
+ _popupOpen: false,
+ _handlers: {},
+
+ 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] = handler;
+ if(event === 'click') {
+ L.markerClickCallback = handler;
+ }
+ },
+ off(event) {
+ delete this._handlers[event];
+ },
+ bindPopup(popup) {
+ this._popup = popup;
+ L.boundPopup = popup;
+ 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 = {
+ _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) {
+ L.addedPolylines = L.addedPolylines || [];
+ L.addedPolylines.push(polyline);
+ return polyline;
+ },
+ remove() {
+ L.removedPolylines = L.removedPolylines || [];
+ L.removedPolylines.push(polyline);
+ },
+ getBounds() {
+ if(!coords || coords.length === 0) {
+ return makeBounds({ lat: 0, lng: 0 }, { lat: 0, lng: 0 });
+ }
+ const lats = coords.map(c => c[0]);
+ const lngs = coords.map(c => c[1]);
+ return makeBounds(
+ { lat: Math.min(...lats), lng: Math.min(...lngs) },
+ { 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..7c33ad600862
--- /dev/null
+++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js
@@ -0,0 +1,1067 @@
+/* global L */
+/* eslint-disable spellcheck/spell-checker -- Leaflet/geo domain terms in tests (e.g. Polylines) */
+
+import $ from 'jquery';
+import { MARKERS, ROUTES } from './utils.js';
+import OsmProvider from '__internal/ui/map/provider.dynamic.osm';
+import errors from 'ui/widget/ui.errors';
+
+import 'ui/map';
+
+const prepareTestingOsmProvider = () => {
+ L.mapCreated = false;
+ L.mapDisposed = false;
+ L.mapResized = false;
+ L.mapOptions = null;
+ L.setViewArgs = null;
+ L.setZoomArg = null;
+ L.fitBoundsArg = null;
+ L.addedMarkers = [];
+ L.removedMarkers = [];
+ L.addedPolylines = [];
+ 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.boundPopup = null;
+ L.popupContent = null;
+};
+
+const moduleConfig = {
+ beforeEach: function() {
+ const fakeUrl = '/fakeLeafletUrl';
+ let leafletMockLoaded = false;
+
+ OsmProvider.remapConstant(fakeUrl);
+
+ // The provider loads Leaflet via CSP-compliant