{"spec_id":"count-basic","library":"altair","language":"python","code":"\"\"\" anyplot.ai\ncount-basic: Basic Count Plot\nLibrary: altair 6.2.2 | Python 3.13.14\nQuality: 91/100 | Updated: 2026-08-11\n\"\"\"\n\nimport os\nimport sys\n\n\nsys.path = [p for p in sys.path if not p.endswith(\"implementations/python\")]\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\n\nBRAND = \"#009E73\"  # Imprint palette position 1\n\n# Data: Survey responses with varying frequencies\nnp.random.seed(42)\nresponses = np.random.choice(\n    [\"Excellent\", \"Good\", \"Average\", \"Poor\", \"Very Poor\"], size=200, p=[0.25, 0.35, 0.20, 0.12, 0.08]\n)\ndf = pd.DataFrame({\"Response\": responses})\n\nTITLE = \"count-basic · python · altair · anyplot.ai\"\n\n# Aggregate counts and each category's share of the total via Altair's\n# declarative transform pipeline, so the percentage annotation is computed\n# inside the chart spec rather than pre-calculated in pandas. `isLeading`\n# flags the top category so it can carry a deliberate focal-point treatment\n# (stroke + full opacity + bold label) instead of a single flat green fill.\nbase = (\n    alt.Chart(df)\n    .transform_aggregate(count=\"count()\", groupby=[\"Response\"])\n    .transform_joinaggregate(total=\"sum(count)\", max=\"max(count)\")\n    .transform_calculate(pct=\"datum.count / datum.total * 100\")\n    .transform_calculate(label=\"format(datum.count, 'd') + ' (' + format(datum.pct, '.0f') + '%)'\")\n    .transform_calculate(isLeading=\"datum.count == datum.max\")\n)\n\n# Hover highlight: a real Altair selection, not a decorative effect — fully\n# functional in the interactive plot-{THEME}.html export.\nhover = alt.selection_point(on=\"pointerover\", fields=[\"Response\"], empty=False)\n\nbars = (\n    base.mark_bar(color=BRAND, cornerRadiusTopLeft=4, cornerRadiusTopRight=4, stroke=INK)\n    .encode(\n        x=alt.X(\"Response:N\", sort=\"-y\", title=\"Survey Response\", axis=alt.Axis(labelAngle=0)),\n        y=alt.Y(\"count:Q\", title=\"Number of Responses\"),\n        opacity=alt.when(hover)\n        .then(alt.value(1.0))\n        .when(\"datum.isLeading\")\n        .then(alt.value(1.0))\n        .otherwise(alt.value(0.8)),\n        strokeWidth=alt.condition(\"datum.isLeading\", alt.value(2.5), alt.value(0)),\n        tooltip=[\n            alt.Tooltip(\"Response:N\", title=\"Response\"),\n            alt.Tooltip(\"count:Q\", title=\"Count\"),\n            alt.Tooltip(\"pct:Q\", title=\"Share\", format=\".1f\"),\n        ],\n    )\n    .add_params(hover)\n)\n\n# fontWeight isn't a data-driven Vega-Lite encoding channel, so the\n# bold-vs-muted label hierarchy is split into two filtered layers instead of\n# a single conditional encoding.\nlabel_encode = {\"x\": alt.X(\"Response:N\", sort=\"-y\"), \"y\": \"count:Q\", \"text\": \"label:N\"}\nlabel_leading = (\n    base.transform_filter(\"datum.isLeading\")\n    .mark_text(align=\"center\", baseline=\"bottom\", dy=-6, fontSize=14, fontWeight=\"bold\", color=INK)\n    .encode(**label_encode)\n)\nlabel_rest = (\n    base.transform_filter(\"!datum.isLeading\")\n    .mark_text(align=\"center\", baseline=\"bottom\", dy=-6, fontSize=12, fontWeight=\"normal\", color=INK_SOFT)\n    .encode(**label_encode)\n)\nlabels = label_rest + label_leading\n\nchart = (\n    (bars + labels)\n    .properties(\n        width=620,  # inner-view landscape target — see prompts/library/altair.md \"Canvas\"\n        height=320,\n        background=PAGE_BG,\n        title=alt.Title(\n            TITLE,\n            subtitle=f\"n = {len(df)} survey responses\",\n            fontSize=18,\n            color=INK,\n            subtitleFontSize=13,\n            subtitleColor=INK_SOFT,\n        ),\n    )\n    .configure_view(fill=PAGE_BG, strokeWidth=0)  # no boxed frame — L-shaped spines via axis domain lines only\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=13,\n    )\n)\n\n# Save\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\nchart.save(f\"plot-{THEME}.html\")\n\n# Canvas contract: pad the rendered PNG up to the exact target — never crop,\n# since cropping would clip the title/axis labels (see prompts/library/altair.md).\nTARGET_W, TARGET_H = 3200, 1800\nimg = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\nw, h = img.size\nif w > TARGET_W or h > TARGET_H:\n    raise SystemExit(\n        f\"altair vl-convert produced {w}x{h}, exceeds target {TARGET_W}x{TARGET_H}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif w < TARGET_W or h < TARGET_H:\n    canvas = Image.new(\"RGB\", (TARGET_W, TARGET_H), PAGE_BG)\n    canvas.paste(img, ((TARGET_W - w) // 2, (TARGET_H - h) // 2))\n    canvas.save(f\"plot-{THEME}.png\")\n"}