use serde_json::{Map, Value};
use crate::maplibre::filter;
use crate::maplibre::layers::fill::resolve_number;
use crate::maplibre::layers::paint_of;
use crate::maplibre::sources::{features_node, resolve_layer_source, Sources};
use crate::maplibre::{Report, ZoomRange};
const RADIUS_BOUND_CAP: f64 = 100.0;
const DEFAULT_HEATMAP_RADIUS: f64 = 30.0;
fn default_heatmap_color() -> Value {
serde_json::json!([
"interpolate",
["linear"],
["heatmap-density"],
0,
"rgba(0, 0, 255, 0)",
0.1,
"#4169e1",
0.3,
"cyan",
0.5,
"lime",
0.7,
"yellow",
1,
"red"
])
}
pub(crate) fn convert_heatmap(
id: &str,
layer: &Map<String, Value>,
nodes: &mut Map<String, Value>,
outputs: &mut Vec<String>,
zoom_range: ZoomRange,
sources: &Sources,
report: &mut Report,
) -> u32 {
let Some((source, source_layer)) = resolve_layer_source(id, layer, sources, report) else {
return 0;
};
let (min_zoom, max_zoom) = zoom_range;
let base_filter_expr = filter::layer_filter_expr(layer, report, id);
let paint = paint_of(layer);
let feat_id = format!("{id}__feat");
nodes.insert(
feat_id.clone(),
features_node(&source, &source_layer, base_filter_expr, min_zoom, max_zoom),
);
let dens_id = format!("{id}__density");
let mut spec = serde_json::json!({
"op": "density",
"features": format!("@{feat_id}"),
});
let (radius, radius_expr) = resolve_number(paint.get("heatmap-radius"));
let mut radius_bound = DEFAULT_HEATMAP_RADIUS;
if let Some(r) = radius {
let r = r.max(0.0);
spec["radius"] = Value::from(r);
radius_bound = r;
}
if let Some(e) = radius_expr {
let bound = radius_bound_from_expr(&e).unwrap_or_else(|| {
report.warn(format!(
"layer `{id}`: heatmap-radius expression has no derivable maximum — \
capping the kernel radius at {RADIUS_BOUND_CAP}px"
));
RADIUS_BOUND_CAP
});
spec["radius"] = Value::from(bound);
spec["radius-expr"] = e;
radius_bound = bound;
}
let (weight, weight_expr) = resolve_number(paint.get("heatmap-weight"));
if let Some(w) = weight {
spec["weight-expr"] = Value::from(w.max(0.0));
}
if let Some(e) = weight_expr {
spec["weight-expr"] = e;
}
let (intensity, intensity_expr) = resolve_number(paint.get("heatmap-intensity"));
if let Some(i) = intensity {
spec["intensity"] = Value::from(i.max(0.0));
}
if let Some(e) = intensity_expr {
spec["intensity-expr"] = e;
}
nodes.insert(dens_id.clone(), spec);
let ramp = match paint.get("heatmap-color") {
Some(v) if v.is_array() => v.clone(),
_ => default_heatmap_color(),
};
let ramp_id = format!("{id}__ramp");
let mut ramp_spec = serde_json::json!({
"op": "color-ramp",
"field": format!("@{dens_id}"),
"ramp-expr": ramp,
});
let (opacity, opacity_expr) = resolve_number(paint.get("heatmap-opacity"));
if let Some(o) = opacity {
if o != 1.0 {
ramp_spec["opacity"] = Value::from(o.clamp(0.0, 1.0));
}
}
if let Some(e) = opacity_expr {
let op_id = format!("{id}__opacity");
nodes.insert(
op_id.clone(),
serde_json::json!({ "op": "expr", "expr": e }),
);
ramp_spec["opacity"] = Value::from(format!("@{op_id}"));
}
nodes.insert(ramp_id.clone(), ramp_spec);
outputs.push(ramp_id);
radius_bound.ceil().max(0.0) as u32
}
fn radius_bound_from_expr(v: &Value) -> Option<f64> {
if let Some(obj) = v.as_object() {
let stops = obj.get("stops")?.as_array()?;
return max_of(stops.iter().map(|s| s.get(1)));
}
let arr = v.as_array()?;
match arr.first()?.as_str()? {
"interpolate" | "interpolate-hcl" | "interpolate-lab" => {
max_of(arr.get(3..)?.iter().skip(1).step_by(2).map(Some))
}
"step" => max_of(arr.get(2..)?.iter().step_by(2).map(Some)),
_ => None,
}
}
fn max_of<'a>(outputs: impl Iterator<Item = Option<&'a Value>>) -> Option<f64> {
let mut max: Option<f64> = None;
for out in outputs {
let n = out?.as_f64()?;
max = Some(max.map_or(n, |m| m.max(n)));
}
max
}