/** * Adapter from /api/firms proxy records (src/data/firmsCsv.js shape) to the * internal fire-record shape rendered by firmsHeatmap.js. Pure — no Cesium, * so the mapping is unit-testable. The normalization helpers previously * lived module-private in firmsHeatmap.js and moved here with the live-data * swap (2026-07-16). */ /** * Map proxy fire records into internal fire records. Records with missing, * non-numeric or out-of-range (WGS84) coordinates are skipped; `index` is the post-skip position * (it keys pick ids and context-store ids, so it must stay sequential). * `contextEntity`/`position` start null and are lazily filled by the layer. * @param {?Array} records - /api/firms `fires` array. * @returns {Array} Internal fire records. */ export function adaptFirmsRecords(records) { const fires = []; if (!Array.isArray(records)) return fires; const acqCache = new Map(); for (const record of records) { const lat = Number(record?.lat); const lon = Number(record?.lon); if ( !hasCoordinateValue(record?.lat) || !hasCoordinateValue(record?.lon) || !Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180 ) continue; fires.push({ index: fires.length, lat, lon, frp: finiteNumber(record.frp), confidence: normalizeConfidence(record.confidence), brightness: finiteNumber(record.brightness), night: record.daynight === 'N', acqMs: parseAcquisitionMs(record.acqDate, record.acqTime, acqCache), sensor: normalizeSensor(record.instrument), satellite: typeof record.satellite === 'string' ? record.satellite : '', contextEntity: null, position: null, }); } return fires; } /** * Normalize FIRMS confidence to 0..1. VIIRS reports the categorical strings * low/nominal/high (`l`/`n`/`h`); MODIS reports 0..100 numeric. * @param {*} value - Raw confidence property. * @returns {number} Confidence in 0..1. */ export function normalizeConfidence(value) { if (typeof value === 'string') { const text = value.trim().toLowerCase(); if (text === 'low' || text === 'l') return 0.3; if (text === 'nominal' || text === 'n') return 0.6; if (text === 'high' || text === 'h') return 0.9; const numeric = Number(text); return Number.isFinite(numeric) ? clamp01(numeric / 100) : 0; } const numeric = Number(value); return Number.isFinite(numeric) ? clamp01(numeric / 100) : 0; } /** * Parse acq_date ("YYYY-MM-DD") + acq_time (unpadded "HHMM", UTC) into epoch * ms. Memoized per unique date:time pair — granule timestamps repeat heavily. * @param {*} date - acq_date property. * @param {*} time - acq_time property. * @param {Map} [cache] - Per-batch memo cache. * @returns {number} Epoch milliseconds, or 0 when unparseable. */ export function parseAcquisitionMs(date, time, cache = new Map()) { if (typeof date !== 'string' || date.length < 10) return 0; const key = `${date}:${time ?? ''}`; const cached = cache.get(key); if (cached !== undefined) return cached; // Same calendar as the CSV gate. Date.UTC would otherwise roll 30 February // into March, and the fire's age would describe a detection that never happened. if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { cache.set(key, 0); return 0; } const timeText = String(time ?? '').trim(); if (!/^\d{1,4}$/.test(timeText)) { cache.set(key, 0); return 0; } const hhmm = timeText.padStart(4, '0'); const year = Number(date.slice(0, 4)); const month = Number(date.slice(5, 7)); const day = Number(date.slice(8, 10)); const hours = Number(hhmm.slice(0, 2)); const minutes = Number(hhmm.slice(2, 4)); if ( month < 1 || month > 12 || day < 1 || day > 31 || hours > 23 || minutes > 59 ) { cache.set(key, 0); return 0; } const ms = Date.UTC(year, month - 1, day, hours, minutes); const parsed = new Date(ms); const result = parsed.getUTCFullYear() === year && parsed.getUTCMonth() === month - 1 && parsed.getUTCDate() === day ? ms : 0; cache.set(key, result); return result; } /** * Map sensor/instrument strings to a short display sensor name. * @param {*} value - Raw instrument property. * @returns {string} */ export function normalizeSensor(value) { const text = String(value || '').toUpperCase(); if (text.includes('VIIRS')) return 'VIIRS'; if (text.includes('MODIS')) return 'MODIS'; return text ? text.slice(0, 12) : ''; } function clamp01(value) { return Math.max(0, Math.min(1, value)); } function finiteNumber(value) { const number = Number(value); return Number.isFinite(number) ? number : 0; } // Same rule as parseFirmsCsv: a blank or non-numeric value is not a zero. function hasCoordinateValue(value) { return ( typeof value === 'number' || (typeof value === 'string' && value.trim() !== '') ); }