diff --git a/apps/demos/Demos/Map/OpenStreetMapProvider/client-script.js b/apps/demos/Demos/Map/OpenStreetMapProvider/client-script.js
new file mode 100644
index 000000000000..34df58e14996
--- /dev/null
+++ b/apps/demos/Demos/Map/OpenStreetMapProvider/client-script.js
@@ -0,0 +1,26 @@
+// Loaded only by the demo test runners. Do not send CI requests to Stadia Maps.
+(() => {
+ const tileImage = `data:image/svg+xml,${encodeURIComponent('')}`;
+ const imageSource = Object.getOwnPropertyDescriptor(window.HTMLImageElement.prototype, 'src');
+
+ Object.defineProperty(window.HTMLImageElement.prototype, 'src', {
+ ...imageSource,
+ set(value) {
+ const isDemoTile = /^https:\/\/tiles-eu\.stadiamaps\.com\/tiles\/alidade_bright\/\d+\/\d+\/\d+\.png(?:\?.*)?$/.test(String(value));
+ imageSource.set.call(this, isDemoTile ? tileImage : value);
+ },
+ });
+
+ const nativeFetch = window.fetch;
+ window.fetch = function fetch(input, options) {
+ const url = input instanceof window.Request ? input.url : String(input);
+ if (/^https:\/\/api-eu\.stadiamaps\.com\/route\/v1(?:\?.*)?$/.test(url)) {
+ // Polyline6 connecting the demo's four waypoints; not real routing service data.
+ const shape = 'gidxlA|`palCtas@jrf@nxBowH||mAddn@';
+ return Promise.resolve(new window.Response(JSON.stringify({ trip: { legs: [{ shape }] } }), {
+ headers: { 'Content-Type': 'application/json' },
+ }));
+ }
+ return nativeFetch.call(this, input, options);
+ };
+})();
diff --git a/apps/demos/Demos/Map/OpenStreetMapProvider/description.md b/apps/demos/Demos/Map/OpenStreetMapProvider/description.md
index 23a024e7ba56..e69de29bb2d1 100644
--- a/apps/demos/Demos/Map/OpenStreetMapProvider/description.md
+++ b/apps/demos/Demos/Map/OpenStreetMapProvider/description.md
@@ -1 +0,0 @@
-We expect to add [OpenStreetMap](https://www.openstreetmap.org/) (OSM) support to the DevExtreme [Map](/Documentation/Guide/UI_Components/Map/Overview/) component. The new provider will allow you to connect the Map to OSM-compatible tile, geocoding, and routing services, including commercial providers and self-hosted infrastructure.
diff --git a/apps/demos/Demos/Map/OpenStreetMapProvider/jQuery/index.html b/apps/demos/Demos/Map/OpenStreetMapProvider/jQuery/index.html
new file mode 100644
index 000000000000..27562512d160
--- /dev/null
+++ b/apps/demos/Demos/Map/OpenStreetMapProvider/jQuery/index.html
@@ -0,0 +1,33 @@
+
+
+
+ DevExtreme Demo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/demos/Demos/Map/OpenStreetMapProvider/jQuery/index.js b/apps/demos/Demos/Map/OpenStreetMapProvider/jQuery/index.js
new file mode 100644
index 000000000000..4b3f7ab9c5e6
--- /dev/null
+++ b/apps/demos/Demos/Map/OpenStreetMapProvider/jQuery/index.js
@@ -0,0 +1,116 @@
+$(() => {
+ const routeCache = new Map();
+
+ async function calculateRoute({ locations, mode }) {
+ const body = JSON.stringify({
+ locations: locations.map(({ lat, lng }) => ({ lat, lon: lng })), // eslint-disable-line spellcheck/spell-checker
+ costing: mode === 'walking' ? 'pedestrian' : 'auto',
+ directions_type: 'none',
+ });
+
+ if (routeCache.has(body)) {
+ return routeCache.get(body);
+ }
+
+ const response = await fetch('https://api-eu.stadiamaps.com/route/v1', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body,
+ });
+ if (!response.ok) {
+ throw new Error(`Stadia Maps routing failed: HTTP ${response.status}`);
+ }
+
+ const { trip } = await response.json();
+ if (!trip?.legs?.length) {
+ throw new Error('Stadia Maps returned no route.');
+ }
+ const format = new ol.format.Polyline({ factor: 1e6 });
+ const route = {
+ type: 'LineString',
+ coordinates: trip.legs.flatMap((leg) => format.readGeometry(leg.shape).getCoordinates()),
+ };
+ routeCache.set(body, route);
+ return route;
+ }
+
+ const map = $('#map').dxMap({
+ provider: 'osm',
+ providerConfig: {
+ // Demo only: tiles are served under Developer Express Inc's Stadia Maps subscription via domain-based authentication.
+ // This configuration will not work on your domain. Configure your own tile provider here.
+ tileServer: {
+ url: 'https://tiles-eu.stadiamaps.com/tiles/alidade_bright/{z}/{x}/{y}.png',
+ attribution: '© Stadia Maps '
+ + '© OpenMapTiles '
+ + '© OpenStreetMap contributors',
+ maxZoom: 20,
+ },
+ calculateRoute,
+ },
+ zoom: 14,
+ height: 440,
+ width: '100%',
+ controls: true,
+ onReady({ originalMap }) {
+ // Ensure attribution is visible to comply with the Stadia Maps Terms of Service.
+ const attribution = originalMap.getControls().getArray()
+ .find((control) => control instanceof ol.control.Attribution);
+ originalMap.removeControl(attribution);
+ originalMap.addControl(new ol.control.Attribution({ collapsible: false }));
+ },
+ markers: [{
+ location: '40.7825, -73.966111',
+ tooltip: { text: 'Central Park' },
+ }, {
+ location: [40.755833, -73.986389],
+ iconSrc: '../../../../images/Maps/map-marker.png',
+ tooltip: { text: 'Times Square' },
+ onClick({ tooltip }) {
+ tooltip.option({
+ showTitle: true,
+ showCloseButton: true,
+ wrapperAttr: {
+ class: 'dx-map-marker-popover compact-marker-tooltip',
+ },
+ });
+ },
+ }, {
+ location: { lat: 40.753889, lng: -73.981389 },
+ tooltip: { text: 'Fifth Avenue' },
+ }, {
+ location: [40.713474, -74.005536],
+ tooltip: { text: 'City Hall Park' },
+ }],
+ routes: [{
+ weight: 6,
+ color: 'blue',
+ opacity: 0.5,
+ mode: 'walking',
+ locations: [
+ [40.7825, -73.966111],
+ [40.755833, -73.986389],
+ [40.753889, -73.981389],
+ [40.713474, -74.005536],
+ ],
+ }],
+ }).dxMap('instance');
+
+ $('#choose-mode').dxSelectBox({
+ dataSource: ['driving', 'walking'],
+ inputAttr: { 'aria-label': 'Mode' },
+ value: 'walking',
+ onValueChanged({ value }) {
+ map.option('routes', [{ ...map.option('routes')[0], mode: value }]);
+ },
+ });
+
+ $('#choose-color').dxSelectBox({
+ dataSource: ['blue', 'green', 'red'],
+ inputAttr: { 'aria-label': 'Route color' },
+ value: 'blue',
+ onValueChanged({ value }) {
+ map.option('routes', [{ ...map.option('routes')[0], color: value }]);
+ },
+ });
+});
diff --git a/apps/demos/Demos/Map/OpenStreetMapProvider/jQuery/styles.css b/apps/demos/Demos/Map/OpenStreetMapProvider/jQuery/styles.css
new file mode 100644
index 000000000000..24bd0cd00303
--- /dev/null
+++ b/apps/demos/Demos/Map/OpenStreetMapProvider/jQuery/styles.css
@@ -0,0 +1,44 @@
+.options {
+ padding: 20px;
+ background-color: var(--dx-color-options-panel-bg);
+ margin-top: 20px;
+}
+
+.caption {
+ font-size: 18px;
+ font-weight: 500;
+}
+
+.option {
+ margin-top: 10px;
+ display: flex;
+ align-items: center;
+}
+
+.option > span {
+ display: inline-block;
+ width: 90px;
+}
+
+.option > .dx-selectbox {
+ width: 100%;
+ max-width: 350px;
+}
+
+.compact-marker-tooltip .dx-popup-title {
+ padding: 4px 8px;
+}
+
+.compact-marker-tooltip .dx-popup-title .dx-toolbar-items-container {
+ height: 24px;
+}
+
+.compact-marker-tooltip .dx-popup-title .dx-closebutton {
+ width: 24px;
+ min-width: 24px;
+ height: 24px;
+}
+
+.compact-marker-tooltip .dx-closebutton .dx-button-content {
+ padding: 1px;
+}
diff --git a/apps/demos/menuMeta.json b/apps/demos/menuMeta.json
index 4ceaddfd4ddb..a2a05382e9e7 100644
--- a/apps/demos/menuMeta.json
+++ b/apps/demos/menuMeta.json
@@ -6565,8 +6565,7 @@
"Name": "OpenStreetMapProvider",
"Widget": "Map",
"DemoType": "Web",
- "Badge": "Roadmap",
- "RoadmapSurveyUrl": "https://www.devexpress.com/support/surveys/devextreme-wg-roadmap-openstreetmap-provider.xml"
+ "Badge": "New"
},
{
"Title": "Markers",
diff --git a/apps/demos/testing/etalons/Map-OpenStreetMapProvider (fluent-next.blue.light).png b/apps/demos/testing/etalons/Map-OpenStreetMapProvider (fluent-next.blue.light).png
new file mode 100644
index 000000000000..13b18a23b440
Binary files /dev/null and b/apps/demos/testing/etalons/Map-OpenStreetMapProvider (fluent-next.blue.light).png differ
diff --git a/apps/demos/testing/etalons/Map-OpenStreetMapProvider (fluent.blue.light).png b/apps/demos/testing/etalons/Map-OpenStreetMapProvider (fluent.blue.light).png
new file mode 100644
index 000000000000..d1218c1f9d7b
Binary files /dev/null and b/apps/demos/testing/etalons/Map-OpenStreetMapProvider (fluent.blue.light).png differ
diff --git a/apps/demos/testing/etalons/Map-OpenStreetMapProvider (material.blue.light).png b/apps/demos/testing/etalons/Map-OpenStreetMapProvider (material.blue.light).png
new file mode 100644
index 000000000000..37b34682d19d
Binary files /dev/null and b/apps/demos/testing/etalons/Map-OpenStreetMapProvider (material.blue.light).png differ
diff --git a/apps/demos/utils/server/csp-server.js b/apps/demos/utils/server/csp-server.js
index f72873e246dc..f8c2899fa3b8 100644
--- a/apps/demos/utils/server/csp-server.js
+++ b/apps/demos/utils/server/csp-server.js
@@ -44,6 +44,10 @@ const CSP_DEMO_ALLOWLIST = {
'font-src': ['https://atlas.microsoft.com'],
'style-src-elem': ["'self'", "'unsafe-inline'"],
},
+ 'Map/OpenStreetMapProvider': {
+ 'img-src': ['https://tiles-eu.stadiamaps.com'],
+ 'connect-src': ['https://api-eu.stadiamaps.com'],
+ },
'DataGrid/CollaborativeEditing': {
'connect-src': ['wss://js.devexpress.com'],
},
@@ -196,6 +200,9 @@ const CSP_DEMO_ALLOWLIST = {
// Framework-specific overrides (e.g. Font Awesome loaded only in React/Vue demos)
const CSP_FRAMEWORK_ALLOWLIST = {
jQuery: {
+ 'Map/OpenStreetMapProvider': {
+ 'style-src-elem': ['https://cdn.jsdelivr.net'],
+ },
FilterBuilder: { 'font-src': ['https://maxcdn.bootstrapcdn.com'] },
// globalize/message.js uses new Function() internally
'Localization/UsingGlobalize': {
diff --git a/apps/demos/utils/tests/server/osm-client-script.test.js b/apps/demos/utils/tests/server/osm-client-script.test.js
new file mode 100644
index 000000000000..2cfa6a5dea6b
--- /dev/null
+++ b/apps/demos/utils/tests/server/osm-client-script.test.js
@@ -0,0 +1,99 @@
+const TILE_URL = 'https://tiles-eu.stadiamaps.com/tiles/alidade_bright/12/1206/1538.png';
+const ROUTE_URL = 'https://api-eu.stadiamaps.com/route/v1';
+
+let nativeFetch;
+let nativeImageSource;
+
+beforeEach(() => {
+ class TestImage {
+ get src() { return this.source; }
+
+ set src(value) { this.source = String(value); }
+ }
+
+ nativeFetch = jest.fn().mockResolvedValue('native response');
+ nativeImageSource = Object.getOwnPropertyDescriptor(TestImage.prototype, 'src');
+ global.window = {
+ HTMLImageElement: TestImage,
+ Request,
+ Response,
+ fetch: nativeFetch,
+ };
+ jest.resetModules();
+ jest.requireActual('../../../Demos/Map/OpenStreetMapProvider/client-script');
+});
+
+afterEach(() => {
+ delete global.window;
+});
+
+test.each([TILE_URL, `${TILE_URL}?test=1`, new URL(TILE_URL)])('replaces demo tile %s with a synthetic image', (url) => {
+ const image = new window.HTMLImageElement();
+ image.src = url;
+
+ expect(image.src).toMatch(/^data:image\/svg\+xml,/);
+ expect(decodeURIComponent(image.src)).toContain('width="256" height="256"');
+});
+
+test.each([
+ '/images/Maps/map-marker.png',
+ 'data:image/png;base64,test',
+ 'https://other.example/tiles/alidade_bright/12/1206/1538.png',
+ `${TILE_URL}.html`,
+ 'https://tiles-eu.stadiamaps.com/other.png',
+ 'https://tiles-eu.stadiamaps.com.example/tiles/alidade_bright/12/1206/1538.png',
+])('preserves unrelated image %s', (url) => {
+ const image = new window.HTMLImageElement();
+ image.src = url;
+
+ expect(image.src).toBe(url);
+});
+
+test('preserves the native image getter and property flags', () => {
+ const descriptor = Object.getOwnPropertyDescriptor(window.HTMLImageElement.prototype, 'src');
+
+ expect(descriptor.get).toBe(nativeImageSource.get);
+ expect(descriptor.configurable).toBe(nativeImageSource.configurable);
+ expect(descriptor.enumerable).toBe(nativeImageSource.enumerable);
+});
+
+test.each([
+ ROUTE_URL,
+ `${ROUTE_URL}?test=1`,
+ new URL(ROUTE_URL),
+ new Request(ROUTE_URL, { method: 'POST', body: '{}' }),
+])('returns a routing fixture for %s without a network request', async (input) => {
+ const response = await window.fetch(input, { method: 'POST' });
+
+ expect(response.ok).toBe(true);
+ expect(response.headers.get('Content-Type')).toBe('application/json');
+ await expect(response.json()).resolves.toEqual({
+ trip: { legs: [{ shape: 'gidxlA|`palCtas@jrf@nxBowH||mAddn@' }] },
+ });
+ expect(nativeFetch).not.toHaveBeenCalled();
+});
+
+test('creates a fresh response for each route request', async () => {
+ const first = await window.fetch(ROUTE_URL);
+ const second = await window.fetch(ROUTE_URL);
+
+ expect(first).not.toBe(second);
+ expect(await first.json()).toEqual(await second.json());
+});
+
+test.each([
+ '/local-data',
+ 'https://other.example/route/v1',
+ 'https://api-eu.stadiamaps.com/other',
+ 'https://api-eu.stadiamaps.com/route/v10',
+ 'https://api-eu.stadiamaps.com.example/route/v1',
+ new URL('https://other.example/route/v1'),
+ new Request('https://other.example/route/v1'),
+])('forwards unrelated request %s with its options and receiver', async (input) => {
+ const options = { method: 'POST', body: 'original body' };
+
+ await expect(window.fetch(input, options)).resolves.toBe('native response');
+ expect(nativeFetch).toHaveBeenCalledTimes(1);
+ expect(nativeFetch).toHaveBeenCalledWith(input, options);
+ expect(nativeFetch.mock.contexts[0]).toBe(window);
+});