diff --git a/apps/react-storybook/stories/map/OSMMap.stories.tsx b/apps/react-storybook/stories/map/OSMMap.stories.tsx new file mode 100644 index 000000000000..3d21286ffdd4 --- /dev/null +++ b/apps/react-storybook/stories/map/OSMMap.stories.tsx @@ -0,0 +1,250 @@ +import type { Meta, StoryObj } from '@storybook/react-webpack5'; + +import React, { useMemo } from 'react'; +import Map from 'devextreme-react/map'; + +// OpenStreetMap (OSM) provider for the DevExtreme Map — powered by Leaflet. +// The provider needs no API key of its own, but it does not bundle a tile/geocoding/routing +// service: you supply them via `providerConfig` (tileServer / geocodeLocation / getRoute). +// +// This story lets you switch between several commercial OSM-based tile providers and paste your +// own key for each (the "Tile provider" controls). The public OpenStreetMap tile server +// (tile.openstreetmap.org) MUST NOT be used in production per the OSM Tile Usage Policy, so it is +// intentionally not offered here. Routing uses the public OSRM demo server (evaluation only). +// +// NOTE: there is deliberately no "self-hosted" option in this published Storybook — a localhost +// URL would point at the viewer's own machine, not a shared server. For the fully free, no-key, +// self-hosted setup (tiles + routing + geocoding), run the OSM_SelfHosted_Server Docker stack +// locally (see the devextreme-how-to-use-openstreetmap example repo). +const OSM_ATTR = '© OpenStreetMap contributors'; +const markerUrl = 'https://js.devexpress.com/Demos/WidgetsGallery/JSDemos/images/maps/map-marker.png'; + +type TileProvider = 'MapTiler' | 'Thunderforest' | 'Stadia Maps'; +type MapType = 'roadmap' | 'satellite' | 'hybrid'; + +interface ProviderKeys { + maptiler: string; + thunderforest: string; + stadia: string; +} + +// Resolve a Leaflet tile-layer config for the selected provider and map type. Each provider is a +// function of the type so switching the "Map type" control re-resolves the tiles. +const buildTileServer = (provider: TileProvider, type: string, keys: ProviderKeys) => { + switch (provider) { + case 'Thunderforest': { + // Thunderforest has no satellite/aerial imagery, so the type slots map to distinct + // cartographic styles to demonstrate type switching. + const style = { roadmap: 'atlas', satellite: 'landscape', hybrid: 'outdoors' }[type] ?? 'atlas'; + return { + url: `https://{s}.tile.thunderforest.com/${style}/{z}/{x}/{y}.png?apikey=${keys.thunderforest}`, + attribution: `Maps © Thunderforest, ${OSM_ATTR}`, + subdomains: 'abc', + maxZoom: 22, + }; + } + case 'Stadia Maps': { + const style = { roadmap: 'alidade_smooth', satellite: 'alidade_satellite', hybrid: 'alidade_satellite' }[type] ?? 'alidade_smooth'; + return { + url: `https://tiles.stadiamaps.com/tiles/${style}/{z}/{x}/{y}.png?api_key=${keys.stadia}`, + attribution: `© Stadia Maps ${OSM_ATTR}`, + maxZoom: 20, + }; + } + case 'MapTiler': + default: { + const style = { roadmap: 'streets-v2', satellite: 'satellite', hybrid: 'hybrid' }[type] ?? 'streets-v2'; + return { + url: `https://api.maptiler.com/maps/${style}/{z}/{x}/{y}.png?key=${keys.maptiler}`, + attribution: `© MapTiler ${OSM_ATTR}`, + maxZoom: 20, + }; + } + } +}; + +// Real road routing via the public OSRM demo server (evaluation only; host your own in production). +const getRoute = ({ locations }: { locations: { lat: number; lng: number }[] }): Promise<[number, number][]> => { + const coords = locations.map((l) => `${l.lng},${l.lat}`).join(';'); + return fetch(`https://router.project-osrm.org/route/v1/driving/${coords}?overview=full&geometries=geojson`) + .then((r) => r.json()) + .then((res) => (res.routes[0].geometry.coordinates as [number, number][]) + .map(([lng, lat]) => [lat, lng] as [number, number])); +}; + +const markersData = [ + { location: { lat: 40.755833, lng: -73.986389 }, tooltip: { text: 'Times Square' } }, + { location: { lat: 40.7825, lng: -73.966111 }, tooltip: { text: 'Central Park' } }, + { location: { lat: 40.753889, lng: -73.981389 }, tooltip: { text: 'Fifth Avenue' } }, + { location: { lat: 40.705748, lng: -73.996299 }, tooltip: { text: 'Brooklyn Bridge' } }, +]; + +const routeWaypoints: [number, number][] = [ + [40.7825, -73.966111], + [40.755833, -73.986389], + [40.753889, -73.981389], + [40.705748, -73.996299], +]; + +const centers: Record = { + 'New York': { lat: 40.74, lng: -73.985 }, + London: { lat: 51.5074, lng: -0.1278 }, + Tokyo: { lat: 35.6762, lng: 139.7649 }, +}; + +// Custom args (not native Map props) used to drive the story controls. +interface OsmStoryArgs { + tileProvider: TileProvider; + maptilerKey: string; + thunderforestKey: string; + stadiaKey: string; + type: MapType; + center: keyof typeof centers; + zoom: number; + controls: boolean; + disabled: boolean; + autoAdjust: boolean; + customMarkerIcons: boolean; + showMarkers: boolean; + showRoutes: boolean; + routeColor: string; + height: number; + width: string; +} + +const meta: Meta = { + title: 'Map/OSM Provider', + component: Map, + parameters: { layout: 'fullscreen' }, + argTypes: { + // --- Tile provider --- + tileProvider: { + control: 'select', + options: ['MapTiler', 'Thunderforest', 'Stadia Maps'], + table: { category: 'Tile provider' }, + description: 'Which commercial OSM tile provider to render.', + }, + maptilerKey: { + control: 'text', + table: { category: 'Tile provider' }, + description: 'Your MapTiler API key (https://cloud.maptiler.com). Required to see MapTiler tiles.', + }, + thunderforestKey: { + control: 'text', + table: { category: 'Tile provider' }, + description: 'Your Thunderforest API key (https://www.thunderforest.com).', + }, + stadiaKey: { + control: 'text', + table: { category: 'Tile provider' }, + description: 'Your Stadia Maps API key (https://stadiamaps.com).', + }, + // --- Map --- + type: { control: 'select', options: ['roadmap', 'satellite', 'hybrid'], table: { category: 'Map' } }, + center: { + control: 'select', options: Object.keys(centers), table: { category: 'Map' }, + description: 'Initial center — the map can be freely panned afterwards.', + }, + zoom: { + control: { type: 'number', min: 1, max: 19 }, table: { category: 'Map' }, + description: 'Initial zoom — the map can be freely zoomed afterwards.', + }, + controls: { control: 'boolean', table: { category: 'Map' } }, + disabled: { control: 'boolean', table: { category: 'Map' } }, + autoAdjust: { + control: 'boolean', + table: { category: 'Map' }, + description: 'Auto-fit the viewport to the markers/routes.', + }, + // --- Markers --- + showMarkers: { control: 'boolean', table: { category: 'Markers' } }, + customMarkerIcons: { + control: 'boolean', + table: { category: 'Markers' }, + description: 'Use a custom pushpin image instead of the default Leaflet marker.', + }, + // --- Routes --- + showRoutes: { control: 'boolean', table: { category: 'Routes' } }, + routeColor: { + control: 'select', + options: ['blue', 'green', 'red', 'purple', 'orange'], + table: { category: 'Routes' }, + }, + // --- Layout --- + height: { control: 'number', table: { category: 'Layout' } }, + width: { control: 'text', table: { category: 'Layout' } }, + }, +}; + +export default meta; + +type Story = StoryObj; + +const render: Story['render'] = (args) => { + const { + tileProvider, maptilerKey, thunderforestKey, stadiaKey, + type, center, zoom, controls, disabled, autoAdjust, + customMarkerIcons, showMarkers, showRoutes, routeColor, height, width, + } = args; + + // providerConfig identity changes only when the provider or a key changes, so the map rebuilds + // its tile layer then — not on every unrelated control change. + const providerConfig = useMemo(() => ({ + tileServer: (t: string) => buildTileServer(tileProvider, t, { + maptiler: maptilerKey, thunderforest: thunderforestKey, stadia: stadiaKey, + }), + getRoute, + }), [tileProvider, maptilerKey, thunderforestKey, stadiaKey]); + + const markers = useMemo(() => (showMarkers ? markersData : []), [showMarkers]); + const routes = useMemo(() => (showRoutes + ? [{ weight: 6, opacity: 0.6, color: routeColor, locations: routeWaypoints }] + : []), [showRoutes, routeColor]); + + return ( + + ); +}; + +export const Default: Story = { + args: { + tileProvider: 'MapTiler', + // Paste your own keys here in the controls panel to see tiles render. + maptilerKey: 'YOUR_MAPTILER_KEY', + thunderforestKey: 'YOUR_THUNDERFOREST_KEY', + stadiaKey: 'YOUR_STADIA_KEY', + type: 'roadmap', + center: 'New York', + zoom: 12, + controls: true, + disabled: false, + autoAdjust: false, + showMarkers: true, + customMarkerIcons: true, + showRoutes: true, + routeColor: 'blue', + height: 520, + width: '100%', + }, + render, +}; diff --git a/packages/devextreme-angular/src/ui/map/index.ts b/packages/devextreme-angular/src/ui/map/index.ts index b27d9beaf4b7..1365d3c659b2 100644 --- a/packages/devextreme-angular/src/ui/map/index.ts +++ b/packages/devextreme-angular/src/ui/map/index.ts @@ -22,7 +22,7 @@ import { } from '@angular/core'; -import type { ClickEvent, DisposingEvent, InitializedEvent, MarkerAddedEvent, MarkerRemovedEvent, OptionChangedEvent, ReadyEvent, RouteAddedEvent, RouteRemovedEvent, MapProvider, RouteMode, MapType } from 'devextreme/ui/map'; +import type { ClickEvent, DisposingEvent, InitializedEvent, MarkerAddedEvent, MarkerRemovedEvent, OptionChangedEvent, ReadyEvent, RouteAddedEvent, RouteRemovedEvent, MapProvider, OsmGetRouteParams, OsmTileServer, RouteMode, MapType } from 'devextreme/ui/map'; import DxMap from 'devextreme/ui/map'; @@ -302,10 +302,10 @@ export class DxMapComponent extends DxComponent implements OnDestroy, OnChanges, */ @Input() - get providerConfig(): { mapId?: string, useAdvancedMarkers?: boolean } { + get providerConfig(): { geocodeLocation?: ((query: string) => any), getRoute?: ((params: OsmGetRouteParams) => any), mapId?: string, tileServer?: OsmTileServer, useAdvancedMarkers?: boolean } { return this._getOption('providerConfig'); } - set providerConfig(value: { mapId?: string, useAdvancedMarkers?: boolean }) { + set providerConfig(value: { geocodeLocation?: ((query: string) => any), getRoute?: ((params: OsmGetRouteParams) => any), mapId?: string, tileServer?: OsmTileServer, useAdvancedMarkers?: boolean }) { this._setOption('providerConfig', value); } @@ -582,7 +582,7 @@ export class DxMapComponent extends DxComponent implements OnDestroy, OnChanges, * This member supports the internal infrastructure and is not intended to be used directly from your code. */ - @Output() providerConfigChange: EventEmitter<{ mapId?: string, useAdvancedMarkers?: boolean }>; + @Output() providerConfigChange: EventEmitter<{ geocodeLocation?: ((query: string) => any), getRoute?: ((params: OsmGetRouteParams) => any), mapId?: string, tileServer?: OsmTileServer, useAdvancedMarkers?: boolean }>; /** diff --git a/packages/devextreme-angular/src/ui/map/nested/provider-config.ts b/packages/devextreme-angular/src/ui/map/nested/provider-config.ts index 0b75435ab9a5..52b79cd66b22 100644 --- a/packages/devextreme-angular/src/ui/map/nested/provider-config.ts +++ b/packages/devextreme-angular/src/ui/map/nested/provider-config.ts @@ -14,6 +14,7 @@ import { +import type { OsmGetRouteParams, OsmTileServer } from 'devextreme/ui/map'; import { DxIntegrationModule, @@ -30,6 +31,22 @@ import { NestedOption } from 'devextreme-angular/core'; providers: [NestedOptionHost] }) export class DxoMapProviderConfigComponent extends NestedOption implements OnDestroy, OnInit { + @Input() + get geocodeLocation(): ((query: string) => any) { + return this._getOption('geocodeLocation'); + } + set geocodeLocation(value: ((query: string) => any)) { + this._setOption('geocodeLocation', value); + } + + @Input() + get getRoute(): ((params: OsmGetRouteParams) => any) { + return this._getOption('getRoute'); + } + set getRoute(value: ((params: OsmGetRouteParams) => any)) { + this._setOption('getRoute', value); + } + @Input() get mapId(): string { return this._getOption('mapId'); @@ -38,6 +55,14 @@ export class DxoMapProviderConfigComponent extends NestedOption implements OnDes this._setOption('mapId', value); } + @Input() + get tileServer(): OsmTileServer { + return this._getOption('tileServer'); + } + set tileServer(value: OsmTileServer) { + this._setOption('tileServer', value); + } + @Input() get useAdvancedMarkers(): boolean { return this._getOption('useAdvancedMarkers'); diff --git a/packages/devextreme-angular/src/ui/nested/provider-config.ts b/packages/devextreme-angular/src/ui/nested/provider-config.ts index c4ef29aa4db1..4a8b10676c60 100644 --- a/packages/devextreme-angular/src/ui/nested/provider-config.ts +++ b/packages/devextreme-angular/src/ui/nested/provider-config.ts @@ -14,6 +14,7 @@ import { +import type { OsmTileServer } from 'devextreme/ui/map'; import { DxIntegrationModule, @@ -30,6 +31,22 @@ import { NestedOption } from 'devextreme-angular/core'; providers: [NestedOptionHost] }) export class DxoProviderConfigComponent extends NestedOption implements OnDestroy, OnInit { + @Input() + get geocodeLocation(): Function { + return this._getOption('geocodeLocation'); + } + set geocodeLocation(value: Function) { + this._setOption('geocodeLocation', value); + } + + @Input() + get getRoute(): Function { + return this._getOption('getRoute'); + } + set getRoute(value: Function) { + this._setOption('getRoute', value); + } + @Input() get mapId(): string { return this._getOption('mapId'); @@ -38,6 +55,14 @@ export class DxoProviderConfigComponent extends NestedOption implements OnDestro this._setOption('mapId', value); } + @Input() + get tileServer(): OsmTileServer { + return this._getOption('tileServer'); + } + set tileServer(value: OsmTileServer) { + this._setOption('tileServer', value); + } + @Input() get useAdvancedMarkers(): boolean { return this._getOption('useAdvancedMarkers'); diff --git a/packages/devextreme-metadata/aspnet/enums.ts b/packages/devextreme-metadata/aspnet/enums.ts index 06c672dca88b..54680970fd01 100644 --- a/packages/devextreme-metadata/aspnet/enums.ts +++ b/packages/devextreme-metadata/aspnet/enums.ts @@ -44,7 +44,7 @@ export const enums = { Options: ['GaugeIndicator.type'], }, GeoMapProvider: { - Items: ['bing', 'google', 'googleStatic', 'azure'], + Items: ['bing', 'google', 'googleStatic', 'azure', 'osm'], }, SchedulerViewType: { Items: [ diff --git a/packages/devextreme-react/src/map.ts b/packages/devextreme-react/src/map.ts index dfaee1be9225..36def26068ae 100644 --- a/packages/devextreme-react/src/map.ts +++ b/packages/devextreme-react/src/map.ts @@ -8,7 +8,7 @@ import dxMap, { import { Component as BaseComponent, IHtmlOptions, ComponentRef, NestedComponentMeta } from "./core/component"; import NestedOption from "./core/nested-option"; -import type { ClickEvent, DisposingEvent, InitializedEvent, MarkerAddedEvent, MarkerRemovedEvent, ReadyEvent, RouteAddedEvent, RouteRemovedEvent, RouteMode } from "devextreme/ui/map"; +import type { ClickEvent, DisposingEvent, InitializedEvent, MarkerAddedEvent, MarkerRemovedEvent, ReadyEvent, RouteAddedEvent, RouteRemovedEvent, OsmGetRouteParams, OsmTileServer, RouteMode } from "devextreme/ui/map"; type ReplaceFieldTypes = { [P in keyof TSource]: P extends keyof TReplacement ? TReplacement[P] : TSource[P]; @@ -182,7 +182,10 @@ const Marker = Object.assign(_comp // owners: // Map type IProviderConfigProps = React.PropsWithChildren<{ + geocodeLocation?: ((query: string) => any); + getRoute?: ((params: OsmGetRouteParams) => any); mapId?: string; + tileServer?: OsmTileServer; useAdvancedMarkers?: boolean; }> const _componentProviderConfig = (props: IProviderConfigProps) => { diff --git a/packages/devextreme-vue/src/map.ts b/packages/devextreme-vue/src/map.ts index 7e1c8fecdc49..b1cb9ff4059b 100644 --- a/packages/devextreme-vue/src/map.ts +++ b/packages/devextreme-vue/src/map.ts @@ -14,6 +14,9 @@ import { RouteRemovedEvent, MapProvider, MapType, + OsmGetRouteParams, + OsmTileServer, + OsmTileServerConfig, RouteMode, } from "devextreme/ui/map"; import { prepareConfigurationComponentConfig } from "./core/index"; @@ -244,11 +247,17 @@ const DxProviderConfigConfig = { emits: { "update:isActive": null, "update:hoveredElement": null, + "update:geocodeLocation": null, + "update:getRoute": null, "update:mapId": null, + "update:tileServer": null, "update:useAdvancedMarkers": null, }, props: { + geocodeLocation: Function as PropType<((query: string) => any)>, + getRoute: Function as PropType<((params: OsmGetRouteParams) => any)>, mapId: String, + tileServer: [Object, Function, String] as PropType string | OsmTileServerConfig | null | undefined)) | OsmTileServerConfig | string>, useAdvancedMarkers: Boolean } }; diff --git a/packages/devextreme/eslint.config.mjs b/packages/devextreme/eslint.config.mjs index bffa402e3518..cb20e11bbbe5 100644 --- a/packages/devextreme/eslint.config.mjs +++ b/packages/devextreme/eslint.config.mjs @@ -27,6 +27,16 @@ const compat = new FlatCompat({ allConfig: js.configs.all }); +// Allow OSM/Leaflet domain identifiers used by the Map's OpenStreetMap provider +// (feature/library names that cannot be renamed: the `osm` provider, Leaflet's `latlng` +// event field, and the `subdomains` tile-layer option). +const spellCheckerRule = spellCheckConfig + .map((config) => config?.rules?.['spellcheck/spell-checker']) + .find((rule) => Array.isArray(rule) && Array.isArray(rule[1]?.skipWords)); +if (spellCheckerRule) { + spellCheckerRule[1].skipWords.push('osm', 'latlng', 'subdomains'); +} + export default [ { ignores: [ diff --git a/packages/devextreme/js/__internal/ui/map/map.ts b/packages/devextreme/js/__internal/ui/map/map.ts index 8b987349f994..613b8a214490 100644 --- a/packages/devextreme/js/__internal/ui/map/map.ts +++ b/packages/devextreme/js/__internal/ui/map/map.ts @@ -21,6 +21,7 @@ import type { LocationOption } from './provider.dynamic'; import azure from './provider.dynamic.azure'; import bing from './provider.dynamic.bing'; import google from './provider.dynamic.google'; +import osm from './provider.dynamic.osm'; // NOTE external urls must have protocol explicitly specified // (because inside Cordova package the protocol is "file:") import googleStatic from './provider.google_static'; @@ -30,6 +31,7 @@ const PROVIDERS = { googleStatic, google, bing, + osm, }; const MAP_CLASS = 'dx-map'; @@ -54,7 +56,7 @@ class Map extends Widget { _lastAsyncAction!: Promise; - _provider!: azure | googleStatic | google | bing; + _provider!: azure | googleStatic | google | bing | osm; _asyncActionSuppressed?: boolean; @@ -262,7 +264,7 @@ class Map extends Widget { } _optionChanged(args: OptionChanged): void { - const { name, value } = args; + const { name, fullName, value } = args; const changeBag = this._optionChangeBag; this._optionChangeBag = null; @@ -336,8 +338,15 @@ class Map extends Widget { this._queueAsyncAction('updateMarkers', this._rendered.markers, this._rendered.markers); break; case 'providerConfig': - this._suppressAsyncAction = true; - this._invalidate(); + // The OSM tile server can be swapped at runtime without recreating the map. + // Any other providerConfig change requires a full provider re-initialization. + if (fullName === 'providerConfig.tileServer') { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this._queueAsyncAction('updateTileServer'); + } else { + this._suppressAsyncAction = true; + this._invalidate(); + } break; case 'onReady': case 'onUpdated': diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts new file mode 100644 index 000000000000..c3d6b55de296 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts @@ -0,0 +1,593 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ +import Color from '@js/color'; +import { noop } from '@js/core/utils/common'; +import { isDefined } from '@js/core/utils/type'; +import { getWindow } from '@js/core/utils/window'; +import type { RouteMode } from '@js/ui/map'; +import errors from '@js/ui/widget/ui.errors'; + +import type { + LocationOption, + MarkerObject, MarkerOptions, PlainLocation, RouteObject, RouteOptions, +} from './provider.dynamic'; +import DynamicProvider from './provider.dynamic'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +declare let L: any; + +const window = getWindow(); + +let LEAFLET_JS_URL = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'; +let LEAFLET_CSS_URL = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'; + +const DEFAULT_MAX_ZOOM = 19; +const DEFAULT_SUBDOMAINS = 'abc'; + +interface OsmTileServerConfig { + url: string; + attribution?: string; + subdomains?: string | string[]; + maxZoom?: number; +} + +type OsmTileServerObject = OsmTileServerConfig + | ((type: string) => string | OsmTileServerConfig | null | undefined); +type OsmTileServerOption = string | OsmTileServerObject; + +export type OsmLocation = PlainLocation; + +// @ts-expect-error ts-error +const osmMapsLoaded = (): boolean => Boolean(window.L?.map); + +// eslint-disable-next-line @typescript-eslint/init-declarations +let osmMapsLoader: Promise | undefined; + +class OsmProvider extends DynamicProvider { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + _tileLayer?: any; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + _zoomControl?: any; + + _currentTileType!: string; + + _clickHandler?: (e: { latlng: OsmLocation; originalEvent: Event }) => void; + + _viewChangeHandler?: () => void; + + _preventZoomChangeEvent?: boolean; + + _movementMode(type: RouteMode | string = ''): string { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return type || 'driving'; + } + + _resolveLocation(location?: LocationOption | null): Promise { + return new Promise((resolve) => { + const latLng = this._getLatLng(location); + if (latLng) { + resolve(L.latLng(latLng.lat, latLng.lng)); + } else { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this._geocodeLocation(location as string).then((geocodedLocation) => { + resolve(geocodedLocation as OsmLocation); + }); + } + }); + } + + _geocodeLocationImpl(location: string): Promise { + return new Promise((resolve) => { + if (!isDefined(location)) { + resolve(L.latLng(0, 0)); + return; + } + + const geocodeFn = this._option('providerConfig')?.geocodeLocation as ((query: string) => Promise<{ lat: number; lng: number } | null | undefined>) | undefined; + + if (!geocodeFn) { + errors.log('W1031', location); + resolve(L.latLng(0, 0)); + return; + } + + geocodeFn(location).then((result) => { + if (result?.lat != null && result?.lng != null) { + resolve(L.latLng(result.lat, result.lng)); + } else { + resolve(L.latLng(0, 0)); + } + }).catch(() => { + resolve(L.latLng(0, 0)); + }); + }); + } + + _normalizeLocation(location: OsmLocation): { lat: number; lng: number } { + return { + lat: location.lat, + lng: location.lng, + }; + } + + _loadImpl(): Promise { + return new Promise((resolve, reject) => { + if (osmMapsLoaded()) { + resolve(); + return; + } + + const fail = (): void => { reject(errors.Error('W1033')); }; + + osmMapsLoader ??= this._loadMapResources(); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + osmMapsLoader.then(() => { + if (osmMapsLoaded()) { + resolve(); + return; + } + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this._loadMapResources().then(() => { + if (osmMapsLoaded()) { + resolve(); + } else { + fail(); + } + }, fail); + }, fail); + }); + } + + _loadMapResources(): Promise { + return Promise.all([ + this._loadMapScript(), + this._loadMapStyles(), + ]).then(() => {}); + } + + _loadMapScript(): Promise { + return new Promise((resolve, reject) => { + const script = window.document.createElement('script'); + script.async = true; + script.onload = (): void => { resolve(); }; + script.onerror = (): void => { reject(); }; + script.src = LEAFLET_JS_URL; + window.document.head.appendChild(script); + }); + } + + _loadMapStyles(): Promise { + return new Promise((resolve, reject) => { + const link = window.document.createElement('link'); + link.rel = 'stylesheet'; + link.onload = (): void => { resolve(); }; + link.onerror = (): void => { reject(); }; + link.href = LEAFLET_CSS_URL; + window.document.head.appendChild(link); + }); + } + + _init(): Promise { + return this._resolveLocation(this._option('center')).then((center) => { + const type = this._option('type') ?? 'roadmap'; + + this._map = L.map(this._$container[0], { + center, + zoom: this._option('zoom'), + zoomControl: false, + attributionControl: true, + }); + + this._zoomControl = L.control.zoom(); + if (this._option('controls')) { + this._zoomControl.addTo(this._map); + } + + this._currentTileType = type; + this._tileLayer = this._buildTileLayer(type); + this._tileLayer?.addTo(this._map); + + this._option('center', this._normalizeLocation(center)); + }); + } + + _resolveTileConfig(type: string): OsmTileServerConfig | undefined { + const option = this._option('providerConfig')?.tileServer as OsmTileServerOption | null | undefined; + + const resolved = typeof option === 'function' ? option(type) : option; + + if (!resolved) { + return undefined; + } + + return typeof resolved === 'string' ? { url: resolved } : resolved; + } + + _buildTileLayer(type: string): unknown { + const config = this._resolveTileConfig(type); + + if (!config?.url) { + errors.log('W1030'); + return undefined; + } + + if (!config.attribution) { + errors.log('W1032'); + } + + const options: Record = { + attribution: config.attribution ?? '', + maxZoom: config.maxZoom ?? DEFAULT_MAX_ZOOM, + }; + + if (config.url.includes('{s}')) { + options.subdomains = config.subdomains ?? DEFAULT_SUBDOMAINS; + } + + return L.tileLayer(config.url, options); + } + + _attachHandlers(): void { + this._viewChangeHandler = this._onViewChange.bind(this); + this._clickHandler = this._onMapClick.bind(this); + + this._map.on('moveend', this._viewChangeHandler); + this._map.on('zoomend', this._viewChangeHandler); + this._map.on('click', this._clickHandler); + } + + _onViewChange(): void { + const bounds = this._map.getBounds(); + this._option('bounds', { + northEast: { lat: bounds.getNorthEast().lat, lng: bounds.getNorthEast().lng }, + southWest: { lat: bounds.getSouthWest().lat, lng: bounds.getSouthWest().lng }, + }); + + const center = this._normalizeLocation(this._map.getCenter()); + const currentCenter = this._getLatLng(this._option('center')); + if (!currentCenter || currentCenter.lat !== center.lat || currentCenter.lng !== center.lng) { + this._option('center', center); + } + + if (!this._preventZoomChangeEvent) { + this._option('zoom', this._map.getZoom()); + } + } + + _onMapClick(e: { latlng: OsmLocation; originalEvent: Event }): void { + this._fireClickAction({ + location: this._normalizeLocation(e.latlng), + event: e.originalEvent, + }); + } + + updateDimensions(): Promise { + this._map.invalidateSize(); + + return Promise.resolve(); + } + + updateMapType(): Promise { + const type = this._option('type') ?? this._currentTileType; + + if (type !== this._currentTileType) { + this._currentTileType = type; + this._rebuildTileLayer(type); + } + + return Promise.resolve(); + } + + updateTileServer(): Promise { + this._rebuildTileLayer(this._currentTileType); + + return Promise.resolve(); + } + + _rebuildTileLayer(type: string): void { + if (this._tileLayer) { + this._map.removeLayer(this._tileLayer); + } + this._tileLayer = this._buildTileLayer(type); + this._tileLayer?.addTo(this._map); + } + + updateDisabled(): Promise { + const disabled = this._option('disabled'); + const handlers = [ + this._map.dragging, + this._map.touchZoom, + this._map.doubleClickZoom, + this._map.scrollWheelZoom, + this._map.boxZoom, + this._map.keyboard, + ]; + + handlers.forEach((handler) => { + if (handler) { + if (disabled) { + handler.disable(); + } else { + handler.enable(); + } + } + }); + + return Promise.resolve(); + } + + updateBounds(): Promise { + const bounds = this._option('bounds'); + + return Promise.all([ + this._resolveLocation(bounds?.northEast), + this._resolveLocation(bounds?.southWest), + ]).then((result) => { + this._map.fitBounds(L.latLngBounds(result[1], result[0])); + }); + } + + updateCenter(): Promise { + return this._resolveLocation(this._option('center')).then((center) => { + this._map.setView(center, this._option('zoom')); + this._option('center', this._normalizeLocation(center)); + }); + } + + updateZoom(): Promise { + this._map.setZoom(this._option('zoom')); + + return Promise.resolve(); + } + + updateControls(): Promise { + const controls = this._option('controls'); + + if (controls) { + this._zoomControl.addTo(this._map); + } else { + this._zoomControl.remove(); + } + + return Promise.resolve(); + } + + _anchorIconBottomCenter(marker: unknown): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const leafletMarker = marker as any; + const iconElement = leafletMarker.getElement?.() ?? leafletMarker._icon; + if (!iconElement || typeof iconElement.addEventListener !== 'function') { + return; + } + const applyAnchor = (): void => { + const iconWidth = iconElement.naturalWidth; + const iconHeight = iconElement.naturalHeight; + if (!iconWidth || !iconHeight) { + return; + } + iconElement.style.marginLeft = `${-iconWidth / 2}px`; + iconElement.style.marginTop = `${-iconHeight}px`; + + const popup = leafletMarker.getPopup?.(); + if (popup) { + popup.options.offset = L.point(0, -iconHeight); + if (leafletMarker.isPopupOpen()) { + leafletMarker.openPopup(); + } + } + }; + if (iconElement.complete && iconElement.naturalWidth) { + applyAnchor(); + } else { + iconElement.addEventListener('load', applyAnchor, { once: true }); + } + } + + _renderMarker(options: MarkerOptions): Promise { + return this._resolveLocation(options.location).then((location) => { + // eslint-disable-next-line @typescript-eslint/init-declarations + let marker; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const icon = options.iconSrc || this._option('markerIconSrc'); + + if (options.html) { + const divIcon = L.divIcon({ + html: options.html, + className: '', + iconAnchor: options.htmlOffset + ? [-options.htmlOffset.left, -options.htmlOffset.top] + : [0, 0], + }); + marker = L.marker(location, { icon: divIcon }); + } else if (icon) { + const customIcon = L.icon({ + iconUrl: icon, + className: 'dx-map-marker', + }); + marker = L.marker(location, { icon: customIcon }); + } else { + marker = L.marker(location); + } + + marker.addTo(this._map); + + const popup = this._renderTooltip(marker, options.tooltip); + + if (icon && !options.html) { + this._anchorIconBottomCenter(marker); + } + + // eslint-disable-next-line @typescript-eslint/init-declarations + let clickHandler: (() => void) | undefined; + if (options.onClick || options.tooltip) { + const markerClickAction = this._mapWidget._createAction(options.onClick ?? noop); + const normalizedLocation = this._normalizeLocation(location); + + clickHandler = (): void => { + markerClickAction({ location: normalizedLocation }); + + if (popup) { + if (marker.isPopupOpen()) { + marker.closePopup(); + } else { + marker.openPopup(); + } + } + }; + + marker.on('click', clickHandler); + } + + return { + location, + marker, + popup, + handler: clickHandler, + }; + }); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + _renderTooltip(marker: unknown, options: MarkerOptions['tooltip']): any { + if (!options) { + return undefined; + } + + const parsedOptions = this._parseTooltipOptions(options); + const popup = L.popup({ autoClose: false, closeOnClick: false }).setContent(parsedOptions.text); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const leafletMarker = marker as any; + leafletMarker.bindPopup(popup); + + if (parsedOptions.visible) { + // eslint-disable-next-line no-restricted-globals + setTimeout(() => { leafletMarker.openPopup(); }, 0); + } + + return popup; + } + + _destroyMarker(markerObj: { + marker: { off: (event: string, fn: unknown) => void; remove: () => void }; + handler?: () => void; + }): void { + if (markerObj.handler) { + markerObj.marker.off('click', markerObj.handler); + } + markerObj.marker.remove(); + } + + _renderRoute(options: RouteOptions): Promise { + const locations = options.locations ?? []; + + return Promise.all(locations.map((point) => this._resolveLocation(point))) + .then((resolvedLocations) => new Promise((resolve) => { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const color = new Color(options.color || this._defaultRouteColor()).toHex(); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const opacity = options.opacity || this._defaultRouteOpacity(); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const weight = options.weight || this._defaultRouteWeight(); + const mode = this._movementMode(options.mode); + + const polylineOptions = { color, opacity, weight }; + const normalizedLocations = resolvedLocations.map((loc) => this._normalizeLocation(loc)); + + const getRouteFn = this._option('providerConfig')?.getRoute as ((params: { locations: { lat: number; lng: number }[]; mode: string }) => Promise<[number, number][]>) | undefined; + + const drawPolyline = (coords: [number, number][]): void => { + const polyline = L.polyline(coords, polylineOptions).addTo(this._map); + const routeBounds = polyline.getBounds(); + + resolve({ + instance: polyline, + northEast: [routeBounds.getNorthEast().lat, routeBounds.getNorthEast().lng], + southWest: [routeBounds.getSouthWest().lat, routeBounds.getSouthWest().lng], + }); + }; + + if (!getRouteFn) { + drawPolyline(resolvedLocations.map((loc) => [loc.lat, loc.lng] as [number, number])); + return; + } + + getRouteFn({ locations: normalizedLocations, mode }).then((coords) => { + drawPolyline(coords); + }).catch((e: unknown) => { + errors.log('W1006', e); + + drawPolyline(resolvedLocations.map((loc) => [loc.lat, loc.lng] as [number, number])); + }); + })); + } + + _destroyRoute(routeObj: { instance: { remove: () => void } }): void { + routeObj.instance.remove(); + } + + _fitBounds(): Promise { + this._updateBounds(); + + if (this._bounds && this._option('autoAdjust')) { + const zoomBeforeFitting = this._map.getZoom(); + this._preventZoomChangeEvent = true; + + this._map.fitBounds(this._bounds); + + const zoomAfterFitting = this._map.getZoom(); + if (zoomBeforeFitting < zoomAfterFitting) { + this._map.setZoom(zoomBeforeFitting); + } else { + this._option('zoom', zoomAfterFitting); + } + + delete this._preventZoomChangeEvent; + } + + return Promise.resolve(); + } + + _extendBounds(location: unknown): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const loc = location as any; + const latLng = Array.isArray(loc) + ? L.latLng(loc[0], loc[1]) + : L.latLng(loc.lat, loc.lng); + + if (this._bounds) { + this._bounds.extend(latLng); + } else { + this._bounds = L.latLngBounds(latLng, latLng); + } + } + + clean(): Promise { + if (this._map) { + this._map.off('moveend', this._viewChangeHandler); + this._map.off('zoomend', this._viewChangeHandler); + this._map.off('click', this._clickHandler); + + this._clearMarkers(); + this._clearRoutes(); + + this._map.remove(); + this._map = undefined; + this._zoomControl = undefined; + } + + return Promise.resolve(); + } +} + +/// #DEBUG +// @ts-expect-error ts-error +OsmProvider.remapConstant = (newValue: string): void => { + LEAFLET_JS_URL = newValue; + LEAFLET_CSS_URL = newValue; +}; + +/// #ENDDEBUG + +export default OsmProvider; diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.ts index f873b4043a2f..bfb0b76998de 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.ts @@ -20,6 +20,11 @@ export interface LocationCoords { lng: number; } +export interface PlainLocation { + lat: number; + lng: number; +} + export interface MarkerOptions { iconSrc?: string; location?: LocationCoords; @@ -64,7 +69,7 @@ class DynamicProvider extends Provider { _routes!: (RouteObject & { options: RouteOptions })[]; - _geocodedLocations: Record; + _geocodedLocations: Record; _mapsLoader?: Promise; @@ -76,7 +81,7 @@ class DynamicProvider extends Provider { _geocodeLocation( location: string, - ): Promise { + ): Promise { return new Promise((resolve) => { const cache = this._geocodedLocations; const cachedLocation = cache[location]; @@ -282,7 +287,7 @@ class DynamicProvider extends Provider { _geocodeLocationImpl( // eslint-disable-next-line @typescript-eslint/no-unused-vars location: string, - ): Promise { + ): Promise { return Promise.resolve([0, 0]); } diff --git a/packages/devextreme/js/__internal/ui/map/provider.ts b/packages/devextreme/js/__internal/ui/map/provider.ts index 7a34a8329f18..c5b11850b4ba 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.ts @@ -57,6 +57,10 @@ class Provider { Class.abstract(); } + updateTileServer(): Promise { + return Promise.resolve(); + } + updateDisabled(): void { Class.abstract(); } @@ -173,7 +177,9 @@ class Provider { return key; } if (isPlainObject(key)) { - return key[providerName] ?? ''; + // apiKey only carries keyed providers (azure/bing/google/googleStatic); other providers + // such as 'osm' simply have no entry, so index through a string-keyed view. + return (key as Record)[providerName] ?? ''; } return ''; } diff --git a/packages/devextreme/js/ui/map.d.ts b/packages/devextreme/js/ui/map.d.ts index 547e62a96f0f..4c73c12a0248 100644 --- a/packages/devextreme/js/ui/map.d.ts +++ b/packages/devextreme/js/ui/map.d.ts @@ -14,9 +14,64 @@ import Widget, { } from './widget/ui.widget'; /** @public */ -export type MapProvider = 'azure' | 'bing' | 'google' | 'googleStatic'; +export type MapProvider = 'azure' | 'bing' | 'google' | 'googleStatic' | 'osm'; /** @public */ export type RouteMode = 'driving' | 'walking'; + +/** + * @docid + * @public + */ +export type OsmGeocodeFunction = (query: string) => Promise; + +/** + * @docid + * @public + */ +export interface OsmGetRouteParams { + locations: Array; + mode: RouteMode | string; +} + +/** + * @docid + * @public + */ +export type OsmGetRouteFunction = (params: OsmGetRouteParams) => Promise>; + +/** + * @docid + * @public + */ +export interface OsmTileServerConfig { + /** + * @docid + */ + url: string; + /** + * @docid + * @default '' + */ + attribution?: string; + /** + * @docid + * @default 'abc' + */ + subdomains?: string | Array; + /** + * @docid + * @default 19 + */ + maxZoom?: number; +} + +/** + * @docid + * @public + */ +export type OsmTileServer = string | OsmTileServerConfig +| ((type: MapType | string) => string | OsmTileServerConfig | null | undefined); + /** @public */ export type MapType = 'hybrid' | 'roadmap' | 'satellite'; @@ -336,6 +391,24 @@ export interface dxMapOptions extends WidgetOptions { * @deprecated */ useAdvancedMarkers?: boolean; + /** + * @docid + * @public + * @default undefined + */ + tileServer?: OsmTileServer; + /** + * @docid + * @public + * @default undefined + */ + geocodeLocation?: ((query: string) => Promise); + /** + * @docid + * @public + * @default undefined + */ + getRoute?: ((params: OsmGetRouteParams) => Promise>); }; /** * @docid diff --git a/packages/devextreme/js/ui/map_types.d.ts b/packages/devextreme/js/ui/map_types.d.ts index 2c1637b47f4d..a4a397278a97 100644 --- a/packages/devextreme/js/ui/map_types.d.ts +++ b/packages/devextreme/js/ui/map_types.d.ts @@ -1,6 +1,11 @@ export { MapProvider, RouteMode, + OsmGeocodeFunction, + OsmGetRouteParams, + OsmGetRouteFunction, + OsmTileServerConfig, + OsmTileServer, MapType, ClickEvent, DisposingEvent, diff --git a/packages/devextreme/js/ui/widget/ui.errors.js b/packages/devextreme/js/ui/widget/ui.errors.js index 29438d4d7e31..ca958fe3296f 100644 --- a/packages/devextreme/js/ui/widget/ui.errors.js +++ b/packages/devextreme/js/ui/widget/ui.errors.js @@ -406,4 +406,20 @@ export default errorUtils(errors.ERROR_MESSAGES, { * @name ErrorsUIWidgets.W1029 */ W1029: '\'hiddenWeekDays\' must leave at least one weekday visible.', + /** + * @name ErrorsUIWidgets.W1030 + */ + W1030: 'No tile server is configured for the OSM map provider. Specify the "providerConfig.tileServer" option.', + /** + * @name ErrorsUIWidgets.W1031 + */ + W1031: 'No geocoding service is configured for the OSM map provider. Specify the "providerConfig.geocodeLocation" option.', + /** + * @name ErrorsUIWidgets.W1032 + */ + W1032: 'The OSM map provider tile server is configured without an "attribution". Attribution is required when displaying OpenStreetMap data; set the "attribution" field of the "providerConfig.tileServer" option (for example, "© OpenStreetMap contributors").', + /** + * @name ErrorsUIWidgets.W1033 + */ + W1033: 'The OSM map provider failed to load the Leaflet library. Make sure the Leaflet script and stylesheet URLs are reachable.', }); diff --git a/packages/devextreme/testing/helpers/forMap/leafletMock.js b/packages/devextreme/testing/helpers/forMap/leafletMock.js new file mode 100644 index 000000000000..2c6665e5b072 --- /dev/null +++ b/packages/devextreme/testing/helpers/forMap/leafletMock.js @@ -0,0 +1,258 @@ +/* eslint-disable spellcheck/spell-checker -- Leaflet/geo domain terms in the test mock (e.g. Polylines, lngs) */ +(() => { + const makeLatLng = (lat, lng) => ({ + lat: typeof lat === 'object' ? lat.lat : lat, + lng: typeof lat === 'object' ? lat.lng : lng, + }); + + const makeBounds = (sw, ne) => { + const _sw = makeLatLng(sw.lat ?? sw[0], sw.lng ?? sw[1]); + const _ne = makeLatLng(ne.lat ?? ne[0], ne.lng ?? ne[1]); + + const bounds = { + _sw, + _ne, + extend(latLng) { + const loc = makeLatLng(latLng.lat, latLng.lng); + this._sw = { lat: Math.min(this._sw.lat, loc.lat), lng: Math.min(this._sw.lng, loc.lng) }; + this._ne = { lat: Math.max(this._ne.lat, loc.lat), lng: Math.max(this._ne.lng, loc.lng) }; + return this; + }, + getNorthEast() { return this._ne; }, + getSouthWest() { return this._sw; }, + }; + return bounds; + }; + + window.L = { + // --- Map --- + map: function(container, options) { + L.mapOptions = options; + L.mapCreated = true; + + const map = { + _center: options?.center ?? { lat: 0, lng: 0 }, + _zoom: options?.zoom ?? 1, + _eventHandlers: {}, + + on(event, handler) { + L.addedEvents = L.addedEvents || []; + L.addedEvents.push(event); + this._eventHandlers[event] = this._eventHandlers[event] || []; + this._eventHandlers[event].push(handler); + + if(event === 'click') { L.mapClickCallback = handler; } + if(event === 'moveend') { L.mapMoveEndCallback = handler; } + if(event === 'zoomend') { L.mapZoomEndCallback = handler; } + }, + off(event, handler) { + L.removedEvents = L.removedEvents || []; + L.removedEvents.push(event); + if(this._eventHandlers[event]) { + this._eventHandlers[event] = this._eventHandlers[event].filter(h => h !== handler); + } + }, + setView(center, zoom) { + L.setViewArgs = { center, zoom }; + this._center = center; + this._zoom = zoom ?? this._zoom; + }, + setZoom(zoom) { + L.setZoomArg = zoom; + this._zoom = zoom; + }, + getZoom() { return L.mockZoom ?? this._zoom; }, + getCenter() { return L.mockCenter ?? this._center; }, + getBounds() { + return L.mockBounds ?? makeBounds( + { lat: (this._center.lat ?? 0) - 1, lng: (this._center.lng ?? 0) - 1 }, + { lat: (this._center.lat ?? 0) + 1, lng: (this._center.lng ?? 0) + 1 } + ); + }, + fitBounds(bounds) { + L.fitBoundsArg = bounds; + if(bounds && bounds.getNorthEast) { + this._center = { + lat: (bounds.getNorthEast().lat + bounds.getSouthWest().lat) / 2, + lng: (bounds.getNorthEast().lng + bounds.getSouthWest().lng) / 2, + }; + } + }, + invalidateSize() { L.mapResized = true; }, + removeLayer(layer) { + L.removedLayers = L.removedLayers || []; + L.removedLayers.push(layer); + }, + remove() { L.mapDisposed = true; }, + zoomControl: { + addTo() { L.zoomControlAdded = true; }, + remove() { L.zoomControlAdded = false; }, + }, + dragging: { enable() {}, disable() {} }, + touchZoom: { enable() {}, disable() {} }, + doubleClickZoom: { enable() {}, disable() {} }, + scrollWheelZoom: { enable() {}, disable() {} }, + boxZoom: { enable() {}, disable() {} }, + keyboard: { enable() {}, disable() {} }, + attributionControl: {}, + }; + + L.mapInstance = map; + return map; + }, + + // --- Tile Layer --- + tileLayer: function(url, options) { + L.tileLayerUrl = url; + L.tileLayerOptions = options; + const layer = { + addTo(map) { + L.addedTileLayers = L.addedTileLayers || []; + L.addedTileLayers.push({ url, options }); + return layer; + }, + }; + return layer; + }, + + // --- LatLng --- + latLng: function(lat, lng) { + if(typeof lat === 'object' && lat !== null && !Array.isArray(lat)) { + return makeLatLng(lat.lat, lat.lng); + } + return makeLatLng(lat, lng); + }, + + // --- LatLngBounds --- + latLngBounds: function(sw, ne) { + return makeBounds(sw, ne ?? sw); + }, + + // --- Marker --- + marker: function(latLng, options) { + L.markerOptions = options; + L.lastMarkerLatLng = latLng; + + const marker = { + _latLng: latLng, + _popup: null, + _popupOpen: false, + _handlers: {}, + + addTo(map) { + L.addedMarkers = L.addedMarkers || []; + L.addedMarkers.push(marker); + return marker; + }, + remove() { + L.removedMarkers = L.removedMarkers || []; + L.removedMarkers.push(marker); + }, + on(event, handler) { + this._handlers[event] = handler; + if(event === 'click') { + L.markerClickCallback = handler; + } + }, + off(event) { + delete this._handlers[event]; + }, + bindPopup(popup) { + this._popup = popup; + L.boundPopup = popup; + return marker; + }, + openPopup() { + this._popupOpen = true; + L.popupOpened = true; + return marker; + }, + closePopup() { + this._popupOpen = false; + L.popupOpened = false; + return marker; + }, + isPopupOpen() { + return this._popupOpen; + }, + }; + + return marker; + }, + + // --- DivIcon --- + divIcon: function(options) { + L.divIconOptions = options; + return { isDivIcon: true, options }; + }, + + // --- Icon --- + icon: function(options) { + L.iconOptions = options; + return { isIcon: true, options }; + }, + + // --- Popup --- + popup: function(options) { + L.popupOptions = options; + const popup = { + _content: '', + setContent(text) { + this._content = text; + L.popupContent = text; + return popup; + }, + }; + return popup; + }, + + // --- Polyline --- + polyline: function(coords, options) { + L.polylineCoords = coords; + L.polylineOptions = options; + + const polyline = { + _coords: coords, + addTo(map) { + L.addedPolylines = L.addedPolylines || []; + L.addedPolylines.push(polyline); + return polyline; + }, + remove() { + L.removedPolylines = L.removedPolylines || []; + L.removedPolylines.push(polyline); + }, + getBounds() { + if(!coords || coords.length === 0) { + return makeBounds({ lat: 0, lng: 0 }, { lat: 0, lng: 0 }); + } + const lats = coords.map(c => c[0]); + const lngs = coords.map(c => c[1]); + return makeBounds( + { lat: Math.min(...lats), lng: Math.min(...lngs) }, + { lat: Math.max(...lats), lng: Math.max(...lngs) } + ); + }, + }; + return polyline; + }, + + // --- Control --- + control: { + zoom: function() { + const ctrl = { + addTo(map) { L.zoomControlAdded = true; return ctrl; }, + remove() { L.zoomControlAdded = false; return ctrl; }, + }; + return ctrl; + }, + }, + + // --- Icon.Default --- + Icon: { + Default: { + imagePath: null, + } + }, + }; +})(); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/map.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/map.tests.js index 6f93519a013d..949d5f68c868 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/map.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/map.tests.js @@ -14,3 +14,4 @@ import './mapParts/googleStaticTests.js'; import './mapParts/googleTests.js'; import './mapParts/bingTests.js'; import './mapParts/azureTests.js'; +import './mapParts/osmTests.js'; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js new file mode 100644 index 000000000000..7c33ad600862 --- /dev/null +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -0,0 +1,1067 @@ +/* global L */ +/* eslint-disable spellcheck/spell-checker -- Leaflet/geo domain terms in tests (e.g. Polylines) */ + +import $ from 'jquery'; +import { MARKERS, ROUTES } from './utils.js'; +import OsmProvider from '__internal/ui/map/provider.dynamic.osm'; +import errors from 'ui/widget/ui.errors'; + +import 'ui/map'; + +const prepareTestingOsmProvider = () => { + L.mapCreated = false; + L.mapDisposed = false; + L.mapResized = false; + L.mapOptions = null; + L.setViewArgs = null; + L.setZoomArg = null; + L.fitBoundsArg = null; + L.addedMarkers = []; + L.removedMarkers = []; + L.addedPolylines = []; + L.removedPolylines = []; + L.addedTileLayers = []; + L.removedLayers = []; + L.addedEvents = []; + L.removedEvents = []; + L.tileLayerUrl = null; + L.tileLayerOptions = null; + L.zoomControlAdded = false; + L.popupOpened = false; + L.markerClickCallback = null; + L.mapClickCallback = null; + L.mapMoveEndCallback = null; + L.mapZoomEndCallback = null; + L.mockZoom = null; + L.mockCenter = null; + L.mockBounds = null; + L.mapInstance = null; + L.boundPopup = null; + L.popupContent = null; +}; + +const moduleConfig = { + beforeEach: function() { + const fakeUrl = '/fakeLeafletUrl'; + let leafletMockLoaded = false; + + OsmProvider.remapConstant(fakeUrl); + + // The provider loads Leaflet via CSP-compliant