From 67bd0856dcea1f420367274b3ee62b6819a229fd Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Mon, 31 Aug 2026 19:33:45 +0300 Subject: [PATCH 1/9] Map: add marker accessibility label --- packages/devextreme/js/localization/messages/ar.json | 2 ++ packages/devextreme/js/localization/messages/bg.json | 2 ++ packages/devextreme/js/localization/messages/ca.json | 2 ++ packages/devextreme/js/localization/messages/cs.json | 2 ++ packages/devextreme/js/localization/messages/da.json | 2 ++ packages/devextreme/js/localization/messages/de.json | 2 ++ packages/devextreme/js/localization/messages/el.json | 2 ++ packages/devextreme/js/localization/messages/en.json | 2 ++ packages/devextreme/js/localization/messages/es.json | 2 ++ packages/devextreme/js/localization/messages/fa.json | 2 ++ packages/devextreme/js/localization/messages/fi.json | 2 ++ packages/devextreme/js/localization/messages/fr.json | 2 ++ packages/devextreme/js/localization/messages/hi.json | 2 ++ packages/devextreme/js/localization/messages/hu.json | 2 ++ packages/devextreme/js/localization/messages/it.json | 2 ++ packages/devextreme/js/localization/messages/ja.json | 2 ++ packages/devextreme/js/localization/messages/ko.json | 2 ++ packages/devextreme/js/localization/messages/lt.json | 2 ++ packages/devextreme/js/localization/messages/lv.json | 2 ++ packages/devextreme/js/localization/messages/nb.json | 2 ++ packages/devextreme/js/localization/messages/nl.json | 2 ++ packages/devextreme/js/localization/messages/pl.json | 2 ++ packages/devextreme/js/localization/messages/pt-BR.json | 2 ++ packages/devextreme/js/localization/messages/pt-PT.json | 2 ++ packages/devextreme/js/localization/messages/pt.json | 2 ++ packages/devextreme/js/localization/messages/ro.json | 2 ++ packages/devextreme/js/localization/messages/ru.json | 2 ++ packages/devextreme/js/localization/messages/sl.json | 2 ++ packages/devextreme/js/localization/messages/sv.json | 2 ++ packages/devextreme/js/localization/messages/tr.json | 2 ++ packages/devextreme/js/localization/messages/uk.json | 2 ++ packages/devextreme/js/localization/messages/vi.json | 2 ++ packages/devextreme/js/localization/messages/zh-CN.json | 2 ++ packages/devextreme/js/localization/messages/zh-Hans.json | 2 ++ packages/devextreme/js/localization/messages/zh-tw.json | 2 ++ packages/devextreme/js/localization/messages/zh.json | 2 ++ 36 files changed, 72 insertions(+) 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 2bbf0909e3f8..e64af859a5bb 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 4db5191d535c..011aafa233da 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 8e2e2c24e948..fec24bfeb685 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": "MultiView", 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", From 97e1d2de51be217f9df0f93ecdc406a5130bfe88 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Mon, 31 Aug 2026 19:33:59 +0300 Subject: [PATCH 2/9] Map: add OSM markers and location calculation --- .../ui/map/provider.dynamic.osm.engine.ts | 24 +- .../provider.dynamic.osm.openlayers.marker.ts | 98 ++++++ .../ui/map/provider.dynamic.osm.openlayers.ts | 294 +++++++++++++++- .../provider.dynamic.osm.openlayers.utils.ts | 14 +- .../__internal/ui/map/provider.dynamic.osm.ts | 317 +++++++++++++++++- .../js/ui/map/openlayers.register.js | 2 + 6 files changed, 723 insertions(+), 26 deletions(-) create mode 100644 packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.marker.ts diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.engine.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.engine.ts index eb011504792e..608332c584f1 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.engine.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.engine.ts @@ -19,6 +19,10 @@ export interface MapEngineBounds { southWest: MapLocation; } +export interface MapEngineFitBoundsOptions { + includeMarkerPadding?: boolean; +} + export interface MapEngineViewState extends MapEngineSetViewOptions { bounds?: MapEngineBounds; } @@ -28,22 +32,38 @@ export interface MapEngineClickEvent { location: MapLocation; } +export interface MapEngineMarkerOptions { + html?: string; + htmlOffset?: { top: number; left: number }; + iconSrc?: string; + location: MapLocation; + onClick?: () => void; +} + +export interface MapEngineMarker { + readonly originalMarker: 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; attachHandlers: (handlers: MapEngineEventHandlers) => void; dispose: () => void; - fitBounds: (bounds: MapEngineBounds) => 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: () => void; + updateDimensions: () => boolean; } export interface MapEngine { 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..3e227416e52e --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.marker.ts @@ -0,0 +1,98 @@ +import type { MapEngineMarkerOptions } from './provider.dynamic.osm.engine'; + +export const MARKER_FALLBACK_WIDTH = 25; +export const MARKER_FALLBACK_HEIGHT = 41; +export const DEFAULT_MARKER_SIZE = 44; +export const DEFAULT_MARKER_CLASS = 'dx-map-marker-default'; + +const DEFAULT_MARKER_WIDTH = 24.5; +const DEFAULT_MARKER_HEIGHT = 36.5; +const DEFAULT_MARKER_COLOR = '#2d7fbd'; +const DEFAULT_MARKER_ACCENT_COLOR = '#fff'; +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(''); + +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 = `dx-map-marker ${DEFAULT_MARKER_CLASS}`; + element.style.alignItems = 'flex-end'; + element.style.display = 'flex'; + element.style.height = `${DEFAULT_MARKER_SIZE}px`; + element.style.justifyContent = 'center'; + element.style.width = `${DEFAULT_MARKER_SIZE}px`; + svg.setAttribute('class', 'dx-map-marker-default-icon'); + 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'); + svg.style.display = 'block'; + svg.style.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))', + ].join(' '); + svg.style.overflow = 'visible'; + body.setAttribute('class', 'dx-map-marker-default-body'); + body.setAttribute('d', DEFAULT_MARKER_BODY_PATH); + body.setAttribute('fill', DEFAULT_MARKER_COLOR); + body.setAttribute('stroke', DEFAULT_MARKER_ACCENT_COLOR); + body.setAttribute('stroke-width', '0.5'); + center.setAttribute('class', 'dx-map-marker-default-center'); + center.setAttribute('d', DEFAULT_MARKER_CENTER_PATH); + center.setAttribute('fill', DEFAULT_MARKER_ACCENT_COLOR); + svg.appendChild(body); + svg.appendChild(center); + element.appendChild(svg); + + return element; +}; + +export const createMarkerElement = ( + ownerDocument: Document, + options: MapEngineMarkerOptions, +): { element: HTMLElement; offset: number[]; positioning: string } => { + if (options.html) { + const element = ownerDocument.createElement('div'); + element.innerHTML = options.html; + + return { + element, + offset: [options.htmlOffset?.left ?? 0, options.htmlOffset?.top ?? 0], + positioning: 'top-left', + }; + } + + if (options.iconSrc) { + const element = ownerDocument.createElement('img'); + element.className = 'dx-map-marker dx-map-marker-image'; + element.src = options.iconSrc; + element.alt = ''; + element.draggable = false; + + return { element, offset: [0, 0], positioning: 'bottom-center' }; + } + + return { + element: createDefaultMarkerElement(ownerDocument), + offset: [0, 0], + positioning: 'bottom-center', + }; +}; diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts index c33e0d8cea10..9e90853ff764 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts @@ -1,14 +1,27 @@ +import messageLocalization from '@js/common/core/localization/message'; +import { ALL_FOCUSABLE_ELEMENTS_SELECTOR } from '@ts/core/utils/m_selectors'; + import type { MapEngine, MapEngineBounds, MapEngineClickEvent, MapEngineEventHandlers, + MapEngineFitBoundsOptions, MapEngineMap, + MapEngineMarker, + MapEngineMarkerOptions, MapEngineSetViewOptions, MapEngineTileLayerOptions, MapEngineViewState, } from './provider.dynamic.osm.engine'; import { SUBDOMAIN_PLACEHOLDER } from './provider.dynamic.osm.engine'; +import { + createMarkerElement, + DEFAULT_MARKER_CLASS, + DEFAULT_MARKER_SIZE, + MARKER_FALLBACK_HEIGHT, + MARKER_FALLBACK_WIDTH, +} from './provider.dynamic.osm.openlayers.marker'; import type { ControlLike, Coordinate, @@ -17,6 +30,7 @@ import type { MapLike, OpenLayersApi, Options, + OverlayLike, TileLayerLike, } from './provider.dynamic.osm.openlayers.utils'; import { @@ -35,6 +49,27 @@ interface MapBrowserEventLike { 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[]; + location: MapEngineMarkerOptions['location']; + offset: number[]; + overlay: OverlayLike; + positioning: string; +} + class OpenLayersMap implements MapEngineMap { readonly originalMap: MapLike; @@ -54,8 +89,11 @@ class OpenLayersMap implements MapEngineMap { private readonly _interactionStates = new Map(); + private readonly _markers = new Set(); + private _eventHandlers?: { click: (event: unknown) => void; + markerSizeChange: () => void; moveEnd: (event: unknown) => void; }; @@ -63,6 +101,8 @@ class OpenLayersMap implements MapEngineMap { private _disposed = false; + private _markerFitNeedsLayout = false; + constructor( private readonly _api: OpenLayersApi, container: Element, @@ -90,18 +130,232 @@ class OpenLayersMap implements MapEngineMap { zoom: view.zoom ?? 1, }), }); + this.originalMap.getOverlayContainer().setAttribute('dir', 'ltr'); + this.originalMap.getOverlayContainerStopEvent().setAttribute('dir', 'ltr'); + this.originalMap.getView().on('change:center', this._viewCenterChangeHandler); this._zoomControl = new _api.control.Zoom(); } + private readonly _viewCenterChangeHandler = (): void => { + this._syncMarkerPositions(); + }; + attachHandlers(handlers: MapEngineEventHandlers): void { this._detachHandlers(); this._initHandlers(handlers); } + private _attachMarkerElementHandlers( + element: HTMLElement, + onClick: MapEngineMarkerOptions['onClick'], + ): 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(); + onClick(); + } + : undefined; + const keydownHandler: EventListener | undefined = focusTargets.length + ? (event): void => { + event.stopPropagation(); + + if (!keyboardInteractive) { + return; + } + + const keyboardEvent = event as KeyboardEvent; + if (keyboardEvent.key !== 'Enter' && keyboardEvent.key !== ' ') { + return; + } + + event.preventDefault(); + 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(); + element.click(); + } + : undefined; + const imageLoadHandler: EventListener | undefined = element.tagName === 'IMG' + ? (): void => this._eventHandlers?.markerSizeChange() + : undefined; + + if (clickHandler) { + element.addEventListener('click', clickHandler); + } + if (keyboardInteractive) { + element.setAttribute('role', 'button'); + if (!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); + } + if (imageLoadHandler) { + element.addEventListener('load', imageLoadHandler); + } + + return { + focusTargets, + detach: (): void => { + if (clickHandler) { + element.removeEventListener('click', clickHandler); + } + if (keydownHandler) { + element.removeEventListener('keydown', keydownHandler); + } + if (keyReleaseHandler) { + element.removeEventListener(KEY_RELEASE_EVENT, keyReleaseHandler); + } + if (imageLoadHandler) { + element.removeEventListener('load', imageLoadHandler); + } + }, + }; + } + + addMarker(options: MapEngineMarkerOptions): MapEngineMarker { + const { element, offset, positioning } = createMarkerElement( + this._container.ownerDocument, + options, + ); + const marker = new this._api.Overlay({ + element, + insertFirst: true, + offset, + position: this._getMarkerPosition(options.location), + positioning, + stopEvent: false, + }); + const markerElementBinding = this._attachMarkerElementHandlers(element, options.onClick); + this.originalMap.addOverlay(marker); + + let disposed = false; + const handle: OpenLayersMarker = { + element, + focusTargets: markerElementBinding.focusTargets, + location: { ...options.location }, + offset, + overlay: marker, + positioning, + originalMarker: marker, + dispose: (): void => { + if (disposed) { + return; + } + + disposed = true; + markerElementBinding.detach(); + this.originalMap.removeOverlay(marker); + this._markers.delete(handle); + }, + }; + + this._markers.add(handle); + this._syncMarkerTabIndex(handle); + + return handle; + } + + 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) => { + marker.overlay.setPosition(this._getMarkerPosition(marker.location)); + }); + } + + private _syncMarkerTabIndex(marker: OpenLayersMarker): void { + marker.focusTargets.forEach(({ element, tabIndex }) => { + if (!this._focusEnabled || this._disabled) { + element.setAttribute('tabindex', '-1'); + } else if (tabIndex === null) { + element.removeAttribute('tabindex'); + } else { + element.setAttribute('tabindex', tabIndex); + } + }); + } + + private _getMarkerFitPadding(): { padding: number[]; needsLayout: boolean } { + const padding = [0, 0, 0, 0]; + let needsLayout = false; + + this._markers.forEach(({ element, offset, positioning }) => { + const isDefault = element.classList.contains(DEFAULT_MARKER_CLASS); + 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) { + if (!coordinate || this._isMarkerEvent(originalEvent)) { return; } @@ -121,11 +375,26 @@ class OpenLayersMap implements MapEngineMap { handlers.viewChange(this._getViewState()); }; - this._eventHandlers = { click, moveEnd }; + this._eventHandlers = { + click, + markerSizeChange: handlers.markerSizeChange, + moveEnd, + }; this.originalMap.on('click', click); this.originalMap.on('moveend', moveEnd); } + 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 }) => element.contains(eventTarget)); + } + private _detachHandlers(): void { if (!this._eventHandlers) { return; @@ -166,7 +435,9 @@ class OpenLayersMap implements MapEngineMap { this._disposed = true; this._detachHandlers(); + this.originalMap.getView().un('change:center', this._viewCenterChangeHandler); this._removeOwnedInert(); + [...this._markers].forEach((marker) => marker.dispose()); this.setControls(false); if (this._tileLayer) { this.originalMap.removeLayer(this._tileLayer); @@ -176,7 +447,7 @@ class OpenLayersMap implements MapEngineMap { this._container.removeAttribute('tabindex'); } - fitBounds(bounds: MapEngineBounds): void { + fitBounds(bounds: MapEngineBounds, options?: MapEngineFitBoundsOptions): void { const west = bounds.southWest.lng; const east = bounds.northEast.lng < west ? bounds.northEast.lng + 360 @@ -193,7 +464,15 @@ class OpenLayersMap implements MapEngineMap { GEOGRAPHIC_PROJECTION, getCoordinateProjection(this._api, view.getProjection()), ); - view.fit(extent); + const markerFit = options?.includeMarkerPadding + ? this._getMarkerFitPadding() + : undefined; + this._markerFitNeedsLayout = Boolean(markerFit?.needsLayout); + view.fit(extent, markerFit ? { padding: markerFit.padding } : undefined); + } + + getZoom(): number | undefined { + return this.originalMap.getView().getZoom(); } replaceTileLayer(options: MapEngineTileLayerOptions): void { @@ -291,6 +570,7 @@ class OpenLayersMap implements MapEngineMap { } else { this._container.removeAttribute('tabindex'); } + this._markers.forEach((marker) => this._syncMarkerTabIndex(marker)); } setView(options: MapEngineSetViewOptions): void { @@ -307,8 +587,12 @@ class OpenLayersMap implements MapEngineMap { } } - updateDimensions(): void { + updateDimensions(): boolean { this.originalMap.updateSize(); + const needsViewportAdjustment = this._markerFitNeedsLayout; + this._markerFitNeedsLayout = false; + + return needsViewportAdjustment; } } diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.ts index dabd818ceecf..3072c758285f 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.ts @@ -23,26 +23,36 @@ export interface CollectionLike { export interface ViewLike { calculateExtent: () => Extent; - fit: (extent: Extent) => void; + 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 { + setPosition: (position: Coordinate) => 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; @@ -50,6 +60,7 @@ export interface MapLike { export interface OpenLayersApi { Map: new (options: Options) => MapLike; + Overlay: new (options: Options) => OverlayLike; View: new (options: Options) => ViewLike; control: { Zoom: new () => ControlLike; @@ -90,6 +101,7 @@ export const isOpenLayersApi = (api: unknown): api is OpenLayersApi => { } return typeof api.Map === 'function' + && typeof api.Overlay === 'function' && typeof api.View === 'function' && isRecord(api.control) && hasFunction(api.control, 'Zoom') diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts index 16752dd3fee6..84eef267e499 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts @@ -9,6 +9,7 @@ import errors from '@js/ui/widget/ui.errors'; import type { LocationOption, + MarkerObject, MarkerOptions, RouteOptions, } from './provider.dynamic'; @@ -18,6 +19,7 @@ import type { MapEngineBounds, MapEngineClickEvent, MapEngineMap, + MapEngineMarker, MapEngineTileLayerOptions, MapEngineViewState, } from './provider.dynamic.osm.engine'; @@ -30,6 +32,83 @@ import { createOpenLayersEngine } from './provider.dynamic.osm.openlayers'; 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 STALE_OPERATION = Symbol('stale operation'); + +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); +}; + +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; +}; + +const createBounds = (locations: MapLocation[]): 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; + + if (gap > largestGap) { + largestGap = gap; + westIndex = (index + 1) % longitudes.length; + } + }); + + return { + northEast: { + lat: north, + lng: longitudes[(westIndex + longitudes.length - 1) % longitudes.length], + }, + southWest: { + lat: south, + lng: longitudes[westIndex], + }, + }; +}; + +interface EngineMarkerObject extends MarkerObject { + engineMarker: MapEngineMarker; + location: MapLocation; +} const areLocationsEqual = ( first: MapLocation | null, @@ -45,6 +124,29 @@ class OsmProvider extends DynamicProvider { _currentTileType?: MapType; + _calculatedLocations = new Map(); + + _pendingLocationCalculations = new Map>(); + + _boundLocations: MapLocation[] = []; + + _generation = 0; + + render(markers: MarkerOptions[], routes: RouteOptions[]): Promise { + const generation = this._generation; + + return super.render(markers, routes).then( + (result) => (generation === this._generation ? result : false), + (error) => { + if (generation !== this._generation) { + return false; + } + + throw error; + }, + ); + } + _loadImpl(): Promise { const window = getWindow() as Window & { ol?: unknown }; const engine = getRegisteredMapEngine() ?? createOpenLayersEngine(window.ol); @@ -59,8 +161,14 @@ class OsmProvider extends DynamicProvider { } _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: this._resolveLocation(this._option('center')), + center, zoom: this._option('zoom') ?? 1, }); @@ -119,13 +227,74 @@ class OsmProvider extends DynamicProvider { return result; } - _resolveLocation(location?: LocationOption | null): MapLocation { - return this._getLatLng(location) ?? { lat: 0, lng: 0 }; + _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 { + const cachedLocation = this._calculatedLocations.get(query); + if (cachedLocation) { + return Promise.resolve(cachedLocation); + } + const pendingCalculation = this._pendingLocationCalculations.get(query); + if (pendingCalculation) { + return pendingCalculation; + } + + const calculateLocation = this._option('providerConfig')?.calculateLocation; + if (!calculateLocation) { + errors.log('W1031'); + + return Promise.resolve(getDefaultLocation()); + } + + const generation = this._generation; + + const calculation = Promise.resolve() + .then(() => calculateLocation(query)) + .then((location) => { + if (location + && Number.isFinite(location.lat) + && Number.isFinite(location.lng)) { + const result = { lat: location.lat, lng: location.lng }; + if (generation === this._generation) { + this._calculatedLocations.set(query, result); + } + + return result; + } + + return getDefaultLocation(); + }, () => getDefaultLocation()); + + this._pendingLocationCalculations.set(query, calculation); + + return calculation.then((location) => { + if (this._pendingLocationCalculations.get(query) === calculation) { + this._pendingLocationCalculations.delete(query); + } + + return location; + }); } _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), }); } @@ -157,7 +326,9 @@ class OsmProvider extends DynamicProvider { } updateDimensions(): Promise { - this._engineMap?.updateDimensions(); + if (this._engineMap?.updateDimensions()) { + return this._fitBounds(); + } return Promise.resolve(); } @@ -186,11 +357,19 @@ class OsmProvider extends DynamicProvider { } updateCenter(): Promise { - this._engineMap?.setView({ - center: this._resolveLocation(this._option('center')), - }); + 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; + } - return Promise.resolve(); + engineMap?.setView({ center }); + this._option('center', center); + }); } updateZoom(): Promise { @@ -215,16 +394,28 @@ class OsmProvider extends DynamicProvider { } updateBounds(): Promise { - const bounds = this._option('bounds'); - const northEast = this._getLatLng(bounds?.northEast); - const southWest = this._getLatLng(bounds?.southWest); - - if (northEast && southWest) { - const engineBounds: MapEngineBounds = { northEast, southWest }; - this._engineMap?.fitBounds(engineBounds); + if (!this._areBoundsSet()) { + return Promise.resolve(); } - 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 { @@ -234,11 +425,95 @@ class OsmProvider extends DynamicProvider { } adjustViewport(): Promise { - return Promise.resolve(); + 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(STALE_OPERATION); + } + + // 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 + ? (): void => markerClickAction({ location }) + : undefined, + }); + + return { + engineMarker, + location, + marker: engineMarker.originalMarker, + }; + }); } addMarkers(markers: MarkerOptions[]): Promise<[boolean, unknown[]]> { - return Promise.resolve([false, markers.map(() => undefined)]); + return super.addMarkers(markers).catch((error) => { + if (error === STALE_OPERATION) { + return [false, []]; + } + + throw error; + }); + } + + _destroyMarker(marker: EngineMarkerObject): void { + marker.engineMarker.dispose(); + } + + _fitBounds(): Promise { + this._updateBounds(); + this._bounds = createBounds(this._boundLocations) ?? 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 = []; } addRoutes(routes: RouteOptions[]): Promise<[boolean, unknown[]]> { @@ -246,6 +521,12 @@ class OsmProvider extends DynamicProvider { } clean(): Promise { + this._generation += 1; + this._calculatedLocations.clear(); + this._pendingLocationCalculations.clear(); + if (this._engineMap) { + this._clearMarkers(); + } this._engineMap?.dispose(); this._engineMap = undefined; this._engine = undefined; diff --git a/packages/devextreme/js/ui/map/openlayers.register.js b/packages/devextreme/js/ui/map/openlayers.register.js index 3e66488c1b75..4289f50b51cb 100644 --- a/packages/devextreme/js/ui/map/openlayers.register.js +++ b/packages/devextreme/js/ui/map/openlayers.register.js @@ -3,6 +3,7 @@ import Zoom from 'ol/control/Zoom.js'; import { defaults as defaultInteractions } from 'ol/interaction/defaults.js'; import TileLayer from 'ol/layer/Tile.js'; import Map from 'ol/Map.js'; +import Overlay from 'ol/Overlay.js'; import { getUserProjection, toLonLat, @@ -17,6 +18,7 @@ import { createOpenLayersEngine } from '../../__internal/ui/map/provider.dynamic setRegisteredMapEngine(createOpenLayersEngine({ Map, + Overlay, View, control: { Zoom, From f60ffa101c7484207b863eee74689f01a22c191d Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Mon, 31 Aug 2026 19:34:23 +0300 Subject: [PATCH 3/9] Map: test OSM markers and location calculation --- ...vider.dynamic.osm.openlayers.utils.test.ts | 3 +- .../testing/helpers/forMap/openLayersMock.js | 81 +- .../mapParts/osmTests.js | 923 +++++++++++++++++- 3 files changed, 975 insertions(+), 32 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.test.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.test.ts index d817293727c6..efbd952ccc0a 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.test.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.test.ts @@ -13,6 +13,7 @@ import { const createApi = (): unknown => ({ Map: jest.fn(), + Overlay: jest.fn(), View: jest.fn(), control: { Zoom: jest.fn(), @@ -40,7 +41,7 @@ describe('OpenLayers utils', () => { it('returns false when a required API member is missing', () => { const api = createApi() as Record; - delete api.View; + delete api.Overlay; expect(isOpenLayersApi(api)).toBe(false); }); diff --git a/packages/devextreme/testing/helpers/forMap/openLayersMock.js b/packages/devextreme/testing/helpers/forMap/openLayersMock.js index 64104646129c..a3897e6bd85f 100644 --- a/packages/devextreme/testing/helpers/forMap/openLayersMock.js +++ b/packages/devextreme/testing/helpers/forMap/openLayersMock.js @@ -11,15 +11,13 @@ return [...coordinate]; } if(source === GEOGRAPHIC_PROJECTION && destination === WEB_MERCATOR_PROJECTION) { - return coordinate.map((value) => value * PROJECTION_SCALE); + return coordinate.map(value => value * PROJECTION_SCALE); } if(source === WEB_MERCATOR_PROJECTION && destination === GEOGRAPHIC_PROJECTION) { - return coordinate.map((value) => value / PROJECTION_SCALE); + return coordinate.map(value => value / PROJECTION_SCALE); } - return [...coordinate]; }; - class MockCollection { constructor(items) { this.items = items; @@ -42,10 +40,12 @@ getActive() { return this.active; } - setActive(active) { this.active = active; - api.interactionStateChanges.push({ interaction: this, active }); + api.interactionStateChanges.push({ + interaction: this, + active + }); if(api.onInteractionStateChanged) { api.onInteractionStateChanged(); } @@ -57,10 +57,20 @@ api.zoomControlCreatedCount += 1; } } - + class MockOverlay { + constructor(options) { + this.options = options; + api.overlayOptions.push(options); + } + 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; @@ -75,8 +85,14 @@ fit(extent, options) { 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; } @@ -88,30 +104,42 @@ 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'); + this.overlayContainerStopEvent = document.createElement('div'); api.mapCreated = true; api.mapInstance = this; api.mapOptions = options; + api.overlayContainer = this.overlayContainer; + api.overlayContainerStopEvent = this.overlayContainerStopEvent; } - addControl(control) { api.addedControls.push(control); } @@ -120,11 +148,22 @@ api.tileLayer = layer; api.addedTileLayers.push(layer); } - + addOverlay(overlay) { + api.addedOverlays.push(overlay); + if(api.getOverlayRect) { + overlay.options.element.getBoundingClientRect = api.getOverlayRect; + } + this.options.target.appendChild(overlay.options.element); + } getInteractions() { return this.options.interactions; } - + getOverlayContainer() { + return this.overlayContainer; + } + getOverlayContainerStopEvent() { + return this.overlayContainerStopEvent; + } getView() { return this.view; } @@ -141,7 +180,10 @@ removeLayer(layer) { api.removedLayers.push(layer); } - + removeOverlay(overlay) { + api.removedOverlays.push(overlay); + overlay.options.element.remove(); + } setTarget(target) { this.target = target; api.mapTarget = target; @@ -150,17 +192,14 @@ updateSize() { api.mapResized = true; } - un(type, listener) { const handlers = this.eventHandlers[type] || []; - this.eventHandlers[type] = handlers.filter((handler) => handler !== listener); + this.eventHandlers[type] = handlers.filter(handler => handler !== listener); } - trigger(type, event) { - (this.eventHandlers[type] || []).slice().forEach((handler) => handler(event || {})); + (this.eventHandlers[type] || []).slice().forEach(handler => handler(event || {})); } } - class MockImageTile { constructor(options) { if(api.throwOnTileSource) { @@ -182,9 +221,9 @@ api.tileSourceChanges.push(source); } } - Object.assign(api, { Map: MockMap, + Overlay: MockOverlay, View: MockView, control: { Zoom: MockZoom, diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js index 52dd179dd79c..f79a92223d97 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -3,6 +3,7 @@ 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 localization from 'localization'; import errors from 'ui/widget/ui.errors'; import 'ui/map'; @@ -11,10 +12,13 @@ let openLayersMock; const resetOpenLayersMock = () => { Object.assign(openLayersMock, { addedControls: [], + addedOverlays: [], addedTileLayers: [], controlOptions: null, + fitZoom: undefined, fitOptions: null, fittedExtent: null, + getOverlayRect: null, interactionOptions: null, interactions: [], interactionStateChanges: [], @@ -24,9 +28,14 @@ const resetOpenLayersMock = () => { mapResized: false, mapTarget: null, onInteractionStateChanged: null, + overlayOptions: [], + overlayContainer: null, + overlayContainerStopEvent: null, + overlayPositionChanges: [], projectedCoordinates: [], removedControls: [], removedLayers: [], + removedOverlays: [], throwOnTileSource: false, tileLayer: null, tileLayerOptions: null, @@ -47,9 +56,7 @@ const resetOpenLayersMock = () => { 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]); - + const stateMatches = actualStates.length === expectedStates.length && actualStates.every((state, index) => state === expectedStates[index]); if(stateMatches) { openLayersMock.onInteractionStateChanged = null; callback(); @@ -215,7 +222,9 @@ QUnit.module('OSM: map loading', moduleConfig, () => { 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 shadowRoot = host.attachShadow({ + mode: 'open' + }); const container = document.createElement('div'); const sibling = document.createElement('button'); shadowRoot.append(container, sibling); @@ -296,7 +305,22 @@ QUnit.module('OSM: map loading', moduleConfig, () => { 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: {} }); + 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(); @@ -311,7 +335,9 @@ QUnit.module('OSM: map loading', moduleConfig, () => { const provider = createProvider(); const projectionApi = Object.assign({}, openLayersMock.proj); delete projectionApi[apiName]; - window.ol = Object.assign({}, openLayersMock, { proj: projectionApi }); + window.ol = Object.assign({}, openLayersMock, { + proj: projectionApi + }); provider._loadImpl().then(() => { assert.ok(false, 'load should reject'); done(); @@ -776,6 +802,850 @@ QUnit.module('OSM: initial view', moduleConfig, () => { }); }); }); +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 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'); + 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 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'); + done(); + }); + }); + QUnit.test('a rejected callback result is not cached', function(assert) { + const done = assert.async(); + const calculateLocation = sinon.stub(); + calculateLocation.onFirstCall().returns(Promise.reject(new Error('service unavailable'))); + 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'); + 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 = []; + 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 = []; + const add = provider.addMarkers([{ + location: 'New York' + }]); + provider.clean(); + resolveLocation({ + lat: 40.74, + lng: -73.98 + }); + add.then(result => { + assert.deepEqual(result, [false, []], 'the stale marker operation is canceled'); + assert.ok(addMarker.notCalled, 'no marker is added to the disposed engine map'); + done(); + }); + }); +}); +QUnit.module('OSM: markers', moduleConfig, () => { + const tileServer = { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + }; + 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(element.style.width, '44px', 'default marker keeps a sufficiently large hit area'); + assert.strictEqual(element.style.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.strictEqual(markerBody.getAttribute('fill'), '#2d7fbd', 'marker body color does not depend on the theme'); + assert.strictEqual(markerBody.getAttribute('stroke'), '#fff', 'marker outline does not depend on the theme'); + assert.strictEqual(markerBody.getAttribute('stroke-width'), '0.5', 'marker outline does not obscure its body'); + assert.ok(markerCenter, 'marker center is rendered'); + assert.strictEqual(markerCenter.getAttribute('fill'), '#fff', 'marker center does not depend on the theme'); + 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 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'), '', 'non-interactive custom marker is decorative'); + 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'), '', 'interactive custom marker image is decorative'); + assert.strictEqual(localIcon.getAttribute('aria-label'), markerAriaLabel, 'interactive custom marker has a localized 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.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('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'); + 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: '', + 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 => { + assert.strictEqual(originalMarker, openLayersMock.addedOverlays[0], 'addMarker returns the overlay'); + assert.ok(onMarkerAdded.calledOnce, 'onMarkerAdded is fired'); + map.removeMarker(marker).done(() => { + assert.strictEqual(openLayersMock.removedOverlays[0], originalMarker, 'overlay is removed'); + assert.ok(onMarkerRemoved.calledOnce, 'onMarkerRemoved is fired'); + 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; + openLayersMock.addedOverlays[0].options.element.dispatchEvent(new Event('load')); + assert.deepEqual(openLayersMock.fitOptions.padding, [60, 40, 0, 40], 'loaded image size is included in fit padding'); + done(); + } + }); + }); + QUnit.test('HTML marker padding is measured after a hidden map becomes visible', function(assert) { + const done = assert.async(); + const container = document.createElement('div'); + container.style.display = 'none'; + $('#qunit-fixture').append(container); + const engine = createOpenLayersEngine(openLayersMock); + const engineMap = engine.createMap(container); + openLayersMock.getOverlayRect = () => container.style.display === 'none' ? { + height: 0, + width: 0 + } : { + height: 60, + width: 80 + }; + engineMap.addMarker({ + html: 'A', + htmlOffset: { + left: 5, + top: 7 + }, + location: { + lat: 40.7, + lng: -74 + } + }); + engineMap.fitBounds({ + northEast: { + lat: 40.7, + lng: -74 + }, + southWest: { + lat: 40.7, + lng: -74 + } + }, { + includeMarkerPadding: true + }); + assert.deepEqual(openLayersMock.fitOptions.padding, [0, 30, 48, 0], 'fallback padding is used without layout'); + container.style.display = 'block'; + const provider = new OsmProvider({ + option: () => ({ + autoAdjust: true, + zoom: 1 + }), + setOptionSilent: () => {} + }, null); + provider._engineMap = engineMap; + provider._markers = [{ + location: { + lat: 40.7, + lng: -74 + }, + options: {} + }]; + provider._routes = []; + provider.updateDimensions().then(() => { + assert.deepEqual(openLayersMock.fitOptions.padding, [0, 85, 67, 0], 'provider refits after layout becomes available'); + engineMap.dispose(); + done(); + }); + }); + 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: viewport and interactions', moduleConfig, () => { QUnit.test('focus options are applied to the OpenLayers keyboard target', function(assert) { const done = assert.async(); @@ -783,6 +1653,14 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { provider: 'osm', focusStateEnabled: false, tabIndex: 5, + markers: [{ + location: [40.74, -73.98], + onClick: () => {} + }, { + location: [40.75, -73.97], + html: '', + onClick: () => {} + }], providerConfig: { tileServer: { url: 'https://tiles.example.com/{z}/{x}/{y}.png', @@ -791,13 +1669,21 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { }, 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); @@ -879,7 +1765,10 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { const engine = createOpenLayersEngine(openLayersMock); const engineMap = engine.createMap(document.createElement('div')); const click = sinon.spy(); - engineMap.attachHandlers({ click, viewChange: sinon.spy() }); + engineMap.attachHandlers({ + click, + viewChange: sinon.spy() + }); openLayersMock.mapInstance.trigger('click', { originalEvent: new PointerEvent('click') }); @@ -1134,7 +2023,7 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { } }).dxMap('instance'); }); - QUnit.test('RTL mode preserves viewport and keyboard behavior', function(assert) { + QUnit.test('RTL mode preserves viewport, overlay positioning, and keyboard behavior', function(assert) { const done = assert.async(); $('#map').dxMap({ provider: 'osm', @@ -1143,6 +2032,12 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { lng: -73.98 }, controls: true, + markers: [{ + location: { + lat: 40.74, + lng: -73.98 + } + }], rtlEnabled: true, providerConfig: { tileServer: { @@ -1153,6 +2048,9 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { 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'); + 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(); @@ -1164,9 +2062,14 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { const engineMap = engine.createMap(document.createElement('div')); const click = sinon.spy(); const viewChange = sinon.spy(); - engineMap.attachHandlers({ click, viewChange }); + engineMap.attachHandlers({ + click, + viewChange + }); engineMap.dispose(); - openLayersMock.mapInstance.trigger('click', { coordinate: [-73980, 40740] }); + 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'); From 863645c5e4a27016db12965fb98671a649405104 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Mon, 31 Aug 2026 19:34:44 +0300 Subject: [PATCH 4/9] Map: showcase markers in Storybook --- apps/react-storybook/.storybook/main.ts | 6 +- .../stories/map/OSMMap.stories.tsx | 110 +++++++++++++++--- 2 files changed, 98 insertions(+), 18 deletions(-) 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/stories/map/OSMMap.stories.tsx b/apps/react-storybook/stories/map/OSMMap.stories.tsx index 865b6130cd0e..b396e8ce34ae 100644 --- a/apps/react-storybook/stories/map/OSMMap.stories.tsx +++ b/apps/react-storybook/stories/map/OSMMap.stories.tsx @@ -7,6 +7,7 @@ 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 { MapLocation, @@ -17,15 +18,45 @@ import 'devextreme/ui/map/openlayers'; const CENTER = { lat: 40.7484, lng: -73.9857 }; const CENTRAL_PARK_CENTER = { lat: 40.7829, lng: -73.9654 }; +const DEFAULT_MARKER_LOCATION = 'Empire State Building'; +const CUSTOM_MARKER_LOCATION = 'Bryant Park'; +const ADDED_MARKER_LOCATION = 'Times Square'; const EXTENT: [number, number, number, number] = [-74.08, 40.67, -73.85, 40.88]; const TILE_SERVER = { url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', attribution: '© OpenStreetMap contributors', maxZoom: 19, }; -const PROVIDER_CONFIG = { tileServer: () => TILE_SERVER }; +const MARKER_LOCATIONS: Record = { + [DEFAULT_MARKER_LOCATION]: CENTER, + [CUSTOM_MARKER_LOCATION]: { lat: 40.7536, lng: -73.9832 }, + [ADDED_MARKER_LOCATION]: { lat: 40.758, lng: -73.9855 }, +}; +const PROVIDER_CONFIG = { + calculateLocation: (query: string): Promise => ( + Promise.resolve(MARKER_LOCATIONS[query]) + ), + tileServer: () => TILE_SERVER, +}; +const DEFAULT_MARKER = { location: DEFAULT_MARKER_LOCATION }; +const CUSTOM_MARKER = { + location: CUSTOM_MARKER_LOCATION, + iconSrc: 'images/maps/map-marker.png', +}; +const ADDED_MARKER = { location: ADDED_MARKER_LOCATION }; +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; @@ -61,6 +92,7 @@ const configureOpenLayersMap = ( }; const OsmMapStory = ({ + autoAdjust, centerOnCentralPark, controls, disabled, @@ -71,29 +103,68 @@ const OsmMapStory = ({ zoom, }: OsmMapStoryProps): React.ReactElement => { const mapRef = React.useRef(null); + const [markerAdded, setMarkerAdded] = React.useState(false); + const markers = React.useMemo(() => [DEFAULT_MARKER, CUSTOM_MARKER], []); const center = centerOnCentralPark ? CENTRAL_PARK_CENTER : CENTER; 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(ADDED_MARKER).then(undefined, () => setMarkerAdded(false)); + }; + + const removeMarker = (): void => { + const map = mapRef.current?.instance(); + if (!map || !markerAdded) { + return; + } + + setMarkerAdded(false); + void map.removeMarker(ADDED_MARKER).then(undefined, () => setMarkerAdded(true)); + }; + return ( - configureOpenLayersMap(event, center, zoom)} - onZoomChange={(value) => updateArgs({ zoom: value })} - /> +
+
+
+ configureOpenLayersMap(event, center, zoom)} + onZoomChange={(value) => updateArgs({ zoom: value })} + /> +
); }; @@ -109,6 +180,10 @@ const meta: Meta = { layout: 'fullscreen', }, argTypes: { + autoAdjust: { + control: 'boolean', + description: 'Automatically adjusts the map viewport when markers change.', + }, centerOnCentralPark: { control: 'boolean', description: 'Switches the map center between the default New York location and Central Park.', @@ -148,6 +223,7 @@ type Story = StoryObj; export const Default: Story = { args: { + autoAdjust: false, centerOnCentralPark: false, controls: true, disabled: false, From 74e9b135eb66e858e6d34b43b3c96253b45ecfbb Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Wed, 2 Sep 2026 10:01:09 +0300 Subject: [PATCH 5/9] Map: apply theme colors to OSM markers --- .../scss/widgets/fluent/map/_colors.scss | 2 ++ .../scss/widgets/fluent/map/_index.scss | 10 ++++++++++ .../scss/widgets/generic/map/_colors.scss | 2 ++ .../scss/widgets/generic/map/_index.scss | 10 ++++++++++ .../scss/widgets/material/map/_colors.scss | 2 ++ .../scss/widgets/material/map/_index.scss | 10 ++++++++++ .../ui/map/provider.dynamic.osm.openlayers.marker.ts | 5 ----- .../tests/DevExpress.ui.widgets/mapParts/osmTests.js | 6 +++--- 8 files changed, 39 insertions(+), 8 deletions(-) create mode 100644 packages/devextreme-scss/scss/widgets/fluent/map/_colors.scss create mode 100644 packages/devextreme-scss/scss/widgets/generic/map/_colors.scss create mode 100644 packages/devextreme-scss/scss/widgets/material/map/_colors.scss 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/js/__internal/ui/map/provider.dynamic.osm.openlayers.marker.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.marker.ts index 3e227416e52e..cef32dfbb3e3 100644 --- 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 @@ -7,8 +7,6 @@ export const DEFAULT_MARKER_CLASS = 'dx-map-marker-default'; const DEFAULT_MARKER_WIDTH = 24.5; const DEFAULT_MARKER_HEIGHT = 36.5; -const DEFAULT_MARKER_COLOR = '#2d7fbd'; -const DEFAULT_MARKER_ACCENT_COLOR = '#fff'; const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; const DEFAULT_MARKER_VIEW_BOX = '5 2 14 20'; const DEFAULT_MARKER_BODY_PATH = [ @@ -52,12 +50,9 @@ const createDefaultMarkerElement = ( svg.style.overflow = 'visible'; body.setAttribute('class', 'dx-map-marker-default-body'); body.setAttribute('d', DEFAULT_MARKER_BODY_PATH); - body.setAttribute('fill', DEFAULT_MARKER_COLOR); - body.setAttribute('stroke', DEFAULT_MARKER_ACCENT_COLOR); body.setAttribute('stroke-width', '0.5'); center.setAttribute('class', 'dx-map-marker-default-center'); center.setAttribute('d', DEFAULT_MARKER_CENTER_PATH); - center.setAttribute('fill', DEFAULT_MARKER_ACCENT_COLOR); svg.appendChild(body); svg.appendChild(center); element.appendChild(svg); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js index f79a92223d97..5ab11a607900 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -1188,11 +1188,11 @@ QUnit.module('OSM: markers', moduleConfig, () => { 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.strictEqual(markerBody.getAttribute('fill'), '#2d7fbd', 'marker body color does not depend on the theme'); - assert.strictEqual(markerBody.getAttribute('stroke'), '#fff', 'marker outline does not depend on the theme'); + 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.strictEqual(markerCenter.getAttribute('fill'), '#fff', 'marker center does not depend on the theme'); + 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(); From ffb524cd901849514f1801312c50fe224acd19af Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Wed, 2 Sep 2026 10:02:16 +0300 Subject: [PATCH 6/9] Map: compose OSM marker CSS classes --- .../provider.dynamic.osm.openlayers.marker.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) 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 index cef32dfbb3e3..1c4dc668734c 100644 --- 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 @@ -3,8 +3,13 @@ import type { MapEngineMarkerOptions } from './provider.dynamic.osm.engine'; export const MARKER_FALLBACK_WIDTH = 25; export const MARKER_FALLBACK_HEIGHT = 41; export const DEFAULT_MARKER_SIZE = 44; -export const DEFAULT_MARKER_CLASS = 'dx-map-marker-default'; +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'; @@ -30,13 +35,13 @@ const createDefaultMarkerElement = ( const body = ownerDocument.createElementNS(SVG_NAMESPACE, 'path'); const center = ownerDocument.createElementNS(SVG_NAMESPACE, 'path'); - element.className = `dx-map-marker ${DEFAULT_MARKER_CLASS}`; + element.className = `${MARKER_CLASS} ${DEFAULT_MARKER_CLASS}`; element.style.alignItems = 'flex-end'; element.style.display = 'flex'; element.style.height = `${DEFAULT_MARKER_SIZE}px`; element.style.justifyContent = 'center'; element.style.width = `${DEFAULT_MARKER_SIZE}px`; - svg.setAttribute('class', 'dx-map-marker-default-icon'); + 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}`); @@ -48,10 +53,10 @@ const createDefaultMarkerElement = ( 'drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.35))', ].join(' '); svg.style.overflow = 'visible'; - body.setAttribute('class', 'dx-map-marker-default-body'); + body.setAttribute('class', DEFAULT_MARKER_BODY_CLASS); body.setAttribute('d', DEFAULT_MARKER_BODY_PATH); body.setAttribute('stroke-width', '0.5'); - center.setAttribute('class', 'dx-map-marker-default-center'); + center.setAttribute('class', DEFAULT_MARKER_CENTER_CLASS); center.setAttribute('d', DEFAULT_MARKER_CENTER_PATH); svg.appendChild(body); svg.appendChild(center); @@ -77,7 +82,7 @@ export const createMarkerElement = ( if (options.iconSrc) { const element = ownerDocument.createElement('img'); - element.className = 'dx-map-marker dx-map-marker-image'; + element.className = `${MARKER_CLASS} ${IMAGE_MARKER_CLASS}`; element.src = options.iconSrc; element.alt = ''; element.draggable = false; From 47073363cc2b94c14d156da9bf828bf888c925f4 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Wed, 2 Sep 2026 13:00:34 +0300 Subject: [PATCH 7/9] Map: handle OpenLayers view replacement --- .../ui/map/provider.dynamic.osm.openlayers.ts | 23 ++++++++++-- .../testing/helpers/forMap/openLayersMock.js | 4 +++ .../mapParts/osmTests.js | 35 +++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts index 9e90853ff764..1b7b1fdbf1d9 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts @@ -32,6 +32,7 @@ import type { Options, OverlayLike, TileLayerLike, + ViewLike, } from './provider.dynamic.osm.openlayers.utils'; import { areCoordinatesEqual, @@ -103,6 +104,8 @@ class OpenLayersMap implements MapEngineMap { private _markerFitNeedsLayout = false; + private _subscribedView: ViewLike; + constructor( private readonly _api: OpenLayersApi, container: Element, @@ -132,7 +135,9 @@ class OpenLayersMap implements MapEngineMap { }); this.originalMap.getOverlayContainer().setAttribute('dir', 'ltr'); this.originalMap.getOverlayContainerStopEvent().setAttribute('dir', 'ltr'); - this.originalMap.getView().on('change:center', this._viewCenterChangeHandler); + 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(); } @@ -140,6 +145,19 @@ class OpenLayersMap implements MapEngineMap { this._syncMarkerPositions(); }; + private readonly _viewChangeHandler = (): void => { + const view = this.originalMap.getView(); + + if (view === this._subscribedView) { + return; + } + + this._subscribedView.un('change:center', this._viewCenterChangeHandler); + this._subscribedView = view; + this._subscribedView.on('change:center', this._viewCenterChangeHandler); + this._syncMarkerPositions(); + }; + attachHandlers(handlers: MapEngineEventHandlers): void { this._detachHandlers(); this._initHandlers(handlers); @@ -435,7 +453,8 @@ class OpenLayersMap implements MapEngineMap { this._disposed = true; this._detachHandlers(); - this.originalMap.getView().un('change:center', this._viewCenterChangeHandler); + this.originalMap.un('change:view', this._viewChangeHandler); + this._subscribedView.un('change:center', this._viewCenterChangeHandler); this._removeOwnedInert(); [...this._markers].forEach((marker) => marker.dispose()); this.setControls(false); diff --git a/packages/devextreme/testing/helpers/forMap/openLayersMock.js b/packages/devextreme/testing/helpers/forMap/openLayersMock.js index a3897e6bd85f..55ba0f45bfb1 100644 --- a/packages/devextreme/testing/helpers/forMap/openLayersMock.js +++ b/packages/devextreme/testing/helpers/forMap/openLayersMock.js @@ -188,6 +188,10 @@ this.target = target; api.mapTarget = target; } + setView(view) { + this.view = view; + this.trigger('change:view'); + } updateSize() { api.mapResized = true; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js index 5ab11a607900..59ef4a3f0610 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -1199,6 +1199,41 @@ QUnit.module('OSM: markers', moduleConfig, () => { } }); }); + 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 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(); From f478f57b9d590aae274287faf4aa9d5846386377 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Wed, 2 Sep 2026 13:31:07 +0300 Subject: [PATCH 8/9] Map: handle images in HTML markers --- .../ui/map/provider.dynamic.osm.openlayers.ts | 7 +++-- .../mapParts/osmTests.js | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts index 1b7b1fdbf1d9..024e476dd344 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts @@ -213,7 +213,8 @@ class OpenLayersMap implements MapEngineMap { element.click(); } : undefined; - const imageLoadHandler: EventListener | undefined = element.tagName === 'IMG' + const hasImage = element.tagName === 'IMG' || element.querySelector('img') !== null; + const imageLoadHandler: EventListener | undefined = hasImage ? (): void => this._eventHandlers?.markerSizeChange() : undefined; @@ -236,7 +237,7 @@ class OpenLayersMap implements MapEngineMap { element.addEventListener(KEY_RELEASE_EVENT, keyReleaseHandler); } if (imageLoadHandler) { - element.addEventListener('load', imageLoadHandler); + element.addEventListener('load', imageLoadHandler, true); } return { @@ -252,7 +253,7 @@ class OpenLayersMap implements MapEngineMap { element.removeEventListener(KEY_RELEASE_EVENT, keyReleaseHandler); } if (imageLoadHandler) { - element.removeEventListener('load', imageLoadHandler); + element.removeEventListener('load', imageLoadHandler, true); } }, }; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js index 59ef4a3f0610..07bc29ecc488 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -1515,6 +1515,34 @@ QUnit.module('OSM: markers', moduleConfig, () => { } }); }); + QUnit.test('autoAdjust refits the view after an HTML 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], + html: '' + }], + providerConfig: { + tileServer + }, + onReady: () => { + assert.deepEqual(openLayersMock.fitOptions.padding, [0, 25, 41, 0], 'fallback size is used while the HTML image loads'); + imageLoaded = true; + openLayersMock.addedOverlays[0].options.element.querySelector('img').dispatchEvent(new Event('load')); + assert.deepEqual(openLayersMock.fitOptions.padding, [0, 80, 60, 0], 'loaded HTML image size is included in fit padding'); + done(); + } + }); + }); QUnit.test('HTML marker padding is measured after a hidden map becomes visible', function(assert) { const done = assert.async(); const container = document.createElement('div'); From 7a386ac3ee15b2037f32f576d97622e8471adb8e Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Wed, 2 Sep 2026 13:39:58 +0300 Subject: [PATCH 9/9] Map: preserve OpenLayers mock callback style --- .../testing/helpers/forMap/openLayersMock.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/devextreme/testing/helpers/forMap/openLayersMock.js b/packages/devextreme/testing/helpers/forMap/openLayersMock.js index 55ba0f45bfb1..3445df42c943 100644 --- a/packages/devextreme/testing/helpers/forMap/openLayersMock.js +++ b/packages/devextreme/testing/helpers/forMap/openLayersMock.js @@ -11,10 +11,10 @@ return [...coordinate]; } if(source === GEOGRAPHIC_PROJECTION && destination === WEB_MERCATOR_PROJECTION) { - return coordinate.map(value => value * PROJECTION_SCALE); + return coordinate.map((value) => value * PROJECTION_SCALE); } if(source === WEB_MERCATOR_PROJECTION && destination === GEOGRAPHIC_PROJECTION) { - return coordinate.map(value => value / PROJECTION_SCALE); + return coordinate.map((value) => value / PROJECTION_SCALE); } return [...coordinate]; }; @@ -120,11 +120,11 @@ api.viewZoomSetCount += 1; } trigger(type) { - (this.eventHandlers[type] || []).slice().forEach(handler => handler()); + (this.eventHandlers[type] || []).slice().forEach((handler) => handler()); } un(type, listener) { const handlers = this.eventHandlers[type] || []; - this.eventHandlers[type] = handlers.filter(handler => handler !== listener); + this.eventHandlers[type] = handlers.filter((handler) => handler !== listener); } } class MockMap { @@ -198,10 +198,10 @@ } un(type, listener) { const handlers = this.eventHandlers[type] || []; - this.eventHandlers[type] = handlers.filter(handler => handler !== listener); + this.eventHandlers[type] = handlers.filter((handler) => handler !== listener); } trigger(type, event) { - (this.eventHandlers[type] || []).slice().forEach(handler => handler(event || {})); + (this.eventHandlers[type] || []).slice().forEach((handler) => handler(event || {})); } } class MockImageTile {