1use crate::export::ExportObject;
2use crate::graph::Station;
3use crate::interface::SelectedElement;
4use crate::interface::tabs::{Navigatable, Tab};
5use crate::interface::widgets::{buttons, timetable_popup};
6use crate::lines::DisplayedLine;
7use crate::units::time::{Duration, TimetableTime};
8use crate::vehicles::AdjustTimetableEntry;
9use crate::vehicles::entries::ActualRouteEntry;
10use crate::vehicles::entries::{TimetableEntry, TimetableEntryCache, TravelMode};
11use crate::vehicles::vehicle_set::VehicleSet;
12
13use bevy::ecs::entity::{EntityMapper, MapEntities};
14use bevy::ecs::system::RunSystemOnce;
15use bevy::prelude::*;
16use egui::{
17 Color32, CornerRadius, FontId, Frame, Margin, Painter, Popup, Pos2, Rect, RichText, Sense,
18 Shape, Stroke, Ui, UiBuilder, Vec2, response, vec2,
19};
20use egui_i18n::tr;
21use moonshine_core::kind::Instance;
22use serde::{Deserialize, Serialize};
23use strum::EnumCount;
24use strum_macros::EnumCount;
25mod calculate_lines;
26mod edit_line;
27
28const TICKS_PER_SECOND: i64 = 100;
30
31#[derive(PartialEq, Eq, Clone, Copy)]
33#[allow(dead_code)]
34pub enum SelectedEntityType {
35 Vehicle(Entity),
36 TimetableEntry { entry: Entity, vehicle: Entity },
37 Interval((Instance<Station>, Instance<Station>)),
38 Station(Instance<Station>),
39 Map(Entity),
40}
41
42#[derive(Debug, Clone)]
43pub struct DiagramPageCache {
44 previous_total_drag_delta: Option<f32>,
46 stroke: Stroke,
49 tick_offset: i64,
51 vertical_offset: f32,
53 zoom: Vec2,
54 line_cache: DiagramLineCache,
55}
56
57impl MapEntities for DiagramPageCache {
58 fn map_entities<M: EntityMapper>(&mut self, entity_mapper: &mut M) {
59 if let Some(heights) = &mut self.line_cache.heights {
60 for (station, _) in heights.iter_mut() {
61 station.map_entities(entity_mapper);
62 }
63 }
64 for entity in &mut self.line_cache.vehicle_entities {
65 entity.map_entities(entity_mapper);
66 }
67 if let Some(vehicle_set) = &mut self.line_cache.vehicle_set {
68 vehicle_set.map_entities(entity_mapper);
69 }
70 }
71}
72
73impl DiagramPageCache {
74 fn get_visible_stations(&self, range: std::ops::Range<f32>) -> &[(Instance<Station>, f32)] {
76 let Some(heights) = &self.line_cache.heights else {
77 return &[];
78 };
79 let first_visible = heights.iter().position(|(_, h)| *h > range.start);
80 let last_visible = heights.iter().rposition(|(_, h)| *h < range.end);
81 if let (Some(mut first_visible), Some(mut last_visible)) = (first_visible, last_visible) {
82 first_visible = first_visible.saturating_sub(2);
84 last_visible = (last_visible + 1).min(heights.len() - 1);
85 &heights[first_visible..=last_visible]
86 } else {
87 &[]
88 }
89 }
90}
91
92impl Default for DiagramPageCache {
93 fn default() -> Self {
94 Self {
95 previous_total_drag_delta: None,
96 tick_offset: 0,
97 vertical_offset: 0.0,
98 stroke: Stroke {
99 width: 1.0,
100 color: Color32::BLACK,
101 },
102 zoom: vec2(0.0005, 1.0),
103 line_cache: DiagramLineCache::default(),
104 }
105 }
106}
107
108#[derive(Debug, Clone, Default)]
109struct DiagramLineCache {
110 heights: Option<Vec<(Instance<Station>, f32)>>,
111 vehicle_entities: Vec<Entity>,
112 vehicle_set: Option<Entity>,
113 line_missing: bool,
114 last_render_context: Option<DiagramRenderContext>,
115}
116
117#[derive(Debug, Clone)]
118struct DiagramRenderContext {
119 screen_rect: Rect,
120 vertical_visible: std::ops::Range<f32>,
121 horizontal_visible: std::ops::Range<i64>,
122 ticks_per_screen_unit: f64,
123}
124
125#[derive(Debug, Clone)]
126struct DiagramLineParams {
127 tick_offset: i64,
128 vertical_offset: f32,
129 zoom_y: f32,
130 stroke: Stroke,
131}
132
133type PointData = (Pos2, Option<Pos2>, ActualRouteEntry);
134
135#[derive(Debug, Clone)]
136struct RenderedVehicle {
137 segments: Vec<Vec<PointData>>,
138 stroke: Stroke,
139 entity: Entity,
140}
141
142#[derive(PartialEq, Debug, Default, Clone, EnumCount, Serialize, Deserialize)]
143enum EditingState {
144 #[default]
145 None,
146 EditingLine,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct DiagramTab {
151 pub displayed_line_entity: Entity,
152 editing: EditingState,
153 #[serde(skip, default)]
154 state: DiagramPageCache,
155 #[serde(skip)]
156 typst_output: String,
157 #[serde(skip)]
158 rendered_vehicles_cache: Vec<RenderedVehicle>,
159}
160
161impl MapEntities for DiagramTab {
162 fn map_entities<M: EntityMapper>(&mut self, entity_mapper: &mut M) {
163 self.displayed_line_entity.map_entities(entity_mapper);
164 self.state.map_entities(entity_mapper);
165 }
166}
167
168impl DiagramTab {
169 pub fn new(displayed_line_entity: Entity) -> Self {
170 Self {
171 displayed_line_entity,
172 editing: EditingState::default(),
173 state: DiagramPageCache::default(),
174 typst_output: String::new(),
175 rendered_vehicles_cache: Vec::new(),
176 }
177 }
178}
179
180impl Navigatable for DiagramTab {
181 fn zoom_x(&self) -> f32 {
182 self.state.zoom.x
183 }
184
185 fn zoom_y(&self) -> f32 {
186 self.state.zoom.y
187 }
188
189 fn set_zoom(&mut self, zoom_x: f32, zoom_y: f32) {
190 self.state.zoom = vec2(zoom_x, zoom_y);
191 }
192
193 fn offset_x(&self) -> f64 {
194 self.state.tick_offset as f64
195 }
196
197 fn offset_y(&self) -> f32 {
198 self.state.vertical_offset
199 }
200
201 fn set_offset(&mut self, offset_x: f64, offset_y: f32) {
202 self.state.tick_offset = offset_x.round() as i64;
203 self.state.vertical_offset = offset_y;
204 }
205
206 fn allow_axis_zoom(&self) -> bool {
207 true
208 }
209
210 fn clamp_zoom(&self, zoom_x: f32, zoom_y: f32) -> (f32, f32) {
211 (zoom_x.clamp(0.00001, 0.4), zoom_y.clamp(0.025, 2048.0))
212 }
213
214 fn post_navigation(&mut self, response: &egui::Response) {
215 self.state.tick_offset = self.state.tick_offset.clamp(
216 -366 * 86400 * TICKS_PER_SECOND,
217 366 * 86400 * TICKS_PER_SECOND
218 - (response.rect.width() as f64 / self.state.zoom.x as f64) as i64,
219 );
220 const TOP_BOTTOM_PADDING: f32 = 30.0;
221 let max_height = self
222 .state
223 .line_cache
224 .heights
225 .as_ref()
226 .and_then(|h| h.last().map(|(_, h)| *h))
227 .unwrap_or(0.0);
228 self.state.vertical_offset = if response.rect.height() / self.state.zoom.y
229 > (max_height + TOP_BOTTOM_PADDING * 2.0 / self.state.zoom.y)
230 {
231 (-response.rect.height() / self.state.zoom.y + max_height) / 2.0
232 } else {
233 self.state.vertical_offset.clamp(
234 -TOP_BOTTOM_PADDING / self.state.zoom.y,
235 max_height - response.rect.height() / self.state.zoom.y
236 + TOP_BOTTOM_PADDING / self.state.zoom.y,
237 )
238 }
239 }
240}
241
242impl PartialEq for DiagramTab {
243 fn eq(&self, other: &Self) -> bool {
244 self.displayed_line_entity == other.displayed_line_entity
245 }
246}
247
248impl Eq for DiagramTab {}
249
250impl Tab for DiagramTab {
251 const NAME: &'static str = "Diagram";
252 fn pre_render(&mut self, world: &mut World) {}
253 fn post_render(&mut self, _world: &mut World) {
254 self.rendered_vehicles_cache.clear();
255 }
256 fn title(&self) -> egui::WidgetText {
257 tr!("tab-diagram").into()
258 }
259 fn main_display(&mut self, world: &mut World, ui: &mut Ui) {
260 Frame::canvas(ui.style())
261 .inner_margin(Margin::ZERO)
262 .show(ui, |ui| {
263 self.state.stroke.color = ui.visuals().text_color();
264
265 let (response, mut painter) =
266 ui.allocate_painter(ui.available_size_before_wrap(), Sense::click_and_drag());
267
268 self.handle_navigation(ui, &response);
269
270 let (vertical_visible, horizontal_visible, ticks_per_screen_unit) =
271 calculate_visible_ranges(&self.state, &response.rect);
272
273 self.state.line_cache.last_render_context = Some(DiagramRenderContext {
274 screen_rect: response.rect,
275 vertical_visible: vertical_visible.clone(),
276 horizontal_visible: horizontal_visible.clone(),
277 ticks_per_screen_unit,
278 });
279
280 let line_params = DiagramLineParams {
281 tick_offset: self.state.tick_offset,
282 vertical_offset: self.state.vertical_offset,
283 zoom_y: self.state.zoom.y,
284 stroke: self.state.stroke.clone(),
285 };
286 if let Err(e) = world.run_system_cached_with(
287 calculate_lines::calculate_lines,
288 (
289 self.displayed_line_entity,
290 &mut self.state.line_cache,
291 &mut self.rendered_vehicles_cache,
292 line_params,
293 ),
294 ) {
295 error!("Error calculating lines for diagram: {}", e);
296 }
297
298 if let Err(e) = world.run_system_cached_with(
299 show_diagram,
300 (
301 ui,
302 &mut self.state,
303 &self.rendered_vehicles_cache,
304 &mut painter,
305 &response,
306 ),
307 ) {
308 error!(
309 "UI Error while displaying diagram ({}): {}",
310 self.displayed_line_entity, e
311 )
312 }
313 });
314 }
315 fn edit_display(&mut self, world: &mut World, ui: &mut Ui) {
316 ui.group(|ui| {
317 world.run_system_cached_with(
318 select_vehicle_set,
319 (ui, &mut self.state.line_cache.vehicle_set),
320 );
321 if ui
322 .button("Generate intervals from displayed line")
323 .clicked()
324 && let Err(e) = world.run_system_once_with(
325 crate::lines::create_intervals_from_displayed_line,
326 self.displayed_line_entity,
327 )
328 {
329 error!(
330 "Error while generating intervals from displayed line: {:?}",
331 e
332 )
333 }
334 if ui
335 .button("Automatically adjust intervals' length")
336 .clicked()
337 && let Err(e) = world.run_system_once_with(
338 crate::lines::adjust_intervals_length,
339 self.displayed_line_entity,
340 )
341 {
342 error!("Error while automatically adjusting intervals: {:?}", e)
343 }
344 });
345 let width = ui.available_width();
347 let spacing = ui.spacing().item_spacing.x;
348 let element_width = (width - spacing) / EditingState::COUNT as f32;
349 ui.horizontal(|ui| {
350 if ui
351 .add_sized(
352 [element_width, 30.0],
353 egui::Button::new("None").selected(self.editing == EditingState::None),
354 )
355 .clicked()
356 {
357 self.editing = EditingState::None;
358 }
359 if ui
360 .add_sized(
361 [element_width, 30.0],
362 egui::Button::new("Edit Lines")
363 .selected(self.editing == EditingState::EditingLine),
364 )
365 .clicked()
366 {
367 self.editing = EditingState::EditingLine;
368 }
369 });
370 if self.editing == EditingState::EditingLine {
377 egui::ScrollArea::vertical().show(ui, |ui| {
378 world.run_system_cached_with(edit_line::edit_line, (ui, self.displayed_line_entity))
379 });
380 }
381 }
382 fn display_display(&mut self, world: &mut World, ui: &mut Ui) {
383 let current_tab = world.resource::<SelectedElement>();
384 use super::super::side_panel::*;
385 match current_tab.0 {
386 None => {
387 ui.label(RichText::new("Nothing Selected").italics());
389 }
390 Some(SelectedEntityType::Interval(i)) => {
391 if let Err(e) =
392 world.run_system_cached_with(interval_stats::show_interval_stats, (ui, i))
393 {
394 error!("UI Error while displaying interval stats: {}", e);
395 }
396 }
397 Some(SelectedEntityType::Map(_)) => {}
398 Some(SelectedEntityType::Station(s)) => {
399 if let Err(e) =
400 world.run_system_cached_with(station_stats::show_station_stats, (ui, s))
401 {
402 error!("UI Error while displaying station stats: {}", e);
403 }
404 }
405 Some(SelectedEntityType::TimetableEntry {
406 entry: _,
407 vehicle: _,
408 }) => {}
409 Some(SelectedEntityType::Vehicle(v)) => {
410 if let Err(e) =
411 world.run_system_cached_with(vehicle_stats::show_vehicle_stats, (ui, v))
412 {
413 error!("UI Error while displaying vehicle stats: {}", e);
414 }
415 }
416 }
417 }
418 fn export_display(&mut self, world: &mut World, ui: &mut Ui) {
419 ui.group(|ui| {
420 ui.strong(tr!("tab-diagram-export-typst-diagram"));
421 ui.label(tr!("tab-diagram-export-typst-diagram-desc"));
422 if ui.button(tr!("export")).clicked() {
424 let mut calculated_vehicles: Vec<RenderedVehicle> = Vec::new();
425 let mut line_cache = self.state.line_cache.clone();
426 let max_height = line_cache
427 .heights
428 .as_ref()
429 .and_then(|h| h.last().map(|(_, h)| *h))
430 .unwrap_or(0.0);
431 let ticks_per_screen_unit = 1.0 / self.state.zoom.x as f64;
432 let horizontal_visible = 0..86400 * TICKS_PER_SECOND;
433 let vertical_visible = 0.0..max_height;
434 let width = (horizontal_visible.end - horizontal_visible.start) as f64
435 / ticks_per_screen_unit;
436 let height = max_height * self.state.zoom.y;
437 line_cache.last_render_context = Some(DiagramRenderContext {
438 screen_rect: Rect::from_min_size(Pos2::ZERO, vec2(width as f32, height)),
439 vertical_visible,
440 horizontal_visible,
441 ticks_per_screen_unit,
442 });
443 if let Err(e) = world.run_system_cached_with(
444 calculate_lines::calculate_lines,
445 (
446 self.displayed_line_entity,
447 &mut line_cache,
448 &mut calculated_vehicles,
449 DiagramLineParams {
450 tick_offset: 0,
451 vertical_offset: 0.0,
452 zoom_y: self.state.zoom.y,
453 stroke: self.state.stroke.clone(),
454 },
455 ),
456 ) {
457 error!("Error calculating lines for diagram: {}", e);
458 }
459 if let Err(e) = world.run_system_once_with(
460 make_typst_string,
461 (
462 &mut self.typst_output,
463 &calculated_vehicles,
464 width as f32,
465 line_cache
466 .heights
467 .as_ref()
468 .map(|h| h.iter().map(|(_, height)| *height).collect::<Vec<_>>())
469 .as_deref()
470 .unwrap_or(&[]),
471 ),
472 ) {
473 error!("UI Error while exporting diagram to typst: {}", e);
474 }
475 }
476 if ui.button(tr!("copy-to-clipboard")).clicked() {
477 ui.ctx().copy_text(self.typst_output.clone());
478 }
479 ui.label(tr!("tab-diagram-export-typst-diagram-output", {
480 bytes: self.typst_output.len()
481 }));
482 });
483 ui.strong(tr!("tab-diagram-export-typst-timetable"));
484 ui.strong(tr!("tab-diagram-export-typst-timetable-desc"));
485 if ui.button("Export").clicked()
486 && let Err(e) = crate::export::typst_timetable::TypstTimetable.export_to_file(
487 world,
488 (
489 self.state.line_cache.vehicle_entities.iter().cloned(),
490 self.displayed_line_entity,
491 ),
492 )
493 {
494 error!("Error exporting typst timetable: {:?}", e)
495 }
496 }
497 fn id(&self) -> egui::Id {
498 egui::Id::new(self.displayed_line_entity)
499 }
500 fn scroll_bars(&self) -> [bool; 2] {
501 [false; 2]
502 }
503 fn frame(&self) -> egui::Frame {
504 egui::Frame::default().inner_margin(egui::Margin::same(2))
505 }
506}
507
508fn make_typst_string(
509 (InMut(buffer), InRef(calculated_vehicles), In(width), InRef(heights)): (
510 InMut<String>,
511 InRef<[RenderedVehicle]>,
512 In<f32>,
513 InRef<[f32]>,
514 ),
515) {
516 buffer.clear();
517 buffer.push_str(&format!(
518 r#"#set page(width: auto, height: auto)
519#let render-diagram(
520 segments,
521 width: {width}pt,
522 heights: ({}),
523 horizontal_scale: 1,
524 vertical_scale: 1,
525) = box({{
526 for segment in segments {{
527 let (first, ..rest) = segment
528 let first = curve.move((first.at(0) * 1pt * horizontal_scale, first.at(1) * 1pt * vertical_scale))
529 let a = rest.map(((x, y)) => curve.line((x * 1pt * horizontal_scale, y * 1pt * vertical_scale)))
530 place(curve(first, ..a))
531 }}
532 grid(
533 columns: (width * horizontal_scale / 24,) * 24,
534 rows: {{
535 let heights = heights.map(h => h * vertical_scale)
536 let (_, a) = heights.fold((0pt, (0pt,)), ((curr, acc), v) => (v, acc + (v - curr,)))
537 a
538 }},
539 stroke: 1pt,
540 )
541}})
542
543#let segments = (
544"#, heights.iter().map(|h| format!("{}pt", h)).collect::<Vec<_>>().join(", "))
545 );
546 for calculated_vehicle in calculated_vehicles {
547 for segment in calculated_vehicle.segments.iter().map(|s| {
548 s.iter()
549 .flat_map(|(a_pos, d_pos, _entry)| std::iter::once(a_pos).chain(d_pos.iter()))
550 }) {
551 buffer.push_str(" (\n");
552 for point in segment {
553 buffer.push_str(&format!(" ({}, {}),\n", point.x, point.y));
554 }
555 buffer.push_str(" ),\n");
556 }
557 }
558 buffer.push_str("\n)\n#render-diagram(segments)");
559}
560
561fn select_vehicle_set(
562 (InMut(ui), InMut(vehicle_set)): (InMut<egui::Ui>, InMut<Option<Entity>>),
563 vehicle_sets: Query<(Entity, &Name), With<VehicleSet>>,
564) {
565 let displayed_text = vehicle_set.map_or("None", |e| {
566 vehicle_sets
567 .get(e)
568 .map(|(_, name)| name.as_str())
569 .unwrap_or("Unknown")
570 });
571 egui::ComboBox::from_id_salt("vehicle set")
572 .selected_text(displayed_text)
573 .show_ui(ui, |ui| {
574 for (entity, name) in vehicle_sets {
575 ui.selectable_value(vehicle_set, Some(entity), name.as_str());
576 }
577 });
578}
579
580fn show_diagram(
581 (InMut(ui), InMut(state), InRef(rendered_vehicles), InMut(mut painter), InRef(response)): (
582 InMut<egui::Ui>,
583 InMut<DiagramPageCache>,
584 InRef<[RenderedVehicle]>,
585 InMut<Painter>,
586 InRef<response::Response>,
587 ),
588 station_names: Query<&Name, With<Station>>,
589 timetable_entries: Query<(&TimetableEntry, &TimetableEntryCache)>,
590 mut selected_element: ResMut<SelectedElement>,
591 mut timetable_adjustment_writer: MessageWriter<AdjustTimetableEntry>,
592 mut visible_stations_scratch: Local<Vec<(Instance<Station>, f32)>>,
594) {
595 if state.line_cache.line_missing {
596 ui.centered_and_justified(|ui| ui.heading("Diagram not found"));
597 return;
598 };
599
600 ui.style_mut().visuals.menu_corner_radius = CornerRadius::ZERO;
601 ui.style_mut().visuals.window_stroke.width = 0.0;
602
603 let Some(render_context) = state.line_cache.last_render_context.clone() else {
604 return;
605 };
606
607 visible_stations_scratch.clear();
611 visible_stations_scratch
612 .extend_from_slice(state.get_visible_stations(render_context.vertical_visible.clone()));
613 let visible_stations: &[(Instance<Station>, f32)] = visible_stations_scratch.as_slice();
614
615 draw_station_lines(
616 state.vertical_offset,
617 state.stroke,
618 &mut painter,
619 &render_context.screen_rect,
620 state.zoom.y,
621 visible_stations,
622 ui.pixels_per_point(),
623 ui.visuals().text_color(),
624 |e| station_names.get(e).ok().map(|s| s.as_str()),
625 );
626
627 draw_time_lines(
628 state.tick_offset,
629 state.stroke,
630 &mut painter,
631 &render_context.screen_rect,
632 render_context.ticks_per_screen_unit,
633 &render_context.horizontal_visible,
634 ui.pixels_per_point(),
635 );
636
637 if response.clicked()
638 && let Some(pos) = response.interact_pointer_pos()
639 {
640 handle_input_selection(
641 pos,
642 rendered_vehicles,
643 visible_stations,
644 &render_context.screen_rect,
645 state.vertical_offset,
646 state.zoom.y,
647 &mut selected_element,
648 );
649 }
650
651 let background_strength = ui.ctx().animate_bool(
652 ui.id().with("background animation"),
653 selected_element.is_some(),
654 );
655
656 draw_vehicles(&mut painter, rendered_vehicles, &mut selected_element);
657
658 if background_strength > 0.1 {
659 painter.rect_filled(painter.clip_rect(), CornerRadius::ZERO, {
660 let amt = (background_strength * 180.0) as u8;
661 if ui.ctx().theme().default_visuals().dark_mode {
662 Color32::from_black_alpha(amt)
663 } else {
664 Color32::from_white_alpha(amt)
665 }
666 });
667 }
668
669 let show_button = state.zoom.x.min(state.zoom.y) > 0.0002;
670 let button_strength = ui
671 .ctx()
672 .animate_bool(ui.id().with("all buttons animation"), show_button);
673
674 match selected_element.0 {
675 None => {}
676 Some(SelectedEntityType::Vehicle(v)) => {
677 draw_vehicle_selection_overlay(
678 ui,
679 &mut painter,
680 &rendered_vehicles,
681 state,
682 background_strength,
683 button_strength,
684 v,
685 &mut timetable_adjustment_writer,
686 &station_names,
687 &timetable_entries,
688 );
689 }
690 Some(SelectedEntityType::TimetableEntry {
691 entry: _,
692 vehicle: _,
693 }) => {}
694 Some(SelectedEntityType::Interval(i)) => {
695 draw_interval_selection_overlay(
696 ui,
697 background_strength,
698 &mut painter,
699 render_context.screen_rect,
700 state.vertical_offset,
701 state.zoom.y,
702 i,
703 visible_stations,
704 );
705 }
706 Some(SelectedEntityType::Station(s)) => {
707 draw_station_selection_overlay(
708 ui,
709 background_strength,
710 &mut painter,
711 render_context.screen_rect,
712 state.vertical_offset,
713 state.zoom.y,
714 s,
715 visible_stations,
716 );
717 }
718 Some(SelectedEntityType::Map(_)) => {
719 todo!("Do this bro")
720 }
721 }
722}
723
724fn ensure_heights(line_cache: &mut DiagramLineCache, displayed_line: &DisplayedLine) {
725 let mut current_height = 0.0;
726 let mut heights = Vec::new();
727 for (station, distance) in displayed_line.stations() {
728 current_height += distance.abs().log2().max(1.0) * 15f32;
729 heights.push((*station, current_height))
730 }
731 line_cache.heights = Some(heights);
732}
733
734fn calculate_visible_ranges(
735 state: &DiagramPageCache,
736 rect: &Rect,
737) -> (std::ops::Range<f32>, std::ops::Range<i64>, f64) {
738 let vertical_visible =
739 state.vertical_offset..rect.height() / state.zoom.y + state.vertical_offset;
740 let horizontal_visible =
741 state.tick_offset..state.tick_offset + (rect.width() as f64 / state.zoom.x as f64) as i64;
742 let ticks_per_screen_unit =
743 (horizontal_visible.end - horizontal_visible.start) as f64 / rect.width() as f64;
744 (vertical_visible, horizontal_visible, ticks_per_screen_unit)
745}
746
747fn handle_input_selection(
748 pointer_pos: Pos2,
749 rendered_vehicles: &[RenderedVehicle],
750 visible_stations: &[(Instance<Station>, f32)],
751 screen_rect: &Rect,
752 vertical_offset: f32,
753 zoom_y: f32,
754 selected_entity: &mut Option<SelectedEntityType>,
755) {
756 const VEHICLE_SELECTION_RADIUS: f32 = 7.0;
757 const STATION_SELECTION_RADIUS: f32 = VEHICLE_SELECTION_RADIUS;
758 if selected_entity.is_some() {
759 *selected_entity = None;
760 return;
761 };
762 let mut found: Option<SelectedEntityType> = None;
763 'check_selected: for vehicle in rendered_vehicles {
764 for segment in &vehicle.segments {
765 let mut points = segment
766 .iter()
767 .flat_map(|(a_pos, d_pos, ..)| std::iter::once(*a_pos).chain(*d_pos));
768
769 if let Some(mut curr) = points.next() {
770 for next in points {
771 let a = pointer_pos.x - curr.x;
772 let b = pointer_pos.y - curr.y;
773 let c = next.x - curr.x;
774 let d = next.y - curr.y;
775 let dot = a * c + b * d;
776 let len_sq = c * c + d * d;
777 if len_sq == 0.0 {
778 continue;
779 }
780 let t = (dot / len_sq).clamp(0.0, 1.0);
781 let px = curr.x + t * c;
782 let py = curr.y + t * d;
783 let dx = pointer_pos.x - px;
784 let dy = pointer_pos.y - py;
785
786 if dx * dx + dy * dy < VEHICLE_SELECTION_RADIUS.powi(2) {
787 found = Some(SelectedEntityType::Vehicle(vehicle.entity));
788 break 'check_selected;
789 }
790 curr = next;
791 }
792 }
793 }
794 }
795 if found.is_some() {
796 *selected_entity = found;
797 return;
798 }
799 for (station_entity, height) in visible_stations {
801 let y = (*height - vertical_offset) * zoom_y + screen_rect.top();
802 if (y - STATION_SELECTION_RADIUS..y + STATION_SELECTION_RADIUS).contains(&pointer_pos.y) {
803 found = Some(SelectedEntityType::Station(*station_entity));
804 break;
805 }
806 }
807 if found.is_some() {
808 *selected_entity = found;
809 return;
810 }
811 for w in visible_stations.windows(2) {
812 let [(e1, h1), (e2, h2)] = w else {
813 continue;
814 };
815 let y1 = (*h1 - vertical_offset) * zoom_y + screen_rect.top();
816 let y2 = (*h2 - vertical_offset) * zoom_y + screen_rect.top();
817 let (min_y, max_y) = if y1 <= y2 { (y1, y2) } else { (y2, y1) };
818 if (min_y..max_y).contains(&pointer_pos.y) {
819 found = Some(SelectedEntityType::Interval((*e1, *e2)));
820 break;
821 }
822 }
823 if found.is_some() {
824 *selected_entity = found;
825 return;
826 }
827}
828
829fn draw_vehicles(
830 painter: &mut Painter,
831 rendered_vehicles: &[RenderedVehicle],
832 selected_entity: &Option<SelectedEntityType>,
833) {
834 let mut selected_vehicle = None;
835 for vehicle in rendered_vehicles {
836 if selected_vehicle.is_none()
837 && let Some(selected_entity) = selected_entity
838 && matches!(selected_entity, SelectedEntityType::Vehicle(e) if *e == vehicle.entity)
839 {
840 selected_vehicle = Some(vehicle);
841 continue;
842 }
843
844 for segment in &vehicle.segments {
845 let points = segment
846 .iter()
847 .flat_map(|(a, d, ..)| std::iter::once(*a).chain(*d))
848 .collect::<Vec<_>>();
849 painter.line(points, vehicle.stroke);
850 }
851 }
852}
853
854fn draw_vehicle_selection_overlay(
855 ui: &mut Ui,
856 painter: &mut Painter,
857 rendered_vehicles: &[RenderedVehicle],
858 state: &mut DiagramPageCache,
859 line_strength: f32,
860 button_strength: f32,
861 selected_entity: Entity,
862 timetable_adjustment_writer: &mut MessageWriter<AdjustTimetableEntry>,
863 station_names: &Query<&Name, With<Station>>,
864 timetable_entries: &Query<(&TimetableEntry, &TimetableEntryCache)>,
865) {
866 let Some(vehicle) = rendered_vehicles
867 .iter()
868 .find(|v| selected_entity == v.entity)
869 else {
870 return;
871 };
872
873 let mut stroke = vehicle.stroke;
874 stroke.width = line_strength * 3.0 * stroke.width + stroke.width;
875
876 for (line_index, segment) in vehicle.segments.iter().enumerate() {
877 let mut line_vec = Vec::with_capacity(segment.len() * 2);
878
879 for idx in 0..segment.len().saturating_sub(1) {
880 let (arrival_pos, departure_pos, entry_entity) = segment[idx];
881 let (next_arrival_pos, _, next_entry_entity) = segment[idx + 1];
882 let Ok((_, entry_cache)) = timetable_entries.get(entry_entity.inner()) else {
883 continue;
884 };
885 let Ok((next_entry, next_entry_cache)) =
886 timetable_entries.get(next_entry_entity.inner())
887 else {
888 continue;
889 };
890 let signal_stroke = Stroke {
891 width: 1.0 + line_strength,
892 color: if matches!(next_entry.arrival, TravelMode::For(_)) {
893 Color32::BLUE
894 } else {
895 Color32::ORANGE
896 },
897 };
898
899 line_vec.push(arrival_pos);
900 let mut curr_pos = if let Some(d_pos) = departure_pos {
901 line_vec.push(d_pos);
902 d_pos
903 } else {
904 arrival_pos
905 };
906
907 let mut next_pos = next_arrival_pos;
908
909 curr_pos.y += 5.0;
910 next_pos.y += 5.0;
911
912 signal_stroke.round_center_to_pixel(ui.pixels_per_point(), &mut curr_pos.x);
913 signal_stroke.round_center_to_pixel(ui.pixels_per_point(), &mut curr_pos.y);
914 signal_stroke.round_center_to_pixel(ui.pixels_per_point(), &mut next_pos.x);
915 signal_stroke.round_center_to_pixel(ui.pixels_per_point(), &mut next_pos.y);
916
917 let duration = next_entry_cache.estimate.as_ref().unwrap().arrival
918 - entry_cache.estimate.as_ref().unwrap().departure;
919
920 let points = if next_pos.y <= curr_pos.y {
921 vec![curr_pos, Pos2::new(next_pos.x, curr_pos.y), next_pos]
922 } else {
923 vec![curr_pos, Pos2::new(curr_pos.x, next_pos.y), next_pos]
924 };
925 painter.add(Shape::line(points, signal_stroke));
926
927 if duration != Duration(0) {
928 let time = duration.to_hms();
929 let text = format!("{}:{:02}", time.0 * 60 + time.1, time.2);
930 let duration_text = painter.layout_no_wrap(
931 text,
932 egui::FontId::monospace(15.0),
933 signal_stroke.color,
934 );
935 painter.galley(
936 Pos2 {
937 x: (curr_pos.x + next_pos.x - duration_text.size().x) / 2.0,
938 y: curr_pos.y.max(next_pos.y) + 1.0,
939 },
940 duration_text,
941 signal_stroke.color,
942 );
943 }
944 }
945
946 if let Some(last_pos) = segment.last() {
947 line_vec.push(last_pos.0);
948 if let Some(departure) = last_pos.1 {
949 line_vec.push(departure)
950 }
951 }
952 painter.line(line_vec, stroke);
953
954 let mut previous_entry: Option<ActualRouteEntry> = None;
955 if button_strength <= 0.0 {
956 continue;
957 }
958 for fragment in segment
959 .iter()
960 .copied()
961 .filter(|(_, _, a)| matches!(a, ActualRouteEntry::Nominal(_)))
962 {
963 let (mut arrival_pos, maybe_departure_pos, entry_entity) = fragment;
964 let Ok((entry, entry_cache)) = timetable_entries.get(entry_entity.inner()) else {
965 continue;
966 };
967 const HANDLE_SIZE: f32 = 12.0;
968 const CIRCLE_HANDLE_SIZE: f32 = 7.0;
969 const TRIANGLE_HANDLE_SIZE: f32 = 10.0;
970 const DASH_HANDLE_SIZE: f32 = 9.0;
971 let departure_pos: Pos2;
972 if let Some(unwrapped_pos) = maybe_departure_pos {
973 if (arrival_pos.x - unwrapped_pos.x).abs() < HANDLE_SIZE {
974 let midpoint_x = (arrival_pos.x + unwrapped_pos.x) / 2.0;
975 arrival_pos.x = midpoint_x - HANDLE_SIZE / 2.0;
976 let mut pos = unwrapped_pos;
977 pos.x = midpoint_x + HANDLE_SIZE / 2.0;
978 departure_pos = pos;
979 } else {
980 departure_pos = unwrapped_pos;
981 }
982 } else {
983 arrival_pos.x -= HANDLE_SIZE / 2.0;
984 let mut pos = arrival_pos;
985 pos.x += HANDLE_SIZE;
986 departure_pos = pos;
987 };
988 let arrival_point_response =
989 ui.place(Rect::from_pos(arrival_pos).expand(5.2), |ui: &mut Ui| {
990 let (rect, resp) =
991 ui.allocate_exact_size(ui.available_size(), Sense::click_and_drag());
992 ui.scope_builder(
993 UiBuilder::new()
994 .sense(resp.sense)
995 .max_rect(rect)
996 .id(ui.id().with(
997 entry_entity.inner().to_bits() as u128 | (line_index as u128) << 64,
998 )),
999 |ui| {
1000 ui.set_min_size(ui.available_size());
1001 let response = ui.response();
1002 let fill = if response.hovered() {
1003 Color32::GRAY
1004 } else {
1005 Color32::WHITE
1006 }
1007 .linear_multiply(button_strength);
1008 let handle_stroke = Stroke {
1009 width: 2.5,
1010 color: stroke.color.linear_multiply(button_strength),
1011 };
1012 match entry.arrival {
1013 TravelMode::At(_) => buttons::circle_button_shape(
1014 painter,
1015 arrival_pos,
1016 CIRCLE_HANDLE_SIZE,
1017 handle_stroke,
1018 fill,
1019 ),
1020 TravelMode::For(_) => buttons::dash_button_shape(
1021 painter,
1022 arrival_pos,
1023 DASH_HANDLE_SIZE,
1024 handle_stroke,
1025 fill,
1026 ),
1027 TravelMode::Flexible => buttons::triangle_button_shape(
1028 painter,
1029 arrival_pos,
1030 TRIANGLE_HANDLE_SIZE,
1031 handle_stroke,
1032 fill,
1033 ),
1034 };
1035 },
1036 )
1037 .response
1038 });
1039
1040 if arrival_point_response.drag_started() {
1041 state.previous_total_drag_delta = None;
1042 }
1043 if let Some(total_drag_delta) = arrival_point_response.total_drag_delta() {
1044 let previous_drag_delta = state.previous_total_drag_delta.unwrap_or(0.0);
1045 let duration = Duration(
1046 ((total_drag_delta.x as f64 - previous_drag_delta as f64)
1047 / state.zoom.x as f64
1048 / TICKS_PER_SECOND as f64) as i32,
1049 );
1050 if duration != Duration(0) {
1051 timetable_adjustment_writer.write(AdjustTimetableEntry {
1052 entity: entry_entity.inner(),
1053 adjustment: crate::vehicles::TimetableAdjustment::AdjustArrivalTime(
1054 duration,
1055 ),
1056 });
1057 state.previous_total_drag_delta = Some(
1058 previous_drag_delta
1059 + (duration.0 as f64 * TICKS_PER_SECOND as f64 * state.zoom.x as f64)
1060 as f32,
1061 );
1062 }
1063 }
1064 if arrival_point_response.drag_stopped() {
1065 state.previous_total_drag_delta = None;
1066 }
1067 if arrival_point_response.dragged() {
1068 arrival_point_response.show_tooltip_ui(|ui| {
1069 ui.label(entry_cache.estimate.as_ref().unwrap().arrival.to_string());
1070 ui.label(
1071 station_names
1072 .get(entry.station.entity())
1073 .map_or("??", |s| s.as_str()),
1074 );
1075 });
1076 } else {
1077 Popup::menu(&arrival_point_response)
1078 .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
1079 .show(|ui| {
1080 timetable_popup::popup(
1081 entry_entity.inner(),
1082 (entry, entry_cache),
1083 previous_entry.and_then(|e| timetable_entries.get(e.inner()).ok()),
1084 timetable_adjustment_writer,
1085 ui,
1086 true,
1087 );
1088 });
1089 }
1090
1091 let departure_point_response =
1092 ui.put(Rect::from_pos(departure_pos).expand(4.5), |ui: &mut Ui| {
1093 let (rect, resp) = ui.allocate_exact_size(
1094 ui.available_size(),
1095 if matches!(
1096 entry.departure.unwrap_or(TravelMode::Flexible),
1097 TravelMode::Flexible
1098 ) {
1099 Sense::click()
1100 } else {
1101 Sense::click_and_drag()
1102 },
1103 );
1104 ui.scope_builder(
1105 UiBuilder::new()
1106 .sense(resp.sense)
1107 .max_rect(rect)
1108 .id((entry_entity.inner().to_bits() as u128
1109 | (line_index as u128) << 64)
1110 ^ (1 << 127)),
1111 |ui| {
1112 ui.set_min_size(ui.available_size());
1113 let response = ui.response();
1114 let fill = if response.hovered() {
1115 Color32::GRAY
1116 } else {
1117 Color32::WHITE
1118 }
1119 .linear_multiply(button_strength);
1120 let handle_stroke = Stroke {
1121 width: 2.5,
1122 color: stroke.color.linear_multiply(button_strength),
1123 };
1124 match entry.departure {
1125 Some(TravelMode::At(_)) => buttons::circle_button_shape(
1126 painter,
1127 departure_pos,
1128 CIRCLE_HANDLE_SIZE,
1129 handle_stroke,
1130 fill,
1131 ),
1132 Some(TravelMode::For(_)) => buttons::dash_button_shape(
1133 painter,
1134 departure_pos,
1135 DASH_HANDLE_SIZE,
1136 handle_stroke,
1137 fill,
1138 ),
1139 Some(TravelMode::Flexible) => buttons::triangle_button_shape(
1140 painter,
1141 departure_pos,
1142 TRIANGLE_HANDLE_SIZE,
1143 handle_stroke,
1144 fill,
1145 ),
1146 None => buttons::double_triangle(
1147 painter,
1148 departure_pos,
1149 DASH_HANDLE_SIZE,
1150 handle_stroke,
1151 fill,
1152 ),
1153 };
1154 },
1155 )
1156 .response
1157 });
1158
1159 if departure_point_response.drag_started() {
1160 state.previous_total_drag_delta = None;
1161 }
1162 if let Some(total_drag_delta) = departure_point_response.total_drag_delta() {
1163 let previous_drag_delta = state.previous_total_drag_delta.unwrap_or(0.0);
1164 let duration = Duration(
1165 ((total_drag_delta.x as f64 - previous_drag_delta as f64)
1166 / state.zoom.x as f64
1167 / TICKS_PER_SECOND as f64) as i32,
1168 );
1169 if duration != Duration(0) {
1170 timetable_adjustment_writer.write(AdjustTimetableEntry {
1171 entity: entry_entity.inner(),
1172 adjustment: crate::vehicles::TimetableAdjustment::AdjustDepartureTime(
1173 duration,
1174 ),
1175 });
1176 state.previous_total_drag_delta = Some(
1177 previous_drag_delta
1178 + (duration.0 as f64 * TICKS_PER_SECOND as f64 * state.zoom.x as f64)
1179 as f32,
1180 );
1181 }
1182 }
1183 if departure_point_response.drag_stopped() {
1184 state.previous_total_drag_delta = None;
1185 }
1186 if departure_point_response.dragged() {
1187 departure_point_response.show_tooltip_ui(|ui| {
1188 ui.label(entry_cache.estimate.as_ref().unwrap().departure.to_string());
1189 ui.label(
1190 station_names
1191 .get(entry.station.entity())
1192 .map_or("??", |s| s.as_str()),
1193 );
1194 });
1195 } else {
1196 Popup::menu(&departure_point_response)
1197 .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
1198 .show(|ui| {
1199 timetable_popup::popup(
1200 entry_entity.inner(),
1201 (entry, entry_cache),
1202 previous_entry.and_then(|e| timetable_entries.get(e.inner()).ok()),
1203 timetable_adjustment_writer,
1204 ui,
1205 false,
1206 );
1207 });
1208 previous_entry = Some(entry_entity);
1209 }
1210 }
1211 }
1212}
1213
1214fn draw_station_selection_overlay(
1215 _ui: &mut Ui,
1216 strength: f32,
1217 painter: &mut Painter,
1218 screen_rect: Rect,
1219 vertical_offset: f32,
1220 zoom_y: f32,
1221 station_entity: Instance<Station>,
1222 visible_stations: &[(Instance<Station>, f32)],
1223) {
1224 let stations = visible_stations
1225 .iter()
1226 .copied()
1227 .filter_map(|(s, h)| if s == station_entity { Some(h) } else { None });
1228 for station in stations {
1229 let station_height = (station - vertical_offset) * zoom_y + screen_rect.top();
1230 painter.rect(
1231 Rect::from_two_pos(
1232 Pos2 {
1233 x: screen_rect.left(),
1234 y: station_height,
1235 },
1236 Pos2 {
1237 x: screen_rect.right(),
1238 y: station_height,
1239 },
1240 )
1241 .expand2(Vec2 { x: -1.0, y: 7.0 }),
1242 4,
1243 Color32::BLUE.linear_multiply(strength * 0.5),
1244 Stroke::new(1.0, Color32::BLUE.linear_multiply(strength)),
1245 egui::StrokeKind::Middle,
1246 );
1247 }
1248}
1249
1250fn draw_interval_selection_overlay(
1251 _ui: &mut Ui,
1252 strength: f32,
1253 painter: &mut Painter,
1254 screen_rect: Rect,
1255 vertical_offset: f32,
1256 zoom_y: f32,
1257 (s1, s2): (Instance<Station>, Instance<Station>),
1258 visible_stations: &[(Instance<Station>, f32)],
1259) {
1260 for w in visible_stations.windows(2) {
1261 let [(e1, h1), (e2, h2)] = w else { continue };
1262 if !((*e1 == s1 && *e2 == s2) || (*e1 == s2 && *e2 == s1)) {
1263 continue;
1264 }
1265 let station_height_1 = (h1 - vertical_offset) * zoom_y + screen_rect.top();
1266 let station_height_2 = (h2 - vertical_offset) * zoom_y + screen_rect.top();
1267 painter.rect(
1268 Rect::from_two_pos(
1269 Pos2 {
1270 x: screen_rect.left(),
1271 y: station_height_1,
1272 },
1273 Pos2 {
1274 x: screen_rect.right(),
1275 y: station_height_2,
1276 },
1277 )
1278 .expand2(Vec2 { x: -1.0, y: 7.0 }),
1279 4,
1280 Color32::GREEN.linear_multiply(strength * 0.5),
1281 Stroke::new(1.0, Color32::GREEN.linear_multiply(strength)),
1282 egui::StrokeKind::Middle,
1283 );
1284 }
1285}
1286
1287fn draw_station_lines<'a, F>(
1288 vertical_offset: f32,
1289 stroke: Stroke,
1290 painter: &mut Painter,
1291 screen_rect: &Rect,
1292 zoom: f32,
1293 to_draw: &[(Instance<Station>, f32)],
1294 pixels_per_point: f32,
1295 text_color: Color32,
1296 mut get_station_name: F,
1297) where
1298 F: FnMut(Entity) -> Option<&'a str>,
1299{
1300 for (entity, height) in to_draw.iter().copied() {
1302 let mut draw_height = (height - vertical_offset) * zoom + screen_rect.top();
1303 stroke.round_center_to_pixel(pixels_per_point, &mut draw_height);
1304 painter.hline(
1305 screen_rect.left()..=screen_rect.right(),
1306 draw_height,
1307 stroke,
1308 );
1309 let Some(station_name) = get_station_name(*entity) else {
1310 continue;
1311 };
1312 let layout = painter.layout_no_wrap(
1313 station_name.to_string(),
1314 egui::FontId::proportional(13.0),
1315 text_color,
1316 );
1317 let layout_pos = Pos2 {
1318 x: screen_rect.left(),
1319 y: draw_height - layout.size().y,
1320 };
1321 painter.galley(layout_pos, layout, text_color);
1322 }
1323}
1324
1325fn ticks_to_screen_x(
1326 ticks: i64,
1327 screen_rect: &Rect,
1328 ticks_per_screen_unit: f64,
1329 offset_ticks: i64,
1330) -> f32 {
1331 let base = (ticks - offset_ticks) as f64 / ticks_per_screen_unit;
1332 screen_rect.left() + base as f32
1333}
1334
1335fn draw_time_lines(
1337 tick_offset: i64,
1338 stroke: Stroke,
1339 painter: &mut Painter,
1340 screen_rect: &Rect,
1341 ticks_per_screen_unit: f64,
1342 visible_ticks: &std::ops::Range<i64>,
1343 pixels_per_point: f32,
1344) {
1345 const MAX_SCREEN_WIDTH: f64 = 64.0;
1346 const MIN_SCREEN_WIDTH: f64 = 32.0;
1347 const SIZES: &[i64] = &[
1348 TICKS_PER_SECOND * 1, TICKS_PER_SECOND * 10, TICKS_PER_SECOND * 30, TICKS_PER_SECOND * 60, TICKS_PER_SECOND * 60 * 5, TICKS_PER_SECOND * 60 * 10, TICKS_PER_SECOND * 60 * 30, TICKS_PER_SECOND * 60 * 60, TICKS_PER_SECOND * 60 * 60 * 4, TICKS_PER_SECOND * 60 * 60 * 24, ];
1359 let mut drawn: Vec<i64> = Vec::with_capacity(30);
1360
1361 let first_visible_position = SIZES
1363 .iter()
1364 .position(|s| *s as f64 / ticks_per_screen_unit * 1.5 > MIN_SCREEN_WIDTH)
1365 .unwrap_or(0);
1366 let visible = &SIZES[first_visible_position..];
1367 for (i, spacing) in visible.iter().enumerate().rev() {
1368 let first = visible_ticks.start - visible_ticks.start.rem_euclid(*spacing) - spacing;
1369 let mut tick = first;
1370 let strength = (((*spacing as f64 / ticks_per_screen_unit * 1.5) - MIN_SCREEN_WIDTH)
1371 / (MAX_SCREEN_WIDTH - MIN_SCREEN_WIDTH))
1372 .clamp(0.0, 1.0);
1373 if strength < 0.1 {
1374 continue;
1375 }
1376 let mut current_stroke = stroke;
1377 if strength.is_finite() {
1378 current_stroke.color = current_stroke.color.gamma_multiply(strength as f32);
1380 }
1381 current_stroke.width = 0.5;
1382 while tick <= visible_ticks.end {
1383 tick += *spacing;
1384 if drawn.contains(&tick) {
1385 continue;
1386 }
1387 let mut x = ticks_to_screen_x(tick, screen_rect, ticks_per_screen_unit, tick_offset);
1388 current_stroke.round_center_to_pixel(pixels_per_point, &mut x);
1389 painter.vline(x, screen_rect.top()..=screen_rect.bottom(), current_stroke);
1390 drawn.push(tick);
1391 let time = TimetableTime((tick / 100) as i32);
1392 let text = match i + first_visible_position {
1393 0..=2 => time.to_hmsd().2.to_string(),
1394 3..=8 => format!("{}:{:02}", time.to_hmsd().0, time.to_hmsd().1),
1395 _ => time.to_string(),
1396 };
1397 let label = painter.layout_no_wrap(text, FontId::monospace(13.0), current_stroke.color);
1398 painter.galley(
1399 Pos2 {
1400 x: x - label.size().x / 2.0,
1401 y: screen_rect.top(),
1402 },
1403 label,
1404 current_stroke.color,
1405 );
1406 }
1407 }
1408}