mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
Adds an "S2 GhostMaps" layer (Events group) that renders S2 Underground's GhostMaps KMZ exports (Common Intelligence Picture, Kinetic 30d, Border Crisis, Iran CIP/Kinetic, Yemen FLOT, latest incident drop). - server/providers/ghostMaps.js: watcher polls the repo's git smart-HTTP ref advertisement (default every 15s, GHOSTMAPS_POLL_SECONDS), fetches the tree only on a new head, caches commit-pinned files by blob sha and pushes catalog updates to browsers over SSE (/api/ghostmaps/events). - src/layers/ghostMaps: KML reader, ArcGIS popup-table parsing, per-product data sources, product chips, horizon-culled pins, report cards. - Unit tests, QA gate (scripts/qa-ghostmaps.mjs), credits (CC BY-NC-SA 4.0), i18n, voice manifest, layer-state token 5. Co-Authored-By: Claude Opus 5.5 <[email protected]>
246 lines
7.9 KiB
JavaScript
246 lines
7.9 KiB
JavaScript
// S2 Underground GhostMaps catalog: which KMZ in the public repository is the
|
|
// current edition of each product. Pure and portable (the server builds the
|
|
// catalog, tests pin the rules); no i18n, no DOM, no Cesium.
|
|
|
|
export const GHOSTMAPS_REPO = Object.freeze({
|
|
owner: 's2underground',
|
|
name: 'GhostMaps',
|
|
branch: 'main',
|
|
url: 'https://github.com/s2underground/GhostMaps',
|
|
});
|
|
|
|
/** Largest KMZ the proxy downloads; matches the browser's import ceiling. */
|
|
export const GHOSTMAPS_MAX_FILE_BYTES = 50 * 1024 * 1024;
|
|
|
|
const ARCGIS = 'ArcGIS Data for ATAK (KMZs)/';
|
|
const INCIDENTS = 'Incident Response Products for ATAK (KMZs and Documents)/';
|
|
|
|
/**
|
|
* Curated products, in panel order. Each names a series of dated uploads; the
|
|
* newest edition wins. Patterns match whole repository paths, so a renamed
|
|
* folder fails closed (the product drops out) instead of matching elsewhere.
|
|
*/
|
|
export const GHOSTMAPS_PRODUCTS = Object.freeze([
|
|
Object.freeze({
|
|
id: 'cip',
|
|
// i18n-ignore: catalog data; the panel shows its own translated label
|
|
title: 'Common Intelligence Picture',
|
|
defaultOn: true,
|
|
pattern: new RegExp(
|
|
`^${escape(ARCGIS)}Common Intelligence Picture/Master Database/[^/]+\\.kmz$`,
|
|
'i',
|
|
),
|
|
}),
|
|
Object.freeze({
|
|
id: 'kinetic-30d',
|
|
// i18n-ignore: catalog data; the panel shows its own translated label
|
|
title: 'Kinetic Activity (30 days)',
|
|
defaultOn: true,
|
|
pattern: new RegExp(
|
|
`^${escape(ARCGIS)}Common Intelligence Picture/30-Day Pulls/Kinetic[^/]*\\.kmz$`,
|
|
'i',
|
|
),
|
|
}),
|
|
Object.freeze({
|
|
id: 'border-crisis',
|
|
// i18n-ignore: catalog data; the panel shows its own translated label
|
|
title: 'Border Crisis Map',
|
|
defaultOn: false,
|
|
pattern: new RegExp(
|
|
`^${escape(ARCGIS)}Border Crisis Map/S2Underground_Border_Crisis_Map[^/]*\\.kmz$`,
|
|
'i',
|
|
),
|
|
}),
|
|
Object.freeze({
|
|
id: 'iran-cip',
|
|
// i18n-ignore: catalog data; the panel shows its own translated label
|
|
title: 'Iran Common Intelligence Picture',
|
|
defaultOn: false,
|
|
pattern: new RegExp(
|
|
`^${escape(INCIDENTS)}Iran[^/]*/Iran Common Intelligence Picture/[^/]+\\.kmz$`,
|
|
'i',
|
|
),
|
|
}),
|
|
Object.freeze({
|
|
id: 'iran-kinetic',
|
|
// i18n-ignore: catalog data; the panel shows its own translated label
|
|
title: 'Iran Kinetic Events',
|
|
defaultOn: false,
|
|
pattern: new RegExp(
|
|
`^${escape(INCIDENTS)}Iran[^/]*/Iran Kinetic Events/[^/]+\\.kmz$`,
|
|
'i',
|
|
),
|
|
}),
|
|
Object.freeze({
|
|
id: 'yemen-flot',
|
|
// i18n-ignore: catalog data; the panel shows its own translated label
|
|
title: 'Yemen FLOT',
|
|
defaultOn: false,
|
|
pattern: new RegExp(
|
|
`^${escape(INCIDENTS)}Yemen[^/]*/Yemen FLOT[^/]*\\.kmz$`,
|
|
'i',
|
|
),
|
|
}),
|
|
]);
|
|
|
|
/** The newest incident product outside the curated series and the archive. */
|
|
export const GHOSTMAPS_LATEST_DROP = Object.freeze({
|
|
id: 'latest-drop',
|
|
// i18n-ignore: catalog data; the panel shows its own translated label
|
|
title: 'Latest incident drop',
|
|
defaultOn: false,
|
|
});
|
|
|
|
const LATEST_DROP_EXCLUDED =
|
|
/\/(?:0_Archive|Basedata|Reference Material)(?:\/|$)/i;
|
|
|
|
function escape(text) {
|
|
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
const MONTHS = [
|
|
'jan',
|
|
'feb',
|
|
'mar',
|
|
'apr',
|
|
'may',
|
|
'jun',
|
|
'jul',
|
|
'aug',
|
|
'sep',
|
|
'oct',
|
|
'nov',
|
|
'dec',
|
|
];
|
|
const DATE_IN_NAME =
|
|
/(january|february|march|april|may|june|july|august|september|sept|october|november|december|jan|feb|mar|apr|jun|jul|aug|sep|oct|nov|dec)[ _-]?(\d{1,2})(?:st|nd|rd|th)?[ ,_-]+(20\d\d)/i;
|
|
const EDITION_IN_NAME = /(?:[ _-]export)?[ _-]v?(\d{1,3})$/i;
|
|
|
|
/**
|
|
* The date and edition written into an S2 file name, e.g.
|
|
* `..._October_16_2026_Export_1.kmz` → 2026-10-16, edition 1. Undated names
|
|
* report `dateMs: null` and sort below every dated edition.
|
|
* @param {string} fileName
|
|
* @returns {{dateMs: ?number, edition: number}}
|
|
*/
|
|
export function parseGhostMapsEdition(fileName) {
|
|
const stem = String(fileName || '').replace(/\.km[lz]$/i, '');
|
|
const match = DATE_IN_NAME.exec(stem);
|
|
let dateMs = null;
|
|
if (match) {
|
|
const month = MONTHS.indexOf(match[1].slice(0, 3).toLowerCase());
|
|
const day = Number(match[2]);
|
|
const year = Number(match[3]);
|
|
const at = Date.UTC(year, month, day);
|
|
// Date.UTC rolls 31 Sep into 1 Oct; only an exact calendar date counts.
|
|
if (month >= 0 && new Date(at).getUTCDate() === day) dateMs = at;
|
|
}
|
|
const tail = match ? stem.slice(match.index + match[0].length) : stem;
|
|
const edition = Number(EDITION_IN_NAME.exec(tail)?.[1] ?? 0);
|
|
return { dateMs, edition };
|
|
}
|
|
|
|
function newer(a, b) {
|
|
if (!b) return true;
|
|
const ad = a.dateMs ?? -Infinity;
|
|
const bd = b.dateMs ?? -Infinity;
|
|
if (ad !== bd) return ad > bd;
|
|
if (a.edition !== b.edition) return a.edition > b.edition;
|
|
return a.path > b.path;
|
|
}
|
|
|
|
/** `S2Underground_Border_Crisis_Map_June_02_2026_v1.kmz` → readable words. */
|
|
export function ghostMapsFileTitle(fileName) {
|
|
return String(fileName || '')
|
|
.replace(/\.km[lz]$/i, '')
|
|
.replace(/^S2Underground_/i, '')
|
|
.replace(/_/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
/**
|
|
* Build the product catalog from a recursive git tree listing.
|
|
* @param {Array<{path: string, type: string, sha: string, size?: number}>} tree
|
|
* @returns {Array<{id: string, title: string, defaultOn: boolean,
|
|
* path: string, fileName: string, fileTitle: string, version: string,
|
|
* size: number, dateMs: ?number, edition: number}>}
|
|
*/
|
|
export function buildGhostMapsCatalog(tree) {
|
|
const best = new Map();
|
|
let drop = null;
|
|
for (const entry of Array.isArray(tree) ? tree : []) {
|
|
if (entry?.type !== 'blob' || typeof entry.path !== 'string') continue;
|
|
if (!/\.kmz$/i.test(entry.path)) continue;
|
|
if (!/^[0-9a-f]{40}$/.test(entry.sha || '')) continue;
|
|
const size = Number(entry.size);
|
|
if (!Number.isFinite(size) || size <= 0 || size > GHOSTMAPS_MAX_FILE_BYTES)
|
|
continue;
|
|
const fileName = entry.path.slice(entry.path.lastIndexOf('/') + 1);
|
|
const candidate = {
|
|
path: entry.path,
|
|
fileName,
|
|
version: entry.sha,
|
|
size,
|
|
...parseGhostMapsEdition(fileName),
|
|
};
|
|
const product = GHOSTMAPS_PRODUCTS.find(({ pattern }) =>
|
|
pattern.test(entry.path),
|
|
);
|
|
if (product) {
|
|
if (newer(candidate, best.get(product.id)))
|
|
best.set(product.id, candidate);
|
|
} else if (
|
|
entry.path.startsWith(INCIDENTS) &&
|
|
!LATEST_DROP_EXCLUDED.test(`/${entry.path}`) &&
|
|
candidate.dateMs != null &&
|
|
newer(candidate, drop)
|
|
) {
|
|
drop = candidate;
|
|
}
|
|
}
|
|
const rows = [];
|
|
for (const product of [...GHOSTMAPS_PRODUCTS, GHOSTMAPS_LATEST_DROP]) {
|
|
const file =
|
|
product === GHOSTMAPS_LATEST_DROP ? drop : best.get(product.id);
|
|
if (!file) continue;
|
|
rows.push({
|
|
id: product.id,
|
|
title: product.title,
|
|
defaultOn: product.defaultOn,
|
|
...file,
|
|
fileTitle: ghostMapsFileTitle(file.fileName),
|
|
});
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
/**
|
|
* Raw download URL pinned to a commit, so a CDN can never hand back an older
|
|
* edition for a newer catalog.
|
|
* @param {string} commit 40-hex commit sha.
|
|
* @param {string} path Repository path.
|
|
*/
|
|
export function ghostMapsRawUrl(commit, path) {
|
|
const encoded = path.split('/').map(encodeURIComponent).join('/');
|
|
return `https://raw.githubusercontent.com/${GHOSTMAPS_REPO.owner}/${GHOSTMAPS_REPO.name}/${commit}/${encoded}`;
|
|
}
|
|
|
|
/**
|
|
* The branch head from a git smart-HTTP ref advertisement
|
|
* (`/info/refs?service=git-upload-pack`), or null.
|
|
* @param {string} text
|
|
* @param {string} [branch]
|
|
* @returns {?string}
|
|
*/
|
|
export function parseGitRefAdvertisement(text, branch = GHOSTMAPS_REPO.branch) {
|
|
const ref = `refs/heads/${branch}`;
|
|
for (const line of String(text || '').split('\n')) {
|
|
// pkt-line: 4 hex length digits, then "<sha> <ref>" (the first ref line
|
|
// also carries a NUL and the capability list).
|
|
const match = /^[0-9a-f]{4}([0-9a-f]{40}) ([^\0\s]+)/.exec(line);
|
|
if (match && match[2] === ref) return match[1];
|
|
}
|
|
return null;
|
|
}
|