perspective_viewer/components/form/
multi_stop_gradient_selector.rs1use itertools::Itertools;
14use wasm_bindgen::JsCast;
15use web_sys::*;
16use yew::prelude::*;
17
18use crate::config::{GradientStopSpec, canonicalize_gradient_stops};
19
20#[derive(Properties, PartialEq)]
23pub struct MultiStopGradientProps {
24 pub stops: Vec<GradientStopSpec>,
25 pub on_change: Callback<Vec<GradientStopSpec>>,
26 pub on_reset: Callback<()>,
27 pub is_modified: bool,
28
29 #[prop_or_default]
30 pub discrete: bool,
31
32 #[prop_or_default]
33 pub title: Option<String>,
34}
35
36fn event_index(target: &Element) -> Option<usize> {
37 target
38 .get_attribute("data-index")
39 .and_then(|x| x.parse::<usize>().ok())
40}
41
42fn parse_hex_color(color: &str) -> Option<(f64, f64, f64)> {
43 let hex = color.strip_prefix('#')?;
44 if hex.len() != 6 {
45 return None;
46 }
47
48 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
49 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
50 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
51 Some((r as f64, g as f64, b as f64))
52}
53
54pub(crate) fn sample_gradient_hex(stops: &[GradientStopSpec], t: f64) -> String {
55 let Some(first) = stops.first() else {
56 return "#000000".to_owned();
57 };
58
59 if t <= first.offset {
60 return first.color.clone();
61 }
62
63 let last = stops.last().unwrap();
64 if t >= last.offset {
65 return last.color.clone();
66 }
67
68 for pair in stops.windows(2) {
69 let (a, b) = (&pair[0], &pair[1]);
70 if t > b.offset {
71 continue;
72 }
73
74 let span = b.offset - a.offset;
75 let u = if span > 0.0 {
76 (t - a.offset) / span
77 } else {
78 0.0
79 };
80 let nearer = if u < 0.5 { a } else { b };
81 let (Some((ar, ag, ab)), Some((br, bg, bb))) =
82 (parse_hex_color(&a.color), parse_hex_color(&b.color))
83 else {
84 return nearer.color.clone();
85 };
86
87 let scale = |x: f64, y: f64| ((x + (y - x) * u).round().clamp(0.0, 255.0)) as u8;
88 return format!(
89 "#{:02x}{:02x}{:02x}",
90 scale(ar, br),
91 scale(ag, bg),
92 scale(ab, bb)
93 );
94 }
95
96 last.color.clone()
97}
98
99const EDGE_PAD_PX: f64 = 8.0;
100
101fn track_position(offset: f64) -> String {
102 format!(
103 "calc({EDGE_PAD_PX}px + (100% - {:.0}px) * {offset:.4})",
104 EDGE_PAD_PX * 2.0
105 )
106}
107
108fn css_gradient(stops: &[GradientStopSpec]) -> String {
109 let body = stops
110 .iter()
111 .sorted_by(|a, b| {
112 a.offset
113 .partial_cmp(&b.offset)
114 .unwrap_or(std::cmp::Ordering::Equal)
115 })
116 .map(|stop| format!("{} {}", stop.color, track_position(stop.offset)))
117 .join(", ");
118
119 format!("background:linear-gradient(to right, {body})")
120}
121
122fn equalized_offset(stops: &[GradientStopSpec], index: usize) -> f64 {
123 let left = if index == 0 {
124 0.0
125 } else {
126 stops[index - 1].offset
127 };
128
129 let right = if index + 1 < stops.len() {
130 stops[index + 1].offset
131 } else {
132 1.0
133 };
134
135 (left + right) / 2.0
136}
137
138fn offset_from_pointer(bar: &Option<Element>, client_x: i32) -> Option<f64> {
139 let rect = bar.as_ref()?.get_bounding_client_rect();
140 let track_width = rect.width() - 2.0 * EDGE_PAD_PX;
141 if track_width <= 0.0 {
142 return None;
143 }
144
145 Some(((client_x as f64 - rect.left() - EDGE_PAD_PX) / track_width).clamp(0.0, 1.0))
146}
147
148#[function_component(MultiStopGradientSelector)]
149pub fn multi_stop_gradient_selector(props: &MultiStopGradientProps) -> Html {
150 let bar_ref = use_node_ref();
151
152 let drag = use_state(|| Option::<(usize, f64)>::None);
153
154 let display = {
155 let mut stops = props.stops.clone();
156 if let Some((index, offset)) = *drag
157 && let Some(stop) = stops.get_mut(index)
158 {
159 stop.offset = offset;
160 }
161
162 stops
163 };
164
165 let on_color = use_callback(
166 (props.stops.clone(), props.on_change.clone()),
167 |event: InputEvent, (stops, on_change)| {
168 let input = event.target().unwrap().unchecked_into::<HtmlInputElement>();
169
170 let Some(index) = event_index(&input) else {
171 return;
172 };
173
174 let mut next = stops.clone();
175 let Some(stop) = next.get_mut(index) else {
176 return;
177 };
178
179 stop.color = input.value();
180 on_change.emit(canonicalize_gradient_stops(next));
181 },
182 );
183
184 let on_pointerdown = {
185 let drag = drag.clone();
186 let stops = props.stops.clone();
187 Callback::from(move |event: PointerEvent| {
188 let Some(target) = event.target().and_then(|x| x.dyn_into::<Element>().ok()) else {
189 return;
190 };
191
192 let Some(index) = event_index(&target) else {
193 return;
194 };
195
196 let Some(stop) = stops.get(index) else {
197 return;
198 };
199
200 let _ = target.set_pointer_capture(event.pointer_id());
201 drag.set(Some((index, stop.offset)));
202 })
203 };
204
205 let on_pointermove = {
206 let drag = drag.clone();
207 let bar_ref = bar_ref.clone();
208 Callback::from(move |event: PointerEvent| {
209 let Some((index, _)) = *drag else {
210 return;
211 };
212
213 let Some(offset) = offset_from_pointer(&bar_ref.cast::<Element>(), event.client_x())
214 else {
215 return;
216 };
217
218 drag.set(Some((index, offset)));
219 })
220 };
221
222 let on_pointerup = {
223 let drag = drag.clone();
224 let bar_ref = bar_ref.clone();
225 let stops = props.stops.clone();
226 let on_change = props.on_change.clone();
227 Callback::from(move |event: PointerEvent| {
228 let Some((index, offset)) = *drag else {
229 return;
230 };
231
232 let offset =
233 offset_from_pointer(&bar_ref.cast::<Element>(), event.client_x()).unwrap_or(offset);
234
235 drag.set(None);
236
237 let unchanged = stops
238 .get(index)
239 .map(|stop| (stop.offset * 1000.0).round() == (offset * 1000.0).round())
240 .unwrap_or(true);
241
242 if unchanged {
243 return;
244 }
245
246 let mut next = stops.clone();
247 if let Some(stop) = next.get_mut(index) {
248 stop.offset = offset;
249 }
250
251 on_change.emit(canonicalize_gradient_stops(next));
252 })
253 };
254
255 let on_grip_dblclick = use_callback(
256 (props.stops.clone(), props.on_change.clone()),
257 |event: MouseEvent, (stops, on_change)| {
258 event.stop_propagation();
259 let Some(target) = event.target().and_then(|x| x.dyn_into::<Element>().ok()) else {
260 return;
261 };
262
263 let Some(index) = event_index(&target) else {
264 return;
265 };
266
267 if index >= stops.len() {
268 return;
269 }
270
271 let mut next = stops.clone();
272 next[index].offset = equalized_offset(stops, index);
273 on_change.emit(canonicalize_gradient_stops(next));
274 },
275 );
276
277 let on_remove = use_callback(
278 (props.stops.clone(), props.on_change.clone()),
279 |event: MouseEvent, (stops, on_change)| {
280 let Some(target) = event.target().and_then(|x| x.dyn_into::<Element>().ok()) else {
281 return;
282 };
283
284 let Some(index) = event_index(&target) else {
285 return;
286 };
287
288 if stops.len() > 2 && index < stops.len() {
289 let mut next = stops.clone();
290 next.remove(index);
291 on_change.emit(canonicalize_gradient_stops(next));
292 }
293 },
294 );
295
296 let can_add = !props.discrete;
297
298 let on_dblclick = {
299 let bar_ref = bar_ref.clone();
300 let stops = props.stops.clone();
301 let on_change = props.on_change.clone();
302 Callback::from(move |event: MouseEvent| {
303 if !can_add {
304 return;
305 }
306
307 let Some(bar) = bar_ref.cast::<Element>() else {
308 return;
309 };
310
311 if event.target() != Some(bar.clone().unchecked_into()) {
312 return;
313 }
314
315 let Some(offset) = offset_from_pointer(&Some(bar), event.client_x()) else {
316 return;
317 };
318
319 let mut next = stops.clone();
320 next.push(GradientStopSpec {
321 color: sample_gradient_hex(&next, offset),
322 offset,
323 });
324
325 on_change.emit(canonicalize_gradient_stops(next));
326 })
327 };
328
329 let on_reset = use_callback(props.on_reset.clone(), |_: MouseEvent, on_reset| {
330 on_reset.emit(())
331 });
332
333 let can_remove = props.stops.len() > 2;
334 let can_drag = !props.discrete;
335 let pinned = props.discrete && !can_remove;
336 html! {
337 <>
338 <label id={props.title.as_deref().unwrap_or("gradient-label").to_owned()} />
339 <div class="color-gradient-container gradient-stops-selector">
340 <div
341 class={if props.discrete {"gradient-stops-bar discrete"} else {"gradient-stops-bar"}}
342 ref={bar_ref}
343 style={css_gradient(&display)}
344 onclick={on_dblclick}
345 >
346 { for display.iter().enumerate().map(|(index, stop)| html! {
347 <div
348 class="gradient-stop-handle"
349 key={index.to_string()}
350 style={format!("left:{}", track_position(stop.offset))}
351 >
352 <span
353 class={classes!("gradient-stop-grip", (!can_drag).then_some("disabled"))}
354 data-index={index.to_string()}
355 onpointerdown={can_drag.then(|| on_pointerdown.clone())}
356 onpointermove={can_drag.then(|| on_pointermove.clone())}
357 onpointerup={can_drag.then(|| on_pointerup.clone())}
358 ondblclick={can_drag.then(|| on_grip_dblclick.clone())}
359 />
360 if pinned {
361 <span class="gradient-stop-lock" />
362 } else {
363 <span
364 class={classes!("gradient-stop-remove", (!can_remove).then_some("disabled"))}
365 data-index={index.to_string()}
366 onclick={can_remove.then(|| on_remove.clone())}
367 />
368 }
369 <input
370 class="parameter"
371 type="color"
372 value={stop.color.to_owned()}
373 data-index={index.to_string()}
374 oninput={on_color.clone()}
375 />
376 </div>
377 }) }
378 </div>
379 if props.is_modified {
380 <span class="reset-default-style" onclick={on_reset} />
381 } else {
382 <span class="reset-default-style-disabled" />
383 }
384 </div>
385 </>
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 fn stop(color: &str, offset: f64) -> GradientStopSpec {
394 GradientStopSpec {
395 color: color.to_owned(),
396 offset,
397 }
398 }
399
400 #[test]
401 fn sample_clamps_outside_stop_range() {
402 let stops = vec![stop("#000000", 0.25), stop("#ff0000", 0.75)];
403 assert_eq!(sample_gradient_hex(&stops, 0.0), "#000000");
404 assert_eq!(sample_gradient_hex(&stops, 1.0), "#ff0000");
405 }
406
407 #[test]
408 fn sample_interpolates_linearly() {
409 let stops = vec![stop("#000000", 0.0), stop("#ff0000", 1.0)];
410 assert_eq!(sample_gradient_hex(&stops, 0.5), "#800000");
411
412 let stops = vec![
413 stop("#000000", 0.0),
414 stop("#ffffff", 0.5),
415 stop("#ff0000", 1.0),
416 ];
417
418 assert_eq!(sample_gradient_hex(&stops, 0.25), "#808080");
419 assert_eq!(sample_gradient_hex(&stops, 0.75), "#ff8080");
420 }
421
422 #[test]
423 fn track_positions_pad_the_bar_edges() {
424 assert_eq!(track_position(0.0), "calc(8px + (100% - 16px) * 0.0000)");
425 assert_eq!(track_position(1.0), "calc(8px + (100% - 16px) * 1.0000)");
426 assert_eq!(track_position(0.333), "calc(8px + (100% - 16px) * 0.3330)");
427 }
428
429 #[test]
430 fn gradient_interpolates_on_padded_track() {
431 let stops = vec![
432 stop("#333333", 1.0),
433 stop("#111111", 0.0),
434 stop("#222222", 0.5),
435 ];
436
437 assert_eq!(
438 css_gradient(&stops),
439 format!(
440 "background:linear-gradient(to right, #111111 {}, #222222 {}, #333333 {})",
441 track_position(0.0),
442 track_position(0.5),
443 track_position(1.0)
444 )
445 );
446 }
447
448 #[test]
449 fn equalize_centers_between_neighbors_or_edges() {
450 let stops = vec![
451 stop("#111111", 0.2),
452 stop("#222222", 0.25),
453 stop("#333333", 0.4),
454 ];
455
456 assert_eq!(equalized_offset(&stops, 1), (0.2 + 0.4) / 2.0);
457
458 assert_eq!(equalized_offset(&stops, 0), 0.125);
459 assert_eq!(equalized_offset(&stops, 2), (0.25 + 1.0) / 2.0);
460
461 let single = vec![stop("#111111", 0.9)];
462 assert_eq!(equalized_offset(&single, 0), 0.5);
463 }
464
465 #[test]
466 fn sample_degenerate_cases() {
467 assert_eq!(sample_gradient_hex(&[], 0.5), "#000000");
468 let stops = vec![stop("#123456", 0.5)];
469 assert_eq!(sample_gradient_hex(&stops, 0.1), "#123456");
470 assert_eq!(sample_gradient_hex(&stops, 0.9), "#123456");
471
472 let stops = vec![stop("red", 0.0), stop("blue", 1.0)];
473 assert_eq!(sample_gradient_hex(&stops, 0.25), "red");
474 assert_eq!(sample_gradient_hex(&stops, 0.75), "blue");
475 }
476}