Various changes

This commit is contained in:
Andras Schmelczer 2026-02-03 19:26:34 +00:00
parent a42591c701
commit c388059f68
19 changed files with 1373 additions and 87 deletions

View file

@ -66,8 +66,11 @@ pub async fn get_features(state: Arc<AppState>) -> Json<FeaturesResponse> {
for feature_group in FEATURE_GROUPS {
if feature_group.name == group_name {
for feature_config in feature_group.features {
if let Some(feat_idx) =
state.data.feature_names.iter().position(|feat_name| feat_name == feature_config.name)
if let Some(feat_idx) = state
.data
.feature_names
.iter()
.position(|feat_name| feat_name == feature_config.name)
{
let stats = &state.data.feature_stats[feat_idx];
features.push(FeatureInfo::Numeric {

View file

@ -31,7 +31,10 @@ pub async fn get_hexagon_stats(
) -> Result<impl IntoResponse, (StatusCode, String)> {
let cell = h3o::CellIndex::from_str(&params.h3).map_err(|error| {
warn!(h3 = %params.h3, error = %error, "Invalid H3 cell index");
(StatusCode::BAD_REQUEST, format!("Invalid H3 cell: {}", error))
(
StatusCode::BAD_REQUEST,
format!("Invalid H3 cell: {}", error),
)
})?;
let cell_u64: u64 = cell.into();
@ -60,13 +63,14 @@ pub async fn get_hexagon_stats(
// Parse optional `fields` param into sets of feature names.
// None = include all, Some = only include listed features.
let field_set: Option<std::collections::HashSet<String>> = params.fields.as_ref().map(|fields_str| {
fields_str
.split(',')
.map(|field| field.trim().to_string())
.filter(|field| !field.is_empty())
.collect()
});
let field_set: Option<std::collections::HashSet<String>> =
params.fields.as_ref().map(|fields_str| {
fields_str
.split(',')
.map(|field| field.trim().to_string())
.filter(|field| !field.is_empty())
.collect()
});
let result = tokio::task::spawn_blocking(move || {
let start_time = std::time::Instant::now();
@ -158,9 +162,10 @@ pub async fn get_hexagon_stats(
// Bin into histogram using global edges (cast to f64 for bin index math)
if bin_width > 0.0 {
let bin_index =
((value as f64 - histogram_min as f64) / bin_width as f64).floor() as isize;
let clamped_index = bin_index.max(0).min((HISTOGRAM_BINS - 1) as isize) as usize;
let bin_index = ((value as f64 - histogram_min as f64) / bin_width as f64)
.floor() as isize;
let clamped_index =
bin_index.max(0).min((HISTOGRAM_BINS - 1) as isize) as usize;
bins[clamped_index] += 1;
}
}

View file

@ -115,7 +115,13 @@ impl CellAgg {
/// Add a row, only aggregating the features at the given indices.
#[inline]
fn add_row_selective(&mut self, feature_data: &[f32], row: usize, num_features: usize, indices: &[usize]) {
fn add_row_selective(
&mut self,
feature_data: &[f32],
row: usize,
num_features: usize,
indices: &[usize],
) {
self.count += 1;
let base = row * num_features;
for &feat_index in indices {
@ -133,7 +139,13 @@ impl CellAgg {
/// Track min/max ordinal index for selected enum features only.
#[inline]
fn add_enums_selective(&mut self, enum_data: &[u8], row: usize, num_enums: usize, indices: &[usize]) {
fn add_enums_selective(
&mut self,
enum_data: &[u8],
row: usize,
num_enums: usize,
indices: &[usize],
) {
let base = row * num_enums;
for &enum_index in indices {
let value = enum_data[base + enum_index];
@ -175,7 +187,9 @@ pub(crate) fn write_json_escaped(buf: &mut String, text: &str) {
'\n' => buf.push_str("\\n"),
'\r' => buf.push_str("\\r"),
'\t' => buf.push_str("\\t"),
ctrl if ctrl < '\x20' => { let _ = write!(buf, "\\u{:04x}", ctrl as u32); }
ctrl if ctrl < '\x20' => {
let _ = write!(buf, "\\u{:04x}", ctrl as u32);
}
other => buf.push(other),
}
}
@ -214,21 +228,31 @@ fn write_hexagons_json(
if let Some(indices) = numeric_indices {
for &feat_index in indices {
if aggregation.mins[feat_index].is_finite() && aggregation.maxs[feat_index].is_finite() {
if aggregation.mins[feat_index].is_finite()
&& aggregation.maxs[feat_index].is_finite()
{
let _ = write!(
buf,
",\"{}\":{},\"{}\":{}",
min_keys[feat_index], aggregation.mins[feat_index], max_keys[feat_index], aggregation.maxs[feat_index]
min_keys[feat_index],
aggregation.mins[feat_index],
max_keys[feat_index],
aggregation.maxs[feat_index]
);
}
}
} else {
for feat_index in 0..num_features {
if aggregation.mins[feat_index].is_finite() && aggregation.maxs[feat_index].is_finite() {
if aggregation.mins[feat_index].is_finite()
&& aggregation.maxs[feat_index].is_finite()
{
let _ = write!(
buf,
",\"{}\":{},\"{}\":{}",
min_keys[feat_index], aggregation.mins[feat_index], max_keys[feat_index], aggregation.maxs[feat_index]
min_keys[feat_index],
aggregation.mins[feat_index],
max_keys[feat_index],
aggregation.maxs[feat_index]
);
}
}
@ -240,8 +264,10 @@ fn write_hexagons_json(
let _ = write!(
buf,
",\"{}\":{},\"{}\":{}",
enum_min_keys[enum_index], aggregation.enum_mins[enum_index],
enum_max_keys[enum_index], aggregation.enum_maxs[enum_index]
enum_min_keys[enum_index],
aggregation.enum_mins[enum_index],
enum_max_keys[enum_index],
aggregation.enum_maxs[enum_index]
);
}
}
@ -251,8 +277,10 @@ fn write_hexagons_json(
let _ = write!(
buf,
",\"{}\":{},\"{}\":{}",
enum_min_keys[enum_index], aggregation.enum_mins[enum_index],
enum_max_keys[enum_index], aggregation.enum_maxs[enum_index]
enum_min_keys[enum_index],
aggregation.enum_mins[enum_index],
enum_max_keys[enum_index],
aggregation.enum_maxs[enum_index]
);
}
}
@ -325,24 +353,30 @@ pub async fn get_hexagons(
// Parse optional `fields` param into numeric and enum index sets.
// If `fields` is absent (None), all features are included.
// If `fields` is present (even empty string), only listed features are included.
let field_indices: Option<(Vec<usize>, Vec<usize>)> = params.fields.as_ref().map(|fields_str| {
let mut numeric_indices = Vec::new();
let mut enum_indices = Vec::new();
if !fields_str.is_empty() {
for name in fields_str.split(',') {
let name = name.trim();
if name.is_empty() {
continue;
}
if let Some(idx) = state.data.feature_names.iter().position(|feat| feat == name) {
numeric_indices.push(idx);
} else if let Some(&idx) = state.enum_name_to_idx.get(name) {
enum_indices.push(idx);
let field_indices: Option<(Vec<usize>, Vec<usize>)> =
params.fields.as_ref().map(|fields_str| {
let mut numeric_indices = Vec::new();
let mut enum_indices = Vec::new();
if !fields_str.is_empty() {
for name in fields_str.split(',') {
let name = name.trim();
if name.is_empty() {
continue;
}
if let Some(idx) = state
.data
.feature_names
.iter()
.position(|feat| feat == name)
{
numeric_indices.push(idx);
} else if let Some(&idx) = state.enum_name_to_idx.get(name) {
enum_indices.push(idx);
}
}
}
}
(numeric_indices, enum_indices)
});
(numeric_indices, enum_indices)
});
let json_body = tokio::task::spawn_blocking(move || -> Result<String, String> {
let t0 = std::time::Instant::now();
@ -380,7 +414,11 @@ pub async fn get_hexagons(
// Choose aggregation strategy based on whether fields are specified
let has_selective = field_indices.is_some();
let (sel_numeric, sel_enum) = field_indices.as_ref().map_or((&[][..], &[][..]), |(ni, ei)| (ni.as_slice(), ei.as_slice()));
let (sel_numeric, sel_enum) = field_indices
.as_ref()
.map_or((&[][..], &[][..]), |(ni, ei)| {
(ni.as_slice(), ei.as_slice())
});
let aggregate_row = |groups: &mut FxHashMap<u64, CellAgg>, cell_id: u64, row: usize| {
let aggregation = groups

View file

@ -1,6 +1,7 @@
mod features;
pub(crate) mod hexagons;
mod hexagon_stats;
pub(crate) mod hexagons;
mod og_image;
pub(crate) mod parse;
mod pois;
pub(crate) mod properties;
@ -8,5 +9,6 @@ pub(crate) mod properties;
pub use features::get_features;
pub use hexagon_stats::get_hexagon_stats;
pub use hexagons::get_hexagons;
pub use og_image::get_og_image;
pub use pois::{get_poi_categories, get_pois};
pub use properties::get_hexagon_properties;

View file

@ -0,0 +1,80 @@
use std::sync::Arc;
use axum::extract::Query;
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;
use crate::state::AppState;
#[derive(serde::Deserialize)]
pub struct OgImageQuery {
#[serde(rename = "v")]
view: Option<String>,
#[serde(rename = "f")]
filters: Option<String>,
poi: Option<String>,
tab: Option<String>,
}
pub async fn get_og_image(
state: Arc<AppState>,
Query(query): Query<OgImageQuery>,
) -> impl IntoResponse {
let sidecar_url = match &state.og_sidecar_url {
Some(url) => url,
None => {
return (StatusCode::SERVICE_UNAVAILABLE, "OG sidecar not configured").into_response();
}
};
let mut params = Vec::new();
if let Some(ref val) = query.view {
params.push(format!("v={}", urlencoding::encode(val)));
}
if let Some(ref val) = query.filters {
params.push(format!("f={}", urlencoding::encode(val)));
}
if let Some(ref val) = query.poi {
params.push(format!("poi={}", urlencoding::encode(val)));
}
if let Some(ref val) = query.tab {
params.push(format!("tab={}", urlencoding::encode(val)));
}
let qs = if params.is_empty() {
String::new()
} else {
format!("?{}", params.join("&"))
};
let url = format!("{}/screenshot{}", sidecar_url, qs);
tracing::info!("Proxying OG screenshot request to: {}", url);
match state.http_client.get(&url).send().await {
Ok(resp) if resp.status().is_success() => match resp.bytes().await {
Ok(bytes) => (
StatusCode::OK,
[
(header::CONTENT_TYPE, "image/png"),
(header::CACHE_CONTROL, "public, max-age=86400"),
],
bytes,
)
.into_response(),
Err(err) => {
tracing::warn!("Failed to read sidecar response: {}", err);
(StatusCode::BAD_GATEWAY, "Failed to read screenshot").into_response()
}
},
Ok(resp) => {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
tracing::warn!("Sidecar returned status {}: {}", status, body);
(StatusCode::BAD_GATEWAY, "Screenshot sidecar error").into_response()
}
Err(err) => {
tracing::warn!("Failed to reach sidecar: {}", err);
(StatusCode::BAD_GATEWAY, "Screenshot sidecar unavailable").into_response()
}
}
}

View file

@ -35,7 +35,11 @@ pub async fn get_pois(
.categories
.as_deref()
.filter(|text| !text.is_empty())
.map(|text| text.split(',').map(|part| part.trim().to_string()).collect());
.map(|text| {
text.split(',')
.map(|part| part.trim().to_string())
.collect()
});
let num_categories = category_filter.as_ref().map(|cats| cats.len()).unwrap_or(0);

View file

@ -8,7 +8,10 @@ use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::consts::{DEFAULT_PROPERTIES_LIMIT, ENUM_NULL, H3_PRECOMPUTE_MAX, H3_REQUEST_MAX, H3_REQUEST_MIN, MAX_PROPERTIES_LIMIT};
use crate::consts::{
DEFAULT_PROPERTIES_LIMIT, ENUM_NULL, H3_PRECOMPUTE_MAX, H3_REQUEST_MAX, H3_REQUEST_MIN,
MAX_PROPERTIES_LIMIT,
};
use crate::data::EnumFeatureData;
use crate::filter::{parse_filters, row_passes_filters};
use crate::state::AppState;
@ -91,7 +94,10 @@ pub async fn get_hexagon_properties(
) -> Result<Json<HexagonPropertiesResponse>, (StatusCode, String)> {
let cell = h3o::CellIndex::from_str(&params.h3).map_err(|error| {
warn!(h3 = %params.h3, error = %error, "Invalid H3 cell index");
(StatusCode::BAD_REQUEST, format!("Invalid H3 cell: {}", error))
(
StatusCode::BAD_REQUEST,
format!("Invalid H3 cell: {}", error),
)
})?;
let cell_u64: u64 = cell.into();
@ -165,7 +171,10 @@ pub async fn get_hexagon_properties(
});
let total = matching_rows.len();
let limit = params.limit.unwrap_or(DEFAULT_PROPERTIES_LIMIT).min(MAX_PROPERTIES_LIMIT);
let limit = params
.limit
.unwrap_or(DEFAULT_PROPERTIES_LIMIT)
.min(MAX_PROPERTIES_LIMIT);
let offset = params.offset.unwrap_or(0);
let truncated = total > offset + limit;