use crate::aes::Aesthetic;
use crate::coord::Coord;
use crate::data::{DataFrame, Value};
use crate::position::identity::PositionIdentity;
use crate::position::Position;
use crate::render::backend::{DrawBackend, LineStyle, Linetype, TextAnchor, TextStyle};
use crate::render::RenderError;
use crate::scale::ScaleSet;
use crate::stat::identity::StatIdentity;
use crate::stat::Stat;
use crate::theme::Theme;
use super::{Geom, GeomParams};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum MapCorner {
#[default]
BottomLeft,
BottomRight,
TopLeft,
TopRight,
}
pub struct GeomAnnotationScale {
pub corner: MapCorner,
pub max_width_px: f64,
pub color: (u8, u8, u8),
pub text_size: f64,
pub line_width: f64,
}
impl Default for GeomAnnotationScale {
fn default() -> Self {
GeomAnnotationScale {
corner: MapCorner::default(),
max_width_px: 90.0,
color: (80, 80, 80),
text_size: 8.0,
line_width: 1.0,
}
}
}
impl GeomAnnotationScale {
pub fn corner(mut self, corner: MapCorner) -> Self {
self.corner = corner;
self
}
pub fn max_width_px(mut self, px: f64) -> Self {
self.max_width_px = px.max(1.0);
self
}
}
impl Geom for GeomAnnotationScale {
fn draw(
&self,
_data: &DataFrame,
coord: &dyn Coord,
scales: &ScaleSet,
_theme: &Theme,
backend: &mut dyn DrawBackend,
) -> Result<(), RenderError> {
let plot_area = backend.plot_area();
let Some((px, label)) = scale_bar_geometry(coord, scales, &plot_area, self.max_width_px)
else {
return Ok(());
};
let (x0, y0) = corner_origin(self.corner, &plot_area, px, self.text_size);
backend.draw_line(
&[(x0, y0), (x0, y0 + 3.0), (x0 + px, y0 + 3.0), (x0 + px, y0)],
&LineStyle {
color: self.color,
alpha: 1.0,
width: self.line_width,
linetype: Linetype::Solid,
..Default::default()
},
)?;
backend.draw_text(
&label,
(x0 + px / 2.0, y0 - 5.0),
&TextStyle {
color: self.color,
size: self.text_size,
anchor: TextAnchor::Middle,
angle: 0.0,
family: None,
face: crate::render::backend::FontFace::Plain,
},
)?;
Ok(())
}
fn required_aes(&self) -> Vec<Aesthetic> {
vec![]
}
fn default_stat(&self) -> Box<dyn Stat> {
Box::new(StatIdentity)
}
fn default_position(&self) -> Box<dyn Position> {
Box::new(PositionIdentity)
}
fn default_params(&self) -> GeomParams {
GeomParams::default()
}
fn name(&self) -> &str {
"annotation_scale"
}
fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
if let (true, Some(ink)) = (self.color == (80, 80, 80), el.ink) {
self.color = ink;
}
}
}
pub struct GeomAttribution {
pub text: String,
pub corner: MapCorner,
pub color: (u8, u8, u8),
pub text_size: f64,
pub runs: Option<Vec<(String, Option<String>)>>,
}
impl GeomAttribution {
pub fn new(text: impl Into<String>) -> Self {
GeomAttribution {
text: text.into(),
corner: MapCorner::BottomRight,
color: (130, 130, 130),
text_size: 7.5,
runs: None,
}
}
pub fn corner(mut self, corner: MapCorner) -> Self {
self.corner = corner;
self
}
pub fn link(mut self, url: impl Into<String>) -> Self {
let text = self.text.clone();
self.runs = Some(vec![(text, Some(url.into()))]);
self
}
pub fn runs(mut self, runs: Vec<(&str, Option<&str>)>) -> Self {
self.runs = Some(
runs.into_iter()
.map(|(t, u)| (t.to_string(), u.map(str::to_string)))
.collect(),
);
self
}
}
fn is_http_url(url: &str) -> bool {
let lower = url.trim_start().to_ascii_lowercase();
lower.starts_with("http://") || lower.starts_with("https://")
}
impl Geom for GeomAttribution {
fn draw(
&self,
_data: &DataFrame,
_coord: &dyn Coord,
_scales: &ScaleSet,
_theme: &Theme,
backend: &mut dyn DrawBackend,
) -> Result<(), RenderError> {
let runs: Vec<(String, Option<String>)> = match &self.runs {
Some(r) => r
.iter()
.map(|(t, u)| match u {
Some(url) if !is_http_url(url) => {
backend.warn(format!(
"annotation_attribution: \"{url}\" is not an http(s) URL; drawing \"{t}\" as plain text"
));
(t.clone(), None)
}
other => (t.clone(), other.clone()),
})
.collect(),
None => vec![(self.text.clone(), None)],
};
if runs.iter().all(|(t, _)| t.is_empty()) {
return Ok(());
}
let plot_area = backend.plot_area();
let full_text: String = runs.iter().map(|(t, _)| t.as_str()).collect();
let tw = crate::render::text::text_width(&full_text, self.text_size);
let (anchor, x) = match self.corner {
MapCorner::BottomLeft | MapCorner::TopLeft => (TextAnchor::Start, plot_area.x + 4.0),
MapCorner::BottomRight | MapCorner::TopRight => {
(TextAnchor::End, plot_area.x + plot_area.width - 4.0)
}
};
let y = match self.corner {
MapCorner::BottomLeft | MapCorner::BottomRight => plot_area.y + plot_area.height - 4.0,
MapCorner::TopLeft | MapCorner::TopRight => plot_area.y + 4.0 + self.text_size / 2.0,
};
let _ = tw; let run_refs: Vec<(&str, Option<&str>)> = runs
.iter()
.map(|(t, u)| (t.as_str(), u.as_deref()))
.collect();
backend.draw_text_runs(
&run_refs,
(x, y),
&TextStyle {
color: self.color,
size: self.text_size,
anchor,
angle: 0.0,
family: None,
face: crate::render::backend::FontFace::Plain,
},
)
}
fn required_aes(&self) -> Vec<Aesthetic> {
vec![]
}
fn default_stat(&self) -> Box<dyn Stat> {
Box::new(StatIdentity)
}
fn default_position(&self) -> Box<dyn Position> {
Box::new(PositionIdentity)
}
fn default_params(&self) -> GeomParams {
GeomParams::default()
}
fn name(&self) -> &str {
"attribution"
}
fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
if let (true, Some(ink)) = (self.color == (130, 130, 130), el.ink) {
self.color = ink;
}
}
}
fn scale_bar_geometry(
coord: &dyn Coord,
scales: &ScaleSet,
plot_area: &crate::render::Rect,
max_width_px: f64,
) -> Option<(f64, String)> {
let x_scale = scales.get(&Aesthetic::X)?;
let (d0, d1) = x_scale.domain()?;
if !(d0.is_finite() && d1.is_finite()) || d1 <= d0 {
return None;
}
let y_scale = scales.get(&Aesthetic::Y);
let ny = y_scale
.and_then(|s| s.domain())
.map(|(y0, y1)| {
let mid = Value::Float((y0 + y1) / 2.0);
y_scale.unwrap().map(&mid)
})
.unwrap_or(0.5);
let p0 = coord.transform((x_scale.map(&Value::Float(d0)), ny), plot_area);
let p1 = coord.transform((x_scale.map(&Value::Float(d1)), ny), plot_area);
let total_px = (p1.0 - p0.0).hypot(p1.1 - p0.1);
if !(total_px.is_finite()) || total_px <= 0.0 {
return None;
}
let px_per_unit = total_px / (d1 - d0);
let px_per_km = px_per_unit * 1000.0;
if !(px_per_km.is_finite()) || px_per_km <= 0.0 {
return None;
}
let max_km = max_width_px / px_per_km;
if !(max_km.is_finite()) || max_km <= 0.0 {
return None;
}
let mag = 10f64.powf(max_km.log10().floor());
let km = [5.0, 2.0, 1.0]
.iter()
.map(|f| f * mag)
.find(|k| *k <= max_km)
.unwrap_or(mag);
let px = km * px_per_km;
if !px.is_finite() || px < 12.0 {
return None;
}
let label = if km >= 1.0 {
format!("{} km", km.round() as i64)
} else {
format!("{} m", (km * 1000.0).round() as i64)
};
Some((px, label))
}
fn corner_origin(
corner: MapCorner,
plot_area: &crate::render::Rect,
bar_px: f64,
text_size: f64,
) -> (f64, f64) {
let pad_x = 6.0;
let pad_y = 8.0 + text_size;
match corner {
MapCorner::BottomLeft => (plot_area.x + pad_x, plot_area.y + plot_area.height - pad_y),
MapCorner::BottomRight => (
plot_area.x + plot_area.width - pad_x - bar_px,
plot_area.y + plot_area.height - pad_y,
),
MapCorner::TopLeft => (plot_area.x + pad_x, plot_area.y + pad_y),
MapCorner::TopRight => (
plot_area.x + plot_area.width - pad_x - bar_px,
plot_area.y + pad_y,
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::coord::cartesian::CoordCartesian;
use crate::scale::continuous::ScaleContinuous;
use crate::scale::Scale;
fn trained_scales(d0: f64, d1: f64) -> ScaleSet {
let mut scales = ScaleSet::new();
let mut x = ScaleContinuous::new().for_aesthetic(Aesthetic::X);
x.train(&[Value::Float(d0), Value::Float(d1)]);
scales.add(Box::new(x));
let mut y = ScaleContinuous::new().for_aesthetic(Aesthetic::Y);
y.train(&[Value::Float(0.0), Value::Float(1000.0)]);
scales.add(Box::new(y));
scales
}
fn panel() -> crate::render::Rect {
crate::render::Rect {
x: 0.0,
y: 0.0,
width: 400.0,
height: 300.0,
}
}
#[test]
fn picks_a_nice_round_distance() {
let scales = trained_scales(0.0, 400_000.0);
let coord = CoordCartesian::default();
let (px, label) = scale_bar_geometry(&coord, &scales, &panel(), 90.0).unwrap();
assert!((12.0..=90.0).contains(&px), "px = {px}");
assert!(
label.ends_with(" km") || label.ends_with(" m"),
"label = {label}"
);
let n: f64 = label.split_whitespace().next().unwrap().parse().unwrap();
let mag = 10f64.powf(n.log10().floor());
assert!(
[1.0, 2.0, 5.0].contains(&(n / mag).round()),
"not a nice 1/2/5 figure: {label}"
);
}
#[test]
fn too_small_a_bar_is_omitted() {
let scales = trained_scales(0.0, 50_000_000.0);
let coord = CoordCartesian::default();
assert!(scale_bar_geometry(&coord, &scales, &panel(), 5.0).is_none());
}
#[test]
fn untrained_scale_has_no_bar() {
let scales = ScaleSet::new();
let coord = CoordCartesian::default();
assert!(scale_bar_geometry(&coord, &scales, &panel(), 90.0).is_none());
}
#[test]
fn corner_placement_matches_each_corner() {
let p = panel();
let (x_bl, y_bl) = corner_origin(MapCorner::BottomLeft, &p, 50.0, 8.0);
let (x_br, y_br) = corner_origin(MapCorner::BottomRight, &p, 50.0, 8.0);
let (x_tl, y_tl) = corner_origin(MapCorner::TopLeft, &p, 50.0, 8.0);
let (x_tr, y_tr) = corner_origin(MapCorner::TopRight, &p, 50.0, 8.0);
assert!(x_bl < x_br, "left < right x");
assert!(y_tl < y_bl, "top < bottom y");
assert_eq!(x_tl, x_bl);
assert_eq!(x_tr, x_br);
assert_eq!(y_tl, y_tr);
assert_eq!(y_bl, y_br);
}
#[test]
fn default_corner_is_bottom_left() {
assert_eq!(MapCorner::default(), MapCorner::BottomLeft);
}
#[test]
fn is_http_url_accepts_only_http_and_https() {
assert!(is_http_url("https://www.bkg.bund.de"));
assert!(is_http_url("http://example.com"));
assert!(is_http_url(" https://example.com")); assert!(is_http_url("HTTPS://EXAMPLE.COM"));
assert!(!is_http_url("javascript:alert(1)"));
assert!(!is_http_url("data:text/html,<script>"));
assert!(!is_http_url("//example.com"));
assert!(!is_http_url("example.com"));
assert!(!is_http_url(""));
}
#[test]
fn link_sets_a_single_run_with_the_whole_text() {
let g = GeomAttribution::new("Natural Earth").link("https://www.naturalearthdata.com");
assert_eq!(
g.runs,
Some(vec![(
"Natural Earth".to_string(),
Some("https://www.naturalearthdata.com".to_string())
)])
);
}
#[test]
fn runs_overrides_text_with_several_segments() {
let g = GeomAttribution::new("ignored")
.runs(vec![("© ", None), ("BKG", Some("https://www.bkg.bund.de"))]);
assert_eq!(
g.runs,
Some(vec![
("© ".to_string(), None),
(
"BKG".to_string(),
Some("https://www.bkg.bund.de".to_string())
),
])
);
}
}