use crate::axis_scale::AxisScale;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CoordinateSystem {
#[default]
Data,
Axes,
}
#[derive(Debug, Clone, PartialEq, Default)]
enum TransformOperation {
#[default]
Identity,
Affine {
scale: f64,
translate: f64,
},
Log {
base: f64,
},
Exp {
base: f64,
},
Then(Box<TransformOperation>, Box<TransformOperation>),
}
impl TransformOperation {
fn then(self, next: TransformOperation) -> Self {
match (self, next) {
(Self::Identity, next) => next,
(this, Self::Identity) => this,
(this, next) => Self::Then(Box::new(this), Box::new(next)),
}
}
fn transform_value(&self, value: f64) -> Option<f64> {
match self {
Self::Identity => value.is_finite().then_some(value),
Self::Affine { scale, translate } => {
if !(value.is_finite() && scale.is_finite() && translate.is_finite()) {
return None;
}
let out = value.mul_add(*scale, *translate);
out.is_finite().then_some(out)
}
Self::Log { base } => {
if !(value.is_finite() && value > 0.0 && valid_log_base(*base)) {
return None;
}
let out = value.log(*base);
out.is_finite().then_some(out)
}
Self::Exp { base } => {
if !(value.is_finite() && valid_log_base(*base)) {
return None;
}
let out = base.powf(value);
(out.is_finite() && out > 0.0).then_some(out)
}
Self::Then(first, second) => second.transform_value(first.transform_value(value)?),
}
}
fn inverted(&self) -> Option<Self> {
match self {
Self::Identity => Some(Self::Identity),
Self::Affine { scale, translate } => {
if !scale.is_finite() || scale.abs() <= f64::EPSILON || !translate.is_finite() {
return None;
}
Some(Self::Affine {
scale: 1.0 / scale,
translate: -translate / scale,
})
}
Self::Log { base } => Some(Self::Exp { base: *base }),
Self::Exp { base } => Some(Self::Log { base: *base }),
Self::Then(first, second) => Some(second.inverted()?.then(first.inverted()?)),
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Transform {
coordinate_system: CoordinateSystem,
operation: TransformOperation,
}
impl From<AxisScale> for Transform {
fn from(scale: AxisScale) -> Self {
match scale {
AxisScale::Linear => Self::identity(),
AxisScale::Log { base } => Self::log(base),
}
}
}
impl Transform {
pub const fn identity() -> Self {
Self {
coordinate_system: CoordinateSystem::Data,
operation: TransformOperation::Identity,
}
}
pub const fn axes() -> Self {
Self {
coordinate_system: CoordinateSystem::Axes,
operation: TransformOperation::Identity,
}
}
pub const fn affine(scale: f64, translate: f64) -> Self {
Self {
coordinate_system: CoordinateSystem::Data,
operation: TransformOperation::Affine { scale, translate },
}
}
pub const fn log(base: f64) -> Self {
Self {
coordinate_system: CoordinateSystem::Data,
operation: TransformOperation::Log { base },
}
}
pub const fn exp(base: f64) -> Self {
Self {
coordinate_system: CoordinateSystem::Data,
operation: TransformOperation::Exp { base },
}
}
pub fn coordinate_system(&self) -> CoordinateSystem {
self.coordinate_system
}
pub fn then(self, next: Transform) -> Self {
Self {
coordinate_system: self.coordinate_system,
operation: self.operation.then(next.operation),
}
}
pub fn transform_value(&self, value: f64) -> Option<f64> {
self.operation.transform_value(value)
}
pub fn inverted(&self) -> Option<Self> {
Some(Self {
coordinate_system: self.coordinate_system,
operation: self.operation.inverted()?,
})
}
pub fn transform_data(&self, pos: f64, axis_scale: AxisScale) -> Option<f64> {
data_value_to_plot(pos, axis_scale, Some(self))
}
pub fn transform_position(
&self,
pos: f64,
axis_scale: AxisScale,
axis_range: [f64; 2],
) -> Option<f64> {
if self.coordinate_system == CoordinateSystem::Axes {
return self.transform_value(pos);
}
let pos = self.transform_data(pos, axis_scale)?;
let min = self.transform_data(axis_range[0], axis_scale)?;
let max = self.transform_data(axis_range[1], axis_scale)?;
let span = max - min;
(span.is_finite() && span.abs() > f64::EPSILON).then_some((pos - min) / span)
}
pub(crate) fn uses_axes_coordinates(&self) -> bool {
self.coordinate_system == CoordinateSystem::Axes
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct PositionTransform {
pub x: Option<Transform>,
pub y: Option<Transform>,
}
impl PositionTransform {
pub fn new(x: Option<Transform>, y: Option<Transform>) -> Self {
Self { x, y }
}
pub const fn identity() -> Self {
Self { x: None, y: None }
}
pub const fn axes() -> Self {
Self {
x: Some(Transform::axes()),
y: Some(Transform::axes()),
}
}
pub fn transform_point(
&self,
point: [f64; 2],
x_axis_scale: AxisScale,
y_axis_scale: AxisScale,
) -> Option<[f64; 2]> {
data_point_to_plot_with_transform(point, x_axis_scale, y_axis_scale, self, None)
}
pub(crate) fn uses_axes_coordinates(&self) -> bool {
self.x
.as_ref()
.is_some_and(Transform::uses_axes_coordinates)
|| self
.y
.as_ref()
.is_some_and(Transform::uses_axes_coordinates)
}
}
pub(crate) fn data_value_to_plot(
value: f64,
axis_scale: AxisScale,
transform: Option<&Transform>,
) -> Option<f64> {
data_value_to_plot_with_axis_range(value, axis_scale, transform, None)
}
pub(crate) fn data_value_to_plot_with_axis_range(
value: f64,
axis_scale: AxisScale,
transform: Option<&Transform>,
axis_range: Option<[f64; 2]>,
) -> Option<f64> {
let Some(transform) = transform else {
return Transform::from(axis_scale).transform_value(value);
};
let value = transform.transform_value(value)?;
match transform.coordinate_system {
CoordinateSystem::Data => Transform::from(axis_scale).transform_value(value),
CoordinateSystem::Axes => {
let [min, max] = axis_range?;
if !(min.is_finite() && max.is_finite()) {
return None;
}
Some(min + value * (max - min))
}
}
}
pub(crate) fn plot_value_to_data(value: f64, axis_scale: AxisScale) -> Option<f64> {
Transform::from(axis_scale)
.inverted()?
.transform_value(value)
}
pub(crate) fn data_point_to_plot_with_transform(
point: [f64; 2],
x_scale: AxisScale,
y_scale: AxisScale,
transform: &PositionTransform,
axis_ranges: Option<([f64; 2], [f64; 2])>,
) -> Option<[f64; 2]> {
Some([
data_value_to_plot_with_axis_range(
point[0],
x_scale,
transform.x.as_ref(),
axis_ranges.map(|ranges| ranges.0),
)?,
data_value_to_plot_with_axis_range(
point[1],
y_scale,
transform.y.as_ref(),
axis_ranges.map(|ranges| ranges.1),
)?,
])
}
pub(crate) fn plot_point_to_data(
point: [f64; 2],
x_scale: AxisScale,
y_scale: AxisScale,
) -> Option<[f64; 2]> {
Some([
plot_value_to_data(point[0], x_scale)?,
plot_value_to_data(point[1], y_scale)?,
])
}
fn valid_log_base(base: f64) -> bool {
base.is_finite() && base > 0.0 && (base - 1.0).abs() > f64::EPSILON
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn affine_transform_normalizes_axis_position() {
let transform = Transform::affine(2.0, 10.0);
assert_eq!(
transform.transform_position(5.0, AxisScale::Linear, [0.0, 10.0]),
Some(0.5)
);
}
#[test]
fn axis_scale_is_applied_after_data_transform() {
let transform = Transform::affine(10.0, 0.0);
assert_eq!(
transform.transform_data(10.0, AxisScale::Log { base: 10.0 }),
Some(2.0)
);
}
#[test]
fn composite_transform_inverts_in_reverse_order() {
let transform = Transform::affine(2.0, 10.0).then(Transform::log(10.0));
let inverted = transform
.inverted()
.expect("transform should be invertible");
let value = transform.transform_value(45.0).unwrap();
let round_trip = inverted.transform_value(value).unwrap();
assert!((round_trip - 45.0).abs() < 1e-12);
}
#[test]
fn axes_transform_maps_normalized_value_into_axis_range() {
let value = data_value_to_plot_with_axis_range(
0.4,
AxisScale::Log { base: 10.0 },
Some(&Transform::axes()),
Some([10.0, 20.0]),
);
assert_eq!(value, Some(14.0));
}
}