Initial commit

This commit is contained in:
Schuwi
2026-03-03 23:26:05 +01:00
commit 6c9a20bf59
9 changed files with 2784 additions and 0 deletions

227
src/render.rs Normal file
View 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(())
}