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 + + + + + + + + + + + + + +
+
+
+
Options
+
+ Set mode +
+
+
+ Route color +
+
+
+
+ + 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); +});