Put news on the globe, with a feed panel in the right rail

Events ▸ News draws the last 24 hours of reports from curated hotspots
(Ukraine–Russia, Israel–Gaza–Lebanon, Iran–Hormuz, Yemen–Red Sea, Sudan,
Sahel) and developing situations elsewhere, from any server that answers the
`/api/news` read routes.

- Globe: one marker per merged event, clamped to the ground and culled at the
  horizon; one beacon per hotspot when zoomed out and a pulsing beacon for a
  developing situation; hotspot outlines with disputed areas dotted and
  labelled by who administers and who claims them; picture cards for at most
  ten major or fast-growing events with two or more sources, never
  overlapping, switchable off.
- Panel: hotspot chips, a severity filter (significant and above by default),
  categories, minor news off by default, and a scrolling list of event cards
  with picture, headline, place, sources, age and the sides that reported it.
  "In view" lists what is near the map below 3,000 km once the camera
  settles. A card flies the globe to its event and opens it with a link to
  the publisher. Reduced motion stops the pulse and the flights.
- Core serves no news itself: the local server proxies GEV_NEWS_ORIGIN when it
  is set, and answers 503 unavailable otherwise, so the layer reads "not
  configured" once instead of retrying.
- Share token 9 (enabled-only); voice stays off for this layer.
This commit is contained in:
Denis Groshev committed 2026-11-06 00:45:24 +01:00
1 parent 2d9d518946
commit 5bb489d1fb
48 files changed
+3394 -53

No files matched your search

+32 -32
View File
@@ -128,7 +128,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
{ ...parseLayerStateTokenReservations(reservationRows) },
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 35);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 36);
assert.deepEqual(
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
{
@@ -140,6 +140,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
'sea-marks': '6',
'ski-pistes': '7',
'drone-airspace': '8',
news: '9',
},
);
assert.throws(
@@ -165,9 +166,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
/Duplicate layer-state token reservation/,
);
assert.equal(
parseLayerStateTokenReservations([...reservationRows, ['future', '9']])
parseLayerStateTokenReservations([...reservationRows, ['future', '00']])
.future,
'9',
'00',
);
assert.throws(
() =>
@@ -185,14 +186,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
test('published retired digits and competing PRs advance the merge-time allocation', () => {
withPublishedBase(
{ rows: [...reservationRows, ['retired-layer', '9']] },
{ rows: [...reservationRows, ['retired-layer', '00']] },
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '00');
assert.equal(nextLayerStateToken(published), '01');
assert.equal(
validateLayerStateAllocations(published, {
...published,
newcomer: '00',
newcomer: '01',
}),
true,
);
@@ -200,15 +201,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
() =>
validateLayerStateAllocations(published, {
...published,
competing: '9',
competing: '00',
}),
/next free token 00/,
/next free token 01/,
);
assert.throws(
() =>
validateLayerStateAllocations(published, {
...LAYER_STATE_TOKEN_RESERVATIONS,
newcomer: '9',
newcomer: '00',
}),
/changed or removed: retired-layer/,
);
@@ -216,8 +217,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
);
});
test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '9']] }, (cwd) => {
test('PR B manually replaces provisional 00 with 01 after PR A publishes 00', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '00']] }, (cwd) => {
mkdirSync(path.join(cwd, 'scripts'));
const fixtureChecker = path.join(
cwd,
@@ -238,8 +239,8 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
writeCodecFixture(
cwd,
[...reservationRows, ['pr-b', '9']],
[{ id: 'pr-b', token: '9' }],
[...reservationRows, ['pr-b', '00']],
[{ id: 'pr-b', token: '00' }],
);
const beforeRebase = runCheck();
assert.equal(beforeRebase.status, 1);
@@ -247,10 +248,10 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '9'], ['pr-b', '9']],
[...reservationRows, ['pr-a', '00'], ['pr-b', '00']],
[
{ id: 'pr-a', token: '9' },
{ id: 'pr-b', token: '9' },
{ id: 'pr-a', token: '00' },
{ id: 'pr-b', token: '00' },
],
);
const staleRebase = runCheck();
@@ -258,16 +259,16 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '00');
const validBaselineRows = [...reservationRows, ['pr-a', '9']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '9' }]);
assert.equal(nextLayerStateToken(published), '01');
const validBaselineRows = [...reservationRows, ['pr-a', '00']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '00' }]);
const helper = spawnSync(
process.execPath,
[realpathSync(fixtureNext), 'pr-b'],
{ cwd, encoding: 'utf8' },
);
assert.equal(helper.status, 0, helper.stderr);
assert.match(helper.stdout, /pr-b: 00/);
assert.match(helper.stdout, /pr-b: 01/);
assert.deepEqual(
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
validBaselineRows,
@@ -275,26 +276,27 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
);
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '9'], ['pr-b', '00']],
[...reservationRows, ['pr-a', '00'], ['pr-b', '01']],
[
{ id: 'pr-a', token: '9' },
{ id: 'pr-b', token: '00' },
{ id: 'pr-a', token: '00' },
{ id: 'pr-b', token: '01' },
],
);
const corrected = runCheck();
assert.equal(corrected.status, 0, corrected.stderr);
assert.match(corrected.stdout, /36 published, 1 new/);
assert.match(corrected.stdout, /37 published, 1 new/);
});
});
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
const digitRows = [...'9'].map((digit) => [`retired-${digit}`, digit]);
// News holds the last digit, 9: the ledger has no free digit left.
const digitRows = [];
const allDigits = parseLayerStateTokenReservations([
...reservationRows,
...digitRows,
]);
const beforeNine = { ...allDigits };
delete beforeNine['retired-9'];
delete beforeNine.news;
assert.equal(
validateLayerStateAllocations(beforeNine, {
...beforeNine,
@@ -340,10 +342,8 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord
test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => {
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
try {
const priorDigits = [...'9'].map((digit) => [
`qa-prior-digit-${digit}`,
digit,
]);
// News holds the last digit: no prior digit to fill.
const priorDigits = [];
const fixtureLayers = ['00', '01', '02', '03'].map((token) => ({
id: `qa-layer-${token}`,
token,
@@ -377,7 +377,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
const params = new URLSearchParams([['v', '2']]);
codec.encodeLayerStateParams(params, state);
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
assert.equal(params.get('l').length, 81);
assert.equal(params.get('l').length, 83);
const restored = codec.decodeLayerStateParams(params);
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
assert.deepEqual(restored?.options, state.options);
@@ -395,7 +395,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
},
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(Object.keys(published).length, 36);
assert.equal(Object.keys(published).length, 37);
assert.equal(published['retired-layer'], '00');
assert.throws(
() =>