Skip to main content

forge_charts/
chart.rs

1//! The `AreaChart` Leptos component.
2//!
3//! Renders an SVG with one or more filled-area series, axes, legend,
4//! a crosshair that tracks the cursor, per-series hover dots, a
5//! consumer-provided tooltip card, and drag-to-zoom with a reset pill.
6//!
7//! v0.3 ships drag-to-zoom (Phase C) on top of the v0.2 hover stack.
8
9use 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
25/// Internal SVG dimensions (viewBox). The actual rendered size is
26/// driven by the CSS `.charts-svg { width: 100%; height: …px; }`
27/// rule; SVG stretches via `preserveAspectRatio`.
28const VIEW_W: f64 = 800.0;
29const VIEW_H: f64 = 280.0;
30/// Max number of X-axis labels rendered before [`select_x_indices`]
31/// thins them.
32const MAX_X_LABELS: usize = 7;
33/// At which fraction of the chart width the tooltip flips to the
34/// left of the crosshair instead of the right.
35const TOOLTIP_FLIP_FRACTION: f64 = 0.65;
36
37/// Boxed tooltip slot. Receives the hovered data-point index and
38/// returns whatever Leptos view the consumer wants to render.
39pub type TooltipSlot = Arc<dyn Fn(usize) -> AnyView + Send + Sync>;
40
41/// Callback fired when the user commits a drag-to-zoom. Receives the
42/// inclusive **original-index** bounds of the selected range (already
43/// composed with any prior internal slice, if the chart is uncontrolled).
44///
45/// When this callback is set, the chart switches to **controlled-zoom**
46/// mode: it does *not* apply the slice internally. The consumer is
47/// expected to react — typically by refetching a narrower window of
48/// data and pushing it into the chart's `data` signal — and the chart
49/// just redraws with whatever shape the consumer hands back.
50pub type ZoomCommit = Arc<dyn Fn(usize, usize) + Send + Sync>;
51
52/// Formats a y-axis tick value into its display label. When unset the
53/// chart renders a plain number. Provide one to label the axis in
54/// real units — durations, bytes, percentages — so a raw `6000000`
55/// reads as `1h 40m` instead.
56pub type YFormat = Arc<dyn Fn(f64) -> String + Send + Sync>;
57
58/// Interactive area chart component.
59#[component]
60pub fn AreaChart<T, FxLabel, FyValues>(
61    /// The data series. Each element becomes one X tick. Order is
62    /// preserved (no internal sorting).
63    #[prop(into)]
64    data: Signal<Vec<T>>,
65    /// X-axis label for one data point.
66    x_label: FxLabel,
67    /// Y values for one data point. Must return one value per
68    /// declared series, in the same order as the `series` prop.
69    y_values: FyValues,
70    /// Series metadata. Length determines the number of plotted
71    /// areas; CSS hooks come from each `Series::color_class`.
72    series: Vec<Series>,
73    /// Outer container height in CSS pixels. Defaults to 320.
74    #[prop(default = 320)]
75    height: u32,
76    /// Show a legend chip strip above the chart. Defaults to true.
77    #[prop(default = true)]
78    legend: bool,
79    /// Custom tooltip slot — receives the hovered point index and
80    /// returns the markup to render inside the tooltip card. When
81    /// `None`, no tooltip appears (the crosshair still draws).
82    #[prop(optional)]
83    tooltip: Option<TooltipSlot>,
84    /// Zoom-commit callback. When set, drag-to-zoom switches to
85    /// controlled mode: the chart fires the callback with the committed
86    /// inclusive index range and does *not* slice its data internally
87    /// — the consumer reacts by updating the `data` signal.
88    #[prop(optional)]
89    on_zoom: Option<ZoomCommit>,
90    /// Extra classes applied to the outer `.charts-root` container.
91    #[prop(default = String::new(), into)]
92    class: String,
93    /// Custom y-axis tick formatter. When `None`, ticks render as plain
94    /// numbers. Provide one to label the axis in real units (see
95    /// [`YFormat`]).
96    #[prop(optional)]
97    y_format: Option<YFormat>,
98    /// Shared live-hover index. When several charts are handed the *same*
99    /// signal, hovering one moves the crosshair on all of them — they
100    /// must share an identical x-domain (same point count + window).
101    /// `None` keeps a private hover, matching standalone behavior.
102    #[prop(optional)]
103    crosshair: Option<RwSignal<Option<usize>>>,
104    /// Shared pinned index. When a signal is provided, a plain click in
105    /// the plot toggles a crosshair that persists after the cursor
106    /// leaves, so sibling charts keep showing the same point. `None`
107    /// disables click-to-pin (the standalone default).
108    #[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    // Live-hover index drives the crosshair + dots. Shared across
123    // sibling charts when the consumer passes a `crosshair` signal;
124    // otherwise private, so a standalone chart behaves exactly as before.
125    let live_idx = crosshair.unwrap_or_else(|| RwSignal::new(None));
126    let pinned_idx = pinned;
127    // Click outside any chart's plot area clears the pinned crosshair.
128    // Every chart sharing the pin installs this; they all test "inside
129    // *any* chart", so clicking one chart keeps the pin while a click on
130    // empty space dismisses it.
131    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        // Esc also dismisses the pin — keyboard parity with click-outside.
139        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    // Phase C — drag-to-zoom state. `zoom` is a committed range in
153    // *original* (unsliced) index space; `drag_start`/`drag_end` are
154    // display-space indices captured during an in-progress drag. We
155    // reset all three together when the user clears the zoom.
156    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    // Color overrides: color_class -> hex. Driven by the legend's
161    // hidden <input type="color"> per dot. When set, we emit inline
162    // CSS variables on .charts-root that override the bundled
163    // stylesheet's per-series colors.
164    let overrides: RwSignal<std::collections::HashMap<String, String>> =
165        RwSignal::new(std::collections::HashMap::new());
166
167    // Series hidden via a legend-name click (keyed by color_class).
168    // Filtered out of the render below; the y-axis rescales to whatever
169    // stays visible.
170    let hidden: RwSignal<std::collections::HashSet<String>> =
171        RwSignal::new(std::collections::HashSet::new());
172
173    let outer_class = format!("charts-root {class}");
174    // The inline style carries both the height var and any color
175    // overrides set via the legend picker. Reactive — recomputes on
176    // every `overrides` change.
177    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                // Defense-in-depth: write-site validation in the
182                // legend picker rejects non-hex input, but
183                // re-validate here so a future bug bypassing the
184                // write path can't inject CSS via the read path.
185                if !is_hex_color(hex) {
186                    continue;
187                }
188                // Set both the solid stroke color and a soft variant
189                // (50% alpha) for the gradient fill. Hex stays the
190                // truthful source; CSS color-mix isn't broadly enough
191                // supported in older browsers so we hand-derive.
192                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                // Apply any committed zoom by slicing the model in
211                // original-index space. The display body then operates
212                // on a tighter window with its own y_max so the zoom
213                // rescales both axes (Apex behaviour).
214                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                // <input type="color"> default value must be a 7-char
255                // hex per the HTML spec; the live color may come from
256                // the CSS variable, but reading computed style isn't
257                // reactive. Initial value is a neutral fallback — the
258                // user picks from there.
259                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                    // Defense-in-depth: native `<input type="color">`
264                    // always emits `#rrggbb`, but a malicious extension
265                    // or synthetic event can dispatch arbitrary text.
266                    // Reject anything that isn't a hex color so the
267                    // value can't break out of the CSS context when
268                    // it's later interpolated into `style="…"`.
269                    if !is_hex_color(&target) {
270                        return;
271                    }
272                    overrides.update(|map| {
273                        map.insert(cc_input.clone(), target);
274                    });
275                };
276                // Clicking the *name* (not the swatch) toggles the series
277                // in/out of the chart — standard legend behavior.
278                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
312/// True for `#?[0-9a-fA-F]{3}` or `#?[0-9a-fA-F]{6}` — exactly the
313/// shape native `<input type="color">` emits. Used to gate writes
314/// to the overrides map so a synthetic input event with arbitrary
315/// text can't land CSS-injection payload in inline `style="…"`.
316fn 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
321/// Produce a "soft" rgba string from a `#rrggbb` hex by holding the
322/// RGB and dropping alpha to 0.45. Used when the user overrides a
323/// series color via the legend picker — we need both the solid
324/// stroke + the soft fill stop in the gradient.
325fn soften_hex(hex: &str) -> String {
326    // Accept `#rgb`, `#rrggbb`, or anything else (passthrough).
327    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        // A click (no drag movement) toggles the shared pin instead of
414        // zooming — only when the consumer wired a `pinned` signal.
415        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        // A zoom changes what each index *means*, so a pin parked in the
429        // old window would point at the wrong instant — drop it.
430        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    // Two independent crosshairs: the *live* one follows the cursor
461    // (and syncs across sibling charts via `live_idx`); the *pinned*
462    // one is parked by a click and persists so you can compare a fixed
463    // instant against wherever the cursor currently is. Both render on
464    // every synced chart.
465    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                // Inner scale matches the SVG's height (flex:1); the
498                // sibling spacer below it stands in for the x-axis strip
499                // so tick `top:%` maps to the plot's top..baseline, not
500                // the whole column (which would drop "0ms" past the
501                // baseline and push the top tick into the legend).
502                <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                // Crosshairs render as HTML in an overlay above the SVG
525                // rather than as SVG <line>s. The series <g> carries a
526                // CSS transform (the rise animation) which, in WebKit,
527                // forms a stacking context that paints over sibling SVG
528                // lines — so an in-SVG crosshair vanishes behind a tall
529                // spike. An HTML overlay sits cleanly above the SVG (same
530                // trick the dots use), below the dots overlay.
531                <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    // The default `.charts-y-tick` rule centers each label on its
548    // gridline (`translateY(-50%)`). For the topmost tick that pushes
549    // half the label above the plot, where it collides with the legend;
550    // for the bottom tick it dips below into the x-axis. Anchor those
551    // two edge labels inward so they stay inside the plot box.
552    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
627/// Position style for one X-axis label. Extreme ticks anchor to their
628/// edge instead of centering, otherwise `translateX(-50%)` would let
629/// half of the leftmost/rightmost label bleed past the chart edge.
630fn 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    // Per-series hover dots — rendered as HTML divs in pixel space
644    // rather than SVG circles, otherwise `preserveAspectRatio="none"`
645    // stretches them into ovals. The overlay covers the SVG region
646    // only so absolute percent positioning maps 1:1 to viewBox coords.
647    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
670/// True when a mouse event happened inside some chart's plot area
671/// (`.charts-plot-area`). Used to decide whether an outside click should
672/// dismiss the pinned crosshair.
673fn 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/// Materialized render model. Computed once per data change.
712#[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
790/// Drop series the user hid via the legend, then rescale `y_max` to the
791/// survivors so the axis fits what's actually drawn. Keeps `empty` keyed
792/// on the data (labels), not visibility — hiding every series shows an
793/// empty plot with axes, not the "no data" placeholder.
794fn 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
810/// Trim a fully-built [`RenderModel`] down to a [`ZoomRange`] in
811/// original-index space. Returns a fresh model with its own `y_max`
812/// recomputed within the window (so a zoom rescales both axes), or
813/// the input unchanged when there's no active zoom.
814fn 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
854/// Pre-computed layout: anything derivable from the [`RenderModel`]
855/// alone, with no signals or DOM access. Computed once per
856/// `view_chart_body` call so the body just wires signals into a
857/// pre-built grid.
858struct 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        // Inflate the raw max by 10% so the topmost data point doesn't
871        // touch the upper edge — both the tick generator and every
872        // value-to-pixel call below see the same padded value.
873        let padded_max = pad_y_max(model.y_max);
874        // Cap tick count by chart height (~40px per label minimum) so
875        // mini-charts don't pile up overlapping labels.
876        let max_ticks = ((chart_height / 40) as usize).clamp(3, 6);
877        let y_ticks = nice_y_ticks_capped(padded_max, max_ticks);
878        // `nice_y_ticks_capped` climbs the 1/2/5 ladder when the natural
879        // step count exceeds the cap, and its top tick is "the first
880        // round number at-or-above max" — so it can sit significantly
881        // *above* the padded data max (e.g. padded=220 → cap=4 → top
882        // tick=300). If we keep the smaller `padded_max` as y_max, every
883        // y-pixel — including the top tick *label*'s position — gets
884        // scaled by `data_value / padded_max > 1`, mapping the top tick
885        // to a *negative* SVG y. The label then lands above the chart's
886        // container (negative `top:%`), overlapping the title row above.
887        // Pin y_max to the topmost tick so the axis ends where the top
888        // tick is drawn and labels never escape the plot rectangle.
889        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
932/// Build a `Copy` closure that maps a `MouseEvent` to
933/// `(display-index, client_x, client_y)`. Returns `None` when the plot
934/// element is detached (transient during mount/unmount).
935fn 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
950/// Apply (or surface) a committed drag-to-zoom selection. `start` /
951/// `end` are display-space indices; we compose with `from_offset` to
952/// land back in original-data space. In controlled mode (`on_zoom`
953/// set) we fire the callback and leave the internal `zoom` signal
954/// alone — the consumer is expected to react. In uncontrolled mode we
955/// write the new range straight onto `zoom`.
956fn 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    // Clear hover so the dot doesn't linger on a now-stale display
978    // index while the new slice renders.
979    hover.set(None);
980}
981
982/// Build the per-series hover-dot closure. Returns `None` when the
983/// pointer isn't over the chart.
984fn 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
1010/// Build the tooltip closure: positions the floating card relative to
1011/// the plot's bounding rect, flips it to the cursor's left when the
1012/// pointer crosses [`TOOLTIP_FLIP_FRACTION`], and forwards the
1013/// original-space index to the consumer slot.
1014fn 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
1035/// Build the *pinned* tooltip closure. Unlike the live one, there's no
1036/// cursor, so it positions itself from the pinned data index in the
1037/// chart's own percent space — which is why it can render on sibling
1038/// charts the cursor never touched. Returns `None` when nothing is
1039/// pinned or no tooltip slot was provided.
1040fn 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        // Anchor on whichever side keeps the card inside the plot, and
1055        // push it 14px off the line so the pinned point stays visible
1056        // underneath rather than hidden behind the card.
1057        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
1070/// Container for the tooltip closure's two outputs — the inline style
1071/// string and the consumer-supplied inner view. Splits cleanly so the
1072/// view! macro at the call site can read both fields without rebuilding
1073/// the bounding-rect math.
1074struct TooltipView {
1075    style: String,
1076    inner: AnyView,
1077}
1078
1079/// Compute the tooltip's inline `style="..."` value. Pulled out as a
1080/// pure function so it's testable without standing up a DOM.
1081fn 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
1103/// Build the zoom-band closure: the translucent rectangle painted
1104/// while a drag is in progress. Suppressed for zero-width drags so a
1105/// stray click doesn't flash a 0-width sliver.
1106fn 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/// Drag-band geometry in viewBox units. Tiny so the view! macro can
1129/// destructure cleanly.
1130#[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        // Allow the `#`-less form because some pickers emit that
1220        // shape; soften_hex already strips the leading `#`.
1221        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        // The realistic synthetic-event injection: arbitrary CSS
1228        // that would otherwise land inside inline `style="…"` and
1229        // execute as additional declarations.
1230        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        // Reject the 4 / 5 / 7 / 8-char in-between sizes that don't
1236        // match either of HTML5's accepted hex shapes.
1237        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        // Regression: with a small chart height the tick generator climbs
1283        // the 1/2/5 ladder and emits a top tick well above the padded
1284        // data max (e.g. data≈200 → padded≈220 → cap=4 → top tick=300).
1285        // If `y_max` stayed at the padded value, `value_to_y(300, 220)`
1286        // would be negative — the top label would render *above* the
1287        // plot, overlapping the chart title. The fix pins `y_max` to the
1288        // topmost tick so the label lands at SVG y=0.
1289        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        // height=180 → max_ticks = clamp(180/40, 3, 6) = 4 → forces climb.
1297        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        // And every gridline's pixel position must be ≥ 0 (i.e. inside
1311        // the plot rect). A negative `y` here is what positions the
1312        // label above the chart container.
1313        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        // Window covers (20, 2) and (30, 5) — max is 30, not the full
1380        // data's 50. Confirms the zoom rescales the Y axis.
1381        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}