from dataclasses import dataclass, field
from loguru import logger
from typing import Literal, get_args
from datetime import date as dt
import uuid
import re
import yaml
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
from . import TEMPLATE_DIR
DATE_PATTERN = r'^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$'
#-----------------------------------------------------------------------------------------------------------------------
# region DOMAIN
#-----------------------------------------------------------------------------------------------------------------------
DomainTypeLiteral = Literal['hour', 'day', 'week', 'month', 'year']
DomainSortLiteral = Literal['asc', 'desc']
[docs]
@dataclass
class CalendarDomain:
""" Define the calendar domain
See https://cal-heatmap.com/docs/options/domain for all options and what they mean
"""
type: DomainTypeLiteral = 'month'
gutter: int = 4
padding: list[int] = field(default_factory=lambda: [0, 0, 0, 0])
dynamic_dimensions: bool = False
sort: DomainSortLiteral = 'asc'
def __post_init__(self):
""" Check data input """
checks = [
['type', DomainTypeLiteral],
['sort', DomainSortLiteral],
]
for name, literal in checks:
allowed = get_args(literal)
value = self.__dict__[name]
if value not in allowed:
msg = f'Domain parameter {name}={value} is not supported. Use one of {allowed}'
logger.error(msg)
raise ValueError(msg)
if any(i < 0 for i in self.padding):
msg = f'Domain padding={self.padding} cannot contain negative values, usage [T,R,B,L]'
logger.error(msg)
raise ValueError(msg)
#-----------------------------------------------------------------------------------------------------------------------
# region LABEL
#-----------------------------------------------------------------------------------------------------------------------
LabelTextLiteral = Literal['YY', 'YYYY',
'M', 'MM', 'MMM', 'MMMM',
'D', 'DD',
'd', 'dd', 'ddd', 'dddd',
'H', 'HH',
'h', 'hh',
None] # noqa PYI061
LabelPositionLiteral = Literal['top', 'right', 'bottom', 'left']
LabelAlignLiteral = Literal['start', 'middle', 'end']
LabelRotateLiteral = Literal['left', 'right', None] # noqa PYI061
[docs]
@dataclass
class CalendarLabel:
""" Define the calendar label
See https://cal-heatmap.com/docs/options/domain/label for all options and what they mean
"""
text: LabelTextLiteral | None = None
position: LabelPositionLiteral = 'bottom'
align: LabelAlignLiteral = 'middle'
offset: list[int] = field(default_factory=lambda: [0, 0])
rotate: LabelRotateLiteral = None
width: int = 100
height: int = 100
font_size: int = 16
font_weight: int = 450
def __post_init__(self):
""" Check data input """
checks = [
['text', LabelTextLiteral],
['position', LabelPositionLiteral],
['align', LabelAlignLiteral],
['rotate', LabelRotateLiteral],
]
for name, literal in checks:
allowed = get_args(literal)
value = self.__dict__[name]
if value not in allowed:
msg = f'Label parameter {name}={value} is not supported. Use one of {allowed}'
logger.error(msg)
raise ValueError(msg)
for k in ['width', 'height', 'font_size', 'font_weight']:
value = self.__dict__[k]
if value < 0:
msg = f'Label parameter `{k}={value}` cannot be negative.'
logger.error(msg)
raise ValueError(msg)
#-----------------------------------------------------------------------------------------------------------------------
# region SUB-DOMAIN
#-----------------------------------------------------------------------------------------------------------------------
SubdomainTypeLiteral = Literal['minute', 'hour', 'day', 'week', 'month', 'xDay', 'ghDay']
SubdomainSortLiteral = Literal['asc', 'desc']
SubdomainLabelLiteral = Literal['M', 'MM', 'MMM', 'MMMM',
'D', 'DD',
'd', 'dd', 'ddd', 'dddd',
'H', 'HH',
'h', 'hh',
'm', 'mm',
None] # noqa PYI061
[docs]
@dataclass
class CalendarSubDomain:
""" Define the calendar sub domain
See https://cal-heatmap.com/docs/options/subDomainfor all options and what they mean
"""
type: SubdomainTypeLiteral = 'day'
gutter: int = 2
width: int = 10
height: int = 10
radius: int = 0
sort: SubdomainSortLiteral = 'asc'
label: SubdomainLabelLiteral = None
font_size: int = 14
font_weight: int = 450
background_color: str = '#ededed'
def __post_init__(self):
""" Check data input """
checks = [
['type', SubdomainTypeLiteral],
['sort', SubdomainSortLiteral],
['label', SubdomainLabelLiteral],
]
for name, literal in checks:
allowed = get_args(literal)
value = self.__dict__[name]
if value not in allowed:
msg = f'Sub-Domain `{name}={value}` is not supported. Use one of {allowed}'
logger.error(msg)
raise ValueError(msg)
for k in ['gutter', 'width', 'height', 'radius', 'font_size', 'font_weight']:
value = self.__dict__[k]
if value < 0:
msg = f'Sub-Domain `{k}={value}` cannot be negative.'
logger.error(msg)
raise ValueError(msg)
if not re.match(r'^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$', self.background_color):
msg = f'Sub-Domain `background_color` is an invalid hex color: `{self.background_color}`'
logger.error(msg)
raise ValueError(msg)
#-----------------------------------------------------------------------------------------------------------------------
# region DATE
#-----------------------------------------------------------------------------------------------------------------------
[docs]
@dataclass
class CalendarDate:
""" Define the calendar dates
See https://cal-heatmap.com/docs/options/date for all options and what they mean
"""
start: str = dt.today().strftime('%Y-%m-%d')
min: str | None = None
max: str | None = None
highlight: list[str] | None = None
weekstart: int = 1
timezone: str = 'Europe/Berlin'
def __post_init__(self):
""" Check data input """
for k in ['start', 'min' , 'max']:
value = self.__dict__[k]
if value is not None and not re.match(DATE_PATTERN, value):
msg = f'Date parameter `{k}={value}` does not match requirements (YYYY-MM-DD).'
logger.error(msg)
raise ValueError(msg)
if self.highlight is not None:
for d in self.highlight:
if d != 'today' and not re.match(DATE_PATTERN, d):
msg = f'Element in date `highlight={d}` does not match requirements (YYYY-MM-DD) or `today`.'
logger.error(msg)
raise ValueError(msg)
if not (0 <= self.weekstart <= 6): # noqa PLR2004
msg = f'Date `weekstart` must be int from 0 (Sunday) - 6 (Saturday), got {self.weekstart}'
logger.error(msg)
raise ValueError(msg)
#-----------------------------------------------------------------------------------------------------------------------
# region COLOR
#-----------------------------------------------------------------------------------------------------------------------
ColorSchemeLiteral = Literal['YlOrRd', 'Blues', 'Greens', 'Greys', 'Oranges', 'Reds', 'Purples', 'Turbo', 'Viridis',
'Inferno', 'Magma', 'Plasma', 'Cividis', 'Warm', 'Cool', 'Cubehelix', 'BuPu', 'GnBu',
'OrRd', 'PuBuGn', 'PuBu', 'PuRd', 'RdPu', 'YlGnBu', 'YlGn', 'YlOrBr', 'BrBG', 'PRGn',
'PiYG', 'PuOr', 'RdBu', 'RdGy', 'RdYlBu', 'RdYlGn', 'Spectral', 'Rainbow', 'Sinebow',
None] # noqa PYI061
ColorInterpolateLiteral = Literal['rgb', 'hsl', 'lab', 'hcl', None] # noqa PYI061
ColorTypeLiteral = Literal['ordinal', 'linear', 'pow', 'sqrt', 'log', 'symlog', 'categorical', 'sequential', 'cyclical',
'threshold', 'quantile', 'quantize', 'diverging', 'diverging-log', 'diverging-pow',
'diverging-sqrt', 'diverging-symlog']
[docs]
@dataclass
class CalendarColor:
""" Define the calendar color scheme
See https://cal-heatmap.com/docs/options/scale for all options and what they mean
"""
domain: list[float]
scheme: ColorSchemeLiteral = 'Blues'
range: list[str] | None = None
interpolate: ColorInterpolateLiteral = None
type: ColorTypeLiteral = 'linear'
def __post_init__(self):
""" Check data input """
if self.scheme is not None and self.range is not None:
msg = 'Color `scheme` and `range` provided. Use either one or the other.'
logger.error(msg)
raise ValueError(msg)
checks = [
['scheme', ColorSchemeLiteral],
['interpolate', ColorInterpolateLiteral],
['type', ColorTypeLiteral],
]
for name, literal in checks:
allowed = get_args(literal)
value = self.__dict__[name]
if value not in allowed:
msg = f'Label {name}={value} is not supported. Use one of {allowed}'
logger.error(msg)
raise ValueError(msg)
#-----------------------------------------------------------------------------------------------------------------------
# region MISC
#-----------------------------------------------------------------------------------------------------------------------
[docs]
@dataclass
class CalendarMisc:
""" Define the calendar miscellaneous configuration
See https://cal-heatmap.com/docs/options for all options and what they mean
"""
id: str = 'hm-' + str(uuid.uuid4())
range: int = 12
animation_duration: int = 200
vertical_orientation: bool = False
tooltip_enabled: bool = True
tooltip_function: str = 'predefined:date+value'
tooltip_precision: int | None = None
legend_width: int = 300
legend_label: str | None = None
legend_font_size: int = 14
legend_font_weight: int = 450
def __post_init__(self):
""" Check data input """
for k in ['animation_duration', 'range', 'legend_width', 'legend_font_size', 'legend_font_weight']:
value = self.__dict__[k]
if value < 0:
msg = f'Misc. `{k}={value}` cannot be negative.'
logger.error(msg)
raise ValueError(msg)
if self.tooltip_function.startswith('predefined:'):
if self.tooltip_precision is not None:
value = f'${{(value ?? 0).toFixed({self.tooltip_precision})}}'
else:
value = '${value ?? 0}'
options = self.tooltip_function.removeprefix('predefined:')
defined_options = ['value', 'date+value', 'datetime+value']
if options == 'value':
tfn = value
elif options == 'date+value':
tfn = f"${{dayjsDate.format('YYYY-MM-DD')}} : {value}"
elif options == 'datetime+value':
tfn = f"${{dayjsDate.format('YYYY-MM-DD HH:mm:ss')}} : {value}"
else:
msg = (f'Unknown predefined tooltip function {self.tooltip_function}.'
f' Use one of `predefined:{defined_options}`')
logger.error(msg)
raise RuntimeError(msg)
self.tooltip_function = f'function (timestamp, value, dayjsDate) {{return `{tfn}`;}}'
#-----------------------------------------------------------------------------------------------------------------------
# region DATA
#-----------------------------------------------------------------------------------------------------------------------
DataTypeLiteral = Literal['date', 'timestamp']
[docs]
@dataclass
class CalendarData:
""" The actual calendar data to be displayed """
x: list[str]
y: list[float]
type: DataTypeLiteral = 'date'
def __post_init__(self):
""" Check data input """
checks = [
['type', DataTypeLiteral],
]
for name, literal in checks:
allowed = get_args(literal)
value = self.__dict__[name]
if value not in allowed:
msg = f'Label {name}={value} is not supported. Use one of {allowed}'
logger.error(msg)
raise ValueError(msg)
[docs]
def get_table(self):
""" Return the dataset as a 2d list"""
return list(zip(self.x, self.y, strict=True))
#-----------------------------------------------------------------------------------------------------------------------
# region HEATMAP
#-----------------------------------------------------------------------------------------------------------------------
[docs]
class CalendarHeatmap:
""" A calendar heatmap to display in the report """
def __init__(
self,
misc: CalendarMisc,
domain: CalendarDomain,
label: CalendarLabel,
subdomain: CalendarSubDomain,
date: CalendarDate,
color: CalendarColor,
):
""" Create a new heatmap instance
Args:
misc (CalendarMisc): The miscellaneous configuration for the calendar heatmap
domain (CalendarDomain): The domain configuration for the calendar heatmap
label (CalendarLabel): The label configuration for the calendar heatmap
subdomain: (CalendarSubDomain): The subdomain configuration for the calendar heatmap
date (CalendarDate): The date configuration for the calendar heatmap
color (CalendarColor): The color configuration for the calendar heatmap
"""
self.misc: CalendarMisc = misc
self.domain: CalendarDomain = domain
self.label: CalendarLabel = label
self.subdomain: CalendarSubDomain = subdomain
self.date: CalendarDate = date
self.color: CalendarColor = color
self.data: CalendarData | None = None
self.file_loader = FileSystemLoader(TEMPLATE_DIR)
self.jinja_env = Environment(loader=self.file_loader)
[docs]
@classmethod
def from_config_file(cls, cfg_file: str | Path):
""" Load a heatmap from a config file
Args:
cfg_file (str | Path): The path to the config file
"""
cfg = Path(cfg_file)
with open(cfg, 'r') as f:
hm_cfg = yaml.load(f, Loader=yaml.FullLoader)
domain = CalendarDomain(**hm_cfg['domain'])
label = CalendarLabel(**hm_cfg['label'])
subdomain = CalendarSubDomain(**hm_cfg['subdomain'])
date = CalendarDate(**hm_cfg['date'])
color = CalendarColor(**hm_cfg['color'])
misc = CalendarMisc(**hm_cfg['misc'])
return cls(misc, domain, label, subdomain, date, color)
[docs]
def set_color_range(self, rng: list[float]):
""" Set the color range of the heatmap """
self.color.domain = rng
[docs]
def set_data(self, x: list[str], y: list[str], data_type: str='date'):
""" Update the data to be plotted in the heatmap """
self.data = CalendarData(x, y, data_type)
[docs]
def make_heatmap_div(self):
""" Make the heatmap div section """
template = self.jinja_env.get_template('heatmap/heatmap.html')
html = template.render(
misc_id=self.misc.id,
)
return html
[docs]
def make_legend_div(self):
""" Make the heatmap html legend section """
template = self.jinja_env.get_template('heatmap/legend.html')
html = template.render(
misc_id=self.misc.id,
)
return html
[docs]
def make_heatmap_script(self):
""" Make the heatmap html script section """
template = self.jinja_env.get_template('heatmap/script.html')
html = template.render(
# MISC
misc_id=self.misc.id,
misc_range=self.misc.range,
misc_animation_duration=self.misc.animation_duration,
misc_vertical_orientation=str(self.misc.vertical_orientation).lower(),
# TOOLTIP
tooltip_enabled=str(self.misc.tooltip_enabled).lower(),
tooltip_text=self.misc.tooltip_function,
# LEGEND
legend_width=self.misc.legend_width,
legend_label=self.misc.legend_label,
# DOMAIN
domain_type=self.domain.type,
domain_gutter=self.domain.gutter,
domain_padding=self.domain.padding,
domain_dynamic_dimension=str(self.domain.dynamic_dimensions).lower(),
domain_sort=self.domain.sort,
# LABEL
label_text=self.label.text,
label_position=self.label.position,
label_align=self.label.align,
label_offset=self.label.offset,
label_rotate=self.label.rotate,
label_width=self.label.width,
label_height=self.label.height,
# SUBDOMAIN
subdomain_type=self.subdomain.type,
subdomain_gutter=self.subdomain.gutter,
subdomain_width=self.subdomain.width,
subdomain_height=self.subdomain.height,
subdomain_radius=self.subdomain.radius,
subdomain_label=self.subdomain.label,
subdomain_sort=self.subdomain.sort,
# DATE
date_start=self.date.start,
date_min=self.date.min,
date_max=self.date.max,
date_highlight=self.date.highlight,
date_weekstart=self.date.weekstart,
date_timezone=self.date.timezone,
# COLOR
color_scheme=self.color.scheme,
color_range=self.color.range,
color_interpolate=self.color.interpolate,
color_domain=self.color.domain,
color_type=self.color.type,
# DATA
data_type=self.data.type,
data_table=self.data.get_table(),
)
return html
[docs]
def make_heatmap(self):
""" Create the actual heatmap html """
template = self.jinja_env.get_template('heatmap/base.html')
html = template.render(
heatmap_div=self.make_heatmap_div(),
heatmap_script=self.make_heatmap_script(),
button_previous=self.make_button_div('previous'),
button_next=self.make_button_div('next'),
legend_div=self.make_legend_div(),
)
return html
[docs]
def make_css(self):
""" Create the CSS file for the heatmap """
template = self.jinja_env.get_template('css/heatmap.css')
return template.render(
misc_id=self.misc.id,
label_font_size=self.label.font_size,
label_font_weight=self.label.font_weight,
subdomain_font_size=self.subdomain.font_size,
subdomain_font_weight=self.subdomain.font_weight,
subdomain_background_color=self.subdomain.background_color,
legend_font_size=self.misc.legend_font_size,
legend_font_weight=self.misc.legend_font_weight,
)