mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
Add adjustable event age filtering to S2 GhostMaps
This commit is contained in:
1 parent
acc9531b3f
commit
36fceb5675
17 files changed
+820
-117
No files matched your search
+191
-80
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user