Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions apps/demos/Demos/Map/OpenStreetMapProvider/client-script.js
Original file line number Diff line number Diff line change
@@ -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('<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256"><rect width="256" height="256" fill="#eee"/></svg>')}`;
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);
};
})();
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions apps/demos/Demos/Map/OpenStreetMapProvider/jQuery/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<title>DevExtreme Demo</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0" />
<meta name="referrer" content="strict-origin-when-cross-origin" />
Comment thread
AlisherAmonulloev marked this conversation as resolved.
<script src="../../../../node_modules/jquery/dist/jquery.min.js"></script>
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/ol@10.10.0/ol.css" />
<script src="https://cdn.jsdelivr.net/npm/ol@10.10.0/dist/ol.js"></script>
<link rel="stylesheet" type="text/css" href="../../../../node_modules/devextreme-dist/css/dx.light.css" />
<script src="../../../../node_modules/devextreme-dist/js/dx.all.js"></script>
<link rel="stylesheet" type="text/css" href="styles.css" />
<script src="index.js"></script>
</head>
<body class="dx-viewport">
<div class="demo-container">
<div id="map"></div>
<div class="options">
<div class="caption">Options</div>
<div class="option">
<span>Set mode</span>
<div id="choose-mode"></div>
</div>
<div class="option">
<span>Route color</span>
<div id="choose-color"></div>
</div>
</div>
</div>
</body>
</html>
116 changes: 116 additions & 0 deletions apps/demos/Demos/Map/OpenStreetMapProvider/jQuery/index.js
Original file line number Diff line number Diff line change
@@ -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: '&copy; <a href="https://stadiamaps.com/attribution/" target="_blank" rel="noopener">Stadia Maps</a> '
+ '&copy; <a href="https://openmaptiles.org/" target="_blank" rel="noopener">OpenMapTiles</a> '
+ '&copy; <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap contributors</a>',
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 }));
},
Comment thread
AlisherAmonulloev marked this conversation as resolved.
markers: [{
location: '40.7825, -73.966111',
tooltip: { text: 'Central Park' },
}, {
location: [40.755833, -73.986389],
iconSrc: '../../../../images/Maps/map-marker.png',
Comment thread
AlisherAmonulloev marked this conversation as resolved.
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 }]);
},
});
});
44 changes: 44 additions & 0 deletions apps/demos/Demos/Map/OpenStreetMapProvider/jQuery/styles.css
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 1 addition & 2 deletions apps/demos/menuMeta.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions apps/demos/utils/server/csp-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
Expand Down Expand Up @@ -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': {
Expand Down
99 changes: 99 additions & 0 deletions apps/demos/utils/tests/server/osm-client-script.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
Loading