1use leptos::ev::MouseEvent;
10use leptos::html::Div;
11use leptos::leptos_dom::helpers::window_event_listener;
12use leptos::prelude::*;
13use leptos::tachys::view::any_view::AnyView;
14use std::sync::Arc;
15
16use wasm_bindgen::JsCast;
17
18use crate::axis::{nice_y_ticks_capped, pad_y_max, select_x_indices};
19use crate::hover::{HoverState, pixel_to_index};
20use crate::path::{area_path, line_path};
21use crate::scale::{index_to_x, value_to_y};
22use crate::series::Series;
23use crate::zoom::{ZoomRange, commit_drag};
24
25const VIEW_W: f64 = 800.0;
29const VIEW_H: f64 = 280.0;
30const MAX_X_LABELS: usize = 7;
33const TOOLTIP_FLIP_FRACTION: f64 = 0.65;
36
37pub type TooltipSlot = Arc<dyn Fn(usize) -> AnyView + Send + Sync>;
40
41pub type ZoomCommit = Arc<dyn Fn(usize, usize) + Send + Sync>;
51
52pub type YFormat = Arc<dyn Fn(f64) -> String + Send + Sync>;
57
58#[component]
60pub fn AreaChart<T, FxLabel, FyValues>(
61 #[prop(into)]
64 data: Signal<Vec<T>>,
65 x_label: FxLabel,
67 y_values: FyValues,
70 series: Vec<Series>,
73 #[prop(default = 320)]
75 height: u32,
76 #[prop(default = true)]
78 legend: bool,
79 #[prop(optional)]
83 tooltip: Option<TooltipSlot>,
84 #[prop(optional)]
89 on_zoom: Option<ZoomCommit>,
90 #[prop(default = String::new(), into)]
92 class: String,
93 #[prop(optional)]
97 y_format: Option<YFormat>,
98 #[prop(optional)]
103 crosshair: Option<RwSignal<Option<usize>>>,
104 #[prop(optional)]
109 pinned: Option<RwSignal<Option<usize>>>,
110) -> impl IntoView
111where
112 T: Clone + Send + Sync + 'static,
113 FxLabel: Fn(&T) -> String + Send + Sync + 'static + Copy,
114 FyValues: Fn(&T) -> Vec<f64> + Send + Sync + 'static + Copy,
115{
116 let series_for_model = series.clone();
117 let render = Memo::new(move |_| {
118 data.with(|rows| build_model(rows, x_label, y_values, &series_for_model))
119 });
120
121 let hover = RwSignal::new(Option::<HoverState>::None);
122 let live_idx = crosshair.unwrap_or_else(|| RwSignal::new(None));
126 let pinned_idx = pinned;
127 if let Some(p) = pinned_idx {
132 let click_handle =
133 window_event_listener(leptos::ev::mousedown, move |ev: web_sys::MouseEvent| {
134 if !event_in_plot_area(&ev) {
135 p.set(None);
136 }
137 });
138 let key_handle =
140 window_event_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
141 if ev.key() == "Escape" {
142 p.set(None);
143 }
144 });
145 on_cleanup(move || {
146 click_handle.remove();
147 key_handle.remove();
148 });
149 }
150 let plot_ref: NodeRef<Div> = NodeRef::new();
151
152 let zoom: RwSignal<Option<ZoomRange>> = RwSignal::new(None);
157 let drag_start: RwSignal<Option<usize>> = RwSignal::new(None);
158 let drag_end: RwSignal<Option<usize>> = RwSignal::new(None);
159
160 let overrides: RwSignal<std::collections::HashMap<String, String>> =
165 RwSignal::new(std::collections::HashMap::new());
166
167 let hidden: RwSignal<std::collections::HashSet<String>> =
171 RwSignal::new(std::collections::HashSet::new());
172
173 let outer_class = format!("charts-root {class}");
174 let style_attr = move || {
178 let mut s = format!("--charts-height: {height}px;");
179 overrides.with(|map| {
180 for (color_class, hex) in map {
181 if !is_hex_color(hex) {
186 continue;
187 }
188 let soft = soften_hex(hex);
193 s.push_str(&format!(" --charts-series-{color_class}: {hex};"));
194 s.push_str(&format!(" --charts-series-{color_class}-soft: {soft};"));
195 }
196 });
197 s
198 };
199
200 view! {
201 <div class=outer_class style=style_attr>
202 { (legend).then(|| view! { <Legend series=series.clone() overrides=overrides hidden=hidden /> }) }
203 { move || {
204 let m = filter_hidden(render.get(), &hidden.get());
205 if m.empty {
206 return view! {
207 <div class="charts-empty">"No data in window."</div>
208 }.into_any();
209 }
210 let z = zoom.get();
215 let displayed = slice_model(&m, z);
216 let from_offset = z.map_or(0, |r| r.from_index);
217 view_chart_body(
218 displayed,
219 from_offset,
220 hover,
221 live_idx,
222 pinned_idx,
223 drag_start,
224 drag_end,
225 zoom,
226 plot_ref,
227 tooltip.clone(),
228 on_zoom.clone(),
229 y_format.clone(),
230 height,
231 ).into_any()
232 } }
233 </div>
234 }
235}
236
237#[component]
238#[allow(
239 clippy::implicit_hasher,
240 reason = "we own the signals end-to-end; consumers never construct the collections themselves"
241)]
242fn Legend(
243 series: Vec<Series>,
244 overrides: RwSignal<std::collections::HashMap<String, String>>,
245 hidden: RwSignal<std::collections::HashSet<String>>,
246) -> impl IntoView {
247 view! {
248 <div class="charts-legend">
249 { series.into_iter().map(|s| {
250 let color_class = s.color_class;
251 let dot_class = format!("charts-legend-dot charts-series-{color_class}");
252 let label_for = format!("charts-color-{color_class}");
253 let input_id = label_for.clone();
254 let initial_value = "#4262ff".to_owned();
260 let cc_input = color_class.clone();
261 let on_input = move |ev: leptos::ev::Event| {
262 let target = event_target_value(&ev);
263 if !is_hex_color(&target) {
270 return;
271 }
272 overrides.update(|map| {
273 map.insert(cc_input.clone(), target);
274 });
275 };
276 let cc_toggle = color_class.clone();
279 let on_toggle = move |_| {
280 hidden.update(|h| {
281 if !h.remove(&cc_toggle) {
282 h.insert(cc_toggle.clone());
283 }
284 });
285 };
286 let cc_class = color_class;
287 let is_hidden = move || hidden.with(|h| h.contains(&cc_class));
288 view! {
289 <div class="charts-legend-item" class:is-hidden=is_hidden>
290 <label class="charts-legend-swatch" for=label_for title="Click to change color">
291 <span class=dot_class></span>
292 <input
293 class="charts-legend-color-input"
294 id=input_id
295 type="color"
296 value=initial_value
297 on:input=on_input
298 />
299 </label>
300 <span
301 class="charts-legend-name"
302 title="Click to show / hide this series"
303 on:click=on_toggle
304 >{ s.name }</span>
305 </div>
306 }
307 }).collect_view() }
308 </div>
309 }
310}
311
312fn is_hex_color(s: &str) -> bool {
317 let h = s.trim().trim_start_matches('#');
318 matches!(h.len(), 3 | 6) && h.chars().all(|c| c.is_ascii_hexdigit())
319}
320
321fn soften_hex(hex: &str) -> String {
326 let h = hex.trim().trim_start_matches('#');
328 let (r, g, b) = match h.len() {
329 3 => (
330 u8::from_str_radix(&h[0..1].repeat(2), 16).ok(),
331 u8::from_str_radix(&h[1..2].repeat(2), 16).ok(),
332 u8::from_str_radix(&h[2..3].repeat(2), 16).ok(),
333 ),
334 6 => (
335 u8::from_str_radix(&h[0..2], 16).ok(),
336 u8::from_str_radix(&h[2..4], 16).ok(),
337 u8::from_str_radix(&h[4..6], 16).ok(),
338 ),
339 _ => (None, None, None),
340 };
341 match (r, g, b) {
342 (Some(r), Some(g), Some(b)) => format!("rgba({r}, {g}, {b}, 0.45)"),
343 _ => hex.to_owned(),
344 }
345}
346
347#[allow(
348 clippy::too_many_arguments,
349 reason = "all signals/refs are co-owned by AreaChart; threading through a struct adds noise without changing the surface"
350)]
351#[allow(
352 clippy::too_many_lines,
353 reason = "deliberately co-located: signals + handlers + sub-view bindings + the final view! flow naturally as one unit; further extraction hurts readability more than it helps"
354)]
355fn view_chart_body(
356 model: RenderModel,
357 from_offset: usize,
358 hover: RwSignal<Option<HoverState>>,
359 live_idx: RwSignal<Option<usize>>,
360 pinned_idx: Option<RwSignal<Option<usize>>>,
361 drag_start: RwSignal<Option<usize>>,
362 drag_end: RwSignal<Option<usize>>,
363 zoom: RwSignal<Option<ZoomRange>>,
364 plot_ref: NodeRef<Div>,
365 tooltip: Option<TooltipSlot>,
366 on_zoom: Option<ZoomCommit>,
367 y_format: Option<YFormat>,
368 chart_height: u32,
369) -> impl IntoView {
370 let BodyLayout {
371 y_max,
372 y_grid,
373 x_axis_ticks,
374 series_paths,
375 view_box,
376 n_points,
377 series_for_dots,
378 } = BodyLayout::from_model(model, y_format.as_ref(), chart_height);
379
380 let resolve_idx = make_resolve_idx(plot_ref, n_points);
381 let on_mousemove = move |ev: MouseEvent| {
382 let Some((idx, cx, cy)) = resolve_idx(&ev) else {
383 return;
384 };
385 hover.set(Some(HoverState {
386 index: idx,
387 client_x: cx,
388 client_y: cy,
389 }));
390 live_idx.set(Some(idx));
391 if drag_start.with(Option::is_some) {
392 drag_end.set(Some(idx));
393 }
394 };
395 let on_mousedown = move |ev: MouseEvent| {
396 if ev.button() != 0 {
397 return;
398 }
399 let Some((idx, _, _)) = resolve_idx(&ev) else {
400 return;
401 };
402 drag_start.set(Some(idx));
403 drag_end.set(Some(idx));
404 };
405 let on_zoom_for_mouseup = on_zoom;
406 let on_mouseup = move |ev: MouseEvent| {
407 let Some(start) = drag_start.get() else {
408 return;
409 };
410 let end = resolve_idx(&ev).map_or(start, |(i, _, _)| i);
411 drag_start.set(None);
412 drag_end.set(None);
413 if start == end {
416 if let Some(p) = pinned_idx {
417 p.update(|cur| {
418 *cur = if *cur == Some(start) {
419 None
420 } else {
421 Some(start)
422 }
423 });
424 }
425 return;
426 }
427 live_idx.set(None);
428 if let Some(p) = pinned_idx {
431 p.set(None);
432 }
433 commit_zoom_drag(
434 start,
435 end,
436 n_points,
437 from_offset,
438 hover,
439 zoom,
440 on_zoom_for_mouseup.as_ref(),
441 );
442 };
443 let on_mouseleave = move |_: MouseEvent| {
444 hover.set(None);
445 live_idx.set(None);
446 drag_start.set(None);
447 drag_end.set(None);
448 };
449 let on_reset = move |_: MouseEvent| {
450 zoom.set(None);
451 drag_start.set(None);
452 drag_end.set(None);
453 hover.set(None);
454 live_idx.set(None);
455 if let Some(p) = pinned_idx {
456 p.set(None);
457 }
458 };
459
460 let live_x = move || live_idx.get().map(|i| index_to_x(i, n_points, VIEW_W));
466 let pinned_x = move || {
467 pinned_idx
468 .and_then(|p| p.get())
469 .map(|i| index_to_x(i, n_points, VIEW_W))
470 };
471 let live_dots = make_dots_closure(
472 move || live_idx.get(),
473 series_for_dots.clone(),
474 y_max,
475 n_points,
476 );
477 let pinned_dots = make_dots_closure(
478 move || pinned_idx.and_then(|p| p.get()),
479 series_for_dots,
480 y_max,
481 n_points,
482 );
483 let pinned_tooltip_view =
484 make_pinned_tooltip_closure(pinned_idx, tooltip.clone(), n_points, from_offset);
485 let tooltip_view = make_tooltip_closure(hover, tooltip, plot_ref, from_offset);
486 let zoom_band = make_zoom_band_closure(drag_start, drag_end, n_points);
487 let reset_visible = move || zoom.with(Option::is_some);
488
489 let color_classes: Vec<String> = series_paths
490 .iter()
491 .map(|sp| sp.color_class.clone())
492 .collect();
493
494 view! {
495 <div class="charts-plot">
496 <div class="charts-y-axis">
497 <div class="charts-y-axis-scale">
503 { y_axis_view(y_grid.clone()) }
504 </div>
505 </div>
506 <div
507 class="charts-plot-area"
508 node_ref=plot_ref
509 on:mousemove=on_mousemove
510 on:mousedown=on_mousedown
511 on:mouseup=on_mouseup
512 on:mouseleave=on_mouseleave
513 >
514 <svg
515 class="charts-svg"
516 viewBox=view_box
517 preserveAspectRatio="none"
518 >
519 <defs>{ svg_defs_view(color_classes) }</defs>
520 <g class="charts-grid">{ svg_grid_view(y_grid) }</g>
521 { svg_series_view(series_paths) }
522 { move || zoom_band().map(zoom_band_rect) }
523 </svg>
524 <div class="charts-crosshair-overlay">
532 { move || pinned_x().map(crosshair_line_pinned) }
533 { move || live_x().map(crosshair_line) }
534 </div>
535 { dots_overlay_view(pinned_dots) }
536 { dots_overlay_view(live_dots) }
537 { x_axis_view(x_axis_ticks) }
538 { move || pinned_tooltip_view().map(tooltip_card_pinned) }
539 { move || tooltip_view().map(tooltip_card) }
540 { move || reset_visible().then(|| reset_button(on_reset)) }
541 </div>
542 </div>
543 }
544}
545
546fn y_axis_view(y_grid: Vec<(f64, String)>) -> impl IntoView {
547 let top_y = y_grid.iter().map(|(y, _)| *y).fold(f64::INFINITY, f64::min);
553 let bottom_y = y_grid
554 .iter()
555 .map(|(y, _)| *y)
556 .fold(f64::NEG_INFINITY, f64::max);
557 y_grid
558 .into_iter()
559 .map(|(y, label)| {
560 let shift = if (y - top_y).abs() < 0.5 {
561 "translateY(0)"
562 } else if (y - bottom_y).abs() < 0.5 {
563 "translateY(-100%)"
564 } else {
565 "translateY(-50%)"
566 };
567 let style = format!("top: {:.2}%; transform: {shift};", (y / VIEW_H) * 100.0);
568 view! { <div class="charts-y-tick" style=style>{ label }</div> }
569 })
570 .collect_view()
571}
572
573fn svg_defs_view(color_classes: Vec<String>) -> impl IntoView {
574 color_classes
575 .into_iter()
576 .map(|color_class| {
577 let id = format!("charts-grad-{color_class}");
578 let cls = format!("charts-gradient charts-series-{color_class}");
579 view! {
580 <linearGradient id=id class=cls x1="0" y1="0" x2="0" y2="1">
581 <stop offset="0%" class="charts-gradient-top"></stop>
582 <stop offset="100%" class="charts-gradient-bottom"></stop>
583 </linearGradient>
584 }
585 })
586 .collect_view()
587}
588
589fn svg_grid_view(y_grid: Vec<(f64, String)>) -> impl IntoView {
590 y_grid
591 .into_iter()
592 .map(|(y, _)| {
593 view! { <line class="charts-grid-line" x1="0" x2=VIEW_W y1=y y2=y /> }
594 })
595 .collect_view()
596}
597
598fn svg_series_view(series_paths: Vec<SeriesPaths>) -> impl IntoView {
599 let paths = series_paths
600 .into_iter()
601 .map(|sp| {
602 let area_class = format!("charts-area charts-series-{}", sp.color_class);
603 let line_class = format!("charts-line charts-series-{}", sp.color_class);
604 let fill = format!("url(#charts-grad-{})", sp.color_class);
605 view! {
606 <g class="charts-series-paths">
607 <path class=area_class d=sp.area_d fill=fill></path>
608 <path class=line_class d=sp.line_d fill="none"></path>
609 </g>
610 }
611 })
612 .collect_view();
613 view! { <g class="charts-series-group">{ paths }</g> }
614}
615
616fn x_axis_view(x_axis_ticks: Vec<(f64, String)>) -> impl IntoView {
617 let ticks = x_axis_ticks
618 .into_iter()
619 .map(|(x, label)| {
620 let style = x_tick_style((x / VIEW_W) * 100.0);
621 view! { <div class="charts-x-tick" style=style>{ label }</div> }
622 })
623 .collect_view();
624 view! { <div class="charts-x-axis">{ ticks }</div> }
625}
626
627fn x_tick_style(pct: f64) -> String {
631 if pct >= 99.5 {
632 "right: 0; left: auto; transform: none;".to_owned()
633 } else if pct <= 0.5 {
634 "left: 0; transform: none;".to_owned()
635 } else {
636 format!("left: {pct:.2}%;")
637 }
638}
639
640fn dots_overlay_view(
641 dots: impl Fn() -> Option<Vec<DotPos>> + Send + Sync + 'static,
642) -> impl IntoView {
643 view! {
648 <div class="charts-dots-overlay">
649 { move || dots().map(|ds| ds.into_iter().map(dot_view).collect_view()) }
650 </div>
651 }
652}
653
654fn dot_view(d: DotPos) -> impl IntoView {
655 let cls = format!("charts-dot charts-series-{}", d.color_class);
656 let style = format!(
657 "left: {:.2}%; top: {:.2}%;",
658 (d.x / VIEW_W) * 100.0,
659 (d.y / VIEW_H) * 100.0,
660 );
661 view! { <div class=cls style=style></div> }
662}
663
664fn zoom_band_rect(band: ZoomBand) -> impl IntoView {
665 view! {
666 <rect class="charts-zoom-band" x=band.x y=0 width=band.width height=VIEW_H />
667 }
668}
669
670fn event_in_plot_area(ev: &web_sys::MouseEvent) -> bool {
674 ev.target()
675 .and_then(|t| t.dyn_into::<web_sys::Element>().ok())
676 .and_then(|el| el.closest(".charts-plot-area").ok().flatten())
677 .is_some()
678}
679
680fn crosshair_line(x: f64) -> impl IntoView {
681 let style = format!("left: {:.3}%;", (x / VIEW_W) * 100.0);
682 view! { <div class="charts-crosshair" style=style></div> }
683}
684
685fn crosshair_line_pinned(x: f64) -> impl IntoView {
686 let style = format!("left: {:.3}%;", (x / VIEW_W) * 100.0);
687 view! { <div class="charts-crosshair charts-crosshair-pinned" style=style></div> }
688}
689
690fn tooltip_card(tip: TooltipView) -> impl IntoView {
691 view! { <div class="charts-tooltip" style=tip.style>{ tip.inner }</div> }
692}
693
694fn tooltip_card_pinned(tip: TooltipView) -> impl IntoView {
695 view! { <div class="charts-tooltip charts-tooltip-pinned" style=tip.style>{ tip.inner }</div> }
696}
697
698fn reset_button(on_reset: impl Fn(MouseEvent) + 'static) -> impl IntoView {
699 view! {
700 <button
701 class="charts-zoom-reset"
702 type="button"
703 title="Reset zoom"
704 on:click=on_reset
705 >
706 "Reset zoom"
707 </button>
708 }
709}
710
711#[derive(Clone, Debug, PartialEq)]
713struct RenderModel {
714 labels: Vec<String>,
715 series: Vec<SeriesValues>,
716 y_max: f64,
717 empty: bool,
718}
719
720#[derive(Clone, Debug, PartialEq)]
721struct SeriesValues {
722 name: String,
723 color_class: String,
724 values: Vec<f64>,
725}
726
727#[derive(Clone)]
728struct SeriesPaths {
729 color_class: String,
730 area_d: String,
731 line_d: String,
732}
733
734#[derive(Clone)]
735struct DotPos {
736 x: f64,
737 y: f64,
738 color_class: String,
739}
740
741fn build_model<T, FxLabel, FyValues>(
742 data: &[T],
743 x_label: FxLabel,
744 y_values: FyValues,
745 series_meta: &[Series],
746) -> RenderModel
747where
748 FxLabel: Fn(&T) -> String,
749 FyValues: Fn(&T) -> Vec<f64>,
750{
751 if data.is_empty() || series_meta.is_empty() {
752 return RenderModel {
753 labels: Vec::new(),
754 series: Vec::new(),
755 y_max: 0.0,
756 empty: true,
757 };
758 }
759
760 let labels: Vec<String> = data.iter().map(&x_label).collect();
761 let mut series_values: Vec<SeriesValues> = series_meta
762 .iter()
763 .map(|s| SeriesValues {
764 name: s.name.clone(),
765 color_class: s.color_class.clone(),
766 values: Vec::with_capacity(data.len()),
767 })
768 .collect();
769
770 for row in data {
771 let ys = y_values(row);
772 for (i, slot) in series_values.iter_mut().enumerate() {
773 slot.values.push(ys.get(i).copied().unwrap_or(0.0));
774 }
775 }
776
777 let y_max = series_values
778 .iter()
779 .flat_map(|s| s.values.iter().copied())
780 .fold(0.0_f64, f64::max);
781
782 RenderModel {
783 labels,
784 series: series_values,
785 y_max,
786 empty: false,
787 }
788}
789
790fn filter_hidden(
795 mut model: RenderModel,
796 hidden: &std::collections::HashSet<String>,
797) -> RenderModel {
798 if hidden.is_empty() {
799 return model;
800 }
801 model.series.retain(|s| !hidden.contains(&s.color_class));
802 model.y_max = model
803 .series
804 .iter()
805 .flat_map(|s| s.values.iter().copied())
806 .fold(0.0_f64, f64::max);
807 model
808}
809
810fn slice_model(model: &RenderModel, zoom: Option<ZoomRange>) -> RenderModel {
815 let Some(z) = zoom else {
816 return model.clone();
817 };
818 let n = model.labels.len();
819 if n == 0 || z.from_index >= n {
820 return model.clone();
821 }
822 let to = z.to_index.min(n - 1);
823 let from = z.from_index.min(to);
824 let labels = model.labels[from..=to].to_vec();
825 let series: Vec<SeriesValues> = model
826 .series
827 .iter()
828 .map(|s| SeriesValues {
829 name: s.name.clone(),
830 color_class: s.color_class.clone(),
831 values: s.values[from..=to].to_vec(),
832 })
833 .collect();
834 let y_max = series
835 .iter()
836 .flat_map(|s| s.values.iter().copied())
837 .fold(0.0_f64, f64::max);
838 RenderModel {
839 labels,
840 series,
841 y_max,
842 empty: false,
843 }
844}
845
846fn format_y_tick(v: f64) -> String {
847 if v.fract().abs() < 1e-9 {
848 format!("{:.0}", v.round())
849 } else {
850 format!("{v:.1}")
851 }
852}
853
854struct BodyLayout {
859 y_max: f64,
860 y_grid: Vec<(f64, String)>,
861 x_axis_ticks: Vec<(f64, String)>,
862 series_paths: Vec<SeriesPaths>,
863 view_box: String,
864 n_points: usize,
865 series_for_dots: Vec<SeriesValues>,
866}
867
868impl BodyLayout {
869 fn from_model(model: RenderModel, y_format: Option<&YFormat>, chart_height: u32) -> Self {
870 let padded_max = pad_y_max(model.y_max);
874 let max_ticks = ((chart_height / 40) as usize).clamp(3, 6);
877 let y_ticks = nice_y_ticks_capped(padded_max, max_ticks);
878 let y_max = y_ticks
890 .last()
891 .copied()
892 .map_or(padded_max, |top| top.max(padded_max));
893 let n_points = model.labels.len();
894 let y_grid = y_ticks
895 .iter()
896 .copied()
897 .map(|t| {
898 let label = y_format.map_or_else(|| format_y_tick(t), |f| f(t));
899 (value_to_y(t, y_max, VIEW_H), label)
900 })
901 .collect();
902 let x_axis_ticks = select_x_indices(n_points, MAX_X_LABELS)
903 .into_iter()
904 .map(|i| {
905 (
906 index_to_x(i, n_points, VIEW_W),
907 model.labels.get(i).cloned().unwrap_or_default(),
908 )
909 })
910 .collect();
911 let series_paths = model
912 .series
913 .iter()
914 .map(|s| SeriesPaths {
915 color_class: s.color_class.clone(),
916 area_d: area_path(&s.values, y_max, VIEW_W, VIEW_H),
917 line_d: line_path(&s.values, y_max, VIEW_W, VIEW_H),
918 })
919 .collect();
920 Self {
921 y_max,
922 y_grid,
923 x_axis_ticks,
924 series_paths,
925 view_box: format!("0 0 {VIEW_W} {VIEW_H}"),
926 n_points,
927 series_for_dots: model.series,
928 }
929 }
930}
931
932fn make_resolve_idx(
936 plot_ref: NodeRef<Div>,
937 n_points: usize,
938) -> impl Fn(&MouseEvent) -> Option<(usize, f64, f64)> + Copy {
939 move |ev: &MouseEvent| {
940 let div_el = plot_ref.get()?;
941 let target_el: web_sys::Element = (*div_el).clone().unchecked_into();
942 let rect = target_el.get_bounding_client_rect();
943 let cx = f64::from(ev.client_x());
944 let cy = f64::from(ev.client_y());
945 let idx = pixel_to_index(cx, rect.left(), rect.width(), n_points);
946 Some((idx, cx, cy))
947 }
948}
949
950fn commit_zoom_drag(
957 start: usize,
958 end: usize,
959 n_points: usize,
960 from_offset: usize,
961 hover: RwSignal<Option<HoverState>>,
962 zoom: RwSignal<Option<ZoomRange>>,
963 on_zoom: Option<&ZoomCommit>,
964) {
965 let Some(range) = commit_drag(start, end, n_points) else {
966 return;
967 };
968 let composed = ZoomRange {
969 from_index: from_offset + range.from_index,
970 to_index: from_offset + range.to_index,
971 };
972 if let Some(cb) = on_zoom {
973 cb(composed.from_index, composed.to_index);
974 } else {
975 zoom.set(Some(composed));
976 }
977 hover.set(None);
980}
981
982fn make_dots_closure<F>(
985 get_idx: F,
986 series_for_dots: Vec<SeriesValues>,
987 y_max: f64,
988 n_points: usize,
989) -> impl Fn() -> Option<Vec<DotPos>> + Send + Sync + 'static
990where
991 F: Fn() -> Option<usize> + Send + Sync + 'static,
992{
993 move || {
994 let idx = get_idx()?;
995 let dots = series_for_dots
996 .iter()
997 .map(|sv| {
998 let v = sv.values.get(idx).copied().unwrap_or(0.0);
999 DotPos {
1000 x: index_to_x(idx, n_points, VIEW_W),
1001 y: value_to_y(v, y_max, VIEW_H),
1002 color_class: sv.color_class.clone(),
1003 }
1004 })
1005 .collect();
1006 Some(dots)
1007 }
1008}
1009
1010fn make_tooltip_closure(
1015 hover: RwSignal<Option<HoverState>>,
1016 tooltip: Option<TooltipSlot>,
1017 plot_ref: NodeRef<Div>,
1018 from_offset: usize,
1019) -> impl Fn() -> Option<TooltipView> + Send + Sync + 'static {
1020 move || {
1021 let h = hover.get()?;
1022 let cb = tooltip.as_ref()?;
1023 let plot_el = plot_ref.get()?;
1024 let plot_rect = (*plot_el)
1025 .clone()
1026 .unchecked_into::<web_sys::Element>()
1027 .get_bounding_client_rect();
1028 let style = tooltip_inline_style(&plot_rect, h.client_x, h.client_y);
1029 let original_idx = from_offset + h.index;
1030 let inner = (cb)(original_idx);
1031 Some(TooltipView { style, inner })
1032 }
1033}
1034
1035fn make_pinned_tooltip_closure(
1041 pinned_idx: Option<RwSignal<Option<usize>>>,
1042 tooltip: Option<TooltipSlot>,
1043 n_points: usize,
1044 from_offset: usize,
1045) -> impl Fn() -> Option<TooltipView> + Send + Sync + 'static {
1046 move || {
1047 let idx = pinned_idx.and_then(|p| p.get())?;
1048 let cb = tooltip.as_ref()?;
1049 let frac = if n_points > 1 {
1050 index_to_x(idx, n_points, VIEW_W) / VIEW_W
1051 } else {
1052 0.0
1053 };
1054 let style = if frac > TOOLTIP_FLIP_FRACTION {
1058 format!(
1059 "right: calc({:.2}% + 14px); top: 6px;",
1060 (1.0 - frac) * 100.0
1061 )
1062 } else {
1063 format!("left: calc({:.2}% + 14px); top: 6px;", frac * 100.0)
1064 };
1065 let inner = (cb)(from_offset + idx);
1066 Some(TooltipView { style, inner })
1067 }
1068}
1069
1070struct TooltipView {
1075 style: String,
1076 inner: AnyView,
1077}
1078
1079fn tooltip_inline_style(plot_rect: &web_sys::DomRect, client_x: f64, client_y: f64) -> String {
1082 let plot_left = plot_rect.left();
1083 let plot_width = plot_rect.width();
1084 let frac_x = if plot_width > 0.0 {
1085 (client_x - plot_left) / plot_width
1086 } else {
1087 0.0
1088 };
1089 let flip = frac_x > TOOLTIP_FLIP_FRACTION;
1090 let left_px = client_x - plot_left;
1091 let top_px = (client_y - plot_rect.top()).clamp(8.0, plot_rect.height() - 8.0);
1092 if flip {
1093 format!(
1094 "right: {:.2}px; top: {:.2}px;",
1095 plot_width - left_px + 14.0,
1096 top_px
1097 )
1098 } else {
1099 format!("left: {:.2}px; top: {:.2}px;", left_px + 14.0, top_px)
1100 }
1101}
1102
1103fn make_zoom_band_closure(
1107 drag_start: RwSignal<Option<usize>>,
1108 drag_end: RwSignal<Option<usize>>,
1109 n_points: usize,
1110) -> impl Fn() -> Option<ZoomBand> + Send + Sync + 'static {
1111 move || {
1112 let s = drag_start.get()?;
1113 let e = drag_end.get()?;
1114 if s == e {
1115 return None;
1116 }
1117 let lo = s.min(e);
1118 let hi = s.max(e);
1119 let x1 = index_to_x(lo, n_points, VIEW_W);
1120 let x2 = index_to_x(hi, n_points, VIEW_W);
1121 Some(ZoomBand {
1122 x: x1,
1123 width: (x2 - x1).max(0.0),
1124 })
1125 }
1126}
1127
1128#[derive(Clone, Copy)]
1131struct ZoomBand {
1132 x: f64,
1133 width: f64,
1134}
1135
1136#[cfg(test)]
1137mod tests {
1138 use super::*;
1139
1140 #[test]
1141 fn build_model_aligns_series_with_y_values() {
1142 let data = vec![(1.0, 4.0), (2.0, 5.0), (3.0, 6.0)];
1143 let m = build_model(
1144 &data,
1145 |d: &(f64, f64)| format!("{}", d.0),
1146 |d: &(f64, f64)| vec![d.0, d.1],
1147 &[Series::area("A", "a"), Series::area("B", "b")],
1148 );
1149 assert_eq!(m.labels, vec!["1", "2", "3"]);
1150 assert_eq!(m.series[0].values, vec![1.0, 2.0, 3.0]);
1151 assert_eq!(m.series[1].values, vec![4.0, 5.0, 6.0]);
1152 assert!((m.y_max - 6.0).abs() < 1e-9);
1153 }
1154
1155 #[test]
1156 fn build_model_empty_data_marks_empty() {
1157 let data: Vec<(f64, f64)> = vec![];
1158 let m = build_model(
1159 &data,
1160 |_d: &(f64, f64)| String::new(),
1161 |_d: &(f64, f64)| vec![0.0],
1162 &[Series::area("A", "a")],
1163 );
1164 assert!(m.empty);
1165 assert_eq!(m.y_max, 0.0);
1166 }
1167
1168 #[test]
1169 fn build_model_fills_missing_series_with_zero() {
1170 let data = vec![(1.0,)];
1171 let m = build_model(
1172 &data,
1173 |_d| "x".to_owned(),
1174 |_d| vec![1.0],
1175 &[Series::area("A", "a"), Series::area("B", "b")],
1176 );
1177 assert_eq!(m.series[0].values, vec![1.0]);
1178 assert_eq!(
1179 m.series[1].values,
1180 vec![0.0],
1181 "missing series should be zero-filled"
1182 );
1183 }
1184
1185 #[test]
1186 fn format_y_tick_drops_fraction_for_integers() {
1187 assert_eq!(format_y_tick(0.0), "0");
1188 assert_eq!(format_y_tick(20.0), "20");
1189 assert_eq!(format_y_tick(100.0), "100");
1190 }
1191
1192 #[test]
1193 fn format_y_tick_shows_one_decimal_for_fractional() {
1194 assert_eq!(format_y_tick(0.5), "0.5");
1195 }
1196
1197 #[test]
1198 fn soften_hex_parses_long_hex() {
1199 assert_eq!(soften_hex("#4262ff"), "rgba(66, 98, 255, 0.45)");
1200 assert_eq!(soften_hex("4262ff"), "rgba(66, 98, 255, 0.45)");
1201 }
1202
1203 #[test]
1204 fn soften_hex_parses_short_hex() {
1205 assert_eq!(soften_hex("#f0a"), "rgba(255, 0, 170, 0.45)");
1206 }
1207
1208 #[test]
1209 fn soften_hex_passes_through_garbage() {
1210 assert_eq!(soften_hex("not a color"), "not a color");
1211 }
1212
1213 #[test]
1214 fn is_hex_color_accepts_short_and_long_forms() {
1215 assert!(is_hex_color("#fff"));
1216 assert!(is_hex_color("#FFF"));
1217 assert!(is_hex_color("#4262ff"));
1218 assert!(is_hex_color("#4262FF"));
1219 assert!(is_hex_color("fff"));
1222 assert!(is_hex_color("4262ff"));
1223 }
1224
1225 #[test]
1226 fn is_hex_color_rejects_css_injection_payloads() {
1227 assert!(!is_hex_color("red; background-image: url(http://evil)"));
1231 assert!(!is_hex_color("#fff; color: red"));
1232 assert!(!is_hex_color(""));
1233 assert!(!is_hex_color("#"));
1234 assert!(!is_hex_color("#ggg"));
1235 assert!(!is_hex_color("#4262f"));
1238 assert!(!is_hex_color("#4262fff"));
1239 assert!(!is_hex_color("rgb(255, 0, 0)"));
1240 }
1241
1242 #[test]
1243 fn x_tick_style_anchors_left_for_first_label() {
1244 assert_eq!(
1245 x_tick_style(0.0),
1246 "left: 0; transform: none;",
1247 "leftmost tick must anchor to the edge, not center-translate past it"
1248 );
1249 }
1250
1251 #[test]
1252 fn x_tick_style_anchors_right_for_last_label() {
1253 assert_eq!(x_tick_style(99.9), "right: 0; left: auto; transform: none;");
1254 }
1255
1256 #[test]
1257 fn x_tick_style_centers_middle_label() {
1258 assert_eq!(x_tick_style(50.0), "left: 50.00%;");
1259 }
1260
1261 #[test]
1262 fn body_layout_includes_all_derived_data() {
1263 let model = five_point_model();
1264 let n_points_expected = model.labels.len();
1265 let layout = BodyLayout::from_model(model, None, 320);
1266 assert_eq!(layout.n_points, n_points_expected);
1267 assert!(!layout.y_grid.is_empty(), "y_grid must have ticks");
1268 assert!(
1269 !layout.x_axis_ticks.is_empty(),
1270 "x_axis_ticks must include at least the endpoints"
1271 );
1272 assert_eq!(layout.series_paths.len(), 2, "two series in the fixture");
1273 assert!(
1274 layout.y_max > 0.0,
1275 "y_max should reflect the padded fixture max"
1276 );
1277 assert_eq!(layout.view_box, format!("0 0 {VIEW_W} {VIEW_H}"));
1278 }
1279
1280 #[test]
1281 fn body_layout_y_max_covers_topmost_tick_on_mini_chart() {
1282 let data: Vec<f64> = vec![10.0, 50.0, 120.0, 180.0, 200.0];
1290 let model = build_model(
1291 &data,
1292 |_: &f64| String::new(),
1293 |v: &f64| vec![*v],
1294 &[Series::area("A", "a")],
1295 );
1296 let layout = BodyLayout::from_model(model, None, 180);
1298 let top_tick = layout
1299 .y_grid
1300 .iter()
1301 .map(|(_, label)| label.parse::<f64>().unwrap_or(0.0))
1302 .fold(0.0_f64, f64::max);
1303 assert!(
1304 layout.y_max + 1e-9 >= top_tick,
1305 "y_max ({}) must be ≥ the topmost tick ({}) so labels never \
1306 render at negative SVG y",
1307 layout.y_max,
1308 top_tick,
1309 );
1310 assert!(
1314 layout.y_grid.iter().all(|(y, _)| *y >= -1e-9),
1315 "every gridline must sit inside the plot: got {:?}",
1316 layout.y_grid,
1317 );
1318 }
1319
1320 #[test]
1321 fn body_layout_applies_custom_y_format() {
1322 let model = five_point_model();
1323 let fmt: YFormat = Arc::new(|v: f64| format!("{v:.0}ms"));
1324 let layout = BodyLayout::from_model(model, Some(&fmt), 320);
1325 assert!(
1326 layout.y_grid.iter().all(|(_, label)| label.ends_with("ms")),
1327 "every y tick should use the custom formatter, got {:?}",
1328 layout.y_grid
1329 );
1330 }
1331
1332 fn five_point_model() -> RenderModel {
1333 let data: Vec<(f64, f64)> = vec![
1334 (10.0, 1.0),
1335 (20.0, 2.0),
1336 (30.0, 5.0),
1337 (40.0, 8.0),
1338 (50.0, 3.0),
1339 ];
1340 build_model(
1341 &data,
1342 |d: &(f64, f64)| format!("{}", d.0),
1343 |d: &(f64, f64)| vec![d.0, d.1],
1344 &[Series::area("A", "a"), Series::area("B", "b")],
1345 )
1346 }
1347
1348 #[test]
1349 fn slice_model_returns_unchanged_when_no_zoom() {
1350 let m = five_point_model();
1351 let s = slice_model(&m, None);
1352 assert_eq!(s.labels, m.labels);
1353 assert_eq!(s.series[0].values, m.series[0].values);
1354 assert!((s.y_max - m.y_max).abs() < 1e-9);
1355 }
1356
1357 #[test]
1358 fn slice_model_trims_to_zoom_range_inclusive() {
1359 let m = five_point_model();
1360 let z = ZoomRange {
1361 from_index: 1,
1362 to_index: 3,
1363 };
1364 let s = slice_model(&m, Some(z));
1365 assert_eq!(s.labels, vec!["20", "30", "40"]);
1366 assert_eq!(s.series[0].values, vec![20.0, 30.0, 40.0]);
1367 assert_eq!(s.series[1].values, vec![2.0, 5.0, 8.0]);
1368 }
1369
1370 #[test]
1371 fn slice_model_rescales_y_max_within_window() {
1372 let m = five_point_model();
1373 assert!((m.y_max - 50.0).abs() < 1e-9);
1374 let z = ZoomRange {
1375 from_index: 1,
1376 to_index: 2,
1377 };
1378 let s = slice_model(&m, Some(z));
1379 assert!((s.y_max - 30.0).abs() < 1e-9);
1382 }
1383
1384 #[test]
1385 fn slice_model_clamps_out_of_range_zoom() {
1386 let m = five_point_model();
1387 let z = ZoomRange {
1388 from_index: 3,
1389 to_index: 999,
1390 };
1391 let s = slice_model(&m, Some(z));
1392 assert_eq!(s.labels, vec!["40", "50"]);
1393 }
1394}