@yz-social/civildefense.io 4.5.21 → 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/agent.js +2 -1
- package/public/javascripts/alert.js +348 -145
- package/public/javascripts/conversation.js +5 -3
- package/public/javascripts/hashtags.js +2 -1
- package/public/javascripts/map.js +1 -1
- package/public/javascripts/p2pWebNetwork.js +12 -11
- package/public/javascripts/protocol.js +15 -8
- 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 +46 -4
- package/server/app.js +30 -10
- package/server/websocket.js +2 -1
|
@@ -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
|
|
|
@@ -133,7 +133,8 @@ export const Hashtags = {
|
|
|
133
133
|
return `<md-filter-chip label="${label}" elevated removable
|
|
134
134
|
${active === 'pub' ? 'class="pub"' : ''}
|
|
135
135
|
${active ? ' selected' : ''}
|
|
136
|
-
>${this.firstEmoji(label) ? '' :
|
|
136
|
+
>${this.firstEmoji(label) ? '' :
|
|
137
|
+
`<div slot="selected-icon" class="identicon"><md-icon class="material-icons">checkmark</md-icon> ${this.identicon(label)}</div>`}
|
|
137
138
|
<md-icon-button slot="remove-trailing-icon"><md-icon class="material-icons"></md-icon></md-icon-button>
|
|
138
139
|
</md-filter-chip>`;
|
|
139
140
|
},
|
|
@@ -83,7 +83,7 @@ export var trackMap;
|
|
|
83
83
|
export function initMap(lat, lng, zoom, positionLabel) { // Set up appropriate zoomed initial map and handlers for this position.
|
|
84
84
|
// Then show initial message and updateSubscriptions.
|
|
85
85
|
|
|
86
|
-
P2PWebNetwork.
|
|
86
|
+
P2PWebNetwork.setSessionLocation({lat, lng});
|
|
87
87
|
|
|
88
88
|
// Map will be centered at the given current location marker, unless overriden by query parameters.
|
|
89
89
|
let center = {lat, lng};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { v4 as uuidv4 } from 'uuid';
|
|
2
|
-
import { connect, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION } from './protocol.js';
|
|
2
|
+
import { connect, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION, dht } from './protocol.js';
|
|
3
3
|
import { stringToBytes, bytesToString, publishChunkedBytes, receiveChunkedBytes } from './protocol.js';
|
|
4
4
|
|
|
5
5
|
if (!Uint8Array.prototype.toBase64) { // NodeJS < 24
|
|
@@ -16,37 +16,38 @@ const { BigInt, URL, File, pica } = globalThis;
|
|
|
16
16
|
await network.disconnect();
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
const {promise:
|
|
19
|
+
const {promise:sessionLocationPromise, resolve:resolveSessionLocation} = Promise.withResolvers();
|
|
20
20
|
|
|
21
21
|
export class P2PWebNetwork {
|
|
22
22
|
static wireVersion = WIRE_VERSION;
|
|
23
23
|
static kernelVersion = KERNEL_VERSION;
|
|
24
24
|
static createAuthorIdentity = createAuthorIdentity;
|
|
25
|
-
static
|
|
26
|
-
static
|
|
25
|
+
static setSessionLocation = resolveSessionLocation;
|
|
26
|
+
static sessionLocation = sessionLocationPromise;
|
|
27
27
|
static async create({infoLogger = console.log, debugLogger,
|
|
28
|
-
|
|
28
|
+
location = this.sessionLocation,
|
|
29
29
|
bridgeUrl = (globalThis.location && new URL(globalThis.location).searchParams.get('bridge')) ||
|
|
30
30
|
globalThis.process?.env.BRIDGE_URL ||
|
|
31
|
-
(
|
|
32
|
-
globalThis.location?.origin.replace(/^http/, 'ws')
|
|
31
|
+
((dht <= 0) && // fixme remove when we host bridges
|
|
32
|
+
(globalThis.location?.origin.replace(/^http/, 'ws') ||
|
|
33
|
+
'ws://localhost:3000')) ||
|
|
33
34
|
'wss://bridge.axona.net',
|
|
34
35
|
} = {}) {
|
|
35
36
|
// Promise a ready-to-use network peer.
|
|
36
|
-
|
|
37
|
+
location = await location;
|
|
37
38
|
|
|
38
39
|
const network = new this();
|
|
39
40
|
network.resetStatePromises();
|
|
40
41
|
|
|
41
42
|
const { peer, nodeIdentity, transport, status, disconnect } = await connect({
|
|
42
43
|
bridge: bridgeUrl,
|
|
43
|
-
location
|
|
44
|
+
location,
|
|
44
45
|
onDisconnect: network.detached,
|
|
45
46
|
author: false
|
|
46
47
|
});
|
|
47
48
|
Object.assign(network, {infoLogger, debugLogger, disconnector: disconnect, transport, nodeIdentity, peer});
|
|
48
49
|
|
|
49
|
-
network.info(`Created network node for kernel ${this.kernelVersion} region 0x${this.regionCode(
|
|
50
|
+
network.info(`Created network node for kernel ${this.kernelVersion} region 0x${this.regionCode(location.lat, location.lng).toString(16)}.`);
|
|
50
51
|
peer.onError(error => {
|
|
51
52
|
network.info(`error: ${error.message || error}`);
|
|
52
53
|
throw error;
|
|
@@ -250,7 +251,7 @@ export class P2PWebNetwork {
|
|
|
250
251
|
}
|
|
251
252
|
shortHealth(health = this.peer.health()) { // Info-report a short form of health.
|
|
252
253
|
const roots = health.axonRoles.filter(r => r.isRoot);
|
|
253
|
-
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.'}`);
|
|
254
255
|
}
|
|
255
256
|
inRegionConnections(health = this.peer.health()) {
|
|
256
257
|
const region = this.nodeIdentity.id.slice(0, 2);
|
|
@@ -3,15 +3,18 @@ const { TextEncoder, TextDecoder, BigInt, URL, WebSocket, Buffer } = globalThis;
|
|
|
3
3
|
|
|
4
4
|
let connect, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION, stringToBytes, bytesToString, publishChunkedBytes, receiveChunkedBytes;
|
|
5
5
|
|
|
6
|
-
// dht 1 -> Axona
|
|
6
|
+
// dht 1 -> Axona
|
|
7
7
|
// dht 0 -> server
|
|
8
8
|
// dht -1 -> in-memory on client only
|
|
9
|
-
const
|
|
9
|
+
const defaultDHT = globalThis.process ? 1 : 0; // Browser defaults to no Axona. Server nodes and alert-bot default to Axona.
|
|
10
|
+
export const dht = parseInt((globalThis.process ?
|
|
11
|
+
globalThis.process.env.DHT :
|
|
12
|
+
new URL(globalThis.location).searchParams.get('dht')) ?? defaultDHT);
|
|
10
13
|
|
|
11
14
|
if (dht < 1) {
|
|
12
15
|
|
|
13
16
|
const { v4:uuidv4 } = await import('uuid');
|
|
14
|
-
const {
|
|
17
|
+
const { getSmallestCellId, getPointInCell } = await import('./s2.js');
|
|
15
18
|
const { cellHex } = await import('./versions.js');
|
|
16
19
|
const operator = await import('./pubsub.js');
|
|
17
20
|
|
|
@@ -35,8 +38,8 @@ if (dht < 1) {
|
|
|
35
38
|
return {authorId: tag};
|
|
36
39
|
};
|
|
37
40
|
geoCellId = (lat, lng) => {
|
|
38
|
-
const
|
|
39
|
-
const hex = cellHex(
|
|
41
|
+
const cellid = getSmallestCellId(lat, lng);
|
|
42
|
+
const hex = cellHex(cellid);
|
|
40
43
|
const sliced = hex.slice(0, 2);
|
|
41
44
|
return parseInt(sliced, 16); // The worst way to do this.
|
|
42
45
|
};
|
|
@@ -93,16 +96,18 @@ if (dht < 1) {
|
|
|
93
96
|
const [tag, ...rest] = JSON.parse(event.data);
|
|
94
97
|
const subHandler = handlers[tag];
|
|
95
98
|
const inFlightResolver = inFlight[tag];
|
|
96
|
-
|
|
99
|
+
|
|
100
|
+
if ((!subHandler && !inFlightResolver) || // debug
|
|
97
101
|
((typeof(subHandler) !== 'function') && (typeof(inFlightResolver) !== 'function')))
|
|
98
|
-
console.
|
|
102
|
+
console.warn('no handler or request', {tag, rest, subHandler, inFlightResolver, handlers, inFlight});
|
|
103
|
+
|
|
99
104
|
if (subHandler) return subHandler(...rest);
|
|
100
105
|
delete inFlight[tag];
|
|
101
106
|
return inFlightResolver?.(...rest);
|
|
102
107
|
};
|
|
103
108
|
socket.onopen = () => {
|
|
104
109
|
if (socket.readyState !== WebSocket.OPEN) return; // You would think that can't happen, but...
|
|
105
|
-
resolve((...rest) => { // send()
|
|
110
|
+
resolve((...rest) => { // Promise the send() function.
|
|
106
111
|
const tag = uuidv4();
|
|
107
112
|
const {promise, resolve} = Promise.withResolvers();
|
|
108
113
|
inFlight[tag] = resolve;
|
|
@@ -149,10 +154,12 @@ if (dht < 1) {
|
|
|
149
154
|
kill(topic, msgId, options) {
|
|
150
155
|
return send('unpublish', topic, msgId, options);
|
|
151
156
|
},
|
|
157
|
+
lookup() { return {}; },
|
|
152
158
|
host() {},
|
|
153
159
|
unhost() {}
|
|
154
160
|
};
|
|
155
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.
|
|
156
163
|
return { peer, nodeIdentity, transport, status, disconnect };
|
|
157
164
|
};
|
|
158
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); }
|
|
@@ -303,9 +328,29 @@ button, .leaflet-control-zoom > a, .leaflet-popup-close-button, md-outlined-icon
|
|
|
303
328
|
md-filter-chip {
|
|
304
329
|
--md-sys-color-surface-container-low: #B8C2D7; /* non-selected elevated filter chip */
|
|
305
330
|
}
|
|
306
|
-
md-filter-chip minidenticon-svg + md-icon-button
|
|
331
|
+
md-filter-chip minidenticon-svg + md-icon-button,
|
|
332
|
+
md-filter-chip .identicon + md-icon-button {
|
|
307
333
|
bottom: 10px;
|
|
308
334
|
}
|
|
335
|
+
md-filter-chip .identicon md-icon {
|
|
336
|
+
font-size: 18px;
|
|
337
|
+
width: 20px;
|
|
338
|
+
position: relative;
|
|
339
|
+
bottom: 3px;
|
|
340
|
+
}
|
|
341
|
+
md-filter-chip .identicon {
|
|
342
|
+
width: 43px;
|
|
343
|
+
}
|
|
344
|
+
md-filter-chip .identicon minidenticon-svg {
|
|
345
|
+
width: 18px;
|
|
346
|
+
display: inline-block;
|
|
347
|
+
position: relative;
|
|
348
|
+
bottom: 5px;
|
|
349
|
+
left: -3px;
|
|
350
|
+
}
|
|
351
|
+
md-filter-chip minidenticon-svg {
|
|
352
|
+
background: white;
|
|
353
|
+
}
|
|
309
354
|
md-filter-chip md-icon-button {
|
|
310
355
|
position: relative;
|
|
311
356
|
bottom: 3px;
|
|
@@ -323,9 +368,6 @@ md-filter-chip:not([selected]) md-icon::after {
|
|
|
323
368
|
md-filter-chip.pub md-icon {
|
|
324
369
|
color: var(--md-sys-color-secondary);
|
|
325
370
|
}
|
|
326
|
-
md-filter-chip minidenticon-svg {
|
|
327
|
-
background: white;
|
|
328
|
-
}
|
|
329
371
|
md-outlined-icon-button minidenticon-svg, md-outlined-icon-button img {
|
|
330
372
|
height: 40px;
|
|
331
373
|
width: 40px;
|
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
|
-
|
|
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
|
}
|