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
/// Horizon chart — stacked, folded area chart for dense multi-series time series.
///
/// Each series is rendered as a single-row area chart where the value range is
/// divided into N equal-width bands. Each band is folded onto the same row, with
/// progressively darker shading for higher value magnitudes. A second color
/// (typically red) distinguishes negative values from positive ones.
///
/// # Example
///
/// ```rust,no_run
/// use kuva::plot::horizon::HorizonPlot;
/// use kuva::backend::svg::SvgBackend;
/// use kuva::render::render::render_horizon;
/// use kuva::render::layout::Layout;
/// use kuva::render::plots::Plot;
///
/// let x: Vec<f64> = (0..24).map(|i| i as f64).collect();
/// let y: Vec<f64> = x.iter().map(|&t| (t * 0.5).sin() * 10.0).collect();
///
/// let plot = HorizonPlot::new()
/// .with_series("Series A", x.clone(), y)
/// .with_n_bands(3);
///
/// let layout = Layout::auto_from_plots(&[Plot::Horizon(plot.clone())]).with_x_label("Time");
/// let svg = SvgBackend.render_scene(&render_horizon(plot, layout));
/// ```
#[derive(Debug, Clone)]
pub struct HorizonSeries {
pub label: String,
pub x: Vec<f64>,
pub y: Vec<f64>,
/// Color for positive deviations from baseline. Default: `"#4292c6"` (blue).
pub pos_color: String,
/// Color for negative deviations from baseline. Default: `"#d73027"` (red).
pub neg_color: String,
}
#[derive(Debug, Clone)]
pub struct HorizonPlot {
pub series: Vec<HorizonSeries>,
/// Number of color bands. Default: 3.
pub n_bands: usize,
/// Per-row pixel height. When `None`, height is derived from the canvas.
pub row_height: Option<f64>,
/// Baseline value separating positive from negative regions. Default: 0.0.
pub baseline: f64,
/// Override the maximum absolute value used for band-width calculation.
/// When `None`, derived from data.
pub value_max: Option<f64>,
/// Whether to emit a legend. Default: false.
pub show_legend: bool,
/// Show the full-scale value (`n_bands × band_width`) at the right end of each row.
/// This tells the reader what "darkest band" corresponds to in data units. Default: false.
pub show_value_labels: bool,
/// Draw a small `+` in `pos_color` and `-` in `neg_color` alongside the row
/// annotation so the reader can see which hue means positive vs negative.
/// Has no visible effect unless `show_value_labels` is also true. Default: false.
pub show_sign_colors: bool,
}
impl Default for HorizonPlot {
fn default() -> Self {
Self::new()
}
}
impl HorizonPlot {
pub fn new() -> Self {
Self {
series: vec![],
n_bands: 3,
row_height: None,
baseline: 0.0,
value_max: None,
show_legend: false,
show_value_labels: false,
show_sign_colors: false,
}
}
/// Add a series, auto-assigning a `pos_color` from the category10 palette.
///
/// The color cycles with the series index, so each call to `with_series` on
/// the same plot gets a distinct hue. `neg_color` is always the palette's
/// red (`#d62728`) — the universal signal for negative deviation.
pub fn with_series<S, IX, IY, X, Y>(mut self, label: S, x: IX, y: IY) -> Self
where
S: Into<String>,
IX: IntoIterator<Item = X>,
IY: IntoIterator<Item = Y>,
X: Into<f64>,
Y: Into<f64>,
{
// category10 palette — same source as Palette::category10() in render/palette.rs
const PALETTE: &[&str] = &[
"#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f",
"#bcbd22", "#17becf",
];
let idx = self.series.len();
let pos_color = PALETTE[idx % PALETTE.len()].to_string();
self.series.push(HorizonSeries {
label: label.into(),
x: x.into_iter().map(|v| v.into()).collect(),
y: y.into_iter().map(|v| v.into()).collect(),
pos_color,
neg_color: "#d62728".to_string(),
});
self
}
/// Add a series with explicit positive and negative colors.
pub fn with_series_colored<S, IX, IY, X, Y, CP, CN>(
mut self,
label: S,
x: IX,
y: IY,
pos_color: CP,
neg_color: CN,
) -> Self
where
S: Into<String>,
IX: IntoIterator<Item = X>,
IY: IntoIterator<Item = Y>,
X: Into<f64>,
Y: Into<f64>,
CP: Into<String>,
CN: Into<String>,
{
self.series.push(HorizonSeries {
label: label.into(),
x: x.into_iter().map(|v| v.into()).collect(),
y: y.into_iter().map(|v| v.into()).collect(),
pos_color: pos_color.into(),
neg_color: neg_color.into(),
});
self
}
/// Set the number of color bands. Default: 3.
pub fn with_n_bands(mut self, n: usize) -> Self {
self.n_bands = n.max(1);
self
}
/// Override per-row pixel height. Enables auto canvas sizing.
pub fn with_row_height(mut self, h: f64) -> Self {
self.row_height = Some(h);
self
}
/// Set the baseline value (zero-line). Default: 0.0.
pub fn with_baseline(mut self, b: f64) -> Self {
self.baseline = b;
self
}
/// Override the maximum absolute deviation used for band scaling.
pub fn with_value_max(mut self, v: f64) -> Self {
self.value_max = Some(v);
self
}
/// Show a legend entry per series.
pub fn with_legend(mut self, show: bool) -> Self {
self.show_legend = show;
self
}
/// Show the full-scale value at the right end of each row.
///
/// The label shows what value the darkest band represents, e.g. `+8.5` for positive
/// and `-3.2` for series that also have negative values.
pub fn with_value_labels(mut self, show: bool) -> Self {
self.show_value_labels = show;
self
}
/// Colorize the `+` / `-` sign characters in `pos_color` / `neg_color` in
/// the row annotation. Requires `with_value_labels(true)` to have any effect.
pub fn with_sign_colors(mut self, show: bool) -> Self {
self.show_sign_colors = show;
self
}
/// Number of series.
pub fn n_series(&self) -> usize {
self.series.len()
}
/// Compute the positive band width: (max_pos_deviation / n_bands).
/// If `value_max` is set, use that; otherwise derive from data.
pub fn pos_band_width(&self) -> f64 {
let vmax = self.value_max.unwrap_or_else(|| {
self.series
.iter()
.flat_map(|s| s.y.iter())
.map(|&v| (v - self.baseline).max(0.0))
.fold(0.0_f64, f64::max)
});
if vmax <= 0.0 || self.n_bands == 0 {
1.0
} else {
vmax / self.n_bands as f64
}
}
/// Compute the negative band width.
pub fn neg_band_width(&self) -> f64 {
let vmax = self.value_max.unwrap_or_else(|| {
self.series
.iter()
.flat_map(|s| s.y.iter())
.map(|&v| (self.baseline - v).max(0.0))
.fold(0.0_f64, f64::max)
});
if vmax <= 0.0 || self.n_bands == 0 {
1.0
} else {
vmax / self.n_bands as f64
}
}
/// x data extent across all series.
pub fn x_range(&self) -> Option<(f64, f64)> {
let mut xmin = f64::INFINITY;
let mut xmax = f64::NEG_INFINITY;
for s in &self.series {
for &x in &s.x {
xmin = xmin.min(x);
xmax = xmax.max(x);
}
}
if xmin.is_finite() {
Some((xmin, xmax))
} else {
None
}
}
}