{"spec_id":"line-growth-percentile","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nline-growth-percentile: Pediatric Growth Chart with Percentile Curves\nLibrary: pygal 3.1.3 | Python 3.13.14\nQuality: 86/100 | Updated: 2026-06-20\n\"\"\"\n\nimport io\nimport os\nimport xml.etree.ElementTree as ET\n\nimport cairosvg\nimport numpy as np\nimport pygal\nfrom PIL import Image\nfrom pygal.style import Style\n\n\n# Theme tokens (see prompts/default-style-guide.md \"Theme-adaptive Chrome\")\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint palette — brand green for patient data (primary foreground series)\nBRAND = \"#009E73\"  # Imprint position 1 — always first categorical series\n\n# Boys chart convention — Imprint blue (#4467A3) for percentile bands\n# Graduated tones lerped from PAGE_BG toward #4467A3: outer darker, inner lighter\nif THEME == \"light\":\n    BAND_OUTER = \"#96A8C6\"  # lerp(#FAF8F1, #4467A3, t=0.55)\n    BAND_MID = \"#BAC5D6\"  # lerp(#FAF8F1, #4467A3, t=0.35)\n    BAND_INNER = \"#D9DEE3\"  # lerp(#FAF8F1, #4467A3, t=0.18)\nelse:\n    BAND_OUTER = \"#354C72\"  # lerp(#1A1A17, #4467A3, t=0.65)\n    BAND_MID = \"#2E3F5A\"  # lerp(#1A1A17, #4467A3, t=0.48)\n    BAND_INNER = \"#273141\"  # lerp(#1A1A17, #4467A3, t=0.30)\n\n# Data — WHO weight-for-age reference for boys, 0–36 months\nnp.random.seed(42)\nage_months = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 15, 18, 21, 24, 27, 30, 33, 36])\n\npercentile_50 = np.array(\n    [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]\n)\noffsets = {\n    3: np.linspace(1.1, 3.2, len(age_months)),\n    10: np.linspace(0.85, 2.5, len(age_months)),\n    25: np.linspace(0.55, 1.5, len(age_months)),\n    75: np.linspace(0.55, 1.5, len(age_months)),\n    90: np.linspace(0.85, 2.5, len(age_months)),\n    97: np.linspace(1.1, 3.2, len(age_months)),\n}\n\npercentile_3 = percentile_50 - offsets[3]\npercentile_10 = percentile_50 - offsets[10]\npercentile_25 = percentile_50 - offsets[25]\npercentile_75 = percentile_50 + offsets[75]\npercentile_90 = percentile_50 + offsets[90]\npercentile_97 = percentile_50 + offsets[97]\n\npatient_ages = np.array([0, 1, 2, 4, 6, 9, 12, 15, 18, 24, 30, 36])\npatient_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])\n\npercentile_label_vals = [\n    (\"P3\", float(percentile_3[-1])),\n    (\"P10\", float(percentile_10[-1])),\n    (\"P25\", float(percentile_25[-1])),\n    (\"P50\", float(percentile_50[-1])),\n    (\"P75\", float(percentile_75[-1])),\n    (\"P90\", float(percentile_90[-1])),\n    (\"P97\", float(percentile_97[-1])),\n]\n\n# Title length-scaled font size (baseline 66 for ~67 chars)\nTITLE = \"WHO Weight-for-Age · line-growth-percentile · python · pygal · anyplot.ai\"\n_n = len(TITLE)\n_ratio = 67 / _n if _n > 67 else 1.0\ntitle_fs = max(44, round(66 * _ratio))\n\n# Pygal style — 3200×1800 canvas, theme-adaptive chrome\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    guide_stroke_color=INK_MUTED,\n    colors=(\n        BAND_OUTER,\n        BAND_MID,\n        BAND_INNER,  # P3–P10, P10–P25, P25–P50\n        BAND_INNER,\n        BAND_MID,\n        BAND_OUTER,  # P50–P75, P75–P90, P90–P97\n        \"#4467A3\",  # P50 median (Imprint blue, visually emphasized reference)\n        BRAND,  # Patient data — Imprint position 1\n    ),\n    opacity=\".65\",\n    opacity_hover=\".80\",\n    stroke_opacity=\"1\",\n    stroke_opacity_hover=\"1\",\n    title_font_size=title_fs,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    value_colors=(\"transparent\",),\n    font_family='Helvetica, Arial, \"DejaVu Sans\", sans-serif',\n)\n\n# Chart — landscape 3200×1800\nchart = pygal.XY(\n    style=custom_style,\n    width=3200,\n    height=1800,\n    explicit_size=True,\n    title=TITLE,\n    x_title=\"Age (months)\",\n    y_title=\"Weight (kg)\",\n    show_dots=True,\n    show_x_guides=False,\n    show_y_guides=True,\n    fill=True,\n    stroke=True,\n    legend_at_bottom=True,\n    legend_at_bottom_columns=4,\n    legend_box_size=26,\n    truncate_legend=-1,\n    range=(0, 18),\n    x_labels=[0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 36],\n    x_labels_major=[0, 6, 12, 18, 24, 30, 36],\n    show_minor_x_labels=True,\n    show_minor_y_labels=False,\n    y_labels=list(range(2, 19, 2)),\n    print_values=False,\n    x_value_formatter=lambda x: f\"{x:.0f}\",\n    value_formatter=lambda x: f\"{x:.1f}\",\n    margin_top=30,\n    margin_bottom=60,\n    margin_left=30,\n    margin_right=90,\n    js=[],\n)\n\n# Percentile bands as filled polygons (upper edge forward, lower edge reversed)\nband_configs = [\n    (\"P3–P10\", percentile_3, percentile_10),\n    (\"P10–P25\", percentile_10, percentile_25),\n    (\"P25–P50\", percentile_25, percentile_50),\n    (\"P50–P75\", percentile_50, percentile_75),\n    (\"P75–P90\", percentile_75, percentile_90),\n    (\"P90–P97\", percentile_90, percentile_97),\n]\n\nfor label, lower, upper in band_configs:\n    polygon = [(float(a), float(u)) for a, u in zip(age_months, upper, strict=True)]\n    for a, lo in zip(reversed(age_months), reversed(lower), strict=True):\n        polygon.append((float(a), float(lo)))\n    chart.add(label, polygon, stroke_style={\"width\": 0.3, \"opacity\": 0.1}, dots_size=0)\n\n# P50 median — dashed neutral line (de-emphasized so patient data stands out)\nmedian_pts = [(float(a), float(v)) for a, v in zip(age_months, percentile_50, strict=True)]\nchart.add(\n    \"P50 (Median)\",\n    median_pts,\n    fill=False,\n    stroke=True,\n    dots_size=0,\n    stroke_style={\"width\": 8, \"linecap\": \"round\", \"dasharray\": \"14,7\"},\n)\n\n# Patient data — brand green, prominent connected markers\npatient_pts = [(float(a), float(w)) for a, w in zip(patient_ages, patient_weights, strict=True)]\nchart.add(\n    \"Patient (Boy)\",\n    patient_pts,\n    fill=False,\n    stroke=True,\n    dots_size=10,\n    stroke_style={\"width\": 6, \"linecap\": \"round\", \"linejoin\": \"round\"},\n)\n\n# Render SVG → inject right-margin percentile labels → convert to PNG\nsvg_data = chart.render()\n\nET.register_namespace(\"\", \"http://www.w3.org/2000/svg\")\nET.register_namespace(\"xlink\", \"http://www.w3.org/1999/xlink\")\nroot = ET.fromstring(svg_data)\n\nns_svg = \"http://www.w3.org/2000/svg\"\ny_guides = root.findall(f\".//{{{ns_svg}}}g[@class='guides']//{{{ns_svg}}}line\")\ny_positions = [\n    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\")\n]\n\nplot_top_svg = min(y_positions) if y_positions else 60\nplot_bottom_svg = max(y_positions) if y_positions else 1480\n\nx_right_svg = 3050.0\nfor g in y_guides:\n    x2 = g.get(\"x2\")\n    if x2:\n        x_right_svg = max(x_right_svg, float(x2))\n\ny_min_val, y_max_val = 0.0, 18.0\nlbl_group = ET.SubElement(root, f\"{{{ns_svg}}}g\")\nlbl_group.set(\"class\", \"percentile-labels\")\n\nfor lbl, val in percentile_label_vals:\n    frac = (val - y_min_val) / (y_max_val - y_min_val)\n    y_svg = plot_bottom_svg - frac * (plot_bottom_svg - plot_top_svg)\n    el = ET.SubElement(lbl_group, f\"{{{ns_svg}}}text\")\n    el.set(\"x\", str(x_right_svg + 10))\n    el.set(\"y\", str(y_svg + 7))\n    el.set(\"font-size\", \"38\")\n    el.set(\"font-family\", 'Helvetica, Arial, \"DejaVu Sans\", sans-serif')\n    el.set(\"font-weight\", \"bold\" if lbl == \"P50\" else \"normal\")\n    el.set(\"fill\", \"#4467A3\" if lbl == \"P50\" else INK_MUTED)\n    el.text = lbl\n\n# Convert to PNG at exactly 3200×1800 (landscape canvas — hard contract)\nmodified_svg = ET.tostring(root, encoding=\"unicode\")\npng_bytes = cairosvg.svg2png(bytestring=modified_svg.encode(\"utf-8\"), output_width=3200, output_height=1800)\nimg = Image.open(io.BytesIO(png_bytes)).convert(\"RGB\")\nimg.save(f\"plot-{THEME}.png\")\n\n# Save interactive HTML\nwith open(f\"plot-{THEME}.html\", \"wb\") as fh:\n    fh.write(chart.render())\n"}