442 lines
13 KiB
Python
442 lines
13 KiB
Python
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'''<path id="hour-name-path" d="M {x1} {y1} a {radius} {radius} 15 0 1 {2 * delta_x} 0"></path>'''
|
|
|
|
|
|
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'''<g class="hour {season}" transform="rotate(-172.5, {image_width / 2}, {image_width / 2}) rotate({rotation}, {image_width / 2}, {image_width / 2})">
|
|
<path
|
|
d="M {x1} {y1} a {outer_r} {outer_r} 15 0 1 {2 * delta_x} 0 l {s_delta_x} {s_delta_y} a {outer_r - ring_width} {outer_r - ring_width} 15 0 0 {i_delta_x} 0 z"></path>
|
|
<text
|
|
x="{image_width / 2}"
|
|
y="{hour_name_y}"
|
|
text-anchor="middle"
|
|
dominant-baseline="mathematical"
|
|
font-size="{hour_name_font_size}"><textPath xlink:href="#hour-name-path" startOffset="50%">{hour_name}</textPath></text>
|
|
<text
|
|
transform="rotate(-7.5, {image_width / 2}, {image_width / 2})"
|
|
class="utc"
|
|
x="{image_width / 2}"
|
|
y="{utc_hour_y}"
|
|
text-anchor="middle"
|
|
dominant-baseline="mathematical"
|
|
font-size="{utc_font_size}">U {hour:02d}</text>
|
|
</g>'''
|
|
|
|
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'''<text
|
|
transform="rotate(180, {image_width / 2}, {image_width / 2}) rotate({rotation}, {image_width / 2}, {image_width / 2})"
|
|
class="local-hour"
|
|
x="{image_width / 2}"
|
|
y="{image_width / 2 - outer_r - font_size / 2}"
|
|
text-anchor="middle"
|
|
font-size="{font_size}">{hour}</text>''',
|
|
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'''<path
|
|
class="{range_name}"
|
|
d="M {image_width / 2} {image_width / 2}
|
|
L {image_width / 2 - start_delta_x} {image_width / 2 + radius - start_delta_y}
|
|
A {radius} {radius} {end_deg - start_deg} {large_arc_flag} 0 {image_width / 2 - end_delta_x} {image_width / 2 + radius - end_delta_y}
|
|
z"></path>''',
|
|
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'''<path
|
|
class="moon"
|
|
d="M {image_width / 2} {image_width / 2 - radius}
|
|
C {h1_x} {top_y}, {h1_x} {bottom_y}, {image_width / 2} {image_width / 2 + radius}
|
|
C {h2_x} {bottom_y}, {h2_x} {top_y}, {image_width / 2} {image_width / 2 - radius}"></path>'''
|
|
|
|
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'''<svg width="{image_width}" height="{image_width}" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
<style>
|
|
.hour path {{stroke: {hname_stroke_rgb}; stroke-width: {line_width};}}
|
|
.hour text {{stroke: none; fill: {line_rgb};}}
|
|
.hour text.utc {{stroke: none; fill: {utc_rgb};}}
|
|
.winter path {{fill: {winter_rgb};}}
|
|
.spring path {{fill: {spring_rgb};}}
|
|
.summer path {{fill: {summer_rgb};}}
|
|
.autumn path {{fill: {autumn_rgb};}}
|
|
.local-hour {{stroke: none; fill: {line_rgb};}}
|
|
.night-time {{stroke: none; fill: {night_rgb};}}
|
|
.blue-hour {{stroke: none; fill: {blue_rgb};}}
|
|
.golden-hour {{stroke: none; fill: {golden_rgb};}}
|
|
.day-time {{stroke: none; fill: {day_rgb};}}
|
|
.marker {{stroke: {night_rgb}; stroke-width: 2px; fill: none;}}
|
|
.moon-background {{stroke: {moon_rgb}; stroke-width: 2px; fill: {night_rgb};}}
|
|
.moon {{stroke: none; fill: {moon_rgb};}}
|
|
.sun {{stroke: none; fill: {line_rgb};}}
|
|
.dial {{stroke-width: 2px; stroke: {line_rgb};}}
|
|
</style>
|
|
<defs>
|
|
{hour_name_path(image_width, outer_r, ring_width)}
|
|
</defs>
|
|
<rect x="0" y="0" width="{image_width}" height="{image_width}" style="stroke: none; fill: {night_rgb};"></rect>
|
|
<g id="local-clock">\n'''
|
|
|
|
for hour in range(24):
|
|
ret += local_hour(
|
|
image_width, outer_r, hour, hours[hour], local_hour_font_size, indent=8
|
|
)
|
|
|
|
ret += f''' </g>
|
|
<g id="seasonal-clock" transform="rotate({seconds_to_degrees(offset)}, {image_width / 2}, {image_width / 2})">
|
|
'''
|
|
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''' <circle cx="{image_width / 2}" cy="{image_width / 2}" r="{marker_radius}" class="day-time"></circle>\n'''
|
|
ret += get_range_path(
|
|
image_width,
|
|
marker_radius,
|
|
'golden-hour',
|
|
morning_golden_end.time(),
|
|
evening_golden_start.time(),
|
|
)
|
|
sun_radius = 10
|
|
ret += f''' <circle cx="{image_width / 2}" cy="{image_width / 2 + outer_r / 2 + sun_radius}" r="{sun_radius}" class="sun" transform="rotate({time_to_degrees(local_time.astimezone(UTC))}, {image_width / 2}, {image_width / 2})"></circle>\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''' <circle cx="{image_width / 2}" cy="{image_width / 2}" r="{marker_radius}" class="marker"></circle>
|
|
</g>
|
|
<g>\n'''
|
|
|
|
moon_radius = 50
|
|
ret += f''' <circle cx="{image_width / 2}" cy="{image_width / 2}" r="{moon_radius}" class="moon-background"></circle>\n'''
|
|
|
|
ret += get_moon_path(image_width, moon_radius, moon_phase, indent=8)
|
|
ret += f''' </g>
|
|
<line
|
|
style="stroke: red;"
|
|
transform="rotate({time_to_degrees(midnight.astimezone(local_tz))}, {image_width / 2}, {image_width / 2})"
|
|
x1="{image_width / 2}"
|
|
y1="{image_width / 2 + marker_radius - sun_radius}"
|
|
x2="{image_width / 2}"
|
|
y2="{image_width / 2 + marker_radius}"></line>
|
|
<line
|
|
style="stroke: red;"
|
|
transform="rotate({time_to_degrees(noon.astimezone(local_tz))}, {image_width / 2}, {image_width / 2})"
|
|
x1="{image_width / 2}"
|
|
y1="{image_width / 2 + marker_radius - sun_radius}"
|
|
x2="{image_width / 2}"
|
|
y2="{image_width / 2 + marker_radius}"></line>
|
|
<line
|
|
id="dial"
|
|
transform="rotate({time_to_degrees(local_time)}, {image_width / 2}, {image_width / 2})"
|
|
class="dial"
|
|
x1="{image_width / 2}"
|
|
y1="{image_width / 2 + outer_r * 0.5}"
|
|
x2="{image_width / 2}"
|
|
y2="{image_width / 2 + outer_r - ring_width + hour_name_font_size}"></line>
|
|
</svg>
|
|
'''
|
|
|
|
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))
|