Attached are a couple of instrumented files that should tell us what is 
happening. Could you rename:

/usr/share/weewx/weeplot/genplot.py to 
/usr/share/weewx/weeplot/genplot_orig.py
/usr/share/weewx/weeplot/utilities.py to 
/usr/share/weewx/weeplot/utilities_orig.py

and then download and save the attached genplot.py and utilities.py to 
directory usr/share/weewx/weeplot

Then restart WeeWX and let it complete at least one full report cycle. Take 
a copy of the log from WeeWX startup through until the first report cycle 
finishes and post the log here. 

Gary

On Tuesday, 6 April 2021 at 14:47:59 UTC+10 [email protected] wrote:

> Yes, /etc/weewx/skins/Seasons/font/OpenSans-Bold.ttf.  Weewx version is 
> 4.40, Python is 2.7.17.  This is on Ubuntu 18.04.5 LTS.
>
> --Richard
>
> On Monday, April 5, 2021 at 8:55:46 PM UTC-7 gjr80 wrote:
>
>> So when you say that '"font/OpenSans-Bold.ttf", which does exist', where 
>> exactly does it exist? /etc/weewx/skins/Seasons/font/OpenSans-Bold.ttf? 
>> OpenSans-Bold.ttf is more than adequate, my install is using it and 
>> rendering µg/m³ just fine. The symptoms sounds very much like PIL is giving 
>> you a default font every time. What version of WeeWx are you using and 
>> under which version of python is it running(will be in the log on WeeWX 
>> startup)?
>>
>> Gary
>>
>> On Tuesday, 6 April 2021 at 10:00:06 UTC+10 [email protected] wrote:
>>
>>> Dug through that.  The font referenced for unit_label_font_path in 
>>> skin.conf for Seasons is "font/OpenSans-Bold.ttf", which does exist. 
>>>  Thinking it might not have full support for the Unicode math symbols, I 
>>> changed that to "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", which 
>>> supposedly covers all 256 symbols.  And I still have the weird unit 
>>> label...nothing there changed.  I see no error messages WRT rendering in 
>>> the log as described in the User's Guide.
>>>
>>> What's quite odd is that the rendered text contains both the "micro" and 
>>> the "cubed" symbols...it just has the extra garbage as well.
>>>
>>> And I found where this unit value gets defined:  in 
>>> /usr/share/weewx/unit.py: "microgram_per_meter_cubed": u"µg/m³".  In the 
>>> HTML for the "current" line, this looks reasonable:  "µg/m³".   
>>> But I have yet to make the image generator happy.
>>>
>>> --Richard
>>>
>>>
>>>
>>> On Monday, April 5, 2021 at 2:39:06 PM UTC-7 gjr80 wrote:
>>>
>>>> Hi,
>>>>
>>>> Nothing to do with drivers or extensions, it’s a font issue. Have a 
>>>> read of the section Funky symbols in plots 
>>>> <http://weewx.com/docs/usersguide.htm#funky_symbols> in the User’s 
>>>> Guide.
>>>>
>>>> Gary
>>>> On Tuesday, 6 April 2021 at 07:31:17 UTC+10 [email protected] wrote:
>>>>
>>>>> This is a minor annoyance, but I am failing to figure out where this 
>>>>> is being done, or how to fix it.  Units for PM2.5 are micrograms per 
>>>>> meter 
>>>>> cubed.  I've got a "current" where the units look as expected: µg/m³.
>>>>>
>>>>> But in the generated history images, the units in the upper show as 
>>>>> shown in this image: [image: Screen Shot 2021-04-05 at 2.27.13 PM.png]
>>>>> Station data is coming from the WX1000 extension and in this case, 
>>>>> some of it from the PurpleAir extension.  Looking through both 
>>>>> extensions, 
>>>>> I am not seeing who/what is choosing the unit string for these values, 
>>>>> nor 
>>>>> where it's getting sucked into the image generator.  Hints?  I am a week 
>>>>> into using WeeWX, and am quite impressed, but finding where the bodies 
>>>>> are 
>>>>> buried for this sort of magic.
>>>>>
>>>>> --Richard
>>>>>
>>>>>

-- 
You received this message because you are subscribed to the Google Groups 
"weewx-user" group.
To unsubscribe from this group and stop receiving emails from it, send an email 
to [email protected].
To view this discussion on the web visit 
https://groups.google.com/d/msgid/weewx-user/d9e7b7d1-bbf1-470b-97fe-d3a6ffbfd651n%40googlegroups.com.
#
#    Copyright (c) 2009-2021 Tom Keffer <[email protected]>
#
#    See the file LICENSE.txt for your full rights.
#
"""Various utilities used by the plot package.

"""
from __future__ import absolute_import
from __future__ import print_function
from six.moves import zip
try:
    from PIL import ImageFont, ImageColor
except ImportError:
    import ImageFont, ImageColor
import datetime
import logging
import time
import math

import six

import weeplot

log = logging.getLogger(__name__)

def scale(data_min, data_max, prescale=(None, None, None), nsteps=10):
    """Calculates an appropriate min, max, and step size for scaling axes on a plot.

    The origin (zero) is guaranteed to be on an interval boundary.

    data_min: The minimum data value

    data_max: The maximum data value. Must be greater than or equal to data_min.

    prescale: A 3-way tuple. A non-None min or max value (positions 0 and 1,
    respectively) will be fixed to that value. A non-None interval (position 2)
    be at least as big as that value. Default = (None, None, None)

    nsteps: The nominal number of desired steps. Default = 10

    Returns: a three-way tuple. First value is the lowest scale value, second the highest.
    The third value is the step (increment) between them.

    Examples:
    >>> print("(%.1f, %.1f, %.1f)" % scale(1.1, 12.3, (0, 14, 2)))
    (0.0, 14.0, 2.0)
    >>> print("(%.1f, %.1f, %.1f)" % scale(1.1, 12.3))
    (0.0, 14.0, 2.0)
    >>> print("(%.1f, %.1f, %.1f)" % scale(-1.1, 12.3))
    (-2.0, 14.0, 2.0)
    >>> print("(%.1f, %.1f, %.1f)" % scale(-12.1, -5.3))
    (-13.0, -5.0, 1.0)
    >>> print("(%.2f, %.2f, %.2f)" % scale(10.0, 10.0))
    (10.00, 10.10, 0.01)
    >>> print("(%.2f, %.4f, %.4f)" % scale(10.0, 10.001))
    (10.00, 10.0010, 0.0001)
    >>> print("(%.2f, %.2f, %.2f)" % scale(10.0, 10.0+1e-8))
    (10.00, 10.10, 0.01)
    >>> print("(%.2f, %.2f, %.2f)" % scale(0.0, 0.05, (None, None, .1), 10))
    (0.00, 1.00, 0.10)
    >>> print("(%.2f, %.2f, %.2f)" % scale(16.8, 21.5, (None, None, 2), 10))
    (16.00, 36.00, 2.00)
    >>> print("(%.2f, %.2f, %.2f)" % scale(16.8, 21.5, (None, None, 2), 4))
    (16.00, 22.00, 2.00)
    >>> print("(%.2f, %.2f, %.2f)" % scale(0.0, 0.21, (None, None, .02)))
    (0.00, 0.22, 0.02)
    >>> print("(%.2f, %.2f, %.2f)" % scale(100.0, 100.0, (None, 100, None)))
    (99.00, 100.00, 0.20)
    >>> print("(%.2f, %.2f, %.2f)" % scale(100.0, 100.0, (100, None, None)))
    (100.00, 101.00, 0.20)
    >>> print("(%.2f, %.2f, %.2f)" % scale(100.0, 100.0, (0, None, None)))
    (0.00, 120.00, 20.00)
    >>> print("(%.2f, %.2f, %.2f)" % scale(0.0, 0.2, (None, 100, None)))
    (0.00, 100.00, 20.00)

    """

    # If all the values are hard-wired in, then there's nothing to do:
    if None not in prescale:
        return prescale

    # Unpack
    minscale, maxscale, min_interval = prescale

    # Make sure data_min and data_max are float values, in case a user passed
    # in integers:
    data_min = float(data_min)
    data_max = float(data_max)

    if data_max < data_min:
        raise weeplot.ViolatedPrecondition("scale() called with max value less than min value")

    # In case minscale and/or maxscale was specified, clip data_min and data_max to make sure they
    # stay within bounds
    if maxscale is not None:
        data_max = min(data_max, maxscale)
        if data_max < data_min:
            data_min = data_max
    if minscale is not None:
        data_min = max(data_min, minscale)
        if data_max < data_min:
            data_max = data_min

    # Check the special case where the min and max values are equal.
    if _rel_approx_equal(data_min, data_max):
        # They are equal. We need to move one or the other to create a range, while
        # being careful that the resultant min/max stay within the interval [minscale, maxscale]
        # Pick a step out value based on min_interval if the user has supplied one. Otherwise,
        # arbitrarily pick 0.1
        if min_interval is not None:
            step_out = min_interval * nsteps
        else:
            step_out = 0.01 * round(abs(data_max), 2) if data_max else 0.1
        if maxscale is not None:
            # maxscale if fixed. Move data_min.
            data_min = data_max - step_out
        elif minscale is not None:
            # minscale if fixed. Move data_max.
            data_max = data_min + step_out
        else:
            # Both can float. Check special case where data_min and data_max are zero
            if data_min == 0.0:
                data_max = 1.0
            else:
                # Just arbitrarily move one. Say, data_max.
                data_max = data_min + step_out

    if minscale is not None and maxscale is not None:
        if maxscale < minscale:
            raise weeplot.ViolatedPrecondition("scale() called with prescale max less than min")
        frange = maxscale - minscale
    elif minscale is not None:
        frange = data_max - minscale
    elif maxscale is not None:
        frange = maxscale - data_min
    else:
        frange = data_max - data_min
    steps = frange / float(nsteps)

    mag = math.floor(math.log10(steps))
    magPow = math.pow(10.0, mag)
    magMsd = math.floor(steps / magPow + 0.5)

    if magMsd > 5.0:
        magMsd = 10.0
    elif magMsd > 2.0:
        magMsd = 5.0
    else:  # magMsd > 1.0
        magMsd = 2

    # This will be the nominal interval size
    interval = magMsd * magPow

    # Test it against the desired minimum, if any
    if min_interval is None or interval >= min_interval:
        # Either no min interval was specified, or its safely
        # less than the chosen interval.
        if minscale is None:
            minscale = interval * math.floor(data_min / interval)

        if maxscale is None:
            maxscale = interval * math.ceil(data_max / interval)

    else:

        # The request for a minimum interval has kicked in.
        # Sometimes this can make for a plot with just one or
        # two intervals in it. Adjust the min and max values
        # to get a nice plot
        interval = float(min_interval)

        if minscale is None:
            if maxscale is None:
                # Both can float. Pick values so the range is near the bottom
                # of the scale:
                minscale = interval * math.floor(data_min / interval)
                maxscale = minscale + interval * nsteps
            else:
                # Only minscale can float
                minscale = maxscale - interval * nsteps
        else:
            if maxscale is None:
                # Only maxscale can float
                maxscale = minscale + interval * nsteps
            else:
                # Both are fixed --- nothing to be done
                pass

    return minscale, maxscale, interval


def scaletime(tmin_ts, tmax_ts) :
    """Picks a time scaling suitable for a time plot.
    
    tmin_ts, tmax_ts: The time stamps in epoch time around which the times will be picked.
    
    Returns a scaling 3-tuple. First element is the start time, second the stop
    time, third the increment. All are in seconds (epoch time in the case of the 
    first two).    
    
    Example 1: 24 hours on an hour boundary
    >>> from weeutil.weeutil import timestamp_to_string as to_string
    >>> time_ts = time.mktime(time.strptime("2013-05-17 08:00", "%Y-%m-%d %H:%M"))
    >>> xmin, xmax, xinc = scaletime(time_ts - 24*3600, time_ts)
    >>> print(to_string(xmin), to_string(xmax), xinc)
    2013-05-16 09:00:00 PDT (1368720000) 2013-05-17 09:00:00 PDT (1368806400) 10800

    Example 2: 24 hours on a 3-hour boundary
    >>> time_ts = time.mktime(time.strptime("2013-05-17 09:00", "%Y-%m-%d %H:%M"))
    >>> xmin, xmax, xinc = scaletime(time_ts - 24*3600, time_ts)
    >>> print(to_string(xmin), to_string(xmax), xinc)
    2013-05-16 09:00:00 PDT (1368720000) 2013-05-17 09:00:00 PDT (1368806400) 10800

    Example 3: 24 hours on a non-hour boundary
    >>> time_ts = time.mktime(time.strptime("2013-05-17 09:01", "%Y-%m-%d %H:%M"))
    >>> xmin, xmax, xinc = scaletime(time_ts - 24*3600, time_ts)
    >>> print(to_string(xmin), to_string(xmax), xinc)
    2013-05-16 12:00:00 PDT (1368730800) 2013-05-17 12:00:00 PDT (1368817200) 10800

    Example 4: 27 hours
    >>> time_ts = time.mktime(time.strptime("2013-05-17 07:45", "%Y-%m-%d %H:%M"))
    >>> xmin, xmax, xinc = scaletime(time_ts - 27*3600, time_ts)
    >>> print(to_string(xmin), to_string(xmax), xinc)
    2013-05-16 06:00:00 PDT (1368709200) 2013-05-17 09:00:00 PDT (1368806400) 10800

    Example 5: 3 hours on a 15 minute boundary
    >>> time_ts = time.mktime(time.strptime("2013-05-17 07:45", "%Y-%m-%d %H:%M"))
    >>> xmin, xmax, xinc = scaletime(time_ts - 3*3600, time_ts)
    >>> print(to_string(xmin), to_string(xmax), xinc)
    2013-05-17 05:00:00 PDT (1368792000) 2013-05-17 08:00:00 PDT (1368802800) 900

    Example 6: 3 hours on a non-15 minute boundary
    >>> time_ts = time.mktime(time.strptime("2013-05-17 07:46", "%Y-%m-%d %H:%M"))
    >>> xmin, xmax, xinc = scaletime(time_ts - 3*3600, time_ts)
    >>> print(to_string(xmin), to_string(xmax), xinc)
    2013-05-17 05:00:00 PDT (1368792000) 2013-05-17 08:00:00 PDT (1368802800) 900

    Example 7: 12 hours
    >>> time_ts = time.mktime(time.strptime("2013-05-17 07:46", "%Y-%m-%d %H:%M"))
    >>> xmin, xmax, xinc = scaletime(time_ts - 12*3600, time_ts)
    >>> print(to_string(xmin), to_string(xmax), xinc)
    2013-05-16 20:00:00 PDT (1368759600) 2013-05-17 08:00:00 PDT (1368802800) 3600

    Example 8: 15 hours
    >>> time_ts = time.mktime(time.strptime("2013-05-17 07:46", "%Y-%m-%d %H:%M"))
    >>> xmin, xmax, xinc = scaletime(time_ts - 15*3600, time_ts)
    >>> print(to_string(xmin), to_string(xmax), xinc)
    2013-05-16 17:00:00 PDT (1368748800) 2013-05-17 08:00:00 PDT (1368802800) 7200
    """
    if tmax_ts <= tmin_ts :
        raise weeplot.ViolatedPrecondition("scaletime called with tmax <= tmin")
    
    tdelta = tmax_ts - tmin_ts
    
    tmin_dt = datetime.datetime.fromtimestamp(tmin_ts)
    tmax_dt = datetime.datetime.fromtimestamp(tmax_ts)
    
    if tdelta <= 16 * 3600:
        if tdelta <= 3*3600:
            # For time intervals less than 3 hours, use an increment of 15 minutes
            interval = 900
        elif tdelta <= 12 * 3600:
            # For intervals from 3 hours up through 12 hours, use one hour
            interval = 3600
        else:
            # For intervals from 12 through 16 hours, use two hours.
            interval = 7200
        # Get to the one hour boundary below tmax:
        stop_dt = tmax_dt.replace(minute=0, second=0, microsecond=0)
        # if tmax happens to be on a one hour boundary we're done. Otherwise, round
        # up to the next one hour boundary:
        if tmax_dt > stop_dt:
            stop_dt += datetime.timedelta(hours=1)
        n_hours = int((tdelta + 3599) / 3600)
        start_dt = stop_dt - datetime.timedelta(hours=n_hours)
        
    elif tdelta <= 27 * 3600:
        # A day plot is wanted. A time increment of 3 hours is appropriate
        interval = 3 * 3600
        # h is the hour of tmax_dt
        h = tmax_dt.timetuple()[3]
        # Subtract off enough to get to the lower 3-hour boundary from tmax: 
        stop_dt = tmax_dt.replace(minute=0, second=0, microsecond=0) - datetime.timedelta(hours = h % 3)
        # If tmax happens to lie on a 3 hour boundary we don't need to do anything. If not, we need
        # to round up to the next 3 hour boundary:
        if tmax_dt > stop_dt:
            stop_dt += datetime.timedelta(hours=3)
        # The stop time is one day earlier
        start_dt = stop_dt - datetime.timedelta(days=1)
        
        if tdelta == 27 * 3600 :
            # A "slightly more than a day plot" is wanted. Start 3 hours earlier:
            start_dt -= datetime.timedelta(hours=3)
    
    elif 27 * 3600 < tdelta <= 31 * 24 * 3600 :
        # The time scale is between a day and a month. A time increment of one day is appropriate
        start_dt = tmin_dt.replace(hour=0, minute=0, second=0, microsecond=0)
        stop_dt  = tmax_dt.replace(hour=0, minute=0, second=0, microsecond=0)
        
        tmax_tt = tmax_dt.timetuple()
        if tmax_tt[3]!=0 or tmax_tt[4]!=0 :
            stop_dt += datetime.timedelta(days=1)
            
        interval = 24 * 3600
    elif tdelta < 2 * 365.25 * 24 * 3600 :
        # The time scale is between a month and 2 years. A time increment of a month is appropriate
        start_dt = tmin_dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
        
        (year , mon, day) = tmax_dt.timetuple()[0:3]
        if day != 1 :
            mon += 1
            if mon==13 :
                mon = 1
                year += 1
        stop_dt = datetime.datetime(year, mon, 1)
        # Average month length:
        interval = 365.25/12 * 24 * 3600
    else :
        # The time scale is between a month and 2 years. A time increment of a year is appropriate
        start_dt = tmin_dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0)

        (year , mon, day) = tmax_dt.timetuple()[0:3]
        if day != 1 or mon !=1 :
            day = 1
            mon = 1
            year += 1
        stop_dt = datetime.datetime(year, mon, 1)
        # Average year length
        interval = 365.25 * 24 * 3600

    # Convert to epoch time stamps
    start_ts = int(time.mktime(start_dt.timetuple()))
    stop_ts  = int(time.mktime(stop_dt.timetuple()))

    return (start_ts, stop_ts, interval)
    

class ScaledDraw(object):
    """Like an ImageDraw object, but lines are scaled.
    
    """
    def __init__(self, draw, imagebox, scaledbox):
        """Initialize a ScaledDraw object.
        
        Example:
        scaledraw = ScaledDraw(draw, ((10, 10), (118, 246)), ((0.0, 0.0), (10.0, 1.0)))
        
        would create a scaled drawing where the upper-left image coordinate (10, 10) would
        correspond to the scaled coordinate( 0.0, 1.0). The lower-right image coordinate
        would correspond to the scaled coordinate (10.0, 0.0).
        
        draw: an instance of ImageDraw
        
        imagebox: a 2-tuple of the box coordinates on the image ((ulx, uly), (lrx, lry))
        
        scaledbox: a 2-tuple of the box coordinates of the scaled plot ((llx, lly), (urx, ury))
        
        """
        uli = imagebox[0]
        lri = imagebox[1]
        lls = scaledbox[0]
        urs = scaledbox[1]
        if urs[1] == lls[1]:
            pass
        self.xscale =  float(lri[0] - uli[0]) / float(urs[0] - lls[0])
        self.yscale = -float(lri[1] - uli[1]) / float(urs[1] - lls[1]) 
        self.xoffset = int(lri[0] - urs[0] * self.xscale + 0.5)
        self.yoffset = int(uli[1] - urs[1] * self.yscale + 0.5)

        self.draw    = draw
        
    def line(self, x, y, line_type='solid', marker_type=None, marker_size=8, maxdx=None, **options) :
        """Draw a scaled line on the instance's ImageDraw object.
        
        x: sequence of x coordinates
        
        y: sequence of y coordinates, some of which are possibly null (value of None)
        
        line_type: 'solid' for line that connect the coordinates
              None for no line
        
        marker_type: None or 'none' for no marker.
                     'cross' for a cross
                     'circle' for a circle
                     'box' for a box
                     'x' for an X

        maxdx: defines what constitutes a gap in samples.  if two data points
               are more than maxdx apart they are treated as separate segments.

        For a scatter plot, set line_type to None and marker_type to something other than None.
        """
        # Break the line up around any nulls or gaps between samples
        for xy_seq in xy_seq_line(x, y, maxdx):
        # Create a list with the scaled coordinates...
            xy_seq_scaled = [(self.xtranslate(xc), self.ytranslate(yc)) for (xc,yc) in xy_seq]
            if line_type == 'solid':
                # Now pick the appropriate drawing function, depending on the length of the line:
                if len(xy_seq) == 1 :
                    self.draw.point(xy_seq_scaled, fill=options['fill'])
                else :
                    self.draw.line(xy_seq_scaled, **options)
            if marker_type and marker_type.lower().strip() not in ['none', '']:
                self.marker(xy_seq_scaled, marker_type, marker_size=marker_size, **options)
        
    def marker(self, xy_seq, marker_type, marker_size=10, **options):
        half_size = marker_size/2
        marker=marker_type.lower()
        for x, y in xy_seq:
            if marker == 'cross':
                self.draw.line([(x-half_size, y), (x+half_size, y)], **options)
                self.draw.line([(x, y-half_size), (x, y+half_size)], **options)
            elif marker == 'x':
                self.draw.line([(x-half_size, y-half_size), (x+half_size, y+half_size)], **options)
                self.draw.line([(x-half_size, y+half_size), (x+half_size, y-half_size)], **options)
            elif marker == 'circle':
                self.draw.ellipse([(x-half_size, y-half_size), 
                                   (x+half_size, y+half_size)], outline=options['fill'])
            elif marker == 'box':
                self.draw.line([(x-half_size, y-half_size), 
                                (x+half_size, y-half_size),
                                (x+half_size, y+half_size),
                                (x-half_size, y+half_size),
                                (x-half_size, y-half_size)], **options)
                 
        
    def rectangle(self, box, **options) :
        """Draw a scaled rectangle.
        
        box: A pair of 2-way tuples, containing coordinates of opposing corners
        of the box.
        
        options: passed on to draw.rectangle. Usually contains 'fill' (the color)
        """
        box_scaled = [(coord[0]*self.xscale + self.xoffset + 0.5, coord[1]*self.yscale + self.yoffset + 0.5) for coord in box]
        self.draw.rectangle(box_scaled, **options)
        
    def vector(self, x, vec, vector_rotate, **options):
        
        if vec is None: 
            return
        xstart_scaled = self.xtranslate(x)
        ystart_scaled = self.ytranslate(0)
        
        vecinc_scaled = vec * self.yscale
        
        if vector_rotate:
            vecinc_scaled *= complex(math.cos(math.radians(vector_rotate)),
                                     math.sin(math.radians(vector_rotate)))
        
        # Subtract off the x increment because the x-axis
        # *increases* to the right, unlike y, which increases
        # downwards
        xend_scaled = xstart_scaled - vecinc_scaled.real
        yend_scaled = ystart_scaled + vecinc_scaled.imag
        
        self.draw.line(((xstart_scaled, ystart_scaled), (xend_scaled, yend_scaled)), **options)

    def xtranslate(self, x):
        return int(x * self.xscale + self.xoffset + 0.5)
                   
    def ytranslate(self, y):
        return int(y * self.yscale + self.yoffset + 0.5)


def xy_seq_line(x, y, maxdx=None):
    """Generator function that breaks a line up into individual segments around
    any nulls held in y or any gaps in x greater than maxdx.
    
    x: iterable sequence of x coordinates. All values must be non-null
    
    y: iterable sequence of y coordinates, possibly with some embedded 
    nulls (that is, their value==None)
    
    yields: Lists of (x,y) coordinates
    
    Example 1
    >>> x=[ 1,  2,  3]
    >>> y=[10, 20, 30]
    >>> for xy_seq in xy_seq_line(x,y):
    ...     print(xy_seq)
    [(1, 10), (2, 20), (3, 30)]
    
    Example 2
    >>> x=[0,  1,    2,  3,    4,    5,  6,  7,   8,    9]
    >>> y=[0, 10, None, 30, None, None, 60, 70,  80, None]
    >>> for xy_seq in xy_seq_line(x,y):
    ...     print(xy_seq)
    [(0, 0), (1, 10)]
    [(3, 30)]
    [(6, 60), (7, 70), (8, 80)]
    
    Example 3
    >>> x=[  0 ]
    >>> y=[None]
    >>> for xy_seq in xy_seq_line(x,y):
    ...     print(xy_seq)
    
    Example 4
    >>> x=[   0,    1,    2]
    >>> y=[None, None, None]
    >>> for xy_seq in xy_seq_line(x,y):
    ...     print(xy_seq)
    
    Example 5 (using gap)
    >>> x=[0,  1,  2,  3, 5.1,  6,  7,   8,  9]
    >>> y=[0, 10, 20, 30,  50, 60, 70,  80, 90]
    >>> for xy_seq in xy_seq_line(x,y,2):
    ...     print(xy_seq)
    [(0, 0), (1, 10), (2, 20), (3, 30)]
    [(5.1, 50), (6, 60), (7, 70), (8, 80), (9, 90)]
    """
    
    line = []
    last_x = None
    for xy in zip(x, y):
        dx = xy[0] - last_x if last_x is not None else 0
        last_x = xy[0]
        # If the y coordinate is None or dx > maxdx, that marks a break
        if xy[1] is None or (maxdx is not None and dx > maxdx):
            # If the length of the line is non-zero, yield it
            if len(line):
                yield line
                line = [] if xy[1] is None else [xy]
        else:
            line.append(xy)
    if len(line):
        yield line

def pickLabelFormat(increment):
    """Pick an appropriate label format for the given increment.
    
    Examples:
    >>> print(pickLabelFormat(1))
    %.0f
    >>> print(pickLabelFormat(20))
    %.0f
    >>> print(pickLabelFormat(.2))
    %.1f
    >>> print(pickLabelFormat(.01))
    %.2f
    """

    i_log = math.log10(increment)
    if i_log < 0 :
        i_log = abs(i_log)
        decimal_places = int(i_log)
        if i_log != decimal_places :
            decimal_places += 1
    else :
        decimal_places = 0
        
    return "%%.%df" % decimal_places

def get_font_handle(fontpath, *args):
    """Get a handle for a font path, caching the results"""

    # For Python 2, we want to make sure fontpath is a string, not unicode
    fontpath_str = six.ensure_str(fontpath) if fontpath is not None else None
    log.info("fontpath_str='%s'" % (fontpath_str,))
    # Look for the font in the cache
    font_key = (fontpath_str, args)
    if font_key in get_font_handle.fontCache:
        log.info("got font handle from cache")
        return get_font_handle.fontCache[font_key]

    font = None
    if fontpath_str is not None :
        try :
            if fontpath_str.endswith('.ttf'):
                log.info("getting .ttf font handle")
                font = ImageFont.truetype(fontpath_str, *args)
            else :
                log.info("getting non-ttf font handle")
                font = ImageFont.load_path(fontpath_str)
        except IOError :
            log.info("couldn't get find font")
            pass
    
    if font is None :
        log.info("getting default font handle")
        font = ImageFont.load_default()
    if font is not None :
        log.info("cached font handle")
        get_font_handle.fontCache[font_key] = font
    return font 
get_font_handle.fontCache={}

def _rel_approx_equal(x, y, rel=1e-7):
    """Relative test for equality.
    
    Example 
    >>> rel_approx_equal(1.23456, 1.23457)
    False
    >>> rel_approx_equal(1.2345678, 1.2345679)
    True
    >>> rel_approx_equal(0.0, 0.0)
    True
    >>> rel_approx_equal(0.0, 0.1)
    False
    >>> rel_approx_equal(0.0, 1e-9)
    False
    >>> rel_approx_equal(1.0, 1.0+1e-9)
    True
    >>> rel_approx_equal(1e8, 1e8+1e-3)
    True
    """
    return abs(x-y) <= rel*max(abs(x), abs(y))


def tobgr(x):
    """Convert a color to little-endian integer.  The PIL wants either
    a little-endian integer (0xBBGGRR) or a string (#RRGGBB).  weewx expects
    little-endian integer.  Accept any standard color format that is known
    by ImageColor for example #RGB, #RRGGBB, hslHSL as well as standard color
    names from X11 and CSS3.  See ImageColor for complete set of colors.
    """
    if isinstance(x, six.string_types):
        if x.startswith('0x'):
            return int(x, 0)
        try:
            (r,g,b) = ImageColor.getrgb(x)
            return r + g*256 + b*256*256
        except ValueError:
            try:
                return int(x)
            except ValueError:
                raise ValueError("Unknown color specifier: '%s'.  "
                                 "Colors must be specified as 0xBBGGRR, #RRGGBB, or standard color names." % x)
    return x

if __name__ == "__main__":
    import doctest

    if not doctest.testmod().failed:
        print("PASSED")
    
#
#    Copyright (c) 2009-2019 Tom Keffer <[email protected]>
#
#    See the file LICENSE.txt for your full rights.
#
"""Routines for generating image plots."""
from __future__ import absolute_import

import colorsys
import locale
import logging
import os
import time

try:
    from PIL import Image, ImageDraw
except ImportError:
    import Image, ImageDraw

from six.moves import zip
import six

import weeplot.utilities
from weeplot.utilities import tobgr
import weeutil.weeutil
import weewx

log = logging.getLogger(__name__)


class GeneralPlot(object):
    """Holds various parameters necessary for a plot. It should be specialized by the type of plot.
    """
    def __init__(self, config_dict):
        """Initialize an instance of GeneralPlot.
        
        config_dict: an instance of ConfigObj, or something that looks like it.
        """

        self.line_list = []
        
        self.xscale = (None, None, None)
        self.yscale = (None, None, None)

        self.anti_alias             = int(config_dict.get('anti_alias',  1))

        self.image_width            = int(config_dict.get('image_width',  300)) * self.anti_alias
        self.image_height           = int(config_dict.get('image_height', 180)) * self.anti_alias
        self.image_background_color = tobgr(config_dict.get('image_background_color', '0xf5f5f5'))

        self.chart_background_color = tobgr(config_dict.get('chart_background_color', '0xd8d8d8'))
        self.chart_gridline_color   = tobgr(config_dict.get('chart_gridline_color',   '0xa0a0a0'))
        color_list                  = config_dict.get('chart_line_colors', ['0xff0000', '0x00ff00', '0x0000ff'])
        fill_color_list             = config_dict.get('chart_fill_colors', color_list)
        width_list                  = config_dict.get('chart_line_width',  [1, 1, 1])
        self.chart_line_colors      = [tobgr(v) for v in color_list]
        self.chart_fill_colors      = [tobgr(v) for v in fill_color_list]
        self.chart_line_widths      = [int(v) for v in width_list]

        
        self.top_label_font_path    = config_dict.get('top_label_font_path')
        self.top_label_font_size    = int(config_dict.get('top_label_font_size', 10)) * self.anti_alias

        self.unit_label             = None
        self.unit_label_font_path   = config_dict.get('unit_label_font_path')
        log.info("self.unit_label_font_path initialised to '%s'" % self.unit_label_font_path)
        self.unit_label_font_color  = tobgr(config_dict.get('unit_label_font_color', '0x000000'))
        self.unit_label_font_size   = int(config_dict.get('unit_label_font_size', 10)) * self.anti_alias
        self.unit_label_position    = (10 * self.anti_alias, 0)

        self.bottom_label           = u""
        self.bottom_label_font_path = config_dict.get('bottom_label_font_path')
        self.bottom_label_font_color= tobgr(config_dict.get('bottom_label_font_color', '0x000000'))
        self.bottom_label_font_size = int(config_dict.get('bottom_label_font_size', 10)) * self.anti_alias
        self.bottom_label_offset    = int(config_dict.get('bottom_label_offset', 3))

        self.axis_label_font_path   = config_dict.get('axis_label_font_path')
        self.axis_label_font_color  = tobgr(config_dict.get('axis_label_font_color', '0x000000'))
        self.axis_label_font_size   = int(config_dict.get('axis_label_font_size', 10)) * self.anti_alias

        self.x_label_format         = config_dict.get('x_label_format')
        self.y_label_format         = config_dict.get('y_label_format')
        
        self.x_nticks               = int(config_dict.get('x_nticks', 10))
        self.y_nticks               = int(config_dict.get('y_nticks', 10))

        self.x_label_spacing        = int(config_dict.get('x_label_spacing', 2))
        self.y_label_spacing        = int(config_dict.get('y_label_spacing', 2))
        
        # Calculate sensible margins for the given image and font sizes.
        self.y_label_side = config_dict.get('y_label_side','left')
        if self.y_label_side == 'left' or self.y_label_side == 'both':
            self.lmargin = int(4.0 * self.axis_label_font_size)
        else:
            self.lmargin = 20 * self.anti_alias
        if self.y_label_side == 'right' or self.y_label_side == 'both':
            self.rmargin = int(4.0 * self.axis_label_font_size)
        else:
            self.rmargin = 20 * self.anti_alias
        self.bmargin = int(1.5 * (self.bottom_label_font_size + self.axis_label_font_size) + 0.5)
        self.tmargin = int(1.5 * self.top_label_font_size + 0.5)
        self.tbandht = int(1.2 * self.top_label_font_size + 0.5)
        self.padding =  3 * self.anti_alias

        self.render_rose            = False
        self.rose_width             = int(config_dict.get('rose_width', 21))
        self.rose_height            = int(config_dict.get('rose_height', 21))
        self.rose_diameter          = int(config_dict.get('rose_diameter', 10))
        self.rose_position          = (self.lmargin + self.padding + 5, self.image_height - self.bmargin - self.padding - self.rose_height)
        self.rose_rotation          = None
        self.rose_label             = config_dict.get('rose_label', u'N')
        self.rose_label_font_path   = config_dict.get('rose_label_font_path', self.bottom_label_font_path)
        self.rose_label_font_size   = int(config_dict.get('rose_label_font_size', 10))  
        self.rose_label_font_color  = tobgr(config_dict.get('rose_label_font_color', '0x000000'))
        self.rose_line_width        = int(config_dict.get('rose_line_width', 1))
        self.rose_color             = config_dict.get('rose_color')
        if self.rose_color is not None:
            self.rose_color = tobgr(self.rose_color)

        # Show day/night transitions
        self.show_daynight          = weeutil.weeutil.tobool(config_dict.get('show_daynight', False))
        self.daynight_day_color     = tobgr(config_dict.get('daynight_day_color', '0xffffff'))
        self.daynight_night_color   = tobgr(config_dict.get('daynight_night_color', '0xf0f0f0'))
        self.daynight_edge_color    = tobgr(config_dict.get('daynight_edge_color', '0xefefef'))
        self.daynight_gradient      = int(config_dict.get('daynight_gradient', 20))

        # initialize the location
        self.latitude               = None
        self.longitude              = None

        # normalize the font paths relative to the skin directory
        skin_dir = config_dict.get('skin_dir', '')
        self.top_label_font_path = self.normalize_path(skin_dir, self.top_label_font_path)
        self.bottom_label_font_path = self.normalize_path(skin_dir, self.bottom_label_font_path)
        self.unit_label_font_path = self.normalize_path(skin_dir, self.unit_label_font_path)
        log.info("skin_dir='%s' normalised self.unit_label_font_path='%s'" % (skin_dir, self.unit_label_font_path))
        self.axis_label_font_path = self.normalize_path(skin_dir, self.axis_label_font_path)
        self.rose_label_font_path = self.normalize_path(skin_dir, self.rose_label_font_path)

    @staticmethod
    def normalize_path(skin_dir, path):
        if path is None:
            return None
        return os.path.join(skin_dir, path)

    def setBottomLabel(self, bottom_label):
        """Set the label to be put at the bottom of the plot. """
        self.bottom_label = bottom_label
        
    def setUnitLabel(self, unit_label):
        """Set the label to be used to show the units of the plot. """
        self.unit_label = unit_label
        
    def setXScaling(self, xscale):
        """Set the X scaling.
        
        xscale: A 3-way tuple (xmin, xmax, xinc)
        """
        self.xscale = xscale
        
    def setYScaling(self, yscale):
        """Set the Y scaling.
        
        yscale: A 3-way tuple (ymin, ymax, yinc)
        """
        self.yscale = yscale
        
    def addLine(self, line):
        """Add a line to be plotted.
        
        line: an instance of PlotLine
        """
        if None in line.x:
            raise weeplot.ViolatedPrecondition("X vector cannot have any values 'None' ")
        self.line_list.append(line)

    def setLocation(self, lat, lon):
        self.latitude  = lat
        self.longitude = lon
        
    def setDayNight(self, showdaynight, daycolor, nightcolor, edgecolor):
        """Configure day/night bands.

        showdaynight: Boolean flag indicating whether to draw day/night bands

        daycolor: color for day bands

        nightcolor: color for night bands

        edgecolor: color for transition between day and night
        """
        self.show_daynight = showdaynight
        self.daynight_day_color = daycolor
        self.daynight_night_color = nightcolor
        self.daynight_edge_color = edgecolor

    def render(self):
        """Traverses the universe of things that have to be plotted in this image, rendering
        them and returning the results as a new Image object.
        """

        # NB: In what follows the variable 'draw' is an instance of an ImageDraw object and is in pixel units.
        # The variable 'sdraw' is an instance of ScaledDraw and its units are in the "scaled" units of the plot
        # (e.g., the horizontal scaling might be for seconds, the vertical for degrees Fahrenheit.)
        image = Image.new("RGB", (self.image_width, self.image_height), self.image_background_color)
        draw = self._getImageDraw(image)
        draw.rectangle(((self.lmargin,self.tmargin), 
                        (self.image_width - self.rmargin, self.image_height - self.bmargin)), 
                        fill=self.chart_background_color)
        
        self._renderBottom(draw)
        self._renderTopBand(draw)
        
        self._calcXScaling()
        self._calcYScaling()
        self._calcXLabelFormat()
        self._calcYLabelFormat()
        
        sdraw = self._getScaledDraw(draw)
        if self.show_daynight:
            self._renderDayNight(sdraw)
        self._renderXAxes(sdraw)
        self._renderYAxes(sdraw)
        self._renderPlotLines(sdraw)
        if self.render_rose:
            self._renderRose(image, draw)

        if self.anti_alias != 1:
            image.thumbnail((self.image_width / self.anti_alias, self.image_height / self.anti_alias), Image.ANTIALIAS)

        return image

    # noinspection PyMethodMayBeStatic
    def _getImageDraw(self, image):
        """Returns an instance of ImageDraw with the proper dimensions and background color"""
        draw = UniDraw(image)
        return draw
    
    def _getScaledDraw(self, draw):
        """Returns an instance of ScaledDraw, with the appropriate scaling.
        
        draw: An instance of ImageDraw
        """
        sdraw = weeplot.utilities.ScaledDraw(draw, ((self.lmargin + self.padding, self.tmargin + self.padding),
                                                    (self.image_width - self.rmargin - self.padding, self.image_height - self.bmargin - self.padding)),
                                                    ((self.xscale[0], self.yscale[0]), (self.xscale[1], self.yscale[1])))
        return sdraw
        
    def _renderDayNight(self, sdraw):
        """Draw vertical bands for day/night."""
        (first, transitions) = weeutil.weeutil.getDayNightTransitions(
            self.xscale[0], self.xscale[1], self.latitude, self.longitude)
        color = self.daynight_day_color \
            if first == 'day' else self.daynight_night_color
        xleft = self.xscale[0]
        for x in transitions:
            sdraw.rectangle(((xleft,self.yscale[0]),
                             (x,self.yscale[1])), fill=color)
            xleft = x
            color = self.daynight_night_color \
                if color == self.daynight_day_color else self.daynight_day_color
        sdraw.rectangle(((xleft,self.yscale[0]),
                         (self.xscale[1],self.yscale[1])), fill=color)
        if self.daynight_gradient:
            if first == 'day':
                color1 = self.daynight_day_color
                color2 = self.daynight_night_color
            else:
                color1 = self.daynight_night_color
                color2 = self.daynight_day_color
            nfade = self.daynight_gradient
            # gradient is longer at the poles than the equator
            d = 120 + 300 * (1 - (90.0 - abs(self.latitude)) / 90.0)
            for i in range(len(transitions)):
                last_ = self.xscale[0] if i == 0 else transitions[i-1]
                next_ = transitions[i+1] if i < len(transitions)-1 else self.xscale[1]
                for z in range(1,nfade):
                    c = blend_hls(color2, color1, float(z)/float(nfade))
                    rgbc = int2rgbstr(c)
                    x1 = transitions[i]-d*(nfade+1)/2+d*z
                    if last_ < x1 < next_:
                        sdraw.rectangle(((x1, self.yscale[0]),
                                         (x1+d, self.yscale[1])),
                                        fill=rgbc)
                if color1 == self.daynight_day_color:
                    color1 = self.daynight_night_color
                    color2 = self.daynight_day_color
                else:
                    color1 = self.daynight_day_color
                    color2 = self.daynight_night_color
        # draw a line at the actual sunrise/sunset
        for x in transitions:
            sdraw.line((x,x),(self.yscale[0],self.yscale[1]),
                       fill=self.daynight_edge_color)

    def _renderXAxes(self, sdraw):
        """Draws the x axis and vertical constant-x lines, as well as the labels. """

        axis_label_font = weeplot.utilities.get_font_handle(self.axis_label_font_path,
                                                            self.axis_label_font_size)

        drawlabelcount = 0
        for x in weeutil.weeutil.stampgen(self.xscale[0], self.xscale[1], self.xscale[2]) :
            sdraw.line((x, x), (self.yscale[0], self.yscale[1]), fill=self.chart_gridline_color,
                       width=self.anti_alias)
            if drawlabelcount % self.x_label_spacing == 0 :
                xlabel = self._genXLabel(x)
                axis_label_size = sdraw.draw.textsize(xlabel, font=axis_label_font)
                xpos = sdraw.xtranslate(x)
                sdraw.draw.text((xpos - axis_label_size[0]/2, self.image_height - self.bmargin + 2),
                                xlabel, fill=self.axis_label_font_color, font=axis_label_font)
            drawlabelcount += 1

    def _renderYAxes(self, sdraw):
        """Draws the y axis and horizontal constant-y lines, as well as the labels.
        Should be sufficient for most purposes.
        """
        nygridlines     = int((self.yscale[1] - self.yscale[0]) / self.yscale[2] + 1.5)
        axis_label_font = weeplot.utilities.get_font_handle(self.axis_label_font_path,
                                                                self.axis_label_font_size)
        
        # Draw the (constant y) grid lines 
        for i in range(nygridlines) :
            y = self.yscale[0] + i * self.yscale[2]
            sdraw.line((self.xscale[0], self.xscale[1]), (y, y), fill=self.chart_gridline_color,
                       width=self.anti_alias)
            # Draw a label on every other line:
            if i % self.y_label_spacing == 0 :
                ylabel = self._genYLabel(y)
                axis_label_size = sdraw.draw.textsize(ylabel, font=axis_label_font)
                ypos = sdraw.ytranslate(y)
                if self.y_label_side == 'left' or self.y_label_side == 'both':
                    sdraw.draw.text( (self.lmargin - axis_label_size[0] - 2, ypos - axis_label_size[1]/2),
                                ylabel, fill=self.axis_label_font_color, font=axis_label_font)
                if self.y_label_side == 'right' or self.y_label_side == 'both':
                    sdraw.draw.text( (self.image_width - self.rmargin + 4, ypos - axis_label_size[1]/2),
                                ylabel, fill=self.axis_label_font_color, font=axis_label_font)

    def _renderPlotLines(self, sdraw):
        """Draw the collection of lines, using a different color for each one. Because there is
        a limited set of colors, they need to be recycled if there are very many lines.
        """
        nlines = len(self.line_list)
        ncolors = len(self.chart_line_colors)
        nfcolors = len(self.chart_fill_colors)
        nwidths = len(self.chart_line_widths)

        # Draw them in reverse order, so the first line comes out on top of the image
        for j, this_line in enumerate(self.line_list[::-1]):
            
            iline=nlines-j-1
            color = self.chart_line_colors[iline%ncolors] if this_line.color is None else this_line.color
            fill_color = self.chart_fill_colors[iline%nfcolors] if this_line.fill_color is None else this_line.fill_color
            width = (self.chart_line_widths[iline%nwidths] if this_line.width is None else this_line.width) * self.anti_alias

            # Calculate the size of a gap in data
            maxdx = None
            if this_line.gap_fraction is not None:
                maxdx = this_line.gap_fraction * (self.xscale[1] - self.xscale[0])

            if this_line.plot_type == 'line' :
                ms = this_line.marker_size
                if ms is not None:
                    ms *= self.anti_alias
                sdraw.line(this_line.x, 
                           this_line.y, 
                           line_type=this_line.line_type,
                           marker_type=this_line.marker_type,
                           marker_size=ms,
                           fill  = color,
                           width = width,
                           maxdx = maxdx)
            elif this_line.plot_type == 'bar' :
                for x, y, bar_width in zip(this_line.x, this_line.y, this_line.bar_width):
                    if y is None:
                        continue
                    sdraw.rectangle(((x - bar_width, self.yscale[0]), (x, y)), fill=fill_color, outline=color)
            elif this_line.plot_type == 'vector' :
                for (x, vec) in zip(this_line.x, this_line.y):
                    sdraw.vector(x, vec,
                                 vector_rotate = this_line.vector_rotate,
                                 fill  = color,
                                 width = width)
                self.render_rose = True
                self.rose_rotation = this_line.vector_rotate
                if self.rose_color is None:
                    self.rose_color = color

    def _renderBottom(self, draw):
        """Draw anything at the bottom (just some text right now). """
        bottom_label_font = weeplot.utilities.get_font_handle(self.bottom_label_font_path, self.bottom_label_font_size)
        bottom_label_size = draw.textsize(self.bottom_label, font=bottom_label_font)
        
        draw.text(((self.image_width - bottom_label_size[0])/2, 
                   self.image_height - bottom_label_size[1] - self.bottom_label_offset),
                  self.bottom_label, 
                  fill=self.bottom_label_font_color,
                  font=bottom_label_font)
        
    def _renderTopBand(self, draw):
        """Draw the top band and any text in it. """
        # Draw the top band rectangle
        draw.rectangle(((0,0), 
                        (self.image_width, self.tbandht)), 
                        fill = self.chart_background_color)

        # Put the units in the upper left corner
        log.info("getting unit_label_font handle")
        unit_label_font = weeplot.utilities.get_font_handle(self.unit_label_font_path, self.unit_label_font_size)
        log.info("got unit_label_font handle")
        if self.unit_label:
            if self.y_label_side == 'left' or self.y_label_side == 'both':
                draw.text(self.unit_label_position,
                          self.unit_label,
                          fill=self.unit_label_font_color,
                          font=unit_label_font)
            if self.y_label_side == 'right' or self.y_label_side == 'both':
                unit_label_position_right = (self.image_width - self.rmargin + 4, 0)
                draw.text(unit_label_position_right,
                          self.unit_label,
                          fill=self.unit_label_font_color,
                          font=unit_label_font)

        top_label_font = weeplot.utilities.get_font_handle(self.top_label_font_path, self.top_label_font_size)
        
        # The top label is the appended label_list. However, it has to be drawn in segments 
        # because each label may be in a different color. For now, append them together to get
        # the total width
        top_label = ' '.join([line.label for line in self.line_list])
        top_label_size = draw.textsize(top_label, font=top_label_font)
        
        x = (self.image_width - top_label_size[0])/2
        y = 0
        
        ncolors = len(self.chart_line_colors)
        for i, this_line in enumerate(self.line_list):
            color = self.chart_line_colors[i%ncolors] if this_line.color is None else this_line.color
            # Draw a label
            draw.text( (x,y), this_line.label, fill = color, font = top_label_font)
            # Now advance the width of the label we just drew, plus a space:
            label_size = draw.textsize(this_line.label + ' ', font= top_label_font)
            x += label_size[0]

    def _renderRose(self, image, draw):
        """Draw a compass rose."""
        
        rose_center_x = self.rose_width/2  + 1
        rose_center_y = self.rose_height/2 + 1
        barb_width  = 3
        barb_height = 3
        # The background is all white with a zero alpha (totally transparent)
        rose_image = Image.new("RGBA", (self.rose_width, self.rose_height), (0x00, 0x00, 0x00, 0x00))
        rose_draw = ImageDraw.Draw(rose_image)
 
        fill_color = add_alpha(self.rose_color)
        # Draw the arrow straight up (North). First the shaft:
        rose_draw.line( ((rose_center_x, 0), (rose_center_x, self.rose_height)), width = self.rose_line_width, fill = fill_color)
        # Now the left barb:
        rose_draw.line( ((rose_center_x - barb_width, barb_height), (rose_center_x, 0)), width = self.rose_line_width, fill = fill_color)
        # And the right barb:
        rose_draw.line( ((rose_center_x, 0), (rose_center_x + barb_width, barb_height)), width = self.rose_line_width, fill = fill_color)
        
        rose_draw.ellipse(((rose_center_x - self.rose_diameter/2,
                            rose_center_y - self.rose_diameter/2),
                           (rose_center_x + self.rose_diameter/2,
                            rose_center_y + self.rose_diameter/2)),
                          outline = fill_color)

        # Rotate if necessary:
        if self.rose_rotation:
            rose_image = rose_image.rotate(self.rose_rotation)
            rose_draw = ImageDraw.Draw(rose_image)
        
        # Calculate the position of the "N" label:
        rose_label_font = weeplot.utilities.get_font_handle(self.rose_label_font_path, self.rose_label_font_size)
        rose_label_size = draw.textsize(self.rose_label, font=rose_label_font)
        
        # Draw the label in the middle of the (possibly) rotated arrow
        rose_draw.text((rose_center_x - rose_label_size[0]/2 - 1,
                        rose_center_y - rose_label_size[1]/2 - 1),
                        self.rose_label,
                        fill = add_alpha(self.rose_label_font_color),
                        font = rose_label_font)

        # Paste the image of the arrow on to the main plot. The alpha
        # channel of the image will be used as the mask.
        # This will cause the arrow to overlay the background plot
        image.paste(rose_image, self.rose_position, rose_image)
        

    def _calcXScaling(self):
        """Calculates the x scaling. It will probably be specialized by
        plots where the x-axis represents time.
        """
        (xmin, xmax) = self._calcXMinMax()

        self.xscale = weeplot.utilities.scale(xmin, xmax, self.xscale, nsteps=self.x_nticks)
            
    def _calcYScaling(self):
        """Calculates y scaling. Can be used 'as-is' for most purposes."""
        # The filter is necessary because unfortunately the value 'None' is not
        # excluded from min and max (i.e., min(None, x) is not necessarily x). 
        # The try block is necessary because min of an empty list throws a
        # ValueError exception.
        ymin = ymax = None
        for line in self.line_list:
            if line.plot_type == 'vector':
                try:
                    # For progressive vector plots, we want the magnitude of the complex vector
                    yline_max = max(abs(c) for c in [v for v in line.y if v is not None])
                except ValueError:
                    yline_max = None
                yline_min = - yline_max if yline_max is not None else None
            else:
                yline_min = weeutil.weeutil.min_with_none(line.y)
                yline_max = weeutil.weeutil.max_with_none(line.y)
            ymin = weeutil.weeutil.min_with_none([ymin, yline_min])
            ymax = weeutil.weeutil.max_with_none([ymax, yline_max])

        if ymin is None and ymax is None :
            # No valid data. Pick an arbitrary scaling
            self.yscale=(0.0, 1.0, 0.2)
        else:
            self.yscale = weeplot.utilities.scale(ymin, ymax, self.yscale, nsteps=self.y_nticks)

    def _calcXLabelFormat(self):
        if self.x_label_format is None:
            self.x_label_format = weeplot.utilities.pickLabelFormat(self.xscale[2])

    def _calcYLabelFormat(self):
        if self.y_label_format is None:
            self.y_label_format = weeplot.utilities.pickLabelFormat(self.yscale[2])
        
    def _genXLabel(self, x):
        xlabel = locale.format_string(self.x_label_format, x)
        return xlabel
    
    def _genYLabel(self, y):
        ylabel = locale.format_string(self.y_label_format, y)
        return ylabel
    
    def _calcXMinMax(self):
        xmin = xmax = None
        for line in self.line_list:
            xline_min = weeutil.weeutil.min_with_none(line.x)
            xline_max = weeutil.weeutil.max_with_none(line.x)
            # If the line represents a bar chart, then the actual minimum has to
            # be adjusted for the bar width of the first point
            if line.plot_type == 'bar':
                xline_min = xline_min - line.bar_width[0]
            xmin = weeutil.weeutil.min_with_none([xmin, xline_min])
            xmax = weeutil.weeutil.max_with_none([xmax, xline_max])
        return (xmin, xmax)

class TimePlot(GeneralPlot) :
    """Class that specializes GeneralPlot for plots where the x-axis is time."""
    
    def _calcXScaling(self):
        """Specialized version for time plots."""
        if None in self.xscale:
            (xmin, xmax) = self._calcXMinMax()
            self.xscale = weeplot.utilities.scaletime(xmin, xmax)

    def _calcXLabelFormat(self):
        """Specialized version for time plots."""
        if self.x_label_format is None:
            (xmin, xmax) = self._calcXMinMax()
            if xmin is not None and xmax is not None:
                delta = xmax - xmin
                if delta > 30*24*3600:
                    self.x_label_format = u"%x"
                elif delta > 24*3600:
                    self.x_label_format = u"%x %X"
                else:
                    self.x_label_format = u"%X"
        
    def _genXLabel(self, x):
        if self.x_label_format is None:
            return ''
        time_tuple = time.localtime(x)
        # There are still some strftimes out there that don't support Unicode.
        try:
            xlabel = time.strftime(self.x_label_format, time_tuple)
        except UnicodeEncodeError:
            # Convert it to UTF8, then back again:
            xlabel = time.strftime(self.x_label_format.encode('utf-8'), time_tuple).decode('utf-8')
        return xlabel


class PlotLine(object):
    """Represents a single line (or bar) in a plot. """
    def __init__(self, x, y, label='', color=None, fill_color=None, width=None, plot_type='line',
                 line_type='solid', marker_type=None, marker_size=10, 
                 bar_width=None, vector_rotate = None, gap_fraction=None):
        self.x               = x
        self.y               = y
        self.label           = label
        self.plot_type       = plot_type
        self.line_type       = line_type
        self.marker_type     = marker_type
        self.marker_size     = marker_size
        self.color           = color
        self.fill_color      = fill_color
        self.width           = width
        self.bar_width       = bar_width
        self.vector_rotate   = vector_rotate
        self.gap_fraction    = gap_fraction

class UniDraw(ImageDraw.ImageDraw):
    """Supports non-Unicode fonts
    
    Not all fonts support Unicode characters. These will raise a UnicodeEncodeError exception.
    This class subclasses the regular ImageDraw.Draw class, adding overridden functions to
    catch these exceptions. It then tries drawing the string again, this time as a UTF8 string
    """
    
    def text(self, position, string, **options):
        try:
            return ImageDraw.ImageDraw.text(self, position, string, **options)
        except UnicodeEncodeError:
            return ImageDraw.ImageDraw.text(self, position, string.encode('utf-8'), **options)
        
    def textsize(self, string, **options):
        try:
            return ImageDraw.ImageDraw.textsize(self, string, **options)
        except UnicodeEncodeError:
            return ImageDraw.ImageDraw.textsize(self, string.encode('utf-8'), **options)
            
            
def blend_hls(c, bg, alpha):
    """Fade from c to bg using alpha channel where 1 is solid and 0 is
    transparent.  This fades across the hue, saturation, and lightness."""
    return blend(c, bg, alpha, alpha, alpha)

def blend_ls(c, bg, alpha):
    """Fade from c to bg where 1 is solid and 0 is transparent.
    Change only the lightness and saturation, not hue."""
    return blend(c, bg, 1.0, alpha, alpha)

def blend(c, bg, alpha_h, alpha_l, alpha_s):
    """Fade from c to bg in the hue, lightness, saturation colorspace.
       Added hue directionality to choose shortest circular hue path e.g.
       https://stackoverflow.com/questions/1416560/hsl-interpolation
       Also, grey detection to minimize colour wheel travel.  Interesting resource:
       http://davidjohnstone.net/pages/lch-lab-colour-gradient-picker
       """

    r1,g1,b1 = int2rgb(c)
    h1,l1,s1 = colorsys.rgb_to_hls(r1/255.0, g1/255.0, b1/255.0)

    r2,g2,b2 = int2rgb(bg)
    h2,l2,s2 = colorsys.rgb_to_hls(r2/255.0, g2/255.0, b2/255.0)

    # Check if either of the values is grey (saturation 0),
    # in which case don't needlessly reset hue to '0', reducing travel around colour wheel
    if s1 == 0: h1 = h2
    if s2 == 0: h2 = h1

    h_delta = h2 - h1

    if abs(h_delta) > 0.5:
        # If interpolating over more than half-circle (0.5 radians) take shorter, opposite direction...
        h_range = 1.0 - abs(h_delta)
        h_dir = +1.0 if h_delta < 0.0 else -1.0

        # Calculte h based on line back from h2 as proportion of h_range and alpha
        h = h2 - ( h_dir * h_range * alpha_h )

        # Clamp h within 0.0 to 1.0 range
        h = h + 1.0 if h < 0.0 else h
        h = h - 1.0 if h > 1.0 else h
    else:
        # Interpolating over less than a half-circle, so use normal interpolation as before
        h = alpha_h * h1 + (1 - alpha_h) * h2

    l = alpha_l * l1 + (1 - alpha_l) * l2
    s = alpha_s * s1 + (1 - alpha_s) * s2

    r,g,b = colorsys.hls_to_rgb(h, l, s)

    r = round(r * 255.0)
    g = round(g * 255.0)
    b = round(b * 255.0)

    t = rgb2int(int(r),int(g),int(b))

    return int(t)

def int2rgb(x):
    b = (x >> 16) & 0xff
    g = (x >> 8) & 0xff
    r = x & 0xff
    return r,g,b

def int2rgbstr(x):
    return '#%02x%02x%02x' % int2rgb(x)

def rgb2int(r,g,b):
    return r + g*256 + b*256*256

def add_alpha(i):
    """Add an opaque alpha channel to an integer RGB value"""
    r = i & 0xff
    g = (i >> 8)  & 0xff
    b = (i >> 16) & 0xff
    a = 0xff    # Opaque alpha
    return r,g,b,a

Reply via email to