From 5a9479f9081c702d301eec99878818e3ccf73b25 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Wed, 17 Jun 2026 13:22:11 +0300 Subject: [PATCH 01/12] feat(Map): add OpenStreetMap (OSM) provider for dxMap Keyless, Leaflet-based OSM provider (provider: 'osm'). Tiles, geocoding and routing are supplied by the consumer via providerConfig.{tileServer, geocodeLocation, getRoute} rather than bundled, so no public OSM tile server is used by default. Adds errors W1030/W1031/W1032 for missing tile server, geocode callback and attribution. - New provider implementation and Leaflet integration - 'osm' added to MapProvider; providerConfig OSM types in map.d.ts - Regenerated React/Vue/Angular wrappers and aspnet metadata enum - React Storybook story (Map/OSM Provider) - Unit tests with a Leaflet mock --- .../stories/map/OSMMap.stories.tsx | 239 ++++ .../devextreme-angular/src/ui/map/index.ts | 8 +- .../src/ui/map/nested/provider-config.ts | 25 + .../src/ui/nested/provider-config.ts | 25 + packages/devextreme-metadata/aspnet/enums.ts | 2 +- packages/devextreme-react/src/map.ts | 5 +- packages/devextreme-vue/src/map.ts | 11 + packages/devextreme/eslint.config.mjs | 10 + .../devextreme/js/__internal/ui/map/map.ts | 17 +- .../__internal/ui/map/provider.dynamic.osm.ts | 611 ++++++++++ .../js/__internal/ui/map/provider.dynamic.ts | 11 +- .../js/__internal/ui/map/provider.ts | 8 +- packages/devextreme/js/ui/map.d.ts | 75 +- packages/devextreme/js/ui/map_types.d.ts | 5 + packages/devextreme/js/ui/widget/ui.errors.js | 12 + .../testing/helpers/forMap/leafletMock.js | 258 ++++ .../tests/DevExpress.ui.widgets/map.tests.js | 1 + .../mapParts/osmTests.js | 1061 +++++++++++++++++ packages/devextreme/ts/dx.all.d.ts | 68 +- 19 files changed, 2436 insertions(+), 16 deletions(-) create mode 100644 apps/react-storybook/stories/map/OSMMap.stories.tsx create mode 100644 packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts create mode 100644 packages/devextreme/testing/helpers/forMap/leafletMock.js create mode 100644 packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js 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..79cb5ef5feed --- /dev/null +++ b/apps/react-storybook/stories/map/OSMMap.stories.tsx @@ -0,0 +1,239 @@ +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' } }, + zoom: { control: { type: 'number', min: 1, max: 19 }, table: { category: 'Map' } }, + 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..4be6d38dde81 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, OSMGeocodeFunction, OSMGetRouteFunction, 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?: OSMGeocodeFunction, getRoute?: OSMGetRouteFunction, mapId?: string, tileServer?: OSMTileServer, useAdvancedMarkers?: boolean } { return this._getOption('providerConfig'); } - set providerConfig(value: { mapId?: string, useAdvancedMarkers?: boolean }) { + set providerConfig(value: { geocodeLocation?: OSMGeocodeFunction, getRoute?: OSMGetRouteFunction, 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?: OSMGeocodeFunction, getRoute?: OSMGetRouteFunction, 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..8b464a26bf6d 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 { OSMGeocodeFunction, OSMGetRouteFunction, 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(): OSMGeocodeFunction { + return this._getOption('geocodeLocation'); + } + set geocodeLocation(value: OSMGeocodeFunction) { + this._setOption('geocodeLocation', value); + } + + @Input() + get getRoute(): OSMGetRouteFunction { + return this._getOption('getRoute'); + } + set getRoute(value: OSMGetRouteFunction) { + 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..b52e8518e9cf 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 { OSMGeocodeFunction, OSMGetRouteFunction, 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(): OSMGeocodeFunction { + return this._getOption('geocodeLocation'); + } + set geocodeLocation(value: OSMGeocodeFunction) { + this._setOption('geocodeLocation', value); + } + + @Input() + get getRoute(): OSMGetRouteFunction { + return this._getOption('getRoute'); + } + set getRoute(value: OSMGetRouteFunction) { + 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..baba689e1ef4 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, OSMGeocodeFunction, OSMGetRouteFunction, 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?: OSMGeocodeFunction; + getRoute?: OSMGetRouteFunction; 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..14d5db667aff 100644 --- a/packages/devextreme-vue/src/map.ts +++ b/packages/devextreme-vue/src/map.ts @@ -14,6 +14,11 @@ import { RouteRemovedEvent, MapProvider, MapType, + OSMGeocodeFunction, + OSMGetRouteFunction, + OSMGetRouteParams, + OSMTileServer, + OSMTileServerConfig, RouteMode, } from "devextreme/ui/map"; import { prepareConfigurationComponentConfig } from "./core/index"; @@ -244,11 +249,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: [Object, Function] as PropType any))>, + getRoute: [Object, Function] as PropType 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 9338d6973791..f3fb5da344dc 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..bdf72cf9a425 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts @@ -0,0 +1,611 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ +import Color from '@js/color'; +import $ from '@js/core/renderer'; +import ajax from '@js/core/utils/ajax'; +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 { + const modes: Record = { + driving: 'driving', + walking: 'walking', + }; + + return modes[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 unknown 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) => { + if (osmMapsLoaded()) { + resolve(); + return; + } + + 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(resolve); + }); + }); + } + + _loadMapResources(): Promise { + return Promise.all([ + this._loadMapScript(), + this._loadMapStyles(), + ]).then(() => {}); + } + + _loadMapScript(): Promise { + return new Promise((resolve) => { + ajax.sendRequest({ + url: LEAFLET_JS_URL, + dataType: 'script', + }).then(() => { + resolve(); + }); + }); + } + + _loadMapStyles(): Promise { + return new Promise((resolve) => { + ajax.sendRequest({ + url: LEAFLET_CSS_URL, + dataType: 'text', + }).then((css) => { + $('