use std::fmt; use rctree::Node; use svg::{ node::{ element::{ path::Data as PathData, Circle, Definitions, Group, Line, Path, Rectangle, Style, Text, TextPath, }, Text as TextNode, }, Document, }; use usvg::Tree; use crate::clock::{ get_current_hour_name, get_hms, get_moon_phase, get_seconds_since_midnight, get_utc_hour_name, get_utc_offset, DayPart, }; use crate::config::Config; const IMAGE_WIDTH: u32 = 700; const HOUR_NAME_FONT_SIZE: f32 = IMAGE_WIDTH as f32 * 0.019109; const OUTER_R: f32 = (IMAGE_WIDTH as f32) / 2.0 - 3.0 * HOUR_NAME_FONT_SIZE; const RING_WIDTH: f32 = HOUR_NAME_FONT_SIZE * 3.0; const UTC_HOUR_FONT_SIZE: f32 = IMAGE_WIDTH as f32 * 0.021462; enum Season { Spring, Summer, Autumn, Winter, } impl fmt::Display for Season { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, "{}", match self { Season::Spring => "spring", Season::Summer => "summer", Season::Autumn => "autumn", Season::Winter => "winter", } ) } } fn seconds_to_degrees(seconds: i32) -> f32 { seconds as f32 * 360.0 / 86400.0 } pub fn svg_to_usvg(document: Document) -> Tree { let doc_str = document.to_string(); let mut opt = usvg::Options::default(); opt.fontdb.load_system_fonts(); opt.font_family = "Liberation Serif".to_string(); Tree::from_str(&doc_str, &opt.to_ref()).unwrap() } fn hour_name_path() -> Path { let radius = OUTER_R - RING_WIDTH / 2.0; let delta_x = radius * (15.0_f32.to_radians() / 2.0).sin(); let delta_y = radius * (1.0 - (15.0_f32.to_radians() / 2.0).cos()); let x1 = (IMAGE_WIDTH as f32) / 2.0 - delta_x; let y1 = ((IMAGE_WIDTH as f32) / 2.0 - radius) + delta_y; let path_data = PathData::new().move_to((x1, y1)).elliptical_arc_by(( radius, radius, 15, 0, 1, 2.0 * delta_x, 0, )); Path::new().set("id", "hour-name-path").set("d", path_data) } fn create_temp_document(hour: usize) -> Document { let definitions = Definitions::new().add(hour_name_path()); let utc_hour_y = IMAGE_WIDTH as f32 / 2.0 - OUTER_R + RING_WIDTH + UTC_HOUR_FONT_SIZE; let hour_name_text_path = TextPath::new() .set("xlink:href", "#hour-name-path") .set("startOffset", "50%") .add(TextNode::new(get_utc_hour_name(hour))); let hour_name_text = Text::new() .set("id", "hour-name") .set("text-anchor", "middle") .set("dominant-baseline", "mathematical") .set("font-size", HOUR_NAME_FONT_SIZE) .add(hour_name_text_path); let utc_hour_text = Text::new() .set("id", "utc-hour") .set( "transform", format!("rotate(-7.5, {}, {})", IMAGE_WIDTH / 2, IMAGE_WIDTH / 2), ) .set("x", IMAGE_WIDTH / 2) .set("y", utc_hour_y) .set("text-anchor", "middle") .set("dominant-baseline", "mathematical") .set("font-size", UTC_HOUR_FONT_SIZE) .add(TextNode::new(format!("U {:02}", hour))); Document::new() .set("viewBox", (0i32, 0i32, 700i32, 700i32)) .set("width", 700i32) .set("height", 700i32) .set("xmlns:xlink", "http://www.w3.org/1999/xlink") .add(definitions) .add(hour_name_text) .add(utc_hour_text) } fn node_to_path(node: Node) -> PathData { let mut svg_path_data = PathData::new(); match *node.borrow() { usvg::NodeKind::Path(ref path) => { let path_data = &path.data; for segment in path_data.0.iter() { match segment { usvg::PathSegment::MoveTo { x, y } => { svg_path_data = svg_path_data.move_to((*x, *y)); } usvg::PathSegment::LineTo { x, y } => { svg_path_data = svg_path_data.line_to((*x, *y)); } usvg::PathSegment::CurveTo { x1, y1, x2, y2, x, y, } => { svg_path_data = svg_path_data.cubic_curve_to((*x1, *y1, *x2, *y2, *x, *y)); } usvg::PathSegment::ClosePath => { svg_path_data = svg_path_data.close(); } } } } _ => { unreachable!() } } svg_path_data } fn cache_hour_name_path(hour: usize) -> (PathData, PathData) { let tree = svg_to_usvg(create_temp_document(hour)); let text_node = tree.node_by_id("hour-name").unwrap(); let utc_text_node = tree.node_by_id("utc-hour").unwrap(); (node_to_path(text_node), node_to_path(utc_text_node)) } pub fn cache_hour_name_paths() -> [(PathData, PathData); 24] { [ cache_hour_name_path(0), cache_hour_name_path(1), cache_hour_name_path(2), cache_hour_name_path(3), cache_hour_name_path(4), cache_hour_name_path(5), cache_hour_name_path(6), cache_hour_name_path(7), cache_hour_name_path(8), cache_hour_name_path(9), cache_hour_name_path(10), cache_hour_name_path(11), cache_hour_name_path(12), cache_hour_name_path(13), cache_hour_name_path(14), cache_hour_name_path(15), cache_hour_name_path(16), cache_hour_name_path(17), cache_hour_name_path(18), cache_hour_name_path(19), cache_hour_name_path(20), cache_hour_name_path(21), cache_hour_name_path(22), cache_hour_name_path(23), ] } fn hour_marker( hour: i32, hour_name_path_data: &(PathData, PathData), is_current_hour: bool, ) -> Group { let season = match hour { 0..=5 => Season::Winter, 6..=11 => Season::Spring, 12..=17 => Season::Summer, 18..=23 => Season::Autumn, _ => panic!("Hour out of range"), }; let rotation = hour * 15; let delta_x = OUTER_R * (15f32.to_radians() / 2.0).sin(); let delta_y = OUTER_R * (1.0 - (15f32.to_radians() / 2.0).cos()); let s_delta_x = 0.0 - RING_WIDTH * (15f32.to_radians() / 2.0).sin(); let s_delta_y = RING_WIDTH * (15f32.to_radians() / 2.0).cos(); let i_delta_x = -2.0 * (OUTER_R - RING_WIDTH) * (15f32.to_radians() / 2.0).sin(); let x1 = IMAGE_WIDTH as f32 / 2.0 - delta_x; let y1 = (IMAGE_WIDTH as f32 / 2.0 - OUTER_R) + delta_y; let path_data = PathData::new() .move_to((x1, y1)) .elliptical_arc_by((OUTER_R, OUTER_R, 15, 0, 1, 2.0 * delta_x, 0)) .line_by((s_delta_x, s_delta_y)) .elliptical_arc_by(( OUTER_R - RING_WIDTH, OUTER_R - RING_WIDTH, 15, 0, 0, i_delta_x, 0, )) .close(); let path = Path::new().set("class", "hour-outline").set("d", path_data); let hour_name_path = Path::new() .set("class", "hour-name") .set("d", hour_name_path_data.0.clone()); let utc_hour_path = Path::new() .set("class", "utc") .set("d", hour_name_path_data.1.clone()); Group::new() .set( "class", format!( "hour {season}{}", if is_current_hour { " active" } else { "" } ), ) .set( "transform", format!( "rotate({}, {}, {})", rotation as f32 - 172.5, IMAGE_WIDTH / 2, IMAGE_WIDTH / 2 ), ) .add(path) .add(hour_name_path) .add(utc_hour_path) } fn get_range_path(radius: f32, range_name: &str, start_time: i32, end_time: i32) -> Path { let start_deg = seconds_to_degrees(start_time); let end_deg = seconds_to_degrees(end_time); let deg_diff = end_deg - start_deg; let start_delta_x = radius * start_deg.to_radians().sin(); let start_delta_y = radius * (1.0 - start_deg.to_radians().cos()); let end_delta_x = radius * end_deg.to_radians().sin(); let end_delta_y = radius * (1.0 - end_deg.to_radians().cos()); let path_data = PathData::new() .move_to((IMAGE_WIDTH / 2, IMAGE_WIDTH / 2)) .line_to(( IMAGE_WIDTH as f32 / 2.0 - start_delta_x, IMAGE_WIDTH as f32 / 2.0 + radius - start_delta_y, )) .elliptical_arc_to(( radius, radius, deg_diff, ((start_deg < end_deg) ^ (deg_diff.abs() >= 180.0)) as u8, 0, IMAGE_WIDTH as f32 / 2.0 - end_delta_x, IMAGE_WIDTH as f32 / 2.0 + radius - end_delta_y, )) .close(); Path::new().set("class", range_name).set("d", path_data) } fn get_moon_path(radius: f32, moon_phase: f32) -> Path { let handle_x_pos = radius * 1.34; let handle_y_pos = radius * 0.88; let min_x = IMAGE_WIDTH as f32 / 2.0 - handle_x_pos; let max_x = min_x + 2.0 * handle_x_pos; let top_y = IMAGE_WIDTH as f32 / 2.0 - handle_y_pos; let bottom_y = IMAGE_WIDTH as f32 / 2.0 + handle_y_pos; let h1_x: f32; let h2_x: f32; if moon_phase < 14.0 { h1_x = min_x + 2.0 * handle_x_pos * (1.0 - moon_phase / 14.0); h2_x = max_x; } else { h1_x = min_x; h2_x = max_x + 2.0 * handle_x_pos * (1.0 - moon_phase / 14.0) } let path_data = PathData::new() .move_to((IMAGE_WIDTH as f32 / 2.0, IMAGE_WIDTH as f32 / 2.0 - radius)) .cubic_curve_to(( h1_x, top_y, h1_x, bottom_y, IMAGE_WIDTH as f32 / 2.0, IMAGE_WIDTH as f32 / 2.0 + radius, )) .cubic_curve_to(( h2_x, bottom_y, h2_x, top_y, IMAGE_WIDTH / 2, IMAGE_WIDTH as f32 / 2.0 - radius, )); Path::new().set("class", "moon").set("d", path_data) } pub fn gen_svg( config: &Option, hour_name_path_cache: &[(PathData, PathData); 24], ) -> Document { let (utc_hour, _, _) = get_hms(config, DayPart::UtcNow); let (local_hour, local_minute, local_second) = get_hms(config, DayPart::LocalNow); let utc_offset = get_utc_offset(); let local_time = get_seconds_since_midnight(config, DayPart::LocalNow); let utc_rotation = seconds_to_degrees(utc_offset); let moon_radius = IMAGE_WIDTH as f32 * 0.071428571; // Calculate the Moon phase let moon_phase = get_moon_phase(); let local_hour_font_size = IMAGE_WIDTH as f32 * 0.02357; let sun_radius = IMAGE_WIDTH as f32 * 0.0142871; let marker_radius = OUTER_R - RING_WIDTH - 2.0 * UTC_HOUR_FONT_SIZE; let border = Rectangle::new() .set("x", 0i32) .set("y", 0i32) .set("width", 700i32) .set("height", 700i32) .set("id", "border"); let stylesheet = Style::new( "\ #border {stroke: none; fill: rgb(19, 17, 30); } .hour path.hour-outline {stroke: rgb(0, 0, 0); stroke-width: 2px;} .hour path.hour-name {stroke: none; fill: rgb(238, 187, 85);} .hour path.utc {stroke: none; fill: rgb(91, 68, 38);} .winter path {fill: rgb(70, 62, 108);} .active.winter path.hour-outline {fill: rgb(100, 92, 138);} .spring path {fill: rgb(55, 87, 55);} .active.spring path.hour-outline {fill: rgb(85, 117, 85);} .summer path {fill: rgb(113, 92, 43);} .active.summer path.hour-outline {fill: rgb(143, 122, 73);} .autumn path {fill: rgb(108, 68, 44);} .active.autumn.hour-outline path {fill: rgb(138, 98, 74);} .local-hour {stroke: none; fill: rgb(238, 187, 85);} .night-time {stroke: none; fill: rgb(19, 17, 30);} .blue-hour {stroke: none; fill: rgb(9, 1, 119);} .golden-hour {stroke: none; fill: rgb(170, 132, 44);} .day-time {stroke: none; fill: rgb(125, 197, 240);} .marker {stroke: rgb(19, 17, 30); stroke-width: 2px; fill: none;} .moon-background {stroke: rgb(170, 170, 170); stroke-width: 2px; fill: rgb(19, 17, 30);} .moon {stroke: none; fill: rgb(170, 170, 170);} .sun {stroke: none; fill: rgb(238, 187, 85);} .mid-marker {stroke: red;} .dial {stroke-width: 2px; stroke: rgb(238, 187, 85);} #current-hour rect {stroke: none; fill: rgba(255, 255, 255, 0.5);} #current-hour-name {font-weight: bold;}", ); let mut local_clock = Group::new().set("id", "local-clock"); for hour in 0i32..24 { let hour_str = match hour { 0 => "Midnight".to_string(), 12 => "Noon".to_string(), _ => hour.to_string(), }; let rotation = hour * 15; let hour_name = TextNode::new(hour_str); let hour_node = Text::new() .set("class", "local-hour") .set("transform", format!("rotate({}, 350, 350)", 180 + rotation)) .set("x", (IMAGE_WIDTH as f32) / 2.0) .set( "y", (IMAGE_WIDTH as f32) / 2.0 - OUTER_R - local_hour_font_size / 2.0, ) .set("text-anchor", "middle") .set("font-size", local_hour_font_size as f32) .add(hour_name); local_clock = local_clock.add(hour_node); } let mut seasonal_clock = Group::new().set( "transform", format!( "rotate({}, {}, {})", utc_rotation, IMAGE_WIDTH / 2, IMAGE_WIDTH / 2 ), ); for hour in 0i32..24 { seasonal_clock = seasonal_clock.add(hour_marker( hour, &hour_name_path_cache[hour as usize], hour == utc_hour as i32, )); } let daytime_circle = Circle::new() .set("class", "day-time") .set("cx", IMAGE_WIDTH / 2) .set("cy", IMAGE_WIDTH / 2) .set("r", marker_radius); let day_parts_group: Option = if config.is_some() { let noon = get_seconds_since_midnight(config, DayPart::UtcNoon); let midnight = get_seconds_since_midnight(config, DayPart::UtcMidnight); let morning_golden_end = get_seconds_since_midnight(config, DayPart::UtcMorningGoldenEnd); let evening_golden_start = get_seconds_since_midnight(config, DayPart::UtcEveningGoldenStart); let sunrise = get_seconds_since_midnight(config, DayPart::UtcSunrise); let sunset = get_seconds_since_midnight(config, DayPart::UtcSunset); let dawn = get_seconds_since_midnight(config, DayPart::UtcDawnStart); let dusk = get_seconds_since_midnight(config, DayPart::UtcDuskEnd); let golden_hour_path = get_range_path( marker_radius, "golden-hour", morning_golden_end, evening_golden_start, ); let sun_disc = Circle::new() .set("class", "sun") .set("cx", IMAGE_WIDTH / 2) .set("cy", IMAGE_WIDTH as f32 / 2.0 + OUTER_R / 2.0 + sun_radius) .set("r", sun_radius) .set( "transform", format!( "rotate({}, {}, {})", seconds_to_degrees(local_time) - utc_rotation, IMAGE_WIDTH / 2, IMAGE_WIDTH / 2 ), ); let blue_hour_path = get_range_path( marker_radius, "blue-hour", sunrise, // morning_blue_end sunset, // evening_blue_start ); let nighttime_path = get_range_path(marker_radius, "night-time", dawn, dusk); let marker_circle = Circle::new() .set("class", "marker") .set("cx", IMAGE_WIDTH / 2) .set("cy", IMAGE_WIDTH / 2) .set("r", marker_radius); let noon_marker = Line::new() .set("class", "mid-marker") .set( "transform", format!( "rotate({}, {}, {})", seconds_to_degrees(noon), IMAGE_WIDTH / 2, IMAGE_WIDTH / 2 ), ) .set("x1", IMAGE_WIDTH / 2) .set( "y1", IMAGE_WIDTH as f32 / 2.0 + marker_radius - sun_radius as f32, ) .set("x2", IMAGE_WIDTH / 2) .set("y2", IMAGE_WIDTH as f32 / 2.0 + marker_radius); let midnight_marker = Line::new() .set("class", "mid-marker") .set( "transform", format!( "rotate({}, {}, {})", seconds_to_degrees(midnight), IMAGE_WIDTH / 2, IMAGE_WIDTH / 2 ), ) .set("x1", IMAGE_WIDTH / 2) .set( "y1", IMAGE_WIDTH as f32 / 2.0 + marker_radius - sun_radius as f32, ) .set("x2", IMAGE_WIDTH / 2) .set("y2", IMAGE_WIDTH as f32 / 2.0 + marker_radius); Some( Group::new() .set("id", "day-parts") .set( "transform", format!( "rotate({}, {}, {})", utc_rotation, IMAGE_WIDTH / 2, IMAGE_WIDTH / 2 ), ) .add(daytime_circle) .add(golden_hour_path) .add(sun_disc) .add(blue_hour_path) .add(nighttime_path) .add(marker_circle) .add(noon_marker) .add(midnight_marker), ) } else { None }; let moon_circle = Circle::new() .set("class", "moon-background") .set("cx", IMAGE_WIDTH / 2) .set("cy", IMAGE_WIDTH / 2) .set("r", moon_radius); let moon_group = Group::new() .set("id", "moon-container") .add(moon_circle) .add(get_moon_path(moon_radius, moon_phase)); let dial = Line::new() .set("id", "dial") .set("class", "dial") .set( "transform", format!( "rotate({}, {}, {})", seconds_to_degrees(local_time), IMAGE_WIDTH / 2, IMAGE_WIDTH / 2 ), ) .set("x1", IMAGE_WIDTH / 2) .set("y1", IMAGE_WIDTH as f32 / 2.0 + OUTER_R * 0.5) .set("x2", IMAGE_WIDTH / 2) .set( "y2", IMAGE_WIDTH as f32 / 2.0 + OUTER_R - RING_WIDTH + HOUR_NAME_FONT_SIZE, ); let current_box_width = (200f32 / 700f32) * IMAGE_WIDTH as f32; let current_box_height = (100f32 / 700f32) * IMAGE_WIDTH as f32; let current_hour_name_font_size = (40f32 / 700f32) * IMAGE_WIDTH as f32; let current_time_font_size = (30f32 / 700f32) * IMAGE_WIDTH as f32; let current_hour_rect = Rectangle::new() .set("x1", 0) .set("y1", 0) .set("width", current_box_width) .set("height", current_box_height); let current_hour_name = Text::new() .set("id", "current-hour-name") .set("font-size", current_hour_name_font_size) .set("text-anchor", "middle") .set("dominant-baseline", "mathematical") .set("x", current_box_width / 2.0) .set( "y", (current_box_height / 5.0) + (current_hour_name_font_size / 2.0), ) .add(TextNode::new(get_current_hour_name())); let current_time_text = Text::new() .set("font-size", current_time_font_size) .set("text-anchor", "middle") .set("dominant-baseline", "mathematical") .set("x", current_box_width / 2.0) .set("y", current_box_height - (current_time_font_size / 2.0)) .add(TextNode::new(format!( "{:02}:{:02}:{:02}", local_hour, local_minute, local_second ))); let top_pos = if (6..=18).contains(&local_hour) { moon_radius * 1.5 // under the moon } else { 0.0 - moon_radius * 1.5 - current_box_height // above the moon }; let current_hour_group = Group::new() .set("id", "current-hour") .set( "transform", format!( "translate({}, {})", IMAGE_WIDTH as f32 / 2.0 - current_box_width / 2.0, IMAGE_WIDTH as f32 / 2.0 + top_pos ), ) .add(current_hour_rect) .add(current_hour_name) .add(current_time_text); let mut document = Document::new() .set("viewBox", (0i32, 0i32, 700i32, 700i32)) .set("width", 700i32) .set("height", 700i32) .set("xmlns:xlink", "http://www.w3.org/1999/xlink") .add(stylesheet) .add(border) .add(local_clock); if let Some(..) = day_parts_group { document = document.add(day_parts_group.unwrap()); } document .add(seasonal_clock) .add(moon_group) .add(dial) .add(current_hour_group) }