forked from
smokesignal.events/smokesignal
The smokesignal.events web application
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}