The smokesignal.events web application
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 193 lines 5.8 kB view raw
1use axum::Json; 2use axum::extract::{Query, State}; 3use axum::http::StatusCode; 4use axum::response::IntoResponse; 5use serde::{Deserialize, Serialize}; 6 7use std::collections::HashMap; 8 9use crate::http::context::WebContext; 10use crate::http::errors::WebError; 11use crate::search_index::{GeoCenter, GeoHexBucket, SearchIndexManager}; 12 13/// H3 precision level (5 = ~8.5km edge length) 14const PRECISION: u8 = 7; 15/// Search radius in miles 16const DISTANCE_MILES: f64 = 60.0; 17 18#[derive(Debug, Deserialize)] 19pub(crate) struct GeoAggregationParams { 20 /// Center latitude 21 lat: f64, 22 /// Center longitude 23 lon: f64, 24} 25 26#[derive(Debug, Serialize)] 27pub(crate) struct GeoAggregationResponse { 28 pub buckets: Vec<GeoHexBucket>, 29} 30 31/// GET /api/geo-aggregation 32/// 33/// Returns H3 hexagonal grid aggregation of upcoming event locations 34/// within 300 miles of the specified center point. 35/// 36/// Query parameters: 37/// - lat: Center latitude (required) 38/// - lon: Center longitude (required) 39pub(crate) async fn handle_geo_aggregation( 40 State(web_context): State<WebContext>, 41 Query(params): Query<GeoAggregationParams>, 42) -> Result<impl IntoResponse, WebError> { 43 let opensearch_endpoint = match web_context.config.opensearch_endpoint.as_ref() { 44 Some(value) => value, 45 None => { 46 return Ok(( 47 StatusCode::SERVICE_UNAVAILABLE, 48 Json(GeoAggregationResponse { buckets: vec![] }), 49 ) 50 .into_response()); 51 } 52 }; 53 54 let manager = match SearchIndexManager::new(opensearch_endpoint) { 55 Ok(m) => m, 56 Err(err) => { 57 tracing::error!(?err, "Failed to create search index manager"); 58 return Ok(( 59 StatusCode::INTERNAL_SERVER_ERROR, 60 Json(GeoAggregationResponse { buckets: vec![] }), 61 ) 62 .into_response()); 63 } 64 }; 65 66 let center = GeoCenter { 67 lat: params.lat, 68 lon: params.lon, 69 distance_miles: DISTANCE_MILES, 70 }; 71 72 let buckets = manager 73 .get_event_geo_aggregation(PRECISION, Some(center), true) 74 .await 75 .unwrap_or_default(); 76 77 Ok((StatusCode::OK, Json(GeoAggregationResponse { buckets })).into_response()) 78} 79 80/// Default H3 precision for globe view (lower for world-scale) 81const GLOBE_PRECISION: u8 = 3; 82 83#[derive(Debug, Deserialize)] 84pub(crate) struct GlobeAggregationParams { 85 /// H3 resolution (1-7, default 3 for world view) 86 #[serde(default = "default_resolution")] 87 resolution: u8, 88} 89 90fn default_resolution() -> u8 { 91 GLOBE_PRECISION 92} 93 94#[derive(Debug, Serialize)] 95pub(crate) struct GlobeHexBucket { 96 /// H3 cell index 97 pub key: String, 98 /// Number of events in this cell 99 pub event_count: u64, 100 /// Number of LFG profiles in this cell 101 pub lfg_count: u64, 102 /// Total activity (events + profiles) 103 pub total: u64, 104} 105 106#[derive(Debug, Serialize)] 107pub(crate) struct GlobeAggregationResponse { 108 pub buckets: Vec<GlobeHexBucket>, 109} 110 111/// GET /api/globe-aggregation 112/// 113/// Returns H3 hexagonal grid aggregation of events and LFG profiles globally. 114/// Used for the 3D globe visualization on the homepage. 115/// 116/// Query parameters: 117/// - resolution: H3 resolution level (1-7, default 3) 118pub(crate) async fn handle_globe_aggregation( 119 State(web_context): State<WebContext>, 120 Query(params): Query<GlobeAggregationParams>, 121) -> Result<impl IntoResponse, WebError> { 122 let opensearch_endpoint = match web_context.config.opensearch_endpoint.as_ref() { 123 Some(value) => value, 124 None => { 125 return Ok(( 126 StatusCode::SERVICE_UNAVAILABLE, 127 Json(GlobeAggregationResponse { buckets: vec![] }), 128 ) 129 .into_response()); 130 } 131 }; 132 133 let manager = match SearchIndexManager::new(opensearch_endpoint) { 134 Ok(m) => m, 135 Err(err) => { 136 tracing::error!(?err, "Failed to create search index manager"); 137 return Ok(( 138 StatusCode::INTERNAL_SERVER_ERROR, 139 Json(GlobeAggregationResponse { buckets: vec![] }), 140 ) 141 .into_response()); 142 } 143 }; 144 145 // Clamp resolution to valid range (1-7) 146 let resolution = params.resolution.clamp(1, 7); 147 148 // Fetch both event and LFG aggregations in parallel 149 let (event_result, lfg_result) = tokio::join!( 150 manager.get_event_geo_aggregation(resolution, None, true), 151 manager.get_lfg_profile_geo_aggregation(resolution, None), 152 ); 153 154 let event_buckets = event_result.unwrap_or_default(); 155 let lfg_buckets = lfg_result.unwrap_or_default(); 156 157 // Merge buckets by H3 key 158 let mut merged: HashMap<String, GlobeHexBucket> = HashMap::new(); 159 160 for bucket in event_buckets { 161 merged 162 .entry(bucket.key.clone()) 163 .and_modify(|b| { 164 b.event_count += bucket.doc_count; 165 b.total += bucket.doc_count; 166 }) 167 .or_insert(GlobeHexBucket { 168 key: bucket.key, 169 event_count: bucket.doc_count, 170 lfg_count: 0, 171 total: bucket.doc_count, 172 }); 173 } 174 175 for bucket in lfg_buckets { 176 merged 177 .entry(bucket.key.clone()) 178 .and_modify(|b| { 179 b.lfg_count += bucket.doc_count; 180 b.total += bucket.doc_count; 181 }) 182 .or_insert(GlobeHexBucket { 183 key: bucket.key, 184 event_count: 0, 185 lfg_count: bucket.doc_count, 186 total: bucket.doc_count, 187 }); 188 } 189 190 let buckets: Vec<GlobeHexBucket> = merged.into_values().collect(); 191 192 Ok((StatusCode::OK, Json(GlobeAggregationResponse { buckets })).into_response()) 193}