-
Notifications
You must be signed in to change notification settings - Fork 1.2k
/
osmose.js
344 lines (278 loc) · 10.3 KB
/
osmose.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
import RBush from 'rbush';
import { dispatch as d3_dispatch } from 'd3-dispatch';
import { json as d3_json } from 'd3-fetch';
import { marked } from 'marked';
import { fileFetcher } from '../core/file_fetcher';
import { localizer } from '../core/localizer';
import { geoExtent, geoVecAdd } from '../geo';
import { QAItem } from '../osm';
import { utilRebind, utilTiler, utilQsString } from '../util';
const tiler = utilTiler();
const dispatch = d3_dispatch('loaded');
const _tileZoom = 14;
const _osmoseUrlRoot = 'https://osmose.openstreetmap.fr/api/0.3';
let _osmoseData = { icons: {}, items: [] };
// This gets reassigned if reset
let _cache;
function abortRequest(controller) {
if (controller) {
controller.abort();
}
}
function abortUnwantedRequests(cache, tiles) {
Object.keys(cache.inflightTile).forEach(k => {
let wanted = tiles.find(tile => k === tile.id);
if (!wanted) {
abortRequest(cache.inflightTile[k]);
delete cache.inflightTile[k];
}
});
}
function encodeIssueRtree(d) {
return { minX: d.loc[0], minY: d.loc[1], maxX: d.loc[0], maxY: d.loc[1], data: d };
}
// Replace or remove QAItem from rtree
function updateRtree(item, replace) {
_cache.rtree.remove(item, (a, b) => a.data.id === b.data.id);
if (replace) {
_cache.rtree.insert(item);
}
}
// Issues shouldn't obscure each other
function preventCoincident(loc) {
let coincident = false;
do {
// first time, move marker up. after that, move marker right.
let delta = coincident ? [0.00001, 0] : [0, 0.00001];
loc = geoVecAdd(loc, delta);
let bbox = geoExtent(loc).bbox();
coincident = _cache.rtree.search(bbox).length;
} while (coincident);
return loc;
}
export default {
title: 'osmose',
init() {
fileFetcher.get('qa_data')
.then(d => {
_osmoseData = d.osmose;
_osmoseData.items = Object.keys(d.osmose.icons)
.map(s => s.split('-')[0])
.reduce((unique, item) => unique.indexOf(item) !== -1 ? unique : [...unique, item], []);
});
if (!_cache) {
this.reset();
}
this.event = utilRebind(this, dispatch, 'on');
},
reset() {
let _strings = {};
let _colors = {};
if (_cache) {
Object.values(_cache.inflightTile).forEach(abortRequest);
// Strings and colors are static and should not be re-populated
_strings = _cache.strings;
_colors = _cache.colors;
}
_cache = {
data: {},
loadedTile: {},
inflightTile: {},
inflightPost: {},
closed: {},
rtree: new RBush(),
strings: _strings,
colors: _colors
};
},
loadIssues(projection) {
let params = {
// Tiles return a maximum # of issues
// So we want to filter our request for only types iD supports
item: _osmoseData.items
};
// determine the needed tiles to cover the view
let tiles = tiler
.zoomExtent([_tileZoom, _tileZoom])
.getTiles(projection);
// abort inflight requests that are no longer needed
abortUnwantedRequests(_cache, tiles);
// issue new requests..
tiles.forEach(tile => {
if (_cache.loadedTile[tile.id] || _cache.inflightTile[tile.id]) return;
let [ x, y, z ] = tile.xyz;
let url = `${_osmoseUrlRoot}/issues/${z}/${x}/${y}.geojson?` + utilQsString(params);
let controller = new AbortController();
_cache.inflightTile[tile.id] = controller;
d3_json(url, { signal: controller.signal })
.then(data => {
delete _cache.inflightTile[tile.id];
_cache.loadedTile[tile.id] = true;
if (data.features) {
data.features.forEach(issue => {
const { item, class: cl, uuid: id } = issue.properties;
/* Osmose issues are uniquely identified by a unique
`item` and `class` combination (both integer values) */
const itemType = `${item}-${cl}`;
// Filter out unsupported issue types (some are too specific or advanced)
if (itemType in _osmoseData.icons) {
let loc = issue.geometry.coordinates; // lon, lat
loc = preventCoincident(loc);
let d = new QAItem(loc, this, itemType, id, { item });
// Setting elems here prevents UI detail requests
if (item === 8300 || item === 8360) {
d.elems = [];
}
_cache.data[d.id] = d;
_cache.rtree.insert(encodeIssueRtree(d));
}
});
}
dispatch.call('loaded');
})
.catch(() => {
delete _cache.inflightTile[tile.id];
_cache.loadedTile[tile.id] = true;
});
});
},
loadIssueDetail(issue) {
// Issue details only need to be fetched once
if (issue.elems !== undefined) {
return Promise.resolve(issue);
}
const url = `${_osmoseUrlRoot}/issue/${issue.id}?langs=${localizer.localeCode()}`;
const cacheDetails = data => {
// Associated elements used for highlighting
// Assign directly for immediate use in the callback
issue.elems = data.elems.map(e => e.type.substring(0,1) + e.id);
// Some issues have instance specific detail in a subtitle
issue.detail = data.subtitle ? marked(data.subtitle.auto) : '';
this.replaceItem(issue);
};
return d3_json(url).then(cacheDetails).then(() => issue);
},
loadStrings(locale=localizer.localeCode()) {
const items = Object.keys(_osmoseData.icons);
if (
locale in _cache.strings
&& Object.keys(_cache.strings[locale]).length === items.length
) {
return Promise.resolve(_cache.strings[locale]);
}
// May be partially populated already if some requests were successful
if (!(locale in _cache.strings)) {
_cache.strings[locale] = {};
}
// Only need to cache strings for supported issue types
// Using multiple individual item + class requests to reduce fetched data size
const allRequests = items.map(itemType => {
// No need to request data we already have
if (itemType in _cache.strings[locale]) return null;
const cacheData = data => {
// Bunch of nested single value arrays of objects
const [ cat = {items:[]} ] = data.categories;
const [ item = {class:[]} ] = cat.items;
const [ cl = null ] = item.class;
// If null default value is reached, data wasn't as expected (or was empty)
if (!cl) {
/* eslint-disable no-console */
console.log(`Osmose strings request (${itemType}) had unexpected data`);
/* eslint-enable no-console */
return;
}
// Cache served item colors to automatically style issue markers later
const { item: itemInt, color } = item;
if (/^#[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}/.test(color)) {
_cache.colors[itemInt] = color;
}
// Value of root key will be null if no string exists
// If string exists, value is an object with key 'auto' for string
const { title, detail, fix, trap } = cl;
// Osmose titles shouldn't contain markdown
let issueStrings = {};
if (title) issueStrings.title = title.auto;
if (detail) issueStrings.detail = marked(detail.auto);
if (trap) issueStrings.trap = marked(trap.auto);
if (fix) issueStrings.fix = marked(fix.auto);
_cache.strings[locale][itemType] = issueStrings;
};
const [ item, cl ] = itemType.split('-');
// Osmose API falls back to English strings where untranslated or if locale doesn't exist
const url = `${_osmoseUrlRoot}/items/${item}/class/${cl}?langs=${locale}`;
return d3_json(url).then(cacheData);
}).filter(Boolean);
return Promise.all(allRequests).then(() => _cache.strings[locale]);
},
getStrings(itemType, locale=localizer.localeCode()) {
// No need to fallback to English, Osmose API handles this for us
return (locale in _cache.strings) ? _cache.strings[locale][itemType] : {};
},
getColor(itemType) {
return (itemType in _cache.colors) ? _cache.colors[itemType] : '#FFFFFF';
},
postUpdate(issue, callback) {
if (_cache.inflightPost[issue.id]) {
return callback({ message: 'Issue update already inflight', status: -2 }, issue);
}
// UI sets the status to either 'done' or 'false'
const url = `${_osmoseUrlRoot}/issue/${issue.id}/${issue.newStatus}`;
const controller = new AbortController();
const after = () => {
delete _cache.inflightPost[issue.id];
this.removeItem(issue);
if (issue.newStatus === 'done') {
// Keep track of the number of issues closed per `item` to tag the changeset
if (!(issue.item in _cache.closed)) {
_cache.closed[issue.item] = 0;
}
_cache.closed[issue.item] += 1;
}
if (callback) callback(null, issue);
};
_cache.inflightPost[issue.id] = controller;
fetch(url, { signal: controller.signal })
.then(after)
.catch(err => {
delete _cache.inflightPost[issue.id];
if (callback) callback(err.message);
});
},
// Get all cached QAItems covering the viewport
getItems(projection) {
const viewport = projection.clipExtent();
const min = [viewport[0][0], viewport[1][1]];
const max = [viewport[1][0], viewport[0][1]];
const bbox = geoExtent(projection.invert(min), projection.invert(max)).bbox();
return _cache.rtree.search(bbox).map(d => d.data);
},
// Get a QAItem from cache
// NOTE: Don't change method name until UI v3 is merged
getError(id) {
return _cache.data[id];
},
// get the name of the icon to display for this item
getIcon(itemType) {
return _osmoseData.icons[itemType];
},
// Replace a single QAItem in the cache
replaceItem(item) {
if (!(item instanceof QAItem) || !item.id) return;
_cache.data[item.id] = item;
updateRtree(encodeIssueRtree(item), true); // true = replace
return item;
},
// Remove a single QAItem from the cache
removeItem(item) {
if (!(item instanceof QAItem) || !item.id) return;
delete _cache.data[item.id];
updateRtree(encodeIssueRtree(item), false); // false = remove
},
// Used to populate `closed:osmose:*` changeset tags
getClosedCounts() {
return _cache.closed;
},
itemURL(item) {
return `https://osmose.openstreetmap.fr/en/error/${item.id}`;
}
};