use std::sync::Arc;
use axum::{
body::Body,
extract::{Path, Query, State},
http::{header, StatusCode},
response::{
sse::{Event, KeepAlive, Sse},
Html, Response,
},
routing::get,
Json, Router,
};
use ezu::core::TileId as CoreTileId;
use ezu::features::mvt;
use ezu::graph::{CanvasInfo, Evaluator, ParamValues, PortValue, TileId};
use ezu::paint::host::{
bind_dem_sources, bind_geojson_sources, bind_raster_sources, raster_to_png, raster_to_webp,
requested_neighbor_offsets, BrushBankLoader, DemFetchError, DemSourceRegistry, GeoJsonSources,
RasterFetchError, RasterSourceRegistry, TileLoader,
};
use futures::stream::{self, Stream};
use serde_json::json;
use std::collections::HashMap;
use std::time::Duration;
use tokio::sync::broadcast;
use super::state::{validate_text, AppState, StyleSnapshot};
use crate::source::FeatureSource;
type FetchedMvt = (
Arc<str>,
Option<(bytes::Bytes, CoreTileId)>,
Vec<((i32, i32), (bytes::Bytes, CoreTileId))>,
);
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(index))
.route("/style", get(get_style).put(put_style))
.route("/style/params", get(get_params_schema))
.route("/style/attribution", get(get_attribution))
.route("/config", get(get_config))
.route("/style/validate", axum::routing::post(post_validate))
.route("/style/fetch", get(get_style_fetch))
.route("/style/events", get(get_style_events))
.route("/schemas/ezu-style.json", get(get_schema))
.route("/tiles/{z}/{x}/{y_ext}", get(get_tile))
.route("/mvt/{z}/{x}/{y}", get(get_mvt))
.route("/mvt-meta/{z}/{x}/{y}", get(get_mvt_meta))
}
async fn index() -> Html<&'static str> {
Html(include_str!("editor.html"))
}
const ABSOLUTE_MAXZOOM: u8 = 22;
async fn get_config(State(s): State<AppState>) -> Json<serde_json::Value> {
let data_maxzoom = s
.feature_sources
.iter()
.map(|src| src.max_zoom())
.collect::<Option<Vec<u8>>>()
.and_then(|zooms| zooms.into_iter().max());
let raster_maxzoom = match data_maxzoom {
Some(z) => z.saturating_add(s.overzoom_levels).min(ABSOLUTE_MAXZOOM),
None => ABSOLUTE_MAXZOOM,
};
Json(serde_json::json!({
"overzoomLevels": s.overzoom_levels,
"dataMaxzoom": data_maxzoom,
"rasterMaxzoom": raster_maxzoom,
}))
}
async fn get_style(State(s): State<AppState>) -> Response {
let snap = s.style.read().await;
Response::builder()
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
.header(header::CACHE_CONTROL, "no-store")
.body(Body::from(snap.text.clone()))
.expect("response builder with valid headers + body never fails")
}
async fn put_style(
State(s): State<AppState>,
body: String,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let next_version = { s.style.read().await.version + 1 };
let snap = StyleSnapshot::build(body, next_version, &s.assets_dir)
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
let version = snap.version;
*s.style.write().await = snap;
Ok(Json(json!({ "version": version })))
}
async fn get_params_schema(State(s): State<AppState>) -> Json<serde_json::Value> {
let snap = s.style.read().await;
Json(snap.doc.params_schema())
}
async fn get_attribution(State(s): State<AppState>) -> Json<serde_json::Value> {
let snap = s.style.read().await;
let mut list: Vec<String> = snap
.doc
.attributions()
.iter()
.map(|a| a.to_string())
.collect();
let mut push = |a: String| {
if !a.is_empty() && !list.contains(&a) {
list.push(a);
}
};
for a in snap
.dem_sources
.resolve_metadata()
.await
.unwrap_or_default()
{
push(a);
}
for a in snap
.raster_sources
.resolve_metadata()
.await
.unwrap_or_default()
{
push(a);
}
for src in s.feature_sources.iter() {
let declares = snap
.doc
.sources
.get(&*src.name)
.is_some_and(|d| d.attribution().is_some());
if !declares {
if let Some(a) = src.attribution() {
push(a.to_string());
}
}
}
Json(json!({ "attributions": list, "attribution": list.join(" | ") }))
}
async fn post_validate(body: String) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
validate_text(&body).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
Ok(Json(json!({ "ok": true })))
}
async fn get_style_fetch(
Query(q): Query<HashMap<String, String>>,
) -> Result<Response, (StatusCode, String)> {
let url = q.get("url").ok_or((
StatusCode::BAD_REQUEST,
"missing url query parameter".into(),
))?;
if !(url.starts_with("http://") || url.starts_with("https://")) {
return Err((
StatusCode::BAD_REQUEST,
"only http(s) URLs are allowed".into(),
));
}
let text = crate::fetch_text(url)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
Ok(Response::builder()
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
.header(header::CACHE_CONTROL, "no-store")
.body(Body::from(text))
.expect("response builder with valid headers + body never fails"))
}
async fn get_style_events(
State(s): State<AppState>,
) -> Sse<impl Stream<Item = Result<Event, std::convert::Infallible>>> {
let rx = s.events.subscribe();
let stream = stream::unfold(rx, |mut rx| async move {
loop {
match rx.recv().await {
Ok(ev) => {
let event = Event::default()
.event("reload")
.json_data(&ev)
.unwrap_or_else(|_| Event::default());
return Some((Ok(event), rx));
}
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => return None,
}
}
});
Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
}
async fn get_schema(State(s): State<AppState>) -> Response {
let body = serde_json::to_vec_pretty(&*s.schema).unwrap_or_default();
Response::builder()
.header(header::CONTENT_TYPE, "application/schema+json")
.header(header::CACHE_CONTROL, "no-store")
.body(Body::from(body))
.expect("response builder with valid headers + body never fails")
}
#[derive(Clone, Copy)]
enum TileFormat {
Png,
Webp,
}
impl TileFormat {
fn content_type(self) -> &'static str {
match self {
TileFormat::Png => "image/png",
TileFormat::Webp => "image/webp",
}
}
}
async fn get_tile(
State(s): State<AppState>,
Path((z, x, y_ext)): Path<(u8, u32, String)>,
Query(q): Query<HashMap<String, String>>,
) -> Result<Response, (StatusCode, String)> {
let (y_str, format) = if let Some(s) = y_ext.strip_suffix(".webp") {
(s, TileFormat::Webp)
} else if let Some(s) = y_ext.strip_suffix(".png") {
(s, TileFormat::Png)
} else {
(y_ext.as_str(), TileFormat::Png)
};
let y: u32 = y_str
.parse()
.map_err(|_| (StatusCode::BAD_REQUEST, "bad y".into()))?;
let tile = CoreTileId::new(z, x, y);
let (
graph,
cache,
assets,
dem_sources,
raster_sources,
geojson_sources,
tile_size,
pad,
params,
) = {
let snap = s.style.read().await;
let mut params = ParamValues::new();
for (name, raw) in &q {
if name == "v" {
continue;
}
let v = ezu::graph::parse_param_value(&snap.doc.params, name, raw)
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
params.set(name.clone(), v);
}
(
Arc::clone(&snap.graph),
Arc::clone(&snap.cache),
Arc::clone(&snap.assets),
Arc::clone(&snap.dem_sources),
Arc::clone(&snap.raster_sources),
Arc::clone(&snap.geojson_sources),
snap.doc.tile_size,
snap.pad,
params,
)
};
let mut fetched_mvt: Vec<FetchedMvt> = Vec::new();
let asset_inputs = graph.asset_inputs();
for src in s.feature_sources.iter() {
let centre = fetch_mvt(&s, src, tile).await?;
let mut neighbours = Vec::new();
let world = 1i64 << tile.z;
for (dx, dy) in requested_neighbor_offsets(&asset_inputs, &src.name) {
let ny = tile.y as i64 + dy as i64;
if ny < 0 || ny >= world {
continue;
}
let nx = (tile.x as i64 + dx as i64).rem_euclid(world) as u32;
if let Some(hit) = fetch_mvt(&s, src, CoreTileId::new(tile.z, nx, ny as u32)).await? {
neighbours.push(((dx, dy), hit));
}
}
fetched_mvt.push((Arc::clone(&src.name), centre, neighbours));
}
let canvas = CanvasInfo::square(tile_size, pad);
let tile_id = TileId {
z: tile.z,
x: tile.x,
y: tile.y,
};
let dem_bindings = fetch_dem_bindings(&dem_sources, tile_id, canvas)
.await
.map_err(dem_fetch_status)?;
let raster_bindings = fetch_raster_bindings(&raster_sources, tile_id, canvas)
.await
.map_err(raster_fetch_status)?;
let bytes = tokio::task::spawn_blocking({
move || {
render_tile(
&graph,
&cache,
&assets,
fetched_mvt,
dem_bindings,
raster_bindings,
&geojson_sources,
tile,
tile_size,
pad,
format,
¶ms,
)
}
})
.await
.map_err(|e| {
tracing::error!("tile {z}/{x}/{} render task panicked: {e}", tile.y);
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
})?
.map_err(|e| {
tracing::error!("tile {z}/{x}/{}: {e}", tile.y);
(StatusCode::INTERNAL_SERVER_ERROR, e)
})?;
Ok(Response::builder()
.header(header::CONTENT_TYPE, format.content_type())
.header(header::CACHE_CONTROL, "no-store")
.body(Body::from(bytes))
.expect("response builder with valid headers + body never fails"))
}
fn requested_source<'s>(
s: &'s AppState,
q: &HashMap<String, String>,
) -> Result<Option<&'s FeatureSource>, (StatusCode, String)> {
match q.get("source") {
Some(name) => s
.feature_sources
.get(Some(name))
.map(Some)
.ok_or((StatusCode::NOT_FOUND, format!("no feature source `{name}`"))),
None => Ok(s.feature_sources.get(None)),
}
}
async fn get_mvt(
State(s): State<AppState>,
Path((z, x, y)): Path<(u8, u32, u32)>,
Query(q): Query<HashMap<String, String>>,
) -> Result<Response, (StatusCode, String)> {
let tile = CoreTileId::new(z, x, y);
let Some(source) = requested_source(&s, &q)? else {
return Err((StatusCode::NOT_FOUND, "tile not in source".into()));
};
let Some((bytes, src)) = fetch_mvt(&s, source, tile).await? else {
return Err((StatusCode::NOT_FOUND, "tile not in source".into()));
};
if src != tile {
return Err((StatusCode::NOT_FOUND, "tile not in source".into()));
}
Ok(Response::builder()
.header(header::CONTENT_TYPE, "application/vnd.mapbox-vector-tile")
.header(header::CACHE_CONTROL, "public, max-age=300")
.body(Body::from(bytes.to_vec()))
.expect("response builder with valid headers + body never fails"))
}
async fn get_mvt_meta(
State(s): State<AppState>,
Path((z, x, y)): Path<(u8, u32, u32)>,
Query(q): Query<HashMap<String, String>>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let tile = CoreTileId::new(z, x, y);
let Some(source) = requested_source(&s, &q)? else {
return Ok(Json(json!({ "layers": [] })));
};
let Some((bytes, _src)) = fetch_mvt(&s, source, tile).await? else {
return Ok(Json(json!({ "layers": [] })));
};
let decoded =
mvt::decode(&bytes).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let layers: Vec<_> = decoded
.layers
.iter()
.map(|l| {
let (mut p, mut ln, mut pg) = (false, false, false);
for f in &l.features {
if !f.geometry.points.is_empty() {
p = true;
}
if !f.geometry.lines.is_empty() {
ln = true;
}
if !f.geometry.polygons.is_empty() {
pg = true;
}
}
let mut geoms: Vec<&str> = Vec::new();
if p {
geoms.push("point");
}
if ln {
geoms.push("line");
}
if pg {
geoms.push("polygon");
}
json!({
"name": l.name,
"geometry_types": geoms,
"features": l.features.len(),
})
})
.collect();
Ok(Json(json!({ "layers": layers })))
}
async fn fetch_mvt(
s: &AppState,
source: &FeatureSource,
tile: CoreTileId,
) -> Result<Option<(bytes::Bytes, CoreTileId)>, (StatusCode, String)> {
let Some(mut current) = source.native_tile(tile) else {
return Ok(None);
};
for _ in 0..=s.overzoom_levels {
let key = (Arc::clone(&source.name), current);
if let Some(b) = s.mvt_cache.get(&key).map(|r| r.clone()) {
return Ok(Some((b, current)));
}
match source.fetch_tile(current).await {
Ok(Some(b)) => {
s.mvt_cache.insert(key, b.clone());
return Ok(Some((b, current)));
}
Ok(None) => {}
Err(e) => return Err((StatusCode::BAD_GATEWAY, e.to_string())),
}
let Some(parent) = current.parent() else {
break;
};
current = parent;
}
Ok(None)
}
async fn fetch_dem_bindings(
registry: &DemSourceRegistry,
tile: TileId,
canvas: CanvasInfo,
) -> Result<Vec<(String, ezu::graph::ScalarField)>, DemFetchError> {
if registry.is_empty() {
return Ok(Vec::new());
}
let base = BrushBankLoader::new();
let mut tmp = TileLoader::new(&base, tile);
bind_dem_sources(&mut tmp, registry, tile, canvas).await?;
let mut out = Vec::new();
for name in registry.names() {
if let Ok(ezu::graph::Asset::ScalarField(field)) = ezu::graph::AssetLoader::load(&tmp, name)
{
out.push((name.to_string(), (*field).clone()));
}
}
Ok(out)
}
async fn fetch_raster_bindings(
registry: &RasterSourceRegistry,
tile: TileId,
canvas: CanvasInfo,
) -> Result<Vec<(String, ezu::graph::RasterBuf)>, RasterFetchError> {
if registry.is_empty() {
return Ok(Vec::new());
}
let base = BrushBankLoader::new();
let mut tmp = TileLoader::new(&base, tile);
bind_raster_sources(&mut tmp, registry, tile, canvas).await?;
let mut out = Vec::new();
for name in registry.names() {
if let Ok(ezu::graph::Asset::Image(buf)) = ezu::graph::AssetLoader::load(&tmp, name) {
out.push((name.to_string(), (*buf).clone()));
}
}
Ok(out)
}
fn dem_fetch_status(e: DemFetchError) -> (StatusCode, String) {
match &e {
DemFetchError::Missing { .. } => (StatusCode::NOT_FOUND, e.to_string()),
_ => (StatusCode::BAD_GATEWAY, e.to_string()),
}
}
fn raster_fetch_status(e: RasterFetchError) -> (StatusCode, String) {
match &e {
RasterFetchError::Missing { .. } => (StatusCode::NOT_FOUND, e.to_string()),
_ => (StatusCode::BAD_GATEWAY, e.to_string()),
}
}
#[allow(clippy::too_many_arguments)]
fn render_tile(
graph: &ezu::graph::Graph,
cache: &ezu::graph::Cache,
assets: &BrushBankLoader,
fetched_mvt: Vec<FetchedMvt>,
dem_bindings: Vec<(String, ezu::graph::ScalarField)>,
raster_bindings: Vec<(String, ezu::graph::RasterBuf)>,
geojson_sources: &GeoJsonSources,
tile: CoreTileId,
tile_size: u32,
pad: u32,
format: TileFormat,
params: &ParamValues,
) -> Result<Vec<u8>, String> {
let tile_id = TileId {
z: tile.z,
x: tile.x,
y: tile.y,
};
let mut tile_loader = TileLoader::new(assets, tile_id);
for (src_name, centre, neighbours) in fetched_mvt {
if let Some((bytes, src_tile)) = centre {
let mut decoded = mvt::decode(&bytes).map_err(|e| format!("mvt decode: {e}"))?;
if src_tile != tile {
tracing::debug!(
"overzoom clip `{src_name}` {}/{}/{} ← {}/{}/{}",
tile.z,
tile.x,
tile.y,
src_tile.z,
src_tile.x,
src_tile.y
);
decoded = mvt::clip_to_descendant(&decoded, src_tile, tile)
.map_err(|e| format!("overzoom clip: {e}"))?;
}
tile_loader.bind_mvt(&src_name, decoded);
}
for ((dx, dy), (bytes, src_tile)) in neighbours {
let ntile = CoreTileId::new(
tile.z,
(tile.x as i64 + dx as i64).rem_euclid(1i64 << tile.z) as u32,
(tile.y as i64 + dy as i64) as u32,
);
let mut decoded = match mvt::decode(&bytes) {
Ok(d) => d,
Err(e) => {
tracing::warn!("neighbour mvt decode {dx},{dy}: {e}");
continue;
}
};
if src_tile != ntile {
match mvt::clip_to_descendant(&decoded, src_tile, ntile) {
Ok(d) => decoded = d,
Err(e) => {
tracing::warn!("neighbour overzoom clip {dx},{dy}: {e}");
continue;
}
}
}
tile_loader.bind_mvt_neighbor(&src_name, dx, dy, decoded);
}
}
for (name, field) in dem_bindings {
tile_loader.bind_scalar_field(name, field);
}
for (name, buf) in raster_bindings {
tile_loader.bind_raster(name, buf);
}
bind_geojson_sources(&mut tile_loader, geojson_sources, &graph.asset_inputs())?;
let ev = Evaluator::new(graph, cache, &tile_loader);
let out = ev
.render(
tile_id,
CanvasInfo::square(tile_size, pad),
params,
tile_seed(tile),
)
.map_err(|e| format!("render: {e}"))?;
let raster = match out {
PortValue::Raster(r) => r,
other => return Err(format!("expected Raster output, got {:?}", other.kind())),
};
match format {
TileFormat::Png => raster_to_png(&raster, tile_size, pad).map_err(|e| format!("png: {e}")),
TileFormat::Webp => {
raster_to_webp(&raster, tile_size, pad).map_err(|e| format!("webp: {e}"))
}
}
}
fn tile_seed(tile: CoreTileId) -> u64 {
let mut s = 0u64;
s = s
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add(tile.z as u64);
s = s
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add(tile.x as u64);
s = s
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add(tile.y as u64);
s
}