summarylogtreecommitdiffstats
diff options
context:
space:
mode:
authorMichael Schantl2023-09-06 12:53:25 +0000
committerMichael Schantl2023-09-06 12:53:25 +0000
commit47cf89105afb918583ae42af4eeb25658181a52a (patch)
treea53118deba32760704656320c70dd6316c4fbb30
parentd8172113415019e16ecf1450772bddc1041faf8d (diff)
downloadaur-47cf89105afb918583ae42af4eeb25658181a52a.tar.gz
upgpkg: weewx 4.10.2-6
Update Pillow to v10 by replacing genplot.py file with newer version.
-rw-r--r--.SRCINFO6
-rw-r--r--.gitignore2
-rw-r--r--PKGBUILD10
-rw-r--r--genplot.py737
4 files changed, 746 insertions, 9 deletions
diff --git a/.SRCINFO b/.SRCINFO
index 548d5a72ab80..4131fbb159dd 100644
--- a/.SRCINFO
+++ b/.SRCINFO
@@ -1,7 +1,7 @@
pkgbase = weewx
pkgdesc = Software for logging data from weather stations
pkgver = 4.10.2
- pkgrel = 5
+ pkgrel = 6
url = http://www.weewx.com/
arch = any
license = GPL3
@@ -10,7 +10,7 @@ pkgbase = weewx
depends = python-six
depends = python-configobj
depends = python-pyserial
- depends = python-pillow95
+ depends = python-pillow
depends = python-pyusb
depends = python-cheetah3
optdepends = python-pyephem: extended almanac information
@@ -28,7 +28,7 @@ pkgbase = weewx
source = weewxd
source = wunderfixer
source = weewx.service
- source = pillow-rect.patch
+ source = genplot.py
source = weewx-version.patch
sha512sums = 8fca9cd7720a29687a0d900e4d89ec2ce5ca5d2aa36bc5b5909ea14ecb849cdbdb6e699cf1c3a0d5505c89ad8c309517db32fd8dc4a0ae4704dfd5ed0cc5747f
sha512sums = SKIP
diff --git a/.gitignore b/.gitignore
index 8363df399f28..c40c2f1392b3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,4 +17,4 @@
!weewxd
!wunderfixer
!weewx-version.patch
-!pillow-rect.patch
+!genplot.py
diff --git a/PKGBUILD b/PKGBUILD
index aa8393ec1298..40b7f125399e 100644
--- a/PKGBUILD
+++ b/PKGBUILD
@@ -13,7 +13,7 @@ function _dl_url {
echo "https://github.com/weewx/weewx/archive/refs/tags/v$1.$2.$3.tar.gz"
}
-pkgrel=5
+pkgrel=6
pkgdesc="Software for logging data from weather stations"
arch=("any")
url="http://www.weewx.com/"
@@ -23,7 +23,7 @@ depends=("python"
"python-six"
"python-configobj"
"python-pyserial"
- "python-pillow95"
+ "python-pillow"
"python-pyusb"
"python-cheetah3")
optdepends=("python-pyephem: extended almanac information"
@@ -41,8 +41,8 @@ source=("$pkgname-$pkgver.tar.xz::$(_dl_url $_MAJOR $_MINOR $_PATCH)"
"weewxd"
"wunderfixer"
"weewx.service"
- "pillow-rect.patch"
- "weewx-version.patch")
+ "genplot.py"
+ "weewx-version.patch")
sha512sums=('8fca9cd7720a29687a0d900e4d89ec2ce5ca5d2aa36bc5b5909ea14ecb849cdbdb6e699cf1c3a0d5505c89ad8c309517db32fd8dc4a0ae4704dfd5ed0cc5747f'
'SKIP'
'SKIP'
@@ -61,7 +61,7 @@ _watch="http://www.weewx.com/downloads/"
prepare() {
cd "$srcdir/${pkgname}-${pkgver}"
patch --strip=1 --input="$srcdir/weewx-version.patch"
- patch --strip=1 --input="$srcdir/pillow-rect.patch"
+ cp -v "$srcdir/genplot.py" bin/weeplot
}
build() {
diff --git a/genplot.py b/genplot.py
new file mode 100644
index 000000000000..7094d770e052
--- /dev/null
+++ b/genplot.py
@@ -0,0 +1,737 @@
+#
+# Copyright (c) 2009-2023 Tom Keffer <tkeffer@gmail.com>
+#
+# See the file LICENSE.txt for your full rights.
+#
+"""Routines for generating image plots."""
+
+import colorsys
+import locale
+import os
+import time
+
+from PIL import Image, ImageDraw, ImageFont
+
+import weeplot.utilities
+import weeutil.weeutil
+from weeplot.utilities import tobgr
+from weeutil.weeutil import max_with_none, min_with_none, to_bool
+
+# Test if this version of Pillow has ImageFont.getbbox. If not, we will activate a workaround.
+try:
+ ImageFont.ImageFont.getbbox
+except AttributeError:
+ PIL_HAS_BBOX = False
+else:
+ PIL_HAS_BBOX = True
+
+
+class GeneralPlot(object):
+ """Holds various parameters necessary for a plot. It should be specialized by the type of plot.
+ """
+ def __init__(self, plot_dict):
+ """Initialize an instance of GeneralPlot.
+
+ plot_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(plot_dict.get('anti_alias', 1))
+
+ self.image_width = int(plot_dict.get('image_width', 300)) * self.anti_alias
+ self.image_height = int(plot_dict.get('image_height', 180)) * self.anti_alias
+ self.image_background_color = tobgr(plot_dict.get('image_background_color', '0xf5f5f5'))
+
+ self.chart_background_color = tobgr(plot_dict.get('chart_background_color', '0xd8d8d8'))
+ self.chart_gridline_color = tobgr(plot_dict.get('chart_gridline_color', '0xa0a0a0'))
+ color_list = plot_dict.get('chart_line_colors', ['0xff0000', '0x00ff00', '0x0000ff'])
+ fill_color_list = plot_dict.get('chart_fill_colors', color_list)
+ width_list = plot_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 = plot_dict.get('top_label_font_path')
+ self.top_label_font_size = int(plot_dict.get('top_label_font_size', 10)) * self.anti_alias
+
+ self.unit_label = None
+ self.unit_label_font_path = plot_dict.get('unit_label_font_path')
+ self.unit_label_font_color = tobgr(plot_dict.get('unit_label_font_color', '0x000000'))
+ self.unit_label_font_size = int(plot_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 = plot_dict.get('bottom_label_font_path')
+ self.bottom_label_font_color= tobgr(plot_dict.get('bottom_label_font_color', '0x000000'))
+ self.bottom_label_font_size = int(plot_dict.get('bottom_label_font_size', 10)) * self.anti_alias
+ self.bottom_label_offset = int(plot_dict.get('bottom_label_offset', 3))
+
+ self.axis_label_font_path = plot_dict.get('axis_label_font_path')
+ self.axis_label_font_color = tobgr(plot_dict.get('axis_label_font_color', '0x000000'))
+ self.axis_label_font_size = int(plot_dict.get('axis_label_font_size', 10)) * self.anti_alias
+
+ # Make sure the formats used for the x- and y-axes are in unicode.
+ self.x_label_format = plot_dict.get('x_label_format')
+ self.y_label_format = plot_dict.get('y_label_format')
+
+ self.x_nticks = int(plot_dict.get('x_nticks', 10))
+ self.y_nticks = int(plot_dict.get('y_nticks', 10))
+
+ self.x_label_spacing = int(plot_dict.get('x_label_spacing', 2))
+ self.y_label_spacing = int(plot_dict.get('y_label_spacing', 2))
+
+ # Calculate sensible margins for the given image and font sizes.
+ self.y_label_side = plot_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(plot_dict.get('rose_width', 21))
+ self.rose_height = int(plot_dict.get('rose_height', 21))
+ self.rose_diameter = int(plot_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 = plot_dict.get('rose_label', 'N')
+ self.rose_label_font_path = plot_dict.get('rose_label_font_path', self.bottom_label_font_path)
+ self.rose_label_font_size = int(plot_dict.get('rose_label_font_size', 10))
+ self.rose_label_font_color = tobgr(plot_dict.get('rose_label_font_color', '0x000000'))
+ self.rose_line_width = int(plot_dict.get('rose_line_width', 1))
+ self.rose_color = plot_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 = to_bool(plot_dict.get('show_daynight', False))
+ self.daynight_day_color = tobgr(plot_dict.get('daynight_day_color', '0xffffff'))
+ self.daynight_night_color = tobgr(plot_dict.get('daynight_night_color', '0xf0f0f0'))
+ self.daynight_edge_color = tobgr(plot_dict.get('daynight_edge_color', '0xefefef'))
+ self.daynight_gradient = int(plot_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 = plot_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)
+ 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 = ImageDraw.ImageDraw(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.LANCZOS)
+
+ return image
+
+ 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)
+ if PIL_HAS_BBOX:
+ axis_label_width = sdraw.draw.textlength(xlabel, font=axis_label_font)
+ else:
+ axis_label_width, _ = sdraw.draw.textsize(xlabel, font=axis_label_font)
+ xpos = sdraw.xtranslate(x)
+ sdraw.draw.text((xpos - axis_label_width/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)
+ if PIL_HAS_BBOX:
+ left, top, right, bottom = axis_label_font.getbbox(ylabel)
+ axis_label_width, axis_label_height = right - left, bottom - top
+ else:
+ axis_label_width, axis_label_height = sdraw.draw.textsize(ylabel,
+ font=axis_label_font)
+ ypos = sdraw.ytranslate(y)
+ # We want to treat Truetype and bitmapped fonts the same. By default, Truetype
+ # measures the top of the bounding box at the top of the ascender, while it's
+ # the top of the text for bitmapped. Specify an anchor of "lt" (left, top) for
+ # both. NB: argument "anchor" has been around at least as early as
+ # Pillow V5.0 (2018), but was not implemented until V8.0.0.
+ if self.y_label_side == 'left' or self.y_label_side == 'both':
+ sdraw.draw.text((self.lmargin - axis_label_width - 2, ypos - axis_label_height/2),
+ ylabel, fill=self.axis_label_font_color, font=axis_label_font,
+ anchor="lt")
+ if self.y_label_side == 'right' or self.y_label_side == 'both':
+ sdraw.draw.text((self.image_width - self.rmargin + 4, ypos - axis_label_height/2),
+ ylabel, fill=self.axis_label_font_color, font=axis_label_font,
+ anchor="lt")
+
+ 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.line_gap_fraction is not None:
+ maxdx = this_line.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)
+ if PIL_HAS_BBOX:
+ left, top, right, bottom = bottom_label_font.getbbox(self.bottom_label)
+ bottom_label_width, bottom_label_height = right - left, bottom - top
+ else:
+ bottom_label_width, bottom_label_height = draw.textsize(self.bottom_label,
+ font=bottom_label_font)
+ draw.text(((self.image_width - bottom_label_width)/2,
+ self.image_height - bottom_label_height - self.bottom_label_offset),
+ self.bottom_label,
+ fill=self.bottom_label_font_color,
+ font=bottom_label_font,
+ anchor="lt")
+
+ 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
+ unit_label_font = weeplot.utilities.get_font_handle(self.unit_label_font_path,
+ self.unit_label_font_size)
+ 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 = u' '.join([line.label for line in self.line_list])
+ if PIL_HAS_BBOX:
+ top_label_width= draw.textlength(top_label, font=top_label_font)
+ else:
+ top_label_width, _ = draw.textsize(top_label, font=top_label_font)
+
+ x = (self.image_width - top_label_width)/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:
+ if PIL_HAS_BBOX:
+ label_width = draw.textlength(this_line.label + u' ', font= top_label_font)
+ else:
+ label_width, _ = draw.textsize(this_line.label + u' ', font= top_label_font)
+ x += label_width
+
+ 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)
+ if PIL_HAS_BBOX:
+ left, top, right, bottom = rose_label_font.getbbox(self.rose_label)
+ rose_label_width, rose_label_height = right - left, bottom - top
+ else:
+ rose_label_width, rose_label_height = 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_width/2 - 1,
+ rose_center_y - rose_label_height/2 - 1),
+ self.rose_label,
+ fill=add_alpha(self.rose_label_font_color),
+ font=rose_label_font,
+ anchor="lt")
+
+ # 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 = min_with_none(line.y)
+ yline_max = max_with_none(line.y)
+ ymin = min_with_none([ymin, yline_min])
+ ymax = 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 = min_with_none(line.x)
+ xline_max = 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 = min_with_none([xmin, xline_min])
+ xmax = 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. Assumes that time is in unix epoch time."""
+ 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):
+ """Specialized version for time plots. Assumes that time is in unix epoch time."""
+ if self.x_label_format is None:
+ return u''
+ 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, line_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.line_gap_fraction = line_gap_fraction
+
+
+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