From 0f2d64b908b8d4c244ede543d70c7aeb3363de3e Mon Sep 17 00:00:00 2001 From: mrkmntal Date: Sat, 18 Apr 2026 09:50:15 -0400 Subject: [PATCH] refine hazard history matching and hazard list SQL selection --- src/hazard-history.mjs | 166 +++++++++++++++++++++++++++++++++-------- 1 file changed, 135 insertions(+), 31 deletions(-) diff --git a/src/hazard-history.mjs b/src/hazard-history.mjs index 9545d17..c2221cd 100644 --- a/src/hazard-history.mjs +++ b/src/hazard-history.mjs @@ -1,6 +1,7 @@ import { getPool } from './mysql.mjs'; const MAX_HISTORY_ENTRIES = 7; +const PRACTICAL_LOCATION_RADIUS_KM = 50; const toIsoString = (value) => { if (!value) return null; @@ -20,6 +21,60 @@ const mapRowToHistoryEntry = (row) => ({ ongoing: Boolean(row.ongoing), }); +const parseLocationKey = (locationKey) => { + if (typeof locationKey !== 'string') return null; + const [latText, lonText] = locationKey.split(','); + const lat = Number.parseFloat(latText); + const lon = Number.parseFloat(lonText); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + return { lat, lon }; +}; + +const distanceKm = (a, b) => { + const toRadians = (value) => value * (Math.PI / 180); + const earthRadiusKm = 6371; + const dLat = toRadians(b.lat - a.lat); + const dLon = toRadians(b.lon - a.lon); + const lat1 = toRadians(a.lat); + const lat2 = toRadians(b.lat); + const haversine = Math.sin(dLat / 2) ** 2 + + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2; + return 2 * earthRadiusKm * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine)); +}; + +const isSamePracticalLocation = (row, location, locationKey) => { + if (row.location_key === locationKey) return true; + if (row.location_label !== location) return false; + const rowCoords = parseLocationKey(row.location_key); + const currentCoords = parseLocationKey(locationKey); + if (!rowCoords || !currentCoords) return false; + return distanceKm(rowCoords, currentCoords) <= PRACTICAL_LOCATION_RADIUS_KM; +}; + +const getCandidateRows = async (connection, location, locationKey) => { + const [rows] = await connection.execute( + `SELECT + id, + location_label, + location_key, + hazard_type, + source, + severity, + latest_hazard_id, + encountered_at, + last_seen_at, + ongoing + FROM hazard_history + WHERE location_key = ? + OR location_label = ?`, + [locationKey, location], + ); + + return rows.filter((row) => isSamePracticalLocation(row, location, locationKey)); +}; + +const buildHazardIdentity = (hazardType, source) => `${hazardType}::${source}`; + /** * Format location label from weather parameters * @param {string} city - City name @@ -43,17 +98,34 @@ const formatLocation = (city, state, country, countryCode) => { const getHistory = async () => { const [rows] = await getPool().query( `SELECT - location_label, - location_key, - hazard_type, - source, - severity, - latest_hazard_id, - encountered_at, - last_seen_at, - ongoing - FROM hazard_history - ORDER BY last_seen_at DESC + h.location_label, + h.location_key, + h.hazard_type, + h.source, + h.severity, + h.latest_hazard_id, + h.encountered_at, + h.last_seen_at, + h.ongoing + FROM hazard_history h + WHERE NOT EXISTS ( + SELECT 1 + FROM hazard_history h2 + WHERE h2.location_key = h.location_key + AND ( + h2.last_seen_at > h.last_seen_at + OR ( + h2.last_seen_at = h.last_seen_at + AND h2.ongoing > h.ongoing + ) + OR ( + h2.last_seen_at = h.last_seen_at + AND h2.ongoing = h.ongoing + AND h2.id > h.id + ) + ) + ) + ORDER BY h.last_seen_at DESC, h.ongoing DESC, h.id DESC LIMIT ?`, [MAX_HISTORY_ENTRIES], ); @@ -69,32 +141,64 @@ const updateHistory = async (payload) => { try { await connection.beginTransaction(); + const candidateRows = await getCandidateRows(connection, location, locationKey); + const activeHazardKeys = new Set(validHazards.map((hazard) => buildHazardIdentity(hazard.hazardType, hazard.source))); if (validHazards.length === 0) { - await connection.execute( - `UPDATE hazard_history - SET ongoing = 0, - last_seen_at = UTC_TIMESTAMP() - WHERE location_key = ? - AND ongoing = 1`, - [locationKey], - ); + const idsToEnd = candidateRows.filter((row) => row.ongoing).map((row) => row.id); + if (idsToEnd.length > 0) { + const placeholders = idsToEnd.map(() => '?').join(', '); + await connection.execute( + `UPDATE hazard_history + SET ongoing = 0, + last_seen_at = UTC_TIMESTAMP() + WHERE id IN (${placeholders}) + AND ongoing = 1`, + idsToEnd, + ); + } } else { - const keepClauses = validHazards.map(() => '(hazard_type = ? AND source = ?)').join(' OR '); - const keepParams = validHazards.flatMap((hazard) => [hazard.hazardType, hazard.source]); - - await connection.execute( - `UPDATE hazard_history - SET ongoing = 0, - last_seen_at = UTC_TIMESTAMP() - WHERE location_key = ? - AND ongoing = 1 - AND NOT (${keepClauses})`, - [locationKey, ...keepParams], - ); + const idsToEnd = candidateRows + .filter((row) => row.ongoing && !activeHazardKeys.has(buildHazardIdentity(row.hazard_type, row.source))) + .map((row) => row.id); + if (idsToEnd.length > 0) { + const placeholders = idsToEnd.map(() => '?').join(', '); + await connection.execute( + `UPDATE hazard_history + SET ongoing = 0, + last_seen_at = UTC_TIMESTAMP() + WHERE id IN (${placeholders}) + AND ongoing = 1`, + idsToEnd, + ); + } } for (const hazard of validHazards) { + const nearbyMatch = candidateRows.find((row) => row.hazard_type === hazard.hazardType + && row.source === hazard.source + && row.location_key !== locationKey); + + if (nearbyMatch) { + await connection.execute( + `UPDATE hazard_history + SET location_key = ?, + location_label = ?, + severity = ?, + latest_hazard_id = ?, + last_seen_at = UTC_TIMESTAMP(), + ongoing = 1 + WHERE id = ?`, + [ + locationKey, + location, + hazard.severity ?? null, + hazard.id ?? null, + nearbyMatch.id, + ], + ); + } + await connection.execute( `INSERT INTO hazard_history ( location_label,