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#[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 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 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 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 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 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 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 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 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 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 const MIN_WIDTH: f32 = 32.0;
582 const MAX_WIDTH: f32 = 120.0;
583
584 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 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 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 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}