316 lines
8.5 KiB
Python
316 lines
8.5 KiB
Python
import math
|
|
import cairo
|
|
|
|
from xmet.units import rad, knots
|
|
from xmet.sounding import Sounding
|
|
|
|
WIND_SPEED_MAX = 140 # knots
|
|
WIND_SPEED_MIN = 10
|
|
WIND_SPEED_STEP = 10
|
|
|
|
WIND_DIR_STEP = 30 # degrees
|
|
|
|
class Hodograph():
|
|
def __init__(self, width, height):
|
|
self.width = min(width, height)
|
|
self.height = min(width, height)
|
|
self.radius = min(width, height) / 2
|
|
self.extents = None
|
|
|
|
def sample_to_screen(self, wind_speed: dir, wind_dir: float) -> tuple:
|
|
r = self.radius * (wind_speed / WIND_SPEED_MAX)
|
|
|
|
x = self.radius + r * math.cos(rad(wind_dir))
|
|
y = self.radius + r * math.sin(rad(wind_dir))
|
|
|
|
if self.extents is None:
|
|
return x, y
|
|
else:
|
|
min_x, min_y = self.extents['min']
|
|
max_x, max_y = self.extents['max']
|
|
|
|
box_width = max_x - min_x
|
|
box_height = max_y - min_y
|
|
box = max(box_width, box_height) * 1.5
|
|
|
|
#
|
|
# Ensure the data points are centered within the hodograph
|
|
# viewbox.
|
|
#
|
|
offset_x = self.width * ((box - box_width) / box) / 2
|
|
offset_y = self.height * ((box - box_height) / box) / 2
|
|
|
|
return (
|
|
offset_x + ((x - min_x) / box) * self.width,
|
|
offset_y + ((y - min_y) / box) * self.height
|
|
)
|
|
|
|
def draw_speed_lines(self, cr: cairo.Context, x, y):
|
|
cr.save()
|
|
|
|
cr.set_source_rgb(0.5, 0.5, 0.5)
|
|
cr.set_line_width(0.5)
|
|
|
|
cx, cy = self.sample_to_screen(0, 0)
|
|
|
|
for speed in range(WIND_SPEED_MIN, 2*WIND_SPEED_MAX, WIND_SPEED_STEP):
|
|
if speed % (WIND_SPEED_STEP*2) == 0:
|
|
cr.set_dash([1, 1], 0)
|
|
else:
|
|
cr.set_dash([5, 5], 1)
|
|
|
|
sx, sy = self.sample_to_screen(speed, 0)
|
|
|
|
cr.arc(x + cx,
|
|
x + cy,
|
|
abs(sy - cy),
|
|
0,
|
|
2*math.pi)
|
|
|
|
cr.stroke()
|
|
|
|
cr.restore()
|
|
|
|
def draw_direction_lines(self, cr: cairo.Context, x, y):
|
|
cr.save()
|
|
|
|
cr.set_source_rgb(0.5, 0.5, 0.5)
|
|
cr.set_line_width(0.5)
|
|
|
|
for angle in range(0, 360+1, WIND_DIR_STEP):
|
|
sx1, sy1 = self.sample_to_screen(2*WIND_SPEED_MAX, angle)
|
|
sx2, sy2 = self.sample_to_screen(2*WIND_SPEED_MAX, angle + 180)
|
|
|
|
if angle % 90 == 0:
|
|
cr.set_dash([1, 1], 0)
|
|
else:
|
|
cr.set_dash([5, 5], 1)
|
|
|
|
cr.move_to(x + sx1, y + sy1)
|
|
cr.line_to(x + sx2, y + sy2)
|
|
cr.stroke()
|
|
|
|
cr.restore()
|
|
|
|
def draw_speed_legends(self, cr: cairo.Context, x, y):
|
|
cr.save()
|
|
|
|
for speed in range(WIND_SPEED_MIN, WIND_SPEED_MAX, WIND_SPEED_STEP):
|
|
text = "%dkt" % speed
|
|
extents = cr.text_extents(text)
|
|
|
|
for angle in (0, 180):
|
|
sx, sy = self.sample_to_screen(speed, angle)
|
|
|
|
cr.move_to(x + sx + extents.width * 0.25,
|
|
y + sy - extents.height * 1.5)
|
|
|
|
cr.show_text(text)
|
|
|
|
cr.restore()
|
|
|
|
def draw_direction_legends(self, cr: cairo.Context, x, y):
|
|
cx, cy = self.sample_to_screen(0, 0)
|
|
|
|
legends = {
|
|
'0°': (
|
|
lambda e: x + cx - e.width / 2,
|
|
lambda e: y + self.height - e.height
|
|
),
|
|
|
|
'90°': (
|
|
lambda e: x + e.height,
|
|
lambda e: y + cy + e.height / 2
|
|
),
|
|
|
|
'180°': (
|
|
lambda e: x + cx - e.width / 2,
|
|
lambda e: y + 2 * e.height
|
|
),
|
|
|
|
'270°': (
|
|
lambda e: x + self.width - e.width - e.height,
|
|
lambda e: y + cy + e.height / 2
|
|
)
|
|
}
|
|
|
|
cr.save()
|
|
|
|
cr.select_font_face('Sans',
|
|
cairo.FONT_SLANT_NORMAL,
|
|
cairo.FONT_WEIGHT_BOLD)
|
|
|
|
cr.set_font_size(14)
|
|
|
|
for text in legends:
|
|
extents = cr.text_extents(text)
|
|
fx, fy = legends[text]
|
|
|
|
cr.move_to(fx(extents), fy(extents))
|
|
cr.show_text(text)
|
|
|
|
cr.restore()
|
|
|
|
COLORS = {
|
|
500: (1.0, 0.4, 1.0),
|
|
3000: (1.0, 0.4, 0.4),
|
|
7000: (0.4, 1.0, 0.4),
|
|
10000: (0.4, 0.4, 1.0)
|
|
}
|
|
|
|
def color(self, height: float):
|
|
for key in self.COLORS:
|
|
if height < key:
|
|
return self.COLORS[key]
|
|
|
|
def each_significant_sample(self, sounding: Sounding):
|
|
for sample in sounding.samples:
|
|
if sample.pressure < 0 or sample.pressure is None:
|
|
continue
|
|
|
|
if sample.height is None:
|
|
continue
|
|
|
|
if self.color(sample.height) is None:
|
|
break
|
|
|
|
yield sample
|
|
|
|
def find_extents(self, sounding: Sounding):
|
|
min_x, min_y = None, None
|
|
max_x, max_y = None, None
|
|
|
|
first = True
|
|
|
|
for sample in self.each_significant_sample(sounding):
|
|
sx, sy = self.sample_to_screen(knots(sample.wind_speed),
|
|
sample.wind_dir)
|
|
|
|
if first:
|
|
min_x = sx
|
|
max_x = sx
|
|
min_y = sy
|
|
max_y = sy
|
|
|
|
first = False
|
|
else:
|
|
if min_x > sx:
|
|
min_x = sx
|
|
|
|
if min_y > sy:
|
|
min_y = sy
|
|
|
|
if max_x < sx:
|
|
max_x = sx
|
|
|
|
if max_y < sy:
|
|
max_y = sy
|
|
|
|
return {
|
|
'min': (min_x, min_y),
|
|
'max': (max_x, max_y)
|
|
}
|
|
|
|
def draw_sounding(self,
|
|
cr: cairo.Context,
|
|
x,
|
|
y,
|
|
sounding: Sounding):
|
|
cr.save()
|
|
|
|
first = True
|
|
color_last = None
|
|
sx_last = None
|
|
sy_last = None
|
|
|
|
min_x, min_y = self.extents['min']
|
|
max_x, max_y = self.extents['max']
|
|
|
|
for sample in self.each_significant_sample(sounding):
|
|
color = self.color(sample.height)
|
|
|
|
if color is None:
|
|
break
|
|
elif color is not color_last:
|
|
cr.stroke()
|
|
cr.set_source_rgb(*color)
|
|
|
|
if sx_last is not None and sy_last is not None:
|
|
cr.move_to(x + sx_last, x + sy_last)
|
|
|
|
sx, sy = self.sample_to_screen(knots(sample.wind_speed),
|
|
sample.wind_dir)
|
|
|
|
if first:
|
|
cr.move_to(x + sx, y + sy)
|
|
first = False
|
|
else:
|
|
cr.line_to(x + sx, y + sy)
|
|
|
|
color_last = color
|
|
sx_last = sx
|
|
sy_last = sy
|
|
|
|
cr.stroke()
|
|
cr.restore()
|
|
|
|
def draw_height_legends(self, cr: cairo.Context, x, y):
|
|
width = 0.02380952 * self.width
|
|
interval = 0.05952380 * self.width
|
|
offset = 0.75 * self.width
|
|
|
|
cr.save()
|
|
cr.set_source_rgb(1, 1, 1)
|
|
|
|
cr.rectangle(offset + x - width * 0.75,
|
|
y + width,
|
|
interval * 4,
|
|
width * 3)
|
|
|
|
cr.fill()
|
|
|
|
cr.set_source_rgb(0.8, 0.8, 0.8)
|
|
cr.rectangle(offset + x - width * 0.75,
|
|
y + width,
|
|
interval * 4,
|
|
width * 3)
|
|
cr.stroke()
|
|
cr.restore()
|
|
|
|
for height in self.COLORS:
|
|
color = self.COLORS[height]
|
|
|
|
cr.save()
|
|
cr.set_source_rgb(*color)
|
|
cr.rectangle(offset + x, y + width * 1.5, width, width)
|
|
cr.fill()
|
|
cr.restore()
|
|
|
|
cr.rectangle(offset + x, y + width * 1.5, width, width)
|
|
cr.stroke()
|
|
|
|
text = "%dm" % (height) if height < 1000 else "%dkm" % (height /1000)
|
|
extents = cr.text_extents(text)
|
|
|
|
cr.move_to(offset + x - extents.width / 4, y + 3.5*width)
|
|
cr.show_text(text)
|
|
|
|
offset += interval
|
|
|
|
def draw(self, cr: cairo.Context, x, y, sounding: Sounding, zoom: bool=True):
|
|
if zoom:
|
|
self.extents = self.find_extents(sounding)
|
|
|
|
cr.rectangle(x, y, self.width, self.height)
|
|
cr.clip()
|
|
|
|
self.draw_direction_lines(cr, x, y)
|
|
self.draw_speed_lines(cr, x, y)
|
|
|
|
self.draw_sounding(cr, x, y, sounding)
|
|
|
|
self.draw_speed_legends(cr, x, y)
|
|
self.draw_direction_legends(cr, x, y)
|
|
self.draw_height_legends(cr, x, y)
|
|
|
|
cr.reset_clip()
|