| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { haversineKm } from './geo-distance'; |
|
|
| export { haversineKm } from './geo-distance'; |
|
|
| export type GeoEventType = 'protest' | 'military_flight' | 'military_vessel' | 'earthquake'; |
|
|
| |
| export const GEO_CONVERGENCE_WINDOW_MS = 24 * 60 * 60 * 1000; |
| |
| export const GEO_CONVERGENCE_THRESHOLD = 3; |
| |
| export const GEO_NEARBY_MIN_TYPES = 2; |
|
|
| |
| export const GEO_CONFLICT_ZONE_RADIUS_KM = 300; |
| export const GEO_WATERWAY_RADIUS_KM = 200; |
| export const GEO_HOTSPOT_RADIUS_KM = 150; |
|
|
| export const GEO_EVENT_TYPE_LABELS: Record<GeoEventType, string> = { |
| protest: 'protests', |
| military_flight: 'military flights', |
| military_vessel: 'naval vessels', |
| earthquake: 'seismic activity', |
| }; |
|
|
| |
| export interface GeoEventInput { |
| lat: number; |
| lon: number; |
| |
| time?: number; |
| } |
|
|
| export interface GeoConvergenceAlert { |
| cellId: string; |
| lat: number; |
| lon: number; |
| types: GeoEventType[]; |
| totalEvents: number; |
| score: number; |
| } |
|
|
| export interface GeoCellEventSnapshot { |
| type: GeoEventType; |
| count: number; |
| |
| lastSeen: number; |
| } |
|
|
| export interface GeoCellSnapshot { |
| id: string; |
| lat: number; |
| lon: number; |
| |
| firstSeen: number; |
| events: GeoCellEventSnapshot[]; |
| } |
|
|
| export interface GeoNearbyConvergence { |
| score: number; |
| types: number; |
| } |
|
|
| |
| export interface GeoNamedPlace { |
| name: string; |
| lat: number; |
| lon: number; |
| } |
|
|
| |
| export interface GeoConflictZoneCenter { |
| name: string; |
| center: readonly [number, number]; |
| } |
|
|
| |
| export interface GeoPlaceDatasets { |
| conflictZones?: readonly GeoConflictZoneCenter[]; |
| waterways?: readonly GeoNamedPlace[]; |
| hotspots?: readonly GeoNamedPlace[]; |
| } |
|
|
| export function getCellId(lat: number, lon: number): string { |
| return `${Math.floor(lat)},${Math.floor(lon)}`; |
| } |
|
|
| |
| export function scoreGeoCell(typeCount: number, totalEvents: number): number { |
| const typeScore = typeCount * 25; |
| const countBoost = Math.min(25, totalEvents * 2); |
| return Math.min(100, typeScore + countBoost); |
| } |
|
|
| |
| export function getLocationName(lat: number, lon: number, places: GeoPlaceDatasets = {}): string { |
| |
| for (const zone of places.conflictZones ?? []) { |
| const [zoneLon, zoneLat] = zone.center; |
| const dist = haversineKm(lat, lon, zoneLat, zoneLon); |
| if (dist < GEO_CONFLICT_ZONE_RADIUS_KM) { |
| return zone.name.replace(' Conflict', '').replace(' Civil War', ''); |
| } |
| } |
|
|
| |
| for (const waterway of places.waterways ?? []) { |
| const dist = haversineKm(lat, lon, waterway.lat, waterway.lon); |
| if (dist < GEO_WATERWAY_RADIUS_KM) { |
| return waterway.name; |
| } |
| } |
|
|
| |
| let nearestHotspot: { name: string; dist: number } | null = null; |
| for (const hotspot of places.hotspots ?? []) { |
| const dist = haversineKm(lat, lon, hotspot.lat, hotspot.lon); |
| if (dist < GEO_HOTSPOT_RADIUS_KM && (!nearestHotspot || dist < nearestHotspot.dist)) { |
| nearestHotspot = { name: hotspot.name, dist }; |
| } |
| } |
| if (nearestHotspot) { |
| |
| return nearestHotspot.name; |
| } |
|
|
| |
| if (lat >= 25 && lat <= 40 && lon >= 25 && lon <= 75) return 'Middle East'; |
| if (lat >= 30 && lat <= 45 && lon >= 100 && lon <= 145) return 'East Asia'; |
| if (lat >= -10 && lat <= 25 && lon >= 90 && lon <= 130) return 'Southeast Asia'; |
| if (lat >= 35 && lat <= 70 && lon >= -10 && lon <= 40) return 'Europe'; |
| if (lat >= 44 && lat <= 75 && lon >= 20 && lon <= 180) return 'Russia'; |
| if (lat >= -35 && lat <= 35 && lon >= -20 && lon <= 55) return 'Africa'; |
| if (lat >= 25 && lat <= 50 && lon >= -125 && lon <= -65) return 'North America'; |
| if (lat >= -60 && lat <= 15 && lon >= -80 && lon <= -30) return 'South America'; |
|
|
| return `${lat.toFixed(1)}°, ${lon.toFixed(1)}°`; |
| } |
|
|
| |
| |
| |
| |
| export interface GeoConvergenceSignal { |
| id: string; |
| type: 'geo_convergence'; |
| title: string; |
| description: string; |
| confidence: number; |
| timestamp: Date; |
| data: { |
| newsVelocity: number; |
| relatedTopics: GeoEventType[]; |
| }; |
| } |
|
|
| export interface GeoSignalOptions { |
| places?: GeoPlaceDatasets; |
| |
| generateId?: () => string; |
| now?: () => Date; |
| } |
|
|
| export function geoConvergenceToSignal( |
| alert: GeoConvergenceAlert, |
| options: GeoSignalOptions = {}, |
| ): GeoConvergenceSignal { |
| const typeDescriptions = alert.types.map((t) => GEO_EVENT_TYPE_LABELS[t]).join(', '); |
| const locationName = getLocationName(alert.lat, alert.lon, options.places ?? {}); |
|
|
| return { |
| id: options.generateId ? options.generateId() : `sig-${crypto.randomUUID()}`, |
| type: 'geo_convergence', |
| title: `Geographic Convergence (${alert.types.length} types)`, |
| description: `${typeDescriptions} in ${locationName} - ${alert.totalEvents} events/24h`, |
| confidence: alert.score / 100, |
| timestamp: options.now ? options.now() : new Date(), |
| data: { |
| newsVelocity: alert.totalEvents, |
| relatedTopics: alert.types, |
| }, |
| }; |
| } |
|
|
| export interface GeoConvergenceEngineOptions { |
| windowMs?: number; |
| convergenceThreshold?: number; |
| nearbyMinTypes?: number; |
| |
| now?: () => number; |
| } |
|
|
| interface GeoCell { |
| id: string; |
| lat: number; |
| lon: number; |
| events: Map<GeoEventType, { count: number; lastSeen: number }>; |
| firstSeen: number; |
| } |
|
|
| |
| |
| |
| |
| |
| export class GeoConvergenceEngine { |
| private readonly cells = new Map<string, GeoCell>(); |
| private readonly windowMs: number; |
| private readonly convergenceThreshold: number; |
| private readonly nearbyMinTypes: number; |
| private readonly now: () => number; |
|
|
| constructor(options: GeoConvergenceEngineOptions = {}) { |
| this.windowMs = options.windowMs ?? GEO_CONVERGENCE_WINDOW_MS; |
| this.convergenceThreshold = options.convergenceThreshold ?? GEO_CONVERGENCE_THRESHOLD; |
| this.nearbyMinTypes = options.nearbyMinTypes ?? GEO_NEARBY_MIN_TYPES; |
| this.now = options.now ?? (() => Date.now()); |
| } |
|
|
| ingest(lat: number, lon: number, type: GeoEventType, timestamp: number = this.now()): void { |
| const cellId = getCellId(lat, lon); |
|
|
| let cell = this.cells.get(cellId); |
| if (!cell) { |
| cell = { |
| id: cellId, |
| lat: Math.floor(lat) + 0.5, |
| lon: Math.floor(lon) + 0.5, |
| events: new Map(), |
| firstSeen: timestamp, |
| }; |
| this.cells.set(cellId, cell); |
| } |
|
|
| const existing = cell.events.get(type); |
| cell.events.set(type, { |
| count: (existing?.count ?? 0) + 1, |
| lastSeen: timestamp, |
| }); |
| } |
|
|
| ingestEvents(events: readonly GeoEventInput[], type: GeoEventType): void { |
| for (const e of events) { |
| this.ingest(e.lat, e.lon, type, e.time ?? this.now()); |
| } |
| } |
|
|
| |
| |
| |
| |
| detect(seenAlerts: Set<string>): GeoConvergenceAlert[] { |
| this.prune(); |
|
|
| const alerts: GeoConvergenceAlert[] = []; |
|
|
| for (const [cellId, cell] of this.cells) { |
| if (cell.events.size >= this.convergenceThreshold) { |
| if (seenAlerts.has(cellId)) continue; |
|
|
| const types = Array.from(cell.events.keys()); |
| const totalEvents = Array.from(cell.events.values()).reduce((sum, d) => sum + d.count, 0); |
|
|
| alerts.push({ |
| cellId, |
| lat: cell.lat, |
| lon: cell.lon, |
| types, |
| totalEvents, |
| score: scoreGeoCell(cell.events.size, totalEvents), |
| }); |
| seenAlerts.add(cellId); |
| } |
| } |
|
|
| return alerts.sort((a, b) => b.score - a.score); |
| } |
|
|
| |
| alertsNear(lat: number, lon: number, radiusKm: number): GeoNearbyConvergence | null { |
| this.prune(); |
|
|
| let maxScore = 0; |
| let maxTypes = 0; |
|
|
| for (const cell of this.cells.values()) { |
| const dist = haversineKm(lat, lon, cell.lat, cell.lon); |
| if (dist <= radiusKm && cell.events.size >= this.nearbyMinTypes) { |
| const types = cell.events.size; |
| const totalEvents = Array.from(cell.events.values()).reduce((sum, d) => sum + d.count, 0); |
| const score = scoreGeoCell(types, totalEvents); |
|
|
| if (score > maxScore) { |
| maxScore = score; |
| maxTypes = types; |
| } |
| } |
| } |
|
|
| return maxScore > 0 ? { score: maxScore, types: maxTypes } : null; |
| } |
|
|
| clear(): void { |
| this.cells.clear(); |
| } |
|
|
| |
| cellCount(): number { |
| return this.cells.size; |
| } |
|
|
| |
| snapshot(): GeoCellSnapshot[] { |
| return Array.from(this.cells.values(), (cell) => ({ |
| id: cell.id, |
| lat: cell.lat, |
| lon: cell.lon, |
| firstSeen: cell.firstSeen, |
| events: Array.from(cell.events, ([type, data]) => ({ |
| type, |
| count: data.count, |
| lastSeen: data.lastSeen, |
| })), |
| })); |
| } |
|
|
| private prune(): void { |
| const cutoff = this.now() - this.windowMs; |
|
|
| for (const [cellId, cell] of this.cells) { |
| for (const [type, data] of cell.events) { |
| if (data.lastSeen < cutoff) { |
| cell.events.delete(type); |
| } |
| } |
| if (cell.events.size === 0) { |
| this.cells.delete(cellId); |
| } |
| } |
| } |
| } |
|
|