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