From 83dab23694cf66189799753cdb1e0314092d7642 Mon Sep 17 00:00:00 2001 From: haosenwang1018 Date: Mon, 23 Feb 2026 08:14:21 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20reduce=20dedup=20coordinate=20roundi?= =?UTF-8?q?ng=20from=200.5=C2=B0=20to=200.1=C2=B0=20(~10km)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous 0.5° rounding (~50km radius) merged distinct events in dense urban areas (e.g. Manhattan vs Brooklyn protests on the same day). Reducing to 0.1° (~10km) preserves neighborhood-level granularity while still deduplicating true duplicates. Fixes #204 Signed-off-by: haosenwang1018 --- server/worldmonitor/unrest/v1/_shared.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/worldmonitor/unrest/v1/_shared.ts b/server/worldmonitor/unrest/v1/_shared.ts index 073fb8c5..03b07fea 100644 --- a/server/worldmonitor/unrest/v1/_shared.ts +++ b/server/worldmonitor/unrest/v1/_shared.ts @@ -73,8 +73,8 @@ export function deduplicateEvents(events: UnrestEvent[]): UnrestEvent[] { for (const event of events) { const lat = event.location?.latitude ?? 0; const lon = event.location?.longitude ?? 0; - const latKey = Math.round(lat * 2) / 2; - const lonKey = Math.round(lon * 2) / 2; + const latKey = Math.round(lat * 10) / 10; + const lonKey = Math.round(lon * 10) / 10; const dateKey = new Date(event.occurredAt).toISOString().split('T')[0]; const key = `${latKey}:${lonKey}:${dateKey}`; From b9728af63dac561acf30a4b4a65255dd00bf3e66 Mon Sep 17 00:00:00 2001 From: haosenwang1018 Date: Mon, 23 Feb 2026 08:15:07 +0800 Subject: [PATCH 2/2] fix: use deterministic hash-based jitter for country centroid fallback Replace Math.random() with a djb2 hash seeded by the threat ID, so the same threat always appears at the same coordinates. This prevents 'jumping' markers on the map when the same data is re-fetched. Fixes #203 Signed-off-by: haosenwang1018 --- server/worldmonitor/cyber/v1/_shared.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/server/worldmonitor/cyber/v1/_shared.ts b/server/worldmonitor/cyber/v1/_shared.ts index 29987557..ed079607 100644 --- a/server/worldmonitor/cyber/v1/_shared.ts +++ b/server/worldmonitor/cyber/v1/_shared.ts @@ -191,12 +191,25 @@ const COUNTRY_CENTROIDS: Record = { SN:[14.5,-14.5],CM:[7.4,12.4],CI:[7.5,-5.5],TZ:[-6.4,34.9],UG:[1.4,32.3], }; -function getCountryCentroid(countryCode: string): { lat: number; lon: number } | null { +/** + * Simple deterministic hash (djb2) that returns a float in [-0.5, 0.5). + * Same seed always produces the same value. + */ +function hashJitter(seed: string, index: number): number { + let hash = 5381; + const s = `${seed}:${index}`; + for (let i = 0; i < s.length; i++) { + hash = ((hash << 5) + hash + s.charCodeAt(i)) | 0; + } + return ((hash & 0x7fffffff) / 0x7fffffff - 0.5) * 2; +} + +function getCountryCentroid(countryCode: string, seed?: string): { lat: number; lon: number } | null { if (!countryCode) return null; const coords = COUNTRY_CENTROIDS[countryCode.toUpperCase()]; if (!coords) return null; - const jitter = () => (Math.random() - 0.5) * 2; - return { lat: coords[0] + jitter(), lon: coords[1] + jitter() }; + const key = seed || countryCode; + return { lat: coords[0] + hashJitter(key, 0), lon: coords[1] + hashJitter(key, 1) }; } // ======================================================================== @@ -366,7 +379,7 @@ export async function hydrateThreatCoordinates(threats: RawThreat[]): Promise