kuva 0.4.0

Scientific plotting library in Rust with various backends.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
mod common;
use kuva::backend::svg::SvgBackend;
use kuva::plot::{LinePlot, ScatterPlot};
use kuva::render::{
    layout::{ComputedLayout, Layout},
    plots::Plot,
    render::render_multiple,
};

fn scatter_svg(layout: Layout) -> String {
    // Simple scatter: two points giving x in [0,13], y in [0,5]
    let plot = ScatterPlot::new().with_data(vec![(0.0f64, 0.0f64), (13.0, 5.0)]);
    let plots = vec![Plot::Scatter(plot)];
    SvgBackend.render_scene(&render_multiple(plots, layout))
}

/// Axis range override: x capped at 10 should suppress auto-tick "15".
#[test]
fn test_axis_range_override() {
    // Without override, auto-nice_range on [0, 13.13] produces ticks up to 15.
    let plots = vec![Plot::Scatter(
        ScatterPlot::new().with_data(vec![(0.0f64, 0.0f64), (13.0, 5.0)]),
    )];
    let layout_auto = Layout::auto_from_plots(&plots);
    let svg_auto = SvgBackend.render_scene(&render_multiple(plots, layout_auto));
    assert!(svg_auto.contains("15"), "auto range should include tick 15");

    // With override, x stops at 10.
    let plots2 = vec![Plot::Scatter(
        ScatterPlot::new().with_data(vec![(0.0f64, 0.0f64), (13.0, 5.0)]),
    )];
    let layout_override = Layout::auto_from_plots(&plots2)
        .with_x_axis_min(0.0)
        .with_x_axis_max(10.0);
    let svg_override = scatter_svg(layout_override);
    common::write_test_output("test_outputs/tick_control_range.svg", &svg_override).unwrap();
    assert!(
        svg_override.contains("10"),
        "overridden range should include tick 10"
    );
    assert!(
        !svg_override.contains(">15<"),
        "overridden range should not show tick 15"
    );
}

/// Explicit tick step: with_x_tick_step(2.5) on [0,10] produces 0, 2.5, 5, 7.5, 10.
#[test]
fn test_explicit_tick_step() {
    let plots = vec![Plot::Scatter(
        ScatterPlot::new().with_data(vec![(0.0f64, 0.0f64), (10.0, 5.0)]),
    )];
    let layout = Layout::auto_from_plots(&plots)
        .with_x_axis_min(0.0)
        .with_x_axis_max(10.0)
        .with_x_tick_step(2.5);
    let svg = scatter_svg(layout);
    common::write_test_output("test_outputs/tick_control_step.svg", &svg).unwrap();
    assert!(
        svg.contains(">0<") || svg.contains(">0.0<") || svg.contains("\"0\"") || svg.contains(">0"),
        "tick 0 should appear"
    );
    assert!(svg.contains("2.5"), "tick 2.5 should appear");
    assert!(
        svg.contains(">5<") || svg.contains("5.0") || svg.contains(">5"),
        "tick 5 should appear"
    );
    assert!(svg.contains("7.5"), "tick 7.5 should appear");
    assert!(
        svg.contains(">10<") || svg.contains(">10"),
        "tick 10 should appear"
    );
}

/// Minor ticks: enabling minor_ticks=5 adds more line elements to the SVG.
#[test]
fn test_minor_ticks() {
    let plots = vec![Plot::Scatter(
        ScatterPlot::new().with_data(vec![(0.0f64, 0.0f64), (10.0, 5.0)]),
    )];
    let layout_no_minor = Layout::auto_from_plots(&plots);
    let svg_no_minor = scatter_svg(layout_no_minor);

    let plots2 = vec![Plot::Scatter(
        ScatterPlot::new().with_data(vec![(0.0f64, 0.0f64), (10.0, 5.0)]),
    )];
    let layout_minor = Layout::auto_from_plots(&plots2).with_minor_ticks(5);
    let svg_minor = scatter_svg(layout_minor);
    common::write_test_output("test_outputs/tick_control_minor.svg", &svg_minor).unwrap();

    let lines_without = svg_no_minor.matches("<line").count();
    let lines_with = svg_minor.matches("<line").count();
    assert!(
        lines_with > lines_without,
        "minor ticks should add more line elements ({} vs {})",
        lines_with,
        lines_without
    );
}

/// Minor grid: enabling show_minor_grid adds even more line elements.
#[test]
fn test_minor_grid() {
    let plots = vec![Plot::Scatter(
        ScatterPlot::new().with_data(vec![(0.0f64, 0.0f64), (10.0, 5.0)]),
    )];
    let layout_minor = Layout::auto_from_plots(&plots).with_minor_ticks(5);
    let svg_minor = scatter_svg(layout_minor);

    let plots2 = vec![Plot::Scatter(
        ScatterPlot::new().with_data(vec![(0.0f64, 0.0f64), (10.0, 5.0)]),
    )];
    let layout_grid = Layout::auto_from_plots(&plots2)
        .with_minor_ticks(5)
        .with_show_minor_grid(true);
    let svg_grid = scatter_svg(layout_grid);
    common::write_test_output("test_outputs/tick_control_minor_grid.svg", &svg_grid).unwrap();

    let lines_minor = svg_minor.matches("<line").count();
    let lines_grid = svg_grid.matches("<line").count();
    assert!(
        lines_grid > lines_minor,
        "minor grid should add even more line elements ({} vs {})",
        lines_grid,
        lines_minor
    );
}

/// Axis line width: with_axis_line_width(4.0) → stroke-width="4" on both axis lines.
#[test]
fn test_axis_line_width() {
    let plots = vec![Plot::Scatter(
        ScatterPlot::new().with_data(vec![(0.0f64, 0.0f64), (10.0, 5.0)]),
    )];
    let layout = Layout::auto_from_plots(&plots).with_axis_line_width(4.0);
    let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
    common::write_test_output("test_outputs/axis_line_width.svg", &svg).unwrap();
    assert!(
        svg.contains(r#"stroke-width="4""#),
        "axis lines should carry stroke-width=\"4\""
    );
}

/// Axis line width default: without the builder, axes use 1px (scale=1).
#[test]
fn test_axis_line_width_default() {
    let plots = vec![Plot::Scatter(
        ScatterPlot::new().with_data(vec![(0.0f64, 0.0f64), (10.0, 5.0)]),
    )];
    let layout = Layout::auto_from_plots(&plots);
    let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
    // Default axis stroke is 1; "4" should not appear as a stroke-width
    assert!(
        !svg.contains(r#"stroke-width="4""#),
        "default rendering should not have stroke-width=\"4\""
    );
}

/// Tick stroke width: with_tick_width(3.5) → stroke-width="3.5" on tick marks.
#[test]
fn test_tick_stroke_width() {
    let plots = vec![Plot::Scatter(
        ScatterPlot::new().with_data(vec![(0.0f64, 0.0f64), (10.0, 5.0)]),
    )];
    let layout = Layout::auto_from_plots(&plots).with_tick_width(3.5);
    let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
    common::write_test_output("test_outputs/tick_stroke_width.svg", &svg).unwrap();
    assert!(
        svg.contains(r#"stroke-width="3.5""#),
        "tick marks should carry stroke-width=\"3.5\""
    );
}

/// Tick stroke width default: "3.5" should not appear without the builder.
#[test]
fn test_tick_stroke_width_default() {
    let plots = vec![Plot::Scatter(
        ScatterPlot::new().with_data(vec![(0.0f64, 0.0f64), (10.0, 5.0)]),
    )];
    let layout = Layout::auto_from_plots(&plots);
    let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
    assert!(
        !svg.contains(r#"stroke-width="3.5""#),
        "default rendering should not have stroke-width=\"3.5\""
    );
}

/// Tick length: with_tick_length(15.0) produces a different SVG than the default (5px ticks).
#[test]
fn test_tick_length() {
    let make_plots = || {
        vec![Plot::Scatter(
            ScatterPlot::new().with_data(vec![(0.0f64, 0.0f64), (10.0, 5.0)]),
        )]
    };
    let plots_default = make_plots();
    let layout_default = Layout::auto_from_plots(&plots_default);
    let svg_default = SvgBackend.render_scene(&render_multiple(plots_default, layout_default));

    let plots_long = make_plots();
    let layout_long = Layout::auto_from_plots(&plots_long).with_tick_length(150.0);
    let svg_long = SvgBackend.render_scene(&render_multiple(plots_long, layout_long));
    common::write_test_output("test_outputs/tick_length.svg", &svg_long).unwrap();

    assert_ne!(
        svg_default, svg_long,
        "with_tick_length(15.0) should produce different tick coordinates than the default"
    );
}

/// Grid line width: with_grid_line_width(2.5) → stroke-width="2.5" on major grid lines.
#[test]
fn test_grid_line_width() {
    let plots = vec![Plot::Scatter(
        ScatterPlot::new().with_data(vec![(0.0f64, 0.0f64), (10.0, 5.0)]),
    )];
    let layout = Layout::auto_from_plots(&plots).with_grid_line_width(2.5);
    let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
    common::write_test_output("test_outputs/grid_line_width.svg", &svg).unwrap();
    assert!(
        svg.contains(r#"stroke-width="2.5""#),
        "grid lines should carry stroke-width=\"2.5\""
    );
}

/// Grid line width default: "2.5" should not appear without the builder.
#[test]
fn test_grid_line_width_default() {
    let plots = vec![Plot::Scatter(
        ScatterPlot::new().with_data(vec![(0.0f64, 0.0f64), (10.0, 5.0)]),
    )];
    let layout = Layout::auto_from_plots(&plots);
    let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
    assert!(
        !svg.contains(r#"stroke-width="2.5""#),
        "default rendering should not have stroke-width=\"2.5\""
    );
}

/// Regression: tick_width and grid_line_width must be independent.
/// Previously, horizontal grid lines used tick_stroke_width instead of grid_stroke_width,
/// so setting with_tick_width would inadvertently widen horizontal grid lines too.
/// With the fix, grid lines only pick up grid_line_width and ticks only pick up tick_width.
///
/// Strategy: set tick_width=3 and grid_line_width=7 simultaneously.
/// Both values must appear. Then verify that setting only tick_width=3 (grid at default 1)
/// produces FEWER "3" occurrences than setting both tick_width=3 AND grid_line_width=3
/// (since adding grid_line_width=3 brings the grid lines up to 3 as well, adding more hits).
#[test]
fn test_tick_and_grid_width_independent() {
    let make_plots = || {
        vec![Plot::Scatter(
            ScatterPlot::new().with_data(vec![(0.0f64, 0.0f64), (10.0, 5.0)]),
        )]
    };

    // Both set to distinct values: each must appear.
    let plots_both = make_plots();
    let layout_both = Layout::auto_from_plots(&plots_both)
        .with_tick_width(3.0)
        .with_grid_line_width(7.0);
    let svg_both = SvgBackend.render_scene(&render_multiple(plots_both, layout_both));
    common::write_test_output("test_outputs/tick_grid_independent.svg", &svg_both).unwrap();
    assert!(
        svg_both.contains(r#"stroke-width="3""#),
        "tick width=3 should appear when set independently of grid"
    );
    assert!(
        svg_both.contains(r#"stroke-width="7""#),
        "grid line width=7 should appear when set independently of ticks"
    );

    // tick_width=3 alone (grid at default 1) must produce FEWER "3"s than
    // tick_width=3 + grid_line_width=3. The second layout adds grid lines to the "3" pool.
    let plots_tick_only = make_plots();
    let layout_tick_only = Layout::auto_from_plots(&plots_tick_only).with_tick_width(3.0);
    let svg_tick_only =
        SvgBackend.render_scene(&render_multiple(plots_tick_only, layout_tick_only));
    let count_tick_only = svg_tick_only.matches(r#"stroke-width="3""#).count();

    let plots_tick_and_grid = make_plots();
    let layout_tick_and_grid = Layout::auto_from_plots(&plots_tick_and_grid)
        .with_tick_width(3.0)
        .with_grid_line_width(3.0);
    let svg_tick_and_grid =
        SvgBackend.render_scene(&render_multiple(plots_tick_and_grid, layout_tick_and_grid));
    let count_tick_and_grid = svg_tick_and_grid.matches(r#"stroke-width="3""#).count();

    assert!(
        count_tick_and_grid > count_tick_only,
        "adding grid_line_width=3 on top of tick_width=3 should increase the stroke-width=\"3\" \
         count (grid lines join in); got tick_only={} vs tick+grid={}",
        count_tick_only,
        count_tick_and_grid
    );
}

/// All four controls combined: each distinctive value appears in the SVG.
#[test]
fn test_axis_controls_combined() {
    let plots = vec![Plot::Scatter(
        ScatterPlot::new().with_data(vec![(0.0f64, 0.0f64), (10.0, 5.0)]),
    )];
    let layout = Layout::auto_from_plots(&plots)
        .with_axis_line_width(5.0)
        .with_tick_width(2.5)
        .with_tick_length(8.0)
        .with_grid_line_width(0.5);
    let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
    common::write_test_output("test_outputs/axis_controls_combined.svg", &svg).unwrap();
    assert!(
        svg.contains(r#"stroke-width="5""#),
        "axis line width=5 should appear"
    );
    assert!(
        svg.contains(r#"stroke-width="2.5""#),
        "tick width=2.5 should appear"
    );
    assert!(
        svg.contains(r#"stroke-width="0.5""#),
        "grid line width=0.5 should appear"
    );
}

/// Regression test: tick labels must never contain "-0".
/// IEEE 754 negative zero (-0.0) formats as "-0" with Rust's {:.0} formatter.
/// TickFormat::format() must normalise -0.0 → 0.0 before dispatching.
#[test]
fn test_no_negative_zero_tick_label() {
    use kuva::render::layout::TickFormat;
    // The direct formatter must not produce "-0"
    assert_ne!(TickFormat::Auto.format(-0.0_f64), "-0");
    assert_ne!(TickFormat::Integer.format(-0.0_f64), "-0");
    assert_ne!(TickFormat::Fixed(1).format(-0.0_f64), "-0.0");
    assert_ne!(TickFormat::Percent.format(-0.0_f64), "-0.0%");

    // A density plot with y-axis floor at 0.0 must not render "-0" on the y-axis.
    // Force a layout where y_min can end up as -0.0 via the layout arithmetic.
    use kuva::plot::DensityPlot;
    use kuva::render::plots::Plot;
    let dp = DensityPlot::new()
        .with_data(vec![0.5_f64; 20])
        .with_x_range(0.0, 1.0);
    let plots = vec![Plot::Density(dp)];
    let layout = Layout::auto_from_plots(&plots);
    let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
    common::write_test_output("test_outputs/tick_no_negative_zero.svg", &svg).unwrap();
    assert!(
        !svg.contains(">-0<"),
        "SVG must not contain a '-0' tick label"
    );
}

/// Regression #80: generate_ticks with tiny values (1e-14 scale) must produce a
/// bounded tick count. The old code used `end + 1e-8` as a loop termination
/// tolerance; when the entire data range is smaller than 1e-8, the loop ran for
/// millions of iterations, producing gigabyte-sized SVG output.
#[test]
fn test_generate_ticks_small_scale_bounded() {
    let ticks = kuva::render::render_utils::generate_ticks(1e-14, 2e-14, 10);
    assert!(
        ticks.len() <= 20,
        "generate_ticks([1e-14, 2e-14], 10) should produce ≤20 ticks, got {}",
        ticks.len()
    );
    assert!(!ticks.is_empty(), "should produce at least one tick");
    // All tick values must be within the axis range
    for &t in &ticks {
        assert!(
            t >= 1e-14 * 0.999 && t <= 2e-14 * 1.001,
            "tick {t} is outside expected range [1e-14, 2e-14]"
        );
    }
}

/// Regression #80: A LinePlot with very small y-values must produce a compact SVG,
/// not a ~920 MB file.
#[test]
fn test_line_plot_small_values_svg_size() {
    let plot = LinePlot::new().with_data(vec![(0.0_f64, 1.0e-14), (0.5, 2.0e-14), (1.0, 1.5e-14)]);
    let plots = vec![Plot::Line(plot)];
    let layout = Layout::auto_from_plots(&plots)
        .with_title("Small Values")
        .with_x_label("X")
        .with_y_label("Y");
    let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
    common::write_test_output("test_outputs/tick_small_scale_line.svg", &svg).unwrap();
    assert!(
        svg.len() < 100_000,
        "LinePlot with 1e-14 y-values should produce a small SVG (<100 KB), got {} bytes",
        svg.len()
    );
}

/// Regression (issue #98): data whose max already lands exactly on a "nice"
/// tick must not have the axis rounded out to a whole extra major tick just
/// because of the small breathing-room pad — that can inflate the range by
/// 15-25%+ for an axis with few, large-value ticks. Data 0..20 (step-5 grid)
/// used to round out to (0, 25); it must now stay within 5% of (0, 20).
#[test]
fn test_axis_range_does_not_over_provision_when_max_lands_on_a_tick() {
    let data: Vec<(f64, f64)> = (0..=20).map(|i| (i as f64, (i as f64).sin())).collect();
    let plot = LinePlot::new().with_data(data);
    let plots = vec![Plot::Line(plot)];
    let layout = Layout::auto_from_plots(&plots);
    let computed = ComputedLayout::from_layout(&layout);

    assert_eq!(computed.x_range.0, 0.0);
    assert!(
        computed.x_range.1 <= 21.0 + 1e-9,
        "x_range.1 should be capped near 21.0 (20 + 5% of the 20-unit span), got {}",
        computed.x_range.1
    );
    assert!(
        computed.x_range.1 < 25.0,
        "x_range.1 must not round out to a whole extra major tick (25.0), got {}",
        computed.x_range.1
    );

    let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
    common::write_test_output("test_outputs/tick_control_no_overprovision.svg", &svg).unwrap();
    assert!(
        !svg.contains(">25<"),
        "the dead-space tick from the old over-provisioned range must not appear"
    );
}

/// Regression (issue #98): when the data's max does NOT land on a nice tick,
/// ordinary nice-rounding already provides natural headroom and must be
/// completely unaffected by the issue #98 cap.
#[test]
fn test_axis_range_natural_overshoot_is_unaffected() {
    let data: Vec<(f64, f64)> = (0..=17).map(|i| (i as f64, (i as f64).sin())).collect();
    let plot = LinePlot::new().with_data(data);
    let plots = vec![Plot::Line(plot)];
    let layout = Layout::auto_from_plots(&plots);
    let computed = ComputedLayout::from_layout(&layout);

    assert_eq!(computed.x_range, (0.0, 17.5));
}