paiagram/interface/tabs/
diagram.rs

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