py-seasonal-hours-clock/seasonal_clock/svg.py

507 lines
15 KiB
Python

from datetime import datetime, time
from math import ceil, cos, pi, radians, sin
from typing import Optional, Union
from astral import LocationInfo, moon
from pytz import UTC, timezone
from .config import load_config
from .times import collect_day_parts, get_rahukaalam_times
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)
ret = f'rgb({r}, {g}, {b})'
return ret
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({rotation - 172.5}, {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
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 + 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 draw_rahukaala(
image_width: float,
rahukaala_radius: float,
rahukaala_width: float,
sun_radius: float,
start: datetime,
end: datetime,
indent: int = 8,
) -> str:
start_deg = time_to_degrees(start)
end_deg = time_to_degrees(end)
alpha = radians(end_deg - start_deg)
delta_x = -rahukaala_radius * sin(alpha)
delta_y = -rahukaala_radius * (1 - cos(alpha))
inner_delta_x = -(rahukaala_radius - rahukaala_width) * sin(alpha)
inner_delta_y = -(rahukaala_radius - rahukaala_width) * (1 - cos(alpha))
s_delta_x = rahukaala_width * sin(alpha)
s_delta_y = -rahukaala_width * cos(alpha)
i_delta_x = -2 * (rahukaala_radius - rahukaala_width) * sin(alpha)
x1 = image_width / 2
y1 = image_width / 2 + rahukaala_radius
x2 = x1 + delta_x
y2 = y1 + delta_y
x4 = x1
y4 = y1 - rahukaala_width
x3 = x4 + inner_delta_x
y3 = y4 + inner_delta_y
ret = f'''<path
transform="rotate({start_deg}, {image_width / 2}, {image_width / 2})"
class="rahukaala"
d="M {x1} {y1}
A {rahukaala_radius} {rahukaala_radius} {end_deg - start_deg} 0 1 {x2} {y2}
A {sun_radius} {sun_radius} 180 1 1 {x3} {y3}
A {rahukaala_radius - rahukaala_width} {rahukaala_radius - rahukaala_width} {end_deg - start_deg} 0 0 {x4} {y4}
A {sun_radius} {sun_radius} 180 1 1 {x1} {y1}
z"></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',
rahukaala_color: str = '#ff7777',
rahukaala_alpha: Optional[int] = None,
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)
rahukaala_rgb = hex_to_rgb(rahukaala_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')
if rahukaala_alpha is not None:
value = rahukaala_alpha / value
rahukaala_opacity = f' fill-opacity: {value:.2f};'
else:
rahukaala_opacity = ''
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};}}
.rahukaala {{stroke: none; fill: {rahukaala_rgb};{rahukaala_opacity}}}
</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(),
)
rahukaala_radius = outer_r / 2 + 2 * sun_radius
rahukaala_width = 2 * sun_radius
for start, end in get_rahukaalam_times(location.observer, local_time):
ret += draw_rahukaala(image_width, rahukaala_radius, rahukaala_width, sun_radius, start, end)
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))