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::text::{text_width, truncate_to_width, wrap_text};
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};
type AttributionRun = (String, Option<String>);
type AttributionRow = Vec<AttributionRun>;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum MapCorner {
#[default]
BottomLeft,
BottomRight,
TopLeft,
TopRight,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum AttributionOverflow {
#[default]
Ellipsis,
Wrap,
}
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,
bg_colour: None,
bg_r: 0.0,
},
)?;
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;
}
}
}
#[non_exhaustive]
pub struct GeomAttribution {
pub text: String,
pub corner: MapCorner,
pub color: (u8, u8, u8),
pub text_size: f64,
pub runs: Option<Vec<AttributionRun>>,
pub lines: Vec<AttributionRow>,
pub fit_min_text_size: Option<f64>,
pub fit_overflow: AttributionOverflow,
}
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,
lines: Vec::new(),
fit_min_text_size: None,
fit_overflow: AttributionOverflow::Ellipsis,
}
}
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
}
pub fn line(mut self, runs: Vec<(&str, Option<&str>)>) -> Self {
self.lines.push(
runs.into_iter()
.map(|(t, u)| (t.to_string(), u.map(str::to_string)))
.collect(),
);
self
}
pub fn fit(mut self, min_size: f64) -> Self {
self.fit_min_text_size = Some(min_size.max(0.1));
self
}
pub fn fit_overflow(mut self, overflow: AttributionOverflow) -> Self {
self.fit_overflow = overflow;
self
}
}
fn fit_row(
row: &[AttributionRun],
base_size: f64,
min_size: f64,
max_width: f64,
overflow: AttributionOverflow,
) -> (f64, Vec<AttributionRow>) {
let min_size = min_size.min(base_size).max(0.1);
if !(max_width.is_finite()) || max_width <= 0.0 {
return (base_size, vec![row.to_vec()]);
}
let concat: String = row.iter().map(|(t, _)| t.as_str()).collect();
let full_width = text_width(&concat, base_size);
if full_width <= max_width {
return (base_size, vec![row.to_vec()]);
}
let shrunk_size = (base_size * max_width / full_width).max(min_size);
if text_width(&concat, shrunk_size) <= max_width {
return (shrunk_size, vec![row.to_vec()]);
}
match overflow {
AttributionOverflow::Ellipsis => {
let truncated = truncate_to_width(&concat, max_width, min_size).unwrap_or(concat);
(min_size, vec![vec![(truncated, None)]])
}
AttributionOverflow::Wrap => {
let mut lines = wrap_text(&concat, max_width, min_size);
if lines.len() > ATTRIBUTION_MAX_WRAPPED_LINES {
lines.truncate(ATTRIBUTION_MAX_WRAPPED_LINES);
if let Some(last) = lines.last_mut() {
*last = force_ellipsis(last, max_width, min_size);
}
}
(
min_size,
lines.into_iter().map(|l| vec![(l, None)]).collect(),
)
}
}
}
fn force_ellipsis(text: &str, max_width: f64, size: f64) -> String {
let chars: Vec<char> = text.chars().collect();
for keep in (0..=chars.len()).rev() {
let candidate: String = chars[..keep].iter().collect::<String>() + "…";
if text_width(&candidate, size) <= max_width {
return candidate;
}
}
"…".to_string()
}
const ATTRIBUTION_FIT_SAFETY_MARGIN: f64 = 0.92;
const ATTRIBUTION_MAX_WRAPPED_LINES: usize = 3;
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 validate_runs = |backend: &mut dyn DrawBackend, runs: &[AttributionRun]| {
runs.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::<Vec<AttributionRun>>()
};
let row0: AttributionRow = match &self.runs {
Some(r) => validate_runs(backend, r),
None => vec![(self.text.clone(), None)],
};
let extra_rows: Vec<AttributionRow> = self
.lines
.iter()
.map(|r| validate_runs(backend, r))
.collect();
let all_empty = row0.iter().all(|(t, _)| t.is_empty())
&& extra_rows
.iter()
.all(|row| row.iter().all(|(t, _)| t.is_empty()));
if all_empty {
return Ok(());
}
let plot_area = backend.plot_area();
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 base_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 line_height = self.text_size * 1.3;
let logical_rows = std::iter::once(row0).chain(extra_rows);
let physical_lines: Vec<(f64, AttributionRow)> =
if let Some(min_size) = self.fit_min_text_size {
let max_width = ((plot_area.width - 8.0) * ATTRIBUTION_FIT_SAFETY_MARGIN).max(0.0);
logical_rows
.flat_map(|row| {
let (size, lines) =
fit_row(&row, self.text_size, min_size, max_width, self.fit_overflow);
lines
.into_iter()
.map(move |l| (size, l))
.collect::<Vec<_>>()
})
.collect()
} else {
logical_rows.map(|r| (self.text_size, r)).collect()
};
for (i, (size, row)) in physical_lines.into_iter().enumerate() {
if row.iter().all(|(t, _)| t.is_empty()) {
continue;
}
let y = match self.corner {
MapCorner::BottomLeft | MapCorner::BottomRight => base_y - (i as f64) * line_height,
MapCorner::TopLeft | MapCorner::TopRight => base_y + (i as f64) * line_height,
};
let run_refs: Vec<(&str, Option<&str>)> = row
.iter()
.map(|(t, u)| (t.as_str(), u.as_deref()))
.collect();
let style = TextStyle {
color: self.color,
size,
anchor: anchor.clone(),
angle: 0.0,
family: None,
face: crate::render::backend::FontFace::Plain,
bg_colour: None,
bg_r: 0.0,
};
backend.draw_text_runs(&run_refs, (x, y), &style)?;
}
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 {
"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())
),
])
);
}
#[test]
fn fit_is_off_by_default() {
let g = GeomAttribution::new("x");
assert_eq!(g.fit_min_text_size, None);
assert_eq!(g.fit_overflow, AttributionOverflow::Ellipsis);
}
#[test]
fn fit_row_leaves_a_fitting_row_untouched_at_full_size() {
let row = vec![("short".to_string(), None)];
let (size, lines) = fit_row(&row, 8.0, 4.0, 1000.0, AttributionOverflow::Ellipsis);
assert_eq!(size, 8.0);
assert_eq!(lines, vec![row]);
}
#[test]
fn fit_row_shrinks_toward_the_floor_when_that_alone_fits() {
let row = vec![("a moderately long attribution line".to_string(), None)];
let full_width = text_width(&row[0].0, 8.0);
let max_width = full_width * 0.7;
let (size, lines) = fit_row(&row, 8.0, 4.0, max_width, AttributionOverflow::Ellipsis);
assert!((4.0..8.0).contains(&size), "size = {size}");
assert_eq!(lines, vec![row]); assert!(text_width(&lines[0][0].0, size) <= max_width + 1e-6);
}
#[test]
fn fit_row_truncates_with_ellipsis_once_the_floor_still_overflows() {
let row = vec![(
"https://example.com/sources · geometry modified (simplified)".to_string(),
None,
)];
let max_width = 40.0; let (size, lines) = fit_row(&row, 8.0, 4.0, max_width, AttributionOverflow::Ellipsis);
assert_eq!(size, 4.0);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].len(), 1);
assert!(lines[0][0].0.ends_with('…'), "line = {:?}", lines[0][0].0);
assert!(text_width(&lines[0][0].0, size) <= max_width + 1e-6);
}
#[test]
fn fit_row_wraps_once_the_floor_still_overflows() {
let row = vec![(
"https://example.com/sources geometry modified simplified".to_string(),
None,
)];
let max_width = 40.0;
let (size, lines) = fit_row(&row, 8.0, 4.0, max_width, AttributionOverflow::Wrap);
assert_eq!(size, 4.0);
assert!(lines.len() > 1, "expected multiple wrapped lines");
for line in &lines {
assert_eq!(line.len(), 1);
assert!(text_width(&line[0].0, size) <= max_width + 1e-6);
}
}
#[test]
fn fit_row_wrap_caps_the_line_count_and_ellipsises_the_last() {
let row = vec![(
"https://sgx.geodatenzentrum.de/web_public/gdz/datenquellen/datenquellen_vg_nuts.pdf"
.to_string(),
None,
)];
let max_width = 15.0; let (size, lines) = fit_row(&row, 8.0, 4.0, max_width, AttributionOverflow::Wrap);
assert_eq!(size, 4.0);
assert_eq!(lines.len(), ATTRIBUTION_MAX_WRAPPED_LINES);
for line in &lines {
assert_eq!(line.len(), 1);
assert!(
text_width(&line[0].0, size) <= max_width + 1e-6,
"line {:?} overflows",
line[0].0
);
}
assert!(
lines.last().unwrap()[0].0.ends_with('…'),
"last kept line should be ellipsised: {:?}",
lines.last().unwrap()[0].0
);
}
#[test]
fn fit_row_wrap_under_the_cap_is_untouched() {
let row = vec![(
"https://example.com/sources geometry modified simplified".to_string(),
None,
)];
let max_width = 40.0;
let (_, lines) = fit_row(&row, 8.0, 4.0, max_width, AttributionOverflow::Wrap);
assert!(lines.len() <= ATTRIBUTION_MAX_WRAPPED_LINES);
assert!(!lines.last().unwrap()[0].0.ends_with('…'));
}
struct RecordingBackend {
area: crate::render::Rect,
calls: Vec<(String, (f64, f64), f64, TextAnchor)>,
}
impl RecordingBackend {
fn new(area: crate::render::Rect) -> Self {
RecordingBackend {
area,
calls: Vec::new(),
}
}
}
impl DrawBackend for RecordingBackend {
fn draw_circle(
&mut self,
_c: (f64, f64),
_r: f64,
_s: &crate::render::backend::PointStyle,
) -> Result<(), RenderError> {
Ok(())
}
fn draw_line(&mut self, _p: &[(f64, f64)], _s: &LineStyle) -> Result<(), RenderError> {
Ok(())
}
fn draw_rect(
&mut self,
_tl: (f64, f64),
_br: (f64, f64),
_s: &crate::render::backend::RectStyle,
) -> Result<(), RenderError> {
Ok(())
}
fn draw_text(
&mut self,
text: &str,
pos: (f64, f64),
style: &TextStyle,
) -> Result<(), RenderError> {
self.calls
.push((text.to_string(), pos, style.size, style.anchor.clone()));
Ok(())
}
fn draw_polygon(
&mut self,
_p: &[(f64, f64)],
_s: &crate::render::backend::RectStyle,
) -> Result<(), RenderError> {
Ok(())
}
fn plot_area(&self) -> crate::render::Rect {
self.area.clone()
}
fn total_area(&self) -> crate::render::Rect {
self.area.clone()
}
}
fn anchor_fraction(anchor: TextAnchor) -> f64 {
match anchor {
TextAnchor::Start => 0.0,
TextAnchor::Middle => 0.5,
TextAnchor::End => 1.0,
}
}
#[test]
fn fit_keeps_every_line_inside_a_narrow_panel_with_a_right_legend() {
let area = crate::render::Rect {
x: 0.0,
y: 0.0,
width: 60.0,
height: 300.0,
};
let mut backend = RecordingBackend::new(area.clone());
let g = GeomAttribution::new(
"https://sg.geodatenzentrum.de/web_public/gdz/lizenz · geometry modified (simplified)",
)
.fit(4.0);
let scales = ScaleSet::new();
let coord = CoordCartesian::default();
g.draw(
&DataFrame::new(),
&coord,
&scales,
&Theme::default(),
&mut backend,
)
.unwrap();
assert_eq!(backend.calls.len(), 1);
let (text, pos, size, anchor) = &backend.calls[0];
let w = text_width(text, *size);
let frac = anchor_fraction(anchor.clone());
let x0 = pos.0 - w * frac;
let x1 = x0 + w;
assert!(
x0 >= area.x - 1e-6 && x1 <= area.x + area.width + 1e-6,
"line overflowed the panel: x0={x0} x1={x1} area=[{},{}]",
area.x,
area.x + area.width
);
}
#[test]
fn fit_off_by_default_leaves_a_wide_panel_unchanged() {
let area = crate::render::Rect {
x: 0.0,
y: 0.0,
width: 400.0,
height: 300.0,
};
let mut backend = RecordingBackend::new(area.clone());
let g = GeomAttribution::new("a short note"); let scales = ScaleSet::new();
let coord = CoordCartesian::default();
g.draw(
&DataFrame::new(),
&coord,
&scales,
&Theme::default(),
&mut backend,
)
.unwrap();
assert_eq!(backend.calls.len(), 1);
assert_eq!(backend.calls[0].2, g.text_size); }
#[test]
fn fit_off_by_default_also_leaves_a_narrow_panel_unchanged() {
let area = crate::render::Rect {
x: 0.0,
y: 0.0,
width: 60.0,
height: 300.0,
};
let mut backend = RecordingBackend::new(area.clone());
let g = GeomAttribution::new(
"https://sg.geodatenzentrum.de/web_public/gdz/lizenz · geometry modified (simplified)",
);
let scales = ScaleSet::new();
let coord = CoordCartesian::default();
g.draw(
&DataFrame::new(),
&coord,
&scales,
&Theme::default(),
&mut backend,
)
.unwrap();
assert_eq!(backend.calls[0].2, g.text_size);
}
#[test]
fn fit_applies_a_safety_margin_below_the_raw_inset_budget() {
let area = crate::render::Rect {
x: 0.0,
y: 0.0,
width: 342.0,
height: 300.0,
};
let mut backend = RecordingBackend::new(area.clone());
let g = GeomAttribution::new(
"https://sgx.geodatenzentrum.de/web_public/gdz/datenquellen/datenquellen_vg_nuts.pdf · geometry modified",
)
.fit(7.0);
let scales = ScaleSet::new();
let coord = CoordCartesian::default();
g.draw(
&DataFrame::new(),
&coord,
&scales,
&Theme::default(),
&mut backend,
)
.unwrap();
assert_eq!(backend.calls.len(), 1);
let (text, _, size, _) = &backend.calls[0];
let raw_budget = area.width - 8.0;
let w = text_width(text, *size);
assert!(w <= raw_budget, "line {text:?} overflows raw budget");
assert!(
w <= raw_budget * ATTRIBUTION_FIT_SAFETY_MARGIN + 1e-6,
"line {text:?} (w={w}) leaves no safety margin below the raw budget ({raw_budget})"
);
}
#[test]
fn fit_keeps_every_line_inside_the_panel_across_widths_and_sizes() {
let text = "https://sgx.geodatenzentrum.de/web_public/gdz/datenquellen/datenquellen_vg_nuts.pdf · geometry modified";
let coord = CoordCartesian::default();
let scales = ScaleSet::new();
for width in (100..=400).step_by(25) {
let area = crate::render::Rect {
x: 0.0,
y: 0.0,
width: width as f64,
height: 300.0,
};
let raw_budget = area.width - 8.0;
for &(base_size, min_size) in &[(7.0, 7.0), (7.0, 4.0), (10.0, 5.0), (8.0, 8.0)] {
for overflow in [AttributionOverflow::Ellipsis, AttributionOverflow::Wrap] {
let mut backend = RecordingBackend::new(area.clone());
let mut g = GeomAttribution::new(text).fit(min_size);
g.text_size = base_size;
g = g.fit_overflow(overflow);
g.draw(
&DataFrame::new(),
&coord,
&scales,
&Theme::default(),
&mut backend,
)
.unwrap();
for (line_text, pos, size, anchor) in &backend.calls {
let w = text_width(line_text, *size);
let frac = anchor_fraction(anchor.clone());
let x0 = pos.0 - w * frac;
let x1 = x0 + w;
assert!(
x0 >= area.x - 1e-6 && x1 <= area.x + area.width + 1e-6,
"width={width} base={base_size} min={min_size} overflow={overflow:?}: \
line {line_text:?} overflowed [x0={x0}, x1={x1}] vs panel [{}, {}]",
area.x,
area.x + area.width
);
assert!(
w <= raw_budget * ATTRIBUTION_FIT_SAFETY_MARGIN + 1e-6,
"width={width} base={base_size} min={min_size} overflow={overflow:?}: \
line {line_text:?} (w={w}) leaves no safety margin below raw budget {raw_budget}"
);
}
}
}
}
}
}