]> git.openstreetmap.org Git - rails.git/blob - app/assets/javascripts/index/layers/data.js
Antimeridian map data display
[rails.git] / app / assets / javascripts / index / layers / data.js
1 OSM.initializeDataLayer = function (map) {
2   let dataLoader, loadedBounds;
3   const dataLayer = map.dataLayer;
4
5   dataLayer.isWayArea = function () {
6     return false;
7   };
8
9   dataLayer.on("click", function (e) {
10     const feature = e.layer.feature;
11     OSM.router.click(e.originalEvent, `/${feature.type}/${feature.id}`);
12   });
13
14   dataLayer.on("add", function () {
15     map.fire("overlayadd", { layer: this });
16     map.on("moveend", updateData);
17     updateData();
18   });
19
20   dataLayer.on("remove", function () {
21     if (dataLoader) dataLoader.abort();
22     dataLoader = null;
23     map.off("moveend", updateData);
24     $("#browse_status").empty();
25     map.fire("overlayremove", { layer: this });
26   });
27
28   function updateData() {
29     const bounds = map.getBounds();
30     if (!loadedBounds || !loadedBounds.contains(bounds)) {
31       getData();
32     }
33   }
34
35   function displayFeatureWarning(num_features, add, cancel) {
36     $("#browse_status").html(
37       $("<div class='p-3'>").append(
38         $("<div class='d-flex'>").append(
39           $("<h2 class='flex-grow-1 text-break'>")
40             .text(OSM.i18n.t("browse.start_rjs.load_data")),
41           $("<div>").append(
42             $("<button type='button' class='btn-close'>")
43               .attr("aria-label", OSM.i18n.t("javascripts.close"))
44               .click(cancel))),
45         $("<p class='alert alert-warning'>")
46           .text(OSM.i18n.t("browse.start_rjs.feature_warning", { num_features })),
47         $("<input type='submit' class='btn btn-primary d-block mx-auto'>")
48           .val(OSM.i18n.t("browse.start_rjs.load_data"))
49           .click(add)));
50   }
51
52   function displayLoadError(message, close) {
53     $("#browse_status").html(
54       $("<div class='p-3'>").append(
55         $("<div class='d-flex'>").append(
56           $("<h2 class='flex-grow-1 text-break'>")
57             .text(OSM.i18n.t("browse.start_rjs.load_data")),
58           $("<div>").append(
59             $("<button type='button' class='btn-close'>")
60               .attr("aria-label", OSM.i18n.t("javascripts.close"))
61               .click(close))),
62         $("<p class='alert alert-warning'>")
63           .text(OSM.i18n.t("browse.start_rjs.feature_error", { message: message }))));
64   }
65
66   function getData() {
67     /*
68      * Modern browsers are quite happy showing far more than 100 features in
69      * the data browser, so increase the limit to 4000.
70      */
71     const maxFeatures = 4000;
72     const bounds = map.getBounds();
73
74     if (dataLoader) dataLoader.abort();
75
76     $("#layers-data-loading").remove();
77
78     const spanLoading = $("<span>")
79       .attr("id", "layers-data-loading")
80       .attr("class", "spinner-border spinner-border-sm ms-1")
81       .attr("role", "status")
82       .html("<span class='visually-hidden'>" + OSM.i18n.t("browse.start_rjs.loading") + "</span>")
83       .appendTo($("#label-layers-data"));
84
85     dataLoader = new AbortController();
86
87     function getWrappedBounds(bounds) {
88       const sw = bounds.getSouthWest().wrap();
89       const ne = bounds.getNorthEast().wrap();
90       return {
91         minLat: sw.lat,
92         minLng: sw.lng,
93         maxLat: ne.lat,
94         maxLng: ne.lng
95       };
96     }
97
98     function getRequestBounds(bounds) {
99       const wrapped = getWrappedBounds(bounds);
100       if (wrapped.minLng > wrapped.maxLng) {
101         // BBox is crossing antimeridian: split into two bboxes in order to stay
102         // within OSM API's map endpoint permitted range for longitude [-180..180].
103         return [
104           L.latLngBounds([wrapped.minLat, wrapped.minLng], [wrapped.maxLat, 180]),
105           L.latLngBounds([wrapped.minLat, -180], [wrapped.maxLat, wrapped.maxLng])
106         ];
107       }
108       return [L.latLngBounds([wrapped.minLat, wrapped.minLng], [wrapped.maxLat, wrapped.maxLng])];
109     }
110
111     function fetchDataForBounds(bounds) {
112       return fetch(`/api/${OSM.API_VERSION}/map.json?bbox=${bounds.toBBoxString()}`, {
113         signal: dataLoader.signal
114       });
115     }
116
117     const requestBounds = getRequestBounds(bounds);
118     const requests = requestBounds.map(fetchDataForBounds);
119
120     Promise.all(requests)
121       .then(responses =>
122         Promise.all(
123           responses.map(async response => {
124             if (response.ok) {
125               return response.json();
126             }
127
128             const status = response.statusText || response.status;
129             if (response.status !== 400 && response.status !== 509) {
130               throw new Error(status);
131             }
132
133             const text = await response.text();
134             throw new Error(text || status);
135           })
136         )
137       )
138       .then(dataArray => {
139         dataLayer.clearLayers();
140         const allElements = dataArray.flatMap(item => item.elements);
141         const originalFeatures = dataLayer.buildFeatures({ elements: allElements });
142         // clone features when crossing antimeridian to work around Leaflet restrictions
143         const features = requestBounds.length > 1 ?
144           [...originalFeatures, ...cloneFeatures(originalFeatures)] : originalFeatures;
145
146         function addFeatures() {
147           $("#browse_status").empty();
148           dataLayer.addData(features);
149           loadedBounds = bounds;
150         }
151
152         function cancelAddFeatures() {
153           $("#browse_status").empty();
154         }
155
156         if (features.length < maxFeatures * requestBounds.length) {
157           addFeatures();
158         } else {
159           displayFeatureWarning(features.length, addFeatures, cancelAddFeatures);
160         }
161
162         if (map._objectLayer) {
163           map._objectLayer.bringToFront();
164         }
165       })
166       .catch(function (error) {
167         if (error.name === "AbortError") return;
168
169         displayLoadError(error?.message, () => {
170           $("#browse_status").empty();
171         });
172       })
173       .finally(() => {
174         dataLoader = null;
175         spanLoading.remove();
176       });
177   }
178
179   function cloneFeatures(features) {
180     const offset = map.getCenter().lng < 0 ? -360 : 360;
181
182     const cloneNode = ({ latLng, ...rest }) => ({
183       ...rest,
184       latLng: { ...latLng, lng: latLng.lng + offset }
185     });
186
187     return features.flatMap(feature => {
188       if (feature.type === "node") {
189         return [cloneNode(feature)];
190       }
191
192       if (feature.type === "way") {
193         const clonedNodes = feature.nodes.map(cloneNode);
194         return [{ ...feature, nodes: clonedNodes }];
195       }
196
197       return [];
198     });
199   }
200 };