diff --git a/bin/xmet-spc-render-file b/bin/xmet-spc-render-file index d906c6a..3aa4595 100755 --- a/bin/xmet-spc-render-file +++ b/bin/xmet-spc-render-file @@ -4,7 +4,11 @@ import argparse import cairo from xmet.db import Database -from xmet.spc import SPCOutlookParser, SPCOutlook, SPCOutlookMap + +from xmet.spc import SPCOutlookParser, \ + SPCOutlook, \ + SPCOutlookMap, \ + SPCOutlookType ASSETS = { 'light': { @@ -32,6 +36,7 @@ def render_categorical(conus: SPCOutlookMap, conus.draw_categories(cr, outlook) conus.draw_cities(cr, db) conus.draw_logo(cr, assets['logo']) + conus.draw_legend(cr, SPCOutlookType.CATEGORICAL) if args.dark: cr.set_source_rgb(1, 1, 1) @@ -54,6 +59,7 @@ def render_probabilistic(conus: SPCOutlookMap, conus.draw_probabilities(cr, outlook, hazard.upper()) conus.draw_cities(cr, db) conus.draw_logo(cr, assets['logo']) + conus.draw_legend(cr, SPCOutlookType.PROBABILISTIC) if args.dark: cr.set_source_rgb(1, 1, 1) diff --git a/lib/xmet/spc.py b/lib/xmet/spc.py index d031fee..71939a8 100644 --- a/lib/xmet/spc.py +++ b/lib/xmet/spc.py @@ -9,6 +9,7 @@ from xmet.coord import COORD_SYSTEM from xmet.city import City from xmet.map import EquirectMap, MAP_SCREEN_DIMENSIONS, MAP_BOUNDS from xmet.afos import MONTHS, TIMEZONES +from xmet.draw import draw_rounded_rect from pyiem.nws.products._outlook_util import ( condition_segment, @@ -64,57 +65,58 @@ RE_POINTS_START = re.compile(r''' RE_POINTS = re.compile(r'^(?:\s+\d{8}){1,6}$') CITIES = { - 'WA': ('Seattle', 'Spokane'), - 'OR': ('Portland', 'Eugene', 'Medford'), 'CA': ( 'Redding', 'Sacramento', 'San Francisco', 'Fresno', 'Santa Barbara', 'Los Angeles', 'San Diego' ), - 'ID': ('Boise', 'Pocatello'), - 'NV': ('Elko', 'Reno', 'Las Vegas'), - 'UT': ('Salt Lake City', 'Cedar City'), - 'AZ': ('Flagstaff', 'Phoenix', 'Tucson'), - 'MT': ('Great Falls', 'Missoula', 'Butte', 'Billings'), - 'WY': ('Sheridan', 'Jackson', 'Casper', 'Cheyenne'), - 'CO': ('Denver', 'Grand Junction', 'Pueblo', 'Durango'), - 'NM': ('Santa Fe', 'Albuquerque', 'Las Cruces'), - 'ND': ('Minot', 'Bismarck', 'Fargo', 'Grand Forks'), - 'SD': ('Aberdeen', 'Pierre', 'Rapid City', 'Sioux Falls'), - 'NE': ('Omaha', 'Lincoln', 'McCook', 'Norfolk'), - 'KS': ('Wichita', 'Colby', 'Liberal', 'Garden City'), - 'OK': ('Woodward', 'Tulsa', 'Oklahoma City', 'Norman', 'Altus'), 'TX': ( 'Amarillo', 'Wichita Falls', 'Lubbock', 'Dallas', 'Abilene', 'Midland', 'Waco', 'Austin', 'San Antonio', 'Houston', 'Corpus Christi', 'Brownsville' ), - 'LA': ('Shreveport', 'New Orleans', 'Alexandria',), - 'AR': ('Little Rock', 'Bentonville'), - 'MO': ('Jefferson City', 'Kansas City'), - 'IA': ('Des Moines',), - 'MN': ('Minneapolis', 'Duluth'), - 'MI': ('Marquette', 'Detroit'), - 'WI': ('Green Bay', 'Milwaukee'), - 'IL': ('Chicago', 'Peoria', 'Springfield'), - 'KY': ('Louisville', 'Paducah', 'Bowling Green'), - 'TN': ('Nashville', 'Jackson', 'Memphis'), - 'MS': ('Jackson',), 'AL': ('Tuscaloosa', 'Mobile'), - 'GA': ('Atlanta', 'Columbus'), - 'FL': ('Tallahassee', 'Jacksonville', 'Orlando', 'Tampa', 'Miami'), - 'SC': ('Columbia', 'Charleston'), - 'NC': ('Charlotte', 'Raleigh', 'Wilmington'), - 'VA': ('Roanoke', 'Richmond'), - 'WV': ('Charleston',), + 'AR': ('Little Rock', 'Bentonville'), + 'AZ': ('Flagstaff', 'Phoenix', 'Tucson'), + 'CO': ('Denver', 'Grand Junction', 'Pueblo', 'Durango'), + 'CT': ('Hartford',), 'DC': ('Washington',), - 'MD': ('Baltimore',), - 'OH': ('Columbus', 'Cincinnati'), + 'FL': ('Tallahassee', 'Jacksonville', 'Orlando', 'Tampa', 'Miami'), + 'GA': ('Atlanta', 'Columbus'), + 'IA': ('Des Moines',), + 'ID': ('Boise', 'Pocatello'), + 'IL': ('Chicago', 'Peoria', 'Springfield'), 'IN': ('Fort Wayne', 'Indianapolis'), - 'PA': ('Philadelphia', 'Pittsburgh', 'Scranton'), - 'NY': ('Rochester', 'Buffalo', 'New York'), - 'VT': ('Burlington',), + 'KS': ('Wichita', 'Colby', 'Liberal', 'Garden City'), + 'KY': ('Louisville', 'Paducah', 'Bowling Green'), + 'LA': ('Shreveport', 'New Orleans', 'Alexandria',), + 'MA': ('Boston',), + 'MD': ('Baltimore',), 'ME': ('Portland',), - 'MA': ('Boston',) + 'MI': ('Marquette', 'Detroit'), + 'MN': ('Minneapolis', 'Duluth'), + 'MO': ('Jefferson City', 'Kansas City'), + 'MS': ('Jackson',), + 'MT': ('Great Falls', 'Missoula', 'Butte', 'Billings'), + 'NC': ('Charlotte', 'Raleigh', 'Wilmington'), + 'ND': ('Minot', 'Bismarck', 'Fargo', 'Grand Forks'), + 'NE': ('Omaha', 'Lincoln', 'McCook', 'Norfolk'), + 'NM': ('Santa Fe', 'Albuquerque', 'Las Cruces'), + 'NV': ('Elko', 'Reno', 'Las Vegas'), + 'NY': ('Rochester', 'Buffalo', 'New York'), + 'OH': ('Columbus', 'Cincinnati'), + 'OK': ('Woodward', 'Tulsa', 'Oklahoma City', 'Norman', 'Altus'), + 'OR': ('Portland', 'Eugene', 'Medford'), + 'PA': ('Philadelphia', 'Pittsburgh', 'Scranton'), + 'SC': ('Columbia', 'Charleston'), + 'SD': ('Aberdeen', 'Pierre', 'Rapid City', 'Sioux Falls'), + 'TN': ('Nashville', 'Jackson', 'Memphis'), + 'UT': ('Salt Lake City', 'Cedar City'), + 'VA': ('Roanoke', 'Richmond'), + 'VT': ('Burlington',), + 'WA': ('Seattle', 'Spokane'), + 'WI': ('Green Bay', 'Milwaukee'), + 'WV': ('Charleston',), + 'WY': ('Sheridan', 'Jackson', 'Casper', 'Cheyenne'), } class SPCOutlookParserException(Exception): @@ -542,13 +544,114 @@ class SPCOutlookParser(): return self.outlook +class SPCOutlookType(enum.Enum): + CATEGORICAL = 1 + PROBABILISTIC = 2 + +class SPCOutlookLegend(): + MARGIN = 16 + RADIUS = 16 + + COLOR_WIDTH = 32 + COLOR_HEIGHT = 32 + + FONT_FACE = 'Muli' + FONT_SIZE = 16 + + def __init__(self, colors: dict[str, tuple[float, float, float]]): + self.colors = colors + + self.text_width = 0.0 + self.width = None + self.height = None + + def find_size(self, cr: cairo.Context) -> tuple[float, float]: + cr.save() + + count = 0 + + cr.select_font_face(self.FONT_FACE, + cairo.FONT_SLANT_NORMAL, + cairo.FONT_WEIGHT_BOLD) + + cr.set_font_size(self.FONT_SIZE) + + for key in self.colors: + if key is None: + continue + + extents = cr.text_extents(str(key)) + + if self.text_width < extents.width: + self.text_width = extents.width + + count += 1 + + self.width = 3 * self.MARGIN + self.COLOR_WIDTH + self.text_width + self.height = self.MARGIN + count * (self.COLOR_HEIGHT + self.MARGIN) + + cr.restore() + + return self.width, self.height + + def draw_item(self, cr: cairo.Context, key: str, x: float, y: float): + cr.save() + + cr.set_source_rgb(*self.colors[key]) + cr.rectangle(x, y, self.COLOR_WIDTH, self.COLOR_HEIGHT) + cr.fill() + + cr.set_source_rgb(0, 0, 0) + cr.rectangle(x, y, self.COLOR_WIDTH, self.COLOR_HEIGHT) + cr.stroke() + + text = str(key) + + extents = cr.text_extents(text) + + cr.move_to(x + self.COLOR_WIDTH + self.MARGIN, + y + self.COLOR_WIDTH / 2 + extents.height / 2) + + cr.show_text(text) + + cr.restore() + + def draw(self, cr: cairo.Context, x: float, y: float): + cr.save() + + cr.select_font_face(self.FONT_FACE, + cairo.FONT_SLANT_NORMAL, + cairo.FONT_WEIGHT_BOLD) + + cr.set_font_size(self.FONT_SIZE) + + cr.set_source_rgba(0.2, 0.2, 0.2, 0.5) + draw_rounded_rect(cr, x, y, self.width, self.height, self.RADIUS) + + cr.fill() + + i = 0 + + for key in self.colors: + if key is None: + continue + + item_y = y + self.MARGIN + i * (self.COLOR_HEIGHT + self.MARGIN) + + self.draw_item(cr, key, x + self.MARGIN, item_y) + + i += 1 + + cr.restore() + class SPCOutlookMap(EquirectMap): TEXT_FONT = 'Muli' LOGO_RATIO = 75.0 / 275.0 LOGO_WIDTH = 360 LOGO_HEIGHT = LOGO_RATIO * LOGO_WIDTH - LOGO_MARGIN = 16 + + MARGIN = 16 __category_colors__ = { 'TSTM': (212/255.0, 240/255.0, 213/255.0), @@ -581,12 +684,36 @@ class SPCOutlookMap(EquirectMap): cr.line_to(4, 4) cr.stroke() + def draw_legend(self, + cr: cairo.Context, + kind: SPCOutlookType): + if kind is SPCOutlookType.CATEGORICAL: + colors = self.__category_colors__ + elif kind is SPCOutlookType.PROBABILISTIC: + colors = dict() + + for key in self.__probability_colors__: + if key is None: + continue + + text = "%d%%" % int(key * 100) + + colors[text] = self.__probability_colors__[key] + + legend = SPCOutlookLegend(colors) + size = legend.find_size(cr) + + x = self.width - 3 * self.MARGIN - size[0] + y = self.height - 6 * self.MARGIN - size[1] + + legend.draw(cr, x, y) + def draw_logo(self, cr: cairo.Context, path: str): cr.save() width = self.LOGO_WIDTH height = width * self.LOGO_RATIO - margin = self.LOGO_MARGIN + margin = self.MARGIN x = margin y = self.height - height - margin