Contour Lines on Geographic Map — Pygal

A contour map overlays isolines (lines of equal value) onto a geographic basemap, visualizing continuous spatial data such as elevation, temperature, or atmospheric pressure across real-world coordinates. Unlike basic contour plots that use abstract x/y coordinates, this plot anchors contours to latitude/longitude positions with geographic context like coastlines, borders, or terrain. It combines the precision of isoline visualization with spatial awareness, making it ideal for meteorological, topographic, and environmental applications.

Contour Lines on Geographic Map rendered with Pygal

Python source (Pygal)

""" anyplot.ai
contour-map-geographic: Contour Lines on Geographic Map
Library: pygal 3.1.0 | Python 3.13.13
Quality: 87/100 | Updated: 2026-05-20
"""

import os
import sys
from pathlib import Path


sys.path = [p for p in sys.path if p != str(Path(__file__).parent)]

import cairosvg
import matplotlib.cm as mpl_cm
import matplotlib.colors as mpl_colors
import numpy as np
import pygal as pygal_lib
from pygal.style import Style


THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Data — North Pacific sea-level pressure field (hPa)
np.random.seed(42)

lon_min, lon_max = 120, 240  # 120°E to 120°W
lat_min, lat_max = 20, 65

n_grid = 40
lon_grid = np.linspace(lon_min, lon_max, n_grid)
lat_grid = np.linspace(lat_min, lat_max, n_grid)
LON, LAT = np.meshgrid(lon_grid, lat_grid)

# Background pressure gradient (higher in subtropics, lower at poles)
pressure_base = 1020 - (LAT - lat_min) * 0.25

# North Pacific High (subtropical high pressure ridge)
hp_lon, hp_lat = 200, 35
dist_hp = np.sqrt(((LON - hp_lon) / 25) ** 2 + ((LAT - hp_lat) / 12) ** 2)
pressure_base += 12 * np.exp(-(dist_hp**2))

# Aleutian Low (subpolar low pressure center)
al_lon, al_lat = 185, 53
dist_al = np.sqrt(((LON - al_lon) / 20) ** 2 + ((LAT - al_lat) / 10) ** 2)
pressure_base -= 18 * np.exp(-(dist_al**2))

# Secondary low off Asian coast
al2_lon, al2_lat = 145, 45
dist_al2 = np.sqrt(((LON - al2_lon) / 15) ** 2 + ((LAT - al2_lat) / 10) ** 2)
pressure_base -= 8 * np.exp(-(dist_al2**2))

PRESSURE = pressure_base + np.random.normal(0, 0.3, pressure_base.shape)

# Isobar levels every 4 hPa
contour_levels = np.arange(996, 1033, 4)

# Simplified coastlines: North Pacific rim (lon, lat)
coastlines = [
    # Japan main islands
    [
        (130, 31),
        (131, 33),
        (130, 34),
        (132, 34),
        (135, 35),
        (137, 37),
        (140, 38),
        (141, 40),
        (141, 43),
        (145, 44),
        (141, 45),
        (130, 31),
    ],
    # Kamchatka + Kuril arc
    [(152, 47), (155, 52), (158, 56), (163, 58), (167, 54), (165, 50), (152, 47)],
    # Aleutian Islands (simplified)
    [(167, 54), (172, 53), (178, 52), (188, 52), (195, 54), (200, 57), (210, 56), (220, 57), (225, 56)],
    # Alaska
    [(195, 57), (200, 59), (205, 60), (210, 60), (215, 60), (220, 60), (225, 59), (230, 58), (235, 55), (237, 53)],
    # North American west coast
    [
        (237, 53),
        (236, 50),
        (235, 48),
        (234, 47),
        (235, 45),
        (236, 42),
        (236, 38),
        (238, 35),
        (240, 32),
        (240, 25),
        (237, 22),
        (234, 20),
    ],
    # Asian mainland coast (rough)
    [
        (120, 22),
        (122, 24),
        (120, 27),
        (121, 30),
        (122, 32),
        (124, 34),
        (127, 37),
        (129, 38),
        (130, 40),
        (130, 43),
        (132, 46),
        (135, 48),
        (138, 48),
        (141, 46),
        (143, 47),
        (147, 48),
        (150, 47),
        (152, 47),
    ],
]

# Pressure colormap: viridis sequential (perceptually uniform, style-guide compliant)
p_min_cb, p_max_cb = 996, 1032
n_colors = 10
pressure_colors = [mpl_colors.to_hex(mpl_cm.viridis(i / (n_colors - 1))) for i in range(n_colors)]

custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    guide_stroke_color="rgba(128,128,128,0.15)",
    guide_stroke_dasharray="",
    colors=(INK_MUTED,) * (len(coastlines) + 1),
    title_font_size=52,
    label_font_size=56,
    major_label_font_size=44,
    legend_font_size=44,
    value_font_size=36,
    stroke_width=2.5,
)

chart = pygal_lib.XY(
    width=3200,
    height=1800,
    style=custom_style,
    title="North Pacific Pressure Isobars · contour-map-geographic · python · pygal · anyplot.ai",
    x_title="Longitude (°E)",
    y_title="Latitude (°N)",
    show_legend=False,
    stroke=True,
    dots_size=0,
    show_x_guides=False,
    show_y_guides=False,
    explicit_size=True,
    print_values=False,
    xrange=(lon_min, lon_max),
    range=(lat_min, lat_max),
    margin=100,
    margin_top=160,
    margin_bottom=160,
    margin_left=240,
    margin_right=260,
)

for coords in coastlines:
    chart.add(None, coords, stroke=True, dots_size=0, show_dots=False, fill=False)

# Plot area pixel coordinates (matching pygal internal layout)
plot_x = 240
plot_y = 160
plot_width = 3200 - 240 - 260
plot_height = 1800 - 160 - 160

svg_parts = []

# Filled pressure cells
cell_w = plot_width / (n_grid - 1)
cell_h = plot_height / (n_grid - 1)

for i in range(n_grid - 1):
    for j in range(n_grid - 1):
        avg_p = (PRESSURE[i, j] + PRESSURE[i, j + 1] + PRESSURE[i + 1, j] + PRESSURE[i + 1, j + 1]) / 4
        color_idx = int((avg_p - p_min_cb) / 4)
        color_idx = max(0, min(color_idx, len(pressure_colors) - 1))
        color = pressure_colors[color_idx]
        px = plot_x + (lon_grid[j] - lon_min) / (lon_max - lon_min) * plot_width
        py = plot_y + plot_height - (lat_grid[i + 1] - lat_min) / (lat_max - lat_min) * plot_height
        svg_parts.append(
            f'<rect x="{px:.1f}" y="{py:.1f}" width="{cell_w + 1:.1f}" '
            f'height="{cell_h + 1:.1f}" fill="{color}" fill-opacity="0.55" stroke="none"/>'
        )

# Isobar contour lines via marching squares
for level in contour_levels:
    is_major = level % 8 == 0
    line_color = INK if is_major else INK_SOFT
    line_width = 3.5 if is_major else 1.8

    all_segments = []
    for i in range(n_grid - 1):
        for j in range(n_grid - 1):
            z00, z01 = PRESSURE[i, j], PRESSURE[i, j + 1]
            z10, z11 = PRESSURE[i + 1, j], PRESSURE[i + 1, j + 1]

            case = 0
            if z00 >= level:
                case |= 1
            if z01 >= level:
                case |= 2
            if z11 >= level:
                case |= 4
            if z10 >= level:
                case |= 8

            if case == 0 or case == 15:
                continue

            x0 = plot_x + (lon_grid[j] - lon_min) / (lon_max - lon_min) * plot_width
            y0 = plot_y + plot_height - (lat_grid[i + 1] - lat_min) / (lat_max - lat_min) * plot_height
            x1 = plot_x + (lon_grid[j + 1] - lon_min) / (lon_max - lon_min) * plot_width
            y1 = plot_y + plot_height - (lat_grid[i] - lat_min) / (lat_max - lat_min) * plot_height

            t_left = 0.5 if abs(z10 - z00) < 1e-10 else (level - z00) / (z10 - z00)
            t_right = 0.5 if abs(z11 - z01) < 1e-10 else (level - z01) / (z11 - z01)
            t_top = 0.5 if abs(z11 - z10) < 1e-10 else (level - z10) / (z11 - z10)
            t_bottom = 0.5 if abs(z01 - z00) < 1e-10 else (level - z00) / (z01 - z00)

            left = (x0, y0 - cell_h * t_left)
            right = (x1, y1 + cell_h * t_right)
            top = (x0 + cell_w * t_top, y0 - cell_h)
            bottom = (x0 + cell_w * t_bottom, y0)

            if case in [1, 14]:
                all_segments.append((left, bottom))
            elif case in [2, 13]:
                all_segments.append((bottom, right))
            elif case in [3, 12]:
                all_segments.append((left, right))
            elif case in [4, 11]:
                all_segments.append((right, top))
            elif case == 5:
                all_segments.append((left, top))
                all_segments.append((bottom, right))
            elif case in [6, 9]:
                all_segments.append((bottom, top))
            elif case in [7, 8]:
                all_segments.append((left, top))
            elif case == 10:
                all_segments.append((left, bottom))
                all_segments.append((right, top))

    # Chain segments into polylines
    tolerance = 1.5
    polylines = []
    used = [False] * len(all_segments)

    for idx, seg in enumerate(all_segments):
        if used[idx]:
            continue
        used[idx] = True
        chain = list(seg)
        extended = True
        while extended:
            extended = False
            for j, other in enumerate(all_segments):
                if used[j]:
                    continue
                p0, p1 = other
                if abs(chain[-1][0] - p0[0]) < tolerance and abs(chain[-1][1] - p0[1]) < tolerance:
                    chain.append(p1)
                    used[j] = True
                    extended = True
                elif abs(chain[-1][0] - p1[0]) < tolerance and abs(chain[-1][1] - p1[1]) < tolerance:
                    chain.append(p0)
                    used[j] = True
                    extended = True
                elif abs(chain[0][0] - p1[0]) < tolerance and abs(chain[0][1] - p1[1]) < tolerance:
                    chain.insert(0, p0)
                    used[j] = True
                    extended = True
                elif abs(chain[0][0] - p0[0]) < tolerance and abs(chain[0][1] - p0[1]) < tolerance:
                    chain.insert(0, p1)
                    used[j] = True
                    extended = True
        polylines.append(chain)

    for chain in polylines:
        if len(chain) < 2:
            continue
        path_data = f"M {chain[0][0]:.1f} {chain[0][1]:.1f}"
        for pt in chain[1:]:
            path_data += f" L {pt[0]:.1f} {pt[1]:.1f}"
        svg_parts.append(
            f'<path d="{path_data}" fill="none" stroke="{line_color}" '
            f'stroke-width="{line_width}" stroke-opacity="0.9" stroke-linejoin="round" stroke-linecap="round"/>'
        )

# Isobar labels at representative positions
label_positions = [
    (135, 30, 1016),
    (150, 55, 1004),
    (185, 52, 1000),
    (200, 37, 1024),
    (215, 55, 1008),
    (230, 45, 1016),
    (155, 38, 1012),
]
for lon_l, lat_l, pval in label_positions:
    if lon_min <= lon_l <= lon_max and lat_min <= lat_l <= lat_max:
        px = plot_x + (lon_l - lon_min) / (lon_max - lon_min) * plot_width
        py = plot_y + plot_height - (lat_l - lat_min) / (lat_max - lat_min) * plot_height
        svg_parts.append(
            f'<rect x="{px - 48}" y="{py - 22}" width="96" height="40" '
            f'fill="{ELEVATED_BG}" fill-opacity="0.90" rx="5" stroke="{INK_MUTED}" stroke-width="1"/>'
        )
        svg_parts.append(
            f'<text x="{px}" y="{py + 7}" text-anchor="middle" fill="{INK}" '
            f'style="font-size:30px;font-weight:bold;font-family:sans-serif">{pval}</text>'
        )

# Colorbar
cb_width = 48
cb_height = plot_height * 0.72
cb_x = plot_x + plot_width + 55
cb_y = plot_y + (plot_height - cb_height) / 2

n_cb = len(pressure_colors)
seg_h = cb_height / n_cb
for i, color in enumerate(pressure_colors[::-1]):
    seg_y = cb_y + i * seg_h
    svg_parts.append(f'<rect x="{cb_x}" y="{seg_y:.1f}" width="{cb_width}" height="{seg_h + 1:.1f}" fill="{color}"/>')

svg_parts.append(
    f'<rect x="{cb_x}" y="{cb_y}" width="{cb_width}" height="{cb_height}" fill="none" stroke="{INK}" stroke-width="2"/>'
)

cb_vals = list(range(p_max_cb, p_min_cb - 1, -4))
for i, val in enumerate(cb_vals):
    label_y = cb_y + i * seg_h + seg_h / 2 + 11
    svg_parts.append(
        f'<text x="{cb_x + cb_width + 12}" y="{label_y:.1f}" fill="{INK}" '
        f'style="font-size:30px;font-family:sans-serif">{val}</text>'
    )

svg_parts.append(
    f'<text x="{cb_x + cb_width / 2}" y="{cb_y - 22}" text-anchor="middle" fill="{INK}" '
    f'style="font-size:32px;font-weight:bold;font-family:sans-serif">hPa</text>'
)

# Pressure center labels (H/L): data-encoding colors, domain-conventional
HIGH_COLOR = "#C475FD"  # Okabe-Ito vermillion — high pressure
LOW_COLOR = "#4467A3"  # Okabe-Ito blue — low pressure
centers = [(200, 35, "H", HIGH_COLOR), (185, 53, "L", LOW_COLOR), (145, 45, "L", LOW_COLOR)]
for lon_c, lat_c, label, color in centers:
    if lon_min <= lon_c <= lon_max and lat_min <= lat_c <= lat_max:
        px = plot_x + (lon_c - lon_min) / (lon_max - lon_min) * plot_width
        py = plot_y + plot_height - (lat_c - lat_min) / (lat_max - lat_min) * plot_height
        svg_parts.append(
            f'<text x="{px}" y="{py + 18}" text-anchor="middle" fill="{color}" '
            f'style="font-size:56px;font-weight:bold;font-family:sans-serif;opacity:0.85">{label}</text>'
        )

custom_svg = "\n".join(svg_parts)

chart.add("", [(lon_min, lat_min)])

base_svg = chart.render(is_unicode=True)
output_svg = base_svg.replace("</svg>", f"{custom_svg}\n</svg>")

cairosvg.svg2png(bytestring=output_svg.encode("utf-8"), write_to=f"plot-{THEME}.png")

with open(f"plot-{THEME}.html", "w") as f:
    f.write(output_svg)

Part of Contour Lines on Geographic Map on anyplot.ai.

Other implementations