Pediatric Growth Chart with Percentile Curves — Pygal

A WHO/CDC-style growth chart displaying smooth percentile curves (3rd, 10th, 25th, 50th, 75th, 90th, 97th) as colored bands, with individual patient data points overlaid and connected by a line. This chart is a standard clinical tool for monitoring child development metrics such as height, weight, or BMI across age. It enables quick visual assessment of whether a child's growth trajectory falls within expected population ranges.

Pediatric Growth Chart with Percentile Curves rendered with Pygal

Python source (Pygal)

""" anyplot.ai
line-growth-percentile: Pediatric Growth Chart with Percentile Curves
Library: pygal 3.1.3 | Python 3.13.14
Quality: 86/100 | Updated: 2026-06-20
"""

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

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


# Theme tokens (see prompts/default-style-guide.md "Theme-adaptive Chrome")
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Imprint palette — brand green for patient data (primary foreground series)
BRAND = "#009E73"  # Imprint position 1 — always first categorical series

# Boys chart convention — Imprint blue (#4467A3) for percentile bands
# Graduated tones lerped from PAGE_BG toward #4467A3: outer darker, inner lighter
if THEME == "light":
    BAND_OUTER = "#96A8C6"  # lerp(#FAF8F1, #4467A3, t=0.55)
    BAND_MID = "#BAC5D6"  # lerp(#FAF8F1, #4467A3, t=0.35)
    BAND_INNER = "#D9DEE3"  # lerp(#FAF8F1, #4467A3, t=0.18)
else:
    BAND_OUTER = "#354C72"  # lerp(#1A1A17, #4467A3, t=0.65)
    BAND_MID = "#2E3F5A"  # lerp(#1A1A17, #4467A3, t=0.48)
    BAND_INNER = "#273141"  # lerp(#1A1A17, #4467A3, t=0.30)

# Data — WHO weight-for-age reference for boys, 0–36 months
np.random.seed(42)
age_months = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 15, 18, 21, 24, 27, 30, 33, 36])

percentile_50 = np.array(
    [3.3, 4.5, 5.6, 6.4, 7.0, 7.5, 7.9, 8.3, 8.6, 8.9, 9.2, 9.4, 9.6, 10.3, 10.9, 11.5, 12.2, 12.7, 13.3, 13.8, 14.3]
)
offsets = {
    3: np.linspace(1.1, 3.2, len(age_months)),
    10: np.linspace(0.85, 2.5, len(age_months)),
    25: np.linspace(0.55, 1.5, len(age_months)),
    75: np.linspace(0.55, 1.5, len(age_months)),
    90: np.linspace(0.85, 2.5, len(age_months)),
    97: np.linspace(1.1, 3.2, len(age_months)),
}

percentile_3 = percentile_50 - offsets[3]
percentile_10 = percentile_50 - offsets[10]
percentile_25 = percentile_50 - offsets[25]
percentile_75 = percentile_50 + offsets[75]
percentile_90 = percentile_50 + offsets[90]
percentile_97 = percentile_50 + offsets[97]

patient_ages = np.array([0, 1, 2, 4, 6, 9, 12, 15, 18, 24, 30, 36])
patient_weights = np.array([3.2, 4.3, 5.3, 6.7, 7.5, 8.5, 9.3, 10.0, 10.5, 11.8, 12.8, 13.7])

percentile_label_vals = [
    ("P3", float(percentile_3[-1])),
    ("P10", float(percentile_10[-1])),
    ("P25", float(percentile_25[-1])),
    ("P50", float(percentile_50[-1])),
    ("P75", float(percentile_75[-1])),
    ("P90", float(percentile_90[-1])),
    ("P97", float(percentile_97[-1])),
]

# Title length-scaled font size (baseline 66 for ~67 chars)
TITLE = "WHO Weight-for-Age · line-growth-percentile · python · pygal · anyplot.ai"
_n = len(TITLE)
_ratio = 67 / _n if _n > 67 else 1.0
title_fs = max(44, round(66 * _ratio))

# Pygal style — 3200×1800 canvas, theme-adaptive chrome
custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    guide_stroke_color=INK_MUTED,
    colors=(
        BAND_OUTER,
        BAND_MID,
        BAND_INNER,  # P3–P10, P10–P25, P25–P50
        BAND_INNER,
        BAND_MID,
        BAND_OUTER,  # P50–P75, P75–P90, P90–P97
        "#4467A3",  # P50 median (Imprint blue, visually emphasized reference)
        BRAND,  # Patient data — Imprint position 1
    ),
    opacity=".65",
    opacity_hover=".80",
    stroke_opacity="1",
    stroke_opacity_hover="1",
    title_font_size=title_fs,
    label_font_size=56,
    major_label_font_size=44,
    legend_font_size=44,
    value_font_size=36,
    value_colors=("transparent",),
    font_family='Helvetica, Arial, "DejaVu Sans", sans-serif',
)

# Chart — landscape 3200×1800
chart = pygal.XY(
    style=custom_style,
    width=3200,
    height=1800,
    explicit_size=True,
    title=TITLE,
    x_title="Age (months)",
    y_title="Weight (kg)",
    show_dots=True,
    show_x_guides=False,
    show_y_guides=True,
    fill=True,
    stroke=True,
    legend_at_bottom=True,
    legend_at_bottom_columns=4,
    legend_box_size=26,
    truncate_legend=-1,
    range=(0, 18),
    x_labels=[0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 36],
    x_labels_major=[0, 6, 12, 18, 24, 30, 36],
    show_minor_x_labels=True,
    show_minor_y_labels=False,
    y_labels=list(range(2, 19, 2)),
    print_values=False,
    x_value_formatter=lambda x: f"{x:.0f}",
    value_formatter=lambda x: f"{x:.1f}",
    margin_top=30,
    margin_bottom=60,
    margin_left=30,
    margin_right=90,
    js=[],
)

# Percentile bands as filled polygons (upper edge forward, lower edge reversed)
band_configs = [
    ("P3–P10", percentile_3, percentile_10),
    ("P10–P25", percentile_10, percentile_25),
    ("P25–P50", percentile_25, percentile_50),
    ("P50–P75", percentile_50, percentile_75),
    ("P75–P90", percentile_75, percentile_90),
    ("P90–P97", percentile_90, percentile_97),
]

for label, lower, upper in band_configs:
    polygon = [(float(a), float(u)) for a, u in zip(age_months, upper, strict=True)]
    for a, lo in zip(reversed(age_months), reversed(lower), strict=True):
        polygon.append((float(a), float(lo)))
    chart.add(label, polygon, stroke_style={"width": 0.3, "opacity": 0.1}, dots_size=0)

# P50 median — dashed neutral line (de-emphasized so patient data stands out)
median_pts = [(float(a), float(v)) for a, v in zip(age_months, percentile_50, strict=True)]
chart.add(
    "P50 (Median)",
    median_pts,
    fill=False,
    stroke=True,
    dots_size=0,
    stroke_style={"width": 8, "linecap": "round", "dasharray": "14,7"},
)

# Patient data — brand green, prominent connected markers
patient_pts = [(float(a), float(w)) for a, w in zip(patient_ages, patient_weights, strict=True)]
chart.add(
    "Patient (Boy)",
    patient_pts,
    fill=False,
    stroke=True,
    dots_size=10,
    stroke_style={"width": 6, "linecap": "round", "linejoin": "round"},
)

# Render SVG → inject right-margin percentile labels → convert to PNG
svg_data = chart.render()

ET.register_namespace("", "http://www.w3.org/2000/svg")
ET.register_namespace("xlink", "http://www.w3.org/1999/xlink")
root = ET.fromstring(svg_data)

ns_svg = "http://www.w3.org/2000/svg"
y_guides = root.findall(f".//{{{ns_svg}}}g[@class='guides']//{{{ns_svg}}}line")
y_positions = [
    float(g.get("y1")) for g in y_guides if g.get("y1") and g.get("x1") and g.get("x2") and g.get("x1") != g.get("x2")
]

plot_top_svg = min(y_positions) if y_positions else 60
plot_bottom_svg = max(y_positions) if y_positions else 1480

x_right_svg = 3050.0
for g in y_guides:
    x2 = g.get("x2")
    if x2:
        x_right_svg = max(x_right_svg, float(x2))

y_min_val, y_max_val = 0.0, 18.0
lbl_group = ET.SubElement(root, f"{{{ns_svg}}}g")
lbl_group.set("class", "percentile-labels")

for lbl, val in percentile_label_vals:
    frac = (val - y_min_val) / (y_max_val - y_min_val)
    y_svg = plot_bottom_svg - frac * (plot_bottom_svg - plot_top_svg)
    el = ET.SubElement(lbl_group, f"{{{ns_svg}}}text")
    el.set("x", str(x_right_svg + 10))
    el.set("y", str(y_svg + 7))
    el.set("font-size", "38")
    el.set("font-family", 'Helvetica, Arial, "DejaVu Sans", sans-serif')
    el.set("font-weight", "bold" if lbl == "P50" else "normal")
    el.set("fill", "#4467A3" if lbl == "P50" else INK_MUTED)
    el.text = lbl

# Convert to PNG at exactly 3200×1800 (landscape canvas — hard contract)
modified_svg = ET.tostring(root, encoding="unicode")
png_bytes = cairosvg.svg2png(bytestring=modified_svg.encode("utf-8"), output_width=3200, output_height=1800)
img = Image.open(io.BytesIO(png_bytes)).convert("RGB")
img.save(f"plot-{THEME}.png")

# Save interactive HTML
with open(f"plot-{THEME}.html", "wb") as fh:
    fh.write(chart.render())

Part of Pediatric Growth Chart with Percentile Curves on anyplot.ai.

Other implementations