Sun indicator

Displays a pretty, dynamically generated indicator of what point in the day or night you are

Image preview of Sun indicator plugin.

sun.1m.py

Edit
Open on GitHub
#!/usr/bin/env python3

# Copyright (c) 2024 Kipras Melnikovas
# MIT License
# https://github.com/kiprasmel/xbar-plugins

# <xbar.title>Sun indicator</xbar.title>
# <xbar.version>v0.1</xbar.version>
# <xbar.author>Kipras Melnikovas</xbar.author>
# <xbar.author.github>kiprasmel</xbar.author.github>
# <xbar.desc>Displays a pretty, dynamically generated indicator of what point in the day or night you are</xbar.desc>
# <xbar.image>https://raw.githubusercontent.com/kiprasmel/xbar-plugins/main/Weather/sunrise-sunset/sun-indicator.png</xbar.image>
# <xbar.dependencies>python,suntime,pillow,ansicolors,scipy</xbar.dependencies>
# <xbar.abouturl>https://github.com/kiprasmel/xbar-plugins/tree/main/Weather/sunrise-sunset</xbar.abouturl>

# <xbar.var>string(VAR_LATITUDE=0.00000): Your latitude to calculate the sun position.</xbar.var>
# <xbar.var>string(VAR_LONGITUDE=0.00000): Your longitude to calculate the sun position.</xbar.var>
# <xbar.var>number(VAR_WIDTH_COMPRESSION_FACTOR=20): The bigger the compression, the smaller the width. Explanation: To accuratelly represent all 1440 minutes in a day, you'd need 1440px of width. Instead, choose how much to compress the width. A compression factor of 20 means that you'll see the visual indicator move slightly every 20 minutes. min = 2, max = 60 (1440/60 = 24 = 1 per hour).</xbar.var>
# <xbar.var>number(VAR_HEIGHT=10): How tall do you want the indicator to be?</xbar.var>
# <xbar.var>number(VAR_BORDER_WIDTH=1): How big should the borders be?</xbar.var>
# <xbar.var>number(VAR_HOURS_OFFSET=0): Debug: how many hours to offset?</xbar.var>
# <xbar.var>boolean(VAR_DRAW_TIME_UNTIL_NEXT_PHASE=false): Display the "time until next phase" indicator?</xbar.var>

import os
import io
import json
import base64
from math import ceil, floor
from datetime import datetime, timezone, timedelta

# deps
from suntime import Sun
from PIL import Image, ImageDraw, ImageFont
from colors import color as ansicolor
# scipy required conditionally below.

def main():
	HOURS = 24
	MINUTES = HOURS * 60

	# ------------------ #
	# begin customizable #
	# ------------------ #

	LATITUDE  = env("VAR_LATITUDE",  "0.00000", float)
	LONGITUDE = env("VAR_LONGITUDE", "0.00000", float)

	WIDTH_COMPRESSION_FACTOR = env("VAR_WIDTH_COMPRESSION_FACTOR", "20", int)
	HOURS_OFFSET             = env("VAR_HOURS_OFFSET",              "0", int)

	height       = env("VAR_HEIGHT",      "10", int)
	border_width = env("VAR_BORDER_WIDTH", "1", int)

	DRAW_TIME_UNTIL_NEXT_PHASE = env("VAR_DRAW_TIME_UNTIL_NEXT_PHASE", "true", boolean)

	# ---------------- #
	# end customizable #
	# ---------------- #

	# RGB
	# TODO customizable
	border_color  = (  0,   0,   0)
	chevron_color = (  0,   0,   0)
	COLOR_DAY     = (255, 255,   0)
	COLOR_NIGHT   = ( 40, 180, 255)
	colors = {
		"day": COLOR_DAY,
		"night": COLOR_NIGHT,
	}
	YEAR_COLOR_DAY   = (255, 255,   0) # TODO
	YEAR_COLOR_NIGHT = ( 40, 180, 255) # TODO
	colors_year = {
		"day": YEAR_COLOR_DAY,
		"night": YEAR_COLOR_NIGHT,
	}

	UNITS = MINUTES // WIDTH_COMPRESSION_FACTOR

	log("vars:")
	log("MINUTES %d" % MINUTES)
	log("UNITS %d" % UNITS)
	log("WIDTH_COMPRESSION_FACTOR %d" % WIDTH_COMPRESSION_FACTOR)
	log("HOURS_OFFSET %d" % HOURS_OFFSET)
	log("LATITUDE %d" % LATITUDE)
	log("LONGITUDE %d" % LONGITUDE)
	log("height %d" % height)
	log("border_width %d" % border_width)
	log("")

	assert(-90  < LATITUDE  <  90)
	assert(-180 < LONGITUDE < 180)
	assert(1 < WIDTH_COMPRESSION_FACTOR <= 60)
	assert(height >= 0)
	assert(border_width >= 0)

	width = UNITS
	border_offset = 0.5 if border_width == 1 else border_width // 2

	log("width %d height %d" % (width, height))
	log("")

	# +1 because otherwise missing 1 px
	image_width  = width  + 2 * border_width + 1
	image_height = height + 2 * border_width + 1

	image = Image.new("RGB", (image_width, image_height), "white")
	draw = ImageDraw.Draw(image)

	draw_border(draw, width, height, border_width, border_color)

	# local time
	now = (datetime.now(timezone.utc) + timedelta(hours=HOURS_OFFSET)).astimezone()
	# now = datetime(2024, 2, 17, 22, 10, 0).astimezone() # night showcase
	# now = datetime(2024, 2, 16, 12, 0, 0).astimezone() # day showcase

	(now_minute,
	sunrise_minute,
	sunset_minute,
	sunrise,
	sunset) = get_time_info_for_latlon(now, LATITUDE, LONGITUDE)

	(colors_by_unit,
	sun_count_by_minute,
	night_count_by_minute) = classify_units_of_time(now_minute,
	                                                sunrise_minute,
	                                                sunset_minute,
	                                                MINUTES,
	                                                UNITS,
	                                                WIDTH_COMPRESSION_FACTOR)

	log("sun_count_by_minute %d night_count_by_minute %d" % (sun_count_by_minute, night_count_by_minute))

	draw_day_night_indicator(draw, colors_by_unit, colors, height, border_width)
	draw_chevron(draw, image_width, image_height, border_width, chevron_color)

	# colors
	colw = lambda x, *y: ansicolor(x, fg="#dddddc", *y) # xbar reverts to black all equal
	cold = lambda x, *y: ansicolor(x, COLOR_DAY, *y)
	coln = lambda x, *y: ansicolor(x, COLOR_NIGHT, *y)

	set_rise_info, percent_done_info, sunrise_in, sunset_in  = get_sunset_sunrise_info(now_minute, sunrise_minute, sunset_minute, HOURS, MINUTES, cold, coln)

	# TODO: some other bug?
	if DRAW_TIME_UNTIL_NEXT_PHASE:
		draw_time_until_next_phase(draw, sunrise_in, sunset_in, width, height)

	image_base64 = enc_image_base64(image)
	# main indicator done

	sun_percent, night_percent = get_percents(sun_count_by_minute, night_count_by_minute)

	rise_at        = cold(format_hours_mins_colon(sunrise))
	set_at         = coln(format_hours_mins_colon(sunset))
	sun_up_for     = cold(format_hours_mins_short(timedelta(minutes=sun_count_by_minute),   True))
	night_down_for = coln(format_hours_mins_short(timedelta(minutes=night_count_by_minute), True))
	up_percent     = cold(sun_percent)
	down_percent   = coln(night_percent)

	day_night_ratio_full = colw("rise ") + rise_at + colw(" for ") + sun_up_for + "\n" + \
	                       colw("set  ") + set_at  + colw(" for ") + night_down_for

	image2 = Image.new("RGB", (width + 1, height + 1), "white")
	draw2 = ImageDraw.Draw(image2)
	# darken 10% (HSL saturation -10%)
	colors2 = {
		"day":   "#f2f20d",
		"night": "#33b1f4",
	}
	draw_day_night_indicator(draw2, sorted(colors_by_unit), colors2, height, 0)
	day_night_ratio_absolute = enc_image_base64(image2)
	# additional indicator done

	img_b64_sun_percent_thru_year = draw_sun_percent_thru_year(now, LATITUDE, LONGITUDE, MINUTES, colors_year)
	img_sun_percent_thru_year = f"| image={img_b64_sun_percent_thru_year}"
	# whole year done

	# equal_spacing_font_arg = "font='Monaco' size=13"
	equal_spacing_font_arg = ""

	xbar_output = f"""
	| image={image_base64}
	---
	{set_rise_info[0]} {colw("(")}{percent_done_info}{colw(")")} | {equal_spacing_font_arg}
	{set_rise_info[1]}                                           | {equal_spacing_font_arg}

	{day_night_ratio_full}

	{up_percent}{colw(":")}{down_percent}
	| image={day_night_ratio_absolute}
	-- {colw("sunrise")} {cold(sunrise)}
	-- {colw("sunset")}  {coln(sunset)}
	--
	-- whole year
	-- {img_sun_percent_thru_year}
	"""

	xbar_output = striplines(xbar_output)

	log("")
	print(xbar_output)
	# done

# utils

def env(x, default, transform = lambda y: y):
	return transform(os.environ.get(x, default))

boolean = lambda x: x == "true"

DEBUG = int(os.environ.get("DEBUG", 0))
def log(*x, lvl=1):
	if DEBUG >= lvl:
		print(*x)

def get_time_info_for_latlon(now, LATITUDE, LONGITUDE, local=True, lvl=1):
	sun = Sun(LATITUDE, LONGITUDE)
	sunrise = sun.get_sunrise_time(now)
	sunset  = sun.get_sunset_time(now)
	if local:
		sunrise = sunrise.astimezone()
		sunset = sunset.astimezone()

	log("now     %s" % now, lvl=lvl)
	log("sunrise %s" % sunrise, lvl=lvl)
	log("sunset  %s" % sunset, lvl=lvl)
	log("", lvl=lvl)

	now_minute     = now.hour     * 60 + now.minute
	sunrise_minute = sunrise.hour * 60 + sunrise.minute
	sunset_minute  = sunset.hour  * 60 + sunset.minute

	log("now     minute %s" %     now_minute, lvl=lvl)
	log("sunrise minute %s" % sunrise_minute, lvl=lvl)
	log("sunset  minute %s" % sunset_minute, lvl=lvl)
	log("", lvl=lvl)
	return now_minute, sunrise_minute, sunset_minute, sunrise, sunset

def classify_units_of_time(now_minute, sunrise_minute, sunset_minute, MINUTES, UNITS, WIDTH_COMPRESSION_FACTOR):
	# calculate uncompressed to get proper accuracy
	sun_count_by_minute   = 0
	night_count_by_minute = 0
	for minute in range(MINUTES):
		is_day = sunrise_minute <= minute <= sunset_minute
		if is_day:
			sun_count_by_minute   += 1
		else:
			night_count_by_minute += 1

	# calculate compressed
	colors_by_unit = [None] * UNITS

	sunrise_unit = sunrise_minute // WIDTH_COMPRESSION_FACTOR
	sunset_unit  = sunset_minute  // WIDTH_COMPRESSION_FACTOR
	for unit in range(UNITS):
		is_day = sunrise_unit <= unit <= sunset_unit
		if is_day:
			color = "day"
		else:
			color = "night"
		colors_by_unit[unit] = color

	# calculate uncompressed to get proper accuracy
	center_minute_abs = MINUTES / 2
	center_minute_rel = now_minute - center_minute_abs
	rotate_index = norm_mod(center_minute_rel, MINUTES) / WIDTH_COMPRESSION_FACTOR
	rotate_index = ceil(rotate_index)
	# why ceil?
	# focus at the time when sunset will be in exactly 12 hours.  this means
	# that all the "day" colors are to the left, and all the night colors to the
	# right (because 12 hours is half of 24 hours, and we're 12 hours away from
	# the center, so nothing from the "day" colors should show up on the right
	# just yet).
	# 
	# now, as soon as 1 more second passes, we're < 12 hours away from sunrise,
	# so we should show at least 1 "day" on the right.  we don't have "seconds",
	# but we have "minutes", hence, once at least 1 minute passes, we show at
	# least one "day" color on the right.  hence, `ceil`.

	# rotate the array to center everything around the current position in time
	colors_by_unit = colors_by_unit[rotate_index:] + colors_by_unit[:rotate_index]

	return colors_by_unit, sun_count_by_minute, night_count_by_minute

def norm_mod(x, mod):
	return ((x % mod) + mod) % mod

# guarantees percents add up to 100, and are properly split up
def get_percents(*counts, total_count=None):
	N = len(counts)

	counts_sum = sum(counts)
	has_custom_count = False

	if total_count == None:
		total_count = counts_sum
	elif total_count != counts_sum:
		has_custom_count = True

	sum_percents = 0
	data = [None] * N
	for i, count in enumerate(counts):
		data[i] = {}
		data[i]["i"] = i
		data[i]["count"] = count
		data[i]["frac"] = count / total_count
		data[i]["percent"] = round(data[i]["frac"] * 100)
		data[i]["rem"] = data[i]["frac"] * (100 * 10) % 10 / 10
		sum_percents += data[i]["percent"]

	if sum_percents < 100 and not has_custom_count:
		# too little, need to add to those who barely didn't round up.
		# too much is impossible, because round() goes up iff > 0.5

		sorted(data, key=lambda x: x["rem"])

		mid = 0
		while data[mid]["frac"] > 0.5 and mid < N:
			mid += 1

		while sum_percents != 100:
			data[mid]["percent"] += 1
			sum_percents += 1
			mid += 1

	# sort by orig idx
	sorted(data, key=lambda x: x["i"])

	log(data)

	return [x["percent"] for x in data]

def draw_border(draw, width, height, border_width, border_color):
	bx1 = 0
	by1 = 0
	bx2 = width  + border_width * 2
	by2 = height + border_width * 2
	draw.rectangle([bx1, by1, bx2, by2], outline=border_color, width=border_width)

def draw_day_night_indicator(draw, colors_by_unit, colors, height, border_width):
	for i, color in enumerate(colors_by_unit):
		x1 = (i	)    + border_width
		x2 = (i + 1) + border_width
		y1 =		   border_width
		y2 = height +  border_width

		draw.rectangle([x1, y1, x2, y2], fill=colors[color])
		log("i %d coords: (%d %d) (%d %d) color %s" % (i, x1, y1, x2, y2, color), lvl=2)

def draw_chevron(draw, image_width, image_height, border_width, chevron_color):
	# TODO: adjust properly when between day & night (now incorrect placement)
	chevron_height = 6
	chevron_width  = 8
	chevron_x      = (image_width - chevron_width) // 2
	chevron_bottom = image_height - border_width
	chevron_top    = chevron_bottom - chevron_height + 2 # TODO AUTO
	chevron_middle = chevron_bottom - chevron_height // 2
	BL = (chevron_x, chevron_bottom)
	MT = (chevron_x + chevron_width // 2, chevron_top)
	BR = (chevron_x + chevron_width, chevron_bottom)
	chevron_points = [BL, MT, BR]
	draw.polygon(chevron_points, fill=chevron_color)
	log(f"chevron polygon: {chevron_points}", lvl=2)

def draw_time_until_next_phase(draw, sunrise_in, sunset_in, width, height):
	# try:
	# 	# font = ImageFont.load("")
	# except:
	font = ImageFont.load_default()
	CHAR_W = 6
	CHAR_H = 8

	if sunrise_in < sunset_in:
		delta = sunrise_in
	else:
		delta = sunset_in

	next_phase_in = timedelta(minutes=delta)
	(next_ph_h, next_ph_m) = parse_hours_mins(next_phase_in)

	will_display_minute_indicator = False

	if next_ph_h > 0:
		if next_ph_h < 10:
			will_display_minute_indicator = True

		next_ph_h = str(next_ph_h)
	else:
		next_ph_h = str(next_ph_m) + "m"


	next_ph_h      = "-" + next_ph_h
	char_count     = len(str(next_ph_h))
	RIGHT_OFFSET   = width - char_count * CHAR_W + 1
	RIGHT_OFFSET  += -4 if will_display_minute_indicator else 0
	next_ph_height = (height - CHAR_H) // 2

	draw.text((RIGHT_OFFSET, next_ph_height), "%s" % next_ph_h, font=font, fill=(0, 0, 0))

	if will_display_minute_indicator:
		min_left  =      next_ph_m // 10
		min_left += 1 if next_ph_m %  10 >= 5 else 0

		# lower part
		dotx1 = width - 2
		doty1 = next_ph_height + 6 + 1 + 1

		# upper part
		dotx2 = dotx1 + 1
		doty2 = doty1 - 1 + 1 - min_left + 0

		draw.rectangle([dotx1, doty1, dotx2, doty2], fill=(0, 0, 0))

def draw_sun_percent_thru_year(now, LATITUDE, LONGITUDE, MINUTES, colors):
	YEAR_DAYS            = 365
	OUR_WIDTH_COMPR_FACT = 12 # factors(1440)
	OUR_UNITS            = MINUTES // OUR_WIDTH_COMPR_FACT

	day = now.replace() # clone
	sun_counts_in_year   = []
	night_counts_in_year = []

	for day_of_year in range(YEAR_DAYS):
		day += timedelta(days=1)

		# local=False to avoid a daylight savings time jump,
		# i.e. would look v ugly
		(now_minute, sunrise_minute, sunset_minute, *_rest) = get_time_info_for_latlon(day, LATITUDE, LONGITUDE, local=False, lvl=2)

		(colors_by_unit, sun_minutes, night_minutes) = classify_units_of_time(now_minute, sunrise_minute, sunset_minute,
		                                                                      MINUTES, OUR_UNITS, OUR_WIDTH_COMPR_FACT)

		sun_count   = sum([1 for x in colors_by_unit if x == "day" ])
		night_count = len(colors_by_unit) - sun_count
		sun_counts_in_year.append(sun_count)
		night_counts_in_year.append(night_count)

	# might not have this dep installed;
	# that's ok, image just won't look as smooth.
	try:
		from scipy.ndimage import uniform_filter1d

		window_size = 30     # days
		mode        = "wrap" # wrap because, well, the year wraps & we start at the same again.
		sun_counts_in_year   = uniform_filter1d(sun_counts_in_year,  window_size, mode=mode)
		night_counts_in_year = uniform_filter1d(night_counts_in_year, window_size, mode=mode)
	except ImportError as e:
		pass

	img_height = MINUTES // OUR_WIDTH_COMPR_FACT
	image      = Image.new("RGB", (YEAR_DAYS , img_height))
	draw       = ImageDraw.Draw(image)

	for day_of_year in range(YEAR_DAYS):
		sun_count = sun_counts_in_year[day_of_year]
		night_count = night_counts_in_year[day_of_year]

		x1 = day_of_year
		x2 = day_of_year
		y1 = 0
		y2 = sun_count
		color = "day"

		draw.rectangle([x1, y1, x2, y2], fill=colors[color])

		y1 = y2
		y2 = sun_count + night_count
		color = "night"

		draw.rectangle([x1, y1, x2, y2], fill=colors[color])

	return enc_image_base64(image)

def enc_image_base64(image):
	buffer = io.BytesIO()
	image.save(buffer, format="PNG")
	imgb64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
	return imgb64

def get_sunset_sunrise_info(now_minute, sunrise_minute, sunset_minute, HOURS, MINUTES, cold, coln):
	sunrise_in = norm_mod(sunrise_minute - now_minute, MINUTES)
	sunset_in  = norm_mod(sunset_minute  - now_minute, MINUTES)
	
	sunrise_info = cold("sunrise in %s" % format_hours_mins(timedelta(minutes=sunrise_in)))
	sunset_info  = coln("sunset  in %s" % format_hours_mins(timedelta(minutes=sunset_in)))
	
	percent_done_info = None
	set_rise_info = None
	if sunrise_minute <= now_minute <= sunset_minute:
		set_rise_info = [sunset_info, sunrise_info]

		total = norm_mod(sunset_minute - sunrise_minute, MINUTES)
		done = norm_mod(now_minute - sunrise_minute, MINUTES)
		night_done, *_rest = get_percents(done, total, total_count=total)
		percent_done_info = cold("day %d%% done" % night_done)
	else:
		set_rise_info = [sunrise_info, sunset_info]

		total = norm_mod(sunrise_minute - sunset_minute, MINUTES)
		done = norm_mod(now_minute - sunset_minute, MINUTES)
		night_done, *_rest = get_percents(done, total, total_count=total)
		percent_done_info = coln("night %d%% done" % night_done)
	
	return set_rise_info, percent_done_info, sunrise_in, sunset_in

def format_hours_mins(timedelta):
	dt = datetime(1,1,1) + timedelta
	return dt.strftime("%Hh %Mm")

def parse_hours_mins(timedelta):
	dt = datetime(1,1,1) + timedelta
	h = int(dt.strftime("%H"))
	m = int(dt.strftime("%M"))
	return h, m

def format_hours_mins_short(timedelta, padmins=False):
	h, m = parse_hours_mins(timedelta)
	hh = f"{h}"
	mm = f"0{m}" if padmins and m <= 9 else m
	return f"{hh}h{mm}m"

def format_hours_mins_colon(dt):
	return dt.strftime("%H") + ":" + dt.strftime("%M")

def striplines(x):
	return "\n".join(
		map(
			lambda y: y.strip(),
			x.strip().split("\n")
		)
	)

if __name__ == '__main__':
	main()