-
Notifications
You must be signed in to change notification settings - Fork 0
/
purp-map-state.js
324 lines (284 loc) · 9.09 KB
/
purp-map-state.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
import {map, MIN_ZOOM_LEVEL} from "./purp-map-gmap.js";
const USE_ZSTD = true;
const DELAY = 250; // ms
let recs;
let groups;
let visibleRecs;
let minTm = null, maxTm = null;
let curTm;
function setCurTm(val) {
curTm = val
}
// Time when the last data was generated, used for animation
let lastDataGen;
function updateData()
{
if (!map) {
return;
}
lastDataGen = Date.now();
let bounds = map.getBounds();
if (!bounds) {
return;
}
// Move in time
let HOUR_SEC = 3600;
curTm = curTm + HOUR_SEC;
if (curTm > maxTm) {
if ($("#check-loop").is(":checked")) {
// loop
curTm = minTm;
} else {
curTm = maxTm;
}
}
let zoomLevel = map.getZoom();
zoomLevel = Math.trunc(Math.max(1, zoomLevel - MIN_ZOOM_LEVEL + 1));
// Select the input records
let useGroups = $("#check-group").is(':checked');
let combined = recs;
if (useGroups) {
combined = combined.concat(groups);
}
// Determine visible records
visibleRecs = [];
let weightSum = 0;
combined.forEach(rec => {
let aqi = rec.snaps[curTm];
if (!bounds.contains(rec.position) || !aqi) {
// Not visible
if (rec.visible) {
rec.visible = false;
rec.marker.setMap(null);
}
return;
}
if (useGroups) {
if (rec.minLevel > zoomLevel || rec.maxLevel < zoomLevel) {
return;
}
}
// Visible
visibleRecs.push(rec);
weightSum += rec.weight;
rec.currentAqi = aqi;
// Also compute nextAqi
let nextAqi;
if (curTm < maxTm) {
nextAqi = rec.snaps[curTm + HOUR_SEC];
}
rec.nextAqi = nextAqi || rec.currentAqi;
return;
if (!rec.visible) {
rec.visible = true;
rec.marker.setMap(map);
}
const MAX_AQI = 300;
aqi = Math.min(MAX_AQI, aqi);
let perc = Math.trunc(10 * (aqi / MAX_AQI)) * 10;
let iconUrl = `purp-map-img/icon-${perc}.png`;
rec.icon.url = iconUrl;
rec.marker.setIcon(rec.icon);
rec.marker.setVisible(true);
});
let groupText = useGroups ? ` in ${visibleRecs.length} groups` : ""
$("#tm").html(`Time: ${new Date(curTm * 1000)}<br/>${weightSum}/${recs.length} sensors${groupText}`);
$("#time-slider").attr("min", minTm).attr("max", maxTm).attr("step", HOUR_SEC).attr("value", curTm).val(curTm);
}
/**
* For a set of input records, split into buckets (based on the level)
* and group records in any bucket into a single new record.
* Return records at this level.
* Bucket size gets smaller with level increasing.
* Each record is marked with minLevel and maxLevel, and is visible if (minLevel <= zoomLevel <= maxLevel)
*/
function optimizeSet(set, level)
{
// Stop at level some level
if (level == 10) {
for (let r = 0; r < set.length; r++) {
set[r].maxLevel = 1000; //
}
return set;
}
let latRange = 1.0 / Math.pow(2, level); // Size of a single bucket
let lngRange = latRange;
let buckets = {}
// Put all input recs in their buckets by lat/lng
for (let r = 0; r < set.length; r++) {
let rec = set[r];
let latBucket = Math.floor((rec.lat + 180.0) / latRange) * latRange - 180.0;
let lngBucket = Math.floor((rec.lng + 180.0) / lngRange) * lngRange - 180.0 ;
let bucketKey = 10000 * latBucket + lngBucket;
let list = buckets[bucketKey];
if (!list) {
list = [];
buckets[bucketKey] = list;
}
list.push(rec);
}
let result = [];
// Iterate over all buckets
for (let [key, list] of Object.entries(buckets)) {
if (list.length == 1) {
// Just one entry in this bucket, add it to the list as is.
// Also, this entry is visible from this level (at least)
let rec = list[0]
rec.minLevel = level;
result.push(rec);
continue;
}
// More than 2 records in that bucket.
// Split this bucket contents into up to 4
list = optimizeSet(list, level + 1);
if (list.length == 1) {
// Only 1 of sub-buckets had children, promote it to this level
let rec = list[0]
rec.minLevel = level;
result.push(rec)
continue;
}
// Multiple records in different sub-buckets
// Introduce a new fake record that combines all recs in this bucket
let sumLat = 0, sumLng = 0, avgSnaps = [], sumWeight = 0;
let minLat, maxLat, minLng, maxLng;
minLat = maxLat = list[0].lat;
minLng = maxLng = list[0].lng;
for (let l = 0; l < list.length; l++) {
let rec = list[l];
let recWeight = rec.weight;
sumLat += rec.lat * recWeight;
sumLng += rec.lng * recWeight;
minLat = Math.min(minLat, rec.minLat);
maxLat = Math.max(maxLat, rec.maxLat);
minLng = Math.min(minLng, rec.minLng);
maxLng = Math.max(maxLng, rec.maxLng);
for (const [snap, val] of Object.entries(rec.snaps)) {
// We keep [sum, count] for each snap
let pair = avgSnaps[snap];
if (!pair) {
pair = [0, 0];
avgSnaps[snap] = pair;
}
pair[0] += val * recWeight;
pair[1] += recWeight;
}
sumWeight += rec.weight;
// Mark rec as not visible at this or lower levels
rec.minLevel = level + 1;
}
let snaps = {}
for (const [snap, pair] of Object.entries(avgSnaps)) {
snaps[snap] = pair[0] / pair[1];
}
let lat = sumLat / sumWeight;
let lng = sumLng / sumWeight;
let rec = {
lat: lat,
lng: lng,
position: new google.maps.LatLng(lat, lng),
snaps: snaps,
weight: sumWeight,
minLevel: level,
maxLevel: level,
minLat: minLat,
maxLat: maxLat,
minLng: minLng,
maxLng: maxLng,
};
result.push(rec);
// Also add this new record to the global list of groups
groups.push(rec);
}
return result;
}
function optimizeRecs()
{
groups = [];
for (let r = 0; r < recs.length; r++) {
let rec = recs[r];
rec.weight = 1;
rec.minLat = rec.lat;
rec.maxLat = rec.lat;
rec.minLng = rec.lng;
rec.maxLng = rec.lng;
}
optimizeSet(recs, 1);
}
function parseData(data)
{
if (USE_ZSTD) {
const compressedBuf = data;
const compressed = new Uint8Array(compressedBuf);
const decompressed = fzstd.decompress(compressed);
const decompressedString = new TextDecoder().decode(decompressed);
data = JSON.parse(decompressedString)
} else {
// Nothing to do, data is in JSON already
}
recs = data;
console.log(`Consumed ${recs.length} records`);
for (let i = 0; i < recs.length; i++) {
let rec = recs[i];
let feeds = rec.feeds;
let snaps = {};
let last = 0;
for (let f = 0; f < feeds.length; f += 2) {
let delta = feeds[f];
let val = feeds[f + 1];
let aqi = PurpleAirApi.aqiFromPM(val);
let tm = last + 60 * delta;
snaps[tm] = aqi;
last = tm;
if (!minTm) {
minTm = tm;
maxTm = tm;
}
minTm = Math.min(minTm, tm);
maxTm = Math.max(maxTm, tm);
console.assert(minTm > 10000, `minTm=${minTm} maxTm=${maxTm} tm=${tm}`);
}
rec.snaps = snaps;
// We try to use "lng" ipo "long" everywhere
rec.lng = rec.long;
delete rec.long;
let position = new google.maps.LatLng(rec.lat, rec.lng);
let label = `id: <b>${rec.id}</b>`
let icon = {
url: "img/icon-0.png",
scaledSize: new google.maps.Size(16, 16)
}
const marker = new google.maps.Marker({
position: position,
icon: icon,
title: label,
opacity: 0.5,
optimized: true,
visible: false,
});
// marker.addListener("click", showMarkerInfo.bind(this, rec));
rec.marker = marker;
rec.icon = icon;
rec.position = position;
rec.visible = false;
}
curTm = minTm;
}
async function loadData()
{
const dataFile = "purp-map-data/preproc.json" + (USE_ZSTD ? ".zst" : "")
let waitFor;
if (USE_ZSTD) {
waitFor = fetch(dataFile).then(data => data.arrayBuffer()).then(consumeData);
} else {
waitFor = fetch(dataFile).then(data=>data.json()).then(consumeData);
}
await waitFor;
}
function consumeData(data)
{
parseData(data);
optimizeRecs();
$("#tm").html(`Data ready`);
}
export {loadData, updateData, visibleRecs, setCurTm, USE_ZSTD, lastDataGen, DELAY}