Add adjustable event age filtering to S2 GhostMaps

This commit is contained in:
Dylan Maniatakes committed 2026-11-03 13:15:33 -05:00
1 parent acc9531b3f
commit 36fceb5675
17 files changed
+820 -117

No files matched your search

+191 -80
View File
@@ -16,7 +16,15 @@ import {
} from '../../i18n/index.js';
import { createHorizonCull } from '../streetLevel/view.js';
import { readGhostMapsKml } from './kml.js';
import { fieldLabel, parseDescriptionFields, wrapCardText } from './records.js';
import {
fieldLabel,
GHOSTMAPS_AGE_OPTIONS,
GHOSTMAPS_DEFAULT_AGE_DAYS,
ghostMapsEventDate,
isGhostMapsEventExpired,
parseDescriptionFields,
wrapCardText,
} from './records.js';
export * from './catalog.js';
export * from './records.js';
export { readGhostMapsKml } from './kml.js';
@@ -26,6 +34,7 @@ export const GHOSTMAPS_LAYER_ID = 's2-ghostmaps';
export const GHOSTMAPS_OVERLAY_SOURCE_ID = 's2-ghostmaps';
const PICK_PREFIX = 'ghostmaps:';
const STORAGE_KEY = 'gev.ghostmaps.products';
const AGE_STORAGE_KEY = 'gev.ghostmaps.ageDays';
const ACCENT = '#c8ff00';
const MAX_ICON_BYTES = 512 * 1024;
const CARD_HOST_OPTIONS = Object.freeze({
@@ -82,6 +91,19 @@ function writeStoredSelection(storage, selected) {
}
}
function readStoredAge(storage) {
try {
const raw = storage?.getItem(AGE_STORAGE_KEY);
if (raw !== null && raw !== undefined) {
const value = JSON.parse(raw);
if (GHOSTMAPS_AGE_OPTIONS.includes(value)) return value;
}
} catch {
/* unavailable or invalid storage uses the default */
}
return GHOSTMAPS_DEFAULT_AGE_DAYS;
}
/** A round marker for placemarks whose icon is missing or remote. */
const markerCache = new Map();
function markerImage(color) {
@@ -137,6 +159,7 @@ export function createGhostMapsLayer({
pointer = null,
openExternal = null,
storage = defaultStorage(),
now = () => Date.now(),
} = {}) {
if (typeof source?.getCatalog !== 'function')
throw new TypeError('GhostMaps layer requires a catalog source');
@@ -154,7 +177,9 @@ export function createGhostMapsLayer({
let _selectedLink = null;
let _lifetime = new AbortController();
let _selection = readStoredSelection(storage);
/** productId → {version, dataSource, urls, count, features} */
let _ageDays = readStoredAge(storage);
let _expiryTimer = null;
/** productId → {version, dataSource, urls, features: Map<index, record>, pins} */
const _loaded = new Map();
/** productId → in-flight version */
const _loading = new Map();
@@ -192,8 +217,31 @@ export function createGhostMapsLayer({
if (_selectedKey?.startsWith(`${id}:`)) clearSelection();
}
// Remove expired entities, rather than hiding them: horizon culling must
// never make an old pin visible again. Map keys keep surviving picks stable.
function expireEvents() {
const at = now();
let changed = false;
for (const [productId, item] of _loaded) {
for (const [index, feature] of item.features) {
if (!isGhostMapsEventExpired(feature.dateMs, at, _ageDays)) continue;
for (const entity of feature.entities)
item.dataSource.entities.remove(entity);
item.features.delete(index);
if (_selectedKey === `${productId}:${index}`) clearSelection();
changed = true;
}
item.pins = item.pins.filter((pin) => item.features.has(pin.index));
}
if (changed) {
notifyRow();
render();
}
}
/** Build one product's data source off-scene, then swap it in. */
async function load(product, signal) {
const ageDays = _ageDays;
const bytes = await source.getFile(product.id, product.version, { signal });
signal.throwIfAborted();
const entries = await readZipEntries(bytes);
@@ -223,12 +271,16 @@ export function createGhostMapsLayer({
const dataSource = new Cesium.CustomDataSource(
`${GHOSTMAPS_LAYER_ID}:${product.id}`,
);
const features = [];
const features = new Map();
const pins = [];
const at = now();
dataSource.entities.suspendEvents();
parsed.features.forEach((feature, index) => {
const dateMs = ghostMapsEventDate(feature.description);
if (isGhostMapsEventExpired(dateMs, at, ageDays)) return;
const key = `${product.id}:${index}`;
const { style } = feature;
const entities = [];
feature.geometries.forEach((geometry, part) => {
const id = `${PICK_PREFIX}${key}:${part}`;
if (geometry.type === 'point') {
@@ -255,7 +307,9 @@ export function createGhostMapsLayer({
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
});
entities.push(entity);
pins.push({
index,
position,
get show() {
return entity.show;
@@ -265,61 +319,76 @@ export function createGhostMapsLayer({
},
});
} else if (geometry.type === 'line') {
dataSource.entities.add({
id,
polyline: {
positions: Cesium.Cartesian3.fromDegreesArray(
geometry.positions.flat(),
),
width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)),
material: cesiumColor(style.lineColor, color),
clampToGround: true,
},
});
} else {
const ring = (points) =>
Cesium.Cartesian3.fromDegreesArray(points.flat());
dataSource.entities.add({
id,
polygon: {
hierarchy: new Cesium.PolygonHierarchy(
ring(geometry.outer),
geometry.holes.map(
(hole) => new Cesium.PolygonHierarchy(ring(hole)),
),
),
material:
style.fill === false
? Cesium.Color.TRANSPARENT
: cesiumColor(
style.polyColor,
color,
style.polyColor ? undefined : 0.25,
),
classificationType: Cesium.ClassificationType.BOTH,
},
});
if (style.outline !== false)
entities.push(
dataSource.entities.add({
id: `${id}:outline`,
id,
polyline: {
positions: ring([...geometry.outer, geometry.outer[0]]),
width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)),
positions: Cesium.Cartesian3.fromDegreesArray(
geometry.positions.flat(),
),
width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)),
material: cesiumColor(style.lineColor, color),
clampToGround: true,
},
});
}),
);
} else {
const ring = (points) =>
Cesium.Cartesian3.fromDegreesArray(points.flat());
entities.push(
dataSource.entities.add({
id,
polygon: {
hierarchy: new Cesium.PolygonHierarchy(
ring(geometry.outer),
geometry.holes.map(
(hole) => new Cesium.PolygonHierarchy(ring(hole)),
),
),
material:
style.fill === false
? Cesium.Color.TRANSPARENT
: cesiumColor(
style.polyColor,
color,
style.polyColor ? undefined : 0.25,
),
classificationType: Cesium.ClassificationType.BOTH,
},
}),
);
if (style.outline !== false)
entities.push(
dataSource.entities.add({
id: `${id}:outline`,
polyline: {
positions: ring([...geometry.outer, geometry.outer[0]]),
width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)),
material: cesiumColor(style.lineColor, color),
clampToGround: true,
},
}),
);
}
});
features.push({
features.set(index, {
name: feature.name,
folder: feature.folder,
description: feature.description,
dateMs,
entities,
anchor: anchorOf(feature.geometries[0]),
});
});
dataSource.entities.resumeEvents();
return { version: product.version, dataSource, urls, features, pins };
return {
version: product.version,
ageDays,
dataSource,
urls,
features,
pins,
};
} catch (error) {
urls.forEach((url) => URL.revokeObjectURL(url));
throw error;
@@ -330,6 +399,7 @@ export function createGhostMapsLayer({
function sync() {
const run = _queue.then(async () => {
if (!_enabled || !_viewer || !_catalog) return;
expireEvents();
const signal = _lifetime.signal;
const products = new Map(_catalog.products.map((p) => [p.id, p]));
for (const id of [..._loaded.keys()]) {
@@ -338,13 +408,15 @@ export function createGhostMapsLayer({
}
for (const product of _catalog.products) {
if (!wanted(product) || signal.aborted) continue;
if (_loaded.get(product.id)?.version === product.version) continue;
const loaded = _loaded.get(product.id);
if (loaded?.version === product.version && loaded.ageDays === _ageDays)
continue;
_loading.set(product.id, product.version);
_failed.delete(product.id);
notifyRow();
try {
const next = await load(product, signal);
if (signal.aborted || !_enabled) {
if (signal.aborted || !_enabled || next.ageDays !== _ageDays) {
next.urls.forEach((url) => URL.revokeObjectURL(url));
return;
}
@@ -391,12 +463,28 @@ export function createGhostMapsLayer({
void sync();
}
function setAgeDays(ageDays) {
if (!GHOSTMAPS_AGE_OPTIONS.includes(ageDays) || ageDays === _ageDays)
return;
_ageDays = ageDays;
try {
storage?.setItem(AGE_STORAGE_KEY, JSON.stringify(ageDays));
} catch {
/* private window or blocked storage: keep the session choice */
}
expireEvents();
notifyRow();
// A wider window must restore previously omitted placemarks from the
// cached KMZ, even though its upstream version has not changed.
return sync();
}
// ---- selection card ----------------------------------------------------
function featureFor(key) {
const [productId, index] = key.split(':');
return {
product: _catalog?.products.find((p) => p.id === productId) || null,
feature: _loaded.get(productId)?.features[Number(index)] || null,
feature: _loaded.get(productId)?.features.get(Number(index)) || null,
};
}
@@ -490,7 +578,7 @@ export function createGhostMapsLayer({
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX))
return null;
const [productId, index] = pickId.slice(PICK_PREFIX.length).split(':');
return _loaded.get(productId)?.features[Number(index)]
return _loaded.get(productId)?.features.has(Number(index))
? `${productId}:${index}`
: null;
}
@@ -542,7 +630,7 @@ export function createGhostMapsLayer({
function featureCount() {
let count = 0;
for (const item of _loaded.values()) count += item.features.length;
for (const item of _loaded.values()) count += item.features.size;
return count;
}
@@ -563,6 +651,8 @@ export function createGhostMapsLayer({
enable() {
_enabled = true;
_lifetime = new AbortController();
if (_expiryTimer === null)
_expiryTimer = setInterval(expireEvents, 60_000);
for (const item of _loaded.values()) item.dataSource.show = true;
installClickHandler();
if (!_unsubscribe && typeof source.subscribe === 'function')
@@ -580,6 +670,8 @@ export function createGhostMapsLayer({
disable() {
_enabled = false;
clearInterval(_expiryTimer);
_expiryTimer = null;
_lifetime.abort();
_unsubscribe?.();
_unsubscribe = null;
@@ -593,6 +685,7 @@ export function createGhostMapsLayer({
async update(_viewerArg, { signal } = {}) {
if (!_enabled || signal?.aborted) return false;
expireEvents();
try {
const catalog = await source.getCatalog({ signal });
if (!_enabled) return false;
@@ -637,37 +730,54 @@ export function createGhostMapsLayer({
})
: t('Connecting to the S2 GhostMaps repository…');
return {
chips: products.map((product) => {
const loaded = _loaded.get(product.id);
const failed = _failed.get(product.id);
return {
id: product.id,
label: t(PRODUCT_LABELS[product.id] || product.title),
active: wanted(product),
busy: _loading.has(product.id),
state: failed ? 'error' : undefined,
color: PRODUCT_COLORS[product.id],
title: [
product.fileTitle,
loaded
? tn(
loaded.features.length,
'{count} placemark',
'{count} placemarks',
{
count: formatNumber(loaded.features.length),
},
)
: null,
failed ? t(failed) : null,
]
.filter(Boolean)
.join(' · '),
onClick: () => toggleProduct(product.id),
};
}),
chips: [
...GHOSTMAPS_AGE_OPTIONS.map((days) => ({
id: `age-${days ?? 'all'}`,
label:
days === null
? t('All markers')
: t('{days}d', { days: formatNumber(days) }),
title:
days === null
? t('Show markers of every age')
: t('Maximum event age: {days}d; undated markers are kept', {
days: formatNumber(days),
}),
active: days === _ageDays,
onClick: () => setAgeDays(days),
})),
...products.map((product) => {
const loaded = _loaded.get(product.id);
const failed = _failed.get(product.id);
return {
id: product.id,
label: t(PRODUCT_LABELS[product.id] || product.title),
active: wanted(product),
busy: _loading.has(product.id),
state: failed ? 'error' : undefined,
color: PRODUCT_COLORS[product.id],
title: [
product.fileTitle,
loaded
? tn(
loaded.features.size,
'{count} placemark',
'{count} placemarks',
{
count: formatNumber(loaded.features.size),
},
)
: null,
failed ? t(failed) : null,
]
.filter(Boolean)
.join(' · '),
onClick: () => toggleProduct(product.id),
};
}),
],
legend: [],
info,
info: `${_ageDays === null ? t('All markers') : t('Event age: {days}d · undated kept', { days: formatNumber(_ageDays) })} · ${info}`,
infoTitle: t(
'S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.',
),
@@ -677,6 +787,7 @@ export function createGhostMapsLayer({
getStats() {
return {
count: featureCount(),
ageDays: _ageDays,
products: [..._loaded.keys()],
commit: _catalog?.commit || null,
lastUpdate: _catalog?.committedAt || _catalog?.changedAt || null,
@@ -693,7 +804,7 @@ export function createGhostMapsLayer({
if (!_enabled) return [];
const out = [];
for (const [productId, item] of _loaded) {
for (const feature of item.features) {
for (const feature of item.features.values()) {
if (out.length >= maxCount) return out;
out.push({
product: productId,