1use 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#[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#[derive(Component)]
37#[allow(dead_code)]
38pub struct ColorbarSegment(pub usize);
39
40pub 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 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 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 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 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 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
125pub 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 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}