paiagram/interface/tabs/
graph.rs

1use super::{Navigatable, Tab};
2use crate::export::ExportObject;
3use crate::graph::{Graph, Interval, Station};
4use crate::lines::DisplayedLine;
5use crate::vehicles::entries::{TimetableEntry, TimetableEntryCache, VehicleScheduleCache};
6use bevy::ecs::entity::{EntityMapper, MapEntities};
7use bevy::prelude::*;
8use egui::{Color32, Painter, Pos2, Rect, Sense, Stroke, Ui, Vec2};
9use egui_i18n::tr;
10use either::Either::{Left, Right};
11use emath::{self, RectTransform};
12use moonshine_core::kind::{InsertInstanceWorld, Instance};
13use petgraph::Direction;
14use petgraph::visit::EdgeRef;
15use serde::{Deserialize, Serialize};
16
17// TODO: display scale on ui.
18// TODO: implement snapping and alignment guides when moving stations
19#[derive(Clone, Serialize, Deserialize)]
20pub struct GraphTab {
21    zoom: f32,
22    translation: Vec2,
23    #[serde(skip)]
24    selected_item: Option<SelectedItem>,
25    #[serde(skip)]
26    edit_mode: Option<EditMode>,
27    animation_counter: f32,
28    animation_playing: bool,
29    iterations: u32,
30    query_region_buffer: String,
31}
32
33#[derive(Debug, Clone, Copy)]
34enum EditMode {
35    EditDisplayedLine(Instance<DisplayedLine>),
36}
37
38impl MapEntities for EditMode {
39    fn map_entities<M: EntityMapper>(&mut self, entity_mapper: &mut M) {
40        match self {
41            EditMode::EditDisplayedLine(line) => line.map_entities(entity_mapper),
42        }
43    }
44}
45
46#[derive(Debug, Clone, Copy)]
47enum SelectedItem {
48    Node(Instance<Station>),
49    Edge(Instance<Interval>),
50    DisplayedLine(Instance<DisplayedLine>),
51}
52
53impl MapEntities for SelectedItem {
54    fn map_entities<M: EntityMapper>(&mut self, entity_mapper: &mut M) {
55        match self {
56            SelectedItem::Node(node) => node.map_entities(entity_mapper),
57            SelectedItem::Edge(edge) => edge.map_entities(entity_mapper),
58            SelectedItem::DisplayedLine(line) => line.map_entities(entity_mapper),
59        }
60    }
61}
62
63impl Default for GraphTab {
64    fn default() -> Self {
65        Self {
66            zoom: 1.0,
67            translation: Vec2::ZERO,
68            selected_item: None,
69            edit_mode: None,
70            animation_playing: false,
71            animation_counter: 0.0,
72            iterations: 3000,
73            query_region_buffer: String::new(),
74        }
75    }
76}
77
78impl MapEntities for GraphTab {
79    fn map_entities<M: EntityMapper>(&mut self, entity_mapper: &mut M) {
80        if let Some(selected_item) = &mut self.selected_item {
81            selected_item.map_entities(entity_mapper);
82        }
83        if let Some(edit_mode) = &mut self.edit_mode {
84            edit_mode.map_entities(entity_mapper);
85        }
86    }
87}
88
89impl Navigatable for GraphTab {
90    fn zoom_x(&self) -> f32 {
91        self.zoom
92    }
93
94    fn zoom_y(&self) -> f32 {
95        self.zoom
96    }
97
98    fn set_zoom(&mut self, zoom_x: f32, _zoom_y: f32) {
99        self.zoom = zoom_x;
100    }
101
102    fn offset_x(&self) -> f64 {
103        self.translation.x as f64
104    }
105
106    fn offset_y(&self) -> f32 {
107        self.translation.y
108    }
109
110    fn set_offset(&mut self, offset_x: f64, offset_y: f32) {
111        self.translation = Vec2::new(offset_x as f32, offset_y);
112    }
113
114    fn clamp_zoom(&self, zoom_x: f32, _zoom_y: f32) -> (f32, f32) {
115        (zoom_x, zoom_x)
116    }
117}
118impl Tab for GraphTab {
119    const NAME: &'static str = "Graph";
120    fn frame(&self) -> egui::Frame {
121        egui::Frame::default().inner_margin(egui::Margin::same(2))
122    }
123    fn main_display(&mut self, world: &mut bevy::ecs::world::World, ui: &mut egui::Ui) {
124        egui::Frame::canvas(&ui.style()).show(ui, |ui| {
125            if let Err(e) = world.run_system_cached_with(show_graph, (ui, self)) {
126                bevy::log::error!("UI Error while displaying graph page: {}", e)
127            }
128        });
129    }
130    fn edit_display(&mut self, world: &mut World, ui: &mut Ui) {
131        let show_spinner = world.contains_resource::<crate::graph::arrange::GraphLayoutTask>();
132        ui.strong(tr!("tab-graph-auto-arrange"));
133        ui.label(tr!("tab-graph-auto-arrange-desc"));
134        ui.add(
135            egui::Slider::new(&mut self.iterations, 100..=10000)
136                .text(tr!("tab-graph-auto-arrange-iterations")),
137        );
138        ui.horizontal(|ui| {
139            if ui.button(tr!("tab-graph-arrange-button")).clicked() {
140                if let Err(e) = world.run_system_cached_with(
141                    crate::graph::arrange::auto_arrange_graph,
142                    (ui.ctx().clone(), self.iterations),
143                ) {
144                    error!("Error while auto-arranging graph: {}", e);
145                }
146            }
147            if show_spinner {
148                ui.add(egui::Spinner::new());
149            };
150        });
151        ui.separator();
152        ui.strong(tr!("tab-graph-arrange-via-osm"));
153        ui.label(tr!("tab-graph-arrange-via-osm-desc"));
154        ui.horizontal(|ui| {
155            if ui.button(tr!("tab-graph-arrange-via-osm-terms")).clicked() {
156                ui.ctx().open_url(egui::OpenUrl {
157                    url: "https://osmfoundation.org/wiki/Terms_of_Use".into(),
158                    new_tab: true,
159                });
160            }
161            if ui.button(tr!("tab-graph-arrange-button")).clicked() {
162                if let Err(e) = world.run_system_cached_with(
163                    crate::graph::arrange::arrange_via_osm,
164                    (
165                        ui.ctx().clone(),
166                        if self.query_region_buffer.is_empty() {
167                            None
168                        } else {
169                            Some(self.query_region_buffer.clone())
170                        },
171                    ),
172                ) {
173                    error!("Error while arranging graph via OSM: {}", e);
174                }
175            }
176            // add a progress bar here
177            if show_spinner {
178                ui.add(egui::Spinner::new());
179            };
180        });
181        ui.horizontal(|ui| {
182            ui.label(tr!("tab-graph-osm-area-name"));
183            ui.text_edit_singleline(&mut self.query_region_buffer);
184        });
185        ui.strong(tr!("tab-graph-animation"));
186        ui.label(tr!("tab-graph-animation-desc"));
187        ui.horizontal(|ui| {
188            if ui
189                .button(if self.animation_playing { "⏸" } else { "►" })
190                .clicked()
191            {
192                self.animation_playing = !self.animation_playing;
193            }
194            if ui.button("⏮").clicked() {
195                self.animation_counter = 0.0;
196            }
197            ui.add(
198                egui::Slider::new(
199                    &mut self.animation_counter,
200                    (-86400.0 * 2.0)..=(86400.0 * 2.0),
201                )
202                .text("Time"),
203            );
204        });
205        ui.strong(tr!("tab-graph-underlay-image"));
206        ui.label(tr!("tab-graph-underlay-image-desc"));
207        match self.selected_item {
208            None => {
209                ui.group(|ui| {
210                    ui.label(tr!("tab-graph-new-displayed-line-desc"));
211                    if !ui.button(tr!("tab-graph-new-displayed-line")).clicked() {
212                        return;
213                    }
214                    let new_displayed_line = world
215                        .spawn((Name::new(tr!("new-displayed-line")),))
216                        .insert_instance(DisplayedLine::new(vec![]))
217                        .into();
218                    self.edit_mode = Some(EditMode::EditDisplayedLine(new_displayed_line));
219                    self.selected_item = Some(SelectedItem::DisplayedLine(new_displayed_line));
220                });
221            }
222            Some(SelectedItem::DisplayedLine(e)) => {
223                ui.group(|ui| {
224                    if let Err(e) = world.run_system_cached_with(display_displayed_line, (ui, e)) {
225                        bevy::log::error!("UI Error while displaying displayed line editor: {}", e)
226                    }
227                    if ui.button(tr!("done")).clicked() {
228                        // check if the displayed line is empty
229                        // if so, delete it
230                        if let Ok((_, line)) = world
231                            .query::<(&Name, &DisplayedLine)>()
232                            .get(world, e.entity())
233                        {
234                            if line.stations().is_empty() {
235                                world.entity_mut(e.entity()).despawn();
236                            }
237                        }
238                        self.edit_mode = None;
239                        self.selected_item = None;
240                    }
241                });
242            }
243            _ => {}
244        }
245    }
246    fn title(&self) -> egui::WidgetText {
247        tr!("tab-graph").into()
248    }
249    fn export_display(&mut self, world: &mut World, ui: &mut egui::Ui) {
250        let mut buffer = String::with_capacity(32768);
251        if ui.button("Export Graph as DOT file").clicked()
252            && let Err(e) = crate::export::graphviz::Graphviz.export_to_file(world, ())
253        {
254            error!("Error while exporting graph as DOT file: {:?}", e)
255        }
256    }
257    fn scroll_bars(&self) -> [bool; 2] {
258        [false; 2]
259    }
260}
261
262fn display_displayed_line(
263    (InMut(ui), In(entity)): (InMut<Ui>, In<Instance<DisplayedLine>>),
264    displayed_lines: Query<(&Name, &DisplayedLine)>,
265    stations: Query<&Name, With<Station>>,
266) {
267    let Ok((name, line)) = displayed_lines.get(entity.entity()) else {
268        return;
269    };
270    ui.heading(name.as_str());
271    for (i, (station_entity, _)) in line.stations().iter().enumerate() {
272        let Some(station_name) = stations.get(station_entity.entity()).ok() else {
273            continue;
274        };
275        ui.horizontal(|ui| {
276            ui.label(format!("{}.", i + 1));
277            ui.label(station_name.as_str());
278        });
279    }
280}
281
282fn draw_line_spline(
283    painter: &egui::Painter,
284    to_screen: RectTransform,
285    viewport: Rect,
286    stations_list: &[(Instance<Station>, f32)],
287    stations: &Query<(&Name, &Station)>,
288) {
289    let n = stations_list.len();
290    if n < 2 {
291        return;
292    }
293
294    // Find the range of visible stations to optimize rendering
295    let mut first_visible = None;
296    let mut last_visible = None;
297    for (i, (entity, _)) in stations_list.iter().enumerate() {
298        if let Ok((_, s)) = stations.get(entity.entity()) {
299            if viewport.expand(100.0).contains(to_screen * s.0) {
300                if first_visible.is_none() {
301                    first_visible = Some(i);
302                }
303                last_visible = Some(i);
304            }
305        }
306    }
307
308    let (Some(start_idx), Some(end_idx)) = (first_visible, last_visible) else {
309        return;
310    };
311
312    // Expand the range by 3 points on each side as requested
313    let render_start = start_idx.saturating_sub(3);
314    let render_end = (end_idx + 3).min(n - 1);
315
316    let mut previous = stations
317        .get(stations_list[render_start].0.entity())
318        .map(|(_, s)| to_screen * s.0)
319        .unwrap_or(Pos2::ZERO);
320
321    for i in render_start..render_end {
322        let p1_world = stations
323            .get(stations_list[i].0.entity())
324            .map(|(_, s)| s.0)
325            .unwrap_or(Pos2::ZERO);
326        let p2_world = stations
327            .get(stations_list[i + 1].0.entity())
328            .map(|(_, s)| s.0)
329            .unwrap_or(Pos2::ZERO);
330
331        let p0 = if i > 0 {
332            to_screen
333                * stations
334                    .get(stations_list[i - 1].0.entity())
335                    .map(|(_, s)| s.0)
336                    .unwrap_or(Pos2::ZERO)
337        } else {
338            to_screen * p1_world
339        };
340        let p1 = to_screen * p1_world;
341        let p2 = to_screen * p2_world;
342        let p3 = if i + 2 < n {
343            to_screen
344                * stations
345                    .get(stations_list[i + 2].0.entity())
346                    .map(|(_, s)| s.0)
347                    .unwrap_or(Pos2::ZERO)
348        } else {
349            p2
350        };
351
352        let num_samples =
353            ((p3.distance(p2) + p2.distance(p1) + p1.distance(p0)) as usize / 20).max(1);
354
355        let v0 = bevy::math::Vec2::new(p0.x, p0.y);
356        let v1 = bevy::math::Vec2::new(p1.x, p1.y);
357        let v2 = bevy::math::Vec2::new(p2.x, p2.y);
358        let v3 = bevy::math::Vec2::new(p3.x, p3.y);
359
360        for j in 1..=num_samples {
361            let t = j as f32 / num_samples as f32;
362            let t2 = t * t;
363            let t3 = t2 * t;
364            let pos_v = 0.5
365                * ((2.0 * v1)
366                    + (-v0 + v2) * t
367                    + (2.0 * v0 - 5.0 * v1 + 4.0 * v2 - v3) * t2
368                    + (-v0 + 3.0 * v1 - 3.0 * v2 + v3) * t3);
369            let pos = Pos2::new(pos_v.x, pos_v.y);
370            painter.line_segment([previous, pos], Stroke::new(4.0, Color32::LIGHT_YELLOW));
371            previous = pos;
372        }
373    }
374}
375
376fn show_graph(
377    (InMut(ui), mut state): (InMut<egui::Ui>, InMut<GraphTab>),
378    graph: Res<Graph>,
379    mut displayed_lines: Query<(Instance<DisplayedLine>, &mut DisplayedLine)>,
380    mut stations: Query<(&Name, &mut Station)>,
381    schedules: Query<&VehicleScheduleCache>,
382    timetable_entries: Query<(&TimetableEntry, &TimetableEntryCache)>,
383    time: Res<Time>,
384) {
385    if state.animation_playing {
386        state.animation_counter += time.delta_secs() * 10.0;
387        ui.ctx().request_repaint();
388    }
389    const EDGE_OFFSET: f32 = 10.0;
390    let selected_strength = ui.ctx().animate_bool(
391        ui.id().with("background animation"),
392        state.selected_item.is_some(),
393    );
394    let selected_strength_ease = ui.ctx().animate_bool_with_time_and_easing(
395        ui.id().with("selected item animation"),
396        state.selected_item.is_some(),
397        0.2,
398        emath::easing::quadratic_out,
399    );
400    let mut focused_pos: Option<(Pos2, Pos2)> = None;
401    // Iterate over the graph and see what's in it
402    // Draw lines between stations with shifted positions
403    let (response, painter) =
404        ui.allocate_painter(ui.available_size_before_wrap(), Sense::click_and_drag());
405    state.handle_navigation(ui, &response);
406    let world_rect = Rect::from_min_size(
407        Pos2::new(state.translation.x, state.translation.y),
408        Vec2::new(
409            response.rect.width() / state.zoom,
410            response.rect.height() / state.zoom,
411        ),
412    );
413    if response.clicked() && !state.edit_mode.is_some() {
414        state.selected_item = None;
415    }
416    let to_screen = RectTransform::from_to(world_rect, response.rect);
417    draw_world_grid(&painter, response.rect, state.translation, state.zoom);
418    // draw edges
419    for (from, to, _weight) in graph.inner().node_indices().flat_map(|n| {
420        graph
421            .inner()
422            .edges_directed(n, Direction::Outgoing)
423            .map(|a| {
424                (
425                    graph.entity(a.source()).unwrap(),
426                    graph.entity(a.target()).unwrap(),
427                    a.weight(),
428                )
429            })
430    }) {
431        let Ok((_, from_station)) = stations.get(from.entity()) else {
432            continue;
433        };
434        let Ok((_, to_station)) = stations.get(to.entity()) else {
435            continue;
436        };
437        let from = from_station.0;
438        let to = to_station.0;
439        // shift the two points to its left by EDGE_OFFSET pixels
440        let direction = (to - from).normalized();
441        let angle = direction.y.atan2(direction.x) + std::f32::consts::FRAC_PI_2;
442        let offset = Vec2::new(angle.cos(), angle.sin()) * EDGE_OFFSET / state.zoom;
443        let from = from + offset;
444        let to = to + offset;
445        painter.line_segment(
446            [to_screen * from, to_screen * to],
447            Stroke::new(1.0, Color32::LIGHT_BLUE),
448        );
449    }
450    // draw nodes after edges
451    for node in graph
452        .inner()
453        .node_indices()
454        .map(|n| graph.entity(n).unwrap())
455    {
456        let Ok((name, mut station)) = stations.get_mut(node.entity()) else {
457            continue;
458        };
459        let pos = &mut station.0;
460        let galley = painter.layout_no_wrap(
461            name.to_string(),
462            egui::FontId::proportional(13.0),
463            ui.visuals().text_color(),
464        );
465        painter.galley(
466            {
467                let pos = to_screen * *pos;
468                let offset = Vec2::new(15.0, -galley.size().y / 2.0);
469                pos + offset
470            },
471            galley,
472            ui.visuals().text_color(),
473        );
474        ui.place(
475            Rect::from_pos(to_screen * *pos).expand(10.0),
476            |ui: &mut Ui| {
477                let (_rect, resp) =
478                    ui.allocate_exact_size(ui.available_size(), Sense::click_and_drag());
479                let fill = if resp.hovered() {
480                    Color32::YELLOW
481                } else {
482                    Color32::LIGHT_GREEN
483                };
484                match (state.edit_mode, resp.clicked()) {
485                    (_, false) => {}
486                    (None, true) => {
487                        state.selected_item = Some(SelectedItem::Node(node));
488                    }
489                    (Some(EditMode::EditDisplayedLine(e)), true) => {
490                        if let Ok((_, mut line)) = displayed_lines.get_mut(e.entity()) {
491                            if let Err(e) = line.push((node, 0.0)) {
492                                error!("Failed to add station to line: {:?}", e);
493                            }
494                        }
495                    }
496                }
497                if matches!(state.selected_item, Some(SelectedItem::Node(n)) if n == node) {
498                    focused_pos = Some((*pos, Pos2::ZERO));
499                }
500                ui.painter().circle_filled(to_screen * *pos, 10.0, fill);
501                if resp.dragged() {
502                    *pos += resp.drag_delta() / state.zoom;
503                }
504                resp
505            },
506        );
507    }
508
509    let stations_readonly = stations.as_readonly();
510    displayed_lines
511        .as_readonly()
512        .par_iter()
513        .for_each(|(_line_entity, line)| {
514            draw_line_spline(
515                &painter,
516                to_screen,
517                response.rect,
518                line.stations(),
519                &stations_readonly,
520            );
521        });
522
523    if state.animation_playing {
524        for section in schedules
525            .iter()
526            .filter_map(|s| s.position(state.animation_counter, |e| timetable_entries.get(e).ok()))
527        {
528            match section {
529                Left((from_entity, to_entity, progress)) => {
530                    let Ok((_, from_station)) = stations.get(from_entity) else {
531                        continue;
532                    };
533                    let Ok((_, to_station)) = stations.get(to_entity) else {
534                        continue;
535                    };
536                    let from_pos = to_screen * from_station.0;
537                    let to_pos = to_screen * to_station.0;
538                    // shift the from and to positions to its left by EDGE_OFFSET pixels
539                    let direction = (to_pos - from_pos).normalized();
540                    let angle = direction.y.atan2(direction.x) + std::f32::consts::FRAC_PI_2;
541                    let offset = Vec2::new(angle.cos(), angle.sin()) * EDGE_OFFSET;
542                    let from_pos = from_pos + offset;
543                    let to_pos = to_pos + offset;
544                    painter.circle_filled(
545                        from_pos.lerp(to_pos, progress),
546                        6.0,
547                        Color32::from_rgb(100, 200, 100),
548                    );
549                }
550                Right(_station_pos) => {}
551            };
552        }
553    }
554    painter.rect_filled(response.rect, 0, {
555        let amt = (selected_strength * 180.0) as u8;
556        if ui.ctx().theme().default_visuals().dark_mode {
557            Color32::from_black_alpha(amt)
558        } else {
559            Color32::from_white_alpha(amt)
560        }
561    });
562    if let (Some(SelectedItem::Node(_)), Some((station_pos, _))) =
563        (state.selected_item, focused_pos)
564    {
565        painter.circle(
566            to_screen * station_pos,
567            12.0 + 10.0 * (1.0 - selected_strength_ease),
568            Color32::RED.gamma_multiply(0.5 * selected_strength_ease),
569            Stroke::new(2.0, Color32::RED.gamma_multiply(selected_strength_ease)),
570        );
571        painter.circle_filled(to_screen * station_pos, 10.0, Color32::LIGHT_RED);
572    }
573}
574
575fn draw_world_grid(painter: &Painter, viewport: Rect, offset: Vec2, zoom: f32) {
576    if zoom <= 0.0 {
577        return;
578    }
579
580    // Transitions like diagram.rs: Linear fade between MIN and MAX screen spacing
581    const MIN_WIDTH: f32 = 32.0;
582    const MAX_WIDTH: f32 = 120.0;
583
584    // Use a neutral gray without querying visuals
585    let base_color = Color32::from_gray(160);
586
587    for p in ((-5)..=5).rev() {
588        let spacing = 10.0f32.powi(p);
589        let screen_spacing = spacing * zoom;
590
591        // Strength calculation identical to diagram.rs (1.5 scaling factor)
592        let strength =
593            ((screen_spacing * 1.5 - MIN_WIDTH) / (MAX_WIDTH - MIN_WIDTH)).clamp(0.0, 1.0);
594        if strength <= 0.0 {
595            continue;
596        }
597
598        let stroke = Stroke::new(0.6, base_color.gamma_multiply(strength));
599
600        // Vertical lines
601        let mut n = (offset.x / spacing).floor();
602        loop {
603            let world_x = n * spacing;
604            let screen_x_rel = (world_x - offset.x) * zoom;
605            if screen_x_rel > viewport.width() {
606                break;
607            }
608            if screen_x_rel >= 0.0 {
609                painter.vline(viewport.left() + screen_x_rel, viewport.y_range(), stroke);
610            }
611            n += 1.0;
612        }
613
614        // Horizontal lines
615        let mut m = (offset.y / spacing).floor();
616        loop {
617            let world_y = m * spacing;
618            let screen_y_rel = (world_y - offset.y) * zoom;
619            if screen_y_rel > viewport.height() {
620                break;
621            }
622            if screen_y_rel >= 0.0 {
623                painter.hline(viewport.x_range(), viewport.top() + screen_y_rel, stroke);
624            }
625            m += 1.0;
626        }
627    }
628}