use std::f64::consts::{FRAC_PI_2, PI, SQRT_2};
use crate::core::color::{DEFAULT_COLOR_CYCLE, Rgba};
use crate::core::{Affine2D, Path};
use crate::render::{GraphicsContext, Renderer};
use crate::skia::SkiaRenderer;
use crate::text::FontSource;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkyProjection {
Aitoff,
Mollweide,
}
#[derive(Debug, Clone)]
struct SkyLine {
points: Vec<(f64, f64)>,
color: Rgba,
width: f64,
}
#[derive(Debug, Clone)]
struct SkyScatter {
points: Vec<(f64, f64)>,
color: Rgba,
}
const SCATTER_MARKER_RADIUS: f64 = 3.0;
const GRID_COLOR: Rgba = Rgba::new(0.7, 0.7, 0.7, 1.0);
const SPINE_COLOR: Rgba = Rgba::new(0.4, 0.4, 0.4, 1.0);
const MARGIN_FRAC: f64 = 0.08;
const GRATICULE_SAMPLES: usize = 90;
const MERIDIAN_STEP_DEG: i32 = 60;
const PARALLEL_STEP_DEG: i32 = 30;
const LABEL_SIZE: f64 = 9.0;
const TITLE_SIZE: f64 = 12.0;
const TITLE_PAD: f64 = 6.0;
const MOLLWEIDE_TOL: f64 = 1e-12;
#[derive(Debug, Clone)]
pub struct SkyAxes {
projection: SkyProjection,
lines: Vec<SkyLine>,
scatters: Vec<SkyScatter>,
title: Option<String>,
cycle: usize,
}
fn project(projection: SkyProjection, lon: f64, lat: f64) -> (f64, f64) {
match projection {
SkyProjection::Aitoff => {
let alpha = (lat.cos() * (lon / 2.0).cos()).acos();
let sinc = if alpha.abs() < 1e-12 {
1.0
} else {
alpha.sin() / alpha
};
(2.0 * lat.cos() * (lon / 2.0).sin() / sinc, lat.sin() / sinc)
}
SkyProjection::Mollweide => {
let rhs = PI * lat.sin();
let mut theta = lat;
for _ in 0..50 {
let f = 2.0 * theta + (2.0 * theta).sin() - rhs;
let fp = 2.0 + 2.0 * (2.0 * theta).cos();
if fp.abs() < 1e-15 {
theta = FRAC_PI_2.copysign(lat);
break;
}
let next = theta - f / fp;
if (next - theta).abs() < MOLLWEIDE_TOL {
theta = next;
break;
}
theta = next;
}
(2.0 * SQRT_2 / PI * lon * theta.cos(), SQRT_2 * theta.sin())
}
}
}
fn native_extents(projection: SkyProjection) -> (f64, f64) {
match projection {
SkyProjection::Aitoff => (PI, FRAC_PI_2),
SkyProjection::Mollweide => (2.0 * SQRT_2, SQRT_2),
}
}
fn sanitize(lon: f64, lat: f64) -> Option<(f64, f64)> {
if !lon.is_finite() || !lat.is_finite() {
return None;
}
let mut lon = (lon + PI).rem_euclid(2.0 * PI) - PI;
if lon == -PI {
lon = PI; }
Some((lon, lat.clamp(-FRAC_PI_2, FRAC_PI_2)))
}
impl SkyAxes {
#[must_use]
pub fn new(projection: SkyProjection) -> Self {
Self {
projection,
lines: Vec::new(),
scatters: Vec::new(),
title: None,
cycle: 0,
}
}
#[must_use]
pub fn projection(&self) -> SkyProjection {
self.projection
}
pub fn set_title(&mut self, title: impl Into<String>) -> &mut Self {
self.title = Some(title.into());
self
}
fn next_color(&mut self) -> Rgba {
let hex = DEFAULT_COLOR_CYCLE[self.cycle % DEFAULT_COLOR_CYCLE.len()];
self.cycle += 1;
Rgba::from_hex(hex).unwrap_or(Rgba::new(0.121, 0.466, 0.705, 1.0))
}
fn gather(lon: &[f64], lat: &[f64]) -> Vec<(f64, f64)> {
lon.iter()
.zip(lat)
.filter_map(|(&lo, &la)| sanitize(lo, la))
.collect()
}
pub fn plot(&mut self, lon: &[f64], lat: &[f64]) -> &mut Self {
let points = Self::gather(lon, lat);
let color = self.next_color();
self.lines.push(SkyLine {
points,
color,
width: 1.5,
});
self
}
pub fn scatter(&mut self, lon: &[f64], lat: &[f64]) -> &mut Self {
let points = Self::gather(lon, lat);
if points.is_empty() {
return self;
}
let color = self.next_color();
self.scatters.push(SkyScatter { points, color });
self
}
fn geometry(&self, width: f64, height: f64) -> (f64, f64, f64) {
let (xmax, ymax) = native_extents(self.projection);
let usable_w = width * (1.0 - 2.0 * MARGIN_FRAC);
let usable_h = height * (1.0 - 2.0 * MARGIN_FRAC);
let scale = (usable_w / (2.0 * xmax)).min(usable_h / (2.0 * ymax));
(0.5 * width, 0.5 * height, scale)
}
#[must_use]
pub fn to_pixel(&self, lon: f64, lat: f64, width: f64, height: f64) -> (f64, f64) {
let (lon, lat) = sanitize(lon, lat).unwrap_or((0.0, 0.0));
let (cx, cy, scale) = self.geometry(width, height);
let (x, y) = project(self.projection, lon, lat);
(cx + x * scale, cy + y * scale)
}
fn stroke_polyline(renderer: &mut dyn Renderer, points: &[[f64; 2]], color: Rgba, width: f64) {
if points.len() < 2 {
return;
}
let path = Path::from_polyline(points);
let gc = GraphicsContext::new()
.with_stroke(color)
.with_line_width(width);
renderer.draw_path(&gc, &path, &Affine2D::identity(), None);
}
fn draw_label(
renderer: &mut dyn Renderer,
font: &FontSource,
text: &str,
anchor_x: f64,
anchor_y: f64,
size: f64,
) {
let extent = font.measure(text, size);
let x = anchor_x - 0.5 * extent.width;
let y = anchor_y - 0.5 * (extent.ascent - extent.descent);
let path = font.text_to_path(text, size, [x, y]);
renderer.draw_path(
&GraphicsContext::new(),
&path,
&Affine2D::identity(),
Some(Rgba::BLACK),
);
}
fn graticule_line(
&self,
fixed: f64,
along_lat: bool,
width: f64,
height: f64,
) -> Vec<[f64; 2]> {
(0..=GRATICULE_SAMPLES)
.map(|i| {
let t = i as f64 / GRATICULE_SAMPLES as f64;
let (lon, lat) = if along_lat {
(fixed, -FRAC_PI_2 + t * PI)
} else {
(-PI + t * 2.0 * PI, fixed)
};
let (px, py) = self.to_pixel(lon, lat, width, height);
[px, py]
})
.collect()
}
pub fn draw(&self, renderer: &mut dyn Renderer, font: &FontSource) {
let (width, height) = renderer.canvas_size();
let s = renderer.decoration_scale();
let mut lat_deg = -90 + PARALLEL_STEP_DEG;
while lat_deg < 90 {
let line = self.graticule_line(f64::from(lat_deg).to_radians(), false, width, height);
Self::stroke_polyline(renderer, &line, GRID_COLOR, 0.8);
lat_deg += PARALLEL_STEP_DEG;
}
let mut lon_deg = -180 + MERIDIAN_STEP_DEG;
while lon_deg < 180 {
let line = self.graticule_line(f64::from(lon_deg).to_radians(), true, width, height);
Self::stroke_polyline(renderer, &line, GRID_COLOR, 0.8);
lon_deg += MERIDIAN_STEP_DEG;
}
let mut boundary = self.graticule_line(PI - 1e-9, true, width, height);
let west = self.graticule_line(-PI + 1e-9, true, width, height);
boundary.extend(west.into_iter().rev());
if let Some(&first) = boundary.first() {
boundary.push(first);
}
Self::stroke_polyline(renderer, &boundary, SPINE_COLOR, 1.0);
let mut lat_deg = -90 + PARALLEL_STEP_DEG;
while lat_deg < 90 {
let lat = f64::from(lat_deg).to_radians();
let (px, py) = self.to_pixel(-PI + 1e-9, lat, width, height);
Self::draw_label(
renderer,
font,
&format!("{lat_deg}°"),
px - 18.0 * s,
py,
LABEL_SIZE * s,
);
lat_deg += PARALLEL_STEP_DEG;
}
let mut lon_deg = -180 + MERIDIAN_STEP_DEG;
while lon_deg < 180 {
if lon_deg != 0 {
let (px, py) = self.to_pixel(f64::from(lon_deg).to_radians(), 0.0, width, height);
Self::draw_label(
renderer,
font,
&format!("{lon_deg}°"),
px,
py + 8.0 * s,
LABEL_SIZE * s,
);
}
lon_deg += MERIDIAN_STEP_DEG;
}
for line in &self.lines {
let pts: Vec<[f64; 2]> = line
.points
.iter()
.map(|&(lon, lat)| {
let (px, py) = self.to_pixel(lon, lat, width, height);
[px, py]
})
.collect();
Self::stroke_polyline(renderer, &pts, line.color, line.width);
}
for scatter in &self.scatters {
for &(lon, lat) in &scatter.points {
let (px, py) = self.to_pixel(lon, lat, width, height);
let r = SCATTER_MARKER_RADIUS * s;
let marker =
Path::unit_circle().transformed(&Affine2D::from_scale(r, r).translate(px, py));
renderer.draw_path(
&GraphicsContext::new(),
&marker,
&Affine2D::identity(),
Some(scatter.color),
);
}
}
if let Some(title) = &self.title {
let size = TITLE_SIZE * s;
let extent = font.measure(title, size);
let anchor_y = height - TITLE_PAD * s - 0.5 * (extent.ascent + extent.descent);
Self::draw_label(renderer, font, title, 0.5 * width, anchor_y, size);
}
}
#[must_use]
pub fn render_png(&self, width_px: u32, height_px: u32, dpi: f64) -> SkiaRenderer {
let mut renderer = SkiaRenderer::new(width_px, height_px, dpi);
let (w, h) = renderer.canvas_size();
let bg = Path::from_polyline(&[[0.0, 0.0], [w, 0.0], [w, h], [0.0, h], [0.0, 0.0]]);
renderer.draw_path(
&GraphicsContext::new(),
&bg,
&Affine2D::identity(),
Some(Rgba::WHITE),
);
self.draw(&mut renderer, &FontSource::dejavu_sans());
renderer
}
pub fn save_png<P: AsRef<std::path::Path>>(
&self,
path: P,
width_px: u32,
height_px: u32,
dpi: f64,
) -> Result<(), crate::skia::PngError> {
self.render_png(width_px, height_px, dpi).save_png(path)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn approx(a: f64, b: f64, tol: f64) {
assert!((a - b).abs() < tol, "expected {b}, got {a}");
}
#[test]
fn aitoff_center_equator_and_poles() {
let (x, y) = project(SkyProjection::Aitoff, 0.0, 0.0);
approx(x, 0.0, 1e-12);
approx(y, 0.0, 1e-12);
for lon in [-PI + 1e-9, -1.0, 0.5, 2.0, PI] {
let (x, y) = project(SkyProjection::Aitoff, lon, 0.0);
approx(x, lon, 1e-6);
approx(y, 0.0, 1e-12);
}
let (x, y) = project(SkyProjection::Aitoff, 0.7, FRAC_PI_2);
approx(x, 0.0, 1e-6);
approx(y, FRAC_PI_2, 1e-6);
}
#[test]
fn mollweide_matches_closed_forms() {
for lon in [-PI, -1.0, 0.0, 2.5, PI] {
let (x, y) = project(SkyProjection::Mollweide, lon, 0.0);
approx(x, 2.0 * SQRT_2 * lon / PI, 1e-9);
approx(y, 0.0, 1e-9);
}
let (x, y) = project(SkyProjection::Mollweide, 1.0, FRAC_PI_2);
approx(x, 0.0, 1e-9);
approx(y, SQRT_2, 1e-9);
let phi = 1.0;
let (_, y) = project(SkyProjection::Mollweide, 0.0, phi);
let theta = (y / SQRT_2).asin();
approx(2.0 * theta + (2.0 * theta).sin(), PI * phi.sin(), 1e-9);
}
#[test]
fn longitudes_wrap_and_latitudes_clamp() {
let (lon, lat) = sanitize(3.0 * PI, 2.0).expect("finite");
approx(lon, PI, 1e-9);
approx(lat, FRAC_PI_2, 1e-12);
assert!(sanitize(f64::NAN, 0.0).is_none());
assert!(sanitize(0.0, f64::INFINITY).is_none());
}
#[test]
fn to_pixel_is_centered_and_fits_the_margins() {
for projection in [SkyProjection::Aitoff, SkyProjection::Mollweide] {
let ax = SkyAxes::new(projection);
let (w, h) = (600.0, 300.0);
let (cx, cy) = ax.to_pixel(0.0, 0.0, w, h);
approx(cx, 300.0, 1e-9);
approx(cy, 150.0, 1e-9);
for (lon, lat) in [
(PI, 0.0),
(-PI + 1e-9, 0.0),
(0.0, FRAC_PI_2),
(0.0, -FRAC_PI_2),
] {
let (px, py) = ax.to_pixel(lon, lat, w, h);
assert!(
px >= 0.0 && px <= w,
"{projection:?}: px {px} out of canvas"
);
assert!(
py >= 0.0 && py <= h,
"{projection:?}: py {py} out of canvas"
);
}
let (e, _) = ax.to_pixel(PI, 0.0, w, h);
let (we, _) = ax.to_pixel(-PI + 1e-9, 0.0, w, h);
approx(e - 300.0, 300.0 - we, 1e-3);
}
}
#[test]
fn render_draws_graticule_and_markers() {
let mut ax = SkyAxes::new(SkyProjection::Mollweide);
ax.scatter(&[0.5], &[0.4]);
let r = ax.render_png(400, 200, 100.0);
let px = r.pixmap();
let mut ink = 0;
for p in px.pixels() {
let d = p.demultiply();
if (d.red(), d.green(), d.blue()) != (255, 255, 255) {
ink += 1;
}
}
assert!(ink > 100, "expected graticule/marker ink, got {ink} px");
}
#[test]
fn empty_plot_and_scatter_are_safe() {
let mut ax = SkyAxes::new(SkyProjection::Aitoff);
ax.plot(&[], &[]);
ax.scatter(&[f64::NAN], &[0.0]);
let r = ax.render_png(200, 100, 100.0);
assert_eq!(r.pixmap().width(), 200);
}
#[test]
fn title_renders_ink_in_the_top_band() {
let mut ax = SkyAxes::new(SkyProjection::Mollweide);
ax.scatter(&[0.5], &[0.4]);
ax.set_title("mollweide");
let r = ax.render_png(400, 200, 100.0);
let px = r.pixmap();
let mut ink = 0;
for y in 0..18u32 {
for x in 0..px.width() {
let d = px.pixel(x, y).unwrap().demultiply();
if (d.red(), d.green(), d.blue()) != (255, 255, 255) {
ink += 1;
}
}
}
assert!(ink > 20, "expected title ink in the top band, got {ink} px");
}
}