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
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Coordinate transforms: data space → normalized → pixel space.
use crate::geom::{Point, Rect};
/// Axis transform type.
#[derive(Clone, Debug)]
pub enum AxisTransform {
/// Linear mapping from `[min, max]`.
Linear {
/// Minimum value.
min: f64,
/// Maximum value.
max: f64,
},
/// Logarithmic mapping from `[min, max]` (base 10).
Log {
/// Minimum value.
min: f64,
/// Maximum value.
max: f64,
},
/// Categorical: maps index → evenly-spaced position.
Categorical {
/// Number of categories.
count: usize,
},
}
impl AxisTransform {
/// Map a data value to normalized `[0, 1]` space.
pub fn normalize(&self, value: f64) -> f64 {
match self {
Self::Linear { min, max } => {
if (max - min).abs() < 1e-15 {
0.5
} else {
(value - min) / (max - min)
}
}
Self::Log { min, max } => {
let log_min = min.max(1e-15).ln();
let log_max = max.max(1e-15).ln();
if (log_max - log_min).abs() < 1e-15 {
0.5
} else {
(value.max(1e-15).ln() - log_min) / (log_max - log_min)
}
}
Self::Categorical { count } => {
if *count <= 1 {
0.5
} else {
value / (*count - 1) as f64
}
}
}
}
/// Map from normalized `[0, 1]` back to data space.
pub fn denormalize(&self, t: f64) -> f64 {
match self {
Self::Linear { min, max } => min + t * (max - min),
Self::Log { min, max } => {
let log_min = min.max(1e-15).ln();
let log_max = max.max(1e-15).ln();
(log_min + t * (log_max - log_min)).exp()
}
Self::Categorical { count } => {
if *count <= 1 {
0.0
} else {
t * (*count - 1) as f64
}
}
}
}
}
/// Maps normalized `[0, 1]` space to pixel space within a viewport rectangle.
///
/// Y-axis is inverted for SVG (0 = top).
#[derive(Clone, Debug)]
pub struct ViewportTransform {
/// The pixel-space rectangle for this plot area.
pub viewport: Rect,
}
impl ViewportTransform {
/// Create a viewport transform for the given rectangle.
pub fn new(viewport: Rect) -> Self {
Self { viewport }
}
/// Map normalized (nx, ny) in `[0,1]` to pixel coordinates.
/// Y is inverted: ny=0 → bottom, ny=1 → top.
pub fn to_pixel(&self, nx: f64, ny: f64) -> Point {
Point::new(
self.viewport.x + nx * self.viewport.width,
self.viewport.y + self.viewport.height - ny * self.viewport.height,
)
}
/// Map pixel coordinates back to normalized `[0,1]`.
pub fn from_pixel(&self, px: f64, py: f64) -> (f64, f64) {
let nx = (px - self.viewport.x) / self.viewport.width;
let ny = 1.0 - (py - self.viewport.y) / self.viewport.height;
(nx, ny)
}
}
/// Full coordinate transform pipeline: data space → pixel space.
#[derive(Clone, Debug)]
pub struct CoordinateTransform {
/// X-axis transform.
pub x_transform: AxisTransform,
/// Y-axis transform.
pub y_transform: AxisTransform,
/// Viewport mapping.
pub viewport: ViewportTransform,
}
impl CoordinateTransform {
/// Create a coordinate transform.
pub fn new(
x_transform: AxisTransform,
y_transform: AxisTransform,
viewport: ViewportTransform,
) -> Self {
Self {
x_transform,
y_transform,
viewport,
}
}
/// Map a data-space point to pixel coordinates.
pub fn to_pixel(&self, x: f64, y: f64) -> Point {
let nx = self.x_transform.normalize(x);
let ny = self.y_transform.normalize(y);
self.viewport.to_pixel(nx, ny)
}
/// Map pixel coordinates back to data space.
pub fn from_pixel(&self, px: f64, py: f64) -> (f64, f64) {
let (nx, ny) = self.viewport.from_pixel(px, py);
let x = self.x_transform.denormalize(nx);
let y = self.y_transform.denormalize(ny);
(x, y)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_linear_normalize() {
let t = AxisTransform::Linear {
min: 0.0,
max: 100.0,
};
assert!((t.normalize(0.0)).abs() < 1e-10);
assert!((t.normalize(50.0) - 0.5).abs() < 1e-10);
assert!((t.normalize(100.0) - 1.0).abs() < 1e-10);
}
#[test]
fn test_viewport_y_inversion() {
let vt = ViewportTransform::new(Rect::new(0.0, 0.0, 100.0, 100.0));
// ny=0 → bottom of viewport (y=100)
let p = vt.to_pixel(0.0, 0.0);
assert!((p.y - 100.0).abs() < 1e-10);
// ny=1 → top of viewport (y=0)
let p = vt.to_pixel(0.0, 1.0);
assert!(p.y.abs() < 1e-10);
}
#[test]
fn test_coordinate_roundtrip() {
let ct = CoordinateTransform::new(
AxisTransform::Linear {
min: 0.0,
max: 10.0,
},
AxisTransform::Linear {
min: 0.0,
max: 100.0,
},
ViewportTransform::new(Rect::new(50.0, 50.0, 400.0, 300.0)),
);
let p = ct.to_pixel(5.0, 50.0);
let (x, y) = ct.from_pixel(p.x, p.y);
assert!((x - 5.0).abs() < 1e-6);
assert!((y - 50.0).abs() < 1e-6);
}
}