ltn/pages/
crossings.rs

1use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
2
3use abstutil::PriorityQueueItem;
4use geom::{Circle, Duration};
5use map_model::{osm, Crossing, CrossingType, Road, RoadID};
6use widgetry::mapspace::{DrawCustomUnzoomedShapes, ObjectID, PerZoom, World, WorldOutcome};
7use widgetry::{
8    lctrl, Color, ControlState, Drawable, EventCtx, GeomBatch, GfxCtx, Key, Line, Outcome, Panel,
9    RewriteColor, State, Text, TextExt, Widget,
10};
11
12use crate::components::{AppwidePanel, BottomPanel, Mode};
13use crate::render::{colors, Toggle3Zoomed};
14use crate::{App, Transition};
15
16pub struct Crossings {
17    appwide_panel: AppwidePanel,
18    bottom_panel: Panel,
19    world: World<Obj>,
20    draw_porosity: Drawable,
21    draw_crossings: Toggle3Zoomed,
22    draw_nearest_crossing: Option<Drawable>,
23    time_to_nearest_crossing: BTreeMap<RoadID, Duration>,
24}
25
26impl Crossings {
27    pub fn new_state(ctx: &mut EventCtx, app: &mut App) -> Box<dyn State<App>> {
28        let appwide_panel = AppwidePanel::new(ctx, app, Mode::Crossings);
29        let contents = make_bottom_panel(ctx, app);
30        let bottom_panel = BottomPanel::new(ctx, &appwide_panel, contents);
31
32        // Just force the layers panel to align above the bottom panel
33        app.session
34            .layers
35            .event(ctx, &app.cs, Mode::Crossings, Some(&bottom_panel));
36
37        let mut state = Self {
38            appwide_panel,
39            bottom_panel,
40            world: World::new(),
41            draw_porosity: Drawable::empty(ctx),
42            draw_crossings: Toggle3Zoomed::empty(ctx),
43            draw_nearest_crossing: None,
44            time_to_nearest_crossing: BTreeMap::new(),
45        };
46        state.update(ctx, app);
47        Box::new(state)
48    }
49
50    pub fn svg_path(ct: CrossingType) -> &'static str {
51        match ct {
52            CrossingType::Signalized => "system/assets/tools/signalized_crossing.svg",
53            CrossingType::Unsignalized => "system/assets/tools/unsignalized_crossing.svg",
54        }
55    }
56
57    fn update(&mut self, ctx: &mut EventCtx, app: &App) {
58        self.draw_porosity = draw_porosity(ctx, app);
59        self.draw_crossings = draw_crossings(ctx, app);
60        let contents = make_bottom_panel(ctx, app);
61        self.bottom_panel = BottomPanel::new(ctx, &self.appwide_panel, contents);
62        self.draw_nearest_crossing = None;
63        self.time_to_nearest_crossing.clear();
64
65        if app.session.layers.show_crossing_time {
66            let (draw, time) = draw_nearest_crossing(ctx, app);
67            self.draw_nearest_crossing = Some(draw);
68            self.time_to_nearest_crossing = time;
69        }
70
71        self.world = make_world(ctx, app, &self.time_to_nearest_crossing);
72    }
73}
74
75impl State<App> for Crossings {
76    fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Transition {
77        if let Some(t) =
78            self.appwide_panel
79                .event(ctx, app, &crate::save::PreserveState::Crossings, help)
80        {
81            return t;
82        }
83        if let Some(t) =
84            app.session
85                .layers
86                .event(ctx, &app.cs, Mode::Crossings, Some(&self.bottom_panel))
87        {
88            if app.session.layers.show_crossing_time != self.draw_nearest_crossing.is_some() {
89                if app.session.layers.show_crossing_time {
90                    let (draw, time) = draw_nearest_crossing(ctx, app);
91                    self.draw_nearest_crossing = Some(draw);
92                    self.time_to_nearest_crossing = time;
93                } else {
94                    self.draw_nearest_crossing = None;
95                    self.time_to_nearest_crossing.clear();
96                }
97                self.world = make_world(ctx, app, &self.time_to_nearest_crossing);
98            }
99
100            return t;
101        }
102        if let Outcome::Clicked(x) = self.bottom_panel.event(ctx) {
103            match x.as_ref() {
104                "signalized crossing" => {
105                    app.session.crossing_type = CrossingType::Signalized;
106                    let contents = make_bottom_panel(ctx, app);
107                    self.bottom_panel = BottomPanel::new(ctx, &self.appwide_panel, contents);
108                }
109                "unsignalized crossing" => {
110                    app.session.crossing_type = CrossingType::Unsignalized;
111                    let contents = make_bottom_panel(ctx, app);
112                    self.bottom_panel = BottomPanel::new(ctx, &self.appwide_panel, contents);
113                }
114                "undo" => {
115                    let mut edits = app.per_map.map.get_edits().clone();
116                    edits.commands.pop().unwrap();
117                    app.apply_edits(edits);
118                    crate::redraw_all_icons(ctx, app);
119                    self.update(ctx, app);
120                }
121                _ => unreachable!(),
122            }
123        }
124
125        let map = &mut app.per_map.map;
126        match self.world.event(ctx) {
127            WorldOutcome::ClickedObject(Obj::Road(r)) => {
128                let cursor_pt = ctx.canvas.get_cursor_in_map_space().unwrap();
129                let road = map.get_r(r);
130                let pt_on_line = road.center_pts.project_pt(cursor_pt);
131                let (dist, _) = road.center_pts.dist_along_of_point(pt_on_line).unwrap();
132
133                let mut edits = map.get_edits().clone();
134                edits.commands.push(map.edit_road_cmd(r, |new| {
135                    new.crossings.push(Crossing {
136                        kind: app.session.crossing_type,
137                        dist,
138                    });
139                    new.crossings.sort_by_key(|c| c.dist);
140                }));
141                app.apply_edits(edits);
142                self.update(ctx, app);
143            }
144            WorldOutcome::ClickedObject(Obj::Crossing(r, idx)) => {
145                // Delete it
146                let mut edits = map.get_edits().clone();
147                edits.commands.push(map.edit_road_cmd(r, |new| {
148                    new.crossings.remove(idx);
149                    // We don't need to re-sort
150                }));
151                app.apply_edits(edits);
152                self.update(ctx, app);
153            }
154            _ => {}
155        }
156
157        Transition::Keep
158    }
159
160    fn draw(&self, g: &mut GfxCtx, app: &App) {
161        self.appwide_panel.draw(g);
162        self.bottom_panel.draw(g);
163        g.redraw(&self.draw_porosity);
164        app.per_map.draw_major_road_labels.draw(g);
165        app.session.layers.draw(g, app);
166        app.per_map.draw_poi_icons.draw(g);
167        if let Some(ref draw) = self.draw_nearest_crossing {
168            g.redraw(draw);
169        }
170        self.draw_crossings.draw(g);
171        // Draw on top of crossings, so hover state is visible
172        self.world.draw(g);
173    }
174
175    fn recreate(&mut self, ctx: &mut EventCtx, app: &mut App) -> Box<dyn State<App>> {
176        Self::new_state(ctx, app)
177    }
178}
179
180fn help() -> Vec<&'static str> {
181    vec![
182        "This shows crossings over main roads.",
183        "The number of crossings determines the \"porosity\" of areas",
184    ]
185}
186
187fn main_roads(app: &App) -> Vec<&Road> {
188    let mut result = Vec::new();
189    for r in app.per_map.map.all_roads() {
190        if r.get_rank() != osm::RoadRank::Local && !r.is_light_rail() {
191            result.push(r);
192        }
193    }
194    result
195}
196
197fn draw_crossings(ctx: &EventCtx, app: &App) -> Toggle3Zoomed {
198    let mut batch = GeomBatch::new();
199    let mut low_zoom = DrawCustomUnzoomedShapes::builder();
200
201    let mut icons = BTreeMap::new();
202    for ct in [CrossingType::Signalized, CrossingType::Unsignalized] {
203        icons.insert(ct, GeomBatch::load_svg(ctx, Crossings::svg_path(ct)));
204    }
205
206    let edits = app.per_map.map.get_edits();
207
208    for road in main_roads(app) {
209        for crossing in &road.crossings {
210            let rewrite_color = if edits.is_crossing_modified(road.id, crossing) {
211                RewriteColor::NoOp
212            } else {
213                RewriteColor::ChangeAlpha(0.7)
214            };
215
216            let icon = &icons[&crossing.kind];
217            if let Ok((pt, angle)) = road.center_pts.dist_along(crossing.dist) {
218                let angle = angle.rotate_degs(90.0);
219                batch.append(
220                    icon.clone()
221                        .scale_to_fit_width(road.get_width().inner_meters())
222                        .centered_on(pt)
223                        .rotate_around_batch_center(angle)
224                        .color(rewrite_color),
225                );
226
227                // TODO Memory intensive
228                let icon = icon.clone();
229                // TODO They can shrink a bit past their map size
230                low_zoom.add_custom(Box::new(move |batch, thickness| {
231                    batch.append(
232                        icon.clone()
233                            .scale_to_fit_width(30.0 * thickness)
234                            .centered_on(pt)
235                            .rotate_around_batch_center(angle)
236                            .color(rewrite_color),
237                    );
238                }));
239            }
240        }
241    }
242
243    let min_zoom_for_detail = 5.0;
244    let step_size = 0.1;
245    // TODO Ideally we get rid of Toggle3Zoomed and make DrawCustomUnzoomedShapes handle this
246    // medium-zoom case.
247    Toggle3Zoomed::new(
248        batch.build(ctx),
249        low_zoom.build(PerZoom::new(min_zoom_for_detail, step_size)),
250    )
251}
252
253#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
254enum Obj {
255    Road(RoadID),
256    // Identify crossings per road by the sorted index. When we make any mutation to a road, we
257    // rebuild the world fully, so this works
258    Crossing(RoadID, usize),
259}
260
261impl ObjectID for Obj {}
262
263fn make_world(
264    ctx: &EventCtx,
265    app: &App,
266    time_to_nearest_crossing: &BTreeMap<RoadID, Duration>,
267) -> World<Obj> {
268    let mut world = World::new();
269
270    for road in main_roads(app) {
271        for (idx, crossing) in road.crossings.iter().enumerate() {
272            world
273                .add(Obj::Crossing(road.id, idx))
274                // The circles change size based on zoom, but for interaction, just use a fixed
275                // multiple of the road's width. It'll be a little weird.
276                .hitbox(
277                    Circle::new(
278                        road.center_pts.must_dist_along(crossing.dist).0,
279                        3.0 * road.get_width() / 2.0,
280                    )
281                    .to_polygon(),
282                )
283                .drawn_in_master_batch()
284                .hover_color(colors::HOVER)
285                .zorder(1)
286                .clickable()
287                .build(ctx);
288        }
289
290        world
291            .add(Obj::Road(road.id))
292            .hitbox(road.get_thick_polygon())
293            .drawn_in_master_batch()
294            .hover_color(colors::HOVER)
295            .zorder(0)
296            .clickable()
297            .maybe_tooltip(if let Some(time) = time_to_nearest_crossing.get(&road.id) {
298                Some(Text::from(Line(format!(
299                    "{time} walking to the nearest crossing"
300                ))))
301            } else {
302                None
303            })
304            .build(ctx);
305    }
306
307    world.initialize_hover(ctx);
308    world
309}
310
311fn draw_porosity(ctx: &EventCtx, app: &App) -> Drawable {
312    let mut batch = GeomBatch::new();
313    for info in app.partitioning().all_neighbourhoods().values() {
314        // I haven't seen a single road segment with multiple crossings yet. If it happens, it's
315        // likely just a complex intersection and probably shouldn't count as multiple.
316        let num_crossings = info
317            .block
318            .perimeter
319            .roads
320            .iter()
321            .filter(|id| !app.per_map.map.get_r(id.road).crossings.is_empty())
322            .count();
323        let color = if num_crossings == 0 {
324            *colors::IMPERMEABLE
325        } else if num_crossings == 1 {
326            *colors::SEMI_PERMEABLE
327        } else {
328            *colors::POROUS
329        };
330
331        batch.push(color.alpha(0.5), info.block.polygon.clone());
332    }
333    ctx.upload(batch)
334}
335
336fn make_bottom_panel(ctx: &mut EventCtx, app: &App) -> Widget {
337    let icon = |ct: CrossingType, key: Key, name: &str| {
338        let hide_color = Color::hex("#FDDA06");
339
340        ctx.style()
341            .btn_solid_primary
342            .icon(Crossings::svg_path(ct))
343            .image_color(
344                RewriteColor::Change(hide_color, Color::CLEAR),
345                ControlState::Default,
346            )
347            .image_color(
348                RewriteColor::Change(hide_color, Color::CLEAR),
349                ControlState::Disabled,
350            )
351            .hotkey(key)
352            .disabled(app.session.crossing_type == ct)
353            .tooltip_and_disabled({
354                let mut txt = Text::new();
355                txt.append(Line(name));
356                txt.add_line(Line("Click").fg(ctx.style().text_hotkey_color));
357                txt.append(Line(" a main road to add or remove a crossing"));
358                txt
359            })
360            .build_widget(ctx, name)
361    };
362
363    let mut total_crossings = 0;
364    for r in main_roads(app) {
365        total_crossings += r.crossings.len();
366    }
367
368    Widget::row(vec![
369        icon(CrossingType::Unsignalized, Key::F1, "unsignalized crossing"),
370        icon(CrossingType::Signalized, Key::F2, "signalized crossing"),
371        Widget::vertical_separator(ctx),
372        Widget::row(vec![
373            ctx.style()
374                .btn_plain
375                .icon("system/assets/tools/undo.svg")
376                .disabled(app.per_map.map.get_edits().commands.is_empty())
377                .hotkey(lctrl(Key::Z))
378                .build_widget(ctx, "undo"),
379            // TODO Only count new crossings
380            format!("{total_crossings} crossings",)
381                .text_widget(ctx)
382                .centered_vert(),
383        ]),
384    ])
385}
386
387fn draw_nearest_crossing(ctx: &EventCtx, app: &App) -> (Drawable, BTreeMap<RoadID, Duration>) {
388    // Consider the undirected graph of main roads. Floodfill from each crossing and count the
389    // walking time to the nearest crossing, at road segment granularity.
390    //
391    // Note this is weird -- the nearest crossing might not be in the direction someone wants to
392    // go!
393    let mut queue: BinaryHeap<PriorityQueueItem<Duration, RoadID>> = BinaryHeap::new();
394
395    let mut main_road_ids = BTreeSet::new();
396    for r in main_roads(app) {
397        main_road_ids.insert(r.id);
398        if !app.per_map.map.get_r(r.id).crossings.is_empty() {
399            queue.push(PriorityQueueItem {
400                cost: Duration::ZERO,
401                value: r.id,
402            });
403        }
404    }
405
406    let mut cost_per_node: BTreeMap<RoadID, Duration> = BTreeMap::new();
407    while let Some(current) = queue.pop() {
408        if cost_per_node.contains_key(&current.value) {
409            continue;
410        }
411        cost_per_node.insert(current.value, current.cost);
412
413        // Walk to all main roads connected at either endpoint
414        for next in app.per_map.map.get_next_roads(current.value) {
415            if main_road_ids.contains(&next) {
416                let cost = app.per_map.map.get_r(next).length() / map_model::MAX_WALKING_SPEED;
417                queue.push(PriorityQueueItem {
418                    cost: current.cost + cost,
419                    value: next,
420                });
421            }
422        }
423    }
424
425    let mut drawn_intersections = BTreeSet::new();
426    let mut batch = GeomBatch::new();
427    for (r, time) in &cost_per_node {
428        let scale = if *time < Duration::minutes(1) {
429            continue;
430        } else if *time < Duration::minutes(2) {
431            0.2
432        } else if *time < Duration::minutes(3) {
433            0.4
434        } else if *time < Duration::minutes(4) {
435            0.6
436        } else if *time < Duration::minutes(5) {
437            0.8
438        } else {
439            1.0
440        };
441        let color = app.cs.good_to_bad_red.eval(scale);
442        let road = app.per_map.map.get_r(*r);
443        batch.push(color, road.get_thick_polygon());
444
445        // Color the intersections too, and don't worry if the colors differ. Just be less weird
446        // looking.
447        for i in [road.src_i, road.dst_i] {
448            if drawn_intersections.contains(&i) {
449                continue;
450            }
451            drawn_intersections.insert(i);
452            batch.push(color, app.per_map.map.get_i(i).polygon.clone());
453        }
454    }
455    (ctx.upload(batch), cost_per_node)
456}