From 83dab23694cf66189799753cdb1e0314092d7642 Mon Sep 17 00:00:00 2001 From: haosenwang1018 Date: Mon, 23 Feb 2026 08:14:21 +0800 Subject: [PATCH 1/3] =?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/3] 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 Date: Mon, 23 Feb 2026 08:16:28 +0800 Subject: [PATCH 3/3] fix: add pagination cursor and totalCount to cyber threats response The handler previously returned pagination: undefined, making it impossible for clients to paginate. Now parses cursor as an offset, returns totalCount of filtered results, and nextCursor for the next page (empty string when no more results). Fixes #202 Signed-off-by: haosenwang1018 --- .../worldmonitor/cyber/v1/list-cyber-threats.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/server/worldmonitor/cyber/v1/list-cyber-threats.ts b/server/worldmonitor/cyber/v1/list-cyber-threats.ts index b155d17b..52232b56 100644 --- a/server/worldmonitor/cyber/v1/list-cyber-threats.ts +++ b/server/worldmonitor/cyber/v1/list-cyber-threats.ts @@ -33,6 +33,7 @@ export async function listCyberThreats( try { const now = Date.now(); const pageSize = clampInt(req.pagination?.pageSize, DEFAULT_LIMIT, 1, MAX_LIMIT); + const offset = req.pagination?.cursor ? parseInt(req.pagination.cursor, 10) || 0 : 0; // Derive days from timeRange or use default let days = DEFAULT_DAYS; @@ -55,7 +56,7 @@ export async function listCyberThreats( const anySucceeded = feodo.ok || urlhaus.ok || c2intel.ok || otx.ok || abuseipdb.ok; if (!anySucceeded) { - return { threats: [], pagination: undefined }; + return { threats: [], pagination: { totalCount: 0, nextCursor: '' } }; } // Merge, deduplicate, hydrate coordinates @@ -95,13 +96,18 @@ export async function listCyberThreats( if (bySeverity !== 0) return bySeverity; return (b.lastSeen || b.firstSeen) - (a.lastSeen || a.firstSeen); }) - .slice(0, pageSize); + const totalCount = results.length; + const paged = results.slice(offset, offset + pageSize); + const nextOffset = offset + paged.length; return { - threats: results.map(toProtoCyberThreat), - pagination: undefined, + threats: paged.map(toProtoCyberThreat), + pagination: { + totalCount, + nextCursor: nextOffset < totalCount ? String(nextOffset) : '', + }, }; } catch { - return { threats: [], pagination: undefined }; + return { threats: [], pagination: { totalCount: 0, nextCursor: '' } }; } }