Skip to main content

visualization/
colorbar.rs

1//! Rainbow contour legend displayed as a bevy_ui overlay in the bottom-right
2//! corner when a result field is active.
3//!
4//! The legend consists of:
5//! * A field name label at the top.
6//! * A vertical gradient bar built from [`SEGMENT_COUNT`] coloured
7//!   [`Node`] rectangles (blue at bottom → red at top).
8//! * Min/max value labels at the bottom and top of the bar.
9//!
10//! The whole widget is hidden when no result is active.
11
12use bevy::prelude::*;
13use fem_core::{FemResultSet, rainbow_color};
14
15pub const SEGMENT_COUNT: usize = 20;
16const BAR_W: f32 = 18.0;
17const BAR_H: f32 = 200.0;
18const SEG_H: f32 = BAR_H / SEGMENT_COUNT as f32;
19
20// ─── components ──────────────────────────────────────────────────────────────
21
22#[derive(Component)]
23pub struct ColorbagRoot;
24
25#[derive(Component)]
26pub struct ColorbarTitle;
27
28#[derive(Component)]
29pub struct ColorbarMaxLabel;
30
31#[derive(Component)]
32pub struct ColorbarMinLabel;
33
34/// Marks one colour segment of the colorbar gradient.
35/// Index used to restore the gradient after leaving a constant field.
36#[derive(Component)]
37#[allow(dead_code)]
38pub struct ColorbarSegment(pub usize);
39
40// ─── spawn ───────────────────────────────────────────────────────────────────
41
42/// Spawns the colorbar overlay (initially hidden).
43pub fn spawn_colorbar(mut commands: Commands) {
44    commands
45        .spawn((
46            Node {
47                position_type: PositionType::Absolute,
48                right: Val::Px(18.0),
49                bottom: Val::Px(18.0),
50                flex_direction: FlexDirection::Column,
51                align_items: AlignItems::Center,
52                row_gap: Val::Px(4.0),
53                ..default()
54            },
55            Visibility::Hidden,
56            ColorbagRoot,
57            Name::new("ColorbarRoot"),
58        ))
59        .with_children(|root| {
60            // Field name
61            root.spawn((
62                Text::new(""),
63                TextFont {
64                    font_size: FontSize::Px(11.5),
65                    ..default()
66                },
67                TextColor(Color::srgb(0.82, 0.90, 0.95)),
68                ColorbarTitle,
69            ));
70
71            // Max value
72            root.spawn((
73                Text::new(""),
74                TextFont {
75                    font_size: FontSize::Px(10.5),
76                    ..default()
77                },
78                TextColor(Color::srgb(0.75, 0.82, 0.88)),
79                ColorbarMaxLabel,
80            ));
81
82            // Colour segments (index 0 = top = high value = red)
83            root.spawn((
84                Node {
85                    width: Val::Px(BAR_W),
86                    height: Val::Px(BAR_H),
87                    flex_direction: FlexDirection::Column,
88                    border: UiRect::all(Val::Px(1.0)),
89                    ..default()
90                },
91                BorderColor::all(Color::srgba(0.30, 0.36, 0.40, 0.70)),
92                BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.0)),
93            ))
94            .with_children(|bar| {
95                for i in 0..SEGMENT_COUNT {
96                    // i=0 → top → t=1.0 (red), i=N-1 → bottom → t=0.0 (blue)
97                    let t = 1.0 - i as f32 / (SEGMENT_COUNT - 1) as f32;
98                    let c = rainbow_color(t);
99
100                    bar.spawn((
101                        Node {
102                            width: Val::Percent(100.0),
103                            height: Val::Px(SEG_H),
104                            ..default()
105                        },
106                        BackgroundColor(Color::LinearRgba(c)),
107                        ColorbarSegment(i),
108                    ));
109                }
110            });
111
112            // Min value
113            root.spawn((
114                Text::new(""),
115                TextFont {
116                    font_size: FontSize::Px(10.5),
117                    ..default()
118                },
119                TextColor(Color::srgb(0.75, 0.82, 0.88)),
120                ColorbarMinLabel,
121            ));
122        });
123}
124
125// ─── update system ───────────────────────────────────────────────────────────
126
127/// Shows/hides the colorbar and updates its labels whenever the active
128/// result field changes.
129pub fn update_colorbar(
130    results: Res<FemResultSet>,
131    range_mode: Option<Res<crate::ContourRangeMode>>,
132    geometry: Option<Res<fem_core::ResultGeometry>>,
133    mut root_query: Query<&mut Visibility, With<ColorbagRoot>>,
134    mut title_query: Query<
135        &mut Text,
136        (
137            With<ColorbarTitle>,
138            Without<ColorbarMaxLabel>,
139            Without<ColorbarMinLabel>,
140        ),
141    >,
142    mut max_query: Query<
143        &mut Text,
144        (
145            With<ColorbarMaxLabel>,
146            Without<ColorbarTitle>,
147            Without<ColorbarMinLabel>,
148        ),
149    >,
150    mut min_query: Query<
151        &mut Text,
152        (
153            With<ColorbarMinLabel>,
154            Without<ColorbarTitle>,
155            Without<ColorbarMaxLabel>,
156        ),
157    >,
158    mut segments: Query<(&ColorbarSegment, &mut BackgroundColor)>,
159) {
160    if !results.is_changed()
161        && !geometry.as_ref().is_some_and(|g| g.is_changed())
162        && !range_mode.as_ref().is_some_and(|r| r.is_changed())
163    {
164        return;
165    }
166
167    let Ok(mut vis) = root_query.single_mut() else {
168        return;
169    };
170    if geometry.as_ref().is_some_and(|g| !g.visible) {
171        *vis = Visibility::Hidden;
172        return;
173    }
174
175    let Some(field) = results.active_field() else {
176        *vis = Visibility::Hidden;
177        return;
178    };
179
180    *vis = Visibility::Visible;
181
182    let mode = range_mode.as_deref().copied().unwrap_or_default();
183    let Some((min, max)) = crate::contour_range::resolve(&results, mode) else {
184        *vis = Visibility::Hidden;
185        return;
186    };
187    let constant = (min == max).then_some(min);
188    for (segment, mut color) in &mut segments {
189        let t = if constant.is_some() {
190            0.5
191        } else {
192            1.0 - segment.0 as f32 / (SEGMENT_COUNT - 1) as f32
193        };
194        color.set_if_neq(BackgroundColor(Color::LinearRgba(rainbow_color(t))));
195    }
196
197    if let Ok(mut text) = title_query.single_mut() {
198        **text = format!(
199            "{}\n{}",
200            field.name(),
201            if mode == crate::ContourRangeMode::AllFrames {
202                "All frames"
203            } else {
204                "Current frame"
205            }
206        );
207    }
208    if let Ok(mut text) = max_query.single_mut() {
209        **text = constant.map_or_else(|| format!("{max:.4e}"), |v| format!("Constant: {v:.4e}"));
210    }
211    if let Ok(mut text) = min_query.single_mut() {
212        **text = if constant.is_some() {
213            String::new()
214        } else {
215            format!("{min:.4e}")
216        };
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use fem_core::{ActiveResult, ResultField, StepResult};
224    #[test]
225    fn constant_legend_matches_surface_and_restores_gradient() {
226        let mut app = App::new();
227        app.insert_resource(FemResultSet {
228            by_mesh: vec![vec![StepResult {
229                fields: vec![ResultField::NodeScalar {
230                    name: "Velocity[2]".into(),
231                    values: vec![0.; 2],
232                    min: 0.,
233                    max: 0.,
234                }],
235                ..default()
236            }]],
237            active: Some(ActiveResult {
238                mesh_index: 0,
239                step_index: 0,
240                field_name: "Velocity[2]".into(),
241            }),
242        })
243        .add_systems(Startup, spawn_colorbar)
244        .add_systems(Update, update_colorbar);
245        app.update();
246        for (_, c) in app
247            .world_mut()
248            .query::<(&ColorbarSegment, &BackgroundColor)>()
249            .iter(app.world())
250        {
251            assert_eq!(c.0, Color::LinearRgba(rainbow_color(0.5)));
252        }
253        let max_label = app
254            .world_mut()
255            .query_filtered::<Entity, With<ColorbarMaxLabel>>()
256            .single(app.world())
257            .unwrap();
258        assert!(
259            app.world()
260                .get::<Text>(max_label)
261                .unwrap()
262                .0
263                .contains("Constant")
264        );
265        app.world_mut().resource_mut::<FemResultSet>().by_mesh[0][0].fields[0] =
266            ResultField::NodeScalar {
267                name: "Velocity[2]".into(),
268                values: vec![0., 0.001],
269                min: 0.,
270                max: 0.001,
271            };
272        app.update();
273        assert!(
274            !app.world()
275                .get::<Text>(max_label)
276                .unwrap()
277                .0
278                .contains("Constant")
279        );
280        for (s, c) in app
281            .world_mut()
282            .query::<(&ColorbarSegment, &BackgroundColor)>()
283            .iter(app.world())
284        {
285            assert_eq!(
286                c.0,
287                Color::LinearRgba(rainbow_color(1. - s.0 as f32 / (SEGMENT_COUNT - 1) as f32))
288            );
289        }
290        // Changing only the view policy must update the legend immediately.
291        let future = StepResult {
292            fields: vec![ResultField::NodeScalar {
293                name: "Velocity[2]".into(),
294                values: vec![10.],
295                min: 10.,
296                max: 10.,
297            }],
298            ..default()
299        };
300        app.world_mut().resource_mut::<FemResultSet>().by_mesh[0].push(future);
301        app.update();
302        app.insert_resource(crate::ContourRangeMode::AllFrames);
303        app.update();
304        assert_eq!(
305            app.world().get::<Text>(max_label).unwrap().0,
306            format!("{:.4e}", 10.)
307        );
308        app.world_mut()
309            .resource_mut::<FemResultSet>()
310            .active
311            .as_mut()
312            .unwrap()
313            .step_index = 1;
314        app.update();
315        assert!(
316            !app.world()
317                .get::<Text>(max_label)
318                .unwrap()
319                .0
320                .contains("Constant")
321        );
322        *app.world_mut().resource_mut::<crate::ContourRangeMode>() =
323            crate::ContourRangeMode::CurrentFrame;
324        app.update();
325        assert!(
326            app.world()
327                .get::<Text>(max_label)
328                .unwrap()
329                .0
330                .contains("Constant")
331        );
332        app.world_mut().resource_mut::<FemResultSet>().active = None;
333        app.update();
334        let vis = app
335            .world_mut()
336            .query_filtered::<&Visibility, With<ColorbagRoot>>()
337            .single(app.world())
338            .unwrap();
339        assert_eq!(*vis, Visibility::Hidden);
340    }
341}