Initial commit
This commit is contained in:
227
src/render.rs
Normal file
227
src/render.rs
Normal file
@@ -0,0 +1,227 @@
|
||||
use crate::projection::OrthoProjection;
|
||||
use anyhow::{Context, Result};
|
||||
use font8x8::{UnicodeFonts, BASIC_FONTS};
|
||||
use plotters::prelude::*;
|
||||
use std::path::Path;
|
||||
|
||||
const IMG_WIDTH: u32 = 900;
|
||||
const IMG_HEIGHT: u32 = 600;
|
||||
|
||||
/// Map image centre and view window (must match what the Python script used).
|
||||
const CENTER_LAT: f64 = 52.56;
|
||||
const CENTER_LON: f64 = 13.08;
|
||||
const HALF_W_DEG: f64 = 0.8; // horizontal half-extent in degrees of arc
|
||||
const HALF_H_DEG: f64 = 0.4; // vertical half-extent
|
||||
|
||||
/// Cloud-cover thresholds and their associated RGB colours.
|
||||
/// Values below the first threshold are drawn as the background colour.
|
||||
const LEVELS: [(f32, (u8, u8, u8)); 7] = [
|
||||
(1.0, (208, 232, 248)), // very thin
|
||||
(2.0, (160, 200, 232)),
|
||||
(5.0, (112, 168, 216)),
|
||||
(10.0, ( 64, 128, 192)),
|
||||
(20.0, ( 32, 80, 160)),
|
||||
(50.0, ( 16, 32, 96)),
|
||||
(100.0, ( 5, 10, 48)),
|
||||
];
|
||||
|
||||
const BACKGROUND: RGBColor = RGBColor(240, 248, 255); // pale sky-blue
|
||||
const LAND_BG: RGBColor = RGBColor(232, 236, 220); // muted green for land
|
||||
const BLACK: RGBColor = RGBColor( 0, 0, 0);
|
||||
const RED: RGBColor = RGBColor(220, 30, 30);
|
||||
|
||||
/// Locations to mark on the map: (label, longitude, latitude)
|
||||
const LOCATIONS: &[(&str, f64, f64)] = &[
|
||||
("Falkensee", 13.08, 52.56),
|
||||
("Pausin", 13.04, 52.64),
|
||||
("Nauen", 12.88, 52.605),
|
||||
("Hennigsdorf", 13.205, 52.64),
|
||||
("Ketzin/Brueckenkopf", 12.82, 52.49),
|
||||
("Potsdam", 13.06, 52.40),
|
||||
("Berlin (Mitte)", 13.39, 52.52),
|
||||
];
|
||||
|
||||
fn cloud_color(cover: f32) -> Option<RGBColor> {
|
||||
if cover < 1.0 {
|
||||
return None; // clear sky — use background
|
||||
}
|
||||
for &(threshold, (r, g, b)) in LEVELS.iter().rev() {
|
||||
if cover >= threshold {
|
||||
return Some(RGBColor(r, g, b));
|
||||
}
|
||||
}
|
||||
Some(RGBColor(LEVELS[0].1 .0, LEVELS[0].1 .1, LEVELS[0].1 .2))
|
||||
}
|
||||
|
||||
/// Draw ASCII text using the built-in 8×8 pixel font.
|
||||
///
|
||||
/// Coordinates are in the drawing area's local coordinate system.
|
||||
/// `scale` is the pixel-multiplier: 1 → 8×8 px/glyph, 2 → 16×16, etc.
|
||||
/// Characters outside Basic Latin are rendered as '?'.
|
||||
fn draw_pixel_text(
|
||||
area: &DrawingArea<BitMapBackend, plotters::coord::Shift>,
|
||||
text: &str,
|
||||
x: i32,
|
||||
y: i32,
|
||||
scale: i32,
|
||||
color: &RGBColor,
|
||||
) {
|
||||
let mut cx = x;
|
||||
for ch in text.chars() {
|
||||
let glyph_ch = if BASIC_FONTS.get(ch).is_some() { ch } else { '?' };
|
||||
if let Some(glyph) = BASIC_FONTS.get(glyph_ch) {
|
||||
for (row, &bits) in glyph.iter().enumerate() {
|
||||
for col in 0i32..8 {
|
||||
if bits & (1 << col) != 0 {
|
||||
for dy in 0..scale {
|
||||
for dx in 0..scale {
|
||||
area.draw_pixel(
|
||||
(cx + col * scale + dx, y + row as i32 * scale + dy),
|
||||
color,
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cx += 8 * scale;
|
||||
}
|
||||
}
|
||||
|
||||
/// Render one forecast frame as a PNG.
|
||||
///
|
||||
/// - `lats`, `lons`, `cloud_cover` must all have the same length.
|
||||
/// - `title` is displayed at the top of the image.
|
||||
/// - Output is written to `output_path`.
|
||||
pub fn render_frame(
|
||||
output_path: &Path,
|
||||
lats: &[f32],
|
||||
lons: &[f32],
|
||||
cloud_cover: &[f32],
|
||||
title: &str,
|
||||
) -> Result<()> {
|
||||
let proj = OrthoProjection::new(CENTER_LAT, CENTER_LON, HALF_W_DEG, HALF_H_DEG);
|
||||
|
||||
let root = BitMapBackend::new(output_path, (IMG_WIDTH, IMG_HEIGHT)).into_drawing_area();
|
||||
root.fill(&BACKGROUND).context("fill background")?;
|
||||
|
||||
const LEGEND_W: u32 = 130;
|
||||
const TITLE_H: u32 = 40;
|
||||
const MARGIN: u32 = 8;
|
||||
|
||||
let map_area = root.margin(TITLE_H, MARGIN, MARGIN, LEGEND_W + MARGIN);
|
||||
|
||||
let map_w = IMG_WIDTH - LEGEND_W - MARGIN * 2;
|
||||
let map_h = IMG_HEIGHT - TITLE_H - MARGIN * 2;
|
||||
|
||||
map_area.fill(&LAND_BG).context("fill map area")?;
|
||||
|
||||
// Plot grid points
|
||||
for i in 0..lats.len() {
|
||||
let lat = lats[i] as f64;
|
||||
let lon = lons[i] as f64;
|
||||
|
||||
let Some((px, py)) = proj.project(lat, lon) else { continue };
|
||||
let Some((col, row)) = proj.to_pixel(px, py, map_w, map_h) else { continue };
|
||||
|
||||
let cover = cloud_cover[i];
|
||||
if cover.is_nan() {
|
||||
continue; // missing value (bitmap) — keep background
|
||||
}
|
||||
let color = match cloud_color(cover) {
|
||||
Some(c) => c,
|
||||
None => continue, // clear sky — keep land background
|
||||
};
|
||||
|
||||
// Paint a 2×2 block to avoid gaps between grid points
|
||||
for dy in 0..2i32 {
|
||||
for dx in 0..2i32 {
|
||||
let c = col + dx;
|
||||
let r = row + dy;
|
||||
if c >= 0 && c < map_w as i32 && r >= 0 && r < map_h as i32 {
|
||||
map_area.draw_pixel((c, r), &color).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw city markers and labels
|
||||
for &(label, lon, lat) in LOCATIONS {
|
||||
let Some((px, py)) = proj.project(lat, lon) else { continue };
|
||||
let Some((col, row)) = proj.to_pixel(px, py, map_w, map_h) else { continue };
|
||||
|
||||
// Small cross marker
|
||||
for d in -3i32..=3i32 {
|
||||
map_area.draw_pixel((col + d, row), &RED).ok();
|
||||
map_area.draw_pixel((col, row + d), &RED).ok();
|
||||
}
|
||||
|
||||
// Label offset slightly above and to the right of the marker
|
||||
draw_pixel_text(&map_area, label, col + 5, row - 9, 1, &BLACK);
|
||||
}
|
||||
|
||||
// Draw title (scale=2 → 16px tall)
|
||||
draw_pixel_text(&root, title, MARGIN as i32, (TITLE_H / 2 - 8) as i32, 2, &BLACK);
|
||||
|
||||
// Draw legend
|
||||
draw_legend(&root, IMG_WIDTH - LEGEND_W, TITLE_H)?;
|
||||
|
||||
root.present().context("write PNG")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn draw_legend(
|
||||
root: &DrawingArea<BitMapBackend, plotters::coord::Shift>,
|
||||
x: u32,
|
||||
y: u32,
|
||||
) -> Result<()> {
|
||||
draw_pixel_text(root, "Cloud cover", x as i32 + 4, y as i32 + 4, 1, &BLACK);
|
||||
|
||||
let box_h = 18i32;
|
||||
let box_w = 24i32;
|
||||
|
||||
// Helper: draw one legend entry
|
||||
let draw_entry = |label: &str, color: RGBColor, row_y: i32| {
|
||||
root.draw(&Rectangle::new(
|
||||
[(x as i32 + 4, row_y), (x as i32 + 4 + box_w, row_y + box_h)],
|
||||
ShapeStyle { color: color.to_rgba(), filled: true, stroke_width: 0 },
|
||||
))
|
||||
.ok();
|
||||
root.draw(&Rectangle::new(
|
||||
[(x as i32 + 4, row_y), (x as i32 + 4 + box_w, row_y + box_h)],
|
||||
ShapeStyle { color: BLACK.to_rgba(), filled: false, stroke_width: 1 },
|
||||
))
|
||||
.ok();
|
||||
draw_pixel_text(
|
||||
root,
|
||||
label,
|
||||
x as i32 + 4 + box_w + 4,
|
||||
row_y + (box_h - 8) / 2,
|
||||
1,
|
||||
&BLACK,
|
||||
);
|
||||
};
|
||||
|
||||
// "< 1%" row (clear sky)
|
||||
draw_entry("< 1%", LAND_BG, y as i32 + 22);
|
||||
|
||||
// Remaining threshold rows — use ASCII hyphen instead of en-dash
|
||||
let entries = [
|
||||
(1.0f32, "1-2%"),
|
||||
(2.0, "2-5%"),
|
||||
(5.0, "5-10%"),
|
||||
(10.0, "10-20%"),
|
||||
(20.0, "20-50%"),
|
||||
(50.0, "50-100%"),
|
||||
];
|
||||
|
||||
for (i, &(lo, label)) in entries.iter().enumerate() {
|
||||
let row_y = y as i32 + 22 + (i as i32 + 1) * (box_h + 2);
|
||||
let color = cloud_color(lo).unwrap_or(BACKGROUND);
|
||||
draw_entry(label, color, row_y);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user