game/edit/traffic_signals/
mod.rs

1use std::collections::{BTreeSet, VecDeque};
2
3use anyhow::Result;
4
5use abstutil::Timer;
6use geom::{Distance, Line, Polygon, Pt2D};
7use map_gui::options::TrafficSignalStyle;
8use map_gui::render::{traffic_signal, DrawMovement, DrawOptions};
9use map_model::{
10    ControlTrafficSignal, EditIntersectionControl, IntersectionID, MovementID, Stage, StageType,
11    TurnPriority,
12};
13use widgetry::tools::PopupMsg;
14use widgetry::{
15    include_labeled_bytes, lctrl, Color, ControlState, DragDrop, DrawBaselayer, Drawable, EventCtx,
16    GeomBatch, GeomBatchStack, GfxCtx, HorizontalAlignment, Image, Key, Line, Outcome, Panel,
17    RewriteColor, StackAxis, State, Text, TextExt, VerticalAlignment, Widget,
18};
19
20use crate::app::{App, ShowEverything, Transition};
21use crate::common::CommonState;
22use crate::edit::{apply_map_edits, ConfirmDiscard};
23use crate::sandbox::GameplayMode;
24
25mod edits;
26mod gmns;
27mod offsets;
28mod picker;
29mod preview;
30
31// Welcome to one of the most overwhelmingly complicated parts of the UI...
32
33pub struct TrafficSignalEditor {
34    side_panel: Panel,
35    top_panel: Panel,
36
37    mode: GameplayMode,
38    members: BTreeSet<IntersectionID>,
39    current_stage: usize,
40
41    movements: Vec<DrawMovement>,
42    // And the next priority to toggle to
43    movement_selected: Option<(MovementID, Option<TurnPriority>)>,
44    draw_current: Drawable,
45    tooltip: Option<Text>,
46
47    command_stack: Vec<BundleEdits>,
48    redo_stack: Vec<BundleEdits>,
49    // Before synchronizing the number of stages
50    original: BundleEdits,
51    warn_changed: bool,
52
53    fade_irrelevant: Drawable,
54}
55
56// For every member intersection, the full state of that signal
57#[derive(Clone, PartialEq)]
58pub struct BundleEdits {
59    signals: Vec<ControlTrafficSignal>,
60}
61
62impl TrafficSignalEditor {
63    pub fn new_state(
64        ctx: &mut EventCtx,
65        app: &mut App,
66        members: BTreeSet<IntersectionID>,
67        mode: GameplayMode,
68    ) -> Box<dyn State<App>> {
69        app.primary.current_selection = None;
70
71        let original = BundleEdits::get_current(app, &members);
72        let synced = BundleEdits::synchronize(app, &members);
73        let warn_changed = original != synced;
74        synced.apply(app);
75
76        let mut editor = TrafficSignalEditor {
77            side_panel: make_side_panel(ctx, app, &members, 0),
78            top_panel: make_top_panel(ctx, app, false, false),
79            mode,
80            current_stage: 0,
81            movements: Vec::new(),
82            movement_selected: None,
83            draw_current: Drawable::empty(ctx),
84            tooltip: None,
85            command_stack: Vec::new(),
86            redo_stack: Vec::new(),
87            warn_changed,
88            original,
89            fade_irrelevant: fade_irrelevant(app, &members).upload(ctx),
90            members,
91        };
92        editor.recalc_draw_current(ctx, app);
93        Box::new(editor)
94    }
95
96    fn change_stage(&mut self, ctx: &mut EventCtx, app: &App, idx: usize) {
97        if self.current_stage == idx {
98            let mut new = make_side_panel(ctx, app, &self.members, self.current_stage);
99            new.restore(ctx, &self.side_panel);
100            self.side_panel = new;
101        } else {
102            self.current_stage = idx;
103            self.side_panel = make_side_panel(ctx, app, &self.members, self.current_stage);
104        }
105
106        self.recalc_draw_current(ctx, app);
107    }
108
109    fn add_new_edit<F: Fn(&mut ControlTrafficSignal)>(
110        &mut self,
111        ctx: &mut EventCtx,
112        app: &mut App,
113        idx: usize,
114        fxn: F,
115    ) {
116        let mut bundle = BundleEdits::get_current(app, &self.members);
117        self.command_stack.push(bundle.clone());
118        self.redo_stack.clear();
119        for ts in &mut bundle.signals {
120            fxn(ts);
121        }
122        bundle.apply(app);
123
124        self.top_panel = make_top_panel(ctx, app, true, false);
125        self.change_stage(ctx, app, idx);
126    }
127
128    fn recalc_draw_current(&mut self, ctx: &mut EventCtx, app: &App) {
129        let mut batch = GeomBatch::new();
130        let mut movements = Vec::new();
131        for i in &self.members {
132            let stage = &app.primary.map.get_traffic_signal(*i).stages[self.current_stage];
133            for (m, draw) in DrawMovement::for_i(
134                ctx.prerender,
135                &app.primary.map,
136                &app.cs,
137                *i,
138                self.current_stage,
139            ) {
140                if self
141                    .movement_selected
142                    .map(|(x, _)| x != m.id)
143                    .unwrap_or(true)
144                    || m.id.crosswalk
145                {
146                    batch.append(draw);
147                } else if !stage.protected_movements.contains(&m.id)
148                    && !stage.yield_movements.contains(&m.id)
149                {
150                    // Still draw the icon, but highlight it
151                    batch.append(draw.color(RewriteColor::Change(
152                        app.cs.signal_banned_turn.alpha(0.5),
153                        Color::hex("#72CE36"),
154                    )));
155                }
156                movements.push(m);
157            }
158            traffic_signal::draw_stage_number(
159                ctx.prerender,
160                app.primary.map.get_i(*i),
161                self.current_stage,
162                &mut batch,
163            );
164        }
165
166        // Draw the selected thing on top of everything else
167        if let Some((selected, next_priority)) = self.movement_selected {
168            for m in &movements {
169                if m.id == selected {
170                    m.draw_selected_movement(app, &mut batch, next_priority);
171                    break;
172                }
173            }
174        }
175
176        self.draw_current = ctx.upload(batch);
177        self.movements = movements;
178    }
179
180    // We may have imported the signal configuration without validating it.
181    fn validate_all_members(&self, app: &App) -> Result<()> {
182        for i in &self.members {
183            app.primary
184                .map
185                .get_traffic_signal(*i)
186                .validate(app.primary.map.get_i(*i))?;
187        }
188        Ok(())
189    }
190}
191
192impl State<App> for TrafficSignalEditor {
193    fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Transition {
194        self.tooltip = None;
195
196        if self.warn_changed {
197            self.warn_changed = false;
198            return Transition::Push(PopupMsg::new_state(
199                ctx,
200                "Note",
201                vec!["Some signals were modified to match the number and duration of stages"],
202            ));
203        }
204
205        ctx.canvas_movement();
206
207        let canonical_signal = app
208            .primary
209            .map
210            .get_traffic_signal(*self.members.iter().next().unwrap());
211        let num_stages = canonical_signal.stages.len();
212
213        match self.side_panel.event(ctx) {
214            Outcome::Clicked(x) => match x.as_ref() {
215                "Edit entire signal" => {
216                    return Transition::Push(edits::edit_entire_signal(
217                        ctx,
218                        app,
219                        canonical_signal.id,
220                        self.mode.clone(),
221                        self.original.clone(),
222                    ));
223                }
224                "Tune offsets between signals" => {
225                    return Transition::Push(offsets::ShowAbsolute::new_state(
226                        ctx,
227                        app,
228                        self.members.clone(),
229                    ));
230                }
231                "Add a new stage" => {
232                    self.add_new_edit(ctx, app, num_stages, |ts| {
233                        ts.stages.push(Stage::new());
234                    });
235                    return Transition::Keep;
236                }
237                "change duration" => {
238                    return Transition::Push(edits::ChangeDuration::new_state(
239                        ctx,
240                        app,
241                        canonical_signal,
242                        self.current_stage,
243                    ));
244                }
245                "delete stage" => {
246                    let idx = self.current_stage;
247                    self.add_new_edit(ctx, app, 0, |ts| {
248                        ts.stages.remove(idx);
249                    });
250                    return Transition::Keep;
251                }
252                "previous stage" => {
253                    self.change_stage(ctx, app, self.current_stage - 1);
254                    return Transition::Keep;
255                }
256                "next stage" => {
257                    self.change_stage(ctx, app, self.current_stage + 1);
258                    return Transition::Keep;
259                }
260                x => {
261                    if let Some(x) = x.strip_prefix("stage ") {
262                        let idx = x.parse::<usize>().unwrap() - 1;
263                        self.change_stage(ctx, app, idx);
264                        return Transition::Keep;
265                    } else {
266                        unreachable!()
267                    }
268                }
269            },
270            Outcome::DragDropReleased(_, old_idx, new_idx) => {
271                self.add_new_edit(ctx, app, new_idx, |ts| {
272                    ts.stages.swap(old_idx, new_idx);
273                });
274            }
275            _ => {}
276        }
277
278        if let Outcome::Clicked(x) = self.top_panel.event(ctx) {
279            match x.as_ref() {
280                "Finish" => {
281                    if let Some(bundle) = check_for_missing_turns(app, &self.members) {
282                        bundle.apply(app);
283                        self.command_stack.push(bundle);
284                        self.redo_stack.clear();
285
286                        self.top_panel = make_top_panel(ctx, app, true, false);
287                        self.change_stage(ctx, app, 0);
288
289                        return Transition::Push(PopupMsg::new_state(
290                            ctx,
291                            "Error: missing turns",
292                            vec![
293                                "Some turns are missing from this traffic signal",
294                                "They've all been added as a new first stage. Please update your \
295                                changes to include them.",
296                            ],
297                        ));
298                    } else if let Err(err) = self.validate_all_members(app) {
299                        // TODO There's some crash between usvg and lyon trying to tesellate the
300                        // error text!
301                        error!("{}", err);
302                        return Transition::Push(PopupMsg::new_state(
303                            ctx,
304                            "Error",
305                            vec!["This signal configuration is somehow invalid; check the console logs"]
306                        ));
307                    } else {
308                        let changes = BundleEdits::get_current(app, &self.members);
309                        self.original.apply(app);
310                        changes.commit(ctx, app);
311                        return Transition::Pop;
312                    }
313                }
314                "Cancel" => {
315                    if BundleEdits::get_current(app, &self.members) == self.original {
316                        self.original.apply(app);
317                        return Transition::Pop;
318                    }
319                    let original = self.original.clone();
320                    return Transition::Push(ConfirmDiscard::new_state(
321                        ctx,
322                        Box::new(move |app| {
323                            original.apply(app);
324                        }),
325                    ));
326                }
327                "Edit multiple signals" => {
328                    if let Err(err) = self.validate_all_members(app) {
329                        error!("{}", err);
330                        return Transition::Push(PopupMsg::new_state(
331                            ctx,
332                            "Error",
333                            vec!["This signal configuration is somehow invalid; check the console logs"]
334                        ));
335                    }
336
337                    // First commit the current changes, so we enter SignalPicker with clean state.
338                    // This UX flow is a little unintuitive.
339                    let changes = check_for_missing_turns(app, &self.members)
340                        .unwrap_or_else(|| BundleEdits::get_current(app, &self.members));
341                    self.original.apply(app);
342                    changes.commit(ctx, app);
343                    return Transition::Replace(picker::SignalPicker::new_state(
344                        ctx,
345                        self.members.clone(),
346                        self.mode.clone(),
347                    ));
348                }
349                "Export" => {
350                    for signal in BundleEdits::get_current(app, &self.members).signals {
351                        let ts = signal.export(&app.primary.map);
352                        abstio::write_json(
353                            format!("traffic_signal_{}.json", ts.intersection_osm_node_id),
354                            &ts,
355                        );
356                    }
357                }
358                "Change crosswalks" => {
359                    // TODO Probably need to follow everything Cancel does
360                    return Transition::Replace(super::crosswalks::CrosswalkEditor::new_state(
361                        ctx,
362                        app,
363                        *self.members.iter().next().unwrap(),
364                    ));
365                }
366                "Preview" => {
367                    // Might have to do this first!
368                    app.primary
369                        .map
370                        .recalculate_pathfinding_after_edits(&mut Timer::throwaway());
371
372                    return Transition::Push(preview::make_previewer(
373                        ctx,
374                        app,
375                        self.members.clone(),
376                        self.current_stage,
377                    ));
378                }
379                "undo" => {
380                    self.redo_stack
381                        .push(BundleEdits::get_current(app, &self.members));
382                    self.command_stack.pop().unwrap().apply(app);
383                    self.top_panel = make_top_panel(ctx, app, !self.command_stack.is_empty(), true);
384                    self.change_stage(ctx, app, 0);
385                    return Transition::Keep;
386                }
387                "redo" => {
388                    self.command_stack
389                        .push(BundleEdits::get_current(app, &self.members));
390                    self.redo_stack.pop().unwrap().apply(app);
391                    self.top_panel = make_top_panel(ctx, app, true, !self.redo_stack.is_empty());
392                    self.change_stage(ctx, app, 0);
393                    return Transition::Keep;
394                }
395                _ => unreachable!(),
396            }
397        }
398
399        {
400            if self.current_stage != 0 && ctx.input.pressed(Key::LeftArrow) {
401                self.change_stage(ctx, app, self.current_stage - 1);
402            }
403
404            if self.current_stage != num_stages - 1 && ctx.input.pressed(Key::RightArrow) {
405                self.change_stage(ctx, app, self.current_stage + 1);
406            }
407        }
408
409        if ctx.redo_mouseover() {
410            let old = self.movement_selected;
411
412            self.movement_selected = None;
413            if let Some(pt) = ctx.canvas.get_cursor_in_map_space() {
414                for m in &self.movements {
415                    let signal = app.primary.map.get_traffic_signal(m.id.parent);
416                    let i = app.primary.map.get_i(signal.id);
417                    if m.hitbox.contains_pt(pt) {
418                        let stage = &signal.stages[self.current_stage];
419                        let next_priority = match stage.get_priority_of_movement(m.id) {
420                            TurnPriority::Banned => {
421                                if stage.could_be_protected(m.id, i) {
422                                    Some(TurnPriority::Protected)
423                                } else if m.id.crosswalk {
424                                    None
425                                } else {
426                                    Some(TurnPriority::Yield)
427                                }
428                            }
429                            TurnPriority::Yield => Some(TurnPriority::Banned),
430                            TurnPriority::Protected => {
431                                if m.id.crosswalk {
432                                    Some(TurnPriority::Banned)
433                                } else {
434                                    Some(TurnPriority::Yield)
435                                }
436                            }
437                        };
438                        self.movement_selected = Some((m.id, next_priority));
439                        break;
440                    }
441                }
442            }
443
444            if self.movement_selected != old {
445                self.change_stage(ctx, app, self.current_stage);
446            }
447        }
448
449        if let Some((id, Some(pri))) = self.movement_selected {
450            let signal = app.primary.map.get_traffic_signal(id.parent);
451            let mut txt = Text::new();
452            txt.add_line(Line(format!(
453                "{} {}",
454                match signal.stages[self.current_stage].get_priority_of_movement(id) {
455                    TurnPriority::Protected => "Protected",
456                    TurnPriority::Yield => "Yielding",
457                    TurnPriority::Banned => "Forbidden",
458                },
459                if id.crosswalk { "crosswalk" } else { "turn" },
460            )));
461            txt.add_appended(vec![
462                Line("Click").fg(ctx.style().text_hotkey_color),
463                Line(format!(
464                    " to {}",
465                    match pri {
466                        TurnPriority::Protected => "add it as protected",
467                        TurnPriority::Yield => "allow it after yielding",
468                        TurnPriority::Banned => "forbid it",
469                    }
470                )),
471            ]);
472            self.tooltip = Some(txt);
473            if app.per_obj.left_click(
474                ctx,
475                format!(
476                    "toggle from {:?} to {:?}",
477                    signal.stages[self.current_stage].get_priority_of_movement(id),
478                    pri
479                ),
480            ) {
481                let idx = self.current_stage;
482                let movement = app.primary.map.get_i(id.parent).movements[&id].clone();
483                self.add_new_edit(ctx, app, idx, |ts| {
484                    if ts.id == id.parent {
485                        ts.stages[idx].edit_movement(&movement, pri);
486                    }
487                });
488                return Transition::KeepWithMouseover;
489            }
490        }
491
492        Transition::Keep
493    }
494
495    fn draw_baselayer(&self) -> DrawBaselayer {
496        DrawBaselayer::Custom
497    }
498
499    fn draw(&self, g: &mut GfxCtx, app: &App) {
500        {
501            let mut opts = DrawOptions::new();
502            opts.suppress_traffic_signal_details
503                .extend(self.members.clone());
504            app.draw(g, opts, &ShowEverything::new());
505        }
506        g.redraw(&self.fade_irrelevant);
507        g.redraw(&self.draw_current);
508
509        self.top_panel.draw(g);
510        self.side_panel.draw(g);
511
512        if let Some((id, _)) = self.movement_selected {
513            let osd = if id.crosswalk {
514                Text::from(format!(
515                    "Crosswalk across {}",
516                    app.primary
517                        .map
518                        .get_r(id.from.road)
519                        .get_name(app.opts.language.as_ref())
520                ))
521            } else {
522                Text::from(format!(
523                    "Turn from {} to {}",
524                    app.primary
525                        .map
526                        .get_r(id.from.road)
527                        .get_name(app.opts.language.as_ref()),
528                    app.primary
529                        .map
530                        .get_r(id.to.road)
531                        .get_name(app.opts.language.as_ref())
532                ))
533            };
534            CommonState::draw_custom_osd(g, app, osd);
535        } else {
536            CommonState::draw_osd(g, app);
537        }
538
539        if let Some(txt) = self.tooltip.clone() {
540            g.draw_mouse_tooltip(txt);
541        }
542    }
543}
544
545fn make_top_panel(ctx: &mut EventCtx, app: &App, can_undo: bool, can_redo: bool) -> Panel {
546    let mut second_row = vec![ctx
547        .style()
548        .btn_outline
549        .text("Change crosswalks")
550        .hotkey(Key::C)
551        .build_def(ctx)];
552    if app.opts.dev {
553        second_row.push(
554            ctx.style()
555                .btn_outline
556                .text("Export")
557                .tooltip(Text::from_multiline(vec![
558                    Line(
559                        "This will create a JSON file in the directory where A/B Street is running",
560                    )
561                    .small(),
562                    Line(
563                        "Contribute this to map how this traffic signal is currently timed in \
564                     real life.",
565                    )
566                    .small(),
567                ]))
568                .build_def(ctx),
569        );
570    }
571
572    Panel::new_builder(Widget::col(vec![
573        Widget::row(vec![
574            Line("Traffic signal editor")
575                .small_heading()
576                .into_widget(ctx),
577            ctx.style()
578                .btn_plain
579                .text("+ Edit multiple")
580                .label_color(Color::hex("#4CA7E9"), ControlState::Default)
581                .hotkey(Key::M)
582                .build_widget(ctx, "Edit multiple signals"),
583        ]),
584        Widget::row(vec![
585            ctx.style()
586                .btn_solid_primary
587                .text("Finish")
588                .hotkey(Key::Enter)
589                .build_def(ctx),
590            ctx.style()
591                .btn_outline
592                .text("Preview")
593                .hotkey(lctrl(Key::P))
594                .build_def(ctx),
595            ctx.style()
596                .btn_plain
597                .icon("system/assets/tools/undo.svg")
598                .disabled(!can_undo)
599                .hotkey(lctrl(Key::Z))
600                .build_widget(ctx, "undo"),
601            ctx.style()
602                .btn_plain
603                .icon("system/assets/tools/redo.svg")
604                .disabled(!can_redo)
605                // TODO ctrl+shift+Z!
606                .hotkey(lctrl(Key::Y))
607                .build_widget(ctx, "redo"),
608            ctx.style()
609                .btn_plain_destructive
610                .text("Cancel")
611                .hotkey(Key::Escape)
612                .build_def(ctx)
613                .align_right(),
614        ]),
615        Widget::row(second_row),
616    ]))
617    .aligned(HorizontalAlignment::Center, VerticalAlignment::Top)
618    .build(ctx)
619}
620
621fn make_side_panel(
622    ctx: &mut EventCtx,
623    app: &App,
624    members: &BTreeSet<IntersectionID>,
625    selected: usize,
626) -> Panel {
627    let map = &app.primary.map;
628    // Use any member for stage duration
629    let canonical_signal = map.get_traffic_signal(*members.iter().next().unwrap());
630
631    let mut txt = Text::new();
632    if members.len() == 1 {
633        let i = *members.iter().next().unwrap();
634        txt.add_line(Line(i.to_string()).big_heading_plain());
635
636        let mut road_names = BTreeSet::new();
637        for r in &app.primary.map.get_i(i).roads {
638            road_names.insert(
639                app.primary
640                    .map
641                    .get_r(*r)
642                    .get_name(app.opts.language.as_ref()),
643            );
644        }
645        for r in road_names {
646            txt.add_line(Line(format!("  {}", r)).secondary());
647        }
648    } else {
649        txt.add_line(Line(format!("{} intersections", members.len())).big_heading_plain());
650        txt.add_line(
651            Line(
652                members
653                    .iter()
654                    .map(|i| format!("#{}", i.0))
655                    .collect::<Vec<_>>()
656                    .join(", "),
657            )
658            .secondary(),
659        );
660    }
661    let mut col = vec![txt.into_widget(ctx)];
662
663    // Stage controls
664    col.push(
665        Widget::row(vec![
666            ctx.style()
667                .btn_plain
668                .icon_bytes(include_labeled_bytes!(
669                    "../../../../../widgetry/icons/arrow_left.svg"
670                ))
671                .disabled(selected == 0)
672                .build_widget(ctx, "previous stage"),
673            ctx.style()
674                .btn_plain
675                .icon_bytes(include_labeled_bytes!(
676                    "../../../../../widgetry/icons/arrow_right.svg"
677                ))
678                .disabled(selected == canonical_signal.stages.len() - 1)
679                .build_widget(ctx, "next stage"),
680            match canonical_signal.stages[selected].stage_type {
681                StageType::Fixed(d) => format!("Stage duration: {}", d),
682                StageType::Variable(min, delay, additional) => format!(
683                    "Stage duration: {}, {}, {} (variable)",
684                    min, delay, additional
685                ),
686            }
687            .text_widget(ctx)
688            .centered_vert(),
689            ctx.style()
690                .btn_plain
691                .icon("system/assets/tools/pencil.svg")
692                .hotkey(Key::X)
693                .build_widget(ctx, "change duration"),
694            if canonical_signal.stages.len() > 1 {
695                ctx.style()
696                    .btn_solid_destructive
697                    .icon("system/assets/tools/trash.svg")
698                    .build_widget(ctx, "delete stage")
699            } else {
700                Widget::nothing()
701            },
702            ctx.style()
703                .btn_plain
704                .icon("system/assets/speed/plus.svg")
705                .build_widget(ctx, "Add a new stage"),
706        ])
707        .padding(10)
708        .bg(app.cs.inner_panel_bg),
709    );
710
711    let translations = squish_polygons_together(
712        members
713            .iter()
714            .map(|i| app.primary.map.get_i(*i).polygon.clone())
715            .collect(),
716    );
717
718    let mut drag_drop = DragDrop::new(ctx, "stage cards", StackAxis::Horizontal);
719    for idx in 0..canonical_signal.stages.len() {
720        let mut stack = GeomBatchStack::vertical(vec![
721            Text::from(Line(format!(
722                "Stage {}: {}",
723                idx + 1,
724                match canonical_signal.stages[idx].stage_type {
725                    StageType::Fixed(d) => format!("{}", d),
726                    StageType::Variable(min, _, _) => format!("{} (v)", min),
727                },
728            )))
729            .render(ctx),
730            draw_multiple_signals(ctx, app, members, idx, &translations),
731        ]);
732        stack.set_spacing(10.0);
733        let icon_batch = stack.batch();
734        let icon_bounds = icon_batch.get_bounds();
735        let image = Image::from_batch(icon_batch, icon_bounds)
736            .dims(150.0)
737            .untinted()
738            .padding(16);
739        let (default_batch, bounds) = image.clone().build_batch(ctx).unwrap();
740        let (hovering_batch, _) = image
741            .clone()
742            .bg_color(ctx.style().btn_tab.bg_disabled.dull(0.3))
743            .build_batch(ctx)
744            .unwrap();
745        let (selected_batch, _) = image
746            .bg_color(ctx.style().btn_solid_primary.bg)
747            .build_batch(ctx)
748            .unwrap();
749
750        drag_drop.push_card(
751            idx,
752            bounds.into(),
753            default_batch,
754            hovering_batch,
755            selected_batch,
756        );
757    }
758    drag_drop.set_initial_state(Some(selected), None);
759
760    col.push(drag_drop.into_widget(ctx));
761
762    col.push(Widget::row(vec![
763        // TODO Say "normally" to account for variable stages?
764        format!(
765            "One full cycle lasts {}",
766            canonical_signal.simple_cycle_duration()
767        )
768        .text_widget(ctx)
769        .centered_vert(),
770        if members.len() == 1 {
771            ctx.style()
772                .btn_outline
773                .text("Edit entire signal")
774                .hotkey(Key::E)
775                .build_def(ctx)
776        } else {
777            ctx.style()
778                .btn_outline
779                .text("Tune offsets between signals")
780                .hotkey(Key::O)
781                .build_def(ctx)
782        },
783    ]));
784
785    Panel::new_builder(Widget::col(col))
786        .aligned(HorizontalAlignment::Left, VerticalAlignment::Center)
787        // Hovering on a stage card after dropping it produces Outcome::Changed
788        .ignore_initial_events()
789        .build(ctx)
790}
791
792impl BundleEdits {
793    fn apply(&self, app: &mut App) {
794        for s in &self.signals {
795            app.primary.map.incremental_edit_traffic_signal(s.clone());
796        }
797    }
798
799    fn commit(self, ctx: &mut EventCtx, app: &mut App) {
800        // Skip if there's no change
801        if self == BundleEdits::get_current(app, &self.signals.iter().map(|s| s.id).collect()) {
802            return;
803        }
804
805        let mut edits = app.primary.map.get_edits().clone();
806        // TODO Can we batch these commands somehow, so undo/redo in edit mode behaves properly?
807        for signal in self.signals {
808            edits
809                .commands
810                .push(app.primary.map.edit_intersection_cmd(signal.id, |new| {
811                    new.control =
812                        EditIntersectionControl::TrafficSignal(signal.export(&app.primary.map));
813                }));
814        }
815        apply_map_edits(ctx, app, edits);
816    }
817
818    fn get_current(app: &App, members: &BTreeSet<IntersectionID>) -> BundleEdits {
819        let signals = members
820            .iter()
821            .map(|i| app.primary.map.get_traffic_signal(*i).clone())
822            .collect();
823        BundleEdits { signals }
824    }
825
826    // If the intersections haven't been edited together before, the number of stages and the
827    // durations might not match up. Just initially force them to align somehow.
828    fn synchronize(app: &App, members: &BTreeSet<IntersectionID>) -> BundleEdits {
829        let map = &app.primary.map;
830        // Pick one of the members with the most stages as canonical.
831        let canonical = map.get_traffic_signal(
832            *members
833                .iter()
834                .max_by_key(|i| map.get_traffic_signal(**i).stages.len())
835                .unwrap(),
836        );
837
838        let mut signals = Vec::new();
839        for i in members {
840            let mut signal = map.get_traffic_signal(*i).clone();
841            for (idx, canonical_stage) in canonical.stages.iter().enumerate() {
842                if signal.stages.len() == idx {
843                    signal.stages.push(Stage::new());
844                }
845                signal.stages[idx].stage_type = canonical_stage.stage_type.clone();
846            }
847            signals.push(signal);
848        }
849
850        BundleEdits { signals }
851    }
852}
853
854// If None, nothing missing.
855fn check_for_missing_turns(app: &App, members: &BTreeSet<IntersectionID>) -> Option<BundleEdits> {
856    let mut all_missing = BTreeSet::new();
857    for i in members {
858        all_missing.extend(
859            app.primary
860                .map
861                .get_traffic_signal(*i)
862                .missing_turns(app.primary.map.get_i(*i)),
863        );
864    }
865    if all_missing.is_empty() {
866        return None;
867    }
868
869    let mut bundle = BundleEdits::get_current(app, members);
870    // Stick all the missing turns in a new stage at the beginning.
871    for signal in &mut bundle.signals {
872        let mut stage = Stage::new();
873        // TODO Could do this more efficiently
874        for m in &all_missing {
875            if m.parent != signal.id {
876                continue;
877            }
878            if m.crosswalk {
879                stage.protected_movements.insert(*m);
880            } else {
881                stage.yield_movements.insert(*m);
882            }
883        }
884        signal.stages.insert(0, stage);
885    }
886    Some(bundle)
887}
888
889fn draw_multiple_signals(
890    ctx: &mut EventCtx,
891    app: &App,
892    members: &BTreeSet<IntersectionID>,
893    idx: usize,
894    translations: &[(f64, f64)],
895) -> GeomBatch {
896    let mut batch = GeomBatch::new();
897    for (i, (dx, dy)) in members.iter().zip(translations) {
898        let mut piece = GeomBatch::new();
899        piece.push(
900            app.cs.normal_intersection,
901            app.primary.map.get_i(*i).polygon.clone(),
902        );
903        traffic_signal::draw_signal_stage(
904            ctx.prerender,
905            &app.primary.map.get_traffic_signal(*i).stages[idx],
906            idx,
907            *i,
908            None,
909            &mut piece,
910            app,
911            TrafficSignalStyle::Yuwen,
912        );
913        batch.append(piece.translate(*dx, *dy));
914    }
915
916    // Make the whole thing fit a fixed width
917    let square_dims = 150.0;
918    batch = batch.autocrop();
919    let bounds = batch.get_bounds();
920    let zoom = (square_dims / bounds.width()).min(square_dims / bounds.height());
921    batch.scale(zoom)
922}
923
924// TODO Move to geom?
925fn squish_polygons_together(mut polygons: Vec<Polygon>) -> Vec<(f64, f64)> {
926    if polygons.len() == 1 {
927        return vec![(0.0, 0.0)];
928    }
929
930    // Can't be too big, or polygons could silently swap places. To be careful, pick something a
931    // bit smaller than the smallest polygon.
932    let step_size = 0.8
933        * polygons.iter().fold(std::f64::MAX, |x, p| {
934            x.min(p.get_bounds().width()).min(p.get_bounds().height())
935        });
936
937    let mut translations: Vec<(f64, f64)> =
938        std::iter::repeat((0.0, 0.0)).take(polygons.len()).collect();
939    // Once a polygon hits another while moving, stop adjusting it. Otherwise, go round-robin.
940    let mut indices: VecDeque<usize> = (0..polygons.len()).collect();
941
942    let mut attempts = 0;
943    while !indices.is_empty() {
944        let idx = indices.pop_front().unwrap();
945        let center = Pt2D::center(&polygons.iter().map(|p| p.center()).collect::<Vec<_>>());
946        let angle = Line::must_new(polygons[idx].center(), center).angle();
947        let pt = Pt2D::new(0.0, 0.0).project_away(Distance::meters(step_size), angle);
948
949        // Do we hit anything if we move this way?
950        let translated = polygons[idx].translate(pt.x(), pt.y());
951        if polygons.iter().enumerate().any(|(i, p)| {
952            i != idx
953                && !translated
954                    .intersection(p)
955                    .map(|list| list.is_empty())
956                    .unwrap_or(true)
957        }) {
958            // Stop moving this polygon
959        } else {
960            translations[idx].0 += pt.x();
961            translations[idx].1 += pt.y();
962            polygons[idx] = translated;
963            indices.push_back(idx);
964        }
965
966        attempts += 1;
967        if attempts == 100 {
968            break;
969        }
970    }
971
972    translations
973}
974
975pub fn fade_irrelevant(app: &App, members: &BTreeSet<IntersectionID>) -> GeomBatch {
976    let mut holes = Vec::new();
977    for i in members {
978        let i = app.primary.map.get_i(*i);
979        holes.push(i.polygon.clone());
980        for r in &i.roads {
981            holes.push(app.primary.map.get_r(*r).get_thick_polygon());
982        }
983    }
984    // The convex hull illuminates a bit more of the surrounding area, looks better
985    match Polygon::convex_hull(holes) {
986        Ok(hole) => {
987            let fade_area = Polygon::with_holes(
988                app.primary
989                    .map
990                    .get_boundary_polygon()
991                    .get_outer_ring()
992                    .clone(),
993                vec![hole.into_outer_ring()],
994            );
995            GeomBatch::from(vec![(app.cs.fade_map_dark, fade_area)])
996        }
997        Err(_) => {
998            // Just give up and don't fade anything
999            GeomBatch::new()
1000        }
1001    }
1002}