fix(ghostmaps): follow-up review fixes for the age switch

- Keep the open card across a widened reload only when its index still
  holds the same report; the server may answer a pinned version with newer
  bytes.
- Narrowing to a window the loaded data already covers clears a failed
  wider reload, so the row no longer stays in error.
- Label the every-age option "All" (contextual) so the six-option switch
  fits the row in every locale; tooltip and info line keep "All markers".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Mazeyar Moeini FeizabadiandClaude Opus 5.5 committed 2026-11-06 17:37:29 +01:00
1 parent c4298c4450
commit 91ab9b5811
14 files changed
+81 -7

No files matched your search

+1 -1
View File
@@ -6007,7 +6007,7 @@ request viewbox and continues searching without that geographic preference.
- **What it shows.** Events ▸ **S2 GhostMaps** draws the current edition of S2 Underground's KMZ intel products from `github.com/s2underground/GhostMaps`. The products are the Common Intelligence Picture and the 30-day kinetic pull (both on by default), the Border Crisis Map, the Iran Common Intelligence Picture, Iran Kinetic Events, Yemen FLOT, and **Latest Drop**: the newest dated incident file outside those series, `0_Archive`, `Basedata` and `Reference Material`. Each product has a chip in the row. The chip choice is kept in `localStorage` (`gev.ghostmaps.products`) and falls back to the defaults when storage is blocked.
- **Editions.** `src/layers/ghostMaps/catalog.js` (pure) matches whole repository paths per product. The newest edition wins by the date written in the file name, then by `Export_N`/`vN`. Undated names, such as the fixed 30-day file, are identified by blob sha. Files over 50 MiB are left out (the 62 MB census KMZ, for example). A renamed folder drops the product rather than matching elsewhere.
- **Event age filter.** One **Event age** switch in the S2 GhostMaps row applies to every selected product: 1, 3, 7 (default), 14 or 30 days, or **All markers**. The choice is saved separately from product selection in `localStorage` (`gev.ghostmaps.ageDays`); missing, invalid or blocked storage falls back to seven days. Age comes only from the placemark's `Date of Event` (or `EventDate`/`Date`), never from report, prose or KMZ edition dates. Undated, unparseable and future dates stay visible. ArcGIS `M/D/YYYY` dates with optional AM/PM times and ISO dates are read as UTC when no zone is given; a date without a time covers its whole UTC day, so **1d** still shows yesterday's reports. Events age out every minute while the layer is on. Narrowing the window only removes markers; widening restores them from the cached KMZ. An open card stays while its marker is shown.
- **Event age filter.** One **Event age** switch in the S2 GhostMaps row applies to every selected product: 1, 3, 7 (default), 14 or 30 days, or **All** (every marker). The choice is saved separately from product selection in `localStorage` (`gev.ghostmaps.ageDays`); missing, invalid or blocked storage falls back to seven days. Age comes only from the placemark's `Date of Event` (or `EventDate`/`Date`), never from report, prose or KMZ edition dates. Undated, unparseable and future dates stay visible. ArcGIS `M/D/YYYY` dates with optional AM/PM times and ISO dates are read as UTC when no zone is given; a date without a time covers its whole UTC day, so **1d** still shows yesterday's reports. Events age out every minute while the layer is on. Narrowing the window only removes markers; widening restores them from the cached KMZ. An open card stays while its marker is shown.
- **Updates.** The repository is not ours, so there is no webhook. `server/providers/ghostMaps.js` starts with the first `/api/ghostmaps` request, so an install that never enables the layer never contacts GitHub.
- It reads the branch head from git's smart-HTTP ref advertisement (`/info/refs?service=git-upload-pack`, about 0.5 KB, outside the REST rate limit) every `GHOSTMAPS_POLL_SECONDS` (15 s by default, 5 s minimum). Failures back off from 15 s to 5 min.
- Only a new head costs REST calls: one recursive tree and one commit. An optional `GITHUB_TOKEN` raises the REST limit.
+1
View File
@@ -3471,6 +3471,7 @@
"All markers": "كل العلامات",
"Show markers of every age": "عرض العلامات من جميع الأعمار",
"Maximum event age: {days}d; undated markers are kept": "الحد الأقصى لعمر الحدث: {days} يوم؛ تُحفظ العلامات غير المؤرخة",
"event age filter|All": "الكل",
"Event age": "عمر الحدث",
"Event age: {days}d · undated kept": "عمر الحدث: {days} يوم · الاحتفاظ بغير المؤرخ"
}
+1
View File
@@ -3171,6 +3171,7 @@
"All markers": "Alle Markierungen",
"Show markers of every age": "Markierungen jeden Alters anzeigen",
"Maximum event age: {days}d; undated markers are kept": "Maximales Ereignisalter: {days} Tage; undatierte Markierungen bleiben",
"event age filter|All": "Alle",
"Event age": "Ereignisalter",
"Event age: {days}d · undated kept": "Ereignisalter: {days} Tage · undatierte bleiben"
}
+1
View File
@@ -3246,6 +3246,7 @@
"All markers": "Todos los marcadores",
"Show markers of every age": "Mostrar marcadores de cualquier antigüedad",
"Maximum event age: {days}d; undated markers are kept": "Antigüedad máxima: {days} días; se conservan los marcadores sin fecha",
"event age filter|All": "Todos",
"Event age": "Antigüedad",
"Event age: {days}d · undated kept": "Antigüedad: {days} días · se conservan los marcadores sin fecha"
}
+1
View File
@@ -3246,6 +3246,7 @@
"All markers": "Tous les marqueurs",
"Show markers of every age": "Afficher les marqueurs de tout âge",
"Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés",
"event age filter|All": "Tous",
"Event age": "Ancienneté",
"Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés"
}
+1
View File
@@ -3246,6 +3246,7 @@
"All markers": "Tutti gli indicatori",
"Show markers of every age": "Mostra gli indicatori di qualsiasi età",
"Maximum event age: {days}d; undated markers are kept": "Età massima degli eventi: {days} giorni; indicatori senza data mantenuti",
"event age filter|All": "Tutti",
"Event age": "Età degli eventi",
"Event age: {days}d · undated kept": "Età degli eventi: {days} giorni · senza data mantenuti"
}
+1
View File
@@ -3096,6 +3096,7 @@
"All markers": "すべてのマーカー",
"Show markers of every age": "日付にかかわらずすべてのマーカーを表示",
"Maximum event age: {days}d; undated markers are kept": "イベントの最大経過日数:{days}日。日付のないマーカーは保持",
"event age filter|All": "すべて",
"Event age": "経過日数",
"Event age: {days}d · undated kept": "経過日数:{days}日以内 · 日付なしは保持"
}
+1
View File
@@ -3246,6 +3246,7 @@
"All markers": "Todos os marcadores",
"Show markers of every age": "Mostrar marcadores de qualquer idade",
"Maximum event age: {days}d; undated markers are kept": "Idade máxima do evento: {days} dias; marcadores sem data mantidos",
"event age filter|All": "Todos",
"Event age": "Idade do evento",
"Event age: {days}d · undated kept": "Idade do evento: {days} dias · sem data mantidos"
}
+1
View File
@@ -3321,6 +3321,7 @@
"All markers": "Все метки",
"Show markers of every age": "Показать метки любого возраста",
"Maximum event age: {days}d; undated markers are kept": "Максимальный возраст события: {days} дн.; метки без даты сохранены",
"event age filter|All": "Все",
"Event age": "Возраст события",
"Event age: {days}d · undated kept": "Возраст события: {days} дн. · метки без даты сохранены"
}
+1
View File
@@ -3321,6 +3321,7 @@
"All markers": "Усі позначки",
"Show markers of every age": "Показати позначки будь-якого віку",
"Maximum event age: {days}d; undated markers are kept": "Максимальний вік події: {days} дн.; позначки без дати збережено",
"event age filter|All": "Усі",
"Event age": "Вік події",
"Event age: {days}d · undated kept": "Вік події: {days} дн. · позначки без дати збережено"
}
+1
View File
@@ -3096,6 +3096,7 @@
"All markers": "所有标记",
"Show markers of every age": "显示所有时间的标记",
"Maximum event age: {days}d; undated markers are kept": "事件最长保留时间:{days}天;保留无日期标记",
"event age filter|All": "全部",
"Event age": "事件时间",
"Event age: {days}d · undated kept": "事件时间:{days}天内 · 保留无日期标记"
}
+1
View File
@@ -3096,6 +3096,7 @@
"All markers": "所有標記",
"Show markers of every age": "顯示所有時間的標記",
"Maximum event age: {days}d; undated markers are kept": "事件最長保留時間:{days}天;保留無日期標記",
"event age filter|All": "全部",
"Event age": "事件時間",
"Event age: {days}d · undated kept": "事件時間:{days}天內 · 保留無日期標記"
}
+18 -6
View File
@@ -423,9 +423,18 @@ export function createGhostMapsLayer({
// Cull before the first frame so far-side pins never flash.
_horizon.update(next.pins);
await _viewer.dataSources.add(next.dataSource);
// The new edition is on the globe before the old one leaves. A wider
// window over the same edition keeps indices, so the card stays.
unload(product.id, loaded?.version === next.version);
// The new edition is on the globe before the old one leaves. The card
// stays only if its index still holds the same report: the server
// may answer a pinned version with newer bytes.
const index = Number(_selectedKey?.split(':')[1]);
const before = loaded?.features.get(index);
const after = next.features.get(index);
unload(
product.id,
Boolean(before && after) &&
before.name === after.name &&
before.description === after.description,
);
_loaded.set(product.id, next);
} catch (error) {
if (signal.aborted) return;
@@ -477,12 +486,15 @@ export function createGhostMapsLayer({
// Narrowing only removes markers. A product loaded with a narrower window
// keeps its old ageDays, so sync() restores omitted placemarks from the
// cached KMZ even though its upstream version has not changed.
for (const item of _loaded.values())
for (const [id, item] of _loaded)
if (
item.ageDays === null ||
(ageDays !== null && item.ageDays >= ageDays)
)
) {
item.ageDays = ageDays;
// A failed wider reload no longer matters; a stale edition retries.
_failed.delete(id);
}
notifyRow();
return sync();
}
@@ -745,7 +757,7 @@ export function createGhostMapsLayer({
id: `age-${days ?? 'all'}`,
label:
days === null
? t('All markers')
? t('All', { context: 'event age filter' })
: t('{days}d', { days: formatNumber(days) }),
title:
days === null
+51
View File
@@ -558,3 +558,54 @@ test('a report dated yesterday is shown by 1d one hour into today (UTC)', async
await ageChip(layer, 1).onClick();
assert.equal(layer.getStats().count, 1);
});
test('a widened reload that returns a different report at the selected index closes its card', async (t) => {
let click;
const cards = [];
const reports = [dated('Recent', '11/2/2026'), dated('Older', '10/20/2026')];
const { layer, viewer } = fixture(t, {
now: () => Date.UTC(2026, 10, 3, 12),
reports,
controls: {
overlayHost: { setEntries: (_id, entries) => cards.push(entries) },
screenSpaceEventHandlerFactory: () => ({
setInputAction: (fn) => {
click = fn;
},
destroy() {},
}),
picking: { resolvePickId: (picked) => picked },
},
});
await layer.update(viewer);
viewer.scene.pick = () => 'ghostmaps:cip:0:0';
click({ position: { x: 0, y: 0 } });
assert.equal(cards.at(-1).length, 1);
// The server answers the pinned version with a newer edition's bytes.
reports[0] = dated('Replacement', '11/2/2026');
await ageChip(layer, 30).onClick();
assert.equal(layer.getStats().count, 2);
assert.equal(
cards.at(-1).length,
0,
'a different report must not keep the old card',
);
});
test('narrowing to a window the loaded data covers clears a failed wider reload', async (t) => {
let fail = false;
const { layer, viewer } = fixture(t, {
now: () => Date.UTC(2026, 10, 3, 12),
reports: [dated('Recent', '11/2/2026'), dated('Older', '10/20/2026')],
beforeFileReturns: () => {
if (fail) throw new Error('network down');
},
});
await layer.update(viewer);
fail = true;
await ageChip(layer, 30).onClick();
assert.ok(layer.getStats().error, 'the wider reload failed');
await ageChip(layer, 3).onClick();
assert.equal(layer.getStats().error, null);
assert.equal(layer.getStats().count, 1);
});