From 95d7d89470f10e6f1a14d6dbfc2742b821b96441 Mon Sep 17 00:00:00 2001 From: Gergely Polonkai Date: Mon, 28 Mar 2022 15:39:53 +0200 Subject: [PATCH] Make it possible to generate an SVG image --- README.rst | 9 + example-output.svg | 681 ++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + seasonal_clock/svg.py | 441 +++++++++++++++++++++++++++ 4 files changed, 1132 insertions(+) create mode 100644 example-output.svg create mode 100644 seasonal_clock/svg.py diff --git a/README.rst b/README.rst index b8959fd..49068d7 100644 --- a/README.rst +++ b/README.rst @@ -81,3 +81,12 @@ It can theoretically make it easier to synchronize events across multiple time zones without actually knowing the time difference; just tell the other attending parties that you eat your lunch during Ladybug hour, or that your event is taking place between Gourd and Soup hours. + +Generated SVG +============= + +With the ``poetry run print_svg`` command, you will get an image similar to this: + +.. image:: example-output.svg + :width: 700 + :alt: An image rendered by this software diff --git a/example-output.svg b/example-output.svg new file mode 100644 index 0000000..7cae419 --- /dev/null +++ b/example-output.svg @@ -0,0 +1,681 @@ + + + + + + + + Midnight + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + Noon + 13 + 14 + 15 + 16 + 17 + 18 + 19 + 20 + 21 + 22 + 23 + + + + + Candle + U 00 + + + + Ice + U 01 + + + + Comet + U 02 + + + + Thimble + U 03 + + + + Root + U 04 + + + + Mist + U 05 + + + + Sprout + U 06 + + + + Rainbow + U 07 + + + + Worm + U 08 + + + + Bud + U 09 + + + + Blossom + U 10 + + + + Ladybug + U 11 + + + + Geese + U 12 + + + + Dust + U 13 + + + + Peach + U 14 + + + + Fog + U 15 + + + + Acorn + U 16 + + + + Gourd + U 17 + + + + Soup + U 18 + + + + Crow + U 19 + + + + Mushroom + U 20 + + + + Thunder + U 21 + + + + Frost + U 22 + + + + Lantern + U 23 + + + + + + + + + + + + + + + + + diff --git a/pyproject.toml b/pyproject.toml index 2fd257a..a7f2e04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ types-pytz = "^2021.3.6" [tool.poetry.scripts] print_data = "seasonal_clock:main" +print_svg = "seasonal_clock.svg:print_svg" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/seasonal_clock/svg.py b/seasonal_clock/svg.py new file mode 100644 index 0000000..e06f0fc --- /dev/null +++ b/seasonal_clock/svg.py @@ -0,0 +1,441 @@ +from datetime import datetime, time +from math import ceil, cos, pi, radians, sin +from typing import Union + +from astral import LocationInfo, moon +from pytz import UTC, timezone + +from .config import load_config +from .times import collect_day_parts + +HOURS_AMPM = ( + 'Midnight', + '1a', + '2a', + '3a', + '4a', + '5a', + '6a', + '7a', + '8a', + '9a', + '10a', + '11a', + 'Noon', + '1p', + '2p', + '3p', + '4p', + '5p', + '6p', + '7p', + '8p', + '9p', + '10p', + '11p', +) +HOURS_24 = ( + 'Midnight', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + 'Noon', + '13', + '14', + '15', + '16', + '17', + '18', + '19', + '20', + '21', + '22', + '23', +) +HOUR_NAMES = ( + 'Candle', + 'Ice', + 'Comet', + 'Thimble', + 'Root', + 'Mist', + 'Sprout', + 'Rainbow', + 'Worm', + 'Bud', + 'Blossom', + 'Ladybug', + 'Geese', + 'Dust', + 'Peach', + 'Fog', + 'Acorn', + 'Gourd', + 'Soup', + 'Crow', + 'Mushroom', + 'Thunder', + 'Frost', + 'Lantern', +) +SEASONS = ('winter', 'spring', 'summer', 'autumn') + + +def hex_to_rgb(hex_color: str) -> str: + r = int(hex_color[1:3], 16) + g = int(hex_color[3:5], 16) + b = int(hex_color[5:7], 16) + + return f'rgb({r}, {g}, {b})' + + +def get_utc_offset(utc_time: datetime, local_time: datetime): + assert utc_time.tzinfo + assert local_time.tzinfo + + utc = utc_time.replace(tzinfo=None) + local = utc_time.replace(tzinfo=None) + + return (utc - local).total_seconds() + + +def seconds_to_degrees(seconds: float) -> float: + one_second = 360 / 86400 + + return seconds * one_second + + +def indent_lines(string: str, indentation: int = 8) -> str: + return '\n'.join((' ' * indentation) + line for line in string.split('\n')) + '\n' + + +def time_to_degrees(timestamp: Union[datetime, time]) -> float: + return seconds_to_degrees( + timestamp.hour * 3600 + timestamp.minute * 60 + timestamp.second + ) + + +def hour_name_path(image_width: int, outer_r: float, ring_width: float) -> str: + radius = outer_r - ring_width / 2 + delta_x = radius * sin(radians(15) / 2) + delta_y = radius * (1 - cos(radians(15) / 2)) + x1 = image_width / 2 - delta_x + y1 = (image_width / 2 - radius) + delta_y + + return f'''''' + + +def hour_marker( + hour: int, + hour_name: str, + image_width: int, + outer_r: float, + ring_width: float, + hour_name_font_size: float, + utc_font_size: float, + indent: int = 8, +) -> str: + season = SEASONS[ceil((hour + 1) / 6) - 1] + rotation = hour * 15 + + delta_x = outer_r * sin(radians(15) / 2) + delta_y = outer_r * (1 - cos(radians(15) / 2)) + + s_delta_x = 0 - ring_width * sin(radians(15) / 2) + s_delta_y = ring_width * cos(radians(15) / 2) + + i_delta_x = -2 * (outer_r - ring_width) * sin(radians(15) / 2) + + x1 = image_width / 2 - delta_x + y1 = (image_width / 2 - outer_r) + delta_y + + hour_name_y = image_width / 2 - outer_r + ring_width / 2 + hour_name_font_size / 4 + utc_hour_y = image_width / 2 - outer_r + ring_width + utc_font_size + + ret = f''' + + {hour_name} + U {hour:02d} +''' + + return indent_lines(ret, indent) + + +def local_hour( + image_width: int, + outer_r: float, + hour_num: int, + hour: str, + font_size: float, + indent: int = 8, +) -> str: + rotation = hour_num * 15 + + return indent_lines( + f'''{hour}''', + indent, + ) + + +def get_range_path( + image_width: int, + radius: float, + range_name: str, + start_time: time, + end_time: time, + outer: bool = True, + indent: int = 8, +) -> str: + start_deg = time_to_degrees(start_time) + end_deg = time_to_degrees(end_time) + + start_delta_x = radius * sin(radians(start_deg)) + start_delta_y = radius * (1 - cos(radians(start_deg))) + end_delta_x = radius * sin(radians(end_deg)) + end_delta_y = radius * (1 - cos(radians(end_deg))) + + deg_diff = end_deg - start_deg + + large_arc_flag = 0 if abs(deg_diff) >= 180 else 1 + + return indent_lines( + f'''''', + indent, + ) + + +def get_moon_path(image_width: int, radius: float, moon_phase: float, indent: int = 8): + handle_x_pos = radius * 1.34 + handle_y_pos = radius * 0.88 + min_x = image_width / 2 - handle_x_pos + max_x = min_x + 2 * handle_x_pos + top_y = image_width / 2 - handle_y_pos + bottom_y = image_width / 2 + handle_y_pos + + if moon_phase < 14: + h1_x = min_x + 2 * handle_x_pos * (1 - moon_phase / 14) + h2_x = max_x + else: + h1_x = min_x + h2_x = max_x + 2 * handle_x_pos * (1 - moon_phase / 14) + + ret = f'''''' + + return indent_lines(ret, indent) + + +def get_svg_data( + local_time: datetime, + image_width: int = 700, + line_width: str = '2px', + line_color: str = '#eebb55', + utc_color: str = '#5b4426', + hname_stroke_color: str = '#000000', + winter_color: str = '#463e6c', + spring_color: str = '#375737', + summer_color: str = '#715c2b', + autumn_color: str = '#6c442c', + night_color: str = '#13111e', + blue_color: str = '#090177', + golden_color: str = '#aa842c', + day_color: str = '#7dc5f0', + moon_color: str = '#aaaaaa', + local_hour_font_size: float = 16.5, + hour_name_font_size: float = 13.37699, + utc_hour_font_size: float = 15.0234, + hour_24: bool = True, +) -> str: + line_rgb = hex_to_rgb(line_color) + hname_stroke_rgb = hex_to_rgb(hname_stroke_color) + utc_rgb = hex_to_rgb(utc_color) + winter_rgb = hex_to_rgb(winter_color) + spring_rgb = hex_to_rgb(spring_color) + summer_rgb = hex_to_rgb(summer_color) + autumn_rgb = hex_to_rgb(autumn_color) + night_rgb = hex_to_rgb(night_color) + blue_rgb = hex_to_rgb(blue_color) + golden_rgb = hex_to_rgb(golden_color) + day_rgb = hex_to_rgb(day_color) + moon_rgb = hex_to_rgb(moon_color) + hours = HOURS_24 if hour_24 else HOURS_AMPM + outer_r = image_width / 2 - 3 * hour_name_font_size + ring_width = hour_name_font_size * 3 + + utc_time = local_time.astimezone(UTC) + utc_naive = utc_time.replace(tzinfo=None) + local_naive = local_time.replace(tzinfo=None) + offset = (local_naive - utc_naive).total_seconds() + + config = load_config() + + location = LocationInfo( + config['city'], + config['country'], + config['timezone'], + config['latitude'], + config['longitude'], + ) + local_tz = location.tzinfo + day_parts = collect_day_parts(location.observer, local_time) + day_parts_dict = dict(part[0:2] for part in day_parts) + morning_blue_start = day_parts_dict.pop('morning_blue_start') + morning_blue_end = day_parts_dict.pop('morning_golden_start') + morning_golden_end = day_parts_dict.pop('morning_golden_end') + evening_golden_start = day_parts_dict.pop('evening_golden_start') + evening_blue_start = day_parts_dict.pop('evening_blue_start') + evening_blue_end = day_parts_dict.pop('evening_blue_end') + moon_phase = moon.phase(local_time) + + noon = day_parts_dict.pop('noon') + midnight = day_parts_dict.pop('midnight') + + ret = f''' + + + {hour_name_path(image_width, outer_r, ring_width)} + + + \n''' + + for hour in range(24): + ret += local_hour( + image_width, outer_r, hour, hours[hour], local_hour_font_size, indent=8 + ) + + ret += f''' + +''' + for hour in range(24): + ret += hour_marker( + hour, + HOUR_NAMES[hour], + image_width, + outer_r, + ring_width, + hour_name_font_size, + utc_hour_font_size, + indent=8, + ) + + marker_radius = outer_r - ring_width - 2 * utc_hour_font_size + ret += f''' \n''' + ret += get_range_path( + image_width, + marker_radius, + 'golden-hour', + morning_golden_end.time(), + evening_golden_start.time(), + ) + sun_radius = 10 + ret += f''' \n''' + ret += get_range_path( + image_width, + marker_radius, + 'blue-hour', + morning_blue_end.time(), + evening_blue_start.time(), + ) + ret += get_range_path( + image_width, + marker_radius, + 'night-time', + morning_blue_start.time(), + evening_blue_end.time(), + ) + + ret += f''' + + \n''' + + moon_radius = 50 + ret += f''' \n''' + + ret += get_moon_path(image_width, moon_radius, moon_phase, indent=8) + ret += f''' + + + + +''' + + return ret + + +def print_svg(): + utc_now = datetime.utcnow().replace(tzinfo=UTC) + local_now = utc_now.astimezone(timezone('Europe/Budapest')) + + print(get_svg_data(local_now))