Source code for basic_report.heatmap

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_button_div(self, direction: str): """ Make the previous/next button div """ if direction == 'next': template = self.jinja_env.get_template('heatmap/button_next.html') elif direction == 'previous': template = self.jinja_env.get_template('heatmap/button_prev.html') else: msg = 'Unknown button direction `{direction}`. Use either `next` or `previous`.' logger.error(msg) raise RuntimeError(msg) html = template.render() return html
[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, )