diff --git a/lib/xmet/hodograph.py b/lib/xmet/hodograph.py
new file mode 100644
index 0000000..9183837
--- /dev/null
+++ b/lib/xmet/hodograph.py
@@ -0,0 +1,168 @@
+import math
+import cairo
+
+from typing import Iterable
+
+from xmet.igra     import IGRAReader
+from xmet.sounding import Sounding, SoundingSample
+
+IMAGE_WIDTH = 800
+IMAGE_HEIGHT = 800
+
+GRAPH_WIDTH  = IMAGE_WIDTH  - 128
+GRAPH_HEIGHT = IMAGE_HEIGHT - 128
+
+WIND_SPEED_MAX  = 90 # knots
+WIND_SPEED_MIN  = 10
+WIND_SPEED_STEP = 10
+
+WIND_DIR_STEP = 45 # degrees
+
+PRESSURE_MIN = 100
+
+def radians(degrees: float) -> float:
+    return (degrees + 90) * (math.pi / 180.0)
+
+def degrees(radians: float) -> float:
+    return (radians * (180.0 / math.pi)) - 90
+
+class Hodograph():
+    def __init__(self, width, height):
+        self.width  = min(width, height)
+        self.height = min(width, height)
+        self.radius = min(width, height) / 2
+
+    def sample_to_screen(self, wind_speed: dir, wind_dir: float) -> tuple:
+        r = self.radius * min(wind_speed, WIND_SPEED_MAX) / WIND_SPEED_MAX
+
+        return (
+            self.width  / 2 + r * math.cos(radians(wind_dir)),
+            self.height / 2 + r * math.sin(radians(wind_dir))
+        )
+
+    def draw_speed_lines(self, cr: cairo.Context, x, y):
+        for speed in range(WIND_SPEED_MIN, WIND_SPEED_MAX+1, WIND_SPEED_STEP):
+            cr.arc(x + self.width  / 2,
+                   y + self.height / 2,
+                   self.radius * min(speed, WIND_SPEED_MAX) / WIND_SPEED_MAX,
+                   0,
+                   2*math.pi)
+
+            cr.stroke()
+
+    def draw_direction_lines(self, cr: cairo.Context, x, y):
+         for angle in range(0, 360+1, WIND_DIR_STEP):
+            sx1, sy1 = self.sample_to_screen(WIND_SPEED_MAX, angle)
+            sx2, sy2 = self.sample_to_screen(WIND_SPEED_MAX, angle + 180)
+
+            cr.move_to(x + sx1, y + sy1)
+            cr.line_to(x + sx2, y + sy2)
+            cr.stroke()
+
+    def draw_direction_legends(self, cr: cairo.Context, x, y):
+        cr.save()
+
+        cr.select_font_face('Sans',
+                            cairo.FONT_SLANT_NORMAL,
+                            cairo.FONT_WEIGHT_BOLD)
+
+        cr.set_font_size(16)
+
+        for angle in range(0, 360, WIND_DIR_STEP):
+            text    = "%d°" % angle
+            extents = cr.text_extents(text)
+            r       = self.radius + ((extents.width + extents.height) / 2)
+
+            sx = self.width  / 2 + r * math.cos(radians(angle))
+            sy = self.height / 2 + r * math.sin(radians(angle))
+
+            cr.move_to(x + sx - extents.width / 2, y + sy + extents.height / 2)
+            cr.show_text(text)
+            cr.stroke()
+
+        cr.restore()
+
+    def draw_speed_legends(self, cr: cairo.Context, x, y):
+        cr.save()
+
+        x_offset_scale = 0.01190476
+        y_offset_scale = 0.02380952
+
+        x_offset = x_offset_scale * self.radius
+        y_offset = y_offset_scale * self.radius
+
+        for speed in range(WIND_SPEED_MIN, WIND_SPEED_MAX, WIND_SPEED_STEP):
+            text    = "%dkt" % speed
+            extents = cr.text_extents(text)
+
+            sx, sy = self.sample_to_screen(speed, 180)
+
+            cr.move_to(x + sx + x_offset, y + sy - y_offset)
+            cr.show_text(text)
+            cr.stroke()
+
+        cr.restore()
+
+    def color(self, height: float):
+        if height <= 500:
+            return (1, 0, 1)
+        elif height <= 3000:
+            return (1, 0, 0)
+        elif height <= 7000:
+            return (0, 1, 0)
+        elif height <= 10000:
+            return (0, 0, 1)
+
+    def draw_samples(self,
+                     cr: cairo.Context,
+                     x,
+                     y,
+                     samples: Iterable[SoundingSample]):
+        cr.save()
+
+        first      = True
+        color_last = None
+        sx_last    = None
+        sy_last    = None
+
+        for sample in samples:
+            if sample.pressure < 0 or sample.pressure is None:
+                continue
+
+            if sample.pressure < PRESSURE_MIN:
+                break
+
+            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(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(self, cr: cairo.Context, x, y, samples: Iterable[SoundingSample]):
+        self.draw_speed_lines(cr, x, y)
+        self.draw_direction_lines(cr, x, y)
+
+        self.draw_samples(cr, x, y, samples)
+
+        self.draw_speed_legends(cr, x, y)
+        self.draw_direction_legends(cr, x, y)