Terrain Elevation Profile Along Transect — Pygal

A cross-sectional line plot showing ground elevation along a path or transect line, with the area below the profile filled to create a terrain silhouette. Distance along the transect is plotted on the x-axis and elevation on the y-axis. This visualization is widely used in hiking and cycling route planning, civil engineering corridor design, and geomorphology to convey terrain shape and difficulty at a glance.

Terrain Elevation Profile Along Transect rendered with Pygal

Python source (Pygal)

""" anyplot.ai
area-elevation-profile: Terrain Elevation Profile Along Transect
Library: pygal 3.1.0 | Python 3.13.13
Quality: 88/100 | Updated: 2026-06-10
"""

import os
import re
import xml.etree.ElementTree as ET

import cairosvg
import numpy as np
import pygal
from pygal.style import Style


# Theme tokens — Imprint palette
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

IMPRINT_PALETTE = ("#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314")

# Landmark type colors — Imprint palette with semantic cues
TYPE_COLORS = {
    "summit": "#AE3030",  # matte red — elevation peak, highest difficulty
    "pass": "#BD8233",  # ochre — earthy mountain col
    "hut": "#C475FD",  # lavender — alpine shelter, distinctive
    "valley": "#99B314",  # lime — green valley floor
    "town": "#4467A3",  # blue — settlement / trailhead
}

# Data — Alpine hiking trail elevation profile (120 km transect)
np.random.seed(42)
distances_km = np.linspace(0, 120, 200)

base_terrain = (
    800
    + 600 * np.sin(distances_km * np.pi / 40) ** 2
    + 400 * np.sin(distances_km * np.pi / 25 + 1.2) ** 2
    + 300 * np.exp(-((distances_km - 55) ** 2) / 80)
    + 500 * np.exp(-((distances_km - 85) ** 2) / 120)
    - 200 * np.exp(-((distances_km - 30) ** 2) / 50)
)
noise = np.random.normal(0, 25, len(distances_km))
elevation_m = base_terrain + noise
elevation_m = np.maximum(elevation_m, 650)

landmarks = [
    {"name": "Grindelwald", "km": 0, "type": "town"},
    {"name": "Kleine Scheidegg", "km": 22, "type": "pass"},
    {"name": "Lauterbrunnen", "km": 38, "type": "valley"},
    {"name": "Mürren", "km": 55, "type": "summit"},
    {"name": "Blüemlisalp Hut", "km": 72, "type": "hut"},
    {"name": "Hohtürli Pass", "km": 85, "type": "pass"},
    {"name": "Kandersteg", "km": 120, "type": "town"},
]

for lm in landmarks:
    idx = np.argmin(np.abs(distances_km - lm["km"]))
    lm["elev"] = float(elevation_m[idx])

# Title length: 80 chars → fontsize = round(66 × 67 / 80) = 55
TITLE = "Bernese Oberland Traverse · area-elevation-profile · python · pygal · anyplot.ai"

custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    colors=IMPRINT_PALETTE,
    font_family="DejaVu Sans, Helvetica, Arial, sans-serif",
    title_font_family="DejaVu Sans, Helvetica, Arial, sans-serif",
    title_font_size=55,
    label_font_size=56,
    major_label_font_size=44,
    value_font_size=36,
    legend_font_size=44,
    legend_font_family="DejaVu Sans, Helvetica, Arial, sans-serif",
    label_font_family="DejaVu Sans, Helvetica, Arial, sans-serif",
    major_label_font_family="DejaVu Sans, Helvetica, Arial, sans-serif",
    value_font_family="DejaVu Sans, Helvetica, Arial, sans-serif",
    opacity=0.70,
    opacity_hover=0.85,
    stroke_opacity=1.0,
    stroke_opacity_hover=1.0,
    tooltip_font_size=32,
    tooltip_font_family="DejaVu Sans, Helvetica, Arial, sans-serif",
    tooltip_border_radius=8,
)

chart = pygal.XY(
    width=3200,
    height=1800,
    title=TITLE,
    x_title="Distance (km)",
    y_title="Elevation (m)",
    style=custom_style,
    fill=True,
    show_dots=False,
    stroke_style={"width": 4},
    show_y_guides=True,
    show_x_guides=False,
    show_legend=True,
    legend_at_bottom=True,
    legend_box_size=30,
    value_formatter=lambda x: f"{x:,.0f} m",
    x_value_formatter=lambda x: f"{x:.0f} km",
    interpolate="cubic",
    interpolation_precision=300,
    min_scale=5,
    max_scale=10,
    margin_bottom=90,
    margin_left=90,
    margin_right=120,
    margin_top=50,
    spacing=12,
    tooltip_fancy_mode=True,
    range=(550, 2100),
    xrange=(0, 125),
    show_minor_x_labels=False,
    show_minor_y_labels=False,
    truncate_legend=-1,
    dynamic_print_values=True,
    print_values=False,
    show_x_labels=True,
    show_y_labels=True,
    x_labels_major_count=7,
    y_labels_major_count=6,
)

# Elevation profile — first series uses Imprint position 1 (#009E73)
profile_data = [
    {"value": (float(d), float(e)), "label": f"{d:.1f} km — {e:.0f} m"}
    for d, e in zip(distances_km, elevation_m, strict=True)
]
chart.add("Elevation Profile", profile_data)

# Landmark markers — colored by type in SVG post-processing below
landmark_data = [
    {"value": (float(lm["km"]), lm["elev"]), "label": f"{lm['name']} — {lm['elev']:.0f} m"} for lm in landmarks
]
chart.add("Landmarks", landmark_data, fill=False, show_dots=True, dots_size=20, stroke=False)

# Save interactive HTML with pygal's native tooltip support
chart.render_to_file(f"plot-{THEME}.html")

# Render SVG for annotation post-processing
svg_bytes = chart.render()
SVG_NS = "http://www.w3.org/2000/svg"
ET.register_namespace("", SVG_NS)
ET.register_namespace("xlink", "http://www.w3.org/1999/xlink")
root = ET.fromstring(svg_bytes)

parent_map = {child: parent for parent in root.iter() for child in parent}
circles = sorted(
    [c for c in root.iter(f"{{{SVG_NS}}}circle") if float(c.get("r", "0")) > 3], key=lambda c: float(c.get("cx", "0"))
)

# Find plot bottom from horizontal guide paths (format: "M0.0 Y h...")
plot_bottom_y = 0
for path in root.iter(f"{{{SVG_NS}}}path"):
    cls = path.get("class", "")
    d_attr = path.get("d", "")
    if "guide" in cls and " h" in d_attr:
        m = re.match(r"M[\d.]+ ([\d.]+) h", d_attr)
        if m:
            plot_bottom_y = max(plot_bottom_y, float(m.group(1)))

# Stagger closely-spaced labels to avoid overlap
label_offsets = {
    "Grindelwald": -44,
    "Kleine Scheidegg": -44,
    "Lauterbrunnen": -44,
    "Mürren": -56,
    "Blüemlisalp Hut": -74,
    "Hohtürli Pass": -44,
    "Kandersteg": -44,
}

ns = f"{{{SVG_NS}}}"
for i, circle in enumerate(circles[: len(landmarks)]):
    parent_elem = parent_map.get(circle)
    if parent_elem is None:
        continue

    cx, cy = float(circle.get("cx", "0")), float(circle.get("cy", "0"))
    lm = landmarks[i]
    lm_color = TYPE_COLORS.get(lm["type"], "#BD8233")
    y_off = label_offsets.get(lm["name"], -44)

    # Override circle fill to landmark-type color for clear visual differentiation
    circle.set("fill", lm_color)
    circle.set("stroke", PAGE_BG)
    circle.set("stroke-width", "3")

    # Text anchor by position along transect
    if i == 0:
        anchor, dx = "start", 14
    elif i == len(landmarks) - 1:
        anchor, dx = "end", -14
    else:
        anchor, dx = "middle", 0

    # Vertical dashed marker line from dot to plot baseline
    vline = ET.SubElement(parent_elem, f"{ns}line")
    for attr, val in [("x1", cx), ("y1", cy), ("x2", cx), ("y2", plot_bottom_y)]:
        vline.set(attr, f"{val:.1f}")
    vline.set("stroke", lm_color)
    vline.set("stroke-width", "2.5")
    vline.set("stroke-dasharray", "8,5")
    vline.set("opacity", "0.65")

    # Landmark name — bold, colored by type
    name_el = ET.SubElement(parent_elem, f"{ns}text")
    name_el.set("x", f"{cx + dx:.1f}")
    name_el.set("y", f"{cy + y_off:.1f}")
    name_el.set("text-anchor", anchor)
    name_el.set("font-size", "32")
    name_el.set("font-family", "DejaVu Sans, Helvetica, Arial, sans-serif")
    name_el.set("fill", lm_color)
    name_el.set("font-weight", "bold")
    name_el.text = lm["name"]

    # Elevation value — muted secondary label below name
    elev_el = ET.SubElement(parent_elem, f"{ns}text")
    elev_el.set("x", f"{cx + dx:.1f}")
    elev_el.set("y", f"{cy + y_off + 32:.1f}")
    elev_el.set("text-anchor", anchor)
    elev_el.set("font-size", "30")
    elev_el.set("font-family", "DejaVu Sans, Helvetica, Arial, sans-serif")
    elev_el.set("fill", INK_MUTED)
    elev_el.text = f"{lm['elev']:.0f} m"

# Vertical exaggeration note — bottom-right, more prominent than previous version
note_el = ET.SubElement(root, f"{ns}text")
note_el.set("x", "3080")
note_el.set("y", f"{plot_bottom_y + 54:.0f}")
note_el.set("text-anchor", "end")
note_el.set("font-size", "32")
note_el.set("font-family", "DejaVu Sans, Helvetica, Arial, sans-serif")
note_el.set("fill", INK_SOFT)
note_el.set("font-style", "italic")
note_el.text = "Vertical exaggeration ~10× for terrain visibility"

# Render final PNG
cairosvg.svg2png(bytestring=ET.tostring(root, encoding="unicode").encode("utf-8"), write_to=f"plot-{THEME}.png")

Part of Terrain Elevation Profile Along Transect on anyplot.ai.

Other implementations