map_gui/tools/
labels.rs

1use std::cell::RefCell;
2use std::collections::HashMap;
3use std::sync::OnceLock;
4
5use regex::Regex;
6
7use abstutil::Timer;
8use geom::{Angle, Bounds, Distance, Polygon, Pt2D, QuadTree};
9use map_model::{osm, Road, RoadID};
10use widgetry::mapspace::PerZoom;
11use widgetry::{Color, Drawable, EventCtx, GeomBatch, GfxCtx, Line, Text};
12
13use crate::AppLike;
14
15/// Labels roads when unzoomed. Label size and frequency depends on the zoom level.
16///
17/// By default, the text is white; it works well on dark backgrounds.
18pub struct DrawRoadLabels {
19    per_zoom: RefCell<Option<PerZoom>>,
20    include_roads: Box<dyn Fn(&Road) -> bool>,
21    fg_color: Color,
22    outline_color: Color,
23}
24
25impl DrawRoadLabels {
26    /// Label roads that the predicate approves
27    pub fn new(include_roads: Box<dyn Fn(&Road) -> bool>) -> Self {
28        Self {
29            per_zoom: Default::default(),
30            include_roads,
31            fg_color: Color::WHITE,
32            outline_color: Color::BLACK,
33        }
34    }
35
36    /// Only label major roads
37    pub fn only_major_roads() -> Self {
38        Self::new(Box::new(|r| {
39            r.get_rank() != osm::RoadRank::Local && !r.is_light_rail()
40        }))
41    }
42
43    pub fn light_background(mut self) -> Self {
44        self.fg_color = Color::BLACK;
45        self.outline_color = Color::WHITE;
46        self
47    }
48
49    pub fn draw(&self, g: &mut GfxCtx, app: &dyn AppLike) {
50        let mut per_zoom = self.per_zoom.borrow_mut();
51        if per_zoom.is_none() {
52            *per_zoom = Some(PerZoom::new(g.canvas.settings.min_zoom_for_detail, 0.1));
53        }
54        let per_zoom = per_zoom.as_mut().unwrap();
55
56        let (zoom, idx) = per_zoom.discretize_zoom(g.canvas.cam_zoom);
57        let draw = &mut per_zoom.draw_per_zoom[idx];
58        if draw.is_none() {
59            *draw = Some(self.render(g, app, zoom));
60        }
61        g.redraw(draw.as_ref().unwrap());
62    }
63
64    fn render(&self, g: &mut GfxCtx, app: &dyn AppLike, zoom: f64) -> Drawable {
65        let mut batch = GeomBatch::new();
66        let map = app.map();
67
68        // We want the effective size of the text to stay around 1
69        // effective = zoom * text_scale
70        let text_scale = 1.0 / zoom;
71
72        let mut quadtree = QuadTree::new();
73
74        'ROAD: for r in map.all_roads() {
75            if !(self.include_roads)(r) || r.length() < Distance::meters(30.0) {
76                continue;
77            }
78
79            let name = if let Some(x) = simplify_name(r.get_name(app.opts().language.as_ref())) {
80                x
81            } else {
82                continue;
83            };
84            let (pt, angle) = r.center_pts.must_dist_along(r.length() / 2.0);
85
86            // Don't get too close to other labels.
87            let big_bounds = cheaply_overestimate_bounds(&name, text_scale, pt, angle);
88            if quadtree.query_bbox(big_bounds).next().is_some() {
89                continue 'ROAD;
90            }
91            quadtree.insert_with_box((), big_bounds);
92
93            // No other labels too close - proceed to render text.
94            let txt = Text::from(
95                Line(&name)
96                    .big_heading_plain()
97                    .fg(self.fg_color)
98                    .outlined(self.outline_color),
99            );
100            batch.append(txt.render_autocropped(g).multi_transform(
101                text_scale,
102                pt,
103                angle.reorient(),
104            ));
105        }
106
107        g.upload(batch)
108    }
109}
110
111static SIMPLIFY_PATTERNS: OnceLock<Vec<(Regex, String)>> = OnceLock::new();
112
113// TODO Surely somebody has written one of these.
114fn simplify_name(mut x: String) -> Option<String> {
115    // Skip unnamed roads and highway exits
116    if x == "???" || x.starts_with("Exit for ") {
117        return None;
118    }
119
120    for (search, replace_with) in SIMPLIFY_PATTERNS.get_or_init(simplify_patterns).iter() {
121        // TODO The string copies are probably avoidable...
122        x = search.replace(&x, replace_with).to_string();
123    }
124
125    Some(x)
126}
127
128fn simplify_patterns() -> Vec<(Regex, String)> {
129    let mut replace = Vec::new();
130
131    for (long, short) in [
132        ("Northeast", "NE"),
133        ("Northwest", "NW"),
134        ("Southeast", "SE"),
135        ("Southwest", "SW"),
136        // Order matters -- do the longer patterns first
137        ("North", "N"),
138        ("South", "S"),
139        ("East", "E"),
140        ("West", "W"),
141    ] {
142        // Only replace directions at the start or end of the string
143        replace.push((
144            Regex::new(&format!("^{long} ")).unwrap(),
145            format!("{short} "),
146        ));
147        replace.push((
148            Regex::new(&format!(" {long}$")).unwrap(),
149            format!(" {short}"),
150        ));
151    }
152
153    for (long, short) in [
154        ("Street", "St"),
155        ("Boulevard", "Blvd"),
156        ("Avenue", "Ave"),
157        ("Place", "Pl"),
158    ] {
159        // At the end is reasonable
160        replace.push((
161            Regex::new(&format!("{}$", long)).unwrap(),
162            short.to_string(),
163        ));
164        // In the middle, surrounded by spaces
165        replace.push((
166            Regex::new(&format!(" {} ", long)).unwrap(),
167            format!(" {} ", short),
168        ));
169    }
170
171    replace
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn test_simplify_name() {
180        for (input, want) in [
181            ("Northeast Northgate Way", "NE Northgate Way"),
182            ("South 42nd Street", "S 42nd St"),
183            ("Northcote Road", "Northcote Road"),
184        ] {
185            let got = simplify_name(input.to_string()).unwrap();
186            if got != want {
187                panic!("simplify_name({}) = {}; expected {}", input, got, want);
188            }
189        }
190    }
191}
192
193fn cheaply_overestimate_bounds(text: &str, text_scale: f64, center: Pt2D, angle: Angle) -> Bounds {
194    // assume all chars are bigger than largest possible char
195    let letter_width = 30.0 * text_scale;
196    let letter_height = 30.0 * text_scale;
197
198    Polygon::rectangle_centered(
199        center,
200        Distance::meters(letter_width * text.len() as f64),
201        Distance::meters(letter_height),
202    )
203    .rotate(angle.reorient())
204    .get_bounds()
205}
206
207/// Draws labels in map-space that roughly fit on the roads. Don't change behavior during zooming;
208/// labels are only meant to be legible when zoomed in.
209pub struct DrawSimpleRoadLabels {
210    draw: Drawable,
211    include_roads: Box<dyn Fn(&Road) -> bool>,
212    fg_color: Color,
213
214    pub label_covers_road: HashMap<RoadID, (Distance, Distance)>,
215}
216
217impl DrawSimpleRoadLabels {
218    /// Label roads that the predicate approves
219    pub fn new(
220        ctx: &mut EventCtx,
221        app: &dyn AppLike,
222        fg_color: Color,
223        include_roads: Box<dyn Fn(&Road) -> bool>,
224    ) -> Self {
225        let mut labels = Self {
226            draw: Drawable::empty(ctx),
227            include_roads,
228            fg_color,
229            label_covers_road: HashMap::new(),
230        };
231        ctx.loading_screen("label roads", |ctx, timer| {
232            labels.render(ctx, app, timer);
233        });
234        labels
235    }
236
237    pub fn empty(ctx: &EventCtx) -> Self {
238        Self {
239            draw: Drawable::empty(ctx),
240            include_roads: Box::new(|_| false),
241            fg_color: Color::CLEAR,
242            label_covers_road: HashMap::new(),
243        }
244    }
245
246    /// Only label major roads
247    pub fn only_major_roads(ctx: &mut EventCtx, app: &dyn AppLike, fg_color: Color) -> Self {
248        Self::new(
249            ctx,
250            app,
251            fg_color,
252            Box::new(|r| r.get_rank() != osm::RoadRank::Local && !r.is_light_rail()),
253        )
254    }
255
256    pub fn all_roads(ctx: &mut EventCtx, app: &dyn AppLike, fg_color: Color) -> Self {
257        Self::new(ctx, app, fg_color, Box::new(|_| true))
258    }
259
260    pub fn draw(&self, g: &mut GfxCtx) {
261        g.redraw(&self.draw);
262    }
263
264    fn render(&mut self, ctx: &mut EventCtx, app: &dyn AppLike, timer: &mut Timer) {
265        let mut batch = GeomBatch::new();
266        let map = app.map();
267
268        timer.start_iter("render roads", map.all_roads().len());
269        for r in map.all_roads() {
270            timer.next();
271            // Skip very short roads and tunnels
272            if !(self.include_roads)(r) || r.length() < Distance::meters(30.0) || r.zorder < 0 {
273                continue;
274            }
275
276            let name = if let Some(x) = simplify_name(r.get_name(app.opts().language.as_ref())) {
277                x
278            } else {
279                continue;
280            };
281
282            let txt_batch = Text::from(Line(&name)).render_autocropped(ctx);
283            if txt_batch.is_empty() {
284                // This happens when we don't have a font loaded with the right characters
285                continue;
286            }
287            let txt_bounds = txt_batch.get_bounds();
288
289            // The approach, part 1:
290            //
291            // We need to make the text fit in the road polygon. road_width gives us the height of
292            // the text, accounting for the outline around the road polygon and a buffer. If the
293            // road's length is short, the text could overflow into the intersections, so scale it
294            // down further.
295            //
296            // Since the text fits inside the road polygon, we don't need to do any kind of hitbox
297            // testing and make sure multiple labels don't overlap!
298
299            // The road has an outline of 1m, but also leave a slight buffer
300            let outline_thickness = Distance::meters(2.0);
301            let road_width = (r.get_width() - 2.0 * outline_thickness).inner_meters();
302            // Also a buffer from both ends of the road
303            let road_length = (0.9 * r.length()).inner_meters();
304
305            // Fit the text height in the road width perfectly
306            let mut scale = road_width / txt_bounds.height();
307
308            // If the road is short and we'll overflow, then scale down even more.
309            if txt_bounds.width() * scale > road_length {
310                scale = road_length / txt_bounds.width();
311                // TODO In this case, the vertical centering in the road polygon is wrong
312            }
313
314            // Record where the label would cover at this scale, if it was perfectly spaced out
315            // without curves
316            let center = r.length() / 2.0;
317            let half_width = Distance::meters(scale * txt_bounds.width() / 2.0);
318            self.label_covers_road
319                .insert(r.id, (center - half_width, center + half_width));
320
321            // The approach, part 2:
322            //
323            // But many roads are curved. We can use the SVG renderer to make text follow a curve.
324            // But use the scale / text size calculated assuming rectangles.
325            //
326            // Note we render the text twice here, and once again in render_curvey. This seems
327            // cheap enough so far. There's internal SVG caching in widgetry, but we could also
328            // consider caching a "road name -> txt_bounds" mapping through the whole app.
329
330            // The orientation of the text and the direction we vertically center depends on the
331            // direction the road points
332            let quadrant = r.center_pts.quadrant();
333            let shift_dir = if quadrant == 2 || quadrant == 3 {
334                -1.0
335            } else {
336                1.0
337            };
338            // The polyline passed to render_curvey will be used as the bottom of the text
339            // (glossing over whether or not this is a "baseline" or something else). We want to
340            // vertically center. SVG 1.1 has alignment-baseline, but usvg doesn't support this. So
341            // shift the road polyline.
342            let mut curve = r
343                .center_pts
344                .shift_either_direction(Distance::meters(shift_dir * road_width / 2.0))
345                .unwrap();
346            if quadrant == 2 || quadrant == 3 {
347                curve = curve.reversed();
348            }
349
350            batch.append(
351                Line(&name)
352                    .fg(self.fg_color)
353                    .render_curvey(ctx, &curve, scale),
354            );
355        }
356
357        self.draw = ctx.upload(batch);
358    }
359}