{"spec_id":"bar-heart-rate-zones","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nbar-heart-rate-zones: Time in Heart Rate Zones Bar Chart\nLibrary: altair 6.2.1 | Python 3.13.13\nQuality: 91/100 | Created: 2026-06-14\n\"\"\"\n\nimport os\n\nimport altair as alt\nimport pandas as pd\nfrom PIL import Image\n\n\n# Theme tokens (Imprint palette, theme-adaptive chrome)\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\n\n# Zone colors — semantic exception: conventional HR zone palette mapped to Imprint members.\n# Z1 grey → Imprint muted anchor, Z2 blue → Imprint blue, Z3 green → Imprint brand green,\n# Z4 orange → Imprint ochre, Z5 red → Imprint matte red.\nZONE_COLOR = {\n    \"Recovery\": \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\",\n    \"Endurance\": \"#4467A3\",\n    \"Aerobic\": \"#009E73\",\n    \"Threshold\": \"#BD8233\",\n    \"Maximum\": \"#AE3030\",\n}\n\n# Data — 90-minute base endurance ride (mostly Z2 aerobic base building)\nzone_codes = [\"Z1\", \"Z2\", \"Z3\", \"Z4\", \"Z5\"]\nzone_names = [\"Recovery\", \"Endurance\", \"Aerobic\", \"Threshold\", \"Maximum\"]\nminutes = [12, 48, 18, 8, 4]\nhr_ranges = [\"50–59% max HR\", \"60–69% max HR\", \"70–79% max HR\", \"80–89% max HR\", \"90–100% max HR\"]\n\ndf = pd.DataFrame(\n    {\n        \"zone\": zone_codes,\n        \"name\": zone_names,\n        \"minutes\": minutes,\n        \"hr_range\": hr_ranges,\n        \"duration\": [f\"{m} min\" for m in minutes],\n    }\n)\n\n# Title — 51 chars, under 67-char baseline, so default 16px fontsize is fine\ntitle = \"bar-heart-rate-zones · python · altair · anyplot.ai\"\ntitle_fontsize = max(11, round(16 * 67 / len(title)))\n\n# Bars — color encoding drives the legend (zone name → Imprint-mapped color)\nbars = (\n    alt.Chart(df)\n    .mark_bar(width={\"band\": 0.6})\n    .encode(\n        x=alt.X(\"zone:O\", sort=zone_codes, axis=alt.Axis(title=\"Heart Rate Zone\", labelPadding=8)),\n        y=alt.Y(\"minutes:Q\", axis=alt.Axis(title=\"Time (minutes)\", titlePadding=12), scale=alt.Scale(domain=[0, 58])),\n        color=alt.Color(\n            \"name:N\",\n            scale=alt.Scale(domain=zone_names, range=[ZONE_COLOR[n] for n in zone_names]),\n            legend=alt.Legend(title=\"Zone\", symbolSize=130, symbolStrokeWidth=0),\n        ),\n        tooltip=[\n            alt.Tooltip(\"zone:N\", title=\"Zone\"),\n            alt.Tooltip(\"name:N\", title=\"Type\"),\n            alt.Tooltip(\"minutes:Q\", title=\"Duration (min)\"),\n            alt.Tooltip(\"hr_range:N\", title=\"HR Range\"),\n        ],\n    )\n)\n\n# Duration labels above each bar\ntext_labels = (\n    alt.Chart(df)\n    .mark_text(align=\"center\", baseline=\"bottom\", dy=-6, fontSize=13, fontWeight=\"bold\")\n    .encode(x=alt.X(\"zone:O\", sort=zone_codes), y=alt.Y(\"minutes:Q\"), text=alt.Text(\"duration:N\"), color=alt.value(INK))\n)\n\n# Percentage-of-session labels above duration labels (transform_calculate computes share of 90 min)\npct_labels = (\n    alt.Chart(df)\n    .transform_calculate(pct_text=\"format(datum.minutes / 90 * 100, '.0f') + '%'\")\n    .mark_text(align=\"center\", baseline=\"bottom\", dy=-22, fontSize=10, fontStyle=\"italic\")\n    .encode(\n        x=alt.X(\"zone:O\", sort=zone_codes), y=alt.Y(\"minutes:Q\"), text=alt.Text(\"pct_text:N\"), color=alt.value(INK_SOFT)\n    )\n)\n\nchart = (\n    (bars + text_labels + pct_labels)\n    .properties(\n        width=620,\n        height=340,\n        background=PAGE_BG,\n        title=alt.TitleParams(\n            text=title,\n            fontSize=title_fontsize,\n            color=INK,\n            anchor=\"start\",\n            offset=12,\n            subtitle=\"90-min base endurance ride · 67% in low-intensity zones (Z1+Z2)\",\n            subtitleColor=INK_SOFT,\n            subtitleFontSize=11,\n        ),\n        padding={\"left\": 0, \"right\": 0, \"top\": 0, \"bottom\": 0},\n    )\n    .configure_view(fill=PAGE_BG, stroke=None)\n    .configure_axis(\n        domainColor=INK_SOFT,\n        tickColor=INK_SOFT,\n        gridColor=INK,\n        gridOpacity=0.12,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        labelFontSize=11,\n        titleFontSize=12,\n    )\n    .configure_legend(\n        fillColor=ELEVATED_BG,\n        strokeColor=INK_SOFT,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        labelFontSize=10,\n        titleFontSize=11,\n        padding=10,\n        cornerRadius=4,\n    )\n)\n\n# Save PNG\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\n# Pad to exact 3200×1800 target (vl-convert may under-shoot the inner-view size).\n# Never crop — cropping clips title/axis labels and triggers AR-09 auto-reject.\nTW, TH = 3200, 1800\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n\n# Save interactive HTML\nchart.save(f\"plot-{THEME}.html\")\n"}