From df510886e545d749744c261a638aee2737a7dbde Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Wed, 9 Sep 2026 10:01:46 +0300 Subject: [PATCH 01/14] Map: implement OSM route calculation and rendering --- .../ui/map/provider.dynamic.osm.engine.ts | 13 + .../ui/map/provider.dynamic.osm.openlayers.ts | 52 +++ ...vider.dynamic.osm.openlayers.utils.test.ts | 7 +- .../provider.dynamic.osm.openlayers.utils.ts | 36 +- .../ui/map/provider.dynamic.osm.route.test.ts | 75 ++++ .../ui/map/provider.dynamic.osm.route.ts | 54 +++ .../ui/map/provider.dynamic.osm.test.ts | 24 ++ .../__internal/ui/map/provider.dynamic.osm.ts | 111 +++++- .../js/ui/map/openlayers.register.js | 11 + .../testing/helpers/forMap/openLayersMock.js | 85 +++- .../mapParts/osmTests.js | 366 ++++++++++++++++++ 11 files changed, 820 insertions(+), 14 deletions(-) create mode 100644 packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.route.test.ts create mode 100644 packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.route.ts diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.engine.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.engine.ts index 715f20da84ec..c252fb86c07b 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.engine.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.engine.ts @@ -50,6 +50,18 @@ export interface MapEngineMarker { dispose: () => void; } +export interface MapEngineRouteOptions { + locations: MapLocation[]; + color: string; + opacity: number; + weight: number; +} + +export interface MapEngineRoute { + readonly originalRoute: unknown; + dispose: () => void; +} + export interface MapEngineEventHandlers { click: (event: MapEngineClickEvent) => void; markerSizeChange: () => void; @@ -59,6 +71,7 @@ export interface MapEngineEventHandlers { export interface MapEngineMap { readonly originalMap: unknown; addMarker: (options: MapEngineMarkerOptions) => MapEngineMarker; + addRoute: (options: MapEngineRouteOptions) => MapEngineRoute; attachHandlers: (handlers: MapEngineEventHandlers) => void; dispose: () => void; fitBounds: (bounds: MapEngineBounds, options?: MapEngineFitBoundsOptions) => void; diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts index 3286d2d3539b..bad46d9b42c0 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts @@ -1,3 +1,4 @@ +import Color from '@js/color'; import messageLocalization from '@js/common/core/localization/message'; import resizeObserverSingleton from '@js/core/resize_observer'; import { ALL_FOCUSABLE_ELEMENTS_SELECTOR } from '@ts/core/utils/m_selectors'; @@ -11,6 +12,8 @@ import type { MapEngineMap, MapEngineMarker, MapEngineMarkerOptions, + MapEngineRoute, + MapEngineRouteOptions, MapEngineSetViewOptions, MapEngineTileLayerOptions, MapEngineUpdateDimensionsResult, @@ -34,6 +37,7 @@ import type { Options, OverlayLike, TileLayerLike, + VectorSourceLike, ViewLike, } from './provider.dynamic.osm.openlayers.utils'; import { @@ -46,6 +50,7 @@ import { toCoordinate, toLocation, } from './provider.dynamic.osm.openlayers.utils'; +import { toRouteCoordinates } from './provider.dynamic.osm.route'; interface MapBrowserEventLike { coordinate?: Coordinate; @@ -105,6 +110,10 @@ class OpenLayersMap implements MapEngineMap { private _tileLayer?: TileLayerLike; + private _routeLayer?: object; + + private _routeSource?: VectorSourceLike; + private _disposed = false; private _markerFitNeedsLayout = false; @@ -160,6 +169,16 @@ class OpenLayersMap implements MapEngineMap { } this._subscribedView.un('change:center', this._viewCenterChangeHandler); + const previousProjection = getCoordinateProjection( + this._api, + this._subscribedView.getProjection(), + ); + const projection = getCoordinateProjection(this._api, view.getProjection()); + if (previousProjection !== projection) { + this._routeSource?.getFeatures().forEach((feature) => { + feature.getGeometry()?.transform(previousProjection, projection); + }); + } this._subscribedView = view; this._subscribedView.on('change:center', this._viewCenterChangeHandler); this._syncMarkerPositions(); @@ -322,6 +341,33 @@ class OpenLayersMap implements MapEngineMap { return handle; } + addRoute(options: MapEngineRouteOptions): MapEngineRoute { + const { _api: api } = this; + const geometry = new api.geom.LineString(toRouteCoordinates(options.locations)); + geometry.transform( + GEOGRAPHIC_PROJECTION, + getCoordinateProjection(api, this.originalMap.getView().getProjection()), + ); + const feature = new api.Feature(geometry); + const { r, g, b } = new Color(options.color); + feature.setStyle(new api.style.Style({ + stroke: new api.style.Stroke({ color: [r, g, b, options.opacity], width: options.weight }), + })); + + if (!this._routeSource) { + this._routeSource = new api.source.Vector(); + this._routeLayer = new api.layer.Vector({ source: this._routeSource, zIndex: 1 }); + this.originalMap.addLayer(this._routeLayer); + } + const source = this._routeSource; + source.addFeature(feature); + + return { + originalRoute: feature, + dispose: (): void => source.removeFeature(feature), + }; + } + private _getMarkerPosition(location: MapEngineMarkerOptions['location']): Coordinate { const view = this.originalMap.getView(); const projection = view.getProjection(); @@ -540,6 +586,12 @@ class OpenLayersMap implements MapEngineMap { this._subscribedView.un('change:center', this._viewCenterChangeHandler); this._removeOwnedInert(); [...this._markers].forEach((marker) => marker.dispose()); + this._routeSource?.clear(); + this._routeSource = undefined; + if (this._routeLayer) { + this.originalMap.removeLayer(this._routeLayer); + this._routeLayer = undefined; + } this.setControls(false); if (this._tileLayer) { this.originalMap.removeLayer(this._tileLayer); diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.test.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.test.ts index efbd952ccc0a..775305e209a5 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.test.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.test.ts @@ -12,9 +12,11 @@ import { } from './provider.dynamic.osm.openlayers.utils'; const createApi = (): unknown => ({ + Feature: jest.fn(), Map: jest.fn(), Overlay: jest.fn(), View: jest.fn(), + geom: { LineString: jest.fn() }, control: { Zoom: jest.fn(), defaults: { defaults: () => [] }, @@ -22,14 +24,15 @@ const createApi = (): unknown => ({ interaction: { defaults: { defaults: () => [] }, }, - layer: { Tile: jest.fn() }, + layer: { Tile: jest.fn(), Vector: jest.fn() }, proj: { getUserProjection: () => null, toLonLat: () => [0, 0], transform: () => [0, 0], transformExtent: () => [0, 0, 0, 0], }, - source: { ImageTile: jest.fn() }, + source: { ImageTile: jest.fn(), Vector: jest.fn() }, + style: { Stroke: jest.fn(), Style: jest.fn() }, }); describe('OpenLayers utils', () => { diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.ts index 12b9fa2ec38d..fe4c7653c8db 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.ts @@ -42,6 +42,22 @@ export interface OverlayLike { setPosition: (position: Coordinate) => void; } +export interface GeometryLike { + transform: (source: unknown, destination: unknown) => GeometryLike; +} + +export interface FeatureLike { + getGeometry: () => GeometryLike | undefined; + setStyle: (style: unknown) => void; +} + +export interface VectorSourceLike { + addFeature: (feature: FeatureLike) => void; + clear: () => void; + getFeatures: () => FeatureLike[]; + removeFeature: (feature: FeatureLike) => void; +} + export interface MapLike { addControl: (control: ControlLike) => void; addLayer: (layer: unknown) => void; @@ -60,9 +76,13 @@ export interface MapLike { } export interface OpenLayersApi { + Feature: new (geometry: GeometryLike) => FeatureLike; Map: new (options: Options) => MapLike; Overlay: new (options: Options) => OverlayLike; View: new (options: Options) => ViewLike; + geom: { + LineString: new (coordinates: Coordinate[]) => GeometryLike; + }; control: { Zoom: new () => ControlLike; defaults: { @@ -76,6 +96,7 @@ export interface OpenLayersApi { }; layer: { Tile: new (options: Options) => TileLayerLike; + Vector: new (options: Options) => object; }; proj: { getUserProjection: () => unknown | null; @@ -85,6 +106,11 @@ export interface OpenLayersApi { }; source: { ImageTile: new (options: Options) => unknown; + Vector: new (options?: Options) => VectorSourceLike; + }; + style: { + Stroke: new (options: Options) => unknown; + Style: new (options: Options) => unknown; }; } @@ -101,9 +127,11 @@ export const isOpenLayersApi = (api: unknown): api is OpenLayersApi => { return false; } - return typeof api.Map === 'function' + return typeof api.Feature === 'function' + && typeof api.Map === 'function' && typeof api.Overlay === 'function' && typeof api.View === 'function' + && hasFunction(api.geom, 'LineString') && isRecord(api.control) && hasFunction(api.control, 'Zoom') && isRecord(api.control.defaults) @@ -112,11 +140,15 @@ export const isOpenLayersApi = (api: unknown): api is OpenLayersApi => { && isRecord(api.interaction.defaults) && hasFunction(api.interaction.defaults, 'defaults') && hasFunction(api.layer, 'Tile') + && hasFunction(api.layer, 'Vector') && hasFunction(api.proj, 'getUserProjection') && hasFunction(api.proj, 'toLonLat') && hasFunction(api.proj, 'transform') && hasFunction(api.proj, 'transformExtent') - && hasFunction(api.source, 'ImageTile'); + && hasFunction(api.source, 'ImageTile') + && hasFunction(api.source, 'Vector') + && hasFunction(api.style, 'Stroke') + && hasFunction(api.style, 'Style'); }; export const getCoordinateProjection = ( diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.route.test.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.route.test.ts new file mode 100644 index 000000000000..8aeb3cbc402f --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.route.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from '@jest/globals'; + +import { getRouteLocations, getRouteLongitudeRange, toRouteCoordinates } from './provider.dynamic.osm.route'; + +describe('OSM route results', () => { + const locations = [{ lat: 40.7, lng: -74 }, { lat: 40.8, lng: -73.9 }]; + + it('reads latitude-longitude tuples without modifying the result', () => { + const result = Object.freeze([Object.freeze([40.7, -74]), Object.freeze([40.8, -73.9])]); + expect(getRouteLocations(result)).toEqual(locations); + }); + + it('reads GeoJSON longitude-latitude positions and ignores elevation', () => { + expect(getRouteLocations({ + type: 'LineString', + coordinates: [[-74, 40.7, 20], [-73.9, 40.8, 30]], + })).toEqual(locations); + }); + + it.each([false, true])('accepts latitude limits and wrapped longitudes (GeoJSON: %s)', (isGeoJson) => { + const coordinates = [[-90, -540], [90, 540]]; + const result = isGeoJson + ? { type: 'LineString', coordinates: coordinates.map(([lat, lng]) => [lng, lat]) } + : coordinates; + + expect(getRouteLocations(result)).toEqual([{ lat: -90, lng: -540 }, { lat: 90, lng: 540 }]); + }); + + it.each([-90.001, 90.001])('rejects latitude %s in either coordinate order', (latitude) => { + expect(getRouteLocations([[0, 10], [latitude, 11]])).toBeUndefined(); + expect(getRouteLocations({ + type: 'LineString', coordinates: [[10, 0], [11, latitude]], + })).toBeUndefined(); + }); + + it.each([ + undefined, null, '', {}, [], [[40.7, -74]], + [[40.7, -74], [NaN, -73]], [[40.7, -74], [40.8, Infinity]], + [[40.7, -74], ['40.8', -73]], [[40.7, -74], [40.8]], + [[40.7, -74], [40.8, -73, 0]], [[40.7, -74], null], + new Array(2), [[40.7, -74], new Array(2)], + { type: 'LineString', coordinates: [[-74, 40.7]] }, + { type: 'LineString', coordinates: [[-74, 40.7], []] }, + { type: 'MultiLineString', coordinates: [[[-74, 40.7], [-73, 40.8]]] }, + { type: 'Feature', geometry: { type: 'LineString', coordinates: [[-74, 40.7], [-73, 40.8]] } }, + ])('rejects an unsupported result: %j', (result) => { + expect(getRouteLocations(result)).toBeUndefined(); + }); +}); + +describe('toRouteCoordinates', () => { + it.each([ + [[179, -179], [179, 181]], + [[-179, 179], [-179, -181]], + [[-74, -73], [-74, -73]], + [[-120, 0, 120], [-120, 0, 120]], + ])('keeps consecutive longitudes %j in the nearest world', (longitudes, expected) => { + expect(toRouteCoordinates(longitudes.map((lng) => ({ lat: 10, lng })))) + .toEqual(expected.map((lng) => [lng, 10])); + }); +}); + +describe('getRouteLongitudeRange', () => { + it.each([ + [[-74, -73, -75], [-75, -73]], + [[179, -179], [179, 181]], + [[-179, 179], [-181, -179]], + [[-120, 0, 120], [-120, 120]], + [[0, 120, -120, 0], [0, 360]], + [[10, 10], [10, 10]], + ])('finds the continuous range of longitudes %j', (longitudes, expected) => { + expect(getRouteLongitudeRange(longitudes.map((lng) => ({ lat: 10, lng })))) + .toEqual(expected); + }); +}); diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.route.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.route.ts new file mode 100644 index 000000000000..97ff243d3b9b --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.route.ts @@ -0,0 +1,54 @@ +import type { MapLocation } from '@js/ui/map'; + +export const getRouteLocations = (result: unknown): MapLocation[] | undefined => { + const isGeoJson = result !== null && typeof result === 'object' + && 'type' in result && result.type === 'LineString'; + const coordinates = isGeoJson && 'coordinates' in result + ? result.coordinates + : result; + + if (!Array.isArray(coordinates) || coordinates.length < 2) { + return undefined; + } + + const locations: MapLocation[] = []; + for (const coordinate of coordinates) { + if (!Array.isArray(coordinate) + || (isGeoJson ? coordinate.length < 2 : coordinate.length !== 2) + || !Number.isFinite(coordinate[0]) || !Number.isFinite(coordinate[1])) { + return undefined; + } + + const [first, second] = coordinate as [number, number]; + const location = isGeoJson ? { lat: second, lng: first } : { lat: first, lng: second }; + if (location.lat < -90 || location.lat > 90) { + return undefined; + } + locations.push(location); + } + + return locations; +}; + +export const toRouteCoordinates = (locations: MapLocation[]): [number, number][] => { + let previousLongitude = locations[0].lng; + + return locations.map(({ lat, lng }) => { + const longitude = lng + Math.round((previousLongitude - lng) / 360) * 360; + previousLongitude = longitude; + + return [longitude, lat]; + }); +}; + +export const getRouteLongitudeRange = (locations: MapLocation[]): [number, number] => { + let west = locations[0].lng; + let east = west; + + for (const [longitude] of toRouteCoordinates(locations)) { + west = Math.min(west, longitude); + east = Math.max(east, longitude); + } + + return [west, east]; +}; diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.test.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.test.ts index 9c0bcd76e22e..69502e190b90 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.test.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.test.ts @@ -102,5 +102,29 @@ describe('OSM bounds', () => { { northEast: { lat: 0, lng: 120 }, southWest: { lat: 0, lng: -120 } }, ]).toContainEqual(bounds); }); + + it.each([ + [[[-120, 0, 120]], [], -120, 120], + [[[120, 0, -120]], [], -120, 120], + [[[179, -179]], [178], 178, -179], + [[[-179, 179]], [-178], 179, -178], + [[[170, -175], [-170, 175]], [], 170, -170], + [[[-120, 0, 120], [120, -120]], [], -180, 180], + [[[0, 120, -120, 0]], [], -180, 180], + [[[10, 10]], [], 10, 10], + [[[]], [15], 15, 15], + ] as [number[][], number[], number, number][])( + 'does not cut route segments in %j with markers %j', + (routeLongitudes, markerLongitudes, west, east) => { + const routes = routeLongitudes.map((longitudes) => ( + longitudes.map((lng) => ({ lat: 10, lng })) + )); + const markers = markerLongitudes.map((lng) => ({ lat: 10, lng })); + expect(createBounds([...routes.flat(), ...markers], routes)).toEqual({ + northEast: { lat: 10, lng: east }, + southWest: { lat: 10, lng: west }, + }); + }, + ); }); }); diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts index a1b5394ce924..4b28383e5b8e 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts @@ -11,6 +11,7 @@ import type { LocationOption, MarkerObject, MarkerOptions, + RouteObject, RouteOptions, } from './provider.dynamic'; import DynamicProvider from './provider.dynamic'; @@ -20,6 +21,7 @@ import type { MapEngineClickEvent, MapEngineMap, MapEngineMarker, + MapEngineRoute, MapEngineTileLayerOptions, MapEngineViewState, } from './provider.dynamic.osm.engine'; @@ -28,6 +30,7 @@ import { SUBDOMAIN_PLACEHOLDER, } from './provider.dynamic.osm.engine'; import { createOpenLayersEngine } from './provider.dynamic.osm.openlayers'; +import { getRouteLocations, getRouteLongitudeRange } from './provider.dynamic.osm.route'; const DEFAULT_MAX_ZOOM = 19; const DEFAULT_SUBDOMAINS = 'abc'; @@ -62,7 +65,10 @@ export const normalizeLongitude = (longitude: number): number => { return (positive % FULL_CIRCLE_DEGREES) - HALF_CIRCLE_DEGREES; }; -export const createBounds = (locations: MapLocation[]): MapEngineBounds | undefined => { +export const createBounds = ( + locations: MapLocation[], + routes: MapLocation[][] = [], +): MapEngineBounds | undefined => { if (!locations.length) { return undefined; } @@ -77,6 +83,7 @@ export const createBounds = (locations: MapLocation[]): MapEngineBounds | undefi south = Math.min(south, lat); }); longitudes.sort((first, second) => first - second); + const routeRanges = routes.filter((route) => route.length).map(getRouteLongitudeRange); let largestGap = -1; let westIndex = 0; @@ -85,8 +92,15 @@ export const createBounds = (locations: MapLocation[]): MapEngineBounds | undefi ? longitudes[0] + FULL_CIRCLE_DEGREES : longitudes[index + 1]; const gap = nextLongitude - longitude; + const midpoint = longitude + gap / 2; + const crossesRoute = routeRanges.some(([west, east]) => { + const offset = (((midpoint - west) % FULL_CIRCLE_DEGREES) + FULL_CIRCLE_DEGREES) + % FULL_CIRCLE_DEGREES; - if (gap > largestGap) { + return offset < east - west; + }); + + if (gap > largestGap && !crossesRoute) { largestGap = gap; westIndex = (index + 1) % longitudes.length; } @@ -95,11 +109,13 @@ export const createBounds = (locations: MapLocation[]): MapEngineBounds | undefi return { northEast: { lat: north, - lng: longitudes[(westIndex + longitudes.length - 1) % longitudes.length], + lng: largestGap < 0 + ? HALF_CIRCLE_DEGREES + : longitudes[(westIndex + longitudes.length - 1) % longitudes.length], }, southWest: { lat: south, - lng: longitudes[westIndex], + lng: largestGap < 0 ? -HALF_CIRCLE_DEGREES : longitudes[westIndex], }, }; }; @@ -109,6 +125,11 @@ interface EngineMarkerObject extends MarkerObject { location: MapLocation; } +interface EngineRouteObject extends RouteObject { + engineRoute?: MapEngineRoute; + locations: MapLocation[]; +} + const areLocationsEqual = ( first: MapLocation | null, second: MapLocation, @@ -117,6 +138,8 @@ const areLocationsEqual = ( && Math.abs(first.lng - second.lng) < LOCATION_EPSILON; class OsmProvider extends DynamicProvider { + declare _routes: (EngineRouteObject & { options: RouteOptions })[]; + _engine?: MapEngine; _engineMap?: MapEngineMap; @@ -437,7 +460,8 @@ class OsmProvider extends DynamicProvider { _fitBounds(): Promise { this._updateBounds(); - this._bounds = createBounds(this._boundLocations) ?? null; + this._bounds = createBounds(this._boundLocations, this._routes.map((route) => route.locations)) + ?? null; const engineMap = this._engineMap; if (!engineMap || !this._bounds || !this._option('autoAdjust')) { @@ -475,13 +499,86 @@ class OsmProvider extends DynamicProvider { this._boundLocations = []; } - addRoutes(routes: RouteOptions[]): Promise<[boolean, unknown[]]> { - return Promise.resolve([false, routes.map(() => undefined)]); + _calculateRoute(options: RouteOptions): Promise { + const calculateRoute = this._option('providerConfig')?.calculateRoute; + if (!calculateRoute) { + errors.log('W1033'); + + return Promise.resolve(undefined); + } + + const engineMap = this._engineMap; + return Promise.all((options.locations ?? []).map((location) => this._resolveLocation(location))) + .then((locations) => { + if (engineMap !== this._engineMap) { + throw new Error('The map was disposed or replaced during route creation.'); + } + + return Promise.resolve() + .then(() => calculateRoute({ locations, mode: options.mode ?? 'driving' })) + .then((result) => { + const routeLocations = getRouteLocations(result); + if (!routeLocations) { + errors.log('W1006', 'calculateRoute returned an invalid result.'); + } + + return routeLocations; + }, (error) => { + errors.log('W1006', error); + + return undefined; + }); + }); + } + + _renderRoute(options: RouteOptions): Promise { + const engineMap = this._engineMap; + if (!engineMap) { + return Promise.reject(errors.Error('E1069')); + } + + return this._calculateRoute(options) + .then((locations) => { + if (engineMap !== this._engineMap) { + throw new Error('The map was disposed or replaced during route creation.'); + } + if (!locations) { + return { locations: [] }; + } + + const engineRoute = engineMap.addRoute({ + locations, + color: options.color ?? this._defaultRouteColor(), + opacity: options.opacity ?? this._defaultRouteOpacity(), + weight: options.weight ?? this._defaultRouteWeight(), + }); + + return { engineRoute, locations, instance: engineRoute.originalRoute }; + }); + } + + _destroyRoute(route: EngineRouteObject): void { + route.engineRoute?.dispose(); + } + + updateRoutes(routesToRemove: RouteOptions[], routesToAdd: RouteOptions[]): Promise { + return this._applyFunctionIfNeeded('removeRoutes', routesToRemove) + .then(() => this._applyFunctionIfNeeded('addRoutes', routesToAdd)); + } + + _updateBounds(): void { + super._updateBounds(); + if (this._option('autoAdjust')) { + this._routes.forEach((route) => { + route.locations.forEach((location) => this._extendBounds(location)); + }); + } } clean(): Promise { if (this._engineMap) { this._clearMarkers(); + this._clearRoutes(); } this._engineMap?.dispose(); this._engineMap = undefined; diff --git a/packages/devextreme/js/ui/map/openlayers.register.js b/packages/devextreme/js/ui/map/openlayers.register.js index 4289f50b51cb..affa22c839a0 100644 --- a/packages/devextreme/js/ui/map/openlayers.register.js +++ b/packages/devextreme/js/ui/map/openlayers.register.js @@ -1,7 +1,10 @@ import { defaults as defaultControls } from 'ol/control/defaults.js'; import Zoom from 'ol/control/Zoom.js'; +import Feature from 'ol/Feature.js'; +import LineString from 'ol/geom/LineString.js'; import { defaults as defaultInteractions } from 'ol/interaction/defaults.js'; import TileLayer from 'ol/layer/Tile.js'; +import VectorLayer from 'ol/layer/Vector.js'; import Map from 'ol/Map.js'; import Overlay from 'ol/Overlay.js'; import { @@ -11,15 +14,20 @@ import { transformExtent, } from 'ol/proj.js'; import ImageTile from 'ol/source/ImageTile.js'; +import VectorSource from 'ol/source/Vector.js'; +import Stroke from 'ol/style/Stroke.js'; +import Style from 'ol/style/Style.js'; import View from 'ol/View.js'; import { setRegisteredMapEngine } from '../../__internal/ui/map/provider.dynamic.osm.engine'; import { createOpenLayersEngine } from '../../__internal/ui/map/provider.dynamic.osm.openlayers'; setRegisteredMapEngine(createOpenLayersEngine({ + Feature, Map, Overlay, View, + geom: { LineString }, control: { Zoom, defaults: { @@ -33,6 +41,7 @@ setRegisteredMapEngine(createOpenLayersEngine({ }, layer: { Tile: TileLayer, + Vector: VectorLayer, }, proj: { getUserProjection, @@ -42,5 +51,7 @@ setRegisteredMapEngine(createOpenLayersEngine({ }, source: { ImageTile, + Vector: VectorSource, }, + style: { Stroke, Style }, })); diff --git a/packages/devextreme/testing/helpers/forMap/openLayersMock.js b/packages/devextreme/testing/helpers/forMap/openLayersMock.js index ed9903c3f773..e65588ec4165 100644 --- a/packages/devextreme/testing/helpers/forMap/openLayersMock.js +++ b/packages/devextreme/testing/helpers/forMap/openLayersMock.js @@ -149,6 +149,10 @@ } addLayer(layer) { + if(layer instanceof MockVectorLayer) { + api.addedVectorLayers.push(layer); + return; + } api.tileLayer = layer; api.addedTileLayers.push(layer); } @@ -233,10 +237,82 @@ api.tileSourceChanges.push(source); } } + class MockLineString { + constructor(coordinates) { + this.coordinates = coordinates; + } + transform(source, destination) { + this.coordinates = this.coordinates.map(coordinate => transformCoordinate(coordinate, source, destination)); + return this; + } + getCoordinates() { + return this.coordinates; + } + } + class MockFeature { + constructor(geometry) { + this.geometry = geometry; + } + getGeometry() { + return this.geometry; + } + setStyle(style) { + this.style = style; + } + getStyle() { + return this.style; + } + } + class MockVectorSource { + constructor() { + this.features = []; + } + addFeature(feature) { + this.features.push(feature); + } + removeFeature(feature) { + this.features = this.features.filter(item => item !== feature); + } + getFeatures() { + return this.features.slice(); + } + clear() { + this.features = []; + } + } + class MockVectorLayer { + constructor(options) { + this.options = options; + } + getSource() { + return this.options.source; + } + } + class MockStroke { + constructor(options) { + this.options = options; + } + getColor() { + return this.options.color; + } + getWidth() { + return this.options.width; + } + } + class MockStyle { + constructor(options) { + this.options = options; + } + getStroke() { + return this.options.stroke; + } + } Object.assign(api, { + Feature: MockFeature, Map: MockMap, Overlay: MockOverlay, View: MockView, + geom: { LineString: MockLineString }, control: { Zoom: MockZoom, defaults: { @@ -256,7 +332,8 @@ } }, layer: { - Tile: MockTileLayer + Tile: MockTileLayer, + Vector: MockVectorLayer }, proj: { getUserProjection() { @@ -295,8 +372,10 @@ } }, source: { - ImageTile: MockImageTile - } + ImageTile: MockImageTile, + Vector: MockVectorSource + }, + style: { Stroke: MockStroke, Style: MockStyle } }); window.ol = api; })(); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js index dc151a3b2295..feda577117b7 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -23,6 +23,7 @@ const resetOpenLayersMock = () => { addedControls: [], addedOverlays: [], addedTileLayers: [], + addedVectorLayers: [], controlOptions: null, fitCallCount: 0, fitZoom: undefined, @@ -1032,6 +1033,7 @@ QUnit.module('OSM: location calculation', moduleConfig, () => { setView }; provider._markers = []; + provider._routes = []; const update = provider.updateCenter(); provider.clean(); resolveLocation({ @@ -1166,6 +1168,7 @@ QUnit.module('OSM: location calculation', moduleConfig, () => { dispose: sinon.spy() }; provider._markers = []; + provider._routes = []; const add = provider.addMarkers([{ location: 'New York' }]); @@ -1873,6 +1876,369 @@ QUnit.module('OSM: markers', moduleConfig, () => { }); }); }); +QUnit.module('OSM: routes', moduleConfig, () => { + const tileServer = { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + }; + const path = [[40.7, -74], [40.9, -73.8], [40.8, -73.9]]; + const route = { locations: [[40.7, -74], [40.8, -73.9]] }; + const createMap = (options = {}) => new Promise(resolve => { + $('#map').dxMap({ + provider: 'osm', + autoAdjust: false, + providerConfig: { tileServer, calculateRoute: () => Promise.resolve(path) }, + ...options, + onReady: ({ component }) => resolve(component) + }); + }); + const getRouteSource = () => openLayersMock.addedVectorLayers[0].getSource(); + + [false, true].forEach(geoJson => { + QUnit.test(`initial route renders ${geoJson ? 'GeoJSON' : 'tuples'} with default styles`, async function(assert) { + const result = geoJson ? { + type: 'LineString', + coordinates: path.map(([lat, lng]) => [lng, lat, 10]) + } : path; + const calculateRoute = sinon.stub().returns(Promise.resolve(result)); + const onRouteAdded = sinon.spy(); + await createMap({ + routes: [route], + providerConfig: { tileServer, calculateRoute }, + onRouteAdded + }); + const feature = getRouteSource().getFeatures()[0]; + assert.deepEqual(feature.getGeometry().getCoordinates(), [[-74000, 40700], [-73800, 40900], [-73900, 40800]], 'geometry is in the view projection'); + assert.deepEqual(calculateRoute.firstCall.args[0], { + locations: [{ lat: 40.7, lng: -74 }, { lat: 40.8, lng: -73.9 }], + mode: 'driving' + }, 'callback receives resolved locations and default mode'); + assert.strictEqual(onRouteAdded.firstCall.args[0].originalRoute, feature, 'event exposes the OpenLayers feature'); + assert.deepEqual(feature.getStyle().getStroke().getColor(), [0, 0, 255, 0.5], 'shared route color and opacity defaults'); + assert.strictEqual(feature.getStyle().getStroke().getWidth(), 5, 'shared route weight default'); + assert.strictEqual(openLayersMock.fitCallCount, 0, 'autoAdjust false preserves the viewport'); + }); + }); + + QUnit.test('addRoute and removeRoute manage features, return values and events', async function(assert) { + const onRouteAdded = sinon.spy(); + const onRouteRemoved = sinon.spy(); + const map = await createMap({ onRouteAdded, onRouteRemoved }); + const originalRoute = await map.addRoute(route); + assert.strictEqual(originalRoute, getRouteSource().getFeatures()[0], 'addRoute returns the feature'); + assert.strictEqual(onRouteAdded.firstCall.args[0].options, route, 'original route options are reported'); + await map.removeRoute(route); + assert.strictEqual(getRouteSource().getFeatures().length, 0, 'feature is removed'); + assert.strictEqual(onRouteRemoved.firstCall.args[0].options, route, 'removal event has the route options'); + }); + + QUnit.test('multiple routes keep separate geometry and styles when one is removed', async function(assert) { + const drivingRoute = { ...route, mode: 'driving', color: '#ff0000', opacity: 0.7, weight: 3 }; + const walkingRoute = { + locations: [[40.6, -73.7], [40.65, -73.6]], + mode: 'walking', color: '#008000', opacity: 0.4, weight: 8 + }; + const walkingPath = { + type: 'LineString', + coordinates: [[-73.7, 40.6], [-73.65, 40.62], [-73.6, 40.65]] + }; + const calculateRoute = sinon.stub().callsFake(({ mode }) => Promise.resolve(mode === 'driving' ? path : walkingPath)); + const onRouteAdded = sinon.spy(); + const onRouteRemoved = sinon.spy(); + const map = await createMap({ + routes: [drivingRoute, walkingRoute], + providerConfig: { tileServer, calculateRoute }, + onRouteAdded, + onRouteRemoved + }); + const source = getRouteSource(); + const drivingFeature = onRouteAdded.getCalls().find(call => call.args[0].options === drivingRoute).args[0].originalRoute; + const walkingFeature = onRouteAdded.getCalls().find(call => call.args[0].options === walkingRoute).args[0].originalRoute; + + assert.strictEqual(calculateRoute.callCount, 2, 'each route is calculated once'); + assert.ok(calculateRoute.calledWithExactly({ + locations: [{ lat: 40.7, lng: -74 }, { lat: 40.8, lng: -73.9 }], mode: 'driving' + }), 'driving callback receives its own waypoints and mode'); + assert.ok(calculateRoute.calledWithExactly({ + locations: [{ lat: 40.6, lng: -73.7 }, { lat: 40.65, lng: -73.6 }], mode: 'walking' + }), 'walking callback receives its own waypoints and mode'); + assert.strictEqual(source.getFeatures().length, 2, 'both routes are rendered'); + assert.ok(source.getFeatures().includes(drivingFeature), 'driving event exposes its rendered feature'); + assert.ok(source.getFeatures().includes(walkingFeature), 'walking event exposes its rendered feature'); + assert.deepEqual(drivingFeature.getGeometry().getCoordinates(), [[-74000, 40700], [-73800, 40900], [-73900, 40800]], 'driving route uses the tuple result'); + assert.deepEqual(walkingFeature.getGeometry().getCoordinates(), [[-73700, 40600], [-73650, 40620], [-73600, 40650]], 'walking route uses the GeoJSON result'); + assert.deepEqual(drivingFeature.getStyle().getStroke().getColor(), [255, 0, 0, 0.7], 'driving color and opacity'); + assert.strictEqual(drivingFeature.getStyle().getStroke().getWidth(), 3, 'driving weight'); + assert.deepEqual(walkingFeature.getStyle().getStroke().getColor(), [0, 128, 0, 0.4], 'walking color and opacity'); + assert.strictEqual(walkingFeature.getStyle().getStroke().getWidth(), 8, 'walking weight'); + + await map.removeRoute(drivingRoute); + + assert.deepEqual(map.option('routes'), [walkingRoute], 'only the requested route is removed from options'); + assert.strictEqual(source.getFeatures().length, 1, 'one route remains'); + assert.strictEqual(source.getFeatures()[0], walkingFeature, 'the remaining feature is not recreated'); + assert.deepEqual(walkingFeature.getGeometry().getCoordinates(), [[-73700, 40600], [-73650, 40620], [-73600, 40650]], 'remaining geometry is unchanged'); + assert.deepEqual(walkingFeature.getStyle().getStroke().getColor(), [0, 128, 0, 0.4], 'remaining color and opacity are unchanged'); + assert.strictEqual(walkingFeature.getStyle().getStroke().getWidth(), 8, 'remaining weight is unchanged'); + assert.strictEqual(calculateRoute.callCount, 2, 'removal does not recalculate the remaining route'); + assert.strictEqual(onRouteAdded.callCount, 2, 'removal does not add the remaining route again'); + assert.strictEqual(onRouteRemoved.callCount, 1, 'one removal event is raised'); + assert.strictEqual(onRouteRemoved.firstCall.args[0].options, drivingRoute, 'removal event identifies the removed route'); + }); + + QUnit.test('route updates reuse one layer and honor zero opacity and weight', async function(assert) { + const map = await createMap({ routes: [route] }); + const source = getRouteSource(); + const oldFeature = source.getFeatures()[0]; + map.option('routes', [{ ...route, color: '#ff0000', opacity: 0, weight: 0 }]); + await map._lastAsyncAction; + const features = source.getFeatures(); + assert.strictEqual(features.length, 1, 'old route is replaced'); + assert.notStrictEqual(features[0], oldFeature, 'updated route has a new feature'); + assert.strictEqual(openLayersMock.addedVectorLayers.length, 1, 'vector layer is reused'); + assert.deepEqual(features[0].getStyle().getStroke().getColor(), [255, 0, 0, 0], 'zero opacity is preserved'); + assert.strictEqual(features[0].getStyle().getStroke().getWidth(), 0, 'zero weight is preserved'); + }); + + ['walking', 'cycling'].forEach(mode => { + QUnit.test(`route callback resolves addresses and passes ${mode} unchanged with a PromiseLike result`, async function(assert) { + const calculateLocation = sinon.stub().callsFake(query => Promise.resolve(query === 'Start' + ? { lat: 40.7, lng: -74 } : { lat: 40.8, lng: -73.9 })); + const calculateRoute = sinon.stub().callsFake(() => $.Deferred().resolve(path).promise()); + await createMap({ + routes: [{ locations: ['Start', 'Finish'], mode }], + providerConfig: { tileServer, calculateLocation, calculateRoute } + }); + assert.deepEqual(calculateRoute.firstCall.args[0], { + locations: [{ lat: 40.7, lng: -74 }, { lat: 40.8, lng: -73.9 }], mode + }, 'locations and mode are passed to the callback'); + assert.strictEqual(calculateLocation.callCount, 2, 'addresses are resolved once'); + assert.strictEqual(getRouteSource().getFeatures().length, 1, 'thenable result is rendered'); + }); + }); + + QUnit.test('missing callback warns without drawing a straight line', async function(assert) { + const log = sinon.stub(errors, 'log'); + try { + const map = await createMap({ routes: [route], providerConfig: { tileServer } }); + assert.ok(log.calledOnceWithExactly('W1033'), 'missing callback is reported'); + assert.strictEqual(openLayersMock.addedVectorLayers.length, 0, 'no fallback line is drawn'); + await map.addMarker({ location: [40.7, -74] }); + await map.removeRoute(route); + assert.strictEqual(openLayersMock.addedOverlays.length, 1, 'map continues to accept operations'); + } finally { + log.restore(); + } + }); + + QUnit.test('missing route callback skips address lookup and does not block map initialization', async function(assert) { + const calculateLocation = sinon.stub().returns(new Promise(() => {})); + const log = sinon.stub(errors, 'log'); + try { + const map = await createMap({ + routes: [{ locations: ['Start', 'Finish'] }], + providerConfig: { tileServer, calculateLocation } + }); + assert.ok(calculateLocation.notCalled, 'no geocoding is needed when routing is unavailable'); + assert.ok(log.calledOnceWithExactly('W1033'), 'missing callback is reported once'); + assert.strictEqual(openLayersMock.addedVectorLayers.length, 0, 'no fallback line is drawn'); + await map.addMarker({ location: [40.7, -74] }); + assert.strictEqual(openLayersMock.addedOverlays.length, 1, 'the action queue remains usable'); + } finally { + log.restore(); + } + }); + + [false, true].forEach(geoJson => { + QUnit.test(`invalid route latitude warns and skips rendering (${geoJson ? 'GeoJSON' : 'tuples'})`, async function(assert) { + const result = geoJson + ? { type: 'LineString', coordinates: [[10, 95], [11, 96]] } + : [[95, 10], [96, 11]]; + const log = sinon.stub(errors, 'log'); + try { + await createMap({ + routes: [route], + providerConfig: { tileServer, calculateRoute: () => Promise.resolve(result) } + }); + assert.ok(log.calledOnceWithExactly('W1006', 'calculateRoute returned an invalid result.'), 'invalid coordinates are reported'); + assert.strictEqual(openLayersMock.addedVectorLayers.length, 0, 'invalid geometry is not rendered'); + } finally { + log.restore(); + } + }); + }); + + ['invalid', 'throw', 'reject'].forEach(failure => { + QUnit.test(`${failure} result skips the route, reports the reason and does not retry`, async function(assert) { + const reason = new Error('Routing service unavailable'); + const calculateRoute = sinon.stub().callsFake(() => { + if(failure === 'throw') { + throw reason; + } + return failure === 'reject' ? Promise.reject(reason) : Promise.resolve({ type: 'Point', coordinates: [-74, 40.7] }); + }); + const log = sinon.stub(errors, 'log'); + try { + const map = await createMap({ routes: [route], providerConfig: { tileServer, calculateRoute } }); + assert.ok(calculateRoute.calledOnce, 'callback is not retried'); + assert.ok(log.calledOnceWithExactly('W1006', failure === 'invalid' ? 'calculateRoute returned an invalid result.' : reason), 'warning preserves the service reason'); + assert.strictEqual(openLayersMock.addedVectorLayers.length, 0, 'failed route is not rendered'); + calculateRoute.callsFake(() => Promise.resolve(path)); + const originalRoute = await map.addRoute({ ...route }); + assert.strictEqual(originalRoute, getRouteSource().getFeatures()[0], 'later explicit request succeeds without a cached failure'); + } finally { + log.restore(); + } + }); + }); + + QUnit.test('autoAdjust includes the full route geometry and markers', async function(assert) { + await createMap({ + autoAdjust: true, + routes: [route], + markers: [{ location: [40.6, -74.1] }] + }); + assert.deepEqual(openLayersMock.fittedExtent, [-74100, 40600, -73800, 40900], 'fit includes intermediate route points and the marker'); + }); + + QUnit.test('antimeridian route uses adjacent world coordinates and narrow bounds', async function(assert) { + await createMap({ + autoAdjust: true, + routes: [{ locations: [[10, 179], [20, -179]] }], + providerConfig: { tileServer, calculateRoute: () => Promise.resolve([[10, 179], [20, -179]]) } + }); + assert.deepEqual(getRouteSource().getFeatures()[0].getGeometry().getCoordinates(), [[179000, 10000], [181000, 20000]], 'line does not cross the whole world'); + assert.deepEqual(openLayersMock.fittedExtent, [179000, 10000, 181000, 20000], 'bounds include the short crossing'); + }); + + QUnit.test('autoAdjust includes continuous route segments, not just their endpoints', async function(assert) { + const locations = [[0, -120], [0, 0], [0, 120]]; + await createMap({ + autoAdjust: true, + routes: [{ locations }], + providerConfig: { tileServer, calculateRoute: () => Promise.resolve(locations) } + }); + assert.deepEqual(getRouteSource().getFeatures()[0].getGeometry().getCoordinates(), [[-120000, 0], [0, 0], [120000, 0]], 'line spans both hemispheres through zero'); + assert.deepEqual(openLayersMock.fittedExtent, [-120000, 0, 120000, 0], 'fit preserves the full line instead of cutting one segment'); + }); + + QUnit.test('autoAdjust combines antimeridian routes and markers in a narrow extent', async function(assert) { + await createMap({ + autoAdjust: true, + routes: [{ locations: [[10, 170], [20, -175]] }, { locations: [[15, -170], [25, 175]] }], + markers: [{ location: [5, 168] }], + providerConfig: { tileServer, calculateRoute: ({ locations }) => Promise.resolve(locations.map(({ lat, lng }) => [lat, lng])) } + }); + assert.deepEqual(openLayersMock.fittedExtent, [168000, 5000, 190000, 25000], 'fit includes both continuous routes and the marker'); + }); + + QUnit.test('feature geometry honors the user projection', async function(assert) { + openLayersMock.userProjection = 'EPSG:4326'; + await createMap({ routes: [route] }); + const geometry = getRouteSource().getFeatures()[0].getGeometry(); + assert.deepEqual(geometry.getCoordinates()[0], [-74, 40.7], 'geometry uses the user projection expected by the renderer'); + openLayersMock.mapInstance.setView(new openLayersMock.View({ projection: 'EPSG:4326', center: [-74, 40.7], zoom: 10 })); + assert.deepEqual(geometry.getCoordinates()[0], [-74, 40.7], 'view replacement preserves the user-projected route'); + }); + + QUnit.test('view projection replacement reprojects existing routes', async function(assert) { + await createMap({ routes: [route] }); + const geometry = getRouteSource().getFeatures()[0].getGeometry(); + openLayersMock.mapInstance.setView(new openLayersMock.View({ projection: 'EPSG:4326', center: [-74, 40.7], zoom: 10 })); + assert.deepEqual(geometry.getCoordinates()[0], [-74, 40.7], 'route is transformed to the replacement view projection'); + }); + + [ + ['red', [255, 0, 0, 0.7]], + ['#f00', [255, 0, 0, 0.7]], + ['rgba(10, 20, 30, 0.2)', [10, 20, 30, 0.7]], + ].forEach(([color, expected]) => { + QUnit.test(`route color ${color} uses shared color parsing and separate opacity`, async function(assert) { + await createMap({ routes: [{ ...route, color, opacity: 0.7 }] }); + const feature = getRouteSource().getFeatures()[0]; + assert.deepEqual(feature.getStyle().getStroke().getColor(), expected); + }); + }); + + QUnit.test('invalid route color does not block subsequent map operations', async function(assert) { + const map = await createMap(); + const firstRoute = await map.addRoute({ ...route, color: '#oops' }); + assert.deepEqual(firstRoute.getStyle().getStroke().getColor(), [0, 0, 0, 0.5], 'shared Color fallback is applied'); + const secondRoute = await map.addRoute({ ...route, color: '#ff0000' }); + assert.deepEqual(secondRoute.getStyle().getStroke().getColor(), [255, 0, 0, 0.5], 'the next route renders normally'); + await map.addMarker({ location: [40.7, -74] }); + assert.strictEqual(openLayersMock.addedOverlays.length, 1, 'the action queue also accepts marker updates'); + }); + + QUnit.test('disposal removes route features and the vector layer', async function(assert) { + const map = await createMap({ routes: [route] }); + const layer = openLayersMock.addedVectorLayers[0]; + const source = layer.getSource(); + map.dispose(); + assert.strictEqual(source.getFeatures().length, 0, 'route source is cleared'); + assert.ok(openLayersMock.removedLayers.includes(layer), 'vector layer is detached'); + }); + + QUnit.test('pending route result is ignored after map disposal', async function(assert) { + let completeRoute; + let callbackStarted; + const started = new Promise(resolve => { callbackStarted = resolve; }); + const calculateRoute = () => new Promise(resolve => { + completeRoute = resolve; + callbackStarted(); + }); + const onRouteAdded = sinon.spy(); + const map = await createMap({ providerConfig: { tileServer, calculateRoute }, onRouteAdded }); + map.addRoute(route); + const pending = map._lastAsyncAction; + await started; + map.dispose(); + completeRoute(path); + await pending; + assert.strictEqual(openLayersMock.addedVectorLayers.length, 0, 'stale result creates no layer'); + assert.ok(onRouteAdded.notCalled, 'stale result fires no route event'); + }); + + QUnit.test('pending route result does not duplicate a route after repaint', async function(assert) { + let completeRoute; + let callbackStarted; + const started = new Promise(resolve => { callbackStarted = resolve; }); + const calculateRoute = sinon.stub(); + calculateRoute.onFirstCall().callsFake(() => new Promise(resolve => { + completeRoute = resolve; + callbackStarted(); + })); + calculateRoute.onSecondCall().returns(Promise.resolve(path)); + const onRouteAdded = sinon.spy(); + const map = await createMap({ providerConfig: { tileServer, calculateRoute }, onRouteAdded }); + const pending = map.addRoute(route); + await started; + map.repaint(); + await map._lastAsyncAction; + completeRoute(path); + await pending; + assert.strictEqual(getRouteSource().getFeatures().length, 1, 'only the replacement map route exists'); + assert.ok(onRouteAdded.calledOnce, 'stale route does not fire a second event'); + }); + + QUnit.test('disposal while resolving waypoints does not request a route', async function(assert) { + let completeLocation; + let locationStarted; + const started = new Promise(resolve => { locationStarted = resolve; }); + const calculateLocation = () => new Promise(resolve => { + completeLocation = resolve; + locationStarted(); + }); + const calculateRoute = sinon.spy(); + const map = await createMap({ providerConfig: { tileServer, calculateLocation, calculateRoute } }); + const pending = map.addRoute({ locations: ['Start', [40.8, -73.9]] }); + await started; + map.dispose(); + completeLocation({ lat: 40.7, lng: -74 }); + await pending; + assert.ok(calculateRoute.notCalled, 'stale geocoding does not start a routing request'); + }); +}); QUnit.module('OSM: viewport and interactions', moduleConfig, () => { const tileServer = { url: 'https://tiles.example.com/{z}/{x}/{y}.png', From 5f5c6558eae8fabaa7f78c06788305a0a9a7bf85 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Wed, 9 Sep 2026 10:02:49 +0300 Subject: [PATCH 02/14] Map: showcase OSM routes in Storybook --- .../stories/map/OSMMap.stories.tsx | 150 +++++++++-- apps/react-storybook/stories/map/routes.ts | 254 ++++++++++++++++++ 2 files changed, 376 insertions(+), 28 deletions(-) create mode 100644 apps/react-storybook/stories/map/routes.ts diff --git a/apps/react-storybook/stories/map/OSMMap.stories.tsx b/apps/react-storybook/stories/map/OSMMap.stories.tsx index 433c67c296a5..9fc4d2ce3b51 100644 --- a/apps/react-storybook/stories/map/OSMMap.stories.tsx +++ b/apps/react-storybook/stories/map/OSMMap.stories.tsx @@ -11,17 +11,18 @@ import React from 'react'; import Button from 'devextreme-react/button'; import Map, { type MapRef } from 'devextreme-react/map'; import type { + CalculateOsmRouteInfo, MapLocation, MapType, ReadyEvent, + OsmRouteResult, + RouteMode, } from 'devextreme/ui/map'; import 'devextreme/ui/map/openlayers'; -const CENTER = { lat: 40.7484, lng: -73.9857 }; +import { ROUTE_PATHS } from './routes'; + const CENTRAL_PARK_CENTER = { lat: 40.7829, lng: -73.9654 }; -const DEFAULT_MARKER_LOCATION = 'Empire State Building'; -const CUSTOM_MARKER_LOCATION = 'Bryant Park'; -const ADDED_MARKER_LOCATION = 'Times Square'; const EXTENT: [number, number, number, number] = [-74.08, 40.67, -73.85, 40.88]; const TILE_SERVER = { url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', @@ -29,30 +30,74 @@ const TILE_SERVER = { maxZoom: 19, }; const MARKER_LOCATIONS: Record = { - [DEFAULT_MARKER_LOCATION]: CENTER, - [CUSTOM_MARKER_LOCATION]: { lat: 40.7536, lng: -73.9832 }, - [ADDED_MARKER_LOCATION]: { lat: 40.758, lng: -73.9855 }, + 'Columbus Circle': { lat: 40.768161, lng: -73.981906 }, + 'Belvedere Castle': { lat: 40.779316, lng: -73.968882 }, + 'Great Hill': { lat: 40.797269, lng: -73.958993 }, + 'Dana Discovery Center': { lat: 40.797064, lng: -73.951349 }, + 'Conservatory Garden': { lat: 40.793621, lng: -73.95261 }, + 'Bethesda Fountain': { lat: 40.774498, lng: -73.970867 }, +}; +const ROUTE_PRESETS = { + walking: { + title: 'Central Park Run — approx. 10.1 km', + markerDescription: 'Blue: Columbus Circle start / finish. Red: Dana Discovery Center.', + center: { lat: 40.7827, lng: -73.9666 }, + zoom: 14, + locations: [ + 'Columbus Circle', + 'Belvedere Castle', + 'Great Hill', + 'Dana Discovery Center', + 'Conservatory Garden', + 'Bethesda Fountain', + 'Columbus Circle', + ], + markers: [{ + location: 'Columbus Circle', + }, { + location: 'Dana Discovery Center', + iconSrc: 'images/maps/map-marker.png', + }], + extraMarker: MARKER_LOCATIONS['Belvedere Castle'], + }, + driving: { + title: 'Manhattan Drive — official Routes demo waypoints, approx. 11 km', + markerDescription: 'Four markers: coordinate string, arrays and an object. Red: custom icon.', + center: { lat: 40.75, lng: -73.986 }, + zoom: 14, + locations: [ + [40.7825, -73.966111], + [40.755833, -73.986389], + [40.753889, -73.981389], + [40.713474, -74.005536], + ], + markers: [{ + location: '40.7825, -73.966111', + }, { + location: [40.755833, -73.986389], + iconSrc: 'images/maps/map-marker.png', + }, { + location: { lat: 40.753889, lng: -73.981389 }, + }, { + location: [40.713474, -74.005536], + }], + extraMarker: { lat: 40.748441, lng: -73.985664 }, + }, }; const PROVIDER_CONFIG = { calculateLocation: (query: string): Promise => ( Promise.resolve(MARKER_LOCATIONS[query]) ), + calculateRoute: ({ mode }: CalculateOsmRouteInfo): Promise => ( + Promise.resolve(mode === 'walking' + ? ROUTE_PATHS.walking + : ROUTE_PATHS.driving.coordinates.map(([lng, lat]) => [lat, lng])) + ), tileServer: () => TILE_SERVER, }; const handleMarkerClick = fn(); -const DEFAULT_MARKER = { - location: DEFAULT_MARKER_LOCATION, - onClick: handleMarkerClick, -}; -const CUSTOM_MARKER = { - location: CUSTOM_MARKER_LOCATION, - iconSrc: 'images/maps/map-marker.png', - onClick: handleMarkerClick, -}; -const ADDED_MARKER = { - location: ADDED_MARKER_LOCATION, - onClick: handleMarkerClick, -}; +const handleRouteAdded = fn(); +const handleRouteRemoved = fn(); const STORY_STYLE: React.CSSProperties = { display: 'flex', flexDirection: 'column', @@ -71,6 +116,11 @@ interface OsmStoryArgs { disabled: boolean; focusStateEnabled: boolean; rtlEnabled: boolean; + showRoute: boolean; + routeColor: string; + routeMode: RouteMode; + routeOpacity: number; + routeWeight: number; type: MapType; zoom: number; } @@ -107,14 +157,39 @@ const OsmMapStory = ({ disabled, focusStateEnabled, rtlEnabled, + showRoute, + routeColor, + routeMode, + routeOpacity, + routeWeight, type, updateArgs, zoom, }: OsmMapStoryProps): React.ReactElement => { const mapRef = React.useRef(null); const [markerAdded, setMarkerAdded] = React.useState(false); - const markers = React.useMemo(() => [DEFAULT_MARKER, CUSTOM_MARKER], []); - const center = centerOnCentralPark ? CENTRAL_PARK_CENTER : CENTER; + const preset = ROUTE_PRESETS[routeMode]; + const markers = React.useMemo(() => preset.markers.map((marker) => ({ + ...marker, + onClick: handleMarkerClick, + })), [preset]); + const addedMarker = React.useMemo(() => ({ + location: preset.extraMarker, + onClick: handleMarkerClick, + }), [preset]); + const routes = React.useMemo(() => showRoute ? [{ + locations: preset.locations, + color: routeColor, + mode: routeMode, + opacity: routeOpacity, + weight: routeWeight, + }] : [], [preset, showRoute, routeColor, routeMode, routeOpacity, routeWeight]); + const center = centerOnCentralPark ? CENTRAL_PARK_CENTER : preset.center; + + React.useEffect(() => { + setMarkerAdded(false); + mapRef.current?.instance()?.option('zoom', preset.zoom); + }, [preset]); React.useEffect(() => { mapRef.current?.instance()?.option('center', center); @@ -127,7 +202,7 @@ const OsmMapStory = ({ } setMarkerAdded(true); - void map.addMarker(ADDED_MARKER).then(undefined, () => setMarkerAdded(false)); + void map.addMarker(addedMarker).then(undefined, () => setMarkerAdded(false)); }; const removeMarker = (): void => { @@ -137,11 +212,12 @@ const OsmMapStory = ({ } setMarkerAdded(false); - void map.removeMarker(ADDED_MARKER).then(undefined, () => setMarkerAdded(true)); + void map.removeMarker(addedMarker).then(undefined, () => setMarkerAdded(true)); }; return (
+
{preset.title}. {preset.markerDescription}