diff --git a/apps/react-storybook/.storybook/main.ts b/apps/react-storybook/.storybook/main.ts index cb34725f0099..214137d65ce0 100644 --- a/apps/react-storybook/.storybook/main.ts +++ b/apps/react-storybook/.storybook/main.ts @@ -28,7 +28,11 @@ const config: StorybookConfig = { docs: {}, - staticDirs: ['../stories/assets', '../node_modules/devextreme/dist'], + staticDirs: [ + '../stories/assets', + '../node_modules/devextreme/dist', + { from: '../../demos/images/Maps', to: '/images/maps' }, + ], typescript: { reactDocgen: "react-docgen-typescript" diff --git a/apps/react-storybook/package.json b/apps/react-storybook/package.json index a80d44488e2b..84540767497c 100644 --- a/apps/react-storybook/package.json +++ b/apps/react-storybook/package.json @@ -26,6 +26,7 @@ "@types/react": "18.3.28", "@types/react-dom": "18.3.7", "http-server": "14.1.1", + "ol": "10.10.0", "prop-types": "15.8.1", "react": "18.3.1", "react-dom": "18.3.1", 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..566feb0a4cb1 --- /dev/null +++ b/apps/react-storybook/stories/map/OSMMap.stories.tsx @@ -0,0 +1,368 @@ +import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import { useArgs } from 'storybook/preview-api'; +import { fn } from 'storybook/test'; + +import Attribution from 'ol/control/Attribution.js'; +import type OpenLayersMap from 'ol/Map.js'; +import 'ol/ol.css'; +import { fromLonLat, transformExtent } from 'ol/proj.js'; +import View from 'ol/View.js'; +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, +} from 'devextreme/ui/map'; +import 'devextreme/ui/map/openlayers'; + +import { ROUTE_PATHS } from './routes'; + +const CENTRAL_PARK_CENTER = { lat: 40.7829, lng: -73.9654 }; +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', + attribution: '© OpenStreetMap contributors', + maxZoom: 19, +}; +const MARKER_LOCATIONS: Record = { + '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 = { + centralParkRun: { + mode: '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'], + }, + manhattanDrive: { + mode: '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 handleRouteAdded = fn(); +const handleRouteRemoved = fn(); +const STORY_STYLE: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: 8, + padding: 8, +}; +const TOOLBAR_STYLE: React.CSSProperties = { + display: 'flex', + gap: 8, +}; + +interface OsmStoryArgs { + autoAdjust: boolean; + centerOnCentralPark: boolean; + controls: boolean; + disabled: boolean; + focusStateEnabled: boolean; + tooltipsEnabled: boolean; + tooltipsInitiallyShown: boolean; + rtlEnabled: boolean; + showRoute: boolean; + routeColor: string; + routePreset: keyof typeof ROUTE_PRESETS; + routeOpacity: number; + routeWeight: number; + type: MapType; + zoom: number; +} + +interface OsmMapStoryProps extends OsmStoryArgs { + updateArgs: (args: Partial) => void; +} + +const configureOpenLayersMap = ( + { originalMap }: ReadyEvent, + center: MapLocation, + zoom: number, +): void => { + const map = originalMap as OpenLayersMap; + const attribution = map.getControls().getArray() + .find((control) => control instanceof Attribution) as Attribution | undefined; + + attribution?.setCollapsed(false); + attribution?.setCollapsible(false); + map.setView(new View({ + center: fromLonLat([center.lng, center.lat]), + extent: transformExtent(EXTENT, 'EPSG:4326', 'EPSG:3857'), + maxZoom: 16, + minZoom: 14, + smoothExtentConstraint: false, + zoom, + })); +}; + +const OsmMapStory = ({ + autoAdjust, + centerOnCentralPark, + controls, + disabled, + focusStateEnabled, + tooltipsEnabled, + tooltipsInitiallyShown, + rtlEnabled, + showRoute, + routeColor, + routePreset, + routeOpacity, + routeWeight, + type, + updateArgs, + zoom, +}: OsmMapStoryProps): React.ReactElement => { + const mapRef = React.useRef(null); + const [markerAdded, setMarkerAdded] = React.useState(false); + const preset = ROUTE_PRESETS[routePreset]; + const markers = React.useMemo(() => preset.markers.map((marker, index) => ({ + ...marker, + onClick: handleMarkerClick, + tooltip: tooltipsEnabled + ? index === 0 ? 'Start' : { + text: `Stop ${index + 1}Explore this location.`, + isShown: tooltipsInitiallyShown, + } + : undefined, + })), [preset, tooltipsEnabled, tooltipsInitiallyShown]); + const addedMarker = React.useMemo(() => ({ + location: preset.extraMarker, + tooltip: tooltipsEnabled ? 'Additional stop' : undefined, + }), [preset, tooltipsEnabled]); + const routes = React.useMemo(() => showRoute ? [{ + locations: preset.locations, + color: routeColor, + mode: preset.mode, + opacity: routeOpacity, + weight: routeWeight, + }] : [], [preset, showRoute, routeColor, routeOpacity, routeWeight]); + const center = centerOnCentralPark ? CENTRAL_PARK_CENTER : preset.center; + + React.useEffect(() => { + setMarkerAdded(false); + }, [preset, tooltipsEnabled, tooltipsInitiallyShown]); + + React.useEffect(() => { + mapRef.current?.instance()?.option('zoom', preset.zoom); + }, [preset]); + + React.useEffect(() => { + mapRef.current?.instance()?.option('center', center); + }, [center]); + + const addMarker = (): void => { + const map = mapRef.current?.instance(); + if (!map || markerAdded) { + return; + } + + setMarkerAdded(true); + void map.addMarker(addedMarker).then(undefined, () => setMarkerAdded(false)); + }; + + const removeMarker = (): void => { + const map = mapRef.current?.instance(); + if (!map || !markerAdded) { + return; + } + + setMarkerAdded(false); + void map.removeMarker(addedMarker).then(undefined, () => setMarkerAdded(true)); + }; + + return ( + + {preset.title}. {preset.markerDescription} + + + + + configureOpenLayersMap(event, center, zoom)} + onZoomChange={(value) => updateArgs({ zoom: value })} + /> + + ); +}; + +const meta: Meta = { + title: 'Components/Map/OSM Provider', + tags: ['!test'], + render: function Render() { + const [args, updateArgs] = useArgs(); + + return ; + }, + parameters: { + layout: 'fullscreen', + }, + argTypes: { + autoAdjust: { + control: 'boolean', + description: 'Automatically adjusts the map viewport when markers or routes are added.', + }, + centerOnCentralPark: { + control: 'boolean', + description: 'Switches the map center between the selected route and Central Park.', + }, + controls: { + control: 'boolean', + }, + disabled: { + control: 'boolean', + }, + focusStateEnabled: { + control: 'boolean', + }, + tooltipsEnabled: { control: 'boolean' }, + tooltipsInitiallyShown: { + control: 'boolean', + description: 'Sets isShown for object-form tooltips. The Start marker uses a string tooltip.', + }, + rtlEnabled: { + control: 'boolean', + }, + showRoute: { control: 'boolean' }, + routeColor: { + control: { + type: 'select', + labels: { '#0000ff': 'Blue', '#008000': 'Green', '#ff0000': 'Red' }, + }, + options: ['#0000ff', '#008000', '#ff0000'], + }, + routePreset: { + control: { + type: 'select', + labels: { manhattanDrive: 'Manhattan Drive', centralParkRun: 'Central Park Run' }, + }, + options: ['manhattanDrive', 'centralParkRun'], + description: 'Saved OSRM routes: official Routes demo waypoints or an independently calculated Central Park loop. ' + + 'Driving returns coordinate tuples; walking returns a GeoJSON LineString. No routing service is called.', + }, + routeOpacity: { control: { type: 'range', min: 0, max: 1, step: 0.1 } }, + routeWeight: { control: { type: 'number', min: 0, max: 20 } }, + type: { + control: 'select', + options: ['roadmap', 'satellite', 'hybrid'], + description: 'The public OSM tile server provides one style, so this control exercises ' + + 'type changes without changing the map appearance.', + }, + zoom: { + control: { + type: 'number', + min: 14, + max: 16, + step: 0.25, + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Overview: Story = { + args: { + autoAdjust: false, + centerOnCentralPark: false, + controls: true, + disabled: false, + focusStateEnabled: true, + tooltipsEnabled: true, + tooltipsInitiallyShown: false, + rtlEnabled: false, + showRoute: true, + routeColor: '#0000ff', + routePreset: 'manhattanDrive', + routeOpacity: 0.5, + routeWeight: 6, + type: 'roadmap', + zoom: 14, + }, +}; diff --git a/apps/react-storybook/stories/map/routes.ts b/apps/react-storybook/stories/map/routes.ts new file mode 100644 index 000000000000..05b0f0dd0d40 --- /dev/null +++ b/apps/react-storybook/stories/map/routes.ts @@ -0,0 +1,254 @@ +import type { OsmGeoJsonLineString, RouteMode } from 'devextreme/ui/map'; + +// Map data © OpenStreetMap contributors, ODbL: https://www.openstreetmap.org/copyright +export const ROUTE_PATHS: Record = { + walking: { + type: 'LineString', + coordinates: [ + [-73.981906, 40.768161], + [-73.982066, 40.768505], + [-73.98184, 40.768682], + [-73.981677, 40.768615], + [-73.978822, 40.772524], + [-73.978519, 40.772473], + [-73.97589, 40.776092], + [-73.975594, 40.77598], + [-73.975533, 40.776043], + [-73.975262, 40.776105], + [-73.974836, 40.7765], + [-73.974396, 40.777094], + [-73.974122, 40.777293], + [-73.974092, 40.77739], + [-73.973962, 40.777337], + [-73.973847, 40.777582], + [-73.973618, 40.777862], + [-73.972432, 40.778575], + [-73.971955, 40.778806], + [-73.971833, 40.778893], + [-73.971589, 40.778611], + [-73.970899, 40.778687], + [-73.970623, 40.778813], + [-73.970476, 40.778819], + [-73.970168, 40.778775], + [-73.970029, 40.778963], + [-73.969982, 40.778972], + [-73.969936, 40.778936], + [-73.969668, 40.778982], + [-73.969587, 40.778906], + [-73.96927, 40.779115], + [-73.969208, 40.779119], + [-73.969089, 40.779299], + [-73.969044, 40.77928], + [-73.969013, 40.779322], + [-73.968909, 40.779281], + [-73.968882, 40.779316], + [-73.968896, 40.779298], + [-73.969131, 40.779397], + [-73.96911, 40.779425], + [-73.96919, 40.779459], + [-73.969119, 40.779556], + [-73.96929, 40.779639], + [-73.969529, 40.779822], + [-73.969447, 40.780027], + [-73.969477, 40.780164], + [-73.969203, 40.780582], + [-73.968952, 40.780786], + [-73.968816, 40.780842], + [-73.968178, 40.7818], + [-73.967872, 40.782174], + [-73.968028, 40.782231], + [-73.967687, 40.7827], + [-73.967596, 40.783046], + [-73.96766, 40.783534], + [-73.967719, 40.783637], + [-73.967851, 40.783743], + [-73.967298, 40.784376], + [-73.967173, 40.7846], + [-73.967112, 40.784859], + [-73.967159, 40.7852], + [-73.967522, 40.786121], + [-73.967554, 40.786396], + [-73.967533, 40.786645], + [-73.967382, 40.787076], + [-73.967084, 40.787508], + [-73.966869, 40.787737], + [-73.966879, 40.787844], + [-73.96672, 40.787974], + [-73.967111, 40.788114], + [-73.962644, 40.794231], + [-73.962567, 40.794209], + [-73.962385, 40.794261], + [-73.962193, 40.794368], + [-73.962124, 40.794589], + [-73.961901, 40.794794], + [-73.961794, 40.795029], + [-73.961601, 40.795183], + [-73.961407, 40.795626], + [-73.961211, 40.795814], + [-73.961029, 40.795884], + [-73.960867, 40.795905], + [-73.960748, 40.796247], + [-73.960579, 40.796257], + [-73.960442, 40.79631], + [-73.960352, 40.796456], + [-73.960025, 40.796627], + [-73.959901, 40.796754], + [-73.959653, 40.796814], + [-73.959631, 40.797027], + [-73.959562, 40.797118], + [-73.959481, 40.797158], + [-73.958723, 40.797311], + [-73.958341, 40.797274], + [-73.958229, 40.79719], + [-73.957864, 40.797486], + [-73.957578, 40.797552], + [-73.95738, 40.797516], + [-73.957182, 40.797401], + [-73.957137, 40.797361], + [-73.957103, 40.797235], + [-73.956926, 40.797268], + [-73.956624, 40.797495], + [-73.956247, 40.797628], + [-73.956004, 40.797836], + [-73.955787, 40.7979], + [-73.955611, 40.797886], + [-73.955467, 40.797833], + [-73.955292, 40.797637], + [-73.955195, 40.797603], + [-73.95487, 40.797732], + [-73.954585, 40.797917], + [-73.95459, 40.79773], + [-73.954394, 40.797707], + [-73.954184, 40.797614], + [-73.954028, 40.797748], + [-73.953743, 40.797917], + [-73.95355, 40.797834], + [-73.95327, 40.797807], + [-73.953077, 40.797724], + [-73.952295, 40.797529], + [-73.951738, 40.797258], + [-73.951246, 40.797171], + [-73.951349, 40.797064], + [-73.951283, 40.797153], + [-73.951182, 40.797161], + [-73.950583, 40.796913], + [-73.950265, 40.796915], + [-73.949935, 40.796785], + [-73.949898, 40.796739], + [-73.949906, 40.796668], + [-73.950105, 40.796271], + [-73.950541, 40.795783], + [-73.950822, 40.795253], + [-73.951215, 40.794803], + [-73.951409, 40.794432], + [-73.95215, 40.793429], + [-73.95261, 40.793621], + [-73.95215, 40.793429], + [-73.952208, 40.79334], + [-73.95245, 40.792999], + [-73.952521, 40.792936], + [-73.952656, 40.792882], + [-73.952772, 40.792778], + [-73.952821, 40.792681], + [-73.952961, 40.792627], + [-73.953224, 40.792598], + [-73.953273, 40.79257], + [-73.953375, 40.792481], + [-73.953443, 40.792304], + [-73.953581, 40.792199], + [-73.953455, 40.792047], + [-73.953042, 40.791825], + [-73.963945, 40.776917], + [-73.96428, 40.776997], + [-73.964382, 40.77698], + [-73.964661, 40.776889], + [-73.964927, 40.776752], + [-73.965036, 40.776602], + [-73.965362, 40.776531], + [-73.966131, 40.776484], + [-73.966498, 40.776368], + [-73.966829, 40.776152], + [-73.967053, 40.775771], + [-73.967352, 40.775401], + [-73.967619, 40.77524], + [-73.968228, 40.774961], + [-73.968414, 40.775124], + [-73.969138, 40.774727], + [-73.969693, 40.774665], + [-73.970063, 40.774444], + [-73.970514, 40.774291], + [-73.970584, 40.7743], + [-73.970625, 40.774418], + [-73.970752, 40.774492], + [-73.970913, 40.774493], + [-73.970984, 40.774464], + [-73.971076, 40.77436], + [-73.97107, 40.774253], + [-73.971031, 40.774197], + [-73.970894, 40.77413], + [-73.970946, 40.774049], + [-73.971068, 40.774079], + [-73.971169, 40.774016], + [-73.971402, 40.773435], + [-73.971611, 40.773472], + [-73.971664, 40.773324], + [-73.971797, 40.773213], + [-73.97186, 40.773043], + [-73.972022, 40.772806], + [-73.972172, 40.77275], + [-73.972508, 40.772435], + [-73.973084, 40.7727], + [-73.973616, 40.772757], + [-73.973913, 40.772861], + [-73.974175, 40.77291], + [-73.975086, 40.772919], + [-73.975704, 40.772885], + [-73.976258, 40.772815], + [-73.976569, 40.772871], + [-73.976807, 40.772282], + [-73.977187, 40.771745], + [-73.977547, 40.77143], + [-73.978471, 40.770812], + [-73.97912, 40.770273], + [-73.979519, 40.769875], + [-73.979721, 40.769622], + [-73.979958, 40.769151], + [-73.980064, 40.769142], + [-73.980267, 40.769068], + [-73.981199, 40.768511], + [-73.981396, 40.76846], + [-73.981415, 40.768513], + [-73.98184, 40.768682], + [-73.982066, 40.768505], + [-73.981906, 40.768161], + ], + }, + driving: { + type: 'LineString', + coordinates: [ + [-73.965345, 40.78364], + [-73.965267, 40.783767], + [-73.96632, 40.784071], + [-73.96668, 40.784201], + [-73.967778, 40.784793], + [-73.968277, 40.784946], + [-73.968356, 40.785006], + [-73.968809, 40.785133], + [-73.96921, 40.78529], + [-73.969344, 40.785297], + [-73.972181, 40.786497], + [-73.992646, 40.758433], + [-73.976055, 40.751439], + [-73.982913, 40.742036], + [-73.984596, 40.742743], + [-73.990007, 40.735312], + [-73.990179, 40.735136], + [-73.990652, 40.734863], + [-73.990791, 40.734728], + [-73.991424, 40.731859], + [-73.991488, 40.731704], + [-74.006304, 40.714173], + [-74.005334, 40.713723], + ], + }, +}; diff --git a/e2e/compilation-cases/map.ts b/e2e/compilation-cases/map.ts new file mode 100644 index 000000000000..6aad066265aa --- /dev/null +++ b/e2e/compilation-cases/map.ts @@ -0,0 +1,23 @@ +import type { MarkerClickEvent, Properties } from 'devextreme/js/ui/map'; +import type { MarkerClickEvent as ReexportedMarkerClickEvent } from 'devextreme/js/ui/map_types'; +import type dxPopover from 'devextreme/js/ui/popover'; + +import { assertType, notAny, toAssertion } from './consts'; + +const options: Properties = { + markers: [{ + location: [40.78, -73.97], + onClick(e) { + notAny(e); + notAny(e.location); + notAny(e.tooltip); + assertType(toAssertion(e)); + assertType(toAssertion(e)); + assertType(toAssertion(e.tooltip)); + e.component.option('center', e.location); + e.tooltip?.option({ showTitle: true, showCloseButton: true }); + }, + }, { + onClick: () => undefined, + }], +}; diff --git a/packages/devextreme-angular/src/ui/map/index.ts b/packages/devextreme-angular/src/ui/map/index.ts index 6e23da977e92..e00102a0cd5a 100644 --- a/packages/devextreme-angular/src/ui/map/index.ts +++ b/packages/devextreme-angular/src/ui/map/index.ts @@ -19,7 +19,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 { MarkerClickEvent, ClickEvent, DisposingEvent, InitializedEvent, MarkerAddedEvent, MarkerRemovedEvent, OptionChangedEvent, ReadyEvent, RouteAddedEvent, RouteRemovedEvent, MapProvider, CalculateOsmRouteInfo, OsmTileServer, RouteMode, MapType } from 'devextreme/ui/map'; import DxMap from 'devextreme/ui/map'; @@ -244,10 +244,10 @@ export class DxMapComponent extends DxComponent implements OnDestroy, OnChanges, * [descr:dxMapOptions.markers] */ @Input() - get markers(): { iconSrc?: string, location?: Array | string | { lat?: number, lng?: number }[], onClick?: Function, tooltip?: string | { isShown?: boolean, text?: string } }[] { + get markers(): { iconSrc?: string, location?: Array | string | { lat?: number, lng?: number }[], onClick?: ((e: MarkerClickEvent) => void), tooltip?: string | { isShown?: boolean, text?: string } }[] { return this._getOption('markers'); } - set markers(value: { iconSrc?: string, location?: Array | string | { lat?: number, lng?: number }[], onClick?: Function, tooltip?: string | { isShown?: boolean, text?: string } }[]) { + set markers(value: { iconSrc?: string, location?: Array | string | { lat?: number, lng?: number }[], onClick?: ((e: MarkerClickEvent) => void), tooltip?: string | { isShown?: boolean, text?: string } }[]) { this._setOption('markers', value); } @@ -266,10 +266,10 @@ export class DxMapComponent extends DxComponent implements OnDestroy, OnChanges, * [descr:dxMapOptions.providerConfig] */ @Input() - get providerConfig(): { mapId?: string, useAdvancedMarkers?: boolean } { + get providerConfig(): { calculateLocation?: ((query: string) => any), calculateRoute?: ((params: CalculateOsmRouteInfo) => any), mapId?: string, tileServer?: OsmTileServer | undefined, useAdvancedMarkers?: boolean } { return this._getOption('providerConfig'); } - set providerConfig(value: { mapId?: string, useAdvancedMarkers?: boolean }) { + set providerConfig(value: { calculateLocation?: ((query: string) => any), calculateRoute?: ((params: CalculateOsmRouteInfo) => any), mapId?: string, tileServer?: OsmTileServer | undefined, useAdvancedMarkers?: boolean }) { this._setOption('providerConfig', value); } @@ -463,7 +463,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() markersChange: EventEmitter<{ iconSrc?: string, location?: Array | string | { lat?: number, lng?: number }[], onClick?: Function, tooltip?: string | { isShown?: boolean, text?: string } }[]>; + @Output() markersChange: EventEmitter<{ iconSrc?: string, location?: Array | string | { lat?: number, lng?: number }[], onClick?: ((e: MarkerClickEvent) => void), tooltip?: string | { isShown?: boolean, text?: string } }[]>; /** * This member supports the internal infrastructure and is not intended to be used directly from your code. @@ -473,7 +473,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<{ calculateLocation?: ((query: string) => any), calculateRoute?: ((params: CalculateOsmRouteInfo) => any), mapId?: string, tileServer?: OsmTileServer | undefined, useAdvancedMarkers?: boolean }>; /** * This member supports the internal infrastructure and is not intended to be used directly from your code. diff --git a/packages/devextreme-angular/src/ui/map/nested/marker-dxi.ts b/packages/devextreme-angular/src/ui/map/nested/marker-dxi.ts index 5250471ca35a..d04569ea65dc 100644 --- a/packages/devextreme-angular/src/ui/map/nested/marker-dxi.ts +++ b/packages/devextreme-angular/src/ui/map/nested/marker-dxi.ts @@ -6,6 +6,7 @@ import { SkipSelf, Input } from '@angular/core'; +import type { MarkerClickEvent } from 'devextreme/ui/map'; import { DxIntegrationModule, @@ -46,10 +47,10 @@ export class DxiMapMarkerComponent extends CollectionNestedOption { } @Input() - get onClick(): Function { + get onClick(): ((e: MarkerClickEvent) => void) { return this._getOption('onClick'); } - set onClick(value: Function) { + set onClick(value: ((e: MarkerClickEvent) => void)) { this._setOption('onClick', value); } 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 e86492356b62..3cf1b3bf3361 100644 --- a/packages/devextreme-angular/src/ui/map/nested/provider-config.ts +++ b/packages/devextreme-angular/src/ui/map/nested/provider-config.ts @@ -8,6 +8,7 @@ import { SkipSelf, Input } from '@angular/core'; +import type { CalculateOsmRouteInfo, OsmTileServer } from 'devextreme/ui/map'; import { DxIntegrationModule, @@ -23,6 +24,22 @@ import { NestedOption } from 'devextreme-angular/core'; providers: [NestedOptionHost] }) export class DxoMapProviderConfigComponent extends NestedOption implements OnDestroy, OnInit { + @Input() + get calculateLocation(): ((query: string) => any) { + return this._getOption('calculateLocation'); + } + set calculateLocation(value: ((query: string) => any)) { + this._setOption('calculateLocation', value); + } + + @Input() + get calculateRoute(): ((params: CalculateOsmRouteInfo) => any) { + return this._getOption('calculateRoute'); + } + set calculateRoute(value: ((params: CalculateOsmRouteInfo) => any)) { + this._setOption('calculateRoute', value); + } + @Input() get mapId(): string { return this._getOption('mapId'); @@ -31,6 +48,14 @@ export class DxoMapProviderConfigComponent extends NestedOption implements OnDes this._setOption('mapId', value); } + @Input() + get tileServer(): OsmTileServer | undefined { + return this._getOption('tileServer'); + } + set tileServer(value: OsmTileServer | undefined) { + this._setOption('tileServer', value); + } + @Input() get useAdvancedMarkers(): boolean { return this._getOption('useAdvancedMarkers'); diff --git a/packages/devextreme-angular/src/ui/nested/marker-dxi.ts b/packages/devextreme-angular/src/ui/nested/marker-dxi.ts index e055d78fa6da..4b7b11f282f9 100644 --- a/packages/devextreme-angular/src/ui/nested/marker-dxi.ts +++ b/packages/devextreme-angular/src/ui/nested/marker-dxi.ts @@ -8,6 +8,7 @@ import { ContentChildren, QueryList } from '@angular/core'; +import type { MarkerClickEvent } from 'devextreme/ui/map'; import { DxIntegrationModule, @@ -57,10 +58,10 @@ export class DxiMarkerComponent extends CollectionNestedOption { } @Input() - get onClick(): Function { + get onClick(): ((e: MarkerClickEvent) => void) { return this._getOption('onClick'); } - set onClick(value: Function) { + set onClick(value: ((e: MarkerClickEvent) => void)) { this._setOption('onClick', value); } diff --git a/packages/devextreme-angular/src/ui/nested/provider-config.ts b/packages/devextreme-angular/src/ui/nested/provider-config.ts index 072dee05f2cf..d3113c76dcb3 100644 --- a/packages/devextreme-angular/src/ui/nested/provider-config.ts +++ b/packages/devextreme-angular/src/ui/nested/provider-config.ts @@ -8,6 +8,7 @@ import { SkipSelf, Input } from '@angular/core'; +import type { OsmTileServer } from 'devextreme/ui/map'; import { DxIntegrationModule, @@ -23,6 +24,22 @@ import { NestedOption } from 'devextreme-angular/core'; providers: [NestedOptionHost] }) export class DxoProviderConfigComponent extends NestedOption implements OnDestroy, OnInit { + @Input() + get calculateLocation(): Function { + return this._getOption('calculateLocation'); + } + set calculateLocation(value: Function) { + this._setOption('calculateLocation', value); + } + + @Input() + get calculateRoute(): Function { + return this._getOption('calculateRoute'); + } + set calculateRoute(value: Function) { + this._setOption('calculateRoute', value); + } + @Input() get mapId(): string { return this._getOption('mapId'); @@ -31,6 +48,14 @@ export class DxoProviderConfigComponent extends NestedOption implements OnDestro this._setOption('mapId', value); } + @Input() + get tileServer(): OsmTileServer | undefined { + return this._getOption('tileServer'); + } + set tileServer(value: OsmTileServer | undefined) { + 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-metadata/make-aspnet-metadata.ts b/packages/devextreme-metadata/make-aspnet-metadata.ts index ea99c30f8259..0cf18565d975 100644 --- a/packages/devextreme-metadata/make-aspnet-metadata.ts +++ b/packages/devextreme-metadata/make-aspnet-metadata.ts @@ -160,6 +160,8 @@ AspNet.makeMetadata({ ['any[]'], ), + replaceTypes('ui/map:dxMapOptions.providerConfig.tileServer', ['*'], ['string']), + ...replaceTypesMutations, [ diff --git a/packages/devextreme-react/src/map.ts b/packages/devextreme-react/src/map.ts index 0a2d1462543d..548646107812 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, MarkerClickEvent, CalculateOsmRouteInfo, OsmTileServer, RouteMode } from "devextreme/ui/map"; type ReplaceFieldTypes = { [P in keyof TSource]: P extends keyof TReplacement ? TReplacement[P] : TSource[P]; @@ -155,7 +155,7 @@ type IMarkerProps = React.PropsWithChildren<{ lat?: number; lng?: number; }[]; - onClick?: (() => void); + onClick?: ((e: MarkerClickEvent) => void); tooltip?: Record | string | { isShown?: boolean; text?: string; @@ -182,7 +182,10 @@ const Marker = Object.assign(_comp // owners: // Map type IProviderConfigProps = React.PropsWithChildren<{ + calculateLocation?: ((query: string) => any); + calculateRoute?: ((params: CalculateOsmRouteInfo) => any); mapId?: string; + tileServer?: OsmTileServer | undefined; useAdvancedMarkers?: boolean; }> const _componentProviderConfig = (props: IProviderConfigProps) => { diff --git a/packages/devextreme-scss/scss/widgets/base/_map.scss b/packages/devextreme-scss/scss/widgets/base/_map.scss index 0f61d97cd58d..04b6e856aab6 100644 --- a/packages/devextreme-scss/scss/widgets/base/_map.scss +++ b/packages/devextreme-scss/scss/widgets/base/_map.scss @@ -18,6 +18,22 @@ user-select: none; } +.dx-map-marker-default { + display: flex; + align-items: flex-end; + justify-content: center; + width: 44px; + height: 44px; +} + +.dx-map-marker-default-icon { + display: block; + overflow: visible; + filter: + drop-shadow(0 0 0.5px rgba(255, 255, 255, 0.85)) + drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.35)); +} + .dx-map-marker-tooltip { margin: 10px; } diff --git a/packages/devextreme-scss/scss/widgets/fluent/map/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent/map/_colors.scss new file mode 100644 index 000000000000..dff74c02caf4 --- /dev/null +++ b/packages/devextreme-scss/scss/widgets/fluent/map/_colors.scss @@ -0,0 +1,2 @@ +$map-marker-color: #0f6cbd !default; +$map-marker-accent-color: #fafafa !default; diff --git a/packages/devextreme-scss/scss/widgets/fluent/map/_index.scss b/packages/devextreme-scss/scss/widgets/fluent/map/_index.scss index 112b5a2f4acb..cf9069b40a88 100644 --- a/packages/devextreme-scss/scss/widgets/fluent/map/_index.scss +++ b/packages/devextreme-scss/scss/widgets/fluent/map/_index.scss @@ -1 +1,11 @@ +@use "colors" as *; @use "../../base/map"; + +.dx-map-marker-default-body { + fill: $map-marker-color; + stroke: $map-marker-accent-color; +} + +.dx-map-marker-default-center { + fill: $map-marker-accent-color; +} diff --git a/packages/devextreme-scss/scss/widgets/generic/map/_colors.scss b/packages/devextreme-scss/scss/widgets/generic/map/_colors.scss new file mode 100644 index 000000000000..33ec08f572f7 --- /dev/null +++ b/packages/devextreme-scss/scss/widgets/generic/map/_colors.scss @@ -0,0 +1,2 @@ +$map-marker-color: #337ab7 !default; +$map-marker-accent-color: #fff !default; diff --git a/packages/devextreme-scss/scss/widgets/generic/map/_index.scss b/packages/devextreme-scss/scss/widgets/generic/map/_index.scss index 112b5a2f4acb..cf9069b40a88 100644 --- a/packages/devextreme-scss/scss/widgets/generic/map/_index.scss +++ b/packages/devextreme-scss/scss/widgets/generic/map/_index.scss @@ -1 +1,11 @@ +@use "colors" as *; @use "../../base/map"; + +.dx-map-marker-default-body { + fill: $map-marker-color; + stroke: $map-marker-accent-color; +} + +.dx-map-marker-default-center { + fill: $map-marker-accent-color; +} diff --git a/packages/devextreme-scss/scss/widgets/material/map/_colors.scss b/packages/devextreme-scss/scss/widgets/material/map/_colors.scss new file mode 100644 index 000000000000..15323124a4e6 --- /dev/null +++ b/packages/devextreme-scss/scss/widgets/material/map/_colors.scss @@ -0,0 +1,2 @@ +$map-marker-color: #1564c0 !default; +$map-marker-accent-color: #fff !default; diff --git a/packages/devextreme-scss/scss/widgets/material/map/_index.scss b/packages/devextreme-scss/scss/widgets/material/map/_index.scss index 112b5a2f4acb..cf9069b40a88 100644 --- a/packages/devextreme-scss/scss/widgets/material/map/_index.scss +++ b/packages/devextreme-scss/scss/widgets/material/map/_index.scss @@ -1 +1,11 @@ +@use "colors" as *; @use "../../base/map"; + +.dx-map-marker-default-body { + fill: $map-marker-color; + stroke: $map-marker-accent-color; +} + +.dx-map-marker-default-center { + fill: $map-marker-accent-color; +} diff --git a/packages/devextreme-themebuilder/tests/data/dependencies.ts b/packages/devextreme-themebuilder/tests/data/dependencies.ts index 8e7ffd2d604a..1705cd611ddf 100644 --- a/packages/devextreme-themebuilder/tests/data/dependencies.ts +++ b/packages/devextreme-themebuilder/tests/data/dependencies.ts @@ -40,7 +40,7 @@ export const dependencies: FlatStylesDependencies = { htmleditor: ['validation', 'button', 'loadindicator', 'loadpanel', 'scrollview', 'popup', 'toolbar', 'textbox', 'list', 'checkbox', 'selectbox', 'numberbox', 'multiview', 'tabs', 'tabpanel', 'box', 'responsivebox', 'calendar', 'datebox', 'form', 'buttongroup', 'colorbox', 'progressbar', 'fileuploader', 'contextmenu', 'textarea', 'menu', 'dropdownbutton', 'treeview', 'informer'], sortable: [], lookup: ['validation', 'button', 'loadindicator', 'textbox', 'popup', 'loadpanel', 'scrollview', 'list', 'popover'], - map: [], + map: ['button', 'loadindicator', 'loadpanel', 'popover', 'popup', 'scrollview', 'toolbar', 'validation'], radiogroup: ['validation'], tooltip: ['validation', 'button', 'popup', 'popover'], slider: ['validation', 'button', 'popup', 'popover', 'tooltip'], diff --git a/packages/devextreme-vue/src/map.ts b/packages/devextreme-vue/src/map.ts index 7e1c8fecdc49..e0705f13e0ac 100644 --- a/packages/devextreme-vue/src/map.ts +++ b/packages/devextreme-vue/src/map.ts @@ -14,6 +14,10 @@ import { RouteRemovedEvent, MapProvider, MapType, + MarkerClickEvent, + CalculateOsmRouteInfo, + OsmTileServer, + OsmTileServerConfig, RouteMode, } from "devextreme/ui/map"; import { prepareConfigurationComponentConfig } from "./core/index"; @@ -224,7 +228,7 @@ const DxMarkerConfig = { props: { iconSrc: String, location: [Array, Object, String] as PropType | Record | string>, - onClick: Function as PropType<(() => void)>, + onClick: Function as PropType<((e: MarkerClickEvent) => void)>, tooltip: [Object, String] as PropType | string> } }; @@ -244,11 +248,17 @@ const DxProviderConfigConfig = { emits: { "update:isActive": null, "update:hoveredElement": null, + "update:calculateLocation": null, + "update:calculateRoute": null, "update:mapId": null, + "update:tileServer": null, "update:useAdvancedMarkers": null, }, props: { + calculateLocation: Function as PropType<((query: string) => any)>, + calculateRoute: Function as PropType<((params: CalculateOsmRouteInfo) => any)>, mapId: String, + tileServer: [Object, Function, String] as PropType string | OsmTileServerConfig | undefined)) | OsmTileServerConfig | string>, useAdvancedMarkers: Boolean } }; diff --git a/packages/devextreme/js/__internal/ui/errors.ts b/packages/devextreme/js/__internal/ui/errors.ts index 5b4ed085a6c6..927cd740dccc 100644 --- a/packages/devextreme/js/__internal/ui/errors.ts +++ b/packages/devextreme/js/__internal/ui/errors.ts @@ -113,6 +113,8 @@ export default errorUtils(errors.ERROR_MESSAGES, { E1068: '\'aiIntegration\' is not configured for the AI Assistant.', + E1069: 'Could not find a compatible version of OpenLayers. Load a supported version before Map initialization.', + W1001: 'The "key" option cannot be modified after initialization', W1002: 'An item with the key \'{0}\' does not exist', @@ -160,4 +162,8 @@ export default errorUtils(errors.ERROR_MESSAGES, { W1027: 'A prompt should be specified for a custom command.', W1028: 'Nested/banded columns do not support the following properties: {0}.', W1029: '\'hiddenWeekDays\' must leave at least one weekday visible.', + W1030: '\'tileServer\' is not configured for the OSM map provider.', + W1031: '\'calculateLocation\' is not configured for the OSM map provider.', + W1032: '\'attribution\' is not configured for the OSM tile server.', + W1033: '\'calculateRoute\' is not configured for the OSM map provider.', }); diff --git a/packages/devextreme/js/__internal/ui/map/map.ts b/packages/devextreme/js/__internal/ui/map/map.ts index 854788d3d368..2b4502f40ea3 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; @@ -263,6 +265,12 @@ class Map extends Widget { // eslint-disable-next-line @typescript-eslint/no-floating-promises this._queueAsyncAction('updateDisabled'); break; + case 'focusStateEnabled': + case 'tabIndex': + super._optionChanged(args); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this._queueAsyncAction('updateFocus'); + break; case 'width': case 'height': super._optionChanged(args); @@ -359,9 +367,10 @@ class Map extends Widget { ): Promise { const markerAndRoutes = [markers, routes].filter(Boolean); const isActionSuppressed = this._suppressAsyncAction; + const provider = this._provider; this._lastAsyncAction = this._lastAsyncAction.then(() => { - if (!this._provider || isActionSuppressed) { + if (!provider || provider !== this._provider || isActionSuppressed) { /// #DEBUG this._asyncActionSuppressed = true; /// #ENDDEBUG @@ -369,7 +378,11 @@ class Map extends Widget { } // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return this._provider[name](...markerAndRoutes).then((result) => { + return provider[name](...markerAndRoutes).then((result) => { + if (provider !== this._provider || this._disposed) { + return undefined; + } + const arrayResult = wrapToArray(result); const mapRefreshed = arrayResult[0]; @@ -384,6 +397,12 @@ class Map extends Widget { // eslint-disable-next-line @typescript-eslint/no-unsafe-return return arrayResult[1]; + }, (error) => { + if (provider !== this._provider || this._disposed) { + return undefined; + } + + return Promise.reject(error); }); }); diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.azure.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.azure.ts index 4f4814a3ad4f..0ca4bef3a1fd 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.azure.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.azure.ts @@ -35,7 +35,7 @@ export type AzureLocation = [number, number]; // eslint-disable-next-line @typescript-eslint/init-declarations let azureMapsLoader; -class AzureProvider extends DynamicProvider { +class AzureProvider extends DynamicProvider { _preventZoomChangeEvent?: boolean; _mapReadyPromise!: Promise; @@ -75,7 +75,7 @@ class AzureProvider extends DynamicProvider { } else { // eslint-disable-next-line @typescript-eslint/no-floating-promises this._geocodeLocation(location as string).then((geocodedLocation) => { - resolve(geocodedLocation as AzureLocation); + resolve(geocodedLocation); }); } }); diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.bing.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.bing.ts index 685ff0214b46..a0b6b6a5ebb7 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.bing.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.bing.ts @@ -38,7 +38,7 @@ export interface BingLocation { // eslint-disable-next-line @typescript-eslint/init-declarations let msMapsLoader; -class BingProvider extends DynamicProvider { +class BingProvider extends DynamicProvider { _providerClickHandler?: (e: { targetType: string; location: BingLocation }) => void; _providerViewChangeHandler?: () => void; @@ -75,7 +75,7 @@ class BingProvider extends DynamicProvider { } else { // eslint-disable-next-line @typescript-eslint/no-floating-promises this._geocodeLocation(location as string).then((geocodedLocation) => { - resolve(geocodedLocation as BingLocation); + resolve(geocodedLocation); }); } }); diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.google.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.google.ts index c963174eaff8..273fc3ac4936 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.google.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.google.ts @@ -90,7 +90,7 @@ const googleMapsLoaded = (): boolean => Boolean(window.google?.maps); // eslint-disable-next-line @typescript-eslint/init-declarations let googleMapsLoader; -class GoogleProvider extends DynamicProvider { +class GoogleProvider extends DynamicProvider { _clickListener?: (e: { latLng: GoogleLocation; domEvent: Event }) => void; _preventZoomChangeEvent?: boolean; @@ -132,7 +132,7 @@ class GoogleProvider extends DynamicProvider { } else { // eslint-disable-next-line @typescript-eslint/no-floating-promises this._geocodeLocation(location as string).then((geocodedLocation) => { - resolve(geocodedLocation as GoogleLocation); + resolve(geocodedLocation); }); } }); 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 new file mode 100644 index 000000000000..4cfb3114a014 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.engine.ts @@ -0,0 +1,99 @@ +import type { MapLocation } from '@js/ui/map'; +import type Popover from '@js/ui/popover'; + +export const SUBDOMAIN_PLACEHOLDER = '{s}'; + +export interface MapEngineTileLayerOptions { + attribution?: string; + maxZoom: number; + subdomains?: string | string[]; + url: string; +} + +export interface MapEngineSetViewOptions { + center?: MapLocation; + zoom?: number; +} + +export interface MapEngineBounds { + northEast: MapLocation; + southWest: MapLocation; +} + +export interface MapEngineFitBoundsOptions { + includeMarkerPadding?: boolean; +} + +export interface MapEngineViewState extends MapEngineSetViewOptions { + bounds?: MapEngineBounds; +} + +export interface MapEngineClickEvent { + event?: Event; + location: MapLocation; +} + +export interface MapEngineMarkerOptions { + html?: string; + htmlOffset?: { top: number; left: number }; + iconSrc?: string; + location: MapLocation; + onClick?: (tooltip?: Popover) => void; + rtlEnabled?: boolean; + tooltip?: { text: string; visible: boolean }; +} + +export interface MapEngineUpdateDimensionsResult { + needsViewportRefit: boolean; +} + +export interface MapEngineMarker { + readonly originalMarker: unknown; + dispose: (restoreFocus?: boolean) => 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; + viewChange: (view: MapEngineViewState) => void; +} + +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; + getZoom: () => number | undefined; + replaceTileLayer: (options: MapEngineTileLayerOptions) => void; + setControls: (visible: boolean) => void; + setDisabled: (disabled: boolean) => void; + setFocus: (enabled: boolean, tabIndex: number) => void; + setView: (options: MapEngineSetViewOptions) => void; + updateDimensions: () => MapEngineUpdateDimensionsResult; +} + +export interface MapEngine { + createMap: (container: Element, view?: MapEngineSetViewOptions) => MapEngineMap; +} + +const registry: { mapEngine?: MapEngine } = {}; + +export const getRegisteredMapEngine = (): MapEngine | undefined => registry.mapEngine; + +export const setRegisteredMapEngine = (engine: MapEngine | undefined): void => { + registry.mapEngine = engine; +}; diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.marker.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.marker.ts new file mode 100644 index 000000000000..8713f22e6d61 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.marker.ts @@ -0,0 +1,106 @@ +import messageLocalization from '@js/common/core/localization/message'; + +import type { MapEngineMarkerOptions } from './provider.dynamic.osm.engine'; + +export const MARKER_FALLBACK_WIDTH = 25; +export const MARKER_FALLBACK_HEIGHT = 41; +// NOTE: Keep this value in sync with .dx-map-marker-default in widgets/base/_map.scss. +export const DEFAULT_MARKER_SIZE = 44; + +const MARKER_CLASS = 'dx-map-marker'; +export const DEFAULT_MARKER_CLASS = `${MARKER_CLASS}-default`; +const DEFAULT_MARKER_ICON_CLASS = `${DEFAULT_MARKER_CLASS}-icon`; +const DEFAULT_MARKER_BODY_CLASS = `${DEFAULT_MARKER_CLASS}-body`; +const DEFAULT_MARKER_CENTER_CLASS = `${DEFAULT_MARKER_CLASS}-center`; +const IMAGE_MARKER_CLASS = `${MARKER_CLASS}-image`; +const DEFAULT_MARKER_WIDTH = 24.5; +const DEFAULT_MARKER_HEIGHT = 36.5; +const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; +const DEFAULT_MARKER_VIEW_BOX = '5 2 14 20'; +const DEFAULT_MARKER_BODY_PATH = [ + 'M12 2c-3.85 0-7 3.176-7 7.059', + ' 0 5.294 7 12.941 7 12.941', + 's7-7.765 7-12.941', + 'c0-3.882-3.15-7.059-7-7.059z', +].join(''); +const DEFAULT_MARKER_CENTER_PATH = [ + 'M12 6.706c-1.283 0-2.333 1.059-2.333 2.353', + 's1.05 2.353 2.333 2.353', + ' 2.333-1.059 2.333-2.353', + 'c0-1.294-1.05-2.353-2.333-2.353z', +].join(''); + +export type MarkerKind = 'default' | 'html' | 'image'; + +export interface MarkerElementInfo { + element: HTMLElement; + kind: MarkerKind; + offset: number[]; + positioning: string; +} + +const createDefaultMarkerElement = ( + ownerDocument: Document, +): HTMLElement => { + const element = ownerDocument.createElement('div'); + const svg = ownerDocument.createElementNS(SVG_NAMESPACE, 'svg'); + const body = ownerDocument.createElementNS(SVG_NAMESPACE, 'path'); + const center = ownerDocument.createElementNS(SVG_NAMESPACE, 'path'); + + element.className = `${MARKER_CLASS} ${DEFAULT_MARKER_CLASS}`; + svg.setAttribute('class', DEFAULT_MARKER_ICON_CLASS); + svg.setAttribute('viewBox', DEFAULT_MARKER_VIEW_BOX); + svg.setAttribute('width', `${DEFAULT_MARKER_WIDTH}`); + svg.setAttribute('height', `${DEFAULT_MARKER_HEIGHT}`); + svg.setAttribute('aria-hidden', 'true'); + svg.setAttribute('focusable', 'false'); + body.setAttribute('class', DEFAULT_MARKER_BODY_CLASS); + body.setAttribute('d', DEFAULT_MARKER_BODY_PATH); + body.setAttribute('stroke-width', '0.5'); + center.setAttribute('class', DEFAULT_MARKER_CENTER_CLASS); + center.setAttribute('d', DEFAULT_MARKER_CENTER_PATH); + svg.appendChild(body); + svg.appendChild(center); + element.appendChild(svg); + + return element; +}; + +export const createMarkerElement = ( + ownerDocument: Document, + options: MapEngineMarkerOptions, +): MarkerElementInfo => { + if (options.html) { + const element = ownerDocument.createElement('div'); + element.innerHTML = options.html; + + return { + element, + kind: 'html', + offset: [options.htmlOffset?.left ?? 0, options.htmlOffset?.top ?? 0], + positioning: 'top-left', + }; + } + + if (options.iconSrc) { + const element = ownerDocument.createElement('img'); + element.className = `${MARKER_CLASS} ${IMAGE_MARKER_CLASS}`; + element.src = options.iconSrc; + element.alt = messageLocalization.format('dxMap-markerAriaLabel'); + element.draggable = false; + + return { + element, + kind: 'image', + offset: [0, 0], + positioning: 'bottom-center', + }; + } + + return { + element: createDefaultMarkerElement(ownerDocument), + kind: 'default', + offset: [0, 0], + positioning: 'bottom-center', + }; +}; diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts new file mode 100644 index 000000000000..58ea172553e8 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts @@ -0,0 +1,220 @@ +import { normalizeKeyName } from '@js/common/core/events/utils'; +import domAdapter from '@js/core/dom_adapter'; +import $ from '@js/core/renderer'; +import type { Properties } from '@js/ui/popover'; +import Popover from '@js/ui/popover'; +import { ALL_FOCUSABLE_ELEMENTS_SELECTOR } from '@ts/core/utils/selectors'; +import type InternalPopover from '@ts/ui/popover/popover'; + +import { DEFAULT_MARKER_CLASS } from './provider.dynamic.osm.openlayers.marker'; +import type { MapLike } from './provider.dynamic.osm.openlayers.utils'; + +const POPOVER_CLASS = 'dx-map-marker-popover'; +const TOOLTIP_MAX_WIDTH = 280; + +type MarkerPopover = Popover & Pick; + +export class OpenLayersMarkerTooltip { + readonly element: HTMLElement; + + private readonly _host: HTMLElement; + + private readonly _popover: MarkerPopover; + + private readonly _inertElements = new Set(); + + private readonly _tabIndexes = new Map(); + + private _focusEnabled = true; + + private _positioning = false; + + private _positionUpdatePending = false; + + private _disposed = false; + + constructor( + private readonly _map: MapLike, + private readonly _container: Element, + private readonly _marker: HTMLElement, + text: string, + rtlEnabled: boolean, + ) { + const { ownerDocument } = _container; + const host = ownerDocument.createElement('div'); + Object.assign(host.style, { position: 'absolute', inset: '0' }); + _map.getOverlayContainer().appendChild(host); + this._host = host; + const element = ownerDocument.createElement('div'); + host.appendChild(element); + const content = ownerDocument.createElement('div'); + content.innerHTML = text; + const target = _marker.classList.contains(DEFAULT_MARKER_CLASS) + ? _marker.firstElementChild ?? _marker + : _marker; + const focusTargets = _marker.querySelectorAll(ALL_FOCUSABLE_ELEMENTS_SELECTOR); + + this._popover = new Popover(element, { + container: host, + // @ts-expect-error Popover also supports renderer collections as targets. + target: focusTargets.length ? $(Array.from(focusTargets)) : _marker, + position: { + of: target, + my: { x: 'center', y: 'bottom' }, + at: { x: 'center', y: 'top' }, + collision: 'flip', + boundary: _container, + }, + animation: undefined, + deferRendering: false, + contentTemplate: (): HTMLElement => content, + maxWidth: TOOLTIP_MAX_WIDTH, + showTitle: false, + showCloseButton: false, + hideOnOutsideClick: false, + hideOnParentScroll: false, + rtlEnabled, + elementAttr: { class: POPOVER_CLASS }, + wrapperAttr: { class: POPOVER_CLASS }, + }) as MarkerPopover; + this.element = $(this._popover.content()).parent().get(0) as HTMLElement; + this._popover.on('showing', this._syncFocusState); + this._popover.on('positioned', this._restoreContentSize); + this._popover.on('positioned', this._syncFocusState); + this._popover.on('shown', this._syncFocusState); + this._popover.on('hidden', this._syncFocusState); + this.element.addEventListener('click', this._stopPropagation); + this.element.addEventListener('dblclick', this._stopPropagation); + this.element.addEventListener('pointerdown', this._stopPropagation); + this.element.addEventListener('keydown', this._stopMapKeyPropagation); + _map.on('postrender', this.syncPosition); + } + + get popover(): Popover { + return this._popover; + } + + setVisible(visible: boolean): void { + if (!this._disposed) { + this._popover.option('visible', visible); + } + } + + setFocusEnabled(enabled: boolean): void { + if (enabled !== this._focusEnabled) { + this._focusEnabled = enabled; + this._popover.option('_preventDialogContainerFocus', !enabled); + const focusEnabled = enabled && this._popover._getEffectiveAriaRole() === 'dialog'; + this._popover.option({ + focusStateEnabled: focusEnabled, + tabFocusLoopEnabled: focusEnabled, + }); + } + this._syncFocusState(); + } + + private readonly _restoreContentSize = (): Promise | undefined => { + if (this._positioning || this._positionUpdatePending) { + return undefined; + } + + this._positionUpdatePending = true; + return Promise.resolve().then(() => { + this._positionUpdatePending = false; + if (this._disposed) { + return; + } + + this._popover._renderDimensions(); + this._popover._setContentHeight(true); + this.syncPosition(); + }); + }; + + readonly syncPosition = (): void => { + if (!this._popover.option('visible')) { + return; + } + + this._positioning = true; + try { + this._popover._renderPosition(false); + } finally { + this._positioning = false; + } + this._syncFocusState(); + }; + + private readonly _stopPropagation = (event: Event): void => event.stopPropagation(); + + private readonly _stopMapKeyPropagation = (event: KeyboardEvent): void => { + const key = normalizeKeyName(event); + if (event.defaultPrevented || (key !== 'escape' && key !== 'tab')) { + event.stopPropagation(); + } + }; + + private readonly _syncFocusState = (): void => { + this._inertElements.forEach((element) => { element.inert = false; }); + this._inertElements.clear(); + if (this._focusEnabled) { + this._tabIndexes.forEach((tabIndex, element) => { + if (tabIndex === null) { + element.removeAttribute('tabindex'); + } else { + element.setAttribute('tabindex', tabIndex); + } + }); + this._tabIndexes.clear(); + } + const focusTargets = this.element + .querySelectorAll(ALL_FOCUSABLE_ELEMENTS_SELECTOR); + if (!this._focusEnabled) { + focusTargets.forEach((element) => { + if (!this._tabIndexes.has(element)) { + this._tabIndexes.set(element, element.getAttribute('tabindex')); + } + element.setAttribute('tabindex', '-1'); + }); + } + const boundary = this._container.getBoundingClientRect(); + const activeElement = domAdapter.getActiveElement(this.element); + const elements = this._popover.option('visible') && this.element.getClientRects().length + ? [this._marker, this.element, ...focusTargets] + : [this._marker]; + + elements.forEach((element) => { + const rect = element.getBoundingClientRect(); + const outside = element === this.element + ? rect.bottom <= boundary.top || rect.top >= boundary.bottom + || rect.right <= boundary.left || rect.left >= boundary.right + : rect.top < boundary.top || rect.bottom > boundary.bottom + || rect.left < boundary.left || rect.right > boundary.right; + + if (outside) { + if (element.contains(activeElement)) { + (this._container as HTMLElement).focus({ preventScroll: true }); + } + if (!element.inert) { + element.inert = true; + this._inertElements.add(element); + } + } + }); + }; + + dispose(): void { + this._disposed = true; + this._map.un('postrender', this.syncPosition); + this.element.removeEventListener('click', this._stopPropagation); + this.element.removeEventListener('dblclick', this._stopPropagation); + this.element.removeEventListener('pointerdown', this._stopPropagation); + this.element.removeEventListener('keydown', this._stopMapKeyPropagation); + this._popover.dispose(); + this._host.remove(); + this._inertElements.forEach((element) => { element.inert = false; }); + this._inertElements.clear(); + this._tabIndexes.clear(); + } +} 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 new file mode 100644 index 000000000000..f25a59cc188a --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts @@ -0,0 +1,825 @@ +import Color from '@js/color'; +import messageLocalization from '@js/common/core/localization/message'; +import domAdapter from '@js/core/dom_adapter'; +import resizeObserverSingleton from '@js/core/resize_observer'; +import { ALL_FOCUSABLE_ELEMENTS_SELECTOR } from '@ts/core/utils/selectors'; + +import type { + MapEngine, + MapEngineBounds, + MapEngineClickEvent, + MapEngineEventHandlers, + MapEngineFitBoundsOptions, + MapEngineMap, + MapEngineMarker, + MapEngineMarkerOptions, + MapEngineRoute, + MapEngineRouteOptions, + MapEngineSetViewOptions, + MapEngineTileLayerOptions, + MapEngineUpdateDimensionsResult, + MapEngineViewState, +} from './provider.dynamic.osm.engine'; +import { SUBDOMAIN_PLACEHOLDER } from './provider.dynamic.osm.engine'; +import { + createMarkerElement, + DEFAULT_MARKER_SIZE, + MARKER_FALLBACK_HEIGHT, + MARKER_FALLBACK_WIDTH, + type MarkerElementInfo, + type MarkerKind, +} from './provider.dynamic.osm.openlayers.marker'; +import { OpenLayersMarkerTooltip } from './provider.dynamic.osm.openlayers.tooltip'; +import type { + ControlLike, + Coordinate, + Extent, + InteractionLike, + MapLike, + OpenLayersApi, + Options, + OverlayLike, + TileLayerLike, + VectorSourceLike, + ViewLike, +} from './provider.dynamic.osm.openlayers.utils'; +import { + areCoordinatesEqual, + createTileUrlList, + DEFAULT_VIEW_PROJECTION, + GEOGRAPHIC_PROJECTION, + getCoordinateProjection, + isOpenLayersApi, + toCoordinate, + toLocation, +} from './provider.dynamic.osm.openlayers.utils'; +import { toRouteCoordinates } from './provider.dynamic.osm.route'; + +interface MapBrowserEventLike { + coordinate?: Coordinate; + originalEvent?: Event; +} + +const KEY_RELEASE_EVENT = 'keyup'; + +interface MarkerFocusTarget { + element: HTMLElement; + tabIndex: string | null; +} + +interface MarkerElementBinding { + focusTargets: MarkerFocusTarget[]; + detach: () => void; +} + +interface OpenLayersMarker extends MapEngineMarker { + element: HTMLElement; + focusTargets: MarkerFocusTarget[]; + kind: MarkerKind; + location: MapEngineMarkerOptions['location']; + offset: number[]; + overlay: OverlayLike; + positioning: string; + tooltip?: OpenLayersMarkerTooltip; +} + +class OpenLayersMap implements MapEngineMap { + readonly originalMap: MapLike; + + private readonly _container: Element; + + private _focusEnabled = true; + + private _tabIndex = 0; + + private readonly _zoomControl: ControlLike; + + private _controlsVisible = false; + + private _disabled = false; + + private _ownsDisabledInert = false; + + private readonly _interactionStates = new Map(); + + private readonly _markers = new Set(); + + private _eventHandlers?: { + click: (event: unknown) => void; + markerSizeChange: () => void; + moveEnd: (event: unknown) => void; + userKeyDown: (event: Event) => void; + userViewChange: () => void; + }; + + private _tileLayer?: TileLayerLike; + + private _routeLayer?: object; + + private _routeSource?: VectorSourceLike; + + private _disposed = false; + + private _markerFitNeedsLayout = false; + + private _markerSizeRefitEnabled = true; + + private _subscribedView: ViewLike; + + constructor( + private readonly _api: OpenLayersApi, + container: Element, + view: MapEngineSetViewOptions = {}, + ) { + this._container = container; + this._syncKeyboardTabIndex(); + + this.originalMap = new _api.Map({ + controls: _api.control.defaults.defaults({ attribution: true, rotate: false, zoom: false }), + interactions: _api.interaction.defaults.defaults({ + altShiftDragRotate: false, + onFocusOnly: false, + pinchRotate: false, + }), + keyboardEventTarget: container, + target: container, + view: new _api.View({ + center: toCoordinate( + _api, + view.center ?? { lat: 0, lng: 0 }, + DEFAULT_VIEW_PROJECTION, + ), + projection: DEFAULT_VIEW_PROJECTION, + zoom: view.zoom ?? 1, + }), + }); + this.originalMap.getOverlayContainer().setAttribute('dir', 'ltr'); + this.originalMap.getOverlayContainer().style.contain = 'layout paint'; + this.originalMap.getOverlayContainerStopEvent().setAttribute('dir', 'ltr'); + this._subscribedView = this.originalMap.getView(); + this._subscribedView.on('change:center', this._viewCenterChangeHandler); + this.originalMap.on('change:view', this._viewChangeHandler); + this._zoomControl = new _api.control.Zoom(); + } + + private readonly _viewCenterChangeHandler = (): void => { + this._syncMarkerPositions(); + }; + + private readonly _viewChangeHandler = (): void => { + const view = this.originalMap.getView(); + + if (view === this._subscribedView) { + return; + } + + 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(); + this._syncMarkerTabIndexes(); + }; + + attachHandlers(handlers: MapEngineEventHandlers): void { + this._detachHandlers(); + this._initHandlers(handlers); + } + + private _attachMarkerElementHandlers( + element: HTMLElement, + onClick?: (event: MouseEvent) => void, + onSizeChange?: () => void, + ): MarkerElementBinding { + const keyboardInteractive = Boolean(onClick) + && !element.querySelector(ALL_FOCUSABLE_ELEMENTS_SELECTOR); + const focusTargets: MarkerFocusTarget[] = keyboardInteractive + ? [{ element, tabIndex: '0' }] + : Array.from(element.querySelectorAll(ALL_FOCUSABLE_ELEMENTS_SELECTOR)) + .map((focusTarget) => ({ + element: focusTarget, + tabIndex: focusTarget.getAttribute('tabindex'), + })); + const clickHandler: EventListener | undefined = onClick + ? (event): void => { + event.stopPropagation(); + if (!this._disabled) { + onClick(event as MouseEvent); + } + } + : undefined; + let spacePressed = false; + const blurHandler = (): void => { spacePressed = false; }; + const keydownHandler: EventListener | undefined = focusTargets.length + ? (event): void => { + if ((event as KeyboardEvent).key !== 'Escape' || event.defaultPrevented) { + event.stopPropagation(); + } + + if (!keyboardInteractive) { + return; + } + + const keyboardEvent = event as KeyboardEvent; + if (keyboardEvent.key !== 'Enter' && keyboardEvent.key !== ' ') { + return; + } + + event.preventDefault(); + if (keyboardEvent.key === ' ') { + spacePressed = true; + } + if (keyboardEvent.key === 'Enter' && !keyboardEvent.repeat) { + element.click(); + } + } + : undefined; + const keyReleaseHandler: EventListener | undefined = keyboardInteractive + ? (event): void => { + const keyboardEvent = event as KeyboardEvent; + if (keyboardEvent.key !== ' ') { + return; + } + + event.preventDefault(); + event.stopPropagation(); + if (spacePressed) { + spacePressed = false; + element.click(); + } + } + : undefined; + let { height, width } = element.getBoundingClientRect(); + const resizeHandler = (): void => { + const rect = element.getBoundingClientRect(); + if (rect.height === height && rect.width === width) { + return; + } + + height = rect.height; + width = rect.width; + onSizeChange?.(); + if (this._markerSizeRefitEnabled) { + this._eventHandlers?.markerSizeChange(); + } + }; + + if (clickHandler) { + element.addEventListener('click', clickHandler); + } + if (keyboardInteractive) { + element.setAttribute('role', 'button'); + if (element.tagName !== 'IMG' + && !element.textContent?.trim() + && !element.getAttribute('alt')) { + element.setAttribute( + 'aria-label', + messageLocalization.format('dxMap-markerAriaLabel'), + ); + } + } + if (keydownHandler) { + element.addEventListener('keydown', keydownHandler); + } + if (keyReleaseHandler) { + element.addEventListener(KEY_RELEASE_EVENT, keyReleaseHandler); + element.addEventListener('blur', blurHandler); + } + resizeObserverSingleton.observe(element, resizeHandler); + + return { + focusTargets, + detach: (): void => { + if (clickHandler) { + element.removeEventListener('click', clickHandler); + } + if (keydownHandler) { + element.removeEventListener('keydown', keydownHandler); + } + if (keyReleaseHandler) { + element.removeEventListener(KEY_RELEASE_EVENT, keyReleaseHandler); + element.removeEventListener('blur', blurHandler); + } + resizeObserverSingleton.unobserve(element); + }, + }; + } + + addMarker(options: MapEngineMarkerOptions): MapEngineMarker { + const markerElement = createMarkerElement(this._container.ownerDocument, options); + const { + element, kind, offset, positioning, + } = markerElement; + element.setAttribute('dir', options.rtlEnabled ? 'rtl' : 'ltr'); + const marker = new this._api.Overlay({ + element, + insertFirst: false, + offset, + position: this._getMarkerPosition(options.location), + positioning, + stopEvent: false, + }); + this.originalMap.addOverlay(marker); + const tooltip = options.tooltip + ? this._createMarkerTooltip(markerElement, options.tooltip.text, Boolean(options.rtlEnabled)) + : undefined; + const onClick = options.onClick || tooltip + ? (): void => { + const showTooltip = !tooltip?.popover.option('visible'); + options.onClick?.(tooltip?.popover); + tooltip?.setVisible(showTooltip); + } + : undefined; + const markerElementBinding = this._attachMarkerElementHandlers( + element, + onClick, + () => tooltip?.syncPosition(), + ); + this._markerSizeRefitEnabled = true; + + let disposed = false; + const handle: OpenLayersMarker = { + element, + focusTargets: markerElementBinding.focusTargets, + kind, + location: { ...options.location }, + offset, + overlay: marker, + positioning, + tooltip, + originalMarker: marker, + dispose: (restoreFocus = true): void => { + if (disposed) { + return; + } + + disposed = true; + if (restoreFocus && !this._disposed + && tooltip?.element.contains(domAdapter.getActiveElement(tooltip.element))) { + (this._container as HTMLElement).focus({ preventScroll: true }); + } + tooltip?.dispose(); + markerElementBinding.detach(); + this.originalMap.removeOverlay(marker); + this._markers.delete(handle); + }, + }; + + this._markers.add(handle); + this._syncMarkerTabIndex(handle); + if (options.tooltip?.visible) { + tooltip?.setVisible(true); + } + + return handle; + } + + private _createMarkerTooltip( + element: MarkerElementInfo, + text: string, + rtlEnabled: boolean, + ): OpenLayersMarkerTooltip { + return new OpenLayersMarkerTooltip( + this.originalMap, + this._container, + element.element, + text, + rtlEnabled, + ); + } + + 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: Number.isFinite(options.weight) && options.weight > 0 + ? new api.style.Stroke({ color: [r, g, b, options.opacity], width: options.weight }) + : undefined, + })); + + 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(); + const coordinate = toCoordinate(this._api, location, projection); + const center = view.getCenter(); + + if (!center) { + return coordinate; + } + + const west = toCoordinate(this._api, { lat: 0, lng: -180 }, projection)[0]; + const east = toCoordinate(this._api, { lat: 0, lng: 180 }, projection)[0]; + const worldWidth = Math.abs(east - west); + + if (Number.isFinite(worldWidth) && worldWidth > 0) { + coordinate[0] += Math.round((center[0] - coordinate[0]) / worldWidth) * worldWidth; + } + + return coordinate; + } + + private _syncMarkerPositions(): void { + this._markers.forEach((marker) => { + const position = this._getMarkerPosition(marker.location); + if (!areCoordinatesEqual(marker.overlay.getPosition(), position)) { + marker.overlay.setPosition(position); + marker.tooltip?.syncPosition(); + } + }); + } + + private _syncMarkerTabIndex(marker: OpenLayersMarker, viewExtent?: Extent): void { + marker.tooltip?.setFocusEnabled(this._focusEnabled && !this._disabled); + const extent = viewExtent ?? this.originalMap.getView().calculateExtent(); + const isVisible = this._isMarkerVisible(marker.overlay, extent); + + marker.focusTargets.forEach(({ element, tabIndex }) => { + const isMarkerOutsideView = !isVisible && marker.element.contains(element); + if (!this._focusEnabled || this._disabled || isMarkerOutsideView) { + element.setAttribute('tabindex', '-1'); + } else if (tabIndex === null) { + element.removeAttribute('tabindex'); + } else { + element.setAttribute('tabindex', tabIndex); + } + }); + + if (this._focusEnabled && !this._disabled && !isVisible) { + this._moveMarkerFocusToMap(marker); + } + } + + private _syncMarkerTabIndexes(): void { + const viewExtent = this.originalMap.getView().calculateExtent(); + + this._markers.forEach((marker) => this._syncMarkerTabIndex(marker, viewExtent)); + } + + private _isMarkerVisible(marker: OverlayLike, viewExtent: Extent): boolean { + const position = marker.getPosition(); + if (!position) { + return false; + } + + const [minX, minY, maxX, maxY] = viewExtent; + const [x, y] = position; + + return x >= minX + && x <= maxX + && y >= minY + && y <= maxY; + } + + private _moveMarkerFocusToMap(marker: OpenLayersMarker): void { + const markerRoot = marker.element.getRootNode() as Document | ShadowRoot; + const { activeElement } = markerRoot; + const markerHasFocus = marker.element.contains(activeElement); + const container = this._container as HTMLElement; + + if (markerHasFocus && typeof container.focus === 'function') { + container.focus({ preventScroll: true }); + } + } + + private _getMarkerFitPadding(): { padding: number[]; needsLayout: boolean } { + const padding = [0, 0, 0, 0]; + let needsLayout = false; + + this._markers.forEach(({ + element, kind, offset, positioning, + }) => { + const isDefault = kind === 'default'; + const rect = isDefault ? undefined : element.getBoundingClientRect(); + needsLayout ||= !isDefault && (!rect?.width || !rect.height); + const defaultSize = isDefault + ? DEFAULT_MARKER_SIZE + : undefined; + const width = Math.max(rect?.width ?? 0, defaultSize ?? MARKER_FALLBACK_WIDTH); + const height = Math.max(rect?.height ?? 0, defaultSize ?? MARKER_FALLBACK_HEIGHT); + let left = offset[0]; + let top = offset[1]; + + if (positioning === 'bottom-center') { + left -= width / 2; + top -= height; + } + + padding[0] = Math.max(padding[0], Math.ceil(Math.max(0, -top))); + padding[1] = Math.max(padding[1], Math.ceil(Math.max(0, left + width))); + padding[2] = Math.max(padding[2], Math.ceil(Math.max(0, top + height))); + padding[3] = Math.max(padding[3], Math.ceil(Math.max(0, -left))); + }); + + return { needsLayout, padding }; + } + + private _initHandlers(handlers: MapEngineEventHandlers): void { + const click = (event: unknown): void => { + const { coordinate, originalEvent } = event as MapBrowserEventLike; + if (!coordinate || this._isMarkerEvent(originalEvent)) { + return; + } + + const clickEvent: MapEngineClickEvent = { + location: toLocation( + this._api, + coordinate, + this.originalMap.getView().getProjection(), + ), + }; + if (originalEvent) { + clickEvent.event = originalEvent; + } + handlers.click(clickEvent); + }; + const moveEnd = (): void => { + this._syncMarkerTabIndexes(); + handlers.viewChange(this._getViewState()); + }; + const userViewChange = (): void => { + this._markerSizeRefitEnabled = false; + }; + const userKeyDown = (event: Event): void => { + const { key } = event as KeyboardEvent; + if (['+', '-', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'Enter', ' '].includes(key)) { + userViewChange(); + } + }; + + this._eventHandlers = { + click, + markerSizeChange: handlers.markerSizeChange, + moveEnd, + userKeyDown, + userViewChange, + }; + this.originalMap.on('click', click); + this.originalMap.on('moveend', moveEnd); + this._container.addEventListener('pointerdown', userViewChange); + this._container.addEventListener('keydown', userKeyDown); + this._container.addEventListener('wheel', userViewChange); + } + + private _isMarkerEvent(event?: Event): boolean { + const eventTarget = event?.target; + const NodeConstructor = this._container.ownerDocument.defaultView?.Node; + + if (!eventTarget || !NodeConstructor || !(eventTarget instanceof NodeConstructor)) { + return false; + } + + return [...this._markers].some(({ element, tooltip }) => ( + element.contains(eventTarget) || tooltip?.element.contains(eventTarget) + )); + } + + private _detachHandlers(): void { + if (!this._eventHandlers) { + return; + } + + this.originalMap.un('click', this._eventHandlers.click); + this.originalMap.un('moveend', this._eventHandlers.moveEnd); + this._container.removeEventListener('pointerdown', this._eventHandlers.userViewChange); + this._container.removeEventListener('keydown', this._eventHandlers.userKeyDown); + this._container.removeEventListener('wheel', this._eventHandlers.userViewChange); + this._eventHandlers = undefined; + } + + private _getViewState(): MapEngineViewState { + const view = this.originalMap.getView(); + const center = view.getCenter(); + const zoom = view.getZoom(); + const extent = view.calculateExtent(); + + const result: MapEngineViewState = { + bounds: { + northEast: toLocation(this._api, [extent[2], extent[3]], view.getProjection()), + southWest: toLocation(this._api, [extent[0], extent[1]], view.getProjection()), + }, + }; + + if (center) { + result.center = toLocation(this._api, center, view.getProjection()); + } + if (zoom !== undefined) { + result.zoom = zoom; + } + + return result; + } + + dispose(): void { + if (this._disposed) { + return; + } + + this._disposed = true; + this._detachHandlers(); + this.originalMap.un('change:view', this._viewChangeHandler); + 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); + this._tileLayer = undefined; + } + this.originalMap.setTarget(undefined); + this._container.removeAttribute('tabindex'); + } + + fitBounds(bounds: MapEngineBounds, options?: MapEngineFitBoundsOptions): void { + this._markerSizeRefitEnabled = Boolean(options?.includeMarkerPadding); + const west = bounds.southWest.lng; + const east = bounds.northEast.lng < west + ? bounds.northEast.lng + 360 + : bounds.northEast.lng; + const geographicExtent: Extent = [ + west, + Math.min(bounds.northEast.lat, bounds.southWest.lat), + east, + Math.max(bounds.northEast.lat, bounds.southWest.lat), + ]; + const view = this.originalMap.getView(); + const extent = this._api.proj.transformExtent( + geographicExtent, + GEOGRAPHIC_PROJECTION, + getCoordinateProjection(this._api, view.getProjection()), + ); + const markerFit = options?.includeMarkerPadding + ? this._getMarkerFitPadding() + : undefined; + this._markerFitNeedsLayout = Boolean(markerFit?.needsLayout); + view.fit(extent, markerFit ? { padding: markerFit.padding } : undefined); + this._syncMarkerTabIndexes(); + } + + getZoom(): number | undefined { + return this.originalMap.getView().getZoom(); + } + + replaceTileLayer(options: MapEngineTileLayerOptions): void { + const sourceOptions: Options = { + maxZoom: options.maxZoom, + url: options.url.includes(SUBDOMAIN_PLACEHOLDER) + ? createTileUrlList(options.url, options.subdomains) + : options.url, + }; + + if (options.attribution !== undefined) { + sourceOptions.attributions = options.attribution; + } + + const source = new this._api.source.ImageTile(sourceOptions); + + if (this._tileLayer) { + this._tileLayer.setSource(source); + return; + } + + this._tileLayer = new this._api.layer.Tile({ source }); + this.originalMap.addLayer(this._tileLayer); + } + + setControls(visible: boolean): void { + if (visible === this._controlsVisible) { + return; + } + + this._controlsVisible = visible; + if (visible) { + this.originalMap.addControl(this._zoomControl); + } else { + this.originalMap.removeControl(this._zoomControl); + } + } + + setDisabled(disabled: boolean): void { + if (disabled === this._disabled) { + return; + } + + this._disabled = disabled; + const interactions = this.originalMap.getInteractions(); + + if (disabled) { + this._disableKeyboardAccess(); + this._interactionStates.clear(); + interactions.forEach((interaction) => { + this._interactionStates.set(interaction, interaction.getActive()); + interaction.setActive(false); + }); + } else { + this._restoreKeyboardAccess(); + interactions.forEach((interaction) => { + const active = this._interactionStates.get(interaction); + if (active !== undefined) { + interaction.setActive(active); + } + }); + this._interactionStates.clear(); + } + } + + private _disableKeyboardAccess(): void { + if (!this._container.hasAttribute('inert')) { + this._container.setAttribute('inert', ''); + this._ownsDisabledInert = true; + } + this._syncKeyboardTabIndex(); + } + + private _restoreKeyboardAccess(): void { + this._removeOwnedInert(); + this._syncKeyboardTabIndex(); + } + + private _removeOwnedInert(): void { + if (this._ownsDisabledInert) { + this._container.removeAttribute('inert'); + this._ownsDisabledInert = false; + } + } + + setFocus(enabled: boolean, tabIndex: number): void { + this._focusEnabled = enabled; + this._tabIndex = tabIndex; + this._syncKeyboardTabIndex(); + } + + private _syncKeyboardTabIndex(): void { + if (this._focusEnabled && !this._disabled) { + this._container.setAttribute('tabindex', String(this._tabIndex)); + } else { + this._container.removeAttribute('tabindex'); + } + this._markers.forEach((marker) => this._syncMarkerTabIndex(marker)); + } + + setView(options: MapEngineSetViewOptions): void { + const view = this.originalMap.getView(); + + if (options.center) { + const center = toCoordinate(this._api, options.center, view.getProjection()); + if (!areCoordinatesEqual(view.getCenter(), center)) { + view.setCenter(center); + } + } + if (options.zoom !== undefined && view.getZoom() !== options.zoom) { + view.setZoom(options.zoom); + } + + this._syncMarkerTabIndexes(); + } + + updateDimensions(): MapEngineUpdateDimensionsResult { + this.originalMap.updateSize(); + this._syncMarkerTabIndexes(); + const needsViewportAdjustment = this._markerFitNeedsLayout; + this._markerFitNeedsLayout = false; + + return { needsViewportRefit: needsViewportAdjustment }; + } +} + +export const createOpenLayersEngine = (api: unknown): MapEngine | undefined => ( + isOpenLayersApi(api) + ? { createMap: (container, view) => new OpenLayersMap(api, container, view) } + : undefined +); 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 new file mode 100644 index 000000000000..fdf89916ef3a --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.test.ts @@ -0,0 +1,110 @@ +import { + describe, expect, it, jest, +} from '@jest/globals'; + +import type { OpenLayersApi } from './provider.dynamic.osm.openlayers.utils'; +import { + areCoordinatesEqual, + createTileUrlList, + isOpenLayersApi, + toCoordinate, + toLocation, +} 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: () => [] }, + }, + interaction: { + defaults: { defaults: () => [] }, + }, + 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(), Vector: jest.fn() }, + style: { Stroke: jest.fn(), Style: jest.fn() }, +}); + +describe('OpenLayers utils', () => { + describe('isOpenLayersApi', () => { + it('returns true for a complete OpenLayers API', () => { + expect(isOpenLayersApi(createApi())).toBe(true); + }); + + it('returns false when a required API member is missing', () => { + const api = createApi() as Record; + + delete api.Overlay; + + expect(isOpenLayersApi(api)).toBe(false); + }); + }); + + describe('coordinate conversion', () => { + it('converts a Map location to the active OpenLayers projection', () => { + const transform = jest.fn().mockReturnValue([100, 200]); + const api = { + proj: { + getUserProjection: () => 'user-projection', + transform, + }, + } as unknown as OpenLayersApi; + + expect(toCoordinate(api, { lat: 40.7, lng: -74 }, 'view-projection')) + .toEqual([100, 200]); + expect(transform).toHaveBeenCalledWith( + [-74, 40.7], + 'EPSG:4326', + 'user-projection', + ); + }); + + it('converts an OpenLayers coordinate to a Map location', () => { + const toLonLat = jest.fn().mockReturnValue([-74, 40.7]); + const api = { + proj: { + getUserProjection: () => null, + toLonLat, + }, + } as unknown as OpenLayersApi; + + expect(toLocation(api, [100, 200], 'view-projection')) + .toEqual({ lat: 40.7, lng: -74 }); + expect(toLonLat).toHaveBeenCalledWith([100, 200], 'view-projection'); + }); + }); + + describe('areCoordinatesEqual', () => { + it('compares both coordinate values', () => { + expect(areCoordinatesEqual([1, 2], [1, 2])).toBe(true); + expect(areCoordinatesEqual([1, 2], [1, 3])).toBe(false); + expect(areCoordinatesEqual(undefined, [1, 2])).toBe(false); + }); + }); + + describe('createTileUrlList', () => { + it('expands string subdomains and replaces every placeholder', () => { + expect(createTileUrlList('https://{s}.example.com/{s}/{z}', 'ab')).toEqual([ + 'https://a.example.com/a/{z}', + 'https://b.example.com/b/{z}', + ]); + }); + + it('expands array subdomains', () => { + expect(createTileUrlList('https://{s}.example.com/{z}', ['one', 'two'])).toEqual([ + 'https://one.example.com/{z}', + 'https://two.example.com/{z}', + ]); + }); + }); +}); 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 new file mode 100644 index 000000000000..fe4c7653c8db --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.ts @@ -0,0 +1,194 @@ +import type { MapLocation } from '@js/ui/map'; + +import type { MapEngineTileLayerOptions } from './provider.dynamic.osm.engine'; +import { SUBDOMAIN_PLACEHOLDER } from './provider.dynamic.osm.engine'; + +export type Options = Record; +export type Coordinate = [number, number]; +export type Extent = [number, number, number, number]; + +export const GEOGRAPHIC_PROJECTION = 'EPSG:4326'; +export const DEFAULT_VIEW_PROJECTION = 'EPSG:3857'; + +export type ControlLike = object; + +export interface InteractionLike { + getActive: () => boolean; + setActive: (active: boolean) => void; +} + +export interface CollectionLike { + forEach: (callback: (item: T) => void) => void; +} + +export interface ViewLike { + calculateExtent: () => Extent; + fit: (extent: Extent, options?: Options) => void; + getCenter: () => Coordinate | undefined; + getProjection: () => unknown; + getZoom: () => number | undefined; + on: (type: string, listener: () => void) => void; + setCenter: (center: Coordinate) => void; + setZoom: (zoom: number) => void; + un: (type: string, listener: () => void) => void; +} + +export interface TileLayerLike { + setSource: (source: unknown) => void; +} + +export interface OverlayLike { + getPosition: () => Coordinate | undefined; + 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; + addOverlay: (overlay: unknown) => void; + getInteractions: () => CollectionLike; + getOverlayContainer: () => HTMLElement; + getOverlayContainerStopEvent: () => HTMLElement; + getView: () => ViewLike; + on: (type: string, listener: (event: unknown) => void) => void; + removeControl: (control: ControlLike) => void; + removeLayer: (layer: unknown) => void; + removeOverlay: (overlay: unknown) => void; + setTarget: (target?: Element) => void; + un: (type: string, listener: (event: unknown) => void) => void; + updateSize: () => void; +} + +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: { + defaults: (options?: Options) => unknown; + }; + }; + interaction: { + defaults: { + defaults: (options?: Options) => unknown; + }; + }; + layer: { + Tile: new (options: Options) => TileLayerLike; + Vector: new (options: Options) => object; + }; + proj: { + getUserProjection: () => unknown | null; + toLonLat: (coordinate: Coordinate, projection?: unknown) => Coordinate; + transform: (coordinate: Coordinate, source: unknown, destination: unknown) => Coordinate; + transformExtent: (extent: Extent, source: unknown, destination: unknown) => Extent; + }; + source: { + ImageTile: new (options: Options) => unknown; + Vector: new (options?: Options) => VectorSourceLike; + }; + style: { + Stroke: new (options: Options) => unknown; + Style: new (options: Options) => unknown; + }; +} + +const isRecord = (value: unknown): value is Record => ( + Boolean(value) && (typeof value === 'object' || typeof value === 'function') +); + +const hasFunction = (value: unknown, property: string): boolean => ( + isRecord(value) && typeof value[property] === 'function' +); + +export const isOpenLayersApi = (api: unknown): api is OpenLayersApi => { + if (!isRecord(api)) { + return false; + } + + 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) + && hasFunction(api.control.defaults, 'defaults') + && isRecord(api.interaction) + && 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, 'Vector') + && hasFunction(api.style, 'Stroke') + && hasFunction(api.style, 'Style'); +}; + +export const getCoordinateProjection = ( + api: OpenLayersApi, + viewProjection: unknown, +): unknown => api.proj.getUserProjection() ?? viewProjection; + +export const areCoordinatesEqual = ( + first: Coordinate | undefined, + second: Coordinate, +): boolean => first?.[0] === second[0] && first[1] === second[1]; + +export const toCoordinate = ( + api: OpenLayersApi, + location: MapLocation, + viewProjection: unknown, +): Coordinate => api.proj.transform( + [location.lng, location.lat], + GEOGRAPHIC_PROJECTION, + getCoordinateProjection(api, viewProjection), +); + +export const toLocation = ( + api: OpenLayersApi, + coordinate: Coordinate, + viewProjection: unknown, +): MapLocation => { + const [lng, lat] = api.proj.toLonLat( + coordinate, + getCoordinateProjection(api, viewProjection), + ); + + return { lat, lng }; +}; + +export const createTileUrlList = ( + url: string, + subdomains: MapEngineTileLayerOptions['subdomains'], +): string[] => { + const values = Array.isArray(subdomains) ? subdomains : [...(subdomains ?? '')]; + + return values.map((value) => url.split(SUBDOMAIN_PLACEHOLDER).join(value)); +}; 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..fb68193fff6f --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.route.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from '@jest/globals'; + +import { getRouteBounds, getRouteLocations, 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('getRouteBounds', () => { + 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(getRouteBounds(longitudes.map((lng) => ({ lat: 10, lng })))) + .toEqual({ northEast: [10, expected[1]], southWest: [10, expected[0]] }); + }); + + it('includes interior latitude extremes without modifying the route', () => { + const locations = Object.freeze([ + Object.freeze({ lat: 10, lng: 179 }), + Object.freeze({ lat: 30, lng: -178 }), + Object.freeze({ lat: -20, lng: -179 }), + Object.freeze({ lat: 15, lng: 178 }), + ]); + + expect(getRouteBounds([...locations])).toEqual({ + northEast: [30, 182], + southWest: [-20, 178], + }); + }); +}); 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..7e4fdaf93b4a --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.route.ts @@ -0,0 +1,65 @@ +import type { MapLocation } from '@js/ui/map'; + +import type { RouteObject } from './provider.dynamic'; + +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 getRouteBounds = ( + locations: MapLocation[], +): Required> => { + let north = locations[0].lat; + let south = north; + let west = locations[0].lng; + let east = west; + + for (const [longitude, latitude] of toRouteCoordinates(locations)) { + north = Math.max(north, latitude); + south = Math.min(south, latitude); + west = Math.min(west, longitude); + east = Math.max(east, longitude); + } + + return { + northEast: [north, east], + southWest: [south, west], + }; +}; 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 new file mode 100644 index 000000000000..7ecd2103b521 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from '@jest/globals'; + +import { createBounds, normalizeLongitude } from './provider.dynamic.osm'; +import { getRouteBounds } from './provider.dynamic.osm.route'; + +describe('OSM bounds', () => { + describe('normalizeLongitude', () => { + it.each([-180, -74.25, 0, 120.5, 180])('preserves longitude %s inside the geographic range', (longitude) => { + expect(normalizeLongitude(longitude)).toBe(longitude); + }); + + it.each([ + [181, -179], + [-181, 179], + [360, 0], + [-360, 0], + [540, -180], + [-540, -180], + [1081, 1], + [-1081, -1], + [720.25, 0.25], + [-720.25, -0.25], + ])('wraps longitude %s to %s', (longitude, expected) => { + expect(normalizeLongitude(longitude)).toBe(expected); + }); + }); + + describe('createBounds', () => { + it('returns no bounds for an empty location list', () => { + expect(createBounds([])).toBeUndefined(); + }); + + it.each([-74, 286])('creates zero-sized bounds for a single location at longitude %s', (lng) => { + expect(createBounds([{ lat: 40.7, lng }])).toEqual({ + northEast: { lat: 40.7, lng: -74 }, + southWest: { lat: 40.7, lng: -74 }, + }); + }); + + it('finds latitude and longitude limits in an unordered location list', () => { + expect(createBounds([ + { lat: 40.7, lng: -74 }, + { lat: -12, lng: 20 }, + { lat: 52, lng: 15 }, + ])).toEqual({ + northEast: { lat: 52, lng: 20 }, + southWest: { lat: -12, lng: -74 }, + }); + }); + + it('uses the shorter interval across the antimeridian', () => { + expect(createBounds([ + { lat: 10, lng: 179 }, + { lat: 20, lng: -179 }, + { lat: 15, lng: 178 }, + ])).toEqual({ + northEast: { lat: 20, lng: -179 }, + southWest: { lat: 10, lng: 178 }, + }); + }); + + it('normalizes wrapped locations before selecting the shorter interval', () => { + expect(createBounds([ + { lat: 10, lng: 539 }, + { lat: 20, lng: -539 }, + ])).toEqual({ + northEast: { lat: 20, lng: -179 }, + southWest: { lat: 10, lng: 179 }, + }); + }); + + it('does not expand bounds for duplicate locations in different world copies', () => { + expect(createBounds([ + { lat: 40.7, lng: -74 }, + { lat: 40.7, lng: 286 }, + { lat: 40.7, lng: -434 }, + ])).toEqual({ + northEast: { lat: 40.7, lng: -74 }, + southWest: { lat: 40.7, lng: -74 }, + }); + }); + + it('treats both antimeridian endpoints as the same longitude', () => { + expect(createBounds([ + { lat: 10, lng: -180 }, + { lat: 20, lng: 180 }, + ])).toEqual({ + northEast: { lat: 20, lng: -180 }, + southWest: { lat: 10, lng: 180 }, + }); + }); + + it.each([ + [[-120, 0, 120]], + [[0, 120, -120]], + [[120, -120, 0]], + ])('selects a minimal interval for evenly spaced longitudes %j', (longitudes) => { + const bounds = createBounds(longitudes.map((lng) => ({ lat: 0, lng }))); + + expect([ + { northEast: { lat: 0, lng: -120 }, southWest: { lat: 0, lng: 0 } }, + { northEast: { lat: 0, lng: 0 }, southWest: { lat: 0, lng: 120 } }, + { 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], + [[[0, 90, 180, 270, 360, 450, 540]], [], -180, 180], + [[[-180, 180]], [], -180, -180], + [[[539, -539]], [], 179, -179], + [[[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 + .filter((longitudes) => longitudes.length) + .map((longitudes) => getRouteBounds(longitudes.map((lng) => ({ lat: 10, lng })))); + const markers = markerLongitudes.map((lng) => ({ lat: 10, lng })); + const routeCorners = routes.flatMap(({ northEast, southWest }) => ( + [northEast, southWest].map(([lat, lng]) => ({ lat, lng })) + )); + expect(createBounds([...routeCorners, ...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 new file mode 100644 index 000000000000..4a2b700aec1f --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts @@ -0,0 +1,626 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ +import { getWindow } from '@js/core/utils/window'; +import type { + MapLocation, + MapType, + OsmTileServerConfig as TileServerConfig, +} from '@js/ui/map'; +import errors from '@js/ui/widget/ui.errors'; + +import type { + LocationOption, + MarkerObject, + MarkerOptions, + RouteObject, + RouteOptions, +} from './provider.dynamic'; +import DynamicProvider from './provider.dynamic'; +import type { + MapEngine, + MapEngineBounds, + MapEngineClickEvent, + MapEngineMap, + MapEngineMarker, + MapEngineRoute, + MapEngineTileLayerOptions, + MapEngineViewState, +} from './provider.dynamic.osm.engine'; +import { + getRegisteredMapEngine, + SUBDOMAIN_PLACEHOLDER, +} from './provider.dynamic.osm.engine'; +import { createOpenLayersEngine } from './provider.dynamic.osm.openlayers'; +import { getRouteBounds, getRouteLocations } from './provider.dynamic.osm.route'; + +const DEFAULT_MAX_ZOOM = 19; +const DEFAULT_SUBDOMAINS = 'abc'; +const LOCATION_EPSILON = 1e-10; +const FULL_CIRCLE_DEGREES = 360; +const HALF_CIRCLE_DEGREES = 180; + +const getDefaultLocation = (): MapLocation => ({ lat: 0, lng: 0 }); + +const getLocationOptionKey = (location?: LocationOption | null): string => { + if (typeof location === 'string') { + return `string:${location}`; + } + if (Array.isArray(location)) { + return `array:${location[0]},${location[1]}`; + } + if (location) { + return `object:${location.lat},${location.lng}`; + } + + return String(location); +}; + +export const normalizeLongitude = (longitude: number): number => { + if (longitude >= -HALF_CIRCLE_DEGREES && longitude <= HALF_CIRCLE_DEGREES) { + return longitude; + } + + const shifted = longitude + HALF_CIRCLE_DEGREES; + const positive = (shifted % FULL_CIRCLE_DEGREES) + FULL_CIRCLE_DEGREES; + + return (positive % FULL_CIRCLE_DEGREES) - HALF_CIRCLE_DEGREES; +}; + +export const createBounds = ( + locations: MapLocation[], + routes: RouteObject[] = [], +): MapEngineBounds | undefined => { + if (!locations.length) { + return undefined; + } + + const longitudes: number[] = []; + let north = locations[0].lat; + let south = locations[0].lat; + + locations.forEach(({ lat, lng }) => { + longitudes.push(normalizeLongitude(lng)); + north = Math.max(north, lat); + south = Math.min(south, lat); + }); + longitudes.sort((first, second) => first - second); + let largestGap = -1; + let westIndex = 0; + + longitudes.forEach((longitude, index) => { + const nextLongitude = index === longitudes.length - 1 + ? longitudes[0] + FULL_CIRCLE_DEGREES + : longitudes[index + 1]; + const gap = nextLongitude - longitude; + const midpoint = longitude + gap / 2; + const crossesRoute = routes.some(({ southWest, northEast }) => { + if (!southWest || !northEast) { + return false; + } + + const west = southWest[1]; + const east = northEast[1]; + const offset = (((midpoint - west) % FULL_CIRCLE_DEGREES) + FULL_CIRCLE_DEGREES) + % FULL_CIRCLE_DEGREES; + + return offset < east - west; + }); + + if (gap > largestGap && !crossesRoute) { + largestGap = gap; + westIndex = (index + 1) % longitudes.length; + } + }); + + return { + northEast: { + lat: north, + lng: largestGap < 0 + ? HALF_CIRCLE_DEGREES + : longitudes[(westIndex + longitudes.length - 1) % longitudes.length], + }, + southWest: { + lat: south, + lng: largestGap < 0 ? -HALF_CIRCLE_DEGREES : longitudes[westIndex], + }, + }; +}; + +interface EngineMarkerObject extends MarkerObject { + engineMarker: MapEngineMarker; + location: MapLocation; +} + +interface EngineRouteObject extends RouteObject { + engineRoute?: MapEngineRoute; +} + +const areLocationsEqual = ( + first: MapLocation | null, + second: MapLocation, +): boolean => first !== null + && Math.abs(first.lat - second.lat) < LOCATION_EPSILON + && Math.abs(first.lng - second.lng) < LOCATION_EPSILON; + +class OsmProvider extends DynamicProvider { + declare _routes: (EngineRouteObject & { options: RouteOptions })[]; + + private _isCleaning = false; + + _engine?: MapEngine; + + _engineMap?: MapEngineMap; + + _currentTileType?: MapType; + + _boundLocations: MapLocation[] = []; + + _calculateLocationWarningLogged = false; + + _calculateRouteWarningLogged = false; + + _loadImpl(): Promise { + const window = getWindow() as Window & { ol?: unknown }; + const engine = getRegisteredMapEngine() ?? createOpenLayersEngine(window.ol); + + if (!engine) { + return Promise.reject(errors.Error('E1069')); + } + + this._engine = engine; + + return Promise.resolve(); + } + + _init(): Promise { + const optionCenter = this._getLatLng(this._option('center')); + const center = optionCenter + && Number.isFinite(optionCenter.lat) + && Number.isFinite(optionCenter.lng) + ? optionCenter + : getDefaultLocation(); + const engineMap = this._engine?.createMap(this._$container[0], { + center, + zoom: this._option('zoom') ?? 1, + }); + + if (!engineMap) { + return Promise.reject(errors.Error('E1069')); + } + + this._engineMap = engineMap; + this._map = engineMap.originalMap; + engineMap.setControls(Boolean(this._option('controls'))); + engineMap.setFocus( + Boolean(this._option('focusStateEnabled')), + this._option('tabIndex') ?? 0, + ); + engineMap.setDisabled(Boolean(this._option('disabled'))); + + return Promise.resolve(); + } + + _resolveTileConfig(type: MapType): TileServerConfig | undefined { + const option = this._option('providerConfig')?.tileServer; + const resolved = typeof option === 'function' ? option(type) : option; + + if (!resolved) { + return undefined; + } + + return typeof resolved === 'string' ? { url: resolved } : resolved; + } + + _resolveTileLayerOptions(type: MapType): MapEngineTileLayerOptions | undefined { + const config = this._resolveTileConfig(type); + + if (!config?.url) { + errors.log('W1030'); + return undefined; + } + + if (!config.attribution) { + errors.log('W1032'); + } + + const result: MapEngineTileLayerOptions = { + maxZoom: config.maxZoom ?? DEFAULT_MAX_ZOOM, + url: config.url, + }; + + if (config.attribution !== undefined) { + result.attribution = config.attribution; + } + + if (config.url.includes(SUBDOMAIN_PLACEHOLDER)) { + result.subdomains = config.subdomains?.length ? config.subdomains : DEFAULT_SUBDOMAINS; + } + + return result; + } + + _resolveLocation(location?: LocationOption | null): Promise { + const resolvedLocation = this._getLatLng(location); + + if (resolvedLocation + && Number.isFinite(resolvedLocation.lat) + && Number.isFinite(resolvedLocation.lng)) { + return Promise.resolve(resolvedLocation); + } + + return typeof location === 'string' + ? this._calculateLocation(location) + : Promise.resolve(getDefaultLocation()); + } + + _calculateLocation(query: string): Promise { + return this._geocodeLocation(query).then((location) => location ?? getDefaultLocation()); + } + + _geocodeLocationImpl(query: string): Promise { + const calculateLocation = this._option('providerConfig')?.calculateLocation; + if (!calculateLocation) { + if (!this._calculateLocationWarningLogged) { + errors.log('W1031'); + this._calculateLocationWarningLogged = true; + } + + return Promise.resolve(undefined); + } + + return Promise.resolve() + .then(() => calculateLocation(query)) + .then((location) => { + if (location + && Number.isFinite(location.lat) + && Number.isFinite(location.lng)) { + return { lat: location.lat, lng: location.lng }; + } + + errors.log('W1006', 'calculateLocation returned an invalid result.'); + + return undefined; + }, (error) => { + errors.log('W1006', error); + + return undefined; + }); + } + + _attachHandlers(): void { + this._engineMap?.attachHandlers({ + click: (event) => this._clickActionHandler(event), + markerSizeChange: () => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this._fitBounds(); + }, + viewChange: (view) => this._viewChangeHandler(view), + }); + } + + _clickActionHandler(event: MapEngineClickEvent): void { + this._fireClickAction(event); + } + + _viewChangeHandler(view: MapEngineViewState): void { + const { bounds, center, zoom } = view; + + if (bounds) { + const currentBounds = this._option('bounds'); + const currentNorthEast = this._getLatLng(currentBounds?.northEast); + const currentSouthWest = this._getLatLng(currentBounds?.southWest); + if (!areLocationsEqual(currentNorthEast, bounds.northEast) + || !areLocationsEqual(currentSouthWest, bounds.southWest)) { + this._option('bounds', bounds); + } + } + + if (center && !areLocationsEqual(this._getLatLng(this._option('center')), center)) { + this._option('center', center); + } + + if (zoom !== undefined && zoom !== this._option('zoom')) { + this._option('zoom', zoom); + } + } + + updateDimensions(): Promise { + const result = this._engineMap?.updateDimensions(); + if (result?.needsViewportRefit) { + return this._fitBounds(); + } + + return Promise.resolve(); + } + + updateMapType(): Promise { + const type = this._option('type') ?? 'roadmap'; + + if (type === this._currentTileType) { + return Promise.resolve(); + } + + const tileServer = this._option('providerConfig')?.tileServer; + if (this._currentTileType !== undefined && typeof tileServer !== 'function') { + this._currentTileType = type; + + return Promise.resolve(); + } + + const options = this._resolveTileLayerOptions(type); + if (options && this._engineMap) { + this._engineMap.replaceTileLayer(options); + this._currentTileType = type; + } + + return Promise.resolve(); + } + + updateCenter(): Promise { + const engineMap = this._engineMap; + const centerOption = this._option('center'); + const centerOptionKey = getLocationOptionKey(centerOption); + + return this._resolveLocation(centerOption).then((center) => { + if (engineMap !== this._engineMap + || centerOptionKey !== getLocationOptionKey(this._option('center'))) { + return; + } + + engineMap?.setView({ center }); + this._option('center', center); + }); + } + + updateZoom(): Promise { + this._engineMap?.setView({ zoom: this._option('zoom') ?? 1 }); + + return Promise.resolve(); + } + + updateDisabled(): Promise { + this._engineMap?.setDisabled(Boolean(this._option('disabled'))); + + return Promise.resolve(); + } + + updateFocus(): Promise { + this._engineMap?.setFocus( + Boolean(this._option('focusStateEnabled')), + this._option('tabIndex') ?? 0, + ); + + return Promise.resolve(); + } + + updateBounds(): Promise { + if (!this._areBoundsSet()) { + return Promise.resolve(); + } + + const bounds = this._option('bounds'); + const engineMap = this._engineMap; + const northEastOption = bounds?.northEast; + const southWestOption = bounds?.southWest; + const northEastOptionKey = getLocationOptionKey(northEastOption); + const southWestOptionKey = getLocationOptionKey(southWestOption); + + return Promise.all([ + this._resolveLocation(northEastOption), + this._resolveLocation(southWestOption), + ]).then(([northEast, southWest]) => { + const currentBounds = this._option('bounds'); + if (engineMap === this._engineMap + && northEastOptionKey === getLocationOptionKey(currentBounds?.northEast) + && southWestOptionKey === getLocationOptionKey(currentBounds?.southWest)) { + engineMap?.fitBounds({ northEast, southWest }); + } + }); + } + + updateControls(): Promise { + this._engineMap?.setControls(Boolean(this._option('controls'))); + + return Promise.resolve(); + } + + adjustViewport(): Promise { + return this._fitBounds(); + } + + _renderMarker(options: MarkerOptions): Promise { + const engineMap = this._engineMap; + if (!engineMap) { + return Promise.reject(errors.Error('E1069')); + } + + return this._resolveLocation(options.location).then((location) => { + if (engineMap !== this._engineMap) { + return Promise.reject(new Error('The map was disposed or replaced during marker creation.')); + } + + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const iconSrc = options.iconSrc || this._option('markerIconSrc'); + const markerClickAction = options.onClick + ? this._mapWidget._createAction(options.onClick) + : undefined; + const engineMarker = engineMap.addMarker({ + html: options.html, + htmlOffset: options.htmlOffset, + iconSrc, + location, + onClick: markerClickAction + ? (tooltip): void => markerClickAction({ location, ...(tooltip && { tooltip }) }) + : undefined, + rtlEnabled: Boolean(this._option('rtlEnabled')), + tooltip: options.tooltip ? this._parseTooltipOptions(options.tooltip) : undefined, + }); + + return { + engineMarker, + location, + marker: engineMarker.originalMarker, + }; + }); + } + + _destroyMarker(marker: EngineMarkerObject): void { + marker.engineMarker.dispose(!this._isCleaning); + } + + _fitBounds(): Promise { + this._updateBounds(); + this._bounds = createBounds(this._boundLocations, this._routes) ?? null; + + const engineMap = this._engineMap; + if (!engineMap || !this._bounds || !this._option('autoAdjust')) { + return Promise.resolve(); + } + + const zoomBeforeFitting = engineMap.getZoom(); + engineMap.fitBounds(this._bounds as MapEngineBounds, { includeMarkerPadding: true }); + const zoomAfterFitting = engineMap.getZoom(); + + if (zoomBeforeFitting !== undefined && zoomAfterFitting !== undefined) { + if (zoomBeforeFitting < zoomAfterFitting) { + engineMap.setView({ zoom: zoomBeforeFitting }); + } else { + this._option('zoom', zoomAfterFitting); + } + } + + return Promise.resolve(); + } + + _extendBounds(location: unknown): void { + const resolvedLocation = this._getLatLng(location as LocationOption); + if (!resolvedLocation + || !Number.isFinite(resolvedLocation.lat) + || !Number.isFinite(resolvedLocation.lng)) { + return; + } + + this._boundLocations.push(resolvedLocation); + } + + _clearBounds(): void { + super._clearBounds(); + this._boundLocations = []; + } + + _resolveRouteLocation(location: LocationOption): Promise { + const coordinates = this._getLatLng(location); + if (coordinates && Number.isFinite(coordinates.lat) && Number.isFinite(coordinates.lng)) { + return Promise.resolve(coordinates); + } + + return typeof location === 'string' + ? this._geocodeLocation(location) + : Promise.resolve(undefined); + } + + _calculateRoute(options: RouteOptions): Promise { + const waypoints = options.locations ?? []; + if (waypoints.length < 2) { + return Promise.resolve(undefined); + } + + const calculateRoute = this._option('providerConfig')?.calculateRoute; + if (!calculateRoute) { + if (!this._calculateRouteWarningLogged) { + errors.log('W1033'); + this._calculateRouteWarningLogged = true; + } + + return Promise.resolve(undefined); + } + + const engineMap = this._engineMap; + return Promise.all( + waypoints.map((location) => this._resolveRouteLocation(location)), + ) + .then((locations) => { + if (engineMap !== this._engineMap) { + throw new Error('The map was disposed or replaced during route creation.'); + } + if (!locations.every((location): location is MapLocation => location !== undefined)) { + return undefined; + } + + 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; + }); + }); + } + + _addRoute(options: RouteOptions): Promise { + return this._renderRoute(options).then((routeObject) => { + if (routeObject.engineRoute) { + this._routes.push({ ...routeObject, options }); + this._fireRouteAddedAction({ options, originalRoute: routeObject.instance }); + } + + return routeObject; + }); + } + + _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 {}; + } + + const engineRoute = engineMap.addRoute({ + locations, + color: options.color || this._defaultRouteColor(), + opacity: options.opacity ?? this._defaultRouteOpacity(), + weight: options.weight ?? this._defaultRouteWeight(), + }); + + return { ...getRouteBounds(locations), engineRoute, instance: engineRoute.originalRoute }; + }); + } + + _destroyRoute(route: EngineRouteObject): void { + route.engineRoute?.dispose(); + } + + clean(): Promise { + this._isCleaning = true; + try { + if (this._engineMap) { + this._clearMarkers(); + this._clearRoutes(); + } + this._engineMap?.dispose(); + this._engineMap = undefined; + this._engine = undefined; + this._map = undefined; + } finally { + this._isCleaning = false; + } + + return Promise.resolve(); + } +} + +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 2ee357335dd8..9bb56bdd268a 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.ts @@ -1,10 +1,8 @@ -import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import { extend } from '@js/core/utils/extend'; import type { DxEvent } from '@js/events'; import type { RouteMode } from '@js/ui/map'; -import type Map from './map'; import Provider from './provider'; import type { AzureLocation } from './provider.dynamic.azure'; import type { BingLocation } from './provider.dynamic.bing'; @@ -55,7 +53,9 @@ export interface RouteObject { southWest?: [number, number]; } -abstract class DynamicProvider extends Provider { +abstract class DynamicProvider< + TLocation = GoogleLocation | BingLocation | AzureLocation, +> extends Provider { // eslint-disable-next-line @typescript-eslint/no-explicit-any _bounds?: any; @@ -63,33 +63,47 @@ abstract class DynamicProvider extends Provider { _routes!: (RouteObject & { options: RouteOptions })[]; - _geocodedLocations: Record; + _geocodedLocations = new Map(); + + _pendingGeocodedLocations = new Map>(); _mapsLoader?: Promise; - constructor(map: Map, $container: dxElementWrapper) { - super(map, $container); + _geocodeLocation( + location: string, + ): Promise { + if (this._geocodedLocations.has(location)) { + return Promise.resolve(this._geocodedLocations.get(location) as TLocation); + } + + const pendingLocation = this._pendingGeocodedLocations.get(location); + if (pendingLocation) { + return pendingLocation; + } + + const geocoding = this._geocodeLocationImpl(location).then( + (geocodedLocation) => { + this._pendingGeocodedLocations.delete(location); + if (this._isGeocodedLocationValid(geocodedLocation)) { + this._geocodedLocations.set(location, geocodedLocation); + } + + return geocodedLocation; + }, + (error) => { + this._pendingGeocodedLocations.delete(location); - this._geocodedLocations = {}; + return Promise.reject(error); + }, + ); + + this._pendingGeocodedLocations.set(location, geocoding); + + return geocoding; } - _geocodeLocation( - location: string, - ): Promise { - return new Promise((resolve) => { - const cache = this._geocodedLocations; - const cachedLocation = cache[location]; - if (cachedLocation) { - resolve(cachedLocation); - } else { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this._geocodeLocationImpl(location) - .then((geocodedLocation) => { - cache[location] = geocodedLocation; - resolve(geocodedLocation); - }); - } - }); + _isGeocodedLocationValid(location: TLocation): boolean { + return location !== undefined && location !== null; } _renderImpl(): Promise { @@ -275,8 +289,8 @@ abstract class DynamicProvider extends Provider { _geocodeLocationImpl( // eslint-disable-next-line @typescript-eslint/no-unused-vars location: string, - ): Promise { - return Promise.resolve([0, 0]); + ): Promise { + return Promise.resolve([0, 0] as TLocation); } _clearRoutes(): void { diff --git a/packages/devextreme/js/__internal/ui/map/provider.ts b/packages/devextreme/js/__internal/ui/map/provider.ts index 23ea1a7ae70a..873106a2a4ac 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.ts @@ -9,6 +9,8 @@ import type Map from './map'; import type { MapProperties } from './map'; import type { LocationOption, MarkerOptions, RouteOptions } from './provider.dynamic'; +type KeyedMapProvider = Exclude; + abstract class Provider { _mapWidget!: Map; @@ -56,6 +58,10 @@ abstract class Provider { return Promise.resolve(); } + updateFocus(): Promise { + return Promise.resolve(); + } + abstract updateBounds(): Promise; abstract updateCenter(): Promise; @@ -71,17 +77,9 @@ abstract class Provider { markerOptionsToRemove: MarkerOptions[], markerOptionsToAdd: MarkerOptions[], ): Promise { - return new Promise((resolve) => { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this._applyFunctionIfNeeded('removeMarkers', markerOptionsToRemove) - .then((removeValue) => { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this._applyFunctionIfNeeded('addMarkers', markerOptionsToAdd) - .then((addValue) => { - resolve(addValue || removeValue); - }); - }); - }); + return this._applyFunctionIfNeeded('removeMarkers', markerOptionsToRemove) + .then((removeValue) => this._applyFunctionIfNeeded('addMarkers', markerOptionsToAdd) + .then((addValue) => addValue || removeValue)); } // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -100,17 +98,9 @@ abstract class Provider { routeOptionsToRemove: RouteOptions[], routeOptionsToAdd: RouteOptions[], ): Promise { - return new Promise((resolve) => { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this._applyFunctionIfNeeded('removeRoutes', routeOptionsToRemove) - .then((removeValue) => { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this._applyFunctionIfNeeded('addRoutes', routeOptionsToAdd) - .then((addValue) => { - resolve(addValue || removeValue); - }); - }); - }); + return this._applyFunctionIfNeeded('removeRoutes', routeOptionsToRemove) + .then((removeValue) => this._applyFunctionIfNeeded('addRoutes', routeOptionsToAdd) + .then((addValue) => addValue || removeValue)); } // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -152,7 +142,7 @@ abstract class Provider { return undefined; } - _keyOption(providerName: MapProvider): string { + _keyOption(providerName: KeyedMapProvider): string { const key = this._option('apiKey') ?? ''; if (typeof key === 'string') { return key; diff --git a/packages/devextreme/js/localization/messages/ar.json b/packages/devextreme/js/localization/messages/ar.json index 5481e4698ad8..7b9c58586079 100644 --- a/packages/devextreme/js/localization/messages/ar.json +++ b/packages/devextreme/js/localization/messages/ar.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "يجب أن يكون تاريخ البداية بعد {0}", "dxGantt-dialogEndDateValidation": "يجب أن يكون تاريخ الانتهاء بعد {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "عنصر المعرض", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/bg.json b/packages/devextreme/js/localization/messages/bg.json index ed237704ffed..f3db558b5121 100644 --- a/packages/devextreme/js/localization/messages/bg.json +++ b/packages/devextreme/js/localization/messages/bg.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Началната дата трябва да е след {0}", "dxGantt-dialogEndDateValidation": "Крайната дата трябва да е след {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Елемент от галерия", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/ca.json b/packages/devextreme/js/localization/messages/ca.json index 36d3fc31030c..064740f0439d 100644 --- a/packages/devextreme/js/localization/messages/ca.json +++ b/packages/devextreme/js/localization/messages/ca.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "La data d'inici ha de ser després de {0}", "dxGantt-dialogEndDateValidation": "La data de finalització ha de ser després de {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Element de galeria", "dxMultiView-elementAriaRoleDescription": "Multivista", diff --git a/packages/devextreme/js/localization/messages/cs.json b/packages/devextreme/js/localization/messages/cs.json index 67335ccae6c3..952c01f17d3b 100644 --- a/packages/devextreme/js/localization/messages/cs.json +++ b/packages/devextreme/js/localization/messages/cs.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Datum zahájení musí být po {0}", "dxGantt-dialogEndDateValidation": "Datum ukončení musí být po {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Položka galerie", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/da.json b/packages/devextreme/js/localization/messages/da.json index f40d9f640af9..0b88345e43b2 100644 --- a/packages/devextreme/js/localization/messages/da.json +++ b/packages/devextreme/js/localization/messages/da.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Startdatoen skal være efter {0}", "dxGantt-dialogEndDateValidation": "Slutdatoen skal være efter {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Gallerielement", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/de.json b/packages/devextreme/js/localization/messages/de.json index 03ca59bd785c..d2be739bb2f6 100644 --- a/packages/devextreme/js/localization/messages/de.json +++ b/packages/devextreme/js/localization/messages/de.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Startdatum muss später sein als {0}", "dxGantt-dialogEndDateValidation": "Enddatum muss später sein als {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Galerieelement", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/el.json b/packages/devextreme/js/localization/messages/el.json index a7d5a18060a9..633cf9b2aeee 100644 --- a/packages/devextreme/js/localization/messages/el.json +++ b/packages/devextreme/js/localization/messages/el.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Η ημερομηνία έναρξης πρέπει να είναι μετά την {0}", "dxGantt-dialogEndDateValidation": "Η ημερομηνία λήξης πρέπει να είναι μετά την {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Στοιχείο γκαλερί", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/en.json b/packages/devextreme/js/localization/messages/en.json index b351d7dca066..a26b1ddbca67 100644 --- a/packages/devextreme/js/localization/messages/en.json +++ b/packages/devextreme/js/localization/messages/en.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Start date must be after {0}", "dxGantt-dialogEndDateValidation": "End date must be after {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Gallery item", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/es.json b/packages/devextreme/js/localization/messages/es.json index c21f7180c064..173bca273676 100644 --- a/packages/devextreme/js/localization/messages/es.json +++ b/packages/devextreme/js/localization/messages/es.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "La fecha de inicio debe ser anterior {0}", "dxGantt-dialogEndDateValidation": "La fecha de finalización debe ser posterior {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Elemento de galería", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/fa.json b/packages/devextreme/js/localization/messages/fa.json index c7a8f14662d9..849e82e4bf0b 100644 --- a/packages/devextreme/js/localization/messages/fa.json +++ b/packages/devextreme/js/localization/messages/fa.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "تاریخ شروع باید بعد از {0} باشد", "dxGantt-dialogEndDateValidation": "تاریخ پایان باید بعد از {0} باشد", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "مورد گالری", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/fi.json b/packages/devextreme/js/localization/messages/fi.json index 85428935b970..cab37bb08889 100644 --- a/packages/devextreme/js/localization/messages/fi.json +++ b/packages/devextreme/js/localization/messages/fi.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Aloituspäivän on oltava {0} jälkeen", "dxGantt-dialogEndDateValidation": "Lopetuspäivän on oltava {0} jälkeen", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Galleriakohde", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/fr.json b/packages/devextreme/js/localization/messages/fr.json index 75e73508a044..8a71d72a49c6 100644 --- a/packages/devextreme/js/localization/messages/fr.json +++ b/packages/devextreme/js/localization/messages/fr.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "La date de début doit être postérieure au {0}", "dxGantt-dialogEndDateValidation": "La date de fin doit être postérieure au {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Élément de la galerie", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/hi.json b/packages/devextreme/js/localization/messages/hi.json index 44809ab806c5..6a915e7c9c7b 100644 --- a/packages/devextreme/js/localization/messages/hi.json +++ b/packages/devextreme/js/localization/messages/hi.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "आरंभ तिथि {0} के बाद होनी चाहिए", "dxGantt-dialogEndDateValidation": "समाप्ति तिथि {0} के बाद होनी चाहिए", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "गैलरी आइटम", "dxMultiView-elementAriaRoleDescription": "मल्टीव्यू", diff --git a/packages/devextreme/js/localization/messages/hu.json b/packages/devextreme/js/localization/messages/hu.json index 62b96ecfa12a..937ee057e431 100644 --- a/packages/devextreme/js/localization/messages/hu.json +++ b/packages/devextreme/js/localization/messages/hu.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "A kezdő dátumnak {0} után kell lennie", "dxGantt-dialogEndDateValidation": "A befejezési dátumnak {0} után kell lennie", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Galéria elem", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/it.json b/packages/devextreme/js/localization/messages/it.json index 77b233d386bc..54ab1b147e15 100644 --- a/packages/devextreme/js/localization/messages/it.json +++ b/packages/devextreme/js/localization/messages/it.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "La data di inizio deve essere successiva al {0}", "dxGantt-dialogEndDateValidation": "La data di fine deve essere successiva al {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Elemento galleria", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/ja.json b/packages/devextreme/js/localization/messages/ja.json index 8ba9516e581e..057a4907d3a3 100644 --- a/packages/devextreme/js/localization/messages/ja.json +++ b/packages/devextreme/js/localization/messages/ja.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "開始日を {0} 以降にする必要があります", "dxGantt-dialogEndDateValidation": "終了日は {0} より後にする必要があります", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "ギャラリー アイテム", "dxMultiView-elementAriaRoleDescription": "マルチビュー", diff --git a/packages/devextreme/js/localization/messages/ko.json b/packages/devextreme/js/localization/messages/ko.json index 108dc23332a7..73ae4172bc76 100644 --- a/packages/devextreme/js/localization/messages/ko.json +++ b/packages/devextreme/js/localization/messages/ko.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "시작 날짜는 {0} 이후여야 합니다", "dxGantt-dialogEndDateValidation": "종료 날짜는 {0} 이후여야 합니다", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "갤러리 항목", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/lt.json b/packages/devextreme/js/localization/messages/lt.json index ad1bb8a3bf1a..4dcc7dd4e1cd 100644 --- a/packages/devextreme/js/localization/messages/lt.json +++ b/packages/devextreme/js/localization/messages/lt.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Pradžios data turi būti po {0}", "dxGantt-dialogEndDateValidation": "Pabaigos data turi būti po {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Galerijos elementas", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/lv.json b/packages/devextreme/js/localization/messages/lv.json index 7e590c0f1fc0..cf47a8fd310f 100644 --- a/packages/devextreme/js/localization/messages/lv.json +++ b/packages/devextreme/js/localization/messages/lv.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Sākuma datumam jābūt vēlākam par {0}", "dxGantt-dialogEndDateValidation": "Beigu datumam jābūt vēlākam par {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Galerijas elements", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/nb.json b/packages/devextreme/js/localization/messages/nb.json index 58b47de71f6f..1f6261fdcdd3 100644 --- a/packages/devextreme/js/localization/messages/nb.json +++ b/packages/devextreme/js/localization/messages/nb.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Startdato må være etter {0}", "dxGantt-dialogEndDateValidation": "Sluttdato må være etter {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Gallerielement", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/nl.json b/packages/devextreme/js/localization/messages/nl.json index 2a8f64fcc2a1..b5746aa9cc4c 100644 --- a/packages/devextreme/js/localization/messages/nl.json +++ b/packages/devextreme/js/localization/messages/nl.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Startdatum moet na {0} vallen", "dxGantt-dialogEndDateValidation": "Einddatum moet na {0} vallen", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Galerij-item", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/pl.json b/packages/devextreme/js/localization/messages/pl.json index 75dd6186c99f..ccfe943344d3 100644 --- a/packages/devextreme/js/localization/messages/pl.json +++ b/packages/devextreme/js/localization/messages/pl.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Data rozpoczęcia musi być późniejsza niż {0}", "dxGantt-dialogEndDateValidation": "Data zakończenia musi być późniejsza niż {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Element galerii", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/pt-BR.json b/packages/devextreme/js/localization/messages/pt-BR.json index 014c96fc3145..0458be85bfdf 100644 --- a/packages/devextreme/js/localization/messages/pt-BR.json +++ b/packages/devextreme/js/localization/messages/pt-BR.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "A data de início deve ser após {0}", "dxGantt-dialogEndDateValidation": "A data de término deve ser posterior a {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Item da galeria", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/pt-PT.json b/packages/devextreme/js/localization/messages/pt-PT.json index 466365cb4fea..f9ee5d624072 100644 --- a/packages/devextreme/js/localization/messages/pt-PT.json +++ b/packages/devextreme/js/localization/messages/pt-PT.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "A data de início deve ser após {0}", "dxGantt-dialogEndDateValidation": "A data de fim deve ser posterior a {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Item da galeria", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/pt.json b/packages/devextreme/js/localization/messages/pt.json index 6b598ae29762..e83c902d75d1 100644 --- a/packages/devextreme/js/localization/messages/pt.json +++ b/packages/devextreme/js/localization/messages/pt.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "A data de início deve ser após {0}", "dxGantt-dialogEndDateValidation": "A data de término deve ser posterior a {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Item da galeria", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/ro.json b/packages/devextreme/js/localization/messages/ro.json index dd868ebe305d..b6882ff19265 100644 --- a/packages/devextreme/js/localization/messages/ro.json +++ b/packages/devextreme/js/localization/messages/ro.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Data de început trebuie să fie după {0}", "dxGantt-dialogEndDateValidation": "Data de sfârșit trebuie să fie după {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Element galerie", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/ru.json b/packages/devextreme/js/localization/messages/ru.json index 1cd2c08413ad..50814d86ec15 100644 --- a/packages/devextreme/js/localization/messages/ru.json +++ b/packages/devextreme/js/localization/messages/ru.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Начальная дата должна быть позднее {0}", "dxGantt-dialogEndDateValidation": "Конечная дата должна быть позднее {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Элемент галереи", "dxMultiView-elementAriaRoleDescription": "Переключатель представлений", diff --git a/packages/devextreme/js/localization/messages/sl.json b/packages/devextreme/js/localization/messages/sl.json index c49db2f4affc..f9279afa7718 100644 --- a/packages/devextreme/js/localization/messages/sl.json +++ b/packages/devextreme/js/localization/messages/sl.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Datum začetka mora biti po {0}", "dxGantt-dialogEndDateValidation": "Datum konca mora biti po {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Element galerije", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/sv.json b/packages/devextreme/js/localization/messages/sv.json index 409cd11b3169..27c837abc3ea 100644 --- a/packages/devextreme/js/localization/messages/sv.json +++ b/packages/devextreme/js/localization/messages/sv.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Startdatum måste vara efter {0}", "dxGantt-dialogEndDateValidation": "Slutdatum måste vara efter {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Gallerielement", "dxMultiView-elementAriaRoleDescription": "Vyväxlare", diff --git a/packages/devextreme/js/localization/messages/tr.json b/packages/devextreme/js/localization/messages/tr.json index ec6da10e1603..f40c1c4dcb35 100644 --- a/packages/devextreme/js/localization/messages/tr.json +++ b/packages/devextreme/js/localization/messages/tr.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Başlangıç tarihi {0} tarihinden sonra olmalıdır", "dxGantt-dialogEndDateValidation": "Bitiş tarihi {0} tarihinden sonra olmalıdır", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Galeri öğesi", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/uk.json b/packages/devextreme/js/localization/messages/uk.json index fedee25990ac..218c7b7b5cb4 100644 --- a/packages/devextreme/js/localization/messages/uk.json +++ b/packages/devextreme/js/localization/messages/uk.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Дата початку має бути після {0}", "dxGantt-dialogEndDateValidation": "Дата завершення має бути після {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Елемент галереї", "dxMultiView-elementAriaRoleDescription": "Кілька представлень", diff --git a/packages/devextreme/js/localization/messages/vi.json b/packages/devextreme/js/localization/messages/vi.json index bf311b55f912..53dd93d94af1 100644 --- a/packages/devextreme/js/localization/messages/vi.json +++ b/packages/devextreme/js/localization/messages/vi.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "Ngày bắt đầu phải sau {0}", "dxGantt-dialogEndDateValidation": "Ngày kết thúc phải sau {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "Mục thư viện", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/zh-CN.json b/packages/devextreme/js/localization/messages/zh-CN.json index be5acebd67e0..0f42cbe76b22 100644 --- a/packages/devextreme/js/localization/messages/zh-CN.json +++ b/packages/devextreme/js/localization/messages/zh-CN.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "开始日期必须晚于 {0}", "dxGantt-dialogEndDateValidation": "结束日期必须晚于 {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "图库项目", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/zh-Hans.json b/packages/devextreme/js/localization/messages/zh-Hans.json index 84f31725a97d..039d1c0e3f32 100644 --- a/packages/devextreme/js/localization/messages/zh-Hans.json +++ b/packages/devextreme/js/localization/messages/zh-Hans.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "开始日期必须晚于 {0}", "dxGantt-dialogEndDateValidation": "结束日期必须晚于 {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "图库项目", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/zh-tw.json b/packages/devextreme/js/localization/messages/zh-tw.json index fa266a611d61..982d2f446c77 100644 --- a/packages/devextreme/js/localization/messages/zh-tw.json +++ b/packages/devextreme/js/localization/messages/zh-tw.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "開始日期必須晚於 {0}", "dxGantt-dialogEndDateValidation": "結束日期必須晚於 {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "圖庫項目", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/localization/messages/zh.json b/packages/devextreme/js/localization/messages/zh.json index 08beff591d81..2de183c44423 100644 --- a/packages/devextreme/js/localization/messages/zh.json +++ b/packages/devextreme/js/localization/messages/zh.json @@ -920,6 +920,8 @@ "dxGantt-dialogStartDateValidation": "开始日期必须晚于 {0}", "dxGantt-dialogEndDateValidation": "结束日期必须晚于 {0}", + "dxMap-markerAriaLabel": "Map marker", + "dxGallery-itemName": "图库项目", "dxMultiView-elementAriaRoleDescription": "MultiView", diff --git a/packages/devextreme/js/ui/map.d.ts b/packages/devextreme/js/ui/map.d.ts index 3114984ba144..45287093d83e 100644 --- a/packages/devextreme/js/ui/map.d.ts +++ b/packages/devextreme/js/ui/map.d.ts @@ -9,17 +9,74 @@ import { ChangedOptionInfo, } from '../events'; +import dxPopover from './popover'; + import Widget, { WidgetOptions, } 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'; /** @public */ export type MapType = 'hybrid' | 'roadmap' | 'satellite'; +/** + * @docid + * @public + */ +export type CalculateOsmRouteInfo = { + locations: Array; + mode: RouteMode | string; +}; + +/** + * @docid + * @public + */ +export type OsmGeoJsonLineString = { + type: 'LineString'; + coordinates: Array>; +}; + +/** + * @docid + * @type Array>|OsmGeoJsonLineString + * @public + */ +export type OsmRouteResult = Array<[number, number]> | OsmGeoJsonLineString; + +/** + * @docid + * @public + */ +export type OsmTileServerConfig = { + /** + * @docid + */ + url: string; + /** + * @docid + */ + attribution?: string; + /** + * @docid + */ + subdomains?: string | Array; + /** + * @docid + */ + maxZoom?: number; +}; + +/** + * @docid + * @public + */ +export type OsmTileServer = string | OsmTileServerConfig +| ((type: MapType) => string | OsmTileServerConfig | undefined); + /** * @docid _ui_map_ClickEvent * @public @@ -66,6 +123,19 @@ export type MarkerAddedEvent = EventInfo & { originalMarker: any; }; +/** + * @docid _ui_map_MarkerClickEvent + * @public + * @type object + * @inherits EventInfo + */ +export type MarkerClickEvent = EventInfo & { + /** @docid _ui_map_MarkerClickEvent.location */ + location: MapLocation; + /** @docid _ui_map_MarkerClickEvent.tooltip */ + tooltip?: dxPopover; +}; + /** * @docid _ui_map_MarkerRemovedEvent * @public @@ -245,8 +315,9 @@ export interface dxMapOptions extends WidgetOptions { location?: any | string | Array; /** * @docid + * @type_function_param1 e:{ui/map:MarkerClickEvent} */ - onClick?: Function; + onClick?: ((e: MarkerClickEvent) => void); /** * @docid */ @@ -336,6 +407,22 @@ export interface dxMapOptions extends WidgetOptions { * @deprecated */ useAdvancedMarkers?: boolean; + /** + * @docid + * @public + * @default undefined + */ + tileServer?: OsmTileServer | undefined; + /** + * @docid + * @public + */ + calculateLocation?: (query: string) => PromiseLike; + /** + * @docid + * @public + */ + calculateRoute?: (params: CalculateOsmRouteInfo) => PromiseLike; }; /** * @docid diff --git a/packages/devextreme/js/ui/map/openlayers.js b/packages/devextreme/js/ui/map/openlayers.js new file mode 100644 index 000000000000..8483a42b2539 --- /dev/null +++ b/packages/devextreme/js/ui/map/openlayers.js @@ -0,0 +1 @@ +import './openlayers.register'; diff --git a/packages/devextreme/js/ui/map/openlayers.register.js b/packages/devextreme/js/ui/map/openlayers.register.js new file mode 100644 index 000000000000..affa22c839a0 --- /dev/null +++ b/packages/devextreme/js/ui/map/openlayers.register.js @@ -0,0 +1,57 @@ +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 { + getUserProjection, + toLonLat, + transform, + 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: { + defaults: defaultControls, + }, + }, + interaction: { + defaults: { + defaults: defaultInteractions, + }, + }, + layer: { + Tile: TileLayer, + Vector: VectorLayer, + }, + proj: { + getUserProjection, + toLonLat, + transform, + transformExtent, + }, + source: { + ImageTile, + Vector: VectorSource, + }, + style: { Stroke, Style }, +})); diff --git a/packages/devextreme/js/ui/map_types.d.ts b/packages/devextreme/js/ui/map_types.d.ts index 2c1637b47f4d..78922937e32b 100644 --- a/packages/devextreme/js/ui/map_types.d.ts +++ b/packages/devextreme/js/ui/map_types.d.ts @@ -2,10 +2,16 @@ export { MapProvider, RouteMode, MapType, + CalculateOsmRouteInfo, + OsmGeoJsonLineString, + OsmRouteResult, + OsmTileServerConfig, + OsmTileServer, ClickEvent, DisposingEvent, InitializedEvent, MarkerAddedEvent, + MarkerClickEvent, MarkerRemovedEvent, OptionChangedEvent, ReadyEvent, diff --git a/packages/devextreme/js/ui/widget/ui.errors.js b/packages/devextreme/js/ui/widget/ui.errors.js index 1efdfe91bc5e..c83ad408bbbe 100644 --- a/packages/devextreme/js/ui/widget/ui.errors.js +++ b/packages/devextreme/js/ui/widget/ui.errors.js @@ -164,6 +164,9 @@ /** * @name ErrorsUIWidgets.E1068 */ +/** + * @name ErrorsUIWidgets.E1069 + */ /** * @name ErrorsUIWidgets.W1001 */ @@ -242,6 +245,18 @@ /** * @name ErrorsUIWidgets.W1029 */ +/** + * @name ErrorsUIWidgets.W1030 + */ +/** + * @name ErrorsUIWidgets.W1031 + */ +/** + * @name ErrorsUIWidgets.W1032 + */ +/** + * @name ErrorsUIWidgets.W1033 + */ import errors from '../../__internal/ui/errors'; export default errors; diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index 3e76b2ceeeaf..b2d266e28c74 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -510,6 +510,7 @@ "srcDir": "./js", "excludePatterns": [ "__internal/**/*", + "ui/map/openlayers.register.js", "viz/docs/**/*", "bundles/**/*" ], diff --git a/packages/devextreme/testing/helpers/forMap/openLayersMock.js b/packages/devextreme/testing/helpers/forMap/openLayersMock.js new file mode 100644 index 000000000000..b9166fac9197 --- /dev/null +++ b/packages/devextreme/testing/helpers/forMap/openLayersMock.js @@ -0,0 +1,389 @@ +/* global window */ + +(() => { + const api = {}; + const GEOGRAPHIC_PROJECTION = 'EPSG:4326'; + const WEB_MERCATOR_PROJECTION = 'EPSG:3857'; + const PROJECTION_SCALE = 1000; + + const transformCoordinate = (coordinate, source, destination) => { + if(source === destination) { + return [...coordinate]; + } + if(source === GEOGRAPHIC_PROJECTION && destination === WEB_MERCATOR_PROJECTION) { + return coordinate.map((value) => value * PROJECTION_SCALE); + } + if(source === WEB_MERCATOR_PROJECTION && destination === GEOGRAPHIC_PROJECTION) { + return coordinate.map((value) => value / PROJECTION_SCALE); + } + return [...coordinate]; + }; + class MockCollection { + constructor(items) { + this.items = items; + } + + forEach(callback) { + this.items.forEach(callback); + } + + getArray() { + return this.items; + } + } + + class MockInteraction { + constructor(active) { + this.active = active; + } + + getActive() { + return this.active; + } + setActive(active) { + this.active = active; + api.interactionStateChanges.push({ + interaction: this, + active + }); + if(api.onInteractionStateChanged) { + api.onInteractionStateChanged(); + } + } + } + + class MockZoom { + constructor() { + api.zoomControlCreatedCount += 1; + } + } + class MockOverlay { + constructor(options) { + this.options = options; + api.overlayOptions.push(options); + } + getPosition() { + return this.options.position; + } + setPosition(position) { + this.options.position = position; + api.overlayPositionChanges.push(position); + } + } + class MockView { + constructor(options) { + this.center = options.center; + this.eventHandlers = {}; + this.projection = options.projection || WEB_MERCATOR_PROJECTION; + this.zoom = options.zoom; + api.viewCenter = options.center; + api.viewOptions = options; + api.viewZoom = options.zoom; + } + + calculateExtent() { + return api.viewExtent; + } + + fit(extent, options) { + api.fitCallCount += 1; + api.fittedExtent = extent; + api.fitOptions = options; + this.center = [(extent[0] + extent[2]) / 2, (extent[1] + extent[3]) / 2]; + api.viewCenter = this.center; + if(api.fitZoom !== undefined) { + this.zoom = api.fitZoom; + api.viewZoom = api.fitZoom; + } + this.trigger('change:center'); + } + getCenter() { + return this.center; + } + + getProjection() { + return this.projection; + } + + getZoom() { + return this.zoom; + } + on(type, listener) { + this.eventHandlers[type] = this.eventHandlers[type] || []; + this.eventHandlers[type].push(listener); + } + setCenter(center) { + this.center = center; + api.viewCenter = center; + api.viewCenterSetCount += 1; + this.trigger('change:center'); + } + setZoom(zoom) { + this.zoom = zoom; + api.viewZoom = zoom; + api.viewZoomSetCount += 1; + } + trigger(type) { + (this.eventHandlers[type] || []).slice().forEach((handler) => handler()); + } + un(type, listener) { + const handlers = this.eventHandlers[type] || []; + this.eventHandlers[type] = handlers.filter((handler) => handler !== listener); + } + } + class MockMap { + constructor(options) { + this.options = options; + this.view = options.view; + this.eventHandlers = {}; + this.overlayContainer = document.createElement('div'); + Object.assign(this.overlayContainer.style, { + position: 'absolute', + zIndex: '0', + width: '100%', + height: '100%', + pointerEvents: 'none' + }); + this.overlayContainerStopEvent = document.createElement('div'); + options.target.append(this.overlayContainer, this.overlayContainerStopEvent); + api.mapCreated = true; + api.mapInstance = this; + api.mapOptions = options; + api.overlayContainer = this.overlayContainer; + api.overlayContainerStopEvent = this.overlayContainerStopEvent; + } + addControl(control) { + api.addedControls.push(control); + } + + addLayer(layer) { + if(layer instanceof MockVectorLayer) { + api.addedVectorLayers.push(layer); + return; + } + api.tileLayer = layer; + api.addedTileLayers.push(layer); + } + addOverlay(overlay) { + api.addedOverlays.push(overlay); + if(api.getOverlayRect) { + overlay.options.element.getBoundingClientRect = api.getOverlayRect; + } + if(overlay.options.insertFirst) { + this.options.target.prepend(overlay.options.element); + } else { + this.options.target.appendChild(overlay.options.element); + } + } + getInteractions() { + return this.options.interactions; + } + getOverlayContainer() { + return this.overlayContainer; + } + getOverlayContainerStopEvent() { + return this.overlayContainerStopEvent; + } + getView() { + return this.view; + } + + on(type, listener) { + this.eventHandlers[type] = this.eventHandlers[type] || []; + this.eventHandlers[type].push(listener); + } + + removeControl(control) { + api.removedControls.push(control); + } + + removeLayer(layer) { + api.removedLayers.push(layer); + } + removeOverlay(overlay) { + api.removedOverlays.push(overlay); + overlay.options.element.remove(); + } + setTarget(target) { + this.target = target; + api.mapTarget = target; + } + setView(view) { + this.view = view; + this.trigger('change:view'); + } + + updateSize() { + api.mapResized = true; + } + un(type, listener) { + const handlers = this.eventHandlers[type] || []; + this.eventHandlers[type] = handlers.filter((handler) => handler !== listener); + } + trigger(type, event) { + (this.eventHandlers[type] || []).slice().forEach((handler) => handler(event || {})); + } + } + class MockImageTile { + constructor(options) { + if(api.throwOnTileSource) { + throw new Error('Tile source creation failed'); + } + this.options = options; + api.tileSourceOptions = options; + } + } + + class MockTileLayer { + constructor(options) { + this.source = options.source; + api.tileLayerOptions = options; + } + + setSource(source) { + this.source = source; + 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: { + defaults(options) { + api.controlOptions = options; + return new MockCollection([]); + } + } + }, + interaction: { + defaults: { + defaults(options) { + api.interactionOptions = options; + api.interactions = [new MockInteraction(true), new MockInteraction(false)]; + return new MockCollection(api.interactions); + } + } + }, + layer: { + Tile: MockTileLayer, + Vector: MockVectorLayer + }, + proj: { + getUserProjection() { + return api.userProjection; + }, + toLonLat(coordinate, projection) { + const result = transformCoordinate(coordinate, projection, GEOGRAPHIC_PROJECTION); + if(result[0] < -180 || result[0] > 180) { + result[0] = ((result[0] + 180) % 360 + 360) % 360 - 180; + } + + return result; + }, + transform(coordinate, source, destination) { + api.transformedCoordinates.push({ + coordinate: [...coordinate], + destination, + source + }); + if(source === GEOGRAPHIC_PROJECTION) { + api.projectedCoordinates.push([...coordinate]); + } + + return transformCoordinate(coordinate, source, destination); + }, + transformExtent(extent, source, destination) { + api.transformedExtents.push({ + destination, + extent: [...extent], + source + }); + const southWest = transformCoordinate([extent[0], extent[1]], source, destination); + const northEast = transformCoordinate([extent[2], extent[3]], source, destination); + + return [southWest[0], southWest[1], northEast[0], northEast[1]]; + } + }, + source: { + ImageTile: MockImageTile, + Vector: MockVectorSource + }, + style: { Stroke: MockStroke, Style: MockStyle } + }); + window.ol = api; +})(); 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/commonTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/commonTests.js index 754ac7c8141a..9a1b90b59b45 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/commonTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/commonTests.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import { MARKERS, ROUTES } from './utils.js'; import Map from 'ui/map'; +import Provider from '__internal/ui/map/provider'; import GoogleStaticProvider from '__internal/ui/map/provider.google_static'; import ajaxMock from '../../../helpers/ajaxMock.js'; @@ -830,6 +831,58 @@ QUnit.test('change provider and async options', function(assert) { }); }); +['resolve', 'reject'].forEach((completion) => { + QUnit.test(`async ${completion} from a replaced provider is ignored`, function(assert) { + let completeAction; + const actionResult = new Promise((resolve, reject) => { + completeAction = completion === 'resolve' ? resolve : reject; + }); + const replacedProvider = { + updateZoom: sinon.stub().returns(actionResult) + }; + const map = { + _provider: replacedProvider, + _lastAsyncAction: Promise.resolve(), + _triggerReadyAction: sinon.spy(), + _triggerUpdateAction: sinon.spy(), + }; + const action = Map.prototype._queueAsyncAction.call(map, 'updateZoom'); + + return Promise.resolve().then(() => { + assert.ok(replacedProvider.updateZoom.calledOnce, 'action starts on the captured provider'); + map._provider = {}; + completeAction(completion === 'resolve' ? true : new Error('stale provider')); + + return action; + }).then(() => { + assert.ok(map._triggerReadyAction.notCalled, 'stale action does not raise onReady'); + assert.ok(map._triggerUpdateAction.notCalled, 'stale action does not raise onUpdated'); + }); + }); +}); + +QUnit.test('queued action is not started after its provider is replaced', function(assert) { + let continueQueue; + const replacedProvider = { + updateZoom: sinon.spy() + }; + const map = { + _provider: replacedProvider, + _lastAsyncAction: new Promise(resolve => { + continueQueue = resolve; + }), + _triggerReadyAction: sinon.spy(), + _triggerUpdateAction: sinon.spy(), + }; + const action = Map.prototype._queueAsyncAction.call(map, 'updateZoom'); + map._provider = {}; + continueQueue(); + + return action.then(() => { + assert.ok(replacedProvider.updateZoom.notCalled, 'action captured for the old provider is skipped'); + }); +}); + QUnit.module('disposed widget', { beforeEach: function() { const fakeURL = '/fakeGoogleUrl?'; @@ -883,6 +936,59 @@ QUnit.module('disposed widget', { }); }); +QUnit.module('provider update operations', () => { + ['Markers', 'Routes'].forEach(collection => { + const updateMethod = `update${collection}`; + const addMethod = `add${collection}`; + const removeMethod = `remove${collection}`; + + QUnit.test(`${updateMethod} waits for removal and returns the addition result`, async function(assert) { + const provider = new Provider(null, null); + const removedOptions = [{}]; + const addedOptions = [{}]; + const addedResult = [false, [{}]]; + let completeRemoval; + const removal = new Promise(resolve => { completeRemoval = resolve; }); + const remove = sinon.stub(provider, removeMethod).returns(removal); + const add = sinon.stub(provider, addMethod).returns(Promise.resolve(addedResult)); + + const pending = provider[updateMethod](removedOptions, addedOptions); + assert.ok(remove.calledOnceWithExactly(removedOptions), 'removal receives its options'); + assert.ok(add.notCalled, 'addition waits for removal to finish'); + completeRemoval(true); + + assert.strictEqual(await pending, addedResult, 'addition result is preserved'); + assert.ok(add.calledOnceWithExactly(addedOptions), 'addition receives its options'); + }); + + ['add', 'remove'].forEach(operation => { + QUnit.test(`${updateMethod} rejects when ${operation} fails`, async function(assert) { + const provider = new Provider(null, null); + const reason = new Error('Provider operation failed'); + const remove = sinon.stub(provider, removeMethod).returns(Promise.resolve()); + const add = sinon.stub(provider, addMethod).returns(Promise.resolve()); + const failingOperation = operation === 'add' ? add : remove; + failingOperation.callsFake(() => Promise.reject(reason)); + + await assert.rejects(provider[updateMethod]([{}], [{}]), reason, 'the update rejects with the original error'); + if(operation === 'remove') { + assert.ok(add.notCalled, 'failed removal prevents addition'); + } + }); + }); + + QUnit.test(`${updateMethod} skips empty batches`, async function(assert) { + const provider = new Provider(null, null); + const remove = sinon.spy(provider, removeMethod); + const add = sinon.spy(provider, addMethod); + + assert.strictEqual(await provider[updateMethod]([], []), undefined, 'empty update completes without a result'); + assert.ok(remove.notCalled, 'empty removal is skipped'); + assert.ok(add.notCalled, 'empty addition is skipped'); + }); + }); +}); + QUnit.module('Accessibility', { beforeEach: function() { const fakeURL = '/fakeGoogleUrl?'; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/googleStaticTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/googleStaticTests.js index 81e454e517c8..97f8e2e39303 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/googleStaticTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/googleStaticTests.js @@ -58,6 +58,28 @@ QUnit.test('default options', function(assert) { }); }); +[ + ['markers', 'removeMarker', MARKERS[0]], + ['routes', 'removeRoute', ROUTES[0]] +].forEach(([option, removeMethod, item]) => { + QUnit.test(`removing the last ${option} item still raises the ready event`, async function(assert) { + const map = await new Promise(resolve => { + new Map($('#map'), { + provider: 'googleStatic', + [option]: [item], + onReady: ({ component }) => resolve(component) + }); + }); + const onReady = sinon.spy(); + map.option('onReady', onReady); + + await map[removeMethod](item); + + assert.deepEqual(map.option(option), [], 'the item is removed'); + assert.ok(onReady.calledOnce, 'the removal result still reports a refreshed map'); + }); +}); + QUnit.test('dimensions', function(assert) { return new Promise(function(resolve) { const map = new Map($('#map'), { 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..30ccc34810da --- /dev/null +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -0,0 +1,3698 @@ +import $ from 'jquery'; + +import OsmProvider from '__internal/ui/map/provider.dynamic.osm'; +import { setRegisteredMapEngine } from '__internal/ui/map/provider.dynamic.osm.engine'; +import { createOpenLayersEngine } from '__internal/ui/map/provider.dynamic.osm.openlayers'; +import coreErrors from 'core/errors'; +import resizeObserverSingleton from 'core/resize_observer'; +import localization from 'localization'; +import Popover from 'ui/popover'; +import SelectBox from 'ui/select_box'; +import errors from 'ui/widget/ui.errors'; + +import 'ui/map'; + +let openLayersMock; +let resizeObserverCallbacks; +const triggerResize = (element) => { + const callback = resizeObserverCallbacks.get(element); + + if(callback) { + callback(); + } +}; +const resetOpenLayersMock = () => { + Object.assign(openLayersMock, { + addedControls: [], + addedOverlays: [], + addedTileLayers: [], + addedVectorLayers: [], + controlOptions: null, + fitCallCount: 0, + fitZoom: undefined, + fitOptions: null, + fittedExtent: null, + getOverlayRect: null, + interactionOptions: null, + interactions: [], + interactionStateChanges: [], + mapCreated: false, + mapInstance: null, + mapOptions: null, + mapResized: false, + mapTarget: null, + onInteractionStateChanged: null, + overlayOptions: [], + overlayContainer: null, + overlayContainerStopEvent: null, + overlayPositionChanges: [], + projectedCoordinates: [], + removedControls: [], + removedLayers: [], + removedOverlays: [], + throwOnTileSource: false, + tileLayer: null, + tileLayerOptions: null, + tileSourceChanges: [], + tileSourceOptions: null, + transformedCoordinates: [], + transformedExtents: [], + userProjection: null, + viewCenter: null, + viewCenterSetCount: 0, + viewExtent: [-74100, 40600, -73800, 40900], + viewOptions: null, + viewZoom: null, + viewZoomSetCount: 0, + zoomControlCreatedCount: 0 + }); +}; +const onInteractionStates = (expectedStates, callback) => { + openLayersMock.onInteractionStateChanged = () => { + const actualStates = openLayersMock.interactions.map(interaction => interaction.getActive()); + const stateMatches = actualStates.length === expectedStates.length && actualStates.every((state, index) => state === expectedStates[index]); + if(stateMatches) { + openLayersMock.onInteractionStateChanged = null; + callback(); + } + }; +}; +const moduleConfig = { + beforeEach(assert) { + resizeObserverCallbacks = new Map(); + sinon.stub(resizeObserverSingleton, 'observe').callsFake((element, callback) => { + resizeObserverCallbacks.set(element, callback); + }); + sinon.stub(resizeObserverSingleton, 'unobserve').callsFake(element => { + resizeObserverCallbacks.delete(element); + }); + const setup = () => { + setRegisteredMapEngine(undefined); + window.ol = openLayersMock; + resetOpenLayersMock(); + }; + if(openLayersMock) { + setup(); + return; + } + const done = assert.async(); + $.getScript({ + url: '../../packages/devextreme/testing/helpers/forMap/openLayersMock.js', + scriptAttrs: { + nonce: 'qunit-test' + } + }).done(() => { + openLayersMock = window.ol; + setup(); + done(); + }).fail((_request, _status, error) => { + assert.ok(false, `failed to load OpenLayers mock: ${error}`); + done(); + }); + }, + afterEach() { + resizeObserverSingleton.observe.restore(); + resizeObserverSingleton.unobserve.restore(); + setRegisteredMapEngine(undefined); + window.ol = openLayersMock; + } +}; +const createProvider = () => new OsmProvider({ + option: () => ({ + providerConfig: {} + }) +}, null); +const getOpenLayersKeyboardTarget = () => openLayersMock.mapOptions.keyboardEventTarget; +const getOpenLayersMapTarget = () => openLayersMock.mapOptions.target; +QUnit.module('OSM: map loading', moduleConfig, () => { + QUnit.test('registered OpenLayers engine takes priority over window.ol', function(assert) { + const done = assert.async(); + const engine = createOpenLayersEngine(openLayersMock); + const provider = createProvider(); + setRegisteredMapEngine(engine); + provider._loadImpl().then(() => { + assert.strictEqual(provider._engine, engine, 'registered engine is selected'); + done(); + }); + }); + QUnit.test('map initializes with a registered OpenLayers engine when window.ol is missing', function(assert) { + const done = assert.async(); + setRegisteredMapEngine(createOpenLayersEngine(openLayersMock)); + delete window.ol; + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: ({ + originalMap + }) => { + assert.ok(openLayersMock.mapCreated, 'registered OpenLayers engine creates the map'); + assert.strictEqual(originalMap, openLayersMock.mapInstance, 'originalMap is returned'); + done(); + } + }); + }); + QUnit.test('map initializes with OpenLayers from window.ol', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: ({ + originalMap + }) => { + assert.ok(openLayersMock.mapCreated, 'OpenLayers creates the map'); + assert.strictEqual(originalMap, openLayersMock.mapInstance, 'originalMap is returned'); + done(); + } + }); + }); + QUnit.test('OpenLayers map target is keyboard focusable', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const container = document.createElement('div'); + const engineMap = engine.createMap(container); + assert.strictEqual(container.getAttribute('tabindex'), '0', 'map target is focusable'); + assert.strictEqual(openLayersMock.mapOptions.keyboardEventTarget, container, 'map target receives keyboard events'); + engineMap.dispose(); + assert.strictEqual(container.getAttribute('tabindex'), null, 'added tabindex is removed on dispose'); + }); + QUnit.test('OpenLayers interactions are configured for dxMap', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const engineMap = engine.createMap(document.createElement('div')); + assert.deepEqual(openLayersMock.interactionOptions, { + altShiftDragRotate: false, + onFocusOnly: false, + pinchRotate: false + }, 'pointer interactions work without focus and cannot rotate the map'); + engineMap.dispose(); + }); + QUnit.test('OpenLayers controls are configured for the tiles stage', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const engineMap = engine.createMap(document.createElement('div')); + assert.deepEqual(openLayersMock.controlOptions, { + attribution: true, + rotate: false, + zoom: false + }, 'only the attribution control remains enabled'); + assert.strictEqual(openLayersMock.addedControls.length, 0, 'zoom control is not added by default'); + engineMap.dispose(); + }); + QUnit.test('OpenLayers map target is the keyboard target in Shadow DOM', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const host = document.createElement('div'); + const shadowRoot = host.attachShadow({ + mode: 'open' + }); + const container = document.createElement('div'); + shadowRoot.appendChild(container); + const engineMap = engine.createMap(container); + assert.strictEqual(host.getAttribute('tabindex'), null, 'Shadow DOM host is unchanged'); + assert.strictEqual(container.getAttribute('tabindex'), '0', 'map target is focusable'); + assert.strictEqual(openLayersMock.mapOptions.keyboardEventTarget, container, 'map target receives keyboard events'); + engineMap.dispose(); + assert.strictEqual(container.getAttribute('tabindex'), null, 'added map target tabindex is removed on dispose'); + }); + QUnit.test('owned inert attribute is removed on dispose', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const container = document.createElement('div'); + const engineMap = engine.createMap(container); + engineMap.setDisabled(true); + assert.ok(container.hasAttribute('inert'), 'map target is inert while disabled'); + engineMap.dispose(); + assert.notOk(container.hasAttribute('inert'), 'owned inert attribute is removed'); + }); + QUnit.test('pre-existing inert attribute is preserved', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const container = document.createElement('div'); + container.setAttribute('inert', ''); + const engineMap = engine.createMap(container); + + engineMap.setDisabled(true); + engineMap.setDisabled(false); + assert.ok(container.hasAttribute('inert'), 'pre-existing inert attribute is preserved after enabling'); + + engineMap.dispose(); + assert.ok(container.hasAttribute('inert'), 'pre-existing inert attribute is preserved on dispose'); + }); + QUnit.test('disabled map does not make its Shadow DOM host inert', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const host = document.createElement('div'); + const shadowRoot = host.attachShadow({ + mode: 'open' + }); + const container = document.createElement('div'); + const sibling = document.createElement('button'); + shadowRoot.append(container, sibling); + const engineMap = engine.createMap(container); + engineMap.setDisabled(true); + assert.ok(container.hasAttribute('inert'), 'map container is inert'); + assert.notOk(host.hasAttribute('inert'), 'Shadow DOM host remains interactive'); + assert.notOk(sibling.hasAttribute('inert'), 'sibling remains interactive'); + assert.strictEqual(container.getAttribute('tabindex'), null, 'owned map target tabindex is removed'); + engineMap.setDisabled(false); + assert.notOk(container.hasAttribute('inert'), 'map container becomes interactive'); + assert.strictEqual(container.getAttribute('tabindex'), '0', 'owned map target tabindex is restored'); + engineMap.dispose(); + }); + QUnit.test('engine map can be disposed more than once', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const engineMap = engine.createMap(document.createElement('div')); + engineMap.replaceTileLayer({ + attribution: 'Example attribution', + maxZoom: 19, + url: 'https://tiles.example.com/{z}/{x}/{y}.png' + }); + const tileLayer = openLayersMock.tileLayer; + engineMap.dispose(); + engineMap.dispose(); + assert.strictEqual(openLayersMock.removedLayers.filter(layer => layer === tileLayer).length, 1, 'tile layer is removed once'); + }); + QUnit.test('updateDimensions updates the OpenLayers map size', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const engineMap = engine.createMap(document.createElement('div')); + const result = engineMap.updateDimensions(); + assert.ok(openLayersMock.mapResized, 'OpenLayers map size is updated'); + assert.deepEqual(result, { + needsViewportRefit: false + }, 'viewport refit requirement is named'); + engineMap.dispose(); + }); + QUnit.test('load rejects with E1069 when OpenLayers is missing', function(assert) { + const done = assert.async(); + const provider = createProvider(); + delete window.ol; + provider._loadImpl().then(() => { + assert.ok(false, 'load should reject'); + done(); + }, error => { + assert.strictEqual(error.message, errors.Error('E1069').message, 'E1069 is returned'); + done(); + }); + }); + QUnit.test('map initializes after OpenLayers is loaded and repaint is called', function(assert) { + const done = assert.async(); + delete window.ol; + const map = $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + } + }).dxMap('instance'); + + map._lastAsyncAction.then(() => { + assert.ok(false, 'initialization should reject'); + done(); + }, error => { + assert.strictEqual(error.message, errors.Error('E1069').message, 'E1069 is returned'); + + window.ol = openLayersMock; + map.repaint(); + + map._lastAsyncAction.then(() => { + assert.ok(openLayersMock.mapCreated, 'OpenLayers creates the map after repaint'); + done(); + }, repaintError => { + assert.ok(false, `repaint failed: ${repaintError.message}`); + done(); + }); + }); + }); + QUnit.test('load rejects with E1069 when the OpenLayers ImageTile API is missing', function(assert) { + const done = assert.async(); + const provider = createProvider(); + window.ol = Object.assign({}, openLayersMock, { + source: {} + }); + provider._loadImpl().then(() => { + assert.ok(false, 'load should reject'); + done(); + }, error => { + assert.strictEqual(error.message, errors.Error('E1069').message, 'E1069 is returned'); + done(); + }); + }); + QUnit.test('load rejects with E1069 when the OpenLayers Overlay API is missing', function(assert) { + const done = assert.async(); + const provider = createProvider(); + window.ol = Object.assign({}, openLayersMock); + delete window.ol.Overlay; + provider._loadImpl().then(() => { + assert.ok(false, 'load should reject'); + done(); + }, error => { + assert.strictEqual(error.message, errors.Error('E1069').message, 'E1069 is returned'); + done(); + }); + }); + ['getUserProjection', 'toLonLat', 'transform', 'transformExtent'].forEach(apiName => { + QUnit.test(`load rejects with E1069 when the OpenLayers ${apiName} API is missing`, function(assert) { + const done = assert.async(); + const provider = createProvider(); + const projectionApi = Object.assign({}, openLayersMock.proj); + delete projectionApi[apiName]; + window.ol = Object.assign({}, openLayersMock, { + proj: projectionApi + }); + provider._loadImpl().then(() => { + assert.ok(false, 'load should reject'); + done(); + }, error => { + assert.strictEqual(error.message, errors.Error('E1069').message, 'E1069 is returned'); + done(); + }); + }); + }); + QUnit.test('dispose detaches the OpenLayers map and removes its tile layer', function(assert) { + const done = assert.async(); + const map = $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + const tileLayer = openLayersMock.tileLayer; + map.dispose(); + assert.strictEqual(openLayersMock.mapTarget, undefined, 'map target is cleared'); + assert.ok(openLayersMock.removedLayers.includes(tileLayer), 'tile layer is removed'); + done(); + } + }).dxMap('instance'); + }); +}); +QUnit.module('OSM: tile server', moduleConfig, () => { + QUnit.test('string tileServer creates a tile layer with defaults', function(assert) { + const done = assert.async(); + const log = sinon.stub(errors, 'log'); + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: 'https://tiles.example.com/{z}/{x}/{y}.png' + }, + onReady: () => { + assert.deepEqual(openLayersMock.tileSourceOptions, { + maxZoom: 19, + url: 'https://tiles.example.com/{z}/{x}/{y}.png' + }, 'tile source defaults are applied'); + assert.ok(log.calledWith('W1032'), 'missing attribution warning is logged'); + log.restore(); + done(); + } + }); + }); + QUnit.test('tileServer config passes attribution and maxZoom', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution', + maxZoom: 17 + } + }, + onReady: () => { + assert.deepEqual(openLayersMock.tileSourceOptions, { + attributions: 'Example attribution', + maxZoom: 17, + url: 'https://tiles.example.com/{z}/{x}/{y}.png' + }, 'tile source config is passed'); + done(); + } + }); + }); + QUnit.test('tileServer callback receives the initial map type', function(assert) { + const done = assert.async(); + const tileServer = sinon.spy(() => ({ + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + })); + $('#map').dxMap({ + provider: 'osm', + type: 'satellite', + providerConfig: { + tileServer + }, + onReady: () => { + assert.ok(tileServer.calledOnceWithExactly('satellite'), 'map type is passed'); + done(); + } + }); + }); + QUnit.test('tileServer callback can return a URL string', function(assert) { + const done = assert.async(); + const log = sinon.stub(errors, 'log'); + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: () => 'https://tiles.example.com/{z}/{x}/{y}.png' + }, + onReady: () => { + assert.strictEqual(openLayersMock.tileSourceOptions.url, 'https://tiles.example.com/{z}/{x}/{y}.png', 'URL string is used'); + assert.ok(log.calledWith('W1032'), 'missing attribution warning is logged'); + log.restore(); + done(); + } + }); + }); + QUnit.test('tileServer callback can return undefined on initialization', function(assert) { + const done = assert.async(); + const log = sinon.stub(errors, 'log'); + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: () => undefined + }, + onReady: () => { + assert.ok(log.calledWith('W1030'), 'W1030 is logged'); + assert.strictEqual(openLayersMock.addedTileLayers.length, 0, 'tile layer is not created'); + log.restore(); + done(); + } + }); + }); + QUnit.test('subdomains string is expanded for OpenLayers', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://{s}.tiles.example.com/{z}/{x}/{y}.png?mirror={s}', + attribution: 'Example attribution', + subdomains: 'ab' + } + }, + onReady: () => { + assert.deepEqual(openLayersMock.tileSourceOptions.url, ['https://a.tiles.example.com/{z}/{x}/{y}.png?mirror=a', 'https://b.tiles.example.com/{z}/{x}/{y}.png?mirror=b'], 'all subdomain placeholders are expanded'); + done(); + } + }); + }); + QUnit.test('default subdomains are expanded for OpenLayers', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://{s}.tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + assert.deepEqual(openLayersMock.tileSourceOptions.url, ['https://a.tiles.example.com/{z}/{x}/{y}.png', 'https://b.tiles.example.com/{z}/{x}/{y}.png', 'https://c.tiles.example.com/{z}/{x}/{y}.png'], 'default subdomains are expanded'); + done(); + } + }); + }); + QUnit.test('empty subdomains use the default value', function(assert) { + ['', []].forEach(value => { + const provider = new OsmProvider({ + option: () => ({ + providerConfig: { + tileServer: { + url: 'https://{s}.tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution', + subdomains: value + } + } + }) + }, null); + const options = provider._resolveTileLayerOptions('roadmap'); + const valueType = Array.isArray(value) ? 'array' : 'string'; + + assert.strictEqual(options.subdomains, 'abc', `empty ${valueType} uses default subdomains`); + }); + }); + QUnit.test('subdomains array is expanded for OpenLayers', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://{s}.tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution', + subdomains: ['first', 'second'] + } + }, + onReady: () => { + assert.deepEqual(openLayersMock.tileSourceOptions.url, ['https://first.tiles.example.com/{z}/{x}/{y}.png', 'https://second.tiles.example.com/{z}/{x}/{y}.png'], 'subdomains are expanded'); + done(); + } + }); + }); + QUnit.test('missing tileServer logs W1030 and keeps the map initialized', function(assert) { + const done = assert.async(); + const log = sinon.stub(errors, 'log'); + $('#map').dxMap({ + provider: 'osm', + onReady: () => { + assert.ok(log.calledWith('W1030'), 'W1030 is logged'); + assert.strictEqual(openLayersMock.addedTileLayers.length, 0, 'tile layer is not created'); + log.restore(); + done(); + } + }); + }); + QUnit.test('tileServer with an empty URL logs W1030 and keeps the map initialized', function(assert) { + const done = assert.async(); + const log = sinon.stub(errors, 'log'); + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: '' + } + }, + onReady: () => { + assert.ok(log.calledWith('W1030'), 'W1030 is logged'); + assert.strictEqual(openLayersMock.addedTileLayers.length, 0, 'tile layer is not created'); + log.restore(); + done(); + } + }); + }); + QUnit.test('missing attribution logs W1032 and keeps the tile layer', function(assert) { + const done = assert.async(); + const log = sinon.stub(errors, 'log'); + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: 'https://tiles.example.com/{z}/{x}/{y}.png' + }, + onReady: () => { + assert.ok(log.calledWith('W1032'), 'W1032 is logged'); + assert.strictEqual(openLayersMock.addedTileLayers.length, 1, 'tile layer is created'); + log.restore(); + done(); + } + }); + }); + QUnit.test('changing type replaces the tile source returned by the callback', function(assert) { + const done = assert.async(); + const initialized = $.Deferred(); + const map = $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: type => ({ + url: `https://${type}.example.com/{z}/{x}/{y}.png`, + attribution: 'Example attribution' + }) + }, + onReady: () => initialized.resolve() + }).dxMap('instance'); + initialized.done(() => { + map.option('onUpdated', () => { + assert.strictEqual(openLayersMock.tileSourceOptions.url, 'https://satellite.example.com/{z}/{x}/{y}.png', 'tile source is replaced'); + assert.strictEqual(openLayersMock.tileSourceChanges.length, 1, 'existing layer receives the new source'); + done(); + }); + map.option('type', 'satellite'); + }); + }); + QUnit.test('changing type preserves a fixed tile source', function(assert) { + const done = assert.async(); + const initialized = $.Deferred(); + const map = $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => initialized.resolve() + }).dxMap('instance'); + initialized.done(() => { + const originalSource = openLayersMock.tileLayer.source; + map.option('onUpdated', () => { + assert.strictEqual(openLayersMock.tileLayer.source, originalSource, 'fixed source is preserved'); + assert.strictEqual(openLayersMock.tileSourceChanges.length, 0, 'tile layer does not receive another source'); + done(); + }); + map.option('type', 'satellite'); + }); + }); + QUnit.test('missing config for a new type preserves the current tile source', function(assert) { + const done = assert.async(); + const initialized = $.Deferred(); + const log = sinon.stub(errors, 'log'); + const map = $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: type => type === 'roadmap' ? { + url: 'https://roadmap.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } : undefined + }, + onReady: () => initialized.resolve() + }).dxMap('instance'); + initialized.done(() => { + const originalSource = openLayersMock.tileLayer.source; + map.option('onUpdated', () => { + assert.strictEqual(openLayersMock.tileLayer.source, originalSource, 'current source is preserved'); + assert.ok(log.calledWith('W1030'), 'W1030 is logged'); + log.restore(); + done(); + }); + map.option('type', 'satellite'); + }); + }); + QUnit.test('tile source creation failure preserves the current source', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const engineMap = engine.createMap(document.createElement('div')); + const firstOptions = { + attribution: 'Example attribution', + maxZoom: 19, + url: 'https://roadmap.example.com/{z}/{x}/{y}.png' + }; + engineMap.replaceTileLayer(firstOptions); + const originalSource = openLayersMock.tileLayer.source; + openLayersMock.throwOnTileSource = true; + assert.throws(() => engineMap.replaceTileLayer({ + ...firstOptions, + url: 'https://satellite.example.com/{z}/{x}/{y}.png' + }), /Tile source creation failed/, 'source error is propagated'); + assert.strictEqual(openLayersMock.tileLayer.source, originalSource, 'current source is preserved'); + engineMap.dispose(); + }); + QUnit.test('changing providerConfig recreates the map with the new tile server', function(assert) { + const done = assert.async(); + const initialized = $.Deferred(); + let originalMap; + const map = $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://first.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: e => { + originalMap = e.originalMap; + initialized.resolve(); + } + }).dxMap('instance'); + initialized.done(() => { + map.option('onReady', e => { + assert.notStrictEqual(e.originalMap, originalMap, 'map is recreated'); + assert.strictEqual(openLayersMock.tileSourceOptions.url, 'https://second.example.com/{z}/{x}/{y}.png', 'new tile server is used'); + done(); + }); + map.option('providerConfig', { + tileServer: { + url: 'https://second.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }); + }); + }); +}); +QUnit.module('OSM: initial view', moduleConfig, () => { + QUnit.test('default center, zoom, and map type are applied', function(assert) { + const done = assert.async(); + const tileServer = sinon.spy(() => ({ + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + })); + $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer + }, + onReady: () => { + assert.deepEqual(openLayersMock.viewCenter, [0, 0], 'default center is applied'); + assert.strictEqual(openLayersMock.viewZoom, 1, 'default zoom is applied'); + assert.ok(tileServer.calledOnceWithExactly('roadmap'), 'default map type is applied'); + done(); + } + }); + }); + QUnit.test('center and fractional zoom are applied to the OpenLayers view', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + center: { + lat: 40.74, + lng: -73.98 + }, + zoom: 12.5, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + const lastProjectedCoordinate = openLayersMock.projectedCoordinates[openLayersMock.projectedCoordinates.length - 1]; + assert.ok(openLayersMock.projectedCoordinates.length > 0, 'projection API is called'); + assert.deepEqual(lastProjectedCoordinate, [-73.98, 40.74], 'longitude and latitude are passed to the projection API'); + assert.deepEqual(openLayersMock.viewCenter, [-73980, 40740], 'projected center is applied'); + assert.strictEqual(openLayersMock.viewZoom, 12.5, 'fractional zoom is applied'); + assert.strictEqual(openLayersMock.viewCenterSetCount, 0, 'initial center is not applied twice'); + assert.strictEqual(openLayersMock.viewZoomSetCount, 0, 'initial zoom is not applied twice'); + done(); + } + }); + }); + QUnit.test('changing center preserves the current map zoom', function(assert) { + const done = assert.async(); + const initialized = $.Deferred(); + const map = $('#map').dxMap({ + provider: 'osm', + center: { + lat: 40.74, + lng: -73.98 + }, + zoom: 12, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => initialized.resolve() + }).dxMap('instance'); + initialized.done(() => { + openLayersMock.mapInstance.getView().setZoom(14); + map.option('onUpdated', () => { + assert.strictEqual(openLayersMock.viewZoom, 14, 'current zoom is preserved'); + done(); + }); + map.option('center', { + lat: 40.75, + lng: -73.97 + }); + }); + }); + QUnit.test('changing zoom applies a fractional value and preserves center', function(assert) { + const done = assert.async(); + const initialized = $.Deferred(); + const map = $('#map').dxMap({ + provider: 'osm', + center: { + lat: 40.74, + lng: -73.98 + }, + zoom: 12, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => initialized.resolve() + }).dxMap('instance'); + initialized.done(() => { + const center = [...openLayersMock.viewCenter]; + const centerSetCount = openLayersMock.viewCenterSetCount; + map.option('onUpdated', () => { + assert.strictEqual(openLayersMock.viewZoom, 12.5, 'fractional zoom is applied'); + assert.deepEqual(openLayersMock.viewCenter, center, 'center value is preserved'); + assert.strictEqual(openLayersMock.viewCenterSetCount, centerSetCount, 'center is not reapplied'); + done(); + }); + map.option('zoom', 12.5); + }); + }); +}); +QUnit.module('OSM: location calculation', moduleConfig, () => { + const tileServer = { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + }; + QUnit.test('calculateLocation resolves a string center', function(assert) { + const done = assert.async(); + const calculateLocation = sinon.spy(query => Promise.resolve({ + lat: 40.74, + lng: -73.98 + })); + $('#map').dxMap({ + provider: 'osm', + center: 'New York', + providerConfig: { + tileServer, + calculateLocation + }, + onReady: () => { + assert.ok(calculateLocation.calledOnceWithExactly('New York'), 'raw query is passed to the callback'); + assert.deepEqual(openLayersMock.viewCenter, [-73980, 40740], 'calculated center is applied'); + done(); + } + }); + }); + QUnit.test('numeric string center does not call calculateLocation', function(assert) { + const done = assert.async(); + const calculateLocation = sinon.spy(() => Promise.resolve({ + lat: 0, + lng: 0 + })); + $('#map').dxMap({ + provider: 'osm', + center: '40.74, -73.98', + providerConfig: { + tileServer, + calculateLocation + }, + onReady: () => { + assert.ok(calculateLocation.notCalled, 'coordinate string is resolved locally'); + assert.deepEqual(openLayersMock.viewCenter, [-73980, 40740], 'coordinate string is applied'); + done(); + } + }); + }); + QUnit.test('missing calculateLocation logs W1031 once and uses the default location', function(assert) { + const done = assert.async(); + const log = sinon.stub(errors, 'log'); + const provider = createProvider(); + provider._resolveLocation('Unknown place').then(location => { + assert.deepEqual(location, { + lat: 0, + lng: 0 + }, 'default location is returned'); + return provider._resolveLocation('Another place'); + }).then(location => { + assert.deepEqual(location, { + lat: 0, + lng: 0 + }, 'default location is returned for subsequent queries'); + assert.ok(log.calledOnceWithExactly('W1031'), 'W1031 is logged'); + log.restore(); + done(); + }); + }); + QUnit.test('successful calculated locations are cached', function(assert) { + const done = assert.async(); + const calculateLocation = sinon.spy(() => Promise.resolve({ + lat: 40.74, + lng: -73.98 + })); + const provider = new OsmProvider({ + option: () => ({ + providerConfig: { + calculateLocation + } + }) + }, null); + provider._resolveLocation('New York').then(() => provider._resolveLocation('New York')).then(location => { + assert.deepEqual(location, { + lat: 40.74, + lng: -73.98 + }, 'cached location is returned'); + assert.ok(calculateLocation.calledOnce, 'callback is called once'); + done(); + }); + }); + QUnit.test('concurrent calculations for the same location share one request', function(assert) { + const done = assert.async(); + let resolveLocation; + const locationPromise = new Promise(resolve => { + resolveLocation = resolve; + }); + const calculateLocation = sinon.spy(() => locationPromise); + const provider = new OsmProvider({ + option: () => ({ + providerConfig: { + calculateLocation + } + }) + }, null); + const first = provider._resolveLocation('New York'); + const second = provider._resolveLocation('New York'); + resolveLocation({ + lat: 40.74, + lng: -73.98 + }); + Promise.all([first, second]).then(locations => { + assert.deepEqual(locations, [{ + lat: 40.74, + lng: -73.98 + }, { + lat: 40.74, + lng: -73.98 + }], 'both callers receive the calculated location'); + assert.ok(calculateLocation.calledOnce, 'callback is called once'); + done(); + }); + }); + QUnit.test('an invalid callback result is not cached', function(assert) { + const done = assert.async(); + const log = sinon.stub(errors, 'log'); + const calculateLocation = sinon.stub(); + calculateLocation.onFirstCall().returns(Promise.resolve(undefined)); + calculateLocation.onSecondCall().returns(Promise.resolve({ + lat: 40.74, + lng: -73.98 + })); + const provider = new OsmProvider({ + option: () => ({ + providerConfig: { + calculateLocation + } + }) + }, null); + provider._resolveLocation('New York').then(firstLocation => { + assert.deepEqual(firstLocation, { + lat: 0, + lng: 0 + }, 'invalid result uses the default location'); + return provider._resolveLocation('New York'); + }).then(secondLocation => { + assert.deepEqual(secondLocation, { + lat: 40.74, + lng: -73.98 + }, 'callback is retried'); + assert.ok(calculateLocation.calledTwice, 'invalid result is not cached'); + assert.ok(log.calledOnceWithExactly('W1006', 'calculateLocation returned an invalid result.'), 'invalid result is reported'); + log.restore(); + done(); + }); + }); + QUnit.test('a rejected callback result is not cached', function(assert) { + const done = assert.async(); + const log = sinon.stub(errors, 'log'); + const rejection = new Error('service unavailable'); + const calculateLocation = sinon.stub(); + calculateLocation.onFirstCall().returns(Promise.reject(rejection)); + calculateLocation.onSecondCall().returns(Promise.resolve({ + lat: 40.74, + lng: -73.98 + })); + const provider = new OsmProvider({ + option: () => ({ + providerConfig: { + calculateLocation + } + }) + }, null); + provider._resolveLocation('New York').then(firstLocation => { + assert.deepEqual(firstLocation, { + lat: 0, + lng: 0 + }, 'rejection uses the default location'); + return provider._resolveLocation('New York'); + }).then(secondLocation => { + assert.deepEqual(secondLocation, { + lat: 40.74, + lng: -73.98 + }, 'callback is retried'); + assert.ok(calculateLocation.calledTwice, 'rejected result is not cached'); + assert.ok(log.calledOnceWithExactly('W1006', rejection), 'service rejection is reported'); + log.restore(); + done(); + }); + }); + QUnit.test('a pending center calculation does not update a cleaned provider', function(assert) { + const done = assert.async(); + let resolveLocation; + const locationPromise = new Promise(resolve => { + resolveLocation = resolve; + }); + const setOptionSilent = sinon.spy(); + const setView = sinon.spy(); + const provider = new OsmProvider({ + option: () => ({ + center: 'New York', + providerConfig: { + calculateLocation: () => locationPromise + } + }), + setOptionSilent + }, null); + provider._engineMap = { + dispose: sinon.spy(), + setView + }; + provider._markers = []; + provider._routes = []; + const update = provider.updateCenter(); + provider.clean(); + resolveLocation({ + lat: 40.74, + lng: -73.98 + }); + update.then(() => { + assert.ok(setView.notCalled, 'the disposed engine map is not updated'); + assert.ok(setOptionSilent.notCalled, 'the stale center is not written to the component'); + done(); + }); + }); + QUnit.test('an older center calculation does not overwrite a newer center', function(assert) { + const done = assert.async(); + let resolveFirst; + let resolveSecond; + const firstLocation = new Promise(resolve => { + resolveFirst = resolve; + }); + const secondLocation = new Promise(resolve => { + resolveSecond = resolve; + }); + const options = { + center: 'First', + providerConfig: { + calculateLocation: query => query === 'First' ? firstLocation : secondLocation + } + }; + const setView = sinon.spy(); + const setOptionSilent = sinon.spy((name, value) => { + options[name] = value; + }); + const provider = new OsmProvider({ + option: () => options, + setOptionSilent + }, null); + provider._engineMap = { + setView + }; + const firstUpdate = provider.updateCenter(); + options.center = 'Second'; + resolveFirst({ + lat: 1, + lng: 2 + }); + firstUpdate.then(() => { + assert.ok(setView.notCalled, 'superseded center is not applied'); + assert.ok(setOptionSilent.notCalled, 'superseded center is not written to the component'); + const secondUpdate = provider.updateCenter(); + resolveSecond({ + lat: 3, + lng: 4 + }); + return secondUpdate; + }).then(() => { + assert.ok(setView.calledOnceWithExactly({ + center: { + lat: 3, + lng: 4 + } + }), 'latest center is applied'); + assert.ok(setOptionSilent.calledOnceWithExactly('center', { + lat: 3, + lng: 4 + }), 'latest center is written to the component'); + done(); + }); + }); + QUnit.test('older calculated bounds are not applied after the option changes', function(assert) { + const done = assert.async(); + let resolveFirst; + const firstLocation = new Promise(resolve => { + resolveFirst = resolve; + }); + const firstBounds = { + northEast: 'First', + southWest: [40, -74] + }; + const options = { + bounds: firstBounds, + providerConfig: { + calculateLocation: () => firstLocation + } + }; + const fitBounds = sinon.spy(); + const provider = new OsmProvider({ + option: () => options + }, null); + provider._engineMap = { + fitBounds + }; + const firstUpdate = provider.updateBounds(); + firstBounds.northEast = [41, -73]; + resolveFirst({ + lat: 42, + lng: -72 + }); + firstUpdate.then(() => { + assert.ok(fitBounds.notCalled, 'superseded bounds are not applied'); + return provider.updateBounds(); + }).then(() => { + assert.ok(fitBounds.calledOnceWithExactly({ + northEast: { + lat: 41, + lng: -73 + }, + southWest: { + lat: 40, + lng: -74 + } + }), 'latest bounds are applied'); + done(); + }); + }); + QUnit.test('a pending marker calculation does not add a marker after cleanup', function(assert) { + const done = assert.async(); + let resolveLocation; + const locationPromise = new Promise(resolve => { + resolveLocation = resolve; + }); + const addMarker = sinon.spy(); + const provider = new OsmProvider({ + option: () => ({ + autoAdjust: false, + providerConfig: { + calculateLocation: () => locationPromise + } + }) + }, null); + provider._engineMap = { + addMarker, + dispose: sinon.spy() + }; + provider._markers = []; + provider._routes = []; + const add = provider.addMarkers([{ + location: 'New York' + }]); + provider.clean(); + resolveLocation({ + lat: 40.74, + lng: -73.98 + }); + add.then(() => { + assert.ok(false, 'the stale marker operation should reject'); + done(); + }, error => { + assert.ok(addMarker.notCalled, 'no marker is added to the disposed engine map'); + assert.ok(error instanceof Error, 'the stale operation rejects with an Error'); + assert.strictEqual(error.message, 'The map was disposed or replaced during marker creation.', 'the rejection explains why marker creation was cancelled'); + done(); + }); + }); +}); +QUnit.module('OSM: markers', moduleConfig, () => { + const tileServer = { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + }; + QUnit.test('addMarker completes after disposal during location calculation', async function(assert) { + let completeLocation; + let locationStarted; + const started = new Promise(resolve => { locationStarted = resolve; }); + const calculateLocation = () => new Promise(resolve => { + completeLocation = resolve; + locationStarted(); + }); + const onMarkerAdded = sinon.spy(); + const map = await new Promise(resolve => { + $('#map').dxMap({ + provider: 'osm', + autoAdjust: false, + providerConfig: { tileServer, calculateLocation }, + onMarkerAdded, + onReady: ({ component }) => resolve(component) + }); + }); + const pending = map.addMarker({ location: 'Start' }); + await started; + map.dispose(); + completeLocation({ lat: 40.7, lng: -74 }); + + assert.strictEqual(await pending, undefined, 'the public operation completes without an instance'); + assert.strictEqual(openLayersMock.addedOverlays.length, 0, 'no marker is added to the disposed map'); + assert.ok(onMarkerAdded.notCalled, 'no added event is fired'); + }); + + QUnit.test('initial marker uses an OpenLayers overlay', function(assert) { + const done = assert.async(); + const marker = { + location: { + lat: 40.74, + lng: -73.98 + } + }; + let markerAddedEvent; + $('#map').dxMap({ + provider: 'osm', + autoAdjust: false, + markers: [marker], + providerConfig: { + tileServer + }, + onMarkerAdded: e => { + markerAddedEvent = e; + }, + onReady: () => { + const overlay = openLayersMock.addedOverlays[0]; + const element = overlay.options.element; + assert.strictEqual(openLayersMock.addedOverlays.length, 1, 'one overlay is added'); + assert.deepEqual(overlay.options.position, [-73980, 40740], 'marker location is projected'); + assert.strictEqual(overlay.options.positioning, 'bottom-center', 'marker tip is anchored to its location'); + assert.strictEqual(overlay.options.stopEvent, false, 'map interactions remain available over the marker'); + assert.ok(element.classList.contains('dx-map-marker-default'), 'default marker is rendered'); + assert.strictEqual(getComputedStyle(element).width, '44px', 'default marker keeps a sufficiently large hit area'); + assert.strictEqual(getComputedStyle(element).height, '44px', 'default marker keeps a sufficiently large hit area'); + const markerSvg = element.querySelector('.dx-map-marker-default-icon'); + assert.ok(markerSvg, 'default marker SVG is rendered'); + assert.strictEqual(markerSvg.getAttribute('viewBox'), '5 2 14 20', 'existing pinmap geometry is fitted to the marker'); + assert.strictEqual(markerSvg.getAttribute('width'), '24.5', 'default marker width matches the standard marker size'); + assert.strictEqual(markerSvg.getAttribute('height'), '36.5', 'default marker height matches the standard marker size'); + const markerBody = element.querySelector('.dx-map-marker-default-body'); + const markerCenter = element.querySelector('.dx-map-marker-default-center'); + assert.ok(markerBody, 'marker body is rendered'); + assert.notOk(markerBody.hasAttribute('fill'), 'marker body color is defined by the theme stylesheet'); + assert.notOk(markerBody.hasAttribute('stroke'), 'marker outline color is defined by the theme stylesheet'); + assert.strictEqual(markerBody.getAttribute('stroke-width'), '0.5', 'marker outline does not obscure its body'); + assert.ok(markerCenter, 'marker center is rendered'); + assert.notOk(markerCenter.hasAttribute('fill'), 'marker center color is defined by the theme stylesheet'); + assert.strictEqual(markerAddedEvent.options, marker, 'marker options are passed to onMarkerAdded'); + assert.strictEqual(markerAddedEvent.originalMarker, overlay, 'OpenLayers overlay is exposed as originalMarker'); + done(); + } + }); + }); + QUnit.test('marker position synchronization follows a replaced OpenLayers view', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const engineMap = engine.createMap(document.createElement('div')); + const map = openLayersMock.mapInstance; + const initialView = map.getView(); + engineMap.addMarker({ + location: { + lat: 10, + lng: -179 + } + }); + const replacementView = new openLayersMock.View({ + center: [179000, 0], + projection: 'EPSG:3857', + zoom: 1 + }); + + map.setView(replacementView); + + assert.deepEqual(openLayersMock.addedOverlays[0].options.position, [181000, 10000], 'marker moves into the replacement view world'); + assert.strictEqual(initialView.eventHandlers['change:center'].length, 0, 'old view listener is removed'); + assert.strictEqual(replacementView.eventHandlers['change:center'].length, 1, 'replacement view listener is added'); + + const synchronizedPositionCount = openLayersMock.overlayPositionChanges.length; + replacementView.setCenter([179000, 0]); + assert.strictEqual(openLayersMock.overlayPositionChanges.length, synchronizedPositionCount, 'unchanged marker position is not written again'); + + const positionChangeCount = openLayersMock.overlayPositionChanges.length; + initialView.setCenter([-179000, 0]); + assert.strictEqual(openLayersMock.overlayPositionChanges.length, positionChangeCount, 'old view no longer updates marker positions'); + + replacementView.setCenter([-179000, 0]); + assert.deepEqual(openLayersMock.addedOverlays[0].options.position, [-179000, 10000], 'replacement view updates marker positions'); + + engineMap.dispose(); + + assert.strictEqual(map.eventHandlers['change:view'].length, 0, 'view replacement listener is removed on dispose'); + assert.strictEqual(replacementView.eventHandlers['change:center'].length, 0, 'replacement view listener is removed on dispose'); + }); + QUnit.test('marker iconSrc takes priority over markerIconSrc', function(assert) { + const done = assert.async(); + const defaultLocale = localization.locale(); + const markerAriaLabel = 'Localized map marker'; + localization.loadMessages({ + 'test': { + 'dxMap-markerAriaLabel': markerAriaLabel + } + }); + localization.locale('test'); + $('#map').dxMap({ + provider: 'osm', + autoAdjust: false, + markerIconSrc: 'global-marker.png', + markers: [{ + location: [40.74, -73.98] + }, { + location: [40.75, -73.97], + iconSrc: 'local-marker.png', + onClick: () => {} + }], + providerConfig: { + tileServer + }, + onReady: () => { + try { + const globalIcon = openLayersMock.addedOverlays[0].options.element; + const localIcon = openLayersMock.addedOverlays[1].options.element; + assert.strictEqual(globalIcon.getAttribute('src'), 'global-marker.png', 'global marker icon is applied'); + assert.strictEqual(globalIcon.getAttribute('alt'), markerAriaLabel, 'non-interactive custom marker has a localized alternative'); + assert.strictEqual(localIcon.getAttribute('src'), 'local-marker.png', 'marker icon overrides the global icon'); + assert.notOk(localIcon.hasAttribute('width'), 'custom marker keeps its natural width'); + assert.notOk(localIcon.hasAttribute('height'), 'custom marker keeps its natural height'); + assert.strictEqual(localIcon.getAttribute('alt'), markerAriaLabel, 'interactive custom marker has a localized alternative'); + assert.notOk(localIcon.hasAttribute('aria-label'), 'image marker does not duplicate its accessible name'); + assert.strictEqual(localIcon.draggable, false, 'custom marker does not start native image dragging'); + } finally { + localization.locale(defaultLocale); + done(); + } + } + }); + }); + QUnit.test('HTML marker and offset are passed to OpenLayers', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + autoAdjust: false, + markers: [{ + location: [40.74, -73.98], + html: 'A', + htmlOffset: { + left: 5, + top: 7 + } + }], + providerConfig: { + tileServer + }, + onReady: () => { + const options = openLayersMock.addedOverlays[0].options; + assert.strictEqual(options.element.firstElementChild.className, 'custom-marker', 'custom HTML is rendered'); + assert.deepEqual(options.offset, [5, 7], 'HTML offset is applied'); + assert.strictEqual(options.positioning, 'top-left', 'HTML offset starts at the marker location'); + done(); + } + }); + }); + QUnit.test('marker click calls onClick with the resolved location', function(assert) { + const done = assert.async(); + const onClick = sinon.spy(); + const onMapClick = sinon.spy(); + $('#map').dxMap({ + provider: 'osm', + autoAdjust: false, + markers: [{ + location: [40.74, -73.98], + onClick + }], + providerConfig: { + tileServer + }, + onClick: onMapClick, + onReady: () => { + const overlayOptions = openLayersMock.addedOverlays[0].options; + const element = overlayOptions.element; + const parentClick = sinon.spy(); + element.parentElement.addEventListener('click', parentClick); + assert.strictEqual(element.getAttribute('role'), 'button', 'clickable marker has button semantics'); + assert.strictEqual(element.getAttribute('aria-label'), 'Map marker', 'clickable marker has an accessible name'); + assert.strictEqual(element.getAttribute('tabindex'), '0', 'clickable marker is keyboard-focusable'); + assert.strictEqual(overlayOptions.stopEvent, false, 'map wheel and drag interactions remain available over a clickable marker'); + element.click(); + assert.ok(onClick.calledOnce, 'marker click action is fired'); + assert.strictEqual(onClick.firstCall.args[0].tooltip, undefined, 'a marker without a tooltip has no tooltip instance'); + assert.ok(parentClick.notCalled, 'marker click does not bubble to the map container'); + assert.deepEqual(onClick.firstCall.args[0].location, { + lat: 40.74, + lng: -73.98 + }, 'resolved location is passed'); + const markerPointerEvent = new PointerEvent('pointerup', { + bubbles: true + }); + element.dispatchEvent(markerPointerEvent); + openLayersMock.mapInstance.trigger('click', { + coordinate: [-73980, 40740], + originalEvent: markerPointerEvent + }); + assert.ok(onMapClick.notCalled, 'marker pointer event does not fire the map click action'); + element.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true + })); + assert.ok(onClick.calledTwice, 'marker can be activated from the keyboard'); + done(); + } + }); + }); + QUnit.test('clickable plain HTML marker is keyboard-accessible', function(assert) { + const done = assert.async(); + const onClick = sinon.spy(); + $('#map').dxMap({ + provider: 'osm', + autoAdjust: false, + markers: [{ + location: [40.74, -73.98], + html: 'Custom marker', + onClick + }], + providerConfig: { + tileServer + }, + onReady: () => { + const element = openLayersMock.addedOverlays[0].options.element; + assert.strictEqual(element.getAttribute('role'), 'button', 'wrapper has button semantics'); + assert.strictEqual(element.getAttribute('tabindex'), '0', 'wrapper is keyboard-focusable'); + element.dispatchEvent(new KeyboardEvent('keyup', { key: ' ', bubbles: true })); + assert.ok(onClick.notCalled, 'Space release without a preceding keydown does not activate the marker'); + element.dispatchEvent(new KeyboardEvent('keydown', { + key: ' ', + bubbles: true, + repeat: true + })); + assert.ok(onClick.notCalled, 'repeated Space keydown does not activate the marker'); + element.dispatchEvent(new KeyboardEvent('keyup', { + key: ' ', + bubbles: true + })); + assert.ok(onClick.calledOnce, 'HTML marker can be activated from the keyboard'); + element.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); + element.dispatchEvent(new FocusEvent('blur')); + element.dispatchEvent(new KeyboardEvent('keyup', { key: ' ', bubbles: true })); + assert.ok(onClick.calledOnce, 'losing focus cancels the pending Space activation'); + done(); + } + }); + }); + QUnit.test('interactive HTML marker content does not forward keyboard commands to the map', function(assert) { + const done = assert.async(); + const onClick = sinon.spy(); + $('#map').dxMap({ + provider: 'osm', + autoAdjust: false, + markers: [{ + location: [40.74, -73.98], + html: 'Custom marker', + onClick + }], + providerConfig: { + tileServer + }, + onReady: () => { + const keyboardTarget = getOpenLayersKeyboardTarget(); + const mapKeydown = sinon.spy(); + const button = openLayersMock.addedOverlays[0].options.element.querySelector('button'); + keyboardTarget.addEventListener('keydown', mapKeydown); + button.dispatchEvent(new KeyboardEvent('keydown', { + key: 'ArrowRight', + bubbles: true + })); + assert.ok(mapKeydown.notCalled, 'the map does not receive the marker control keydown'); + button.click(); + assert.ok(onClick.calledOnce, 'the marker control keeps its click behavior'); + keyboardTarget.removeEventListener('keydown', mapKeydown); + done(); + } + }); + }); + QUnit.test('addMarker and removeMarker manage the OpenLayers overlay and events', function(assert) { + const done = assert.async(); + const marker = { + location: [40.74, -73.98] + }; + const onMarkerAdded = sinon.spy(); + const onMarkerRemoved = sinon.spy(); + const map = $('#map').dxMap({ + provider: 'osm', + autoAdjust: false, + providerConfig: { + tileServer + }, + onMarkerAdded, + onMarkerRemoved, + onReady: () => { + map.addMarker(marker).done(originalMarker => { + const markerElement = openLayersMock.addedOverlays[0].options.element; + assert.strictEqual(originalMarker, openLayersMock.addedOverlays[0], 'addMarker returns the overlay'); + assert.ok(onMarkerAdded.calledOnce, 'onMarkerAdded is fired'); + assert.ok(resizeObserverCallbacks.has(markerElement), 'marker size is observed'); + map.removeMarker(marker).done(() => { + assert.strictEqual(openLayersMock.removedOverlays[0], originalMarker, 'overlay is removed'); + assert.ok(onMarkerRemoved.calledOnce, 'onMarkerRemoved is fired'); + assert.notOk(resizeObserverCallbacks.has(markerElement), 'marker size observation is removed'); + done(); + }); + }); + } + }).dxMap('instance'); + }); + QUnit.test('autoAdjust fits the view to markers', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + markers: [{ + location: [40.7, -74] + }, { + location: [40.8, -73.9] + }], + providerConfig: { + tileServer + }, + onReady: () => { + assert.deepEqual(openLayersMock.fittedExtent, [-74000, 40700, -73900, 40800], 'marker bounds are fitted'); + assert.deepEqual(openLayersMock.fitOptions.padding, [44, 22, 0, 22], 'marker size is included in fit padding'); + done(); + } + }); + }); + QUnit.test('autoAdjust measures HTML marker padding', function(assert) { + const done = assert.async(); + openLayersMock.getOverlayRect = () => ({ + height: 60, + width: 80 + }); + $('#map').dxMap({ + provider: 'osm', + markers: [{ + location: [40.7, -74], + html: 'A', + htmlOffset: { + left: 5, + top: 7 + } + }], + providerConfig: { + tileServer + }, + onReady: () => { + assert.deepEqual(openLayersMock.fitOptions.padding, [0, 85, 67, 0], 'HTML size and offset are included in fit padding'); + done(); + } + }); + }); + QUnit.test('autoAdjust refits the view after a custom marker image loads', function(assert) { + const done = assert.async(); + let imageLoaded = false; + openLayersMock.getOverlayRect = () => imageLoaded ? { + height: 60, + width: 80 + } : { + height: 0, + width: 0 + }; + $('#map').dxMap({ + provider: 'osm', + markers: [{ + location: [40.7, -74], + iconSrc: 'custom-marker.png' + }], + providerConfig: { + tileServer + }, + onReady: () => { + assert.deepEqual(openLayersMock.fitOptions.padding, [41, 13, 0, 13], 'fallback size is used while the image loads'); + imageLoaded = true; + triggerResize(openLayersMock.addedOverlays[0].options.element); + assert.deepEqual(openLayersMock.fitOptions.padding, [60, 40, 0, 40], 'loaded image size is included in fit padding'); + done(); + } + }); + }); + [true, false].forEach(isHtml => { + const markerType = isHtml ? 'HTML' : 'image'; + QUnit.test(`autoAdjust refits after successive ${markerType} marker size changes`, function(assert) { + const done = assert.async(); + let markerSize = { height: 0, width: 0 }; + openLayersMock.getOverlayRect = () => markerSize; + $('#map').dxMap({ + provider: 'osm', + markers: [{ + location: [40.7, -74], + ...(isHtml ? { html: '' } : { iconSrc: 'custom-marker.png' }) + }], + providerConfig: { + tileServer + }, + onReady: () => { + const markerElement = openLayersMock.addedOverlays[0].options.element; + const initialFitCallCount = openLayersMock.fitCallCount; + assert.deepEqual(openLayersMock.fitOptions.padding, + isHtml ? [0, 25, 41, 0] : [41, 13, 0, 13], 'fallback size is used before layout'); + + markerSize = { height: 60, width: 80 }; + triggerResize(markerElement); + assert.strictEqual(openLayersMock.fitCallCount, initialFitCallCount + 1, 'first size change refits the view'); + assert.deepEqual(openLayersMock.fitOptions.padding, + isHtml ? [0, 80, 60, 0] : [60, 40, 0, 40], 'first measured size is included in padding'); + + triggerResize(markerElement); + assert.strictEqual(openLayersMock.fitCallCount, initialFitCallCount + 1, 'unchanged size does not refit'); + + markerSize = { height: 90, width: 120 }; + triggerResize(markerElement); + assert.strictEqual(openLayersMock.fitCallCount, initialFitCallCount + 2, 'second size change refits the view'); + assert.deepEqual(openLayersMock.fitOptions.padding, + isHtml ? [0, 120, 90, 0] : [90, 60, 0, 60], 'padding follows the second size change'); + + triggerResize(markerElement); + assert.strictEqual(openLayersMock.fitCallCount, initialFitCallCount + 2, 'repeated notification still does not refit'); + done(); + } + }); + }); + }); + [ + { type: 'pointerdown' }, + { type: 'pointerdown', onControl: true }, + { type: 'wheel' }, + ...['+', '-', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp'].map(key => ({ type: 'keydown', key })), + { type: 'keydown', key: 'Enter', onControl: true }, + { type: 'keydown', key: ' ', onControl: true } + ].forEach(({ type, key, onControl }) => { + QUnit.test(`marker size refit stops after ${type} ${key || ''} on ${onControl ? 'a control' : 'the map'}`, function(assert) { + let markerSizeChanged = 0; + let markerWidth = 20; + const engine = createOpenLayersEngine(openLayersMock); + const container = document.createElement('div'); + const engineMap = engine.createMap(container); + const eventTarget = onControl ? document.createElement('button') : container; + if(onControl) { + container.appendChild(eventTarget); + } + const bounds = { + northEast: { + lat: 40.7, + lng: -74 + }, + southWest: { + lat: 40.7, + lng: -74 + } + }; + openLayersMock.getOverlayRect = () => ({ + height: markerWidth, + width: markerWidth + }); + engineMap.attachHandlers({ + click: () => {}, + markerSizeChange: () => { + markerSizeChanged += 1; + engineMap.fitBounds(bounds, { includeMarkerPadding: true }); + }, + viewChange: () => {} + }); + engineMap.addMarker({ + iconSrc: 'custom-marker.png', + location: { + lat: 40.7, + lng: -74 + } + }); + const markerElement = openLayersMock.addedOverlays[0].options.element; + engineMap.fitBounds(bounds, { includeMarkerPadding: true }); + + markerWidth = 25; + triggerResize(markerElement); + assert.strictEqual(markerSizeChanged, 1, 'marker size refits before user movement'); + + eventTarget.dispatchEvent(key + ? new KeyboardEvent(type, { bubbles: true, key }) + : new Event(type, { bubbles: true })); + const fitCallCountAfterUserMove = openLayersMock.fitCallCount; + markerWidth = 30; + triggerResize(markerElement); + assert.strictEqual(markerSizeChanged, 1, 'user movement prevents an automatic marker-size refit'); + assert.strictEqual(openLayersMock.fitCallCount, fitCallCountAfterUserMove, 'view is not fitted after user movement'); + + engineMap.fitBounds(bounds, { includeMarkerPadding: true }); + const fitCallCountBeforeResize = openLayersMock.fitCallCount; + markerWidth = 44; + triggerResize(markerElement); + assert.strictEqual(markerSizeChanged, 2, 'autoAdjust fitting enables marker-size refit again'); + assert.strictEqual(openLayersMock.fitCallCount, fitCallCountBeforeResize + 1, 'view is fitted after marker layout changes'); + assert.deepEqual(openLayersMock.fitOptions.padding, [44, 22, 0, 22], 'resumed refit uses the latest marker size'); + + engineMap.dispose(); + }); + }); + [false, true].forEach(afterUserInteraction => { + QUnit.test(`explicit bounds are not refitted after a marker resize (after user interaction: ${afterUserInteraction})`, function(assert) { + let markerWidth = 20; + const container = document.createElement('div'); + const engineMap = createOpenLayersEngine(openLayersMock).createMap(container); + const markerSizeChange = sinon.spy(); + const location = { lat: 40.7, lng: -74 }; + openLayersMock.getOverlayRect = () => ({ height: markerWidth, width: markerWidth }); + engineMap.attachHandlers({ click: () => {}, markerSizeChange, viewChange: () => {} }); + engineMap.addMarker({ iconSrc: 'custom-marker.png', location }); + engineMap.fitBounds({ northEast: location, southWest: location }, { includeMarkerPadding: true }); + + if(afterUserInteraction) { + container.dispatchEvent(new Event('pointerdown', { bubbles: true })); + } + engineMap.fitBounds({ + northEast: { lat: 41, lng: -73 }, + southWest: { lat: 40, lng: -75 } + }); + const fitCallCount = openLayersMock.fitCallCount; + + markerWidth = 44; + triggerResize(openLayersMock.addedOverlays[0].options.element); + + assert.ok(markerSizeChange.notCalled, 'marker resize does not request an automatic refit'); + assert.strictEqual(openLayersMock.fitCallCount, fitCallCount, 'view is not fitted again'); + assert.deepEqual(openLayersMock.fittedExtent, [-75000, 40000, -73000, 41000], 'explicit bounds remain applied'); + + engineMap.dispose(); + }); + }); + QUnit.test('newer marker overlays are rendered above older markers', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const container = document.createElement('div'); + const engineMap = engine.createMap(container); + engineMap.addMarker({ + location: { + lat: 40.7, + lng: -74 + } + }); + engineMap.addMarker({ + location: { + lat: 40.8, + lng: -73.9 + } + }); + + assert.strictEqual(openLayersMock.overlayOptions[0].insertFirst, false, 'first marker uses append order'); + assert.strictEqual(openLayersMock.overlayOptions[1].insertFirst, false, 'second marker uses append order'); + assert.strictEqual(container.lastElementChild, openLayersMock.addedOverlays[1].options.element, 'newer marker is the last overlay element'); + + engineMap.dispose(); + }); + QUnit.test('HTML marker padding is measured after a hidden map becomes visible', function(assert) { + const done = assert.async(); + const $map = $('#map').css({ + display: 'none', + height: '300px', + width: '500px' + }); + const map = $map.dxMap({ + provider: 'osm', + autoAdjust: true, + markers: [{ + html: 'A', + htmlOffset: { + left: 5, + top: 7 + }, + location: [40.7, -74] + }], + providerConfig: { + tileServer + }, + onReady: () => { + const markerElement = openLayersMock.addedOverlays[0].options.element; + markerElement.style.height = '60px'; + markerElement.style.width = '80px'; + assert.deepEqual(openLayersMock.fitOptions.padding, [0, 30, 48, 0], 'fallback padding is used without layout'); + + $map.css('display', 'block'); + map.option('onUpdated', () => { + assert.deepEqual(openLayersMock.fitOptions.padding, [0, 85, 67, 0], 'visible marker dimensions are measured without a layout stub'); + done(); + }); + map._visibilityChanged(true); + } + }).dxMap('instance'); + }); + QUnit.test('autoAdjust keeps the current zoom when fitting would zoom in', function(assert) { + const done = assert.async(); + openLayersMock.fitZoom = 15; + $('#map').dxMap({ + provider: 'osm', + zoom: 12, + markers: [{ + location: [40.7, -74] + }], + providerConfig: { + tileServer + }, + onReady: () => { + assert.strictEqual(openLayersMock.viewZoom, 12, 'zoom is restored after fitting'); + assert.strictEqual(openLayersMock.viewZoomSetCount, 1, 'zoom is restored through the view API'); + done(); + } + }); + }); + QUnit.test('autoAdjust updates the option when fitting zooms out', function(assert) { + const done = assert.async(); + openLayersMock.fitZoom = 8; + const map = $('#map').dxMap({ + provider: 'osm', + zoom: 12, + markers: [{ + location: [40.7, -74] + }, { + location: [41.7, -73] + }], + providerConfig: { + tileServer + }, + onReady: () => { + assert.strictEqual(map.option('zoom'), 8, 'fitted zoom is synchronized with the component'); + done(); + } + }).dxMap('instance'); + }); + QUnit.test('autoAdjust uses the shortest extent across the antimeridian', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + markers: [{ + location: [10, 179] + }, { + location: [20, -179] + }], + providerConfig: { + tileServer + }, + onReady: () => { + assert.deepEqual(openLayersMock.fittedExtent, [179000, 10000, 181000, 20000], 'wrapped marker bounds are fitted'); + assert.deepEqual(openLayersMock.addedOverlays[1].options.position, [181000, 20000], 'wrapped marker is moved into the fitted world'); + openLayersMock.mapInstance.getView().setCenter([-179000, 15000]); + assert.deepEqual(openLayersMock.addedOverlays.map(({ + options + }) => options.position), [[-181000, 10000], [-179000, 20000]], 'markers follow the view into an adjacent world'); + done(); + } + }); + }); + QUnit.test('autoAdjust selects the shortest extent for three markers', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + markers: [{ + location: [10, 0] + }, { + location: [20, -160] + }, { + location: [30, 100] + }], + providerConfig: { + tileServer + }, + onReady: () => { + assert.deepEqual(openLayersMock.fittedExtent, [0, 10000, 200000, 30000], 'largest circular gap is excluded from the fitted extent'); + assert.deepEqual(openLayersMock.addedOverlays.map(({ + options + }) => options.position), [[0, 10000], [200000, 20000], [100000, 30000]], 'all markers use the fitted world'); + done(); + } + }); + }); + QUnit.test('autoAdjust false preserves the current view', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + autoAdjust: false, + markers: [{ + location: [40.7, -74] + }, { + location: [40.8, -73.9] + }], + providerConfig: { + tileServer + }, + onReady: () => { + assert.strictEqual(openLayersMock.fittedExtent, null, 'marker bounds are not fitted'); + done(); + } + }); + }); +}); +QUnit.module('OSM: marker tooltips', moduleConfig, () => { + const location = { lat: 40.74, lng: -73.98 }; + const createMap = (options = {}) => new Promise(resolve => { + $('#map').dxMap({ + provider: 'osm', + autoAdjust: false, + width: 600, + height: 400, + providerConfig: { + tileServer: { url: 'https://tiles.example.com/{z}/{x}/{y}.png', attribution: 'Example' } + }, + ...options, + onReady: ({ component }) => resolve(component) + }); + }); + const getPopovers = (root = document) => Array.from(root.querySelectorAll('.dx-map-marker-popover.dx-popover')) + .map(element => Popover.getInstance(element)); + const getTooltip = () => getPopovers()[0]; + const getContent = popover => $(popover.content())[0]; + const getMarker = () => openLayersMock.addedOverlays[0].options.element; + const positionMarker = (marker = getMarker(), top = 200) => $(marker).css({ position: 'absolute', left: 300, top }); + + QUnit.test('tooltips use the standard Popover without a title, Close button or custom styles', async function(assert) { + const map = await createMap({ markers: [{ location, tooltip: 'First' }] }); + await map.addMarker({ location, tooltip: 'Second' }); + assert.strictEqual(getPopovers().length, 2, 'initial and added markers have popovers'); + getPopovers().forEach(popover => { + assert.strictEqual(popover.constructor, Popover, 'no subclass'); + assert.notOk(popover.option('showTitle'), 'no title'); + assert.notOk(popover.option('showCloseButton'), 'no Close button'); + assert.notOk(getContent(popover).querySelector('.dx-button'), 'no custom Close button'); + assert.strictEqual(popover.option('wrapperAttr').class, 'dx-map-marker-popover', 'customization hook'); + }); + }); + + QUnit.test('tooltip creation does not use deprecated options', async function(assert) { + const log = sinon.stub(coreErrors, 'log'); + try { + await createMap({ markers: [{ location, tooltip: 'Start' }] }); + assert.ok(log.withArgs('W0001').notCalled, 'no deprecated option warning'); + } finally { + log.restore(); + } + }); + + QUnit.test('tooltip hosts share map containment without isolating their z-indices', async function(assert) { + const firstMarker = { location, tooltip: 'First' }; + const map = await createMap({ markers: [firstMarker, { location, tooltip: 'Second' }] }); + const [first, second] = getPopovers(); + const firstHost = first.option('container'); + const secondHost = second.option('container'); + const overlayContainer = openLayersMock.mapInstance.getOverlayContainer(); + + assert.strictEqual(firstHost.parentElement, overlayContainer, 'first tooltip belongs to the shared layer'); + assert.strictEqual(secondHost.parentElement, overlayContainer, 'second tooltip belongs to the shared layer'); + assert.strictEqual(getComputedStyle(overlayContainer).contain, 'layout paint', 'the shared layer clips tooltip content'); + assert.strictEqual(getComputedStyle(firstHost).contain, 'none', 'first host does not isolate the Popover z-index'); + assert.strictEqual(getComputedStyle(secondHost).contain, 'none', 'second host does not isolate the Popover z-index'); + + await map.removeMarker(firstMarker); + + assert.strictEqual(secondHost.parentElement, overlayContainer, 'removing a tooltip preserves the shared layer'); + assert.ok(overlayContainer.isConnected, 'remaining tooltips retain their container'); + }); + + QUnit.test('disposing the map in the marker callback does not show its removed popover', async function(assert) { + const map = await createMap({ markers: [{ location, tooltip: 'Start', onClick: () => map.dispose() }] }); + const show = sinon.spy(getTooltip(), 'show'); + getMarker().click(); + assert.notOk(show.called, 'disposed popover is not shown'); + assert.strictEqual(getPopovers().length, 0, 'popover is removed'); + }); + + QUnit.test('a string tooltip opens without a marker callback and describes its marker', async function(assert) { + const onClick = sinon.spy(); + await createMap({ markers: [{ location, tooltip: 'Start' }], onClick }); + positionMarker(); + const marker = getMarker(); + const tooltip = getTooltip(); + assert.notOk(tooltip.option('visible'), 'initially hidden'); + marker.click(); + const popup = getContent(tooltip).parentElement; + assert.ok(tooltip.option('visible'), 'click opens the tooltip'); + assert.strictEqual(popup.getAttribute('role'), 'tooltip', 'uses native semantics'); + assert.strictEqual(marker.getAttribute('aria-describedby'), popup.id, 'native description'); + assert.strictEqual(tooltip.option('target'), marker, 'focusable marker is the target'); + assert.strictEqual(tooltip.option('position').of, marker.firstElementChild, 'arrow targets the visible icon'); + assert.ok(onClick.notCalled, 'marker click is not a map click'); + marker.click(); + assert.notOk(tooltip.option('visible'), 'the second click closes the tooltip'); + marker.click(); + assert.ok(tooltip.option('visible'), 'the third click opens the tooltip again'); + assert.ok(onClick.notCalled, 'toggling the tooltip does not trigger map clicks'); + }); + + [false, true].forEach(rtlEnabled => { + QUnit.test(`initial visibility preserves HTML and focus (RTL: ${rtlEnabled})`, async function(assert) { + const activeElement = document.activeElement; + await createMap({ rtlEnabled, markers: [{ location, tooltip: { text: 'Start', isShown: true } }] }); + const tooltip = getTooltip(); + assert.ok(tooltip.option('visible'), 'isShown is respected'); + assert.strictEqual(tooltip.option('rtlEnabled'), rtlEnabled, 'direction is inherited'); + assert.strictEqual(getContent(tooltip).querySelector('b').textContent, 'Start', 'HTML is preserved'); + assert.strictEqual(document.activeElement, activeElement, 'no focus is stolen'); + }); + }); + + ['Enter', ' '].forEach(key => { + QUnit.test(`keyboard activation (${key}) keeps focus on the marker and supports Escape`, async function(assert) { + const onClick = sinon.spy(); + await createMap({ markers: [{ location, tooltip: 'Start', onClick }] }); + positionMarker(); + const marker = getMarker(); + marker.focus(); + [true, false, true].forEach((visible, index) => { + marker.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + marker.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true, cancelable: true })); + assert.strictEqual(getTooltip().option('visible'), visible, 'keyboard toggles the tooltip'); + assert.strictEqual(onClick.callCount, index + 1, 'each activation calls the marker handler once'); + assert.strictEqual(onClick.lastCall.args[0].tooltip, getTooltip(), 'keyboard activation exposes the same Popover'); + assert.strictEqual(document.activeElement, marker, 'focus remains on the marker'); + }); + marker.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + assert.notOk(getTooltip().option('visible'), 'native Popover handles Escape'); + }); + }); + + QUnit.test('marker callback receives coordinates and can customize the popover before its first showing', async function(assert) { + const onClick = sinon.spy(({ location: coordinates, tooltip }) => { + assert.deepEqual(coordinates, location, 'resolved coordinates are passed'); + assert.strictEqual(tooltip, getTooltip(), 'the actual Popover instance is passed'); + assert.notOk(tooltip.option('visible'), 'callback runs before showing'); + tooltip.option({ showTitle: true, title: 'Details', showCloseButton: true }); + }); + await createMap({ markers: [{ location, tooltip: 'Start', onClick }] }); + positionMarker(); + getMarker().click(); + assert.ok(onClick.calledOnce, 'one callback'); + assert.ok(getTooltip().option('visible'), 'popover is shown'); + assert.ok(getContent(getTooltip()).parentElement.querySelector('.dx-closebutton'), 'public options add Close'); + }); + + QUnit.test('isShown opens the tooltip without calling the marker click handler', async function(assert) { + const onClick = sinon.spy(); + await createMap({ markers: [{ location, tooltip: { text: 'Start', isShown: true }, onClick }] }); + + assert.ok(getTooltip().option('visible'), 'initial showing does not wait for a click'); + assert.ok(onClick.notCalled, 'onClick is not an initialization callback'); + getMarker().click(); + assert.ok(onClick.calledOnce, 'the actual click calls the handler'); + assert.strictEqual(onClick.firstCall.args[0].tooltip, getTooltip(), 'the handler receives the already shown Popover'); + assert.notOk(getTooltip().option('visible'), 'the click closes an initially shown tooltip'); + }); + + QUnit.test('hiding the Popover in the marker callback does not reopen it', async function(assert) { + const onClick = sinon.spy(({ tooltip }) => tooltip.hide()); + await createMap({ markers: [{ location, tooltip: { text: 'Start', isShown: true }, onClick }] }); + positionMarker(); + + getMarker().click(); + + assert.ok(onClick.calledOnce, 'the callback runs once'); + assert.notOk(getTooltip().option('visible'), 'the hidden Popover is not reopened by the marker click'); + }); + + QUnit.test('onHiding can cancel closing the tooltip by clicking its marker', async function(assert) { + const onHiding = sinon.spy(event => { event.cancel = true; }); + await createMap({ markers: [{ + location, + tooltip: 'Start', + onClick: ({ tooltip }) => tooltip.option('onHiding', onHiding) + }] }); + positionMarker(); + const marker = getMarker(); + marker.click(); + + marker.click(); + + assert.ok(onHiding.calledOnce, 'the second click tries to hide the Popover'); + assert.ok(getTooltip().option('visible'), 'onHiding keeps the tooltip open'); + }); + + QUnit.test('clicking a marker closes only its tooltip', async function(assert) { + await createMap({ markers: [ + { location, tooltip: { text: 'First', isShown: true } }, + { location, tooltip: { text: 'Second', isShown: true } } + ] }); + positionMarker(); + + getMarker().click(); + + const [first, second] = getPopovers(); + assert.notOk(first.option('visible'), 'the clicked marker tooltip is closed'); + assert.ok(second.option('visible'), 'the other tooltip remains open'); + }); + + [false, true].forEach(focusStateEnabled => { + QUnit.test(`dialog focus is consistent on first and repeated showing (focusStateEnabled: ${focusStateEnabled})`, async function(assert) { + await createMap({ + focusStateEnabled, + markers: [{ + location, + tooltip: 'Start', + onClick: () => getTooltip().option({ title: 'Details', showTitle: true, showCloseButton: true }) + }] + }); + positionMarker(); + const input = $('').appendTo('#qunit-fixture')[0]; + const tooltip = getTooltip(); + for(let attempt = 0; attempt < 2; attempt++) { + input.focus(); + getMarker().click(); + const popup = getContent(tooltip).parentElement; + const close = popup.querySelector('.dx-closebutton'); + assert.notOk(popup.inert, 'the visible popup is not inert'); + assert.strictEqual(document.activeElement, focusStateEnabled ? close : input, 'native autofocus respects the map'); + assert.strictEqual(tooltip.option('tabFocusLoopEnabled'), focusStateEnabled, 'the focus loop respects the map'); + await tooltip.hide(); + assert.strictEqual(document.activeElement, focusStateEnabled ? getMarker() : input, 'hiding respects the map focus setting'); + } + }); + }); + + [false, true].forEach(focusStateEnabled => { + [ + { name: 'title and Close button', options: { title: 'Details', showTitle: true, showCloseButton: true } }, + { name: 'toolbar items', options: { toolbarItems: [{ widget: 'dxButton', options: { text: 'Details' } }] } } + ].forEach(({ name, options }) => { + QUnit.test(`customizing an open tooltip with ${name} respects focusStateEnabled: ${focusStateEnabled}`, async function(assert) { + await createMap({ focusStateEnabled, markers: [{ location, tooltip: 'Start' }] }); + positionMarker(); + getMarker().click(); + const tooltip = getTooltip(); + tooltip.option(options); + + assert.ok(tooltip.option('visible'), 'the tooltip remains open'); + assert.strictEqual(tooltip.option('focusStateEnabled'), focusStateEnabled, 'native focus respects the map'); + assert.strictEqual(tooltip.option('tabFocusLoopEnabled'), focusStateEnabled, 'the focus loop respects the map'); + await Promise.resolve(); + const button = getContent(tooltip).parentElement.querySelector('.dx-button'); + assert.strictEqual(button.tabIndex, focusStateEnabled ? 0 : -1, 'the new button respects tab navigation'); + }); + }); + }); + + QUnit.test('dialog keyboard access is restored after changing focusStateEnabled', async function(assert) { + const map = await createMap({ focusStateEnabled: false, markers: [{ location, tooltip: 'Start' }] }); + positionMarker(); + const tooltip = getTooltip(); + tooltip.option({ title: 'Details', showTitle: true, showCloseButton: true }); + getMarker().click(); + const close = getContent(tooltip).parentElement.querySelector('.dx-closebutton'); + assert.strictEqual(close.tabIndex, -1, 'Close is excluded from tab navigation'); + map.option('focusStateEnabled', true); + await map._lastAsyncAction; + assert.strictEqual(close.tabIndex, 0, 'Close is restored to tab navigation'); + assert.ok(tooltip.option('focusStateEnabled'), 'native focus is enabled'); + assert.ok(tooltip.option('tabFocusLoopEnabled'), 'native focus loop is enabled'); + await tooltip.hide(); + getMarker().click(); + assert.strictEqual(document.activeElement, close, 'native autofocus works after enabling'); + }); + + ['focusStateEnabled', 'disabled'].forEach(optionName => { + QUnit.test(`an open dialog follows runtime changes of ${optionName}`, async function(assert) { + const map = await createMap({ focusStateEnabled: true, markers: [{ location, tooltip: 'Start' }] }); + positionMarker(); + const tooltip = getTooltip(); + tooltip.option({ title: 'Details', showTitle: true, showCloseButton: true }); + getMarker().click(); + const enabledValue = optionName === 'focusStateEnabled'; + + map.option(optionName, !enabledValue); + await map._lastAsyncAction; + tooltip.option('toolbarItems', [{ widget: 'dxButton', options: { text: 'Details' } }]); + assert.notOk(tooltip.option('focusStateEnabled'), 'native focus stays disabled after customization'); + assert.notOk(tooltip.option('tabFocusLoopEnabled'), 'the focus loop stays disabled after customization'); + + map.option(optionName, enabledValue); + await map._lastAsyncAction; + assert.ok(tooltip.option('focusStateEnabled'), 'native focus is restored'); + assert.ok(tooltip.option('tabFocusLoopEnabled'), 'the focus loop is restored'); + await tooltip.hide(); + getMarker().click(); + const button = getContent(tooltip).parentElement.querySelector('.dx-button'); + assert.strictEqual(document.activeElement, button, 'native autofocus works after restoring focus'); + await tooltip.hide(); + assert.strictEqual(document.activeElement, getMarker(), 'native focus restoration works'); + }); + }); + + QUnit.test('tooltip describes the focusable children of an HTML marker', async function(assert) { + await createMap({ markers: [{ + location, + html: 'FirstSecond', + tooltip: 'Details' + }] }); + positionMarker(); + const marker = getMarker(); + const buttons = marker.querySelectorAll('button'); + buttons[0].click(); + const popup = getContent(getTooltip()).parentElement; + assert.strictEqual(buttons[0].getAttribute('aria-describedby'), `existing-description ${popup.id}`, 'first button keeps its existing description'); + assert.strictEqual(buttons[1].getAttribute('aria-describedby'), popup.id, 'second button is also described'); + assert.notOk(marker.hasAttribute('aria-describedby'), 'the non-focusable wrapper is not described'); + assert.strictEqual(getTooltip().option('position').of, marker, 'position remains relative to the entire marker'); + }); + + QUnit.test('Escape closes a nested SelectBox before its marker tooltip', async function(assert) { + await createMap({ markers: [{ location, tooltip: '' }] }); + positionMarker(); + getMarker().click(); + const tooltip = getTooltip(); + const selectBox = new SelectBox(getContent(tooltip).querySelector('.nested-select-box'), { + items: ['First', 'Second'], + value: 'First', + dropDownOptions: { animation: undefined } + }); + try { + selectBox.focus(); + selectBox.open(); + const input = getContent(tooltip).querySelector('.dx-texteditor-input'); + assert.ok(selectBox.option('opened'), 'the nested list is open'); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + assert.notOk(selectBox.option('opened'), 'the first Escape closes the nested list'); + assert.ok(tooltip.option('visible'), 'the tooltip remains open'); + assert.strictEqual(document.activeElement, input, 'the editor retains focus'); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + assert.notOk(tooltip.option('visible'), 'the next Escape closes the tooltip'); + } finally { + selectBox.dispose(); + } + }); + + QUnit.test('onShowing can cancel showing without changing the marker click location', async function(assert) { + let clickLocation; + await createMap({ markers: [{ + location, + tooltip: 'Start', + onClick: (event) => { + clickLocation = event.location; + event.tooltip.option('onShowing', e => { e.cancel = true; }); + } + }] }); + positionMarker(); + getMarker().click(); + assert.deepEqual(clickLocation, location, 'sidebar can use the coordinates'); + assert.notOk(getTooltip().option('visible'), 'onShowing cancels the popup'); + }); + + QUnit.test('public contentTemplate and hide support a user-provided Close button', async function(assert) { + await createMap({ markers: [{ location, tooltip: 'Start' }] }); + positionMarker(); + const tooltip = getTooltip(); + tooltip.option('contentTemplate', () => $('').text('Close details').on('click', () => tooltip.hide())); + getMarker().click(); + const button = getContent(tooltip).querySelector('button'); + assert.ok(button, 'custom content is rendered'); + button.click(); + assert.notOk(tooltip.option('visible'), 'public hide closes the popover'); + }); + + QUnit.test('popover flips and keeps its size on move, first show and public repaint near the boundary', async function(assert) { + await createMap({ markers: [{ location, tooltip: 'First lineSecond line' }] }); + positionMarker(); + const marker = getMarker(); + const tooltip = getTooltip(); + marker.click(); + const popup = getContent(tooltip).parentElement; + const initial = popup.getBoundingClientRect(); + $(marker).css('top', 0); + openLayersMock.mapInstance.trigger('postrender'); + assert.ok(popup.getBoundingClientRect().top >= marker.firstElementChild.getBoundingClientRect().bottom, 'flips down'); + $(marker).css('top', -90); + openLayersMock.mapInstance.trigger('postrender'); + assert.strictEqual(popup.getBoundingClientRect().height, initial.height, 'move does not shrink content'); + assert.ok(popup.getBoundingClientRect().top < getOpenLayersMapTarget().getBoundingClientRect().top, 'can leave the map'); + tooltip.repaint(); + await Promise.resolve(); + assert.strictEqual(popup.getBoundingClientRect().height, initial.height, 'public repaint preserves size'); + await tooltip.hide(); + marker.click(); + await Promise.resolve(); + assert.strictEqual(popup.getBoundingClientRect().height, initial.height, 'opening at the edge preserves size'); + assert.strictEqual(popup.getBoundingClientRect().width, initial.width, 'width is preserved'); + }); + + [false, true].forEach(shownBefore => { + QUnit.test(`postrender skips DOM work for a hidden tooltip (shown before: ${shownBefore})`, async function(assert) { + await createMap({ markers: [{ location, tooltip: 'Details' }] }); + positionMarker(); + const marker = getMarker(); + const tooltip = getTooltip(); + if(shownBefore) { + marker.click(); + await Promise.resolve(); + await tooltip.hide(); + } + const popup = getContent(tooltip).parentElement; + const position = sinon.spy(tooltip, '_renderPosition'); + const boundaryRect = sinon.spy(getOpenLayersMapTarget(), 'getBoundingClientRect'); + const markerRect = sinon.spy(marker, 'getBoundingClientRect'); + const popupRect = sinon.spy(popup, 'getBoundingClientRect'); + const focusTargets = sinon.spy(popup, 'querySelectorAll'); + + openLayersMock.mapInstance.trigger('postrender'); + + assert.notOk(tooltip.option('visible'), 'tooltip remains hidden'); + assert.ok(position.notCalled, 'position is not recalculated'); + assert.ok(boundaryRect.notCalled, 'map bounds are not measured'); + assert.ok(markerRect.notCalled, 'marker bounds are not measured'); + assert.ok(popupRect.notCalled, 'popup bounds are not measured'); + assert.ok(focusTargets.notCalled, 'focusable content is not queried'); + }); + }); + + QUnit.test('postrender updates the position without repainting or recalculating dimensions', async function(assert) { + await createMap({ markers: [{ location, tooltip: { text: 'Start', isShown: true } }] }); + const tooltip = getTooltip(); + const position = sinon.spy(tooltip, '_renderPosition'); + const dimensions = sinon.spy(tooltip, '_renderDimensions'); + const repaint = sinon.spy(tooltip, 'repaint'); + openLayersMock.mapInstance.trigger('postrender'); + assert.ok(position.calledOnceWithExactly(false), 'position only'); + assert.ok(dimensions.notCalled, 'dimensions are not recomputed'); + assert.ok(repaint.notCalled, 'no full repaint'); + }); + + QUnit.test('tooltip position follows a resized image marker', async function(assert) { + await createMap({ markers: [{ + location, + iconSrc: 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=', + tooltip: 'Start' + }] }); + positionMarker(); + const marker = getMarker(); + $(marker).css({ width: 25, height: 40, transform: 'translateY(-100%)' }); + marker.click(); + const tooltip = getTooltip(); + const popup = getContent(tooltip).parentElement; + const initialTop = popup.getBoundingClientRect().top; + $(marker).css('height', 70); + triggerResize(marker); + assert.strictEqual(popup.getBoundingClientRect().top, initialTop - 30, 'popup follows the changed image bounds'); + assert.strictEqual(tooltip.option('position').of, marker, 'the image remains the position target'); + }); + + QUnit.test('tooltip positioning follows its marker into a wrapped world', async function(assert) { + await createMap({ markers: [{ location, tooltip: 'Start' }] }); + positionMarker(); + getMarker().click(); + const tooltip = getTooltip(); + const position = sinon.spy(tooltip, '_renderPosition'); + openLayersMock.viewExtent = [285900, 40600, 286200, 40900]; + openLayersMock.mapInstance.getView().setCenter([286020, 40740]); + assert.deepEqual(openLayersMock.addedOverlays[0].options.position, [286020, 40740], 'marker moves to the adjacent world'); + assert.ok(position.calledWithExactly(false), 'popup position is updated without resizing'); + assert.strictEqual(tooltip.option('target'), getMarker(), 'the same marker remains the target'); + }); + + QUnit.test('an open offscreen popover stays open but its controls cannot scroll the map on focus', async function(assert) { + await createMap({ markers: [{ location, tooltip: 'Details' }] }); + positionMarker(); + getMarker().click(); + const tooltip = getTooltip(); + const popup = getContent(tooltip).parentElement; + const button = popup.querySelector('button'); + button.focus(); + $(getMarker()).css('top', -1000); + openLayersMock.mapInstance.trigger('postrender'); + assert.ok(tooltip.option('visible'), 'remains logically open'); + assert.ok(popup.inert, 'offscreen popup is inert'); + assert.strictEqual(document.activeElement, getOpenLayersMapTarget(), 'focus moves to the map without scrolling'); + positionMarker(); + openLayersMock.mapInstance.trigger('postrender'); + assert.notOk(popup.inert, 'returning content can receive focus'); + }); + + QUnit.test('a marker with a closed offscreen tooltip becomes interactive after returning to the viewport', async function(assert) { + await createMap({ markers: [{ location, tooltip: 'Details' }] }); + positionMarker(); + const marker = getMarker(); + const tooltip = getTooltip(); + marker.click(); + await Promise.resolve(); + positionMarker(marker, -1000); + openLayersMock.mapInstance.trigger('postrender'); + assert.ok(marker.inert, 'offscreen marker cannot receive focus'); + await tooltip.hide(); + + positionMarker(); + openLayersMock.mapInstance.trigger('postrender'); + openLayersMock.mapInstance.trigger('moveend'); + + assert.notOk(tooltip.option('visible'), 'moving the map does not reopen the tooltip'); + assert.notOk(marker.inert, 'marker accessibility is restored at the end of movement'); + assert.strictEqual(marker.tabIndex, 0, 'marker returns to the tab order'); + marker.focus(); + assert.strictEqual(document.activeElement, marker, 'marker can receive focus'); + marker.click(); + assert.ok(tooltip.option('visible'), 'tooltip can be reopened'); + assert.notOk(getContent(tooltip).parentElement.inert, 'reopened content is interactive'); + }); + + QUnit.test('disabled and focusStateEnabled cover content added with public options', async function(assert) { + const map = await createMap({ markers: [{ location, tooltip: 'Start' }] }); + positionMarker(); + const tooltip = getTooltip(); + tooltip.option('contentTemplate', () => $('').text('Details')); + getMarker().click(); + const button = getContent(tooltip).querySelector('button'); + map.option('disabled', true); + await map._lastAsyncAction; + assert.ok(getOpenLayersMapTarget().inert, 'map is inert'); + assert.ok(button.closest('[inert]'), 'custom content cannot receive focus while the map is inert'); + map.option({ disabled: false, focusStateEnabled: false }); + await map._lastAsyncAction; + assert.strictEqual(button.tabIndex, -1, 'focusStateEnabled also applies'); + map.option('focusStateEnabled', true); + await map._lastAsyncAction; + assert.strictEqual(button.tabIndex, 0, 'natural tab order is restored'); + }); + + QUnit.test('disabled markers do not open their tooltips', async function(assert) { + const map = await createMap({ markers: [{ location, tooltip: 'Start' }] }); + map.option('disabled', true); + await map._lastAsyncAction; + getMarker().click(); + assert.notOk(getTooltip().option('visible'), 'disabled activation is ignored'); + }); + + QUnit.test('popup events do not activate the map, but wheel events can reach it', async function(assert) { + const onClick = sinon.spy(); + await createMap({ markers: [{ location, tooltip: 'Details' }], onClick }); + positionMarker(); + getMarker().click(); + const popup = getContent(getTooltip()).parentElement; + const mapTarget = getOpenLayersMapTarget(); + const wheel = sinon.spy(); + const click = sinon.spy(); + mapTarget.addEventListener('wheel', wheel); + mapTarget.addEventListener('click', click); + popup.querySelector('button').click(); + popup.dispatchEvent(new WheelEvent('wheel', { bubbles: true })); + openLayersMock.mapInstance.trigger('click', { coordinate: [-73980, 40740], originalEvent: { target: popup } }); + assert.ok(onClick.notCalled, 'engine event is filtered'); + assert.ok(click.notCalled, 'DOM click is stopped'); + assert.ok(wheel.calledOnce, 'wheel reaches the map'); + mapTarget.removeEventListener('wheel', wheel); + mapTarget.removeEventListener('click', click); + }); + + [false, true].forEach(rtlEnabled => { + QUnit.test(`tooltip keyboard activation and cleanup work in Shadow DOM (RTL: ${rtlEnabled})`, function(assert) { + const host = document.createElement('div'); + document.getElementById('qunit-fixture').appendChild(host); + const shadow = host.attachShadow({ mode: 'open' }); + const container = document.createElement('div'); + Object.assign(container.style, { width: '600px', height: '400px' }); + container.tabIndex = 0; + shadow.appendChild(container); + const engine = createOpenLayersEngine(openLayersMock); + const engineMap = engine.createMap(container, { center: location, zoom: 12 }); + try { + const marker = engineMap.addMarker({ location, tooltip: { text: 'Start', visible: false }, rtlEnabled }); + const element = marker.originalMarker.options.element; + positionMarker(element); + element.focus(); + element.click(); + const [tooltip] = getPopovers(shadow); + assert.ok(tooltip.option('visible'), 'popover opens'); + assert.strictEqual(shadow.activeElement, element, 'focus stays on the marker'); + assert.strictEqual(tooltip.option('rtlEnabled'), rtlEnabled, 'direction is forwarded'); + element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, composed: true })); + assert.notOk(tooltip.option('visible'), 'Escape reaches the stock component'); + marker.dispose(); + assert.strictEqual(getPopovers(shadow).length, 0, 'removal cleans up the widget'); + } finally { + engineMap.dispose(); + host.remove(); + } + }); + }); + + QUnit.test('removing a focused tooltip focuses the map and detaches render synchronization', async function(assert) { + const options = { location, tooltip: 'Details' }; + const map = await createMap({ markers: [options] }); + positionMarker(); + getMarker().click(); + const tooltip = getTooltip(); + getContent(tooltip).querySelector('button').focus(); + const position = sinon.spy(tooltip, '_renderPosition'); + await map.removeMarker(options); + position.resetHistory(); + openLayersMock.mapInstance.trigger('postrender'); + assert.strictEqual(document.activeElement, getOpenLayersMapTarget(), 'removal restores focus'); + assert.ok(position.notCalled, 'render subscription is removed'); + assert.strictEqual(getPopovers().length, 0, 'widget host is removed'); + }); + + QUnit.test('runtime tooltip updates and map disposal do not retain old popovers', async function(assert) { + const map = await createMap({ markers: [{ location, tooltip: 'Old' }] }); + const dispose = sinon.spy(getTooltip(), 'dispose'); + map.option('markers[0].tooltip', { text: 'New', isShown: true }); + await map._lastAsyncAction; + assert.ok(dispose.calledOnce, 'old component is disposed'); + assert.strictEqual(getPopovers().length, 1, 'one current component'); + assert.strictEqual(getContent(getTooltip()).textContent, 'New', 'new content is rendered'); + map.dispose(); + assert.strictEqual(getPopovers().length, 0, 'no popover remains after disposal'); + }); +}); + +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('empty route color uses the default color', async function(assert) { + const map = await createMap(); + const feature = await map.addRoute({ ...route, color: '' }); + + assert.deepEqual(feature.getStyle().getStroke().getColor(), [0, 0, 255, 0.5], 'empty color uses the shared route default'); + }); + + QUnit.test('route updates reuse one layer and honor zero opacity', 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: 8 }]); + 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(), 8, 'weight is updated'); + }); + + [0, -1, NaN, Infinity, -Infinity].forEach(weight => { + QUnit.test(`non-positive or non-finite weight omits the stroke but keeps a removable route instance (${weight})`, async function(assert) { + const onRouteAdded = sinon.spy(); + const onRouteRemoved = sinon.spy(); + const map = await createMap({ routes: [{ ...route, weight: 8 }], onRouteAdded, onRouteRemoved }); + const invisibleRoute = { ...route, weight }; + const feature = await map.addRoute(invisibleRoute); + assert.ok(feature, 'valid geometry still creates a route instance'); + assert.notOk(feature.getStyle().getStroke(), 'no stroke is sent to the renderer'); + assert.strictEqual(getRouteSource().getFeatures().length, 2, 'both valid routes remain in the source'); + assert.strictEqual(onRouteAdded.secondCall.args[0].originalRoute, feature, 'added event exposes the instance'); + await map.removeRoute(invisibleRoute); + assert.strictEqual(onRouteRemoved.firstCall.args[0].options, invisibleRoute, 'removed event identifies the route'); + assert.strictEqual(getRouteSource().getFeatures().length, 1, 'the other route remains'); + }); + }); + + ['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'); + }); + }); + + ['missing', 'undefined', 'throw', 'reject'].forEach(failure => { + QUnit.test(`unresolved route address skips routing without a zero-coordinate fallback (${failure})`, async function(assert) { + const reason = new Error('Location service unavailable'); + const calculateLocation = sinon.stub().callsFake(() => { + if(failure === 'throw') { + throw reason; + } + return failure === 'reject' ? Promise.reject(reason) : Promise.resolve(undefined); + }); + const calculateRoute = sinon.stub().returns(Promise.resolve(path)); + const onRouteAdded = sinon.spy(); + const onRouteRemoved = sinon.spy(); + const providerConfig = { tileServer, calculateRoute }; + if(failure !== 'missing') { + providerConfig.calculateLocation = calculateLocation; + } + const log = sinon.stub(errors, 'log'); + try { + const map = await createMap({ providerConfig, onRouteAdded, onRouteRemoved }); + const failedRoute = { locations: ['Unknown address', [40.8, -73.9]] }; + assert.strictEqual(await map.addRoute(failedRoute), undefined, 'the operation completes without an instance'); + assert.ok(calculateRoute.notCalled, 'unresolved waypoints are not passed to the route service'); + assert.strictEqual(openLayersMock.addedVectorLayers.length, 0, 'no route is drawn'); + assert.ok(onRouteAdded.notCalled, 'no added event is fired'); + assert.ok(log.calledOnceWithExactly(...(failure === 'missing' + ? ['W1031'] + : ['W1006', failure === 'undefined' ? 'calculateLocation returned an invalid result.' : reason])), 'existing location diagnostics report the failure'); + await map.removeRoute(failedRoute); + assert.ok(onRouteRemoved.notCalled, 'no removed event is fired'); + if(failure !== 'missing') { + calculateLocation.callsFake(() => Promise.resolve({ lat: 40.7, lng: -74 })); + const result = await map.addRoute(failedRoute); + assert.ok(result, 'an explicit later attempt can succeed'); + assert.ok(calculateLocation.calledTwice, 'failed geocoding was not cached'); + assert.ok(calculateRoute.calledOnce, 'only the successful attempt requests a route'); + } + } finally { + log.restore(); + } + }); + }); + + [ + ['null', null], + ['undefined', undefined], + ['NaN latitude', { lat: NaN, lng: 10 }], + ['infinite longitude', { lat: 10, lng: Infinity }], + ['incomplete tuple', [10]], + ['extra tuple component', [10, 20, 30]], + ['numeric strings', { lat: '10', lng: '20' }], + ['empty object', {}], + ['boolean', true] + ].forEach(([name, location]) => { + QUnit.test(`invalid non-string waypoint skips routing without a zero-coordinate fallback (${name})`, async function(assert) { + const calculateLocation = sinon.spy(); + const calculateRoute = sinon.stub().returns(Promise.resolve(path)); + const onRouteAdded = sinon.spy(); + const onRouteRemoved = sinon.spy(); + const map = await createMap({ + providerConfig: { tileServer, calculateLocation, calculateRoute }, + onRouteAdded, + onRouteRemoved + }); + const invalidRoute = { locations: [location, [40.8, -73.9]] }; + + assert.strictEqual(await map.addRoute(invalidRoute), undefined, 'invalid waypoints do not create a route'); + assert.ok(calculateLocation.notCalled, 'non-string waypoints do not invoke geocoding'); + assert.ok(calculateRoute.notCalled, 'invalid waypoints are not passed to the route service'); + assert.strictEqual(openLayersMock.addedVectorLayers.length, 0, 'no route is drawn'); + assert.ok(onRouteAdded.notCalled, 'no added event is fired'); + await map.removeRoute(invalidRoute); + assert.ok(onRouteRemoved.notCalled, 'no removed event is fired'); + }); + }); + + [ + ['missing locations', undefined], + ['empty locations', []], + ['one coordinate', [[40.7, -74]]], + ['one address', ['Start']] + ].forEach(([name, locations]) => { + QUnit.test(`fewer than two waypoints skip geocoding and routing (${name})`, async function(assert) { + const calculateLocation = sinon.stub().returns(Promise.resolve({ lat: 40.7, lng: -74 })); + const calculateRoute = sinon.stub().returns(Promise.resolve(path)); + const onRouteAdded = sinon.spy(); + const onRouteRemoved = sinon.spy(); + const map = await createMap({ + providerConfig: { tileServer, calculateLocation, calculateRoute }, + onRouteAdded, + onRouteRemoved + }); + const incompleteRoute = { locations }; + + assert.strictEqual(await map.addRoute(incompleteRoute), undefined, 'incomplete waypoints do not create a route'); + assert.ok(calculateLocation.notCalled, 'geocoding is not requested'); + assert.ok(calculateRoute.notCalled, 'routing is not requested'); + assert.strictEqual(openLayersMock.addedVectorLayers.length, 0, 'no route is drawn'); + assert.ok(onRouteAdded.notCalled, 'no added event is fired'); + await map.removeRoute(incompleteRoute); + assert.ok(onRouteRemoved.notCalled, 'no removed event is fired'); + }); + }); + + QUnit.test('explicit zero coordinates and a successfully calculated zero location are valid route waypoints', async function(assert) { + const calculateLocation = sinon.stub().returns(Promise.resolve({ lat: 0, lng: 0 })); + const calculateRoute = sinon.stub().returns(Promise.resolve(path)); + await createMap({ + routes: [{ locations: [[0, 0], { lat: 0, lng: 0 }, '0, 0', 'Known location'] }], + providerConfig: { tileServer, calculateLocation, calculateRoute } + }); + assert.ok(calculateLocation.calledOnceWithExactly('Known location'), 'coordinate strings bypass geocoding'); + assert.deepEqual(calculateRoute.firstCall.args[0].locations, Array(4).fill({ lat: 0, lng: 0 }), 'valid zero coordinates are passed unchanged'); + assert.strictEqual(getRouteSource().getFeatures().length, 1, 'the route is rendered'); + }); + + QUnit.test('missing callback warns without drawing a straight line', async function(assert) { + const log = sinon.stub(errors, 'log'); + const onRouteAdded = sinon.spy(); + const onRouteRemoved = sinon.spy(); + try { + const map = await createMap({ providerConfig: { tileServer }, onRouteAdded, onRouteRemoved }); + const result = await map.addRoute(route); + assert.ok(log.calledOnceWithExactly('W1033'), 'missing callback is reported'); + assert.strictEqual(result, undefined, 'skipped route has no instance'); + assert.ok(onRouteAdded.notCalled, 'skipped route fires no added event'); + assert.strictEqual(map._provider._routes.length, 0, 'skipped route is not stored in the provider'); + assert.strictEqual(openLayersMock.addedVectorLayers.length, 0, 'no fallback line is drawn'); + await map.addMarker({ location: [40.7, -74] }); + await map.removeRoute(route); + assert.ok(onRouteRemoved.notCalled, 'removing skipped route options fires no removed event'); + assert.deepEqual(map.option('routes'), [], 'skipped route options can still be removed'); + assert.strictEqual(openLayersMock.addedOverlays.length, 1, 'map continues to accept operations'); + } finally { + log.restore(); + } + }); + + QUnit.test('missing route callback warns once for initial and subsequently added routes', async function(assert) { + const log = sinon.stub(errors, 'log'); + try { + const map = await createMap({ + routes: [route, { ...route, mode: 'walking' }], + providerConfig: { tileServer } + }); + assert.ok(log.calledOnceWithExactly('W1033'), 'initial routes share one configuration warning'); + + await map.addRoute({ ...route }); + await map.addRoute({ ...route, mode: 'walking' }); + assert.ok(log.calledOnceWithExactly('W1033'), 'later attempts do not repeat the warning'); + } finally { + log.restore(); + } + }); + + QUnit.test('a route callback configured after a warning is still called', async function(assert) { + const log = sinon.stub(errors, 'log'); + const providerConfig = {}; + const provider = new OsmProvider({ option: () => ({ providerConfig }) }, null); + const calculateRoute = sinon.stub().returns(Promise.resolve(path)); + try { + assert.strictEqual(await provider._calculateRoute(route), undefined, 'unconfigured route is skipped'); + providerConfig.calculateRoute = calculateRoute; + + const locations = await provider._calculateRoute(route); + assert.ok(calculateRoute.calledOnceWithExactly({ + locations: [{ lat: 40.7, lng: -74 }, { lat: 40.8, lng: -73.9 }], + mode: 'driving' + }), 'warning suppression does not skip a configured callback'); + assert.deepEqual(locations, path.map(([lat, lng]) => ({ lat, lng })), 'route geometry is returned'); + assert.ok(log.calledOnceWithExactly('W1033'), 'only the missing configuration was reported'); + } 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'); + const onRouteAdded = sinon.spy(); + const onRouteRemoved = sinon.spy(); + try { + const map = await createMap({ routes: [route], providerConfig: { tileServer, calculateRoute }, onRouteAdded, onRouteRemoved }); + 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'); + assert.ok(onRouteAdded.notCalled, 'failed route fires no added event'); + assert.strictEqual(map._provider._routes.length, 0, 'failed route is not stored in the provider'); + await map.removeRoute(route); + assert.ok(onRouteRemoved.notCalled, 'failed route fires no removed event'); + 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'); + assert.ok(onRouteAdded.calledOnce, 'only the successful route fires an added event'); + } finally { + log.restore(); + } + }); + }); + + QUnit.test('mixed route results preserve result positions and only register successful routes', async function(assert) { + const firstRoute = { ...route, color: 'red' }; + const skippedRoute = { ...route, mode: 'walking' }; + const lastRoute = { ...route, color: 'green' }; + const calculateRoute = sinon.stub().callsFake(({ mode }) => Promise.resolve(mode === 'walking' ? undefined : path)); + const onRouteAdded = sinon.spy(); + const onRouteRemoved = sinon.spy(); + const log = sinon.stub(errors, 'log'); + try { + const map = await createMap({ providerConfig: { tileServer, calculateRoute }, onRouteAdded, onRouteRemoved }); + const results = await map.addRoute([firstRoute, skippedRoute, lastRoute]); + assert.strictEqual(results.length, 3, 'result positions match the requested routes'); + assert.strictEqual(results[1], undefined, 'skipped result retains its position'); + assert.deepEqual(getRouteSource().getFeatures(), [results[0], results[2]], 'only successful routes have features'); + assert.deepEqual(map._provider._routes.map(item => item.options), [firstRoute, lastRoute], 'only successful routes are registered'); + assert.strictEqual(onRouteAdded.callCount, 2, 'only successful routes fire added events'); + assert.ok(onRouteAdded.alwaysCalledWithMatch({ originalRoute: sinon.match.truthy }), 'added events always expose a route instance'); + await map.removeRoute(skippedRoute); + assert.ok(onRouteRemoved.notCalled, 'removing skipped route options fires no event'); + assert.deepEqual(map.option('routes'), [firstRoute, lastRoute], 'successful route options remain'); + map.option('routes', []); + await map._lastAsyncAction; + assert.strictEqual(onRouteRemoved.callCount, 2, 'both created routes fire removed events'); + assert.strictEqual(getRouteSource().getFeatures().length, 0, 'all route features are removed'); + } 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('marker resize refits cached route bounds without visiting every vertex', async function(assert) { + let markerSize = { height: 30, width: 20 }; + openLayersMock.getOverlayRect = () => markerSize; + const calculateRoute = sinon.stub().resolves(Array.from({ length: 100 }, (_, index) => path[index % path.length])); + const map = await createMap({ + autoAdjust: true, + routes: [route], + markers: [{ location: [40.6, -74.1] }], + providerConfig: { tileServer, calculateRoute } + }); + const extendBounds = sinon.spy(map._provider, '_extendBounds'); + const markerElement = openLayersMock.addedOverlays[0].options.element; + const initialFitCallCount = openLayersMock.fitCallCount; + + try { + markerSize = { height: 60, width: 40 }; + triggerResize(markerElement); + markerSize = { height: 90, width: 60 }; + triggerResize(markerElement); + + assert.strictEqual(openLayersMock.fitCallCount, initialFitCallCount + 2, 'both size changes refit the view'); + assert.strictEqual(extendBounds.callCount, 6, 'each refit uses one marker and two route corners'); + assert.deepEqual(openLayersMock.fittedExtent, [-74100, 40600, -73800, 40900], 'cached bounds still include intermediate route points'); + assert.ok(calculateRoute.calledOnce, 'resizing does not request the route again'); + } finally { + extendBounds.restore(); + } + }); + + QUnit.test('autoAdjust uses cached route bounds when enabled after rendering', async function(assert) { + const calculateRoute = sinon.stub().resolves(path); + const map = await createMap({ + routes: [route], + providerConfig: { tileServer, calculateRoute } + }); + + map.option('autoAdjust', true); + await map._lastAsyncAction; + + assert.deepEqual(openLayersMock.fittedExtent, [-74000, 40700, -73800, 40900], 'route bounds are available even when rendered without autoAdjust'); + assert.ok(calculateRoute.calledOnce, 'enabling autoAdjust does not recalculate the route'); + }); + + QUnit.test('replacing and removing routes does not retain old route bounds', async function(assert) { + const map = await createMap({ + autoAdjust: true, + routes: [route], + markers: [{ location: [5, 178] }], + providerConfig: { tileServer, calculateRoute: ({ locations }) => Promise.resolve(locations.map(({ lat, lng }) => [lat, lng])) } + }); + const replacementRoute = { locations: [[10, 179], [30, -178], [-20, -179], [15, 178]] }; + + map.option('routes', [replacementRoute]); + await map._lastAsyncAction; + + assert.deepEqual(openLayersMock.fittedExtent, [178000, -20000, 182000, 30000], 'only replacement route bounds and the marker are fitted'); + + await map.removeRoute(replacementRoute); + assert.strictEqual(getRouteSource().getFeatures().length, 0, 'replacement route is removed'); + + await map.addMarker({ location: [7, 179] }); + + assert.deepEqual(openLayersMock.fittedExtent, [178000, 5000, 179000, 7000], 'the next fit includes only markers and no removed route bounds'); + }); + + 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 }); + const pending = map.addRoute(route); + await started; + map.dispose(); + completeRoute(path); + assert.strictEqual(await pending, undefined, 'the public operation completes without an instance'); + 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', + attribution: 'Example attribution' + }; + + QUnit.test('only visible markers participate in sequential keyboard navigation', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + autoAdjust: false, + markers: [{ + location: [40.74, -73.98], + onClick: () => {} + }, { + location: [50, 10], + onClick: () => {} + }], + providerConfig: { + tileServer + }, + onReady: () => { + const visibleMarker = openLayersMock.addedOverlays[0].options.element; + const hiddenMarker = openLayersMock.addedOverlays[1].options.element; + assert.strictEqual(visibleMarker.getAttribute('tabindex'), '0', 'visible marker is keyboard-focusable'); + assert.strictEqual(hiddenMarker.getAttribute('tabindex'), '-1', 'offscreen marker is excluded from the tab order'); + + visibleMarker.focus(); + openLayersMock.viewExtent = [9000, 49900, 10100, 50100]; + openLayersMock.mapInstance.trigger('moveend'); + + assert.strictEqual(visibleMarker.getAttribute('tabindex'), '-1', 'marker leaving the viewport is excluded from the tab order'); + assert.strictEqual(hiddenMarker.getAttribute('tabindex'), '0', 'marker entering the viewport returns to the tab order'); + assert.strictEqual(document.activeElement, getOpenLayersMapTarget(), 'focus returns to the map without panning'); + done(); + } + }); + }); + QUnit.test('marker focus is managed inside Shadow DOM', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const host = document.createElement('div'); + const shadowRoot = host.attachShadow({ mode: 'open' }); + const container = document.createElement('div'); + shadowRoot.appendChild(container); + $('#qunit-fixture').append(host); + const engineMap = engine.createMap(container); + engineMap.attachHandlers({ + click: () => {}, + markerSizeChange: () => {}, + viewChange: () => {} + }); + engineMap.addMarker({ + location: { + lat: 40.74, + lng: -73.98 + }, + onClick: () => {} + }); + const markerElement = openLayersMock.addedOverlays[0].options.element; + markerElement.focus(); + assert.strictEqual(shadowRoot.activeElement, markerElement, 'visible marker receives focus inside Shadow DOM'); + + openLayersMock.viewExtent = [0, 0, 100, 100]; + openLayersMock.mapInstance.trigger('moveend'); + assert.strictEqual(markerElement.getAttribute('tabindex'), '-1', 'offscreen marker leaves the tab order'); + assert.strictEqual(shadowRoot.activeElement, container, 'focus returns to the Shadow DOM map target'); + + engineMap.dispose(); + }); + QUnit.test('focus options are applied to the OpenLayers keyboard target', function(assert) { + const done = assert.async(); + const map = $('#map').dxMap({ + provider: 'osm', + focusStateEnabled: false, + tabIndex: 5, + markers: [{ + location: [40.74, -73.98], + onClick: () => {} + }, { + location: [40.75, -73.97], + html: 'Custom marker', + onClick: () => {} + }], + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + const target = getOpenLayersKeyboardTarget(); + const markerElement = openLayersMock.addedOverlays[0].options.element; + const customButton = openLayersMock.addedOverlays[1].options.element.querySelector('button'); + assert.strictEqual(target.getAttribute('tabindex'), null, 'focus is disabled on initialization'); + assert.strictEqual(markerElement.getAttribute('tabindex'), '-1', 'marker is removed from the tab order on initialization'); + assert.strictEqual(customButton.getAttribute('tabindex'), '-1', 'custom interactive content is removed from the tab order'); + map.option('onUpdated', () => { + assert.strictEqual(target.getAttribute('tabindex'), '5', 'configured tabIndex is applied'); + assert.strictEqual(markerElement.getAttribute('tabindex'), '0', 'marker focus is enabled'); + assert.strictEqual(customButton.getAttribute('tabindex'), null, 'custom interactive content returns to the tab order'); + map.option('onUpdated', () => { + assert.strictEqual(target.getAttribute('tabindex'), '-1', 'runtime tabIndex is applied'); + map.option('onUpdated', () => { + assert.strictEqual(target.getAttribute('tabindex'), null, 'runtime focus disabling is applied'); + assert.strictEqual(markerElement.getAttribute('tabindex'), '-1', 'marker is removed from the tab order at runtime'); + assert.strictEqual(customButton.getAttribute('tabindex'), '-1', 'custom interactive content leaves the tab order'); + done(); + }); + map.option('focusStateEnabled', false); + }); + map.option('tabIndex', -1); + }); + map.option('focusStateEnabled', true); + } + }).dxMap('instance'); + }); + QUnit.test('OpenLayers view changes update center, fractional zoom, and bounds', function(assert) { + const done = assert.async(); + const map = $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + const view = openLayersMock.mapInstance.getView(); + view.setCenter([-73980, 40740]); + view.setZoom(12.5); + const centerSetCount = openLayersMock.viewCenterSetCount; + const zoomSetCount = openLayersMock.viewZoomSetCount; + openLayersMock.viewExtent = [-74100, 40600, -73800, 40900]; + openLayersMock.mapInstance.trigger('moveend'); + assert.deepEqual(map.option('center'), { + lat: 40.74, + lng: -73.98 + }, 'center is synchronized'); + assert.strictEqual(map.option('zoom'), 12.5, 'fractional zoom is synchronized'); + assert.deepEqual(map.option('bounds'), { + northEast: { + lat: 40.9, + lng: -73.8 + }, + southWest: { + lat: 40.6, + lng: -74.1 + } + }, 'bounds are synchronized'); + assert.strictEqual(openLayersMock.viewCenterSetCount, centerSetCount, 'center is not written back to OpenLayers'); + assert.strictEqual(openLayersMock.viewZoomSetCount, zoomSetCount, 'zoom is not written back to OpenLayers'); + done(); + } + }).dxMap('instance'); + }); + QUnit.test('OpenLayers click fires onClick with location and original event', function(assert) { + const done = assert.async(); + const originalEvent = new PointerEvent('click'); + const map = $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onClick: event => { + assert.strictEqual(event.component, map, 'component is passed'); + assert.deepEqual(event.location, { + lat: 40.74, + lng: -73.98 + }, 'location is normalized'); + assert.strictEqual(event.event, originalEvent, 'original event is passed'); + done(); + }, + onReady: () => { + openLayersMock.mapInstance.trigger('click', { + coordinate: [-73980, 40740], + originalEvent + }); + } + }).dxMap('instance'); + }); + QUnit.test('OpenLayers click without a coordinate is ignored', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const engineMap = engine.createMap(document.createElement('div')); + const click = sinon.spy(); + engineMap.attachHandlers({ + click, + viewChange: sinon.spy() + }); + openLayersMock.mapInstance.trigger('click', { + originalEvent: new PointerEvent('click') + }); + assert.ok(click.notCalled, 'click handler is not called without a location'); + engineMap.dispose(); + }); + QUnit.test('OpenLayers user projection is honored for the initial view, synchronization, and bounds', function(assert) { + const done = assert.async(); + openLayersMock.userProjection = 'EPSG:4326'; + const map = $('#map').dxMap({ + provider: 'osm', + center: { + lat: 40.74, + lng: -73.98 + }, + zoom: 12.5, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + const view = openLayersMock.mapInstance.getView(); + assert.deepEqual(openLayersMock.viewCenter, [-73.98, 40.74], 'initial center uses the user projection'); + view.setCenter([-73.97, 40.75]); + openLayersMock.viewExtent = [-74.1, 40.6, -73.8, 40.9]; + openLayersMock.mapInstance.trigger('moveend'); + assert.deepEqual(map.option('center'), { + lat: 40.75, + lng: -73.97 + }, 'center is synchronized from the user projection'); + assert.deepEqual(map.option('bounds'), { + northEast: { + lat: 40.9, + lng: -73.8 + }, + southWest: { + lat: 40.6, + lng: -74.1 + } + }, 'bounds are synchronized from the user projection'); + map.option('onUpdated', () => { + assert.deepEqual(openLayersMock.fittedExtent, [-74, 40.7, -73.9, 40.8], 'bounds are fitted in the user projection'); + done(); + }); + map.option('bounds', { + northEast: { + lat: 40.8, + lng: -73.9 + }, + southWest: { + lat: 40.7, + lng: -74 + } + }); + } + }).dxMap('instance'); + }); + QUnit.test('bounds crossing the antimeridian use the shorter wrapped extent', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + bounds: { + northEast: { + lat: 10, + lng: -170 + }, + southWest: { + lat: -10, + lng: 170 + } + }, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + assert.deepEqual(openLayersMock.fittedExtent, [170000, -10000, 190000, 10000], 'the wrapped 20 degree extent is fitted'); + done(); + } + }); + }); + QUnit.test('OpenLayers wrapped view coordinates are normalized on synchronization', function(assert) { + const done = assert.async(); + const map = $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + openLayersMock.mapInstance.getView().setCenter([190000, 0]); + openLayersMock.viewExtent = [170000, -10000, 190000, 10000]; + openLayersMock.mapInstance.trigger('moveend'); + assert.deepEqual(map.option('center'), { + lat: 0, + lng: -170 + }, 'center longitude is normalized'); + assert.deepEqual(map.option('bounds'), { + northEast: { + lat: 10, + lng: -170 + }, + southWest: { + lat: -10, + lng: 170 + } + }, 'bounds preserve the antimeridian crossing'); + done(); + } + }).dxMap('instance'); + }); + QUnit.test('controls option toggles the OpenLayers zoom control', function(assert) { + const done = assert.async(); + const map = $('#map').dxMap({ + provider: 'osm', + controls: true, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + assert.strictEqual(openLayersMock.addedControls.length, 1, 'zoom control is added on initialization'); + map.option('onUpdated', () => { + assert.strictEqual(openLayersMock.removedControls.length, 1, 'zoom control is removed'); + map.option('onUpdated', () => { + assert.strictEqual(openLayersMock.addedControls.length, 2, 'zoom control is added again'); + done(); + }); + map.option('controls', true); + }); + map.option('controls', false); + } + }).dxMap('instance'); + }); + QUnit.test('disabled option restores the previous interaction states', function(assert) { + const done = assert.async(); + const map = $('#map').dxMap({ + provider: 'osm', + controls: true, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + assert.strictEqual(openLayersMock.addedControls.length, 1, 'zoom control is present'); + assert.deepEqual(openLayersMock.interactions.map(interaction => interaction.getActive()), [true, false], 'initial states are preserved'); + assert.notOk(getOpenLayersMapTarget().hasAttribute('inert'), 'map target is keyboard accessible'); + onInteractionStates([false, false], () => { + assert.deepEqual(openLayersMock.interactions.map(interaction => interaction.getActive()), [false, false], 'all interactions are disabled'); + assert.ok(getOpenLayersMapTarget().hasAttribute('inert'), 'map target and controls are removed from keyboard navigation'); + assert.strictEqual(getOpenLayersKeyboardTarget().getAttribute('tabindex'), null, 'owned keyboard target tabindex is removed'); + onInteractionStates([true, false], () => { + assert.deepEqual(openLayersMock.interactions.map(interaction => interaction.getActive()), [true, false], 'previous states are restored'); + assert.notOk(getOpenLayersMapTarget().hasAttribute('inert'), 'map target and controls return to keyboard navigation'); + assert.strictEqual(getOpenLayersKeyboardTarget().getAttribute('tabindex'), '0', 'owned keyboard target tabindex is restored'); + done(); + }); + map.option('disabled', false); + }); + map.option('disabled', true); + } + }).dxMap('instance'); + }); + QUnit.test('disabled option is applied on initialization', function(assert) { + const done = assert.async(); + onInteractionStates([false, false], () => { + assert.strictEqual(openLayersMock.addedControls.length, 1, 'zoom control is present'); + assert.deepEqual(openLayersMock.interactions.map(interaction => interaction.getActive()), [false, false], 'all interactions are disabled'); + assert.ok(getOpenLayersMapTarget().hasAttribute('inert'), 'map target and controls are removed from keyboard navigation'); + assert.strictEqual(getOpenLayersKeyboardTarget().getAttribute('tabindex'), null, 'owned keyboard target tabindex is removed'); + map.option('onUpdated', done); + map.option('disabled', false); + }); + const map = $('#map').dxMap({ + provider: 'osm', + controls: true, + disabled: true, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + } + }).dxMap('instance'); + }); + QUnit.test('disabled state removes clickable markers from keyboard navigation', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const engineMap = engine.createMap(document.createElement('div')); + engineMap.addMarker({ + location: { + lat: 40.74, + lng: -73.98 + }, + onClick: () => {} + }); + const markerElement = openLayersMock.addedOverlays[0].options.element; + + assert.strictEqual(markerElement.getAttribute('tabindex'), '0', 'clickable marker starts in the tab order'); + engineMap.setDisabled(true); + assert.strictEqual(markerElement.getAttribute('tabindex'), '-1', 'disabled marker leaves the tab order'); + engineMap.setDisabled(false); + assert.strictEqual(markerElement.getAttribute('tabindex'), '0', 'marker returns to the tab order'); + + engineMap.dispose(); + }); + QUnit.test('bounds option fits the OpenLayers view on initialization and at runtime', function(assert) { + const done = assert.async(); + const map = $('#map').dxMap({ + provider: 'osm', + bounds: { + northEast: { + lat: 40.8, + lng: -73.9 + }, + southWest: { + lat: 40.7, + lng: -74 + } + }, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + assert.deepEqual(openLayersMock.fittedExtent, [-74000, 40700, -73900, 40800], 'initial bounds are fitted'); + assert.strictEqual(openLayersMock.fitOptions, undefined, 'OpenLayers selects the viewport size'); + map.option('onUpdated', () => { + assert.deepEqual(openLayersMock.fittedExtent, [-74100, 40600, -73800, 40900], 'runtime bounds are fitted'); + done(); + }); + map.option('bounds', { + northEast: [40.9, -73.8], + southWest: [40.6, -74.1] + }); + } + }).dxMap('instance'); + }); + QUnit.test('incomplete bounds do not change the OpenLayers view', function(assert) { + const done = assert.async(); + const map = $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + map.option('onUpdated', () => { + assert.strictEqual(openLayersMock.fittedExtent, null, 'view is not fitted without both bounds'); + done(); + }); + map.option('bounds', { + northEast: { + lat: 40.8, + lng: -73.9 + }, + southWest: null + }); + } + }).dxMap('instance'); + }); + QUnit.test('RTL mode preserves viewport, overlay positioning, and keyboard behavior', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + center: { + lat: 40.74, + lng: -73.98 + }, + controls: true, + markers: [{ + location: { + lat: 40.74, + lng: -73.98 + }, + html: 'Marker text' + }], + rtlEnabled: true, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + assert.ok($('#map').hasClass('dx-rtl'), 'RTL mode is applied to the widget'); + assert.deepEqual(openLayersMock.viewCenter, [-73980, 40740], 'center coordinates are not mirrored'); + assert.strictEqual(openLayersMock.addedOverlays.length, 1, 'marker overlay remains available'); + const markerElement = openLayersMock.addedOverlays[0].options.element; + assert.strictEqual(markerElement.getAttribute('dir'), 'rtl', 'HTML marker uses the widget text direction'); + assert.strictEqual(getComputedStyle(markerElement.querySelector('.rtl-marker-text')).direction, 'rtl', 'HTML marker text inherits RTL direction'); + assert.strictEqual(openLayersMock.overlayContainer.getAttribute('dir'), 'ltr', 'regular overlays use LTR coordinates'); + assert.strictEqual(openLayersMock.overlayContainerStopEvent.getAttribute('dir'), 'ltr', 'interactive overlays use LTR coordinates'); + assert.strictEqual(openLayersMock.addedControls.length, 1, 'zoom control remains available'); + assert.strictEqual(getOpenLayersKeyboardTarget().getAttribute('tabindex'), '0', 'map remains keyboard focusable'); + done(); + } + }); + }); + QUnit.test('dispose detaches OpenLayers event handlers', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const engineMap = engine.createMap(document.createElement('div')); + const click = sinon.spy(); + const viewChange = sinon.spy(); + engineMap.attachHandlers({ + click, + viewChange + }); + engineMap.dispose(); + openLayersMock.mapInstance.trigger('click', { + coordinate: [-73980, 40740] + }); + openLayersMock.mapInstance.trigger('moveend'); + assert.ok(click.notCalled, 'click handler is detached'); + assert.ok(viewChange.notCalled, 'view change handler is detached'); + }); +}); diff --git a/packages/devextreme/ts/dx.all.d.ts b/packages/devextreme/ts/dx.all.d.ts index 36d0fd1babca..134bf60958c8 100644 --- a/packages/devextreme/ts/dx.all.d.ts +++ b/packages/devextreme/ts/dx.all.d.ts @@ -23378,6 +23378,13 @@ declare module DevExpress.ui { ): DevExpress.core.utils.DxPromise; } module dxMap { + /** + * [descr:CalculateOsmRouteInfo] + */ + export type CalculateOsmRouteInfo = { + locations: Array; + mode: RouteMode | string; + }; /** * [descr:_ui_map_ClickEvent] */ @@ -23399,7 +23406,12 @@ declare module DevExpress.ui { */ export type InitializedEvent = DevExpress.common.core.events.InitializedEventInfo; - export type MapProvider = 'azure' | 'bing' | 'google' | 'googleStatic'; + export type MapProvider = + | 'azure' + | 'bing' + | 'google' + | 'googleStatic' + | 'osm'; export type MapType = 'hybrid' | 'roadmap' | 'satellite'; /** * [descr:_ui_map_MarkerAddedEvent] @@ -23415,6 +23427,20 @@ declare module DevExpress.ui { */ originalMarker: any; }; + /** + * [descr:_ui_map_MarkerClickEvent] + */ + export type MarkerClickEvent = + DevExpress.common.core.events.EventInfo & { + /** + * [descr:_ui_map_MarkerClickEvent.location] + */ + location: MapLocation; + /** + * [descr:_ui_map_MarkerClickEvent.tooltip] + */ + tooltip?: dxPopover; + }; /** * [descr:_ui_map_MarkerRemovedEvent] */ @@ -23431,6 +23457,45 @@ declare module DevExpress.ui { export type OptionChangedEvent = DevExpress.common.core.events.EventInfo & DevExpress.common.core.events.ChangedOptionInfo; + /** + * [descr:OsmGeoJsonLineString] + */ + export type OsmGeoJsonLineString = { + type: 'LineString'; + coordinates: Array>; + }; + /** + * [descr:OsmRouteResult] + */ + export type OsmRouteResult = Array<[number, number]> | OsmGeoJsonLineString; + /** + * [descr:OsmTileServer] + */ + export type OsmTileServer = + | string + | OsmTileServerConfig + | ((type: MapType) => string | OsmTileServerConfig | undefined); + /** + * [descr:OsmTileServerConfig] + */ + export type OsmTileServerConfig = { + /** + * [descr:OsmTileServerConfig.url] + */ + url: string; + /** + * [descr:OsmTileServerConfig.attribution] + */ + attribution?: string; + /** + * [descr:OsmTileServerConfig.subdomains] + */ + subdomains?: string | Array; + /** + * [descr:OsmTileServerConfig.maxZoom] + */ + maxZoom?: number; + }; export type Properties = dxMapOptions; /** * [descr:_ui_map_ReadyEvent] @@ -23536,7 +23601,7 @@ declare module DevExpress.ui { /** * [descr:dxMapOptions.markers.onClick] */ - onClick?: Function; + onClick?: (e: DevExpress.ui.dxMap.MarkerClickEvent) => void; /** * [descr:dxMapOptions.markers.tooltip] */ @@ -23602,6 +23667,22 @@ declare module DevExpress.ui { * @deprecated [depNote:dxMapOptions.providerConfig.useAdvancedMarkers] */ useAdvancedMarkers?: boolean; + /** + * [descr:dxMapOptions.providerConfig.tileServer] + */ + tileServer?: DevExpress.ui.dxMap.OsmTileServer | undefined; + /** + * [descr:dxMapOptions.providerConfig.calculateLocation] + */ + calculateLocation?: ( + query: string + ) => PromiseLike; + /** + * [descr:dxMapOptions.providerConfig.calculateRoute] + */ + calculateRoute?: ( + params: DevExpress.ui.dxMap.CalculateOsmRouteInfo + ) => PromiseLike; }; /** * [descr:dxMapOptions.routes] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba9eb5c4e6ed..7224bc2849ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -845,6 +845,9 @@ importers: http-server: specifier: 14.1.1 version: 14.1.1(supports-color@7.2.0) + ol: + specifier: 10.10.0 + version: 10.10.0 prop-types: specifier: 15.8.1 version: 15.8.1 @@ -6579,6 +6582,9 @@ packages: resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} engines: {node: '>=20.0.0'} + '@petamoriken/float16@3.9.3': + resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==} + '@phenomnomnominal/tsquery@6.2.0': resolution: {integrity: sha512-Vo9nkhfZxDB/sBiqIY3pjDC4mOSyure+AFlEW5hcy/tRE82MqCXjRN4InnVNMldinRt0dLYqg4HAU2XPq5e1LA==} peerDependencies: @@ -7732,6 +7738,9 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/rbush@4.0.0': + resolution: {integrity: sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==} + '@types/react-dom@17.0.26': resolution: {integrity: sha512-Z+2VcYXJwOqQ79HreLU/1fyQ88eXSSFh6I3JdrEHQIfYSI0kCQpTGvOrbE6jFGGYXKsHuwY9tBa/w5Uo6KzrEg==} peerDependencies: @@ -8480,6 +8489,9 @@ packages: '@yarnpkg/lockfile@1.1.0': resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==} + '@zarrita/storage@0.2.0': + resolution: {integrity: sha512-855ZXqtnds7spnT8vNvD+MXa3QExP1m2GqShe8yt7uZXHnQLgJHgkpVwFjE1B0KDDRO0ki09hmk6OboTaIfPsQ==} + '@zkochan/js-yaml@0.0.7': resolution: {integrity: sha512-nrUSn7hzt7J6JWgWGz78ZYI8wj+gdIJdk0Ynjpp8l+trkn58Uqsf6RYrYkEK+3X18EX+TNdtJI0WxAtc+L84SQ==} hasBin: true @@ -10244,6 +10256,9 @@ packages: duplexer2@0.1.4: resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + earcut@3.2.3: + resolution: {integrity: sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -11127,6 +11142,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + geotiff@3.1.0-beta.0: + resolution: {integrity: sha512-wbVwOaQYuN+ywly5NoMXn37nHslwkQg5EFHqMeUTtjDACrSag0EEVlFsc+2DK3Pzmmp6XzJW2RTZOamPsBM6ng==} + engines: {node: '>=10.19'} + get-amd-module-type@5.0.1: resolution: {integrity: sha512-jb65zDeHyDjFR1loOVk0HQGM5WNwoGB8aLWy3LKCieMKol0/ProHkhO2X1JxojuN10vbz1qNn09MJ7tNp7qMzw==} engines: {node: '>=14'} @@ -12513,6 +12532,9 @@ packages: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} + lerc@3.0.0: + resolution: {integrity: sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==} + less-loader@12.3.1: resolution: {integrity: sha512-JZZmG7gMzoDP3VGeEG8Sh6FW5wygB5jYL7Wp29FFihuRTsIBacqO3LbRPr2yStYD11riVf13selLm/CPFRDBRQ==} engines: {node: '>= 18.12.0'} @@ -13525,6 +13547,9 @@ packages: nullthrows@1.1.1: resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} + numcodecs@0.3.2: + resolution: {integrity: sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==} + nwsapi@2.2.24: resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} @@ -13586,6 +13611,9 @@ packages: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} + ol@10.10.0: + resolution: {integrity: sha512-tLPKn6zl+6uWdPufYlqG/lQzuVUTVmfwahQqVr5+wZNyZecyAtIhMTyOtKpu7ooNDLY2sEjKZNXw9HL+sOjC1A==} + on-finished@2.3.0: resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} engines: {node: '>= 0.8'} @@ -13790,6 +13818,9 @@ packages: parse-color@1.0.0: resolution: {integrity: sha512-fuDHYgFHJGbpGMgw9skY/bj3HL/Jrn4l/5rSspy00DoT4RyLnDcRvPxdZ+r6OFwIsgAuhDh4I09tAId4mI12bw==} + parse-headers@2.0.6: + resolution: {integrity: sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==} + parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} @@ -13893,6 +13924,9 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pbf@5.1.2: + resolution: {integrity: sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==} + pbkdf2@3.1.7: resolution: {integrity: sha512-nS4mvFgVwUrecPTRdpdseM2fwpdfnX0/HibA8DgBRQtihUAzHQYJFU2l+OlEtiXtpqkrMvCPDwyXMUnASDt6Qw==} engines: {node: '>= 0.10'} @@ -14193,6 +14227,9 @@ packages: proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + protocol-buffers-schema@3.6.1: + resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==} + proxy-addr@2.0.8: resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} engines: {node: '>= 0.10'} @@ -14286,6 +14323,13 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-lru@6.1.2: + resolution: {integrity: sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==} + engines: {node: '>=12'} + + quickselect@3.0.0: + resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} + quill-delta@5.1.0: resolution: {integrity: sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==} engines: {node: '>= 12.0.0'} @@ -14323,6 +14367,9 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + rbush@4.0.1: + resolution: {integrity: sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==} + react-docgen-typescript@2.4.0: resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==} peerDependencies: @@ -14456,6 +14503,9 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + reference-spec-reader@0.2.0: + resolution: {integrity: sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==} + reflect-metadata@0.1.13: resolution: {integrity: sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==} @@ -14592,6 +14642,9 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve-protobuf-schema@2.1.0: + resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} + resolve-url-loader@5.0.0: resolution: {integrity: sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==} engines: {node: '>=12'} @@ -16104,6 +16157,10 @@ packages: unrs-resolver@1.12.2: resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} + unzipit@2.0.0: + resolution: {integrity: sha512-DVeVIWUZCAQPNzm5sB0hpsG1GygTTdBnzNtYYEpInkttx5evkyqRgZi6rTczoySqp8hO5jHVKzrH0f23X8FZLg==} + engines: {node: '>=18'} + unzipper@0.12.5: resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==} @@ -16433,6 +16490,9 @@ packages: resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} engines: {node: '>= 14'} + web-worker@1.5.0: + resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} + webdriver-bidi-protocol@0.4.2: resolution: {integrity: sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==} @@ -16747,6 +16807,9 @@ packages: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} + xml-utils@1.10.2: + resolution: {integrity: sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==} + xmlbuilder@12.0.0: resolution: {integrity: sha512-lMo8DJ8u6JRWp0/Y4XLa/atVDr75H9litKlb2E5j3V3MesoL50EBgZDWoLT3F/LztVnG67GjPXLZpqcky/UMnQ==} engines: {node: '>=6.0'} @@ -16837,6 +16900,9 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + zarrita@0.7.4: + resolution: {integrity: sha512-NChufj86SceFj45z4CYjndmI98dphaG7hU8Wj0UGpMyT21o0V3HTY6zo87aSobYWts1tNFivSXzH2AR1AkaWjA==} + zip-stream@7.0.5: resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==} engines: {node: '>=18'} @@ -16872,6 +16938,9 @@ packages: zone.js@0.16.2: resolution: {integrity: sha512-Eky7p2Z1Ig3NnbfodSPoARCjKBSTFMnE/ACsP1L/XJEfY4SdOFce19BsUCWVwL6K5ABZFy5J3bjcMWffX+YM3Q==} + zstddec@0.2.0: + resolution: {integrity: sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -22766,6 +22835,8 @@ snapshots: tslib: 2.8.1 tsyringe: 4.10.0 + '@petamoriken/float16@3.9.3': {} + '@phenomnomnominal/tsquery@6.2.0(typescript@5.9.3)': dependencies: '@types/esquery': 1.5.4 @@ -23778,6 +23849,8 @@ snapshots: '@types/range-parser@1.2.7': {} + '@types/rbush@4.0.0': {} + '@types/react-dom@17.0.26(@types/react@17.0.91)': dependencies: '@types/react': 17.0.91 @@ -24969,6 +25042,11 @@ snapshots: '@yarnpkg/lockfile@1.1.0': {} + '@zarrita/storage@0.2.0': + dependencies: + reference-spec-reader: 0.2.0 + unzipit: 2.0.0 + '@zkochan/js-yaml@0.0.7': dependencies: argparse: 2.0.1 @@ -27076,6 +27154,8 @@ snapshots: dependencies: readable-stream: 2.3.8 + earcut@3.2.3: {} + eastasianwidth@0.2.0: {} editorconfig@1.0.7: @@ -28590,6 +28670,17 @@ snapshots: gensync@1.0.0-beta.2: {} + geotiff@3.1.0-beta.0: + dependencies: + '@petamoriken/float16': 3.9.3 + lerc: 3.0.0 + pako: 2.1.0 + parse-headers: 2.0.6 + quick-lru: 6.1.2 + web-worker: 1.5.0 + xml-utils: 1.10.2 + zstddec: 0.2.0 + get-amd-module-type@5.0.1: dependencies: ast-module-types: 5.0.0 @@ -30667,6 +30758,8 @@ snapshots: dependencies: readable-stream: 2.3.8 + lerc@3.0.0: {} + less-loader@12.3.1(less@4.9.0)(webpack@5.105.2(@swc/core@1.15.30(@swc/helpers@0.5.21))(esbuild@0.28.1)): dependencies: less: 4.9.0 @@ -31871,6 +31964,10 @@ snapshots: nullthrows@1.1.1: {} + numcodecs@0.3.2: + dependencies: + fflate: 0.8.3 + nwsapi@2.2.24: {} nx@23.2.1(@swc/core@1.15.30(@swc/helpers@0.5.21)): @@ -32065,6 +32162,15 @@ snapshots: obug@2.1.4: {} + ol@10.10.0: + dependencies: + '@types/rbush': 4.0.0 + earcut: 3.2.3 + geotiff: 3.1.0-beta.0 + pbf: 5.1.2 + rbush: 4.0.1 + zarrita: 0.7.4 + on-finished@2.3.0: dependencies: ee-first: 1.1.1 @@ -32345,6 +32451,8 @@ snapshots: dependencies: color-convert: 0.5.3 + parse-headers@2.0.6: {} + parse-json@5.2.0: dependencies: '@babel/code-frame': 7.29.7 @@ -32440,6 +32548,10 @@ snapshots: pathval@2.0.1: {} + pbf@5.1.2: + dependencies: + resolve-protobuf-schema: 2.1.0 + pbkdf2@3.1.7: dependencies: create-hash: 1.2.0 @@ -32739,6 +32851,8 @@ snapshots: proto-list@1.2.4: {} + protocol-buffers-schema@3.6.1: {} + proxy-addr@2.0.8: dependencies: forwarded: 0.2.0 @@ -32830,6 +32944,10 @@ snapshots: queue-microtask@1.2.3: {} + quick-lru@6.1.2: {} + + quickselect@3.0.0: {} + quill-delta@5.1.0: dependencies: fast-diff: 1.3.0 @@ -32875,6 +32993,10 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + rbush@4.0.1: + dependencies: + quickselect: 3.0.0 + react-docgen-typescript@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -33037,6 +33159,8 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + reference-spec-reader@0.2.0: {} + reflect-metadata@0.1.13: {} reflect-metadata@0.2.2: {} @@ -33194,6 +33318,10 @@ snapshots: resolve-pkg-maps@1.0.0: {} + resolve-protobuf-schema@2.1.0: + dependencies: + protocol-buffers-schema: 3.6.1 + resolve-url-loader@5.0.0: dependencies: adjust-sourcemap-loader: 4.0.0 @@ -35456,6 +35584,8 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 + unzipit@2.0.0: {} + unzipper@0.12.5: dependencies: bluebird: 3.7.2 @@ -35816,6 +35946,8 @@ snapshots: web-streams-polyfill@4.0.0-beta.3: {} + web-worker@1.5.0: {} + webdriver-bidi-protocol@0.4.2: {} webidl-conversions@3.0.1: {} @@ -36488,6 +36620,8 @@ snapshots: xml-name-validator@5.0.0: {} + xml-utils@1.10.2: {} + xmlbuilder@12.0.0: {} xmlchars@2.2.0: {} @@ -36575,6 +36709,11 @@ snapshots: yoctocolors@2.1.2: {} + zarrita@0.7.4: + dependencies: + '@zarrita/storage': 0.2.0 + numcodecs: 0.3.2 + zip-stream@7.0.5: dependencies: compress-commons: 7.0.1 @@ -36607,4 +36746,6 @@ snapshots: zone.js@0.16.2: {} + zstddec@0.2.0: {} + zwitch@2.0.4: {}