@yz-social/civildefense.io 4.5.22 → 4.5.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/public/about/es.html +1 -1
- package/public/javascripts/alert.js +348 -145
- package/public/javascripts/conversation.js +5 -3
- package/public/javascripts/p2pWebNetwork.js +1 -1
- package/public/javascripts/protocol.js +8 -6
- package/public/javascripts/pubsub.js +31 -10
- package/public/javascripts/s2.js +34 -18
- package/public/javascripts/versions.js +5 -2
- package/public/service-worker.js +3 -1
- package/public/stylesheets/style.css +25 -0
- package/server/app.js +30 -10
- package/server/websocket.js +2 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yz-social/civildefense.io",
|
|
3
3
|
"description": "Browser app to safely share live sightings on a map.",
|
|
4
|
-
"version": "4.5.
|
|
4
|
+
"version": "4.5.23",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"map",
|
|
7
7
|
"browser",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "npm stop; node ./server/app.js",
|
|
12
12
|
"stop": "pkill yz.social",
|
|
13
|
+
"background": "npm stop; node ./server/app.js >server.log &",
|
|
13
14
|
"postinstall": "touch server/location.json; chmod a+w server/location.json",
|
|
14
15
|
"prepack": "mv server/location.json server/location.save.json",
|
|
15
16
|
"postpack": "mv server/location.save.json server/location.json"
|
|
@@ -19,7 +20,7 @@
|
|
|
19
20
|
".": "./index.js"
|
|
20
21
|
},
|
|
21
22
|
"dependencies": {
|
|
22
|
-
"@axona/protocol": "github:axona-net/axona-protocol#semver:^4.
|
|
23
|
+
"@axona/protocol": "github:axona-net/axona-protocol#semver:^4.62.2",
|
|
23
24
|
"cors": "^2.8.6",
|
|
24
25
|
"express": "^4.22.1",
|
|
25
26
|
"leaflet": "^1.9.4",
|
package/public/about/es.html
CHANGED
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
<li>Añadir comentarios, fotos, videos u otros archivos adjuntos.</li>
|
|
80
80
|
<li>Recibir notificaciones de nuevas alertas cerca de ti o en tu mapa actual.</li>
|
|
81
81
|
<li>Compartir en redes sociales, por mensaje de texto o correo electrónico.</li>
|
|
82
|
-
<li>Discutir una alerta mediante videoconferencia segura. (Disponible este
|
|
82
|
+
<li>Discutir una alerta mediante videoconferencia segura. (Disponible este invierno.)</li>
|
|
83
83
|
</ul>
|
|
84
84
|
|
|
85
85
|
<a href="/?lang=es" class="launch-btn" target="civildefense">
|
|
@@ -7,8 +7,8 @@ import { consume } from './display.js';
|
|
|
7
7
|
import { Hashtags } from './hashtags.js';
|
|
8
8
|
import { Agent } from './agent.js';
|
|
9
9
|
import { Conversation, Reply } from './conversation.js';
|
|
10
|
-
import { alertTopic, topicRegion,
|
|
11
|
-
import { getContainingCells, getSubdivision, findCoverCellsByMinMaxLatLng } from './s2.js';
|
|
10
|
+
import { alertTopic, topicRegion, cellHex, topicCell } from './versions.js';
|
|
11
|
+
import { getContainingCells, getSmallestCellId, getSubdivision, findCoverCellsByMinMaxLatLng, cellContains, pointFromLatLng, cellFromCellID } from './s2.js';
|
|
12
12
|
const { localStorage, getComputedStyle, URL, URLSearchParams, domtoimage } = globalThis;
|
|
13
13
|
|
|
14
14
|
|
|
@@ -63,7 +63,9 @@ const ttl = 24 * 60 * 60e3; // 24 hours
|
|
|
63
63
|
let openOnReceive = null;
|
|
64
64
|
export function go({lat = null, lng = null, zoom = null, alert = null}) { // Go to specified location (if any) and open marker (if any).
|
|
65
65
|
if (lat !== null && lng !== null) {
|
|
66
|
-
|
|
66
|
+
lat = parseFloat(lat);
|
|
67
|
+
lng = parseFloat(lng);
|
|
68
|
+
if (zoom) map.flyTo({lat, lng}, parseFloat(zoom));
|
|
67
69
|
else map.flyTo({lat, lng});
|
|
68
70
|
}
|
|
69
71
|
openOnReceive = null;
|
|
@@ -102,13 +104,291 @@ class AlertReply extends Reply {
|
|
|
102
104
|
|
|
103
105
|
import { s2 } from 's2js';
|
|
104
106
|
export class Alert extends Conversation { // A wrapper around L.marker
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
107
|
+
// For each hashtag, we subscribe to a set of non-overlapping cells at varying S2 levels that cover the current map.
|
|
108
|
+
// An Alert is made when a subscription handler fires, and it keeps information that is (mostly) true regardless of
|
|
109
|
+
// which cell brought it in. E.g., each alert has a bookkeeping tag that identifies that map-click, which is
|
|
110
|
+
// the same at all S2 levels, and forms the topic for replies to that alert. When the map changes and the subscriptions
|
|
111
|
+
// are updated, we try to re-use the same Alerts so that the markers don't flash and we don't create garbage.
|
|
112
|
+
|
|
113
|
+
// INSTANCE MANAGEMENT
|
|
114
|
+
// When there are "too many" instances in a cell (at any level), we replace the individual Alerts with a single larger aggregate.
|
|
115
|
+
// This allows us to handle any any quantity of Alerts in memory and visual clutter, and to not mislead users in the presence
|
|
116
|
+
// of publication topic "rollover". However, it greatly complicates insance management.
|
|
117
|
+
|
|
118
|
+
// Conversation.ensure is the subscription handler, and it keeps track of the instances by tag, initializing a new one if needed.
|
|
119
|
+
initialize({topic, payload, hashtag, tag, agent, issuedTime, ...rest}) { // Make appropriate instance for a new individual tag, or update aggregate.
|
|
120
|
+
|
|
121
|
+
if (!Hashtags.isSubscribed(hashtag)) return null; // A subscribed event may have been in flight while unsubscribing. Caller destroys instance.
|
|
122
|
+
const now = Date.now(),
|
|
123
|
+
expiration = issuedTime + ttl,
|
|
124
|
+
remaining = expiration - now;
|
|
125
|
+
if (remaining < 0) return null; // Network shouldn't send us expired, but if it does, let its instance be destroyed.
|
|
126
|
+
|
|
127
|
+
const eventName = topic.name;
|
|
128
|
+
let keep = this; // Instance to be kept as marker.
|
|
129
|
+
let aggregate = this.constructor.getAggregate(eventName); // If we already have one for this eventName
|
|
130
|
+
|
|
131
|
+
// Each new initialization gets counted, which may be more than the network rollover.
|
|
132
|
+
// We do not "back up" for deletions and expirations - i.e., subtract and possibly go back to individual alerts.
|
|
133
|
+
// Note that initialize() will not be called for an event handler that has a tag (msgId) the same as one we have already seen for a different cell scale.
|
|
134
|
+
this.constructor.subscriptions[eventName]++;
|
|
135
|
+
|
|
136
|
+
if (aggregate) {
|
|
137
|
+
keep = null; // This new instance is superfluous. Tell ensure() to destroy it...
|
|
138
|
+
aggregate.lerp(eventName, payload.lat, payload.lng); // ...but nudge the existing aggregate towards us.
|
|
139
|
+
} else { // Does not exist yet
|
|
140
|
+
let {lat, lng, originalPosting} = payload;
|
|
141
|
+
lat = parseFloat(lat);
|
|
142
|
+
lng = parseFloat(lng);
|
|
143
|
+
if (this.constructor.cellCountOverLimit(eventName)) aggregate = this; // If now over, treat this marker as an aggregate.
|
|
144
|
+
const icon = this.constructor.makeIcon(hashtag, tag, aggregate);
|
|
145
|
+
const marker = this.marker = L.marker([lat, lng], {icon, autoPan: false}).addTo(map);
|
|
146
|
+
const region = P2PWebNetwork.regionCode(lat, lng);
|
|
147
|
+
hashtag = Hashtags.add(hashtag); // We already have it and are subscribing, but this updates our extended form if needed.
|
|
148
|
+
super.initialize({payload, hashtag, tag, agent, lat, lng, issuedTime, originalPosting, ...rest});
|
|
149
|
+
if (aggregate) {
|
|
150
|
+
// Destroy existing eventName markers and add their positions to the aggregate we are creating.
|
|
151
|
+
this.becomeAggregate(eventName);
|
|
152
|
+
this.constructor.clearEventMarkers(eventName, aggregate);
|
|
153
|
+
} else {
|
|
154
|
+
this.noteEventName(eventName);
|
|
155
|
+
marker.bindPopup('', {className: 'alert'}).on('popupopen', event => this.ensureContent(event.popup));
|
|
156
|
+
tooltip(marker.getElement(), Int`Show conversation for this ${hashtag} alert.`);
|
|
157
|
+
if (tag === openOnReceive) { // Bug! How can we handle URLs to an alert that has been aggregated?
|
|
158
|
+
openOnReceive = false;
|
|
159
|
+
this.openPopup();
|
|
160
|
+
}
|
|
161
|
+
networkPromise.then(async contact => { // Subscribe to replies to this tag, now that we have an alert for them to go to.
|
|
162
|
+
contact.subscribe({eventName: tag, region, handler: data => this.ensure(data)});
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const alert = aggregate || this;
|
|
167
|
+
alert.startExpiration('.alert-pin', remaining);
|
|
168
|
+
alert.showNotification({agent, issuedTime});
|
|
169
|
+
return keep;
|
|
170
|
+
}
|
|
171
|
+
update({topic, ts, ...rest}) { // Called when handling an existing Conversation. super confirms that nothing immutable has changed.
|
|
172
|
+
return super.update({...rest}); // topic and ts vary with level, and so must not be part of ensure/update checks.
|
|
173
|
+
}
|
|
174
|
+
async destroy(markerDelayMS = 400) { // Remove this Alert pin entirely, either through unpublish, expiration, or conversion of a cell to aggregate.
|
|
175
|
+
// We do not decrement Alert.subscriptions[this.eventName] and unaggregate into individual markers.
|
|
176
|
+
// That won't happen until the user completely unsubscribes from this cell and resubscribes (by toggle or map movement).
|
|
177
|
+
clearInterval(this['.alert-pin']);
|
|
178
|
+
clearInterval(this['.alert-commented']);
|
|
179
|
+
clearInterval(this.destroyer);
|
|
180
|
+
this.clearAvatars();
|
|
181
|
+
const {isAggregate, marker, tag, region} = this;
|
|
182
|
+
// Unsubscribe from replies.
|
|
183
|
+
if (!isAggregate) networkPromise?.then(async contact => contact.subscribe({eventName: tag, region, handler: null}));
|
|
184
|
+
this.cellBorder?.removeFrom(map);
|
|
185
|
+
super.destroy();
|
|
186
|
+
if (markerDelayMS) {
|
|
187
|
+
marker.closePopup();
|
|
188
|
+
marker.unbindPopup(); // It would be confusing if it happens to be open, or clicked on while being removed.
|
|
189
|
+
marker.setOpacity(0);
|
|
190
|
+
await P2PWebNetwork.delay(markerDelayMS);
|
|
191
|
+
}
|
|
192
|
+
marker.removeFrom(map);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
static subscriptionQueue = Promise.resolve(); // Serialize updates so they don't overlap each other.
|
|
196
|
+
static async updateSubscriptions({newKeys, oldKeys, throttleMS = 20} = {}) { // Update current subscriptions.
|
|
197
|
+
// A value of {} passed for oldKeys is used to start things off fresh (i.e., without supressing subscription of any carry-overs).
|
|
198
|
+
return this.subscriptionQueue = this.subscriptionQueue.then(async () => {
|
|
199
|
+
oldKeys ||= this.subscriptions;
|
|
200
|
+
newKeys ||= this.subscriptionFromMap();
|
|
201
|
+
|
|
202
|
+
if (!newKeys) return; // e.g., wacky computation. Don't change anything.
|
|
203
|
+
const contact = await networkPromise;
|
|
204
|
+
const dropped = [], added = [];
|
|
205
|
+
if (!contact) { console.warn("No network through which to subscribe."); return; } // Does this ever happen? Why?
|
|
206
|
+
this.subscriptions = newKeys; // Before subscribing.
|
|
207
|
+
const subscribe = async (eventName, handler) => {
|
|
208
|
+
if (!eventName) console.log('sub to no eventName', {oldKeys, newKeys, dropped, added, handler});
|
|
209
|
+
const region = topicRegion(eventName);
|
|
210
|
+
if (handler) Agent.current?.trackPublicChanges(region); // Background. No need to await.
|
|
211
|
+
await contact.subscribe({eventName, region, handler}).then(() => throttleMS && P2PWebNetwork.delay(throttleMS));
|
|
212
|
+
};
|
|
213
|
+
for (const key in newKeys) oldKeys.hasOwnProperty(key) || added.push(key);
|
|
214
|
+
for (const key in oldKeys) newKeys.hasOwnProperty(key) || dropped.push(key);
|
|
215
|
+
console.log('updating subscriptions', {added, dropped, newKeys, oldKeys});
|
|
216
|
+
|
|
217
|
+
// Before subscribing, as that that may bring in an alert with the same tag as one being cleared.
|
|
218
|
+
if (this.aggregateLimit) this.transferOrClearEventMarkers(added, dropped, newKeys, oldKeys);
|
|
219
|
+
|
|
220
|
+
for (const key of added) await subscribe(key, data => Alert.ensure(data));
|
|
221
|
+
for (const key of dropped) await subscribe(key, null);
|
|
222
|
+
|
|
223
|
+
// for (const alert of this.items) { // fixme remove
|
|
224
|
+
// if (this.subscriptions[alert.eventName] == undefined) {
|
|
225
|
+
// let kind = added.includes(alert.eventName) && 'added';
|
|
226
|
+
// kind ||= dropped.includes(alert.eventName) && 'dropped';
|
|
227
|
+
// kind ||= Object.keys(newKeys).includes(alert.eventName) && 'new';
|
|
228
|
+
// kind ||= Object.keys(oldKeys).includes(alert.eventName) && 'old';
|
|
229
|
+
// throw new Error(`${kind} ${alert.eventName} has no count after subscription update.`);
|
|
230
|
+
// }
|
|
231
|
+
// }
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Instance Management Internals
|
|
236
|
+
// In general here, a key is an eventName - i.e., a string <mumble>:<cellID>:<hashtag>.
|
|
237
|
+
// newKeys/oldKeys are a map of the currently subscribed eventName and the count of events for that cell+hashtag
|
|
108
238
|
static subscriptions = {}; // maps currently active eventNames (<mumble>:<cellID>:<hashtag>) to count of event received for it.
|
|
239
|
+
static aggregateLimit = parseInt(new URLSearchParams(location.search).get('max') || 100); // How many individuals are allowed before we aggregate.
|
|
240
|
+
static cellCountOverLimit(eventName, countsDictionary = this.subscriptions) { // Have we received enough that we must show an aggregate?
|
|
241
|
+
return countsDictionary[eventName] >= this.aggregateLimit;
|
|
242
|
+
}
|
|
243
|
+
static getAggregate(eventName) { // Answer the existing aggregate for this cell, if any.
|
|
244
|
+
return this.getItem(eventName); // While individual Alerts are stored in items/conversations by tag, the tag for aggregate is the eventName.
|
|
245
|
+
}
|
|
246
|
+
logAlert(label = '') { // For debugging, when an individual or aggregate marker is clicked, show the alert, cell, and count in console.
|
|
247
|
+
const {lat, lng, eventName} = this;
|
|
248
|
+
console.warn(`${label} lat: ${lat}, lng: ${lng}, ${eventName}: ${this.constructor.subscriptions[eventName]}`);
|
|
249
|
+
}
|
|
250
|
+
noteEventName(eventName) { // Be a part of the specified grouping.
|
|
251
|
+
if (this.eventName === eventName) return;
|
|
252
|
+
this.eventName = eventName;
|
|
253
|
+
if (!this.isAggregate) return;
|
|
254
|
+
this.cellBorder?.removeFrom(map);
|
|
255
|
+
const cell = cellFromCellID(topicCell(eventName));
|
|
256
|
+
const radiansToDegrees = 180 / Math.PI;
|
|
257
|
+
const corners = Array.from({ length: 4 }, (_, i) => {
|
|
258
|
+
const point = cell.vertex(i);
|
|
259
|
+
const latLng = s2.LatLng.fromPoint(point);
|
|
260
|
+
return [latLng.lat * radiansToDegrees, latLng.lng * radiansToDegrees];
|
|
261
|
+
});
|
|
262
|
+
const border = this.cellBorder = L.polygon(corners, {color: 'red'});
|
|
263
|
+
border.addTo(map);
|
|
264
|
+
}
|
|
265
|
+
static forEachAlertOf(eventName, callback, alerts = this.items) { // Apply callback to each of alerts matching eventName.
|
|
266
|
+
// TODO? Record a cell's Alert's more efficiently, so that we don't have to cycle through everything.
|
|
267
|
+
alerts.forEach((alert, index, alerts) => (alert.eventName === eventName) && callback(alert, index, alerts));
|
|
268
|
+
}
|
|
269
|
+
static clearEventMarkers(eventName, aggregate = null, alerts = this.items) { // destroy each, but
|
|
270
|
+
// if aggregate is specified, keep the aggregate and average all position into the aggregate.
|
|
271
|
+
if (!aggregate) return this.forEachAlertOf(eventName, alert => alert.destroy(), alerts);
|
|
272
|
+
const eachExceptAggregate = cb => this.forEachAlertOf(eventName, alert => (alert === aggregate) || cb(alert), alerts);
|
|
273
|
+
let lat = aggregate.lat, lng = aggregate.lng, count = 1;
|
|
274
|
+
if (aggregate.eventName != eventName) { // Loading from another eventName. Keep existing weight.
|
|
275
|
+
count = this.subscriptions[aggregate.eventName];
|
|
276
|
+
lat *= count;
|
|
277
|
+
lng *= count;
|
|
278
|
+
}
|
|
279
|
+
eachExceptAggregate(alert => { lat += alert.lat; lng += alert.lng; count++; });
|
|
280
|
+
lat /= count;
|
|
281
|
+
lng /= count;
|
|
282
|
+
return this.forEachAlertOf(eventName, alert => {
|
|
283
|
+
alert.reposition(lat, lng);
|
|
284
|
+
if (alert !== aggregate) alert.destroy(400); // May or may not be default. Half the translation transition time.
|
|
285
|
+
}, alerts);
|
|
286
|
+
}
|
|
287
|
+
becomeAggregate(eventName) { // Make an individual alert be an aggregate
|
|
288
|
+
const {tag, marker, hashtag, region} = this;
|
|
289
|
+
const element = marker.getElement();
|
|
290
|
+
const pin = element.querySelector('.alert-pin');
|
|
291
|
+
this.isAggregate = true;
|
|
292
|
+
pin.classList.toggle('aggregate', true); pin.classList.toggle('starting', true);
|
|
293
|
+
setTimeout(() => pin.classList.toggle('starting', false), 100);
|
|
294
|
+
marker.unbindPopup();
|
|
295
|
+
marker.off('click');
|
|
296
|
+
marker.on('click', event => this.logAlert('FIXME go down one level'));
|
|
297
|
+
tooltip(element, Int`Zoom in on multiple ${hashtag} alerts.`); // fixme Int.
|
|
298
|
+
this.noteEventName(this.tag = eventName);
|
|
299
|
+
if (tag !== eventName) {
|
|
300
|
+
networkPromise.then(async contact => contact.subscribe({eventName: tag, region, handler: null})); // Unsubscribe from replies.
|
|
301
|
+
this.items = [];
|
|
302
|
+
clearInterval(this['.alert-commented']);
|
|
303
|
+
element.querySelector('.alert-commented').style = "opacity: 0;";
|
|
304
|
+
this.constructor.removeItem(tag);
|
|
305
|
+
}
|
|
306
|
+
this.constructor.setItem(eventName, this);
|
|
307
|
+
}
|
|
308
|
+
static handleAggregation(droppedEventName, addedEventName, newCounts, alerts) { // Return aggregate if over limit, setting it up if necessary. Else null.
|
|
309
|
+
// If over limit, converts an addedEventName Alert if needed and clears the rest, and then clears all droppedEventName Alerts.
|
|
310
|
+
if (!this.cellCountOverLimit(addedEventName, newCounts)) return null;
|
|
311
|
+
let aggregate = this.getAggregate(addedEventName);
|
|
312
|
+
if (!aggregate) { // If no existing aggregate:
|
|
313
|
+
aggregate = alerts.find(alert => alert.eventName === droppedEventName); // Pick a dropped individual to become aggregate. There will be one, by construction.
|
|
314
|
+
aggregate.becomeAggregate(addedEventName);
|
|
315
|
+
this.clearEventMarkers(addedEventName, aggregate, alerts); // Kill the rest, adding their positions to the aggregate.
|
|
316
|
+
}
|
|
317
|
+
return aggregate;
|
|
318
|
+
}
|
|
319
|
+
static transferOrClearEventMarkers(added, dropped, newCounts, oldCounts) { // Clear dropped alerts as necessary,
|
|
320
|
+
// but try to preserve (individual and aggregated) alerts so that the markers don't flash.
|
|
321
|
+
const addedCells = added.map(topicCell); // List of BigInt, in same order as added eventNames.
|
|
322
|
+
const alerts = this.items;
|
|
323
|
+
dropped.sort((a, b) => oldCounts[b] - oldCounts[a]); // So that we always process aggregates before related individuals.
|
|
324
|
+
dropped.forEach(droppedEventName => {
|
|
325
|
+
const droppedCell = topicCell(droppedEventName);
|
|
326
|
+
// Whether zooming in or out, each dropped cell may have added cells that contain it, or vice versa.
|
|
327
|
+
// We don't have to worry about cells that are neither added nor dropped, because those will not overlap added or dropped.
|
|
328
|
+
|
|
329
|
+
// Find the one added cell (if any) that contains the dropped cell. (Typically when zooming out.)
|
|
330
|
+
const containerIndex = addedCells.findIndex(added => cellContains(added, droppedCell));
|
|
331
|
+
if (containerIndex >= 0) {
|
|
332
|
+
const addedContainingEventName = added[containerIndex];
|
|
333
|
+
newCounts[addedContainingEventName] += oldCounts[droppedEventName]; // It all goes to the container, which may have already started filling.
|
|
334
|
+
const aggregate = this.handleAggregation(droppedEventName, addedContainingEventName, newCounts, alerts);
|
|
335
|
+
if (aggregate) {
|
|
336
|
+
this.clearEventMarkers(droppedEventName, aggregate, alerts); // Absorb the discarded. Their distinctiveness will be added to our own.
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
this.forEachAlertOf(droppedEventName, alert => alert.noteEventName(addedContainingEventName)); // Label the dropped individual alerts for new container.
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const aggregate = this.getAggregate(droppedEventName);
|
|
344
|
+
if (aggregate) { // We don't know how mucch of this will be included in any added subregion, and cannot reconstruct where they were.
|
|
345
|
+
aggregate.destroy();
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Find the 0-4 added cells that are contained by the dropped cell. (Typically when zooming in.)
|
|
350
|
+
const addedSubCells = addedCells.filter(added => cellContains(droppedCell, added));
|
|
351
|
+
// We don't know how many oldCounts to distribute to each subcell, so we have to work with each dropped alert's location.
|
|
352
|
+
if (addedSubCells.length) {
|
|
353
|
+
const addedS2Cells = addedSubCells.map(cellFromCellID);
|
|
354
|
+
this.forEachAlertOf(droppedEventName, alert => {
|
|
355
|
+
const point = pointFromLatLng(alert.lat, alert.lng);
|
|
356
|
+
const addedS2Cell = addedS2Cells.find(cell => cell.containsPoint(point));
|
|
357
|
+
if (addedS2Cell) {
|
|
358
|
+
const includedIndex = addedCells.indexOf(addedS2Cell.id);
|
|
359
|
+
const addedIncludedEventName = added[includedIndex];
|
|
360
|
+
newCounts[addedIncludedEventName] += 1;
|
|
361
|
+
if (this.handleAggregation(droppedEventName, addedIncludedEventName, newCounts, alerts)) return;
|
|
362
|
+
alert.noteEventName(addedIncludedEventName); // Leave this alert in place, but label where it belongs.
|
|
363
|
+
} else {
|
|
364
|
+
alert.destroy(); // This alert is not within an added subcell.
|
|
365
|
+
}
|
|
366
|
+
}, alerts);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Otherwise, no overlap, so clear the (individual and aggregate) markers of the dropped cell.
|
|
371
|
+
this.clearEventMarkers(droppedEventName);
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
lerp(eventName, additionaLatitude, additionalLongitude) { // Move this Alert towards this location, inversely weight by count in cell.
|
|
375
|
+
const count = this.constructor.subscriptions[eventName];
|
|
376
|
+
const k = 1 / count;
|
|
377
|
+
const k1 = 1 - k;
|
|
378
|
+
const {lat, lng} = this;
|
|
379
|
+
this.reposition(k1 * lat + k * additionaLatitude,
|
|
380
|
+
k1 * lng + k * additionalLongitude);
|
|
381
|
+
}
|
|
382
|
+
reposition(lat, lng) { // Update the marker's position for new data.
|
|
383
|
+
this.lat = lat;
|
|
384
|
+
this.lng = lng;
|
|
385
|
+
this.marker.setLatLng([lat, lng]);
|
|
386
|
+
this.startExpiration('.alert-pin', Date.now() + ttl);
|
|
387
|
+
}
|
|
388
|
+
|
|
109
389
|
// We do not record exactly where you were looking across sessions, but we do record the containing level 9 cell.
|
|
110
390
|
static lastLevel9Cell = null; // S2 level 9 cells average a radius of about 10km ~ 6.5 miles.
|
|
111
|
-
static subscriptionFromMap() { // Generate {eventName => count} for current map bounds.
|
|
391
|
+
static subscriptionFromMap() { // Generate new subscriptions list ({eventName => count}) for current map bounds.
|
|
112
392
|
const center = map.getCenter();
|
|
113
393
|
const bounds = map.getBounds();
|
|
114
394
|
const northEast = bounds.getNorthEast();
|
|
@@ -123,84 +403,47 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
123
403
|
});
|
|
124
404
|
if (!newCells) return null;
|
|
125
405
|
const newKeys = {};
|
|
126
|
-
|
|
127
|
-
newCells.forEach(cell => Hashtags.getSubscribe().forEach(hash => {
|
|
406
|
+
newCells.forEach(cell => Hashtags.getSubscribe().forEach(hash => { // Populate count with existing count (exctly carried over cell sizes), else 0.
|
|
128
407
|
const eventName = alertTopic(cell, hash);
|
|
129
408
|
newKeys[eventName] = this.subscriptions[eventName] || 0;
|
|
130
409
|
}));
|
|
131
410
|
// Record a zoomed-out cell id in case next session does not have geolocation services.
|
|
132
|
-
let level9Cell =
|
|
411
|
+
let level9Cell = getSmallestCellId(center.lat, center.lng, 9);
|
|
133
412
|
if (level9Cell !== this.lastLevel9Cell) localStorage.setItem('level9Cell', this.lastLevel9Cell = level9Cell);
|
|
134
413
|
return newKeys;
|
|
135
414
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
this.subscriptions = newKeys; // Before subscribing.
|
|
146
|
-
const subscribe = async (key, handlerIn) => {
|
|
147
|
-
const handler = handlerIn && (properties => {
|
|
148
|
-
// When there are enough alerts within a topic, it rolls over and just reports the most recent number.
|
|
149
|
-
// When get near that count, we update the subscriptions such that the overloading topic is replaced
|
|
150
|
-
// with the topics for each of the four subcells that that make up the one with too many alerts.
|
|
151
|
-
// This repeats until we reach cells that are not rolling over.
|
|
152
|
-
const maxAlertsInCell = 900;
|
|
153
|
-
let count = ++newKeys[key];
|
|
154
|
-
handlerIn(properties);
|
|
155
|
-
if (count >= maxAlertsInCell) {
|
|
156
|
-
let nextKeys = {};
|
|
157
|
-
for (const old in newKeys) {
|
|
158
|
-
if (old !== key) {
|
|
159
|
-
nextKeys[old] = newKeys[old];
|
|
160
|
-
} else {
|
|
161
|
-
const cellTag = topicCell(key);
|
|
162
|
-
const subdivisions = getSubdivision(cellTag);
|
|
163
|
-
const topics = subdivisions.map(cellTag => alertTopic(cellTag, properties.hashtag));
|
|
164
|
-
console.log('subdividing', key, 'into', topics);
|
|
165
|
-
topics.forEach(topic => nextKeys[topic] = 0);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
this.updateSubscriptions({oldKeys: newKeys, newKeys: nextKeys, throttleMS});
|
|
169
|
-
}
|
|
170
|
-
});
|
|
171
|
-
const region = topicRegion(key);
|
|
172
|
-
Agent.current?.trackPublicChanges(region); // Background. No need to await.
|
|
173
|
-
await contact.subscribe({eventName: key, region, handler}).then(() => throttleMS && P2PWebNetwork.delay(throttleMS));
|
|
174
|
-
};
|
|
175
|
-
console.log('updating subscriptions', {newKeys, oldKeys});
|
|
176
|
-
// For each entry in the new subscription set that was not previously subscribed, subscribe now.
|
|
177
|
-
for (const key in newKeys) oldKeys.hasOwnProperty(key) || await subscribe(key, data => Alert.ensure(data));
|
|
178
|
-
// For each existing subscription, if it does not appear in the new set then unsubscribe.
|
|
179
|
-
for (const key in oldKeys) newKeys.hasOwnProperty(key) || await subscribe(key, null);
|
|
180
|
-
}
|
|
415
|
+
|
|
416
|
+
// PUBLISHING NEW ALERTS
|
|
417
|
+
// Each map click publishes to that point at each supported S2 level that contains it, so that any map display of
|
|
418
|
+
// non-overlapping cells will catch it. (Network subscriptions are much more expensive than publishing, and the app
|
|
419
|
+
// users watch more than they publish, so we minimize the number of subscriptions that a user has at any moment.)
|
|
420
|
+
|
|
421
|
+
// The app (not the network) restricts the user to publish up to maxPublish alerts over the last maxPublish minutes.
|
|
422
|
+
// I.e., one / minute, but allowing bursts up to maxPublish. After that, one can still publish, but kill the oldest.
|
|
423
|
+
// We keep enough data in memory that we can reproduce what to kill,m even if Alert has since scrolled off the map and been destroyed.
|
|
181
424
|
static maxPublish = 5;
|
|
182
425
|
static publishing = false;
|
|
183
|
-
static lastPublished = []; // Last published lat, lng,
|
|
426
|
+
static lastPublished = []; // Last published lat, lng, hashtag
|
|
184
427
|
// Publish an alert to all applicable eventNames, canceling as required. Promises tag (msgId).
|
|
185
428
|
static async publish({lat, lng,
|
|
186
429
|
originalPosting = undefined,
|
|
187
430
|
hashtag = Hashtags.getPublish(true),
|
|
188
|
-
payload = {lat, lng, originalPosting}, // If payload is null (cancels
|
|
431
|
+
payload = {lat, lng, originalPosting}, // If payload is null (cancels tag), lat & lng are still used to generate eventNames.
|
|
189
432
|
cancel = undefined, // First unpublish the specified data, if any. Complicated default.
|
|
190
|
-
issuedTime = Date.now(),
|
|
433
|
+
issuedTime = Date.now(), tag,
|
|
191
434
|
throttleMS = 0,
|
|
192
435
|
...rest
|
|
193
436
|
}) {
|
|
194
|
-
// We call all the publishing at once and return
|
|
437
|
+
// We call all the publishing at once and return tag, without waiting for each to occur.
|
|
195
438
|
// However, the 'unpublishing' (if any) is invoked first.
|
|
196
439
|
// To do this, we must hash the eventName ourselves.
|
|
197
|
-
//console.log('publish', {lat, lng, hashtag, payload, cancel,
|
|
440
|
+
//console.log('publish', {lat, lng, hashtag, payload, cancel, tag, issuedTime, rest});
|
|
198
441
|
if (this.publishing) { console.log('skiping overlapping publish'); return null; } // do not stack them up.
|
|
199
442
|
try {
|
|
200
443
|
this.publishing = true;
|
|
201
444
|
|
|
202
445
|
const contact = await networkPromise; // subtle: The rest of this all happens synchronously, with any null payloads definitely first.
|
|
203
|
-
let oldCells = null, oldHash,
|
|
446
|
+
let oldCells = null, oldHash, oldTag = null; // Recorded for logging, below.
|
|
204
447
|
let lastFillIn;
|
|
205
448
|
if (payload) {
|
|
206
449
|
lastFillIn = {lat, lng, hashtag, issuedTime};
|
|
@@ -213,14 +456,14 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
213
456
|
}
|
|
214
457
|
}
|
|
215
458
|
if (cancel) {
|
|
216
|
-
const {lat, lng, hashtag,
|
|
459
|
+
const {lat, lng, hashtag, tag} = cancel;
|
|
217
460
|
oldCells = getContainingCells(lat, lng);
|
|
218
|
-
oldHash = hashtag;
|
|
461
|
+
oldHash = hashtag; oldTag = tag;
|
|
219
462
|
const region = P2PWebNetwork.regionCode(lat, lng);
|
|
220
463
|
for (const cell of oldCells) {
|
|
221
464
|
const eventName = alertTopic(cell, hashtag);
|
|
222
465
|
// Note: we cannot unpublish replies by others, but they expire after a while anyway.
|
|
223
|
-
await contact.publish({eventName, region, killTag:
|
|
466
|
+
await contact.publish({eventName, region, killTag: tag, payload: null});
|
|
224
467
|
throttleMS && await P2PWebNetwork.delay(throttleMS);
|
|
225
468
|
}
|
|
226
469
|
}
|
|
@@ -234,23 +477,23 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
234
477
|
// and when combined with the publisher's authorId will be unique to this user/time/hashtag,
|
|
235
478
|
// and yet the same for each of the individual publications at the different s2 scales.
|
|
236
479
|
const msgId = await contact.publish({eventName, region, payload, issuedTime, hashtag, ...rest});
|
|
237
|
-
if (
|
|
238
|
-
|
|
480
|
+
if (tag && tag !== msgId) throw new Error(`msgId is drifting: ${tag} => ${msgId}`);
|
|
481
|
+
tag = msgId;
|
|
239
482
|
if (lastFillIn) {
|
|
240
|
-
lastFillIn.
|
|
483
|
+
lastFillIn.tag = tag;
|
|
241
484
|
lastFillIn = null;
|
|
242
485
|
}
|
|
243
486
|
} else {
|
|
244
|
-
await contact.publish({eventName, region, killTag:
|
|
487
|
+
await contact.publish({eventName, region, killTag: tag, payload: null});
|
|
245
488
|
throttleMS && await P2PWebNetwork.delay(throttleMS);
|
|
246
489
|
}
|
|
247
490
|
}
|
|
248
491
|
if (!payload) {
|
|
249
|
-
const index = this.lastPublished.findIndex(past => past.
|
|
492
|
+
const index = this.lastPublished.findIndex(past => past.tag === tag);
|
|
250
493
|
if (index >= 0) this.lastPublished.splice(index, 1);
|
|
251
494
|
}
|
|
252
|
-
console.log('Published', {cells, n: cells.length, region, hashtag,
|
|
253
|
-
return
|
|
495
|
+
console.log('Published', {cells, n: cells.length, region, hashtag, tag, payload, oldCells, oldHash, oldTag});
|
|
496
|
+
return tag;
|
|
254
497
|
} finally {
|
|
255
498
|
this.publishing = false;
|
|
256
499
|
}
|
|
@@ -261,7 +504,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
261
504
|
map.closePopup();
|
|
262
505
|
Hashtags.closeSelector();
|
|
263
506
|
}
|
|
264
|
-
static openPopup(alertTag) { // Open the marker specified by
|
|
507
|
+
static openPopup(alertTag) { // Open the marker specified by tag.
|
|
265
508
|
const wrapper = this.getItem(alertTag);
|
|
266
509
|
wrapper?.openPopup() || (openOnReceive = alertTag);
|
|
267
510
|
}
|
|
@@ -274,9 +517,10 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
274
517
|
}
|
|
275
518
|
this.marker.openPopup();
|
|
276
519
|
}
|
|
277
|
-
static makeIcon(hashtag) { // Return a Leaflet icon.
|
|
520
|
+
static makeIcon(hashtag, tag, isAggregate = false) { // Return a Leaflet icon.
|
|
521
|
+
// tag is handy for debugging. TODO: are these cacheable and reusable?
|
|
278
522
|
return L.divIcon({
|
|
279
|
-
html: `<div class="alert-commented"></div><div class="alert-pin">${Hashtags.formatAlert(hashtag)}</div>`,
|
|
523
|
+
html: `<div class="alert-commented"></div><div class="alert-pin${isAggregate ? ' aggregate' : ''}" data-debug="${tag}">${Hashtags.formatAlert(hashtag)}</div>`,
|
|
280
524
|
iconSize: [40, 40],
|
|
281
525
|
popupAnchor: [0, 0],
|
|
282
526
|
className: 'alert-marker'
|
|
@@ -286,12 +530,12 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
286
530
|
this.items.forEach(wrapper => {
|
|
287
531
|
const { hashtag, marker, agent } = wrapper;
|
|
288
532
|
if (hashtag !== canonicalHashtag) return;
|
|
289
|
-
const newIcon = this.makeIcon(extendedHashtag);
|
|
290
|
-
const popup = marker.getPopup();
|
|
533
|
+
const newIcon = this.makeIcon(extendedHashtag, wrapper.tag);
|
|
291
534
|
marker.setIcon(newIcon);
|
|
292
535
|
wrapper.hashtag = extendedHashtag;
|
|
293
536
|
wrapper.needsRedisplay = true; // See comment for initializeHandlers. We need to clear and rebuild content on re-open.
|
|
294
|
-
|
|
537
|
+
const popup = marker.getPopup();
|
|
538
|
+
if (!popup?.isOpen()) return; // Either an aggregate or closed.
|
|
295
539
|
// Fix what's showing now without flashing everything. Make sure menu works.
|
|
296
540
|
const popupAttribution = popup.getElement().querySelector('.attribution');
|
|
297
541
|
const attributionActions = popupAttribution.lastElementChild;
|
|
@@ -300,45 +544,11 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
300
544
|
wrapper.initChangeHashtag(popupAttribution);
|
|
301
545
|
});
|
|
302
546
|
}
|
|
303
|
-
static async ensure({tag, topic, ts, issuedTime, ...rest}) { // Add marker at position with appropriate fade if not already present.
|
|
304
|
-
const alert = await super.ensure({tag, subject: tag, issuedTime, ...rest}); // Does not include topic or ts. fixme: get rid of subject. fixme: why is ts different?
|
|
305
|
-
if (!alert) return null;
|
|
306
|
-
// Regardless of initialize vs update, reset fader.
|
|
307
|
-
const now = Date.now(),
|
|
308
|
-
expiration = issuedTime + ttl,
|
|
309
|
-
remaining = expiration - now;
|
|
310
|
-
if (remaining < 0) return alert?.destroy(); // Expired.
|
|
311
|
-
alert.startFader('.alert-pin', remaining); // From the new value of remaining, after marker is set in wrapper, regardless of popup/dirty state.
|
|
312
|
-
alert.destroyer = setTimeout(() => alert.destroy(), remaining);
|
|
313
|
-
return alert;
|
|
314
|
-
}
|
|
315
|
-
initialize({payload, hashtag, subject, agent, issuedTime, ...rest}) { // Set up the marker for a newly received alert.
|
|
316
|
-
if (!payload) return null; // Do not cache. E.g., Received a delete event without the initial creation.
|
|
317
|
-
if (!Hashtags.isSubscribed(hashtag)) return null; // A subscribed event may have been in flight while unsubscribing.
|
|
318
|
-
const icon = this.constructor.makeIcon(hashtag);
|
|
319
|
-
const {lat, lng, originalPosting} = payload;
|
|
320
|
-
const marker = this.marker = L.marker([lat, lng], {icon, autoPan: false}).addTo(map);
|
|
321
|
-
const region = P2PWebNetwork.regionCode(lat, lng);
|
|
322
|
-
hashtag = Hashtags.add(hashtag); // We already have it and are subscribing, but this updates our extended form if needed.
|
|
323
|
-
super.initialize({payload, hashtag, subject, agent, lat, lng, issuedTime, originalPosting, ...rest});
|
|
324
|
-
|
|
325
|
-
marker.bindPopup('', {className: 'alert'}).on('popupopen', event => this.ensureContent(event.popup));
|
|
326
|
-
tooltip(marker.getElement(), Int`Show conversation for this ${hashtag} alert.`);
|
|
327
|
-
if (subject === openOnReceive) {
|
|
328
|
-
openOnReceive = false;
|
|
329
|
-
this.openPopup();
|
|
330
|
-
}
|
|
331
|
-
// Subscribe to replies to this subject, now that we're set up to receive them.
|
|
332
|
-
networkPromise.then(async contact => {
|
|
333
|
-
contact.subscribe({eventName: subject, region, handler: data => this.ensure(data)});
|
|
334
|
-
});
|
|
335
|
-
this.showNotification({agent, issuedTime});
|
|
336
|
-
return this;
|
|
337
|
-
}
|
|
338
547
|
|
|
339
548
|
needsRedisplay = true;
|
|
340
549
|
ensureContent(popup = this.marker.getPopup()) { // Set content and handlers in popup if/as needed.
|
|
341
550
|
if (!popup.isOpen()) return;
|
|
551
|
+
this.logAlert();
|
|
342
552
|
if (!this.needsRedisplay) {
|
|
343
553
|
this.initializeHandlers(popup);
|
|
344
554
|
return;
|
|
@@ -353,7 +563,6 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
353
563
|
this.marker.getPopup().update();
|
|
354
564
|
this.initializeHandlers(popup);
|
|
355
565
|
});
|
|
356
|
-
console.warn(`latitude: ${this.lat}, longitude: ${this.lng}`);
|
|
357
566
|
}
|
|
358
567
|
clearAvatars(popup = this.marker?.getPopup()) {
|
|
359
568
|
popup?.getElement()?.querySelectorAll('.correspondent[data-tag]')
|
|
@@ -422,7 +631,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
422
631
|
menu.onclick = consume; // Must be onlick rather than addEventListener.
|
|
423
632
|
const handler = event => {
|
|
424
633
|
menu.removeEventListener('close-menu', handler);
|
|
425
|
-
this.updatePost(event.detail.initiator.dataset.tag);
|
|
634
|
+
this.updatePost(event.detail.initiator.dataset.tag); // initiator is a hashtag menu item and dataset.tag is a hashtag.
|
|
426
635
|
};
|
|
427
636
|
menu.addEventListener('close-menu', handler); // Must be addEventListener because there's no onclosemenu.
|
|
428
637
|
});
|
|
@@ -467,16 +676,16 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
467
676
|
${actions}
|
|
468
677
|
</div>`;
|
|
469
678
|
}
|
|
470
|
-
updatePost(
|
|
679
|
+
updatePost(newHashtag) { // Republish under a different hashtag, or cancel altogether if no newHashtag (which is not allowed as a hashtag).
|
|
471
680
|
resetInactivityTimer();
|
|
472
|
-
const {lat, lng, hashtag,
|
|
473
|
-
console.log("updatePost", {
|
|
474
|
-
if (!
|
|
475
|
-
if (
|
|
476
|
-
const cancel = {lat, lng,
|
|
477
|
-
Hashtags.setPublish(
|
|
681
|
+
const {lat, lng, hashtag, tag, issuedTime, originalPosting = issuedTime} = this;
|
|
682
|
+
console.log("updatePost", {newHashtag, lat, lng, hashtag, tag, issuedTime, originalPosting, self:this});
|
|
683
|
+
if (!newHashtag) return Alert.publish({lat, lng, tag, originalPosting, hashtag, payload: null, cancel: null}); // Remove post with null payload, cancel.
|
|
684
|
+
if (newHashtag === hashtag) return this.needsRedisplay = true;
|
|
685
|
+
const cancel = {lat, lng, tag, hashtag}; // Cancel old hashtag as we publish newHashtag, below.
|
|
686
|
+
Hashtags.setPublish(newHashtag);
|
|
478
687
|
Hashtags.onchange({redisplaySubscribers: false, resetSubscriptions: false});
|
|
479
|
-
return Alert.publish({lat, lng, hashtag:
|
|
688
|
+
return Alert.publish({lat, lng, hashtag: newHashtag, originalPosting, cancel}); // Publish new alert w/cancellation.
|
|
480
689
|
}
|
|
481
690
|
|
|
482
691
|
// Each reply is separately published by its author, and only they can modify/unpublish it.
|
|
@@ -484,7 +693,6 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
484
693
|
return AlertReply;
|
|
485
694
|
}
|
|
486
695
|
async ensure(data) { // Add or update reply for this reply.
|
|
487
|
-
data.subject = data.tag; //fixme
|
|
488
696
|
const remaining = data.issuedTime + ttl - Date.now();
|
|
489
697
|
if (remaining < 0) return null;
|
|
490
698
|
const reply = await super.ensure(data);
|
|
@@ -507,13 +715,13 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
507
715
|
this.ensureContent();
|
|
508
716
|
return reply;
|
|
509
717
|
}
|
|
510
|
-
async postReply(event) { // Post a reply to this marker's
|
|
718
|
+
async postReply(event) { // Post a reply to this marker's tag, in response to a text-field change event.
|
|
511
719
|
resetInactivityTimer();
|
|
512
720
|
event.stopPropagation();
|
|
513
721
|
const button = event.target;
|
|
514
722
|
const inputElement = button.parentElement;
|
|
515
723
|
let payload = inputElement.value.trim();
|
|
516
|
-
const {
|
|
724
|
+
const {tag, hashtag, lat, lng} = this;
|
|
517
725
|
const region = P2PWebNetwork.regionCode(lat, lng);
|
|
518
726
|
const files = inputElement.parentElement.querySelector('input[type="file"]').files;
|
|
519
727
|
if (!payload && !files.length) return;
|
|
@@ -524,17 +732,17 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
524
732
|
const {topic:file, msgIds} = await contact.chunkifyBlob({blob: files[0], region});
|
|
525
733
|
payload = {message: payload, file};
|
|
526
734
|
}
|
|
527
|
-
await contact.publish({eventName:
|
|
735
|
+
await contact.publish({eventName: tag, region, payload}); // Publish the new reply.
|
|
528
736
|
Agent.current.persistPublicMetadata();
|
|
529
737
|
}
|
|
530
738
|
deleteReply(replyElement) {
|
|
531
739
|
resetInactivityTimer();
|
|
532
|
-
const {lat, lng,
|
|
740
|
+
const {lat, lng, tag} = this;
|
|
533
741
|
const region = P2PWebNetwork.regionCode(lat, lng);
|
|
534
|
-
const killTag = replyElement.dataset.
|
|
742
|
+
const killTag = replyElement.dataset.tag;
|
|
535
743
|
networkPromise.then(async contact => {
|
|
536
744
|
// We won't be here unless we are the signer.
|
|
537
|
-
await contact.publish({eventName:
|
|
745
|
+
await contact.publish({eventName: tag, region, killTag, payload: null});
|
|
538
746
|
// IFF there's an attachment AND we're given msgIds by receiveChunkedBytes, then delete the attachment.
|
|
539
747
|
const reply = this.getItem(killTag);
|
|
540
748
|
const {attachmentTopic, msgIds = []} = reply?.payload || {};
|
|
@@ -546,7 +754,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
546
754
|
}
|
|
547
755
|
});
|
|
548
756
|
}
|
|
549
|
-
showNotification({issuedTime = this.issuedTime, body = '', agent = this.agent, alert = this.
|
|
757
|
+
showNotification({issuedTime = this.issuedTime, body = '', agent = this.agent, alert = this.tag, lat = this.lat, lng = this.lng, hashtag = this.hashtag}) {
|
|
550
758
|
// Give OS notification that comes back to here, unless act is us.
|
|
551
759
|
// All notifications on the same alert (e.g., the post and each reply) have the same tag, so OS can collapse them.
|
|
552
760
|
if (agent === Agent.tag || !notificationsAllowed()) return;
|
|
@@ -564,12 +772,12 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
564
772
|
registration.showNotification(hashtag, options);
|
|
565
773
|
});
|
|
566
774
|
}
|
|
567
|
-
// Each reply element is a DIV.reply with data-
|
|
775
|
+
// Each reply element is a DIV.reply with data-tag and data-text attributes that are used in sharing.
|
|
568
776
|
// It contains an attribution header with controls, zero or one attachments, and then the message text.
|
|
569
777
|
// If present the attachment will be an A element with download attribute, surrounding either an IMG, A/V player, or an attachment icon followed by the file name.
|
|
570
778
|
formatReplies() { // Answer HTML for the replies and input box.
|
|
571
779
|
const { items, agent, originalPosting } = this;
|
|
572
|
-
const formatReply = ({
|
|
780
|
+
const formatReply = ({tag, payload, ...rest}) => {
|
|
573
781
|
const {message = payload, file, name} = payload || {}; // Message text converts recognized urls to A/V players or links.
|
|
574
782
|
let text = message
|
|
575
783
|
.replace(/https?:\/\/\S+\.(mp3|aac|ogg|oga|opus|m4a|m3u8|m3u|mpu|mpd)$/ig, url => `<audio controls src="${url}" crossorigin="anonymous"></audio>`) // show audio urls as players
|
|
@@ -587,7 +795,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
587
795
|
</a>
|
|
588
796
|
</div>`;
|
|
589
797
|
const messageDisplay = message ? `<span class="message">${text}</span>` : '';
|
|
590
|
-
let dataAttributes = `data-
|
|
798
|
+
let dataAttributes = `data-tag="${tag}" data-text="${message}"`;
|
|
591
799
|
if (file) dataAttributes += ` data-file="${file}" data-name="${name}"`;
|
|
592
800
|
return `<div class="reply" ${dataAttributes}>${this.formatAttribution(rest)}${attachment}${messageDisplay}</div>`;
|
|
593
801
|
};
|
|
@@ -608,18 +816,23 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
608
816
|
|
|
609
817
|
async share(event) { // Share reply or post
|
|
610
818
|
resetInactivityTimer();
|
|
611
|
-
// TODO: Preserve attribution data. Maybe by including the
|
|
819
|
+
// TODO: Preserve attribution data. Maybe by including the tag reply tag in the url, and metadata in the text?
|
|
612
820
|
const shareable = event.currentTarget.closest('[data-text]');
|
|
613
821
|
const {text, file, name = 'unknown'} = shareable.dataset;
|
|
614
822
|
const {lat, lng} = this;
|
|
615
823
|
console.log('Share', shareable.dataset);
|
|
616
|
-
const url = getShareableURL(this.
|
|
824
|
+
const url = getShareableURL(this.tag, [this.hashtag]).href;
|
|
617
825
|
let textBase = `New CivilDefense.io alert @${lat},${lng}`;
|
|
618
826
|
const extendedText = text ? `${textBase}\n${text}` : textBase;
|
|
619
827
|
const data = {text: extendedText, url};
|
|
620
828
|
if (file) data.files = [await P2PWebNetwork.dataURL2blob(file, name)];
|
|
621
829
|
share(data);
|
|
622
830
|
}
|
|
831
|
+
startExpiration(selector, remaining) { // Setup or update fader and destroy-on-expiration.
|
|
832
|
+
clearTimeout(this.destroyer);
|
|
833
|
+
this.destroyer = setTimeout(() => this.destroy(), remaining);
|
|
834
|
+
this.startFader(selector, remaining);
|
|
835
|
+
}
|
|
623
836
|
startFader(selector, remaining) { // Set up or update fader on the specified marker element, returning that element.
|
|
624
837
|
const { marker } = this;
|
|
625
838
|
const markerElement = marker.getElement();
|
|
@@ -644,15 +857,5 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
644
857
|
}, interval);
|
|
645
858
|
return element;
|
|
646
859
|
}
|
|
647
|
-
destroy() { // Remove this Alert pin entirely.
|
|
648
|
-
clearInterval(this['.alert-pin']);
|
|
649
|
-
clearInterval(this['.alert-commented']);
|
|
650
|
-
clearInterval(this.destroyer);
|
|
651
|
-
this.clearAvatars();
|
|
652
|
-
// Unsubscribe from replies.
|
|
653
|
-
networkPromise?.then(async contact => contact.subscribe({eventName: this.subject, region: this.region, handler: null}));
|
|
654
|
-
this.marker.removeFrom(map);
|
|
655
|
-
super.destroy();
|
|
656
|
-
}
|
|
657
860
|
}
|
|
658
861
|
globalThis.Alert = Alert; // for debugging
|
|
@@ -19,7 +19,7 @@ export class Tagged { // Maintains cached existence within a (possibly instance-
|
|
|
19
19
|
for (const key in properties) {
|
|
20
20
|
const existing = this[key];
|
|
21
21
|
const proposed = properties[key];
|
|
22
|
-
if (JSON.stringify(existing) !== JSON.stringify(proposed)) throw new Error(`Cannot update '${key}' from ${existing} to ${proposed}.`);
|
|
22
|
+
if (JSON.stringify(existing) !== JSON.stringify(proposed)) throw new Error(`Cannot update '${key}' in ${this.constructor.name} ${this.tag} from ${JSON.stringify(existing)} to ${JSON.stringify(proposed)}.`);
|
|
23
23
|
}
|
|
24
24
|
return this;
|
|
25
25
|
}
|
|
@@ -32,13 +32,15 @@ export class Tagged { // Maintains cached existence within a (possibly instance-
|
|
|
32
32
|
|
|
33
33
|
static async ensureIn(data, container, kind = container.itemKind) { // update() or initialize() item and remember what those answer. (Falsy is deleted).
|
|
34
34
|
const {tag, payload, ...rest} = data;
|
|
35
|
-
let item = container.getItem(tag);
|
|
35
|
+
let item = container.getItem(tag); // existing item
|
|
36
36
|
if (!payload) return item?.destroy();
|
|
37
|
+
|
|
38
|
+
// update or create item.
|
|
37
39
|
if (item) item = await item.update(data);
|
|
38
40
|
else item = await (new kind().initialize({...data, container}));
|
|
39
41
|
|
|
40
42
|
if (!item) return container.removeItem(tag)?.destroy();
|
|
41
|
-
return container.setItem(tag, item);
|
|
43
|
+
return container.setItem(item.tag, item); // initialize() may come up with a better tag.
|
|
42
44
|
}
|
|
43
45
|
}
|
|
44
46
|
|
|
@@ -251,7 +251,7 @@ export class P2PWebNetwork {
|
|
|
251
251
|
}
|
|
252
252
|
shortHealth(health = this.peer.health()) { // Info-report a short form of health.
|
|
253
253
|
const roots = health.axonRoles.filter(r => r.isRoot);
|
|
254
|
-
this.info(`disconnected with connections ${health.peers.map(P2PWebNetwork.short)}\nand ${roots.length ? `roots ${roots.map(r => P2PWebNetwork.short(r.topic))}.` : 'no roots.'}`);
|
|
254
|
+
this.info(`disconnected with ${health.peers.length} connections: ${health.peers.map(P2PWebNetwork.short)}\nand ${roots.length ? `roots ${roots.map(r => P2PWebNetwork.short(r.topic))}.` : 'no roots.'}`);
|
|
255
255
|
}
|
|
256
256
|
inRegionConnections(health = this.peer.health()) {
|
|
257
257
|
const region = this.nodeIdentity.id.slice(0, 2);
|
|
@@ -6,7 +6,7 @@ let connect, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNE
|
|
|
6
6
|
// dht 1 -> Axona
|
|
7
7
|
// dht 0 -> server
|
|
8
8
|
// dht -1 -> in-memory on client only
|
|
9
|
-
const defaultDHT = 1;
|
|
9
|
+
const defaultDHT = globalThis.process ? 1 : 0; // Browser defaults to no Axona. Server nodes and alert-bot default to Axona.
|
|
10
10
|
export const dht = parseInt((globalThis.process ?
|
|
11
11
|
globalThis.process.env.DHT :
|
|
12
12
|
new URL(globalThis.location).searchParams.get('dht')) ?? defaultDHT);
|
|
@@ -14,7 +14,7 @@ export const dht = parseInt((globalThis.process ?
|
|
|
14
14
|
if (dht < 1) {
|
|
15
15
|
|
|
16
16
|
const { v4:uuidv4 } = await import('uuid');
|
|
17
|
-
const {
|
|
17
|
+
const { getSmallestCellId, getPointInCell } = await import('./s2.js');
|
|
18
18
|
const { cellHex } = await import('./versions.js');
|
|
19
19
|
const operator = await import('./pubsub.js');
|
|
20
20
|
|
|
@@ -38,8 +38,8 @@ if (dht < 1) {
|
|
|
38
38
|
return {authorId: tag};
|
|
39
39
|
};
|
|
40
40
|
geoCellId = (lat, lng) => {
|
|
41
|
-
const
|
|
42
|
-
const hex = cellHex(
|
|
41
|
+
const cellid = getSmallestCellId(lat, lng);
|
|
42
|
+
const hex = cellHex(cellid);
|
|
43
43
|
const sliced = hex.slice(0, 2);
|
|
44
44
|
return parseInt(sliced, 16); // The worst way to do this.
|
|
45
45
|
};
|
|
@@ -99,7 +99,7 @@ if (dht < 1) {
|
|
|
99
99
|
|
|
100
100
|
if ((!subHandler && !inFlightResolver) || // debug
|
|
101
101
|
((typeof(subHandler) !== 'function') && (typeof(inFlightResolver) !== 'function')))
|
|
102
|
-
console.
|
|
102
|
+
console.warn('no handler or request', {tag, rest, subHandler, inFlightResolver, handlers, inFlight});
|
|
103
103
|
|
|
104
104
|
if (subHandler) return subHandler(...rest);
|
|
105
105
|
delete inFlight[tag];
|
|
@@ -107,7 +107,7 @@ if (dht < 1) {
|
|
|
107
107
|
};
|
|
108
108
|
socket.onopen = () => {
|
|
109
109
|
if (socket.readyState !== WebSocket.OPEN) return; // You would think that can't happen, but...
|
|
110
|
-
resolve((...rest) => { // send()
|
|
110
|
+
resolve((...rest) => { // Promise the send() function.
|
|
111
111
|
const tag = uuidv4();
|
|
112
112
|
const {promise, resolve} = Promise.withResolvers();
|
|
113
113
|
inFlight[tag] = resolve;
|
|
@@ -154,10 +154,12 @@ if (dht < 1) {
|
|
|
154
154
|
kill(topic, msgId, options) {
|
|
155
155
|
return send('unpublish', topic, msgId, options);
|
|
156
156
|
},
|
|
157
|
+
lookup() { return {}; },
|
|
157
158
|
host() {},
|
|
158
159
|
unhost() {}
|
|
159
160
|
};
|
|
160
161
|
const status = {peers: 0, ms: 0}; // fixme ms
|
|
162
|
+
await new Promise(resolve => setTimeout(resolve, 1e3)); // Simulate finding peers, and give the app some time to do stuff.
|
|
161
163
|
return { peer, nodeIdentity, transport, status, disconnect };
|
|
162
164
|
};
|
|
163
165
|
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
// In memory pubsub, for either client-only testing, or server-websocket testing
|
|
2
2
|
const { v4:uuidv4 } = await import('uuid');
|
|
3
3
|
const { TextEncoder, crypto, Buffer } = globalThis;
|
|
4
|
+
const publicationRolloverLimit = 1000;
|
|
4
5
|
|
|
5
6
|
function setBucket(collection, type, topicId, subject, value) { // Set value in the collection.
|
|
6
7
|
const bucket = collection[type][topicId] ||= {};
|
|
8
|
+
if ((collection === data) && (type === 'pub')) {
|
|
9
|
+
const keys = Object.keys(bucket);
|
|
10
|
+
const size = keys.length;
|
|
11
|
+
if (size >= publicationRolloverLimit) {
|
|
12
|
+
console.warn('Over pub limit on topic', topicId, size); // TODO: rotate out the earliest received.
|
|
13
|
+
delete bucket[keys[0]];
|
|
14
|
+
}
|
|
15
|
+
}
|
|
7
16
|
bucket[subject] = value;
|
|
8
17
|
}
|
|
9
18
|
function removeBucket(collection, type, topicId, subject) { // Return the value and stop storing it.
|
|
@@ -20,7 +29,7 @@ const PUBLISH_TIMEOUT = 24 * 60 * 60e3; // Delete after 24 hours.
|
|
|
20
29
|
const timeouts = {pub: {}, sub: {}};
|
|
21
30
|
function expire(type, topicId, subject, remover, timeout) { // Cancellably schedule remover() to fire at timeout.
|
|
22
31
|
if (!timeout) return;
|
|
23
|
-
setBucket(timeouts, type, topicId, subject, setTimeout(remover, timeout));
|
|
32
|
+
setBucket(timeouts, type, topicId, subject, setTimeout(() => { remover(); cancel(type, topicId, subject); }, timeout));
|
|
24
33
|
}
|
|
25
34
|
function cancel(type, topicId, subject) { // Cancel a sheduled expiration.
|
|
26
35
|
clearTimeout(removeBucket(timeouts, type, topicId, subject));
|
|
@@ -46,25 +55,34 @@ function normalizeTopic({name, region, owner, write = 'open'} = {}) {
|
|
|
46
55
|
function deriveTopicId(topic) {
|
|
47
56
|
return JSON.stringify(normalizeTopic(topic)); // No need to hash in this implementation.
|
|
48
57
|
}
|
|
58
|
+
function delay(ms = 0) {
|
|
59
|
+
return ms && new Promise(resolve => setTimeout(resolve, ms));
|
|
60
|
+
}
|
|
49
61
|
|
|
50
62
|
let invoke;
|
|
51
63
|
export function setReceiver(receiver) {
|
|
52
64
|
invoke = receiver;
|
|
53
65
|
}
|
|
66
|
+
const throttleMS = 30; // Just to yield to other stuff.
|
|
67
|
+
async function pauseInvoke(...rest) {
|
|
68
|
+
invoke(...rest);
|
|
69
|
+
await new Promise(resolve => setTimeout(resolve, throttleMS));
|
|
70
|
+
}
|
|
54
71
|
|
|
55
|
-
export function subscribe(topicName, nodeTag, {since = 'all'}) {
|
|
72
|
+
export async function subscribe(topicName, nodeTag, {since = 'all'}) {
|
|
56
73
|
// Axona allows multiple handlers on the same topic, but we don't use that in civildefense, and do not implement it here.
|
|
57
74
|
const topicId = deriveTopicId(topicName);
|
|
58
75
|
const id = uuidv4();
|
|
59
76
|
cancel('sub', topicId, id);
|
|
60
77
|
expire('sub', topicId, id, () => deleteSub(topicId, id), SUBSCRIPTION_TIMEOUT);
|
|
61
78
|
setBucket(data, 'sub', topicId, nodeTag, id);
|
|
62
|
-
if (since)
|
|
79
|
+
if (since) { // invoke handler on any sticky data, but only after we have told client the subscription id.
|
|
80
|
+
await delay(100);
|
|
63
81
|
let lastEnvelope = null, lastTime = 0;
|
|
64
82
|
for (const envelope of getDataValues('pub', topicId)) {
|
|
65
83
|
switch (since) {
|
|
66
84
|
case 'all':
|
|
67
|
-
|
|
85
|
+
await pauseInvoke(nodeTag, id, envelope);
|
|
68
86
|
break;
|
|
69
87
|
case 'latest':
|
|
70
88
|
if (envelope.ts > lastTime) {
|
|
@@ -73,11 +91,11 @@ export function subscribe(topicName, nodeTag, {since = 'all'}) {
|
|
|
73
91
|
}
|
|
74
92
|
break;
|
|
75
93
|
default: // Must be a timestamp
|
|
76
|
-
if (envelope.ts === since)
|
|
94
|
+
if (envelope.ts === since) await pauseInvoke(nodeTag, id, envelope);
|
|
77
95
|
}
|
|
78
96
|
}
|
|
79
97
|
if (lastEnvelope) invoke(nodeTag, id, lastEnvelope);
|
|
80
|
-
}
|
|
98
|
+
}
|
|
81
99
|
return {topicName, topicId, id};
|
|
82
100
|
}
|
|
83
101
|
|
|
@@ -92,7 +110,10 @@ export function deleteSubscriber(nodeTag) {
|
|
|
92
110
|
for (const topicId in data.sub) {
|
|
93
111
|
const keySubs = data.sub[topicId];
|
|
94
112
|
for (const [subject, value] of Object.entries(keySubs)) {
|
|
95
|
-
if (nodeTag === subject)
|
|
113
|
+
if (nodeTag === subject) {
|
|
114
|
+
deleteSub(topicId, subject, keySubs);
|
|
115
|
+
cancel('sub', topicId, subject);
|
|
116
|
+
}
|
|
96
117
|
}
|
|
97
118
|
}
|
|
98
119
|
}
|
|
@@ -106,19 +127,19 @@ export async function publish(topic, message, {signWith}) {
|
|
|
106
127
|
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(payload));
|
|
107
128
|
const msgId = toHex(new Uint8Array(hash));
|
|
108
129
|
const envelope = {msgId, topic, ts: Date.now(), message, signerPubkey};
|
|
109
|
-
for (const [nodeTag, id] of getDataEntries('sub', topicId))
|
|
130
|
+
for (const [nodeTag, id] of getDataEntries('sub', topicId)) await pauseInvoke(nodeTag, id, envelope);
|
|
110
131
|
setBucket(data, 'pub', topicId, msgId, envelope);
|
|
111
132
|
expire('pub', topicId, msgId, () => removeBucket(data, 'pub', topicId, msgId), PUBLISH_TIMEOUT);
|
|
112
133
|
return msgId;
|
|
113
134
|
}
|
|
114
135
|
|
|
115
|
-
export function unpublish(topic, msgId, {signWith}) {
|
|
136
|
+
export async function unpublish(topic, msgId, {signWith}) {
|
|
116
137
|
const topicId = deriveTopicId(topic);
|
|
117
138
|
cancel('pub', topicId, msgId);
|
|
118
139
|
const envelope = removeBucket(data, 'pub', topicId, msgId);
|
|
119
140
|
if (!envelope) return {ok: false}; // we didn't have it.
|
|
120
141
|
envelope.deleted = true;
|
|
121
142
|
envelope.message = null;
|
|
122
|
-
for (const [nodeTag, id] of getDataEntries('sub', topicId))
|
|
143
|
+
for (const [nodeTag, id] of getDataEntries('sub', topicId)) await pauseInvoke(nodeTag, id, envelope);
|
|
123
144
|
return {ok: true};
|
|
124
145
|
}
|
package/public/javascripts/s2.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { s2, s1, r1 } from 's2js';
|
|
2
2
|
const { cellid, LatLng, Point, Cell, Cap, RegionCoverer } = s2;
|
|
3
3
|
import { cellHex } from './versions.js';
|
|
4
|
+
import { geoCellCenter/*, geoCellId*/ } from '@axona/protocol';
|
|
4
5
|
const { BigInt } = globalThis;
|
|
5
6
|
|
|
6
7
|
// s2 defines non-overlapping cells that completely cover the globe, at several different levels of cell-size.
|
|
@@ -12,43 +13,59 @@ const { BigInt } = globalThis;
|
|
|
12
13
|
// Meanwhile as the user changes the area being shown, we subscribe to whatever cells we need in order to cover the display
|
|
13
14
|
// area without overlapping cells.
|
|
14
15
|
|
|
15
|
-
export const MIN_LEVEL = 3; //
|
|
16
|
+
export const MIN_LEVEL = 3; // The largest cell that is still smaller than an Axona region.
|
|
16
17
|
const MAX_S2_LEVEL = 30; // The leaf level that Cell.fromPoint operates at.
|
|
17
18
|
const MAX_MAP_LEVEL = 17; // The max level that findCoverCellsByCenterAndRadius will use on our maps.
|
|
18
19
|
|
|
19
20
|
const EARTH_RADIUS_METERS = 6371e3;
|
|
20
21
|
|
|
21
22
|
export function getPointInCell(cellId) { // answer [lat, lng] in degrees from a BigInt
|
|
23
|
+
// const {lat, lng} = geoCellCenter(cellid);
|
|
24
|
+
// return [lat, lng];
|
|
22
25
|
// CAUTION: This is intended for s2 level 3 or finer, and won't always work symmetrically for our top-level regions.
|
|
23
26
|
// e.g. getContainingCells(...getPointInCell( cell for 0x47 )][0] is 0x46!
|
|
24
27
|
let center = s2.cellid.latLng(cellId);
|
|
25
28
|
return [s1.angle.degrees(center.lat), s1.angle.degrees(center.lng)];
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
function getCellSubdivision(cell) {
|
|
31
|
+
function getCellSubdivision(cell) { // Answer four children off BigInt cell, as BigInt.
|
|
29
32
|
return cellid.children(cell);
|
|
30
33
|
}
|
|
31
|
-
function
|
|
34
|
+
export function getSubdivision(hexString) { // Same as getCellSubDivision, but accepting and returning hex strings.
|
|
35
|
+
return getCellSubdivision(BigInt('0x' + hexString)).map(childCell => cellHex(childCell));
|
|
36
|
+
}
|
|
37
|
+
function getCellLevel(cell) { // Answer level of BigInt cell, as number.
|
|
32
38
|
return cellid.level(cell);
|
|
33
39
|
}
|
|
34
|
-
function
|
|
40
|
+
function getCellFace(cell) { // Answer top level face id of BigInt cell, as integer 0 through 5.
|
|
35
41
|
return cellid.face(cell);
|
|
36
42
|
}
|
|
37
|
-
export function
|
|
38
|
-
return
|
|
43
|
+
export function cellContains(putativeOuter, putativeInner) { // Answer true IFF putativeOuter BigInt cell contains putativeInner
|
|
44
|
+
return cellid.contains(putativeOuter, putativeInner);
|
|
45
|
+
}
|
|
46
|
+
export function pointFromLatLng(lat, lng) { // Answer an s2 Point from lat/lng in degrees.
|
|
47
|
+
const latLng = LatLng.fromDegrees(lat, lng);
|
|
48
|
+
return Point.fromLatLng(latLng);
|
|
49
|
+
}
|
|
50
|
+
export function cellFromCellID(bigint) {
|
|
51
|
+
return Cell.fromCellID(bigint);
|
|
52
|
+
}
|
|
53
|
+
export function getSmallestCellId(lat, lng, level = MAX_MAP_LEVEL) { // Answer smallest BigInt cellid containing latitude/longitude in degrees, at integer level.
|
|
54
|
+
const userPt = pointFromLatLng(lat, lng);
|
|
55
|
+
const userLocCellId = Cell.fromPoint(userPt).id; // This is at MAX_S2_LEVEL
|
|
56
|
+
return cellid.parent(userLocCellId, level);
|
|
39
57
|
}
|
|
40
58
|
|
|
41
|
-
// Return a list of the cell ids that contain the point
|
|
59
|
+
// Return a list of the cell ids that contain the point, from region to MAX_S2_LEVEL
|
|
60
|
+
// Note that the first of the cells (the region
|
|
42
61
|
export function getContainingCells(lat, lng) {
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
for (let level = 0; level <= MAX_S2_LEVEL; level++) { // This would be more efficient going backwards using immediateParent, but who cares.
|
|
49
|
-
cells[level] = cellid.parent(userLocCellId, level);
|
|
62
|
+
let id = getSmallestCellId(lat, lng);
|
|
63
|
+
const level = MAX_MAP_LEVEL, cells = Array(MAX_MAP_LEVEL + 1 - MIN_LEVEL);
|
|
64
|
+
for (let index = level - MIN_LEVEL; index >= 0; index--) {
|
|
65
|
+
cells[index] = id;
|
|
66
|
+
id = cellid.immediateParent(id);
|
|
50
67
|
}
|
|
51
|
-
return cells
|
|
68
|
+
return cells;
|
|
52
69
|
}
|
|
53
70
|
|
|
54
71
|
export function findCoverCellsByMinMaxLatLng({minLat, maxLat, minLng, maxLng, full = false,
|
|
@@ -58,8 +75,8 @@ export function findCoverCellsByMinMaxLatLng({minLat, maxLat, minLng, maxLng, fu
|
|
|
58
75
|
|
|
59
76
|
// There are a lot of ways that seem like they do this, but it is very easy to find something that works for a few cases,
|
|
60
77
|
// but misses cells in some circumstances, so be wary about rewriting this.
|
|
61
|
-
const lo =
|
|
62
|
-
const hi =
|
|
78
|
+
const lo = LatLng.fromDegrees(minLat, minLng);
|
|
79
|
+
const hi = LatLng.fromDegrees(maxLat, maxLng);
|
|
63
80
|
|
|
64
81
|
const rect = full ? s2.Rect.fullRect() : new s2.Rect(
|
|
65
82
|
new r1.Interval(lo.lat, hi.lat),
|
|
@@ -69,4 +86,3 @@ export function findCoverCellsByMinMaxLatLng({minLat, maxLat, minLng, maxLng, fu
|
|
|
69
86
|
const coverer = new RegionCoverer({minLevel, maxLevel, maxCells});
|
|
70
87
|
return coverer.covering(rect); // a CellUnion — array-like of bigint cell IDs, already normalized/minimal
|
|
71
88
|
}
|
|
72
|
-
|
|
@@ -25,10 +25,13 @@ export function cellHex(cellid) { // Convert cellid BigInt to properly padded he
|
|
|
25
25
|
export function alertTopic(cellid, tag) { // Return topic name for public info about specified tag in cellid.
|
|
26
26
|
return `civildefense.io:${dataVersion}:${cellHex(cellid)}:${canonicalTag(tag)}`;
|
|
27
27
|
}
|
|
28
|
-
export function
|
|
28
|
+
export function topicCellHex(topicName) { // Return the hex string cell that is baked in to the topic.
|
|
29
29
|
// Note that canonicalTag(tag) may contain a colon, but dataVersion must not.
|
|
30
30
|
return topicName.match(/civildefense.io:[^:]+:([a-z0-9]+):/)[1];
|
|
31
31
|
}
|
|
32
|
+
export function topicCell(topicName) { // Return the BigInt cell that is baked in to the topic.
|
|
33
|
+
return BigInt('0x' + topicCellHex(topicName));
|
|
34
|
+
}
|
|
32
35
|
export function topicRegion(topicName) { // Return the region that is baked in to the topic.
|
|
33
|
-
return '0x' +
|
|
36
|
+
return '0x' + topicCellHex(topicName).slice(0, 2);
|
|
34
37
|
}
|
package/public/service-worker.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { Request, Response, URL, clients} = self;
|
|
2
2
|
// Little point in trying to automatically pull this through package.json, as we need a byte change in THIS file to trigger a new worker.
|
|
3
|
-
const serviceVersion = '4.5.
|
|
3
|
+
const serviceVersion = '4.5.23';
|
|
4
4
|
|
|
5
5
|
const cacheList = [ // The files we need.
|
|
6
6
|
"/",
|
|
@@ -124,6 +124,8 @@ async function cacheFirst({request, event}) {
|
|
|
124
124
|
try {
|
|
125
125
|
const responseFromNetwork = await fetch(request);
|
|
126
126
|
if (request.method !== 'GET' || request.cache === 'no-store') return responseFromNetwork; // Cache shouldn't allow anyway.
|
|
127
|
+
const mime = responseFromNetwork.headers.get("Content-Type");
|
|
128
|
+
if (/^(audio|video)\//.test(mime)) return responseFromNetwork; // Do not cache streamable media.
|
|
127
129
|
// Put clone of response in cache (so that original can be returned.
|
|
128
130
|
// Tell event to keep worker open while we put it, even though we return response immediately.
|
|
129
131
|
const cache = await caches.open(serviceVersion);
|
|
@@ -254,6 +254,7 @@ button, .leaflet-control-zoom > a, .leaflet-popup-close-button, md-outlined-icon
|
|
|
254
254
|
.alert-marker {
|
|
255
255
|
text-shadow: 8px -3px 10px rgb(0 0 0 / 60%);
|
|
256
256
|
animation: pulse ease 0.8s 1;
|
|
257
|
+
transition: transform 0.8s, opacity 0.4s;
|
|
257
258
|
}
|
|
258
259
|
.alert-commented {
|
|
259
260
|
display: none;
|
|
@@ -271,6 +272,30 @@ button, .leaflet-control-zoom > a, .leaflet-popup-close-button, md-outlined-icon
|
|
|
271
272
|
left: 5px;
|
|
272
273
|
text-align: center;
|
|
273
274
|
}
|
|
275
|
+
.aggregate.alert-pin {
|
|
276
|
+
font-size: 60px;
|
|
277
|
+
line-height: 60px;
|
|
278
|
+
top: -10px;
|
|
279
|
+
left: -10px;
|
|
280
|
+
transition: font-size 0.8s;
|
|
281
|
+
}
|
|
282
|
+
.aggregate.alert-pin:after {
|
|
283
|
+
content: "";
|
|
284
|
+
width: 70px;
|
|
285
|
+
height: 70px;
|
|
286
|
+
position: absolute;
|
|
287
|
+
top: -5px;
|
|
288
|
+
left: -5px;
|
|
289
|
+
background-image: radial-gradient(transparent 30%, var(--md-sys-color-primary), transparent 70%);
|
|
290
|
+
transition: opacity 0.8s;
|
|
291
|
+
animation: pulse ease 0.8s 1;
|
|
292
|
+
}
|
|
293
|
+
.aggregate.alert-pin.starting {
|
|
294
|
+
font-size: 0px;
|
|
295
|
+
}
|
|
296
|
+
.aggregate.alert-pin.starting:after {
|
|
297
|
+
opacity: 0;
|
|
298
|
+
}
|
|
274
299
|
@keyframes pulse {
|
|
275
300
|
0% { filter: saturate(1); }
|
|
276
301
|
25% { filter: saturate(4); }
|
package/server/app.js
CHANGED
|
@@ -14,18 +14,33 @@ const argv = yargs(hideBin(process.argv))
|
|
|
14
14
|
.option('nPortals', {
|
|
15
15
|
alias: 'p',
|
|
16
16
|
type: 'number',
|
|
17
|
-
default: Math.max(2, logicalCores - 3),
|
|
17
|
+
default: process.env.PORTALS ? parseInt(process.env.PORTALS) : Math.max(2, logicalCores - 3),
|
|
18
18
|
description: "The number of steady nodes that handle initial connections."
|
|
19
19
|
})
|
|
20
|
+
.option('regionStartIndex', {
|
|
21
|
+
type: 'number',
|
|
22
|
+
default: -1,
|
|
23
|
+
description: "If non-negative, place each of the nPortals in successive regions (excluding excludedRegionFromSeries) starting with the region at regionStaartIndex."
|
|
24
|
+
})
|
|
25
|
+
.option('excludedRegionFromSeries', {
|
|
26
|
+
type: 'string', array: true,
|
|
27
|
+
default: ['80', '88', '89'],
|
|
28
|
+
description: "If regionStartIndex is used, the ordered list of regions to occupy is the list of canonical regions less these."
|
|
29
|
+
})
|
|
20
30
|
.option('baseURL', {
|
|
21
31
|
type: 'string',
|
|
22
|
-
default: '
|
|
32
|
+
default: process.env.BRIDGE_URL || 'wss://bridge.axona.net',
|
|
23
33
|
description: "The base URL of the portal server through which to bootstrap."
|
|
24
34
|
})
|
|
25
|
-
.option('externalBaseURL', {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
35
|
+
// .option('externalBaseURL', {
|
|
36
|
+
// type: 'string',
|
|
37
|
+
// default: '',
|
|
38
|
+
// description: "The base URL of the some other portal server to which we should connect ours, if any."
|
|
39
|
+
// })
|
|
40
|
+
.option('port', {
|
|
41
|
+
type: 'number',
|
|
42
|
+
default: 3000,
|
|
43
|
+
description: "Port for the server to listen on."
|
|
29
44
|
})
|
|
30
45
|
.option('announce', {
|
|
31
46
|
type: 'boolean',
|
|
@@ -61,7 +76,6 @@ if (cluster.isPrimary) { // Parent process with portal webserver through which c
|
|
|
61
76
|
const logger = (await import('morgan')).default;
|
|
62
77
|
const { configureWebsocket } = await import('./websocket.js');
|
|
63
78
|
|
|
64
|
-
const port = parseInt((new URL(argv.baseURL)).port || '80');
|
|
65
79
|
process.title = 'yz.social';
|
|
66
80
|
const app = express();
|
|
67
81
|
app.use(logger(':date[iso] :status :method :url :res[content-length] - :response-time ms'));
|
|
@@ -101,8 +115,8 @@ if (cluster.isPrimary) { // Parent process with portal webserver through which c
|
|
|
101
115
|
extensions: ['js'] // Some dependencies refer to .js files as relative pathnames, with the .js missing.
|
|
102
116
|
}));
|
|
103
117
|
|
|
104
|
-
server.listen(port);
|
|
105
|
-
log(`Listening on ${port} and starting ${argv.nPortals} nodes on ${logicalCores} ${cpus()[0].model} logical cores.`);
|
|
118
|
+
server.listen(argv.port);
|
|
119
|
+
log(`Listening on ${argv.port} and starting ${argv.nPortals} nodes on ${logicalCores} ${cpus()[0].model} logical cores.`);
|
|
106
120
|
for (let i = 0; i < argv.nPortals; i++) {
|
|
107
121
|
cluster.fork();
|
|
108
122
|
await delay(); // Number chosen to give an unspikey rise in packets/s.
|
|
@@ -118,8 +132,14 @@ if (cluster.isPrimary) { // Parent process with portal webserver through which c
|
|
|
118
132
|
} else {
|
|
119
133
|
process.title = 'axona-starting';
|
|
120
134
|
const { P2PWebNetwork, location } = await import('../index.js');
|
|
135
|
+
const { MAJORS, geoCellCenter } = await import('@axona/protocol');
|
|
136
|
+
const excluded = argv.excludedRegionFromSeries.map(hex => parseInt(hex, 16).toString());
|
|
137
|
+
const regions = Object.keys(MAJORS).filter(r => !excluded.includes(r));
|
|
138
|
+
const index = (cluster.worker.id - 1 + argv.regionStartIndex) % regions.length;
|
|
139
|
+
const region = (argv.regionStartIndex >= 0) && regions[index];
|
|
140
|
+
const loc = region ? geoCellCenter(parseInt(region)) : location;
|
|
121
141
|
const network = await P2PWebNetwork.create({
|
|
122
|
-
location,
|
|
142
|
+
location: loc,
|
|
123
143
|
infoLogger: log,
|
|
124
144
|
debugLogger: debug
|
|
125
145
|
});
|
package/server/websocket.js
CHANGED
|
@@ -31,6 +31,7 @@ export function configureWebsocket(server) {
|
|
|
31
31
|
ws.on('error', console.error);
|
|
32
32
|
ws.on('close', () => {
|
|
33
33
|
console.log('Disconnected', nodeTag);
|
|
34
|
+
delete sockets[nodeTag];
|
|
34
35
|
operator.deleteSubscriber(nodeTag);
|
|
35
36
|
});
|
|
36
37
|
});
|
|
@@ -42,5 +43,5 @@ export function configureWebsocket(server) {
|
|
|
42
43
|
ws.ping();
|
|
43
44
|
return null;
|
|
44
45
|
});
|
|
45
|
-
},
|
|
46
|
+
}, 20e3);
|
|
46
47
|
}
|