Skip to content

Commit b719a0b

Browse files
authored
Merge pull request #100 from aaronlidman/resilent-requests
Request resiliency, caching, and service abstraction
2 parents 4b19eb5 + 0f7841f commit b719a0b

7 files changed

Lines changed: 730 additions & 163 deletions

File tree

js/change.js

Lines changed: 29 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -2,132 +2,36 @@ import { makeBbox } from './utils';
22

33
import { formatDistanceStrict } from 'date-fns';
44

5-
// Helper function to calculate distance between two points in meters (Haversine formula)
6-
function distanceBetween(lat1, lon1, lat2, lon2) {
7-
const R = 6371000; // Earth's radius in meters
8-
const lat1Rad = lat1 * Math.PI / 180;
9-
const lat2Rad = lat2 * Math.PI / 180;
10-
const dLat = (lat2 - lat1) * Math.PI / 180;
11-
const dLon = (lon2 - lon1) * Math.PI / 180;
12-
13-
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
14-
Math.cos(lat1Rad) * Math.cos(lat2Rad) *
15-
Math.sin(dLon / 2) * Math.sin(dLon / 2);
16-
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
17-
18-
return R * c;
19-
}
20-
215
class Change {
226
constructor(context, changeObj) {
237
this.context = context;
248
Object.assign(this, changeObj);
259
}
2610

27-
fetchChangesetData(id) {
28-
return new Promise((resolve, reject) => {
29-
const cachedData = this.context.changesetCache.get(id);
30-
if (cachedData) {
31-
return resolve(cachedData);
32-
}
33-
34-
fetch(`//www.openstreetmap.org/api/0.6/changeset/${id}`, {
35-
mode: 'cors'
36-
})
37-
.then((response) => response.text())
38-
.then((responseString) => {
39-
return new window.DOMParser()
40-
.parseFromString(responseString, 'text/xml');
41-
})
42-
.then((data) => {
43-
const changesetData = {};
44-
const tags = data.getElementsByTagName('tag');
45-
46-
for (let i = 0; i < tags.length; i++) {
47-
const key = tags[i].getAttribute('k');
48-
const value = tags[i].getAttribute('v');
49-
changesetData[key] = value;
50-
}
51-
52-
this.context.changesetCache.set(id, changesetData);
53-
54-
resolve(changesetData);
55-
})
56-
.catch((err) => {
57-
console.log('Error fetching changeset data', err);
58-
reject(err);
59-
});
60-
});
61-
}
62-
63-
fetchDisplayName(boundsCenter) {
64-
return new Promise((resolve, reject) => {
65-
const CLOSE_THRESHOLD_METERS = 10000;
66-
const closeByKey = this.context.geocodeCache.keys().find((key) => {
67-
const [ lat, lon ] = key.split(',').map(parseFloat);
68-
return distanceBetween(boundsCenter.lat, boundsCenter.lng, lat, lon) < CLOSE_THRESHOLD_METERS;
69-
});
70-
71-
if (closeByKey) {
72-
const cachedGeocode = this.context.geocodeCache.get(closeByKey);
73-
if (cachedGeocode) {
74-
return resolve(cachedGeocode);
75-
}
76-
}
77-
78-
const lat = boundsCenter.lat;
79-
const lon = boundsCenter.lng;
80-
81-
const nominatimUrl = `//nominatim.openstreetmap.org/reverse`
82-
+ `?format=json&lat=${lat}&lon=${lon}&zoom=5`;
83-
84-
fetch(nominatimUrl, {
85-
mode: 'cors'
86-
})
87-
.then((response) => response.json())
88-
.then((data) => {
89-
const id = `${lat},${lon}`;
90-
const displayName = data.display_name;
91-
this.context.geocodeCache.set(id, displayName);
92-
resolve(displayName);
93-
})
94-
.catch((err) => {
95-
console.error('Error fetching location', err);
96-
reject(err);
97-
});
98-
});
99-
}
100-
101-
isRelevant() {
102-
return new Promise((resolve) => {
103-
let commentRelevance = false;
104-
let keyRelevance = false;
105-
const mapElement = this.neu || this.old;
11+
async isRelevant() {
12+
const mapElement = this.neu || this.old;
10613

107-
if (this.context.comment === "" && !this.context.key) {
108-
return resolve(true);
109-
}
14+
if (this.context.comment === "" && !this.context.key) {
15+
return true;
16+
}
11017

111-
this.fetchChangesetData(mapElement.changeset)
112-
.then((changesetData) => {
18+
const changesetData = await this.context.changesetService.get(mapElement.changeset);
11319

114-
commentRelevance =
115-
this.context.comment !== "" &&
116-
changesetData.comment?.toLowerCase()
117-
.includes(this.context.comment.toLowerCase()) || false;
20+
const commentRelevance =
21+
this.context.comment !== "" &&
22+
changesetData.comment?.toLowerCase()
23+
.includes(this.context.comment.toLowerCase()) || false;
11824

119-
keyRelevance = Object.keys(mapElement.tags).includes(this.context.key);
25+
const keyRelevance = Object.keys(mapElement.tags).includes(this.context.key);
12026

121-
if (!(commentRelevance || keyRelevance)) {
122-
console.log(
123-
"Skipping map element " + mapElement.id
124-
+ " because it didn't match filters."
125-
);
126-
}
27+
if (!(commentRelevance || keyRelevance)) {
28+
console.log(
29+
"Skipping map element " + mapElement.id
30+
+ " because it didn't match filters."
31+
);
32+
}
12733

128-
return resolve(commentRelevance | keyRelevance);
129-
});
130-
});
34+
return commentRelevance || keyRelevance;
13135
}
13236

13337
createTagText() {
@@ -145,7 +49,7 @@ class Change {
14549
return 'a ' + mapElement.type;
14650
}
14751

148-
enhance() {
52+
async enhance() {
14953
console.log('Enhancing change, fetching changeset + geocode...');
15054
const startTime = Date.now();
15155
const mapElement = this.type === 'delete' ? this.old : this.neu;
@@ -180,19 +84,16 @@ class Change {
18084

18185
this.tagText = this.createTagText();
18286

183-
return Promise.all([
184-
this.fetchChangesetData(this.meta.changeset),
185-
this.fetchDisplayName(bounds.getCenter()),
186-
]).then(([changesetData, displayName]) => {
187-
console.log(`Enhance complete in ${Date.now() - startTime}ms`);
188-
this.meta.comment = changesetData.comment;
189-
this.meta.createdBy = changesetData.created_by;
190-
this.meta.displayName = displayName;
191-
return this;
192-
}).catch((err) => {
193-
console.error(`Enhance failed after ${Date.now() - startTime}ms:`, err);
194-
throw err;
195-
});
87+
const [changesetData, displayName] = await Promise.all([
88+
this.context.changesetService.get(this.meta.changeset),
89+
this.context.geocodeService.get(bounds.getCenter()),
90+
]);
91+
92+
console.log(`Enhance complete in ${Date.now() - startTime}ms`);
93+
this.meta.comment = changesetData.comment || '';
94+
this.meta.createdBy = changesetData.created_by || '';
95+
this.meta.displayName = displayName || '';
96+
return this;
19697
}
19798
}
19899

js/changeset-service.js

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import LRU from 'lru-cache';
2+
import { fetchWithRetry } from './utils';
3+
4+
const STORAGE_KEY = 'smtw-changesets';
5+
const MAX_SIZE = 500;
6+
const PERSIST_INTERVAL_MS = 30000;
7+
8+
class ChangesetService {
9+
constructor() {
10+
this.cache = this.loadFromStorage();
11+
this.inFlight = new Map(); // Track pending requests to avoid duplicates
12+
this.persistIntervalId = null;
13+
}
14+
15+
loadFromStorage() {
16+
try {
17+
const stored = localStorage.getItem(STORAGE_KEY);
18+
if (stored) {
19+
const cache = LRU(MAX_SIZE);
20+
cache.load(JSON.parse(stored));
21+
console.log(`[ChangesetService] Loaded ${cache.length} entries from cache`);
22+
return cache;
23+
}
24+
} catch (err) {
25+
console.warn('[ChangesetService] Failed to load cache:', err.message);
26+
}
27+
return LRU(MAX_SIZE);
28+
}
29+
30+
saveToStorage() {
31+
// Defer to idle time to avoid blocking UI
32+
const doSave = () => {
33+
try {
34+
const data = JSON.stringify(this.cache.dump());
35+
localStorage.setItem(STORAGE_KEY, data);
36+
console.log(`[ChangesetService] Saved ${this.cache.length} entries to cache`);
37+
} catch (err) {
38+
console.warn('[ChangesetService] Failed to save cache:', err.message);
39+
}
40+
};
41+
42+
if (typeof requestIdleCallback !== 'undefined') {
43+
requestIdleCallback(doSave, { timeout: 10000 });
44+
} else {
45+
setTimeout(doSave, 0);
46+
}
47+
}
48+
49+
saveToStorageSync() {
50+
// Synchronous version for beforeunload (can't use async there)
51+
try {
52+
const data = JSON.stringify(this.cache.dump());
53+
localStorage.setItem(STORAGE_KEY, data);
54+
} catch (err) {
55+
// Ignore errors on unload
56+
}
57+
}
58+
59+
startPersisting() {
60+
if (this.persistIntervalId) return;
61+
62+
this.persistIntervalId = setInterval(() => this.saveToStorage(), PERSIST_INTERVAL_MS);
63+
window.addEventListener('beforeunload', () => this.saveToStorageSync());
64+
}
65+
66+
stopPersisting() {
67+
if (this.persistIntervalId) {
68+
clearInterval(this.persistIntervalId);
69+
this.persistIntervalId = null;
70+
}
71+
}
72+
73+
/**
74+
* Get changeset data by ID
75+
* @param {number} id - Changeset ID
76+
* @returns {Promise<Object>} Changeset tags/metadata
77+
*/
78+
async get(id) {
79+
const cached = this.cache.get(id);
80+
if (cached) {
81+
console.log(`[ChangesetService] Cache hit for changeset ${id}`);
82+
return cached;
83+
}
84+
85+
// Check if request is already in flight
86+
if (this.inFlight.has(id)) {
87+
console.log(`[ChangesetService] Waiting for in-flight request for changeset ${id}`);
88+
return this.inFlight.get(id);
89+
}
90+
91+
console.log(`[ChangesetService] Cache miss, fetching changeset ${id}`);
92+
93+
// Create and track the fetch promise
94+
const fetchPromise = this.fetchChangeset(id);
95+
this.inFlight.set(id, fetchPromise);
96+
97+
try {
98+
const result = await fetchPromise;
99+
return result;
100+
} finally {
101+
this.inFlight.delete(id);
102+
}
103+
}
104+
105+
async fetchChangeset(id) {
106+
const response = await fetchWithRetry(
107+
`//www.openstreetmap.org/api/0.6/changeset/${id}`,
108+
{ mode: 'cors' },
109+
{ timeout: 5000, retries: 2 }
110+
);
111+
const responseString = await response.text();
112+
const data = new window.DOMParser()
113+
.parseFromString(responseString, 'text/xml');
114+
115+
const changesetData = {};
116+
const tags = data.getElementsByTagName('tag');
117+
118+
for (let i = 0; i < tags.length; i++) {
119+
const key = tags[i].getAttribute('k');
120+
const value = tags[i].getAttribute('v');
121+
changesetData[key] = value;
122+
}
123+
124+
this.cache.set(id, changesetData);
125+
return changesetData;
126+
}
127+
}
128+
129+
export { ChangesetService };
130+

0 commit comments

Comments
 (0)