{"spec_id":"marimekko-basic","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nmarimekko-basic: Basic Marimekko Chart\nLibrary: pygal 3.1.3 | Python 3.13.14\nQuality: 95/100 | Updated: 2026-07-24\n\"\"\"\n\nimport os\nimport sys\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\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\nGRID_COLOR = \"#C8C6BC\" if THEME == \"light\" else \"#2E2E2A\"\n\nIMPRINT = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\")\n\n# Temporarily remove current directory from path to avoid name collision with pygal module\n_cwd = sys.path[0] if sys.path[0] else \".\"\nif _cwd in sys.path:\n    sys.path.remove(_cwd)\n\nfrom pygal.graph.graph import Graph\nfrom pygal.style import Style\n\n\n# Restore path\nsys.path.insert(0, _cwd)\n\n\n# Custom class required: pygal has no native Marimekko/mekko chart type.\n# Variable-width stacked bars require extending the Graph base class.\nclass Marimekko(Graph):\n    _serie_margin = 0\n\n    def __init__(self, *args, **kwargs):\n        self.gap = kwargs.pop(\"gap\", 0.015)\n        self.ink = kwargs.pop(\"ink\", INK)\n        self.ink_muted = kwargs.pop(\"ink_muted\", INK_MUTED)\n        self.grid_color = kwargs.pop(\"grid_color\", GRID_COLOR)\n        super().__init__(*args, **kwargs)\n\n    def _compute_x_labels(self):\n        pass\n\n    def _compute_y_labels(self):\n        pass\n\n    def _column_layout(self, num_cols, col_totals, grand_total, plot_width):\n        \"\"\"Single pass computing (x_pos, bar_width, width_pct) per column, shared by\n        the highlight band, the bars and the x-axis labels so the three passes can\n        never drift out of sync with each other.\"\"\"\n        total_gap = self.gap * plot_width * (num_cols - 1)\n        usable_width = plot_width - total_gap\n        gap_px = self.gap * plot_width\n\n        layout = []\n        x_pos = self.view.x(0)\n        for col_idx in range(num_cols):\n            col_total = col_totals[col_idx]\n            bar_width = (col_total / grand_total) * usable_width if col_total else 0\n            layout.append((x_pos, bar_width, (col_total / grand_total) * 100 if col_total else 0))\n            x_pos += bar_width + gap_px\n        return layout\n\n    def _plot(self):\n        if not self.series:\n            return\n\n        num_cols = len(self.series[0].values) if self.series else 0\n        col_totals = [0] * num_cols\n\n        for serie in self.series:\n            for i, val in enumerate(serie.values):\n                if val is not None:\n                    col_totals[i] += val\n\n        grand_total = sum(col_totals)\n        if grand_total == 0:\n            return\n\n        # pygal's Box.fix() bakes a 2% margin into the value box, so raw\n        # self.view.width/height overshoot the true 0..1 pixel span. Deriving the\n        # usable extents from the actual projected coordinates keeps bars, the\n        # focal-column spotlight and the axes in agreement.\n        x_start = self.view.x(0)\n        y_bottom = self.view.y(0)\n        y_top = self.view.y(1)\n        plot_width = self.view.x(1) - x_start\n        plot_height = y_bottom - y_top\n\n        layout = self._column_layout(num_cols, col_totals, grand_total, plot_width)\n        focal_idx = max(range(num_cols), key=lambda i: col_totals[i])\n\n        plot_node = self.nodes[\"plot\"]\n        mekko_group = self.svg.node(plot_node, class_=\"marimekko-chart\")\n\n        # Focal-column spotlight: a faint tint band behind the largest market, drawn\n        # first so the segment rects composite on top of it. Draws the eye to the\n        # single biggest insight without touching the categorical palette.\n        focal_x, focal_width, focal_pct = layout[focal_idx]\n        if focal_width > 0:\n            self.svg.node(\n                mekko_group,\n                \"rect\",\n                x=focal_x,\n                y=y_top,\n                width=focal_width,\n                height=plot_height,\n                fill=self.ink,\n                **{\"fill-opacity\": \"0.07\", \"class\": \"focal-band\"},\n            )\n\n        # Segments\n        for col_idx in range(num_cols):\n            col_total = col_totals[col_idx]\n            if col_total == 0:\n                continue\n\n            x_pos, bar_width, _ = layout[col_idx]\n            y_offset = 0\n\n            for serie_idx, serie in enumerate(self.series):\n                val = serie.values[col_idx] if col_idx < len(serie.values) else None\n                if val is None or val == 0:\n                    continue\n\n                segment_height = (val / col_total) * plot_height\n                color = self.style.colors[serie_idx % len(self.style.colors)]\n                y_pos = y_bottom - y_offset - segment_height\n\n                # Class names avoid pygal's built-in \"series\"/\"reactive\" selectors\n                # (pygal/css/style.css targets those exact names with a\n                # fill-opacity rule and a text-fill rule), which would otherwise\n                # silently override the explicit fill colors below.\n                serie_group = self.svg.node(\n                    mekko_group, class_=\"mekko-serie mekko-serie-%d color-%d\" % (serie_idx, serie_idx)\n                )\n\n                self.svg.node(\n                    serie_group,\n                    \"rect\",\n                    x=x_pos,\n                    y=y_pos,\n                    width=bar_width,\n                    height=segment_height,\n                    fill=color,\n                    stroke=PAGE_BG,\n                    **{\"stroke-width\": \"2\", \"fill-opacity\": \"1\", \"class\": \"mekko-rect tooltip-trigger\"},\n                )\n\n                if segment_height > 0.045 * plot_height and bar_width > 0.035 * plot_width:\n                    pct = (val / col_total) * 100\n                    label_y = y_pos + segment_height / 2\n                    label_x = x_pos + bar_width / 2\n\n                    self.svg.node(\n                        serie_group,\n                        \"text\",\n                        x=label_x,\n                        y=label_y,\n                        fill=\"white\",\n                        **{\n                            \"text-anchor\": \"middle\",\n                            \"dominant-baseline\": \"middle\",\n                            \"font-size\": \"36\",\n                            \"font-weight\": \"bold\",\n                        },\n                    ).text = f\"{pct:.0f}%\"\n\n                y_offset += segment_height\n\n        # Focal callout: leader line + annotation above the largest market, living in\n        # the top margin so it never competes with the plot area itself.\n        if focal_width > 0:\n            focal_center = focal_x + focal_width / 2\n            callout_group = self.svg.node(mekko_group, class_=\"focal-callout\")\n            self.svg.node(\n                callout_group,\n                \"line\",\n                x1=focal_center,\n                y1=y_top - 45,\n                x2=focal_center,\n                y2=y_top,\n                stroke=self.ink,\n                **{\"stroke-width\": \"2\"},\n            )\n            self.svg.node(\n                callout_group,\n                \"text\",\n                x=focal_center,\n                y=y_top - 55,\n                fill=self.ink,\n                **{\"text-anchor\": \"middle\", \"font-size\": \"38\", \"font-weight\": \"bold\"},\n            ).text = f\"Largest market — {focal_pct:.0f}% of revenue\"\n\n        # X-axis category labels + axis title\n        if hasattr(self, \"x_labels\") and self.x_labels:\n            label_group = self.svg.node(mekko_group, class_=\"x-labels\")\n            for col_idx in range(num_cols):\n                col_total = col_totals[col_idx]\n                if col_total == 0:\n                    continue\n\n                x_pos, bar_width, width_pct = layout[col_idx]\n                label_x = x_pos + bar_width / 2\n                is_focal = col_idx == focal_idx\n\n                self.svg.node(\n                    label_group,\n                    \"text\",\n                    x=label_x,\n                    y=y_bottom + 46,\n                    fill=self.ink,\n                    **{\n                        \"text-anchor\": \"middle\",\n                        \"font-size\": \"48\" if is_focal else \"44\",\n                        \"font-weight\": \"bold\" if is_focal else \"normal\",\n                    },\n                ).text = str(self.x_labels[col_idx]) if col_idx < len(self.x_labels) else \"\"\n\n                self.svg.node(\n                    label_group,\n                    \"text\",\n                    x=label_x,\n                    y=y_bottom + 86,\n                    fill=self.ink_muted,\n                    **{\"text-anchor\": \"middle\", \"font-size\": \"32\", \"font-style\": \"italic\"},\n                ).text = f\"({width_pct:.0f}%)\"\n\n            axis_title_x = self.view.x(0) + plot_width / 2\n            self.svg.node(\n                label_group,\n                \"text\",\n                x=axis_title_x,\n                y=y_bottom + 150,\n                fill=self.ink,\n                **{\"text-anchor\": \"middle\", \"font-size\": \"38\", \"font-weight\": \"bold\"},\n            ).text = \"Region\"\n\n        # Legend: drawn by hand (show_legend=False) rather than relying on pygal's\n        # built-in bottom legend, which places itself immediately under the bars and\n        # collides with the custom x-axis label stack above — the exact crowding the\n        # previous review flagged. A fixed offset from y_bottom keeps a clean gap.\n        legend_group = self.svg.node(mekko_group, class_=\"legend\")\n        legend_y = y_bottom + 230\n        slot_width = plot_width / len(self.series)\n        for serie_idx, serie in enumerate(self.series):\n            slot_x = x_start + serie_idx * slot_width\n            color = self.style.colors[serie_idx % len(self.style.colors)]\n\n            self.svg.node(legend_group, \"rect\", x=slot_x, y=legend_y - 20, width=22, height=22, fill=color)\n            self.svg.node(\n                legend_group, \"text\", x=slot_x + 34, y=legend_y - 2, fill=self.ink, **{\"font-size\": \"40\"}\n            ).text = serie.title\n\n        # Y-axis percentage scale\n        y_axis_group = self.svg.node(mekko_group, class_=\"y-axis-labels\")\n        for pct in [0, 25, 50, 75, 100]:\n            y_pos = y_bottom - (pct / 100) * plot_height\n            label_x = x_start - 22\n\n            self.svg.node(\n                y_axis_group,\n                \"text\",\n                x=label_x,\n                y=y_pos + 5,\n                fill=self.ink_muted,\n                **{\"text-anchor\": \"end\", \"font-size\": \"32\"},\n            ).text = f\"{pct}%\"\n\n            if pct > 0:\n                self.svg.node(\n                    y_axis_group,\n                    \"line\",\n                    x1=x_start,\n                    y1=y_pos,\n                    x2=x_start + plot_width,\n                    y2=y_pos,\n                    stroke=self.grid_color,\n                    **{\"stroke-width\": \"1\", \"stroke-dasharray\": \"5,5\"},\n                )\n\n        # Y-axis title\n        y_title_x = x_start - 100\n        y_title_y = y_bottom - plot_height / 2\n        self.svg.node(\n            y_axis_group,\n            \"text\",\n            x=y_title_x,\n            y=y_title_y,\n            fill=self.ink,\n            **{\n                \"text-anchor\": \"middle\",\n                \"font-size\": \"40\",\n                \"font-weight\": \"normal\",\n                \"transform\": f\"rotate(-90, {y_title_x}, {y_title_y})\",\n            },\n        ).text = \"Share within Region (%)\"\n\n    def _compute(self):\n        self._box.xmin = 0\n        self._box.xmax = 1\n        self._box.ymin = 0\n        self._box.ymax = 1\n\n\n# Style\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    colors=IMPRINT,\n    title_font_size=66,\n    font_family=\"sans-serif\",\n)\n\n# Data - Market share by region and product line (revenue in millions USD)\nregions = [\"North America\", \"Europe\", \"Asia Pacific\", \"Latin America\", \"MEA\"]\n\nproducts = {\n    \"Enterprise\": [180, 140, 200, 60, 40],\n    \"Consumer\": [120, 130, 180, 70, 45],\n    \"SMB\": [90, 70, 90, 35, 25],\n    \"Government\": [60, 40, 50, 15, 10],\n}\n\n# Chart\nchart = Marimekko(\n    width=3200,\n    height=1800,\n    gap=0.015,\n    ink=INK,\n    ink_muted=INK_MUTED,\n    grid_color=GRID_COLOR,\n    style=custom_style,\n    title=\"marimekko-basic · python · pygal · anyplot.ai\",\n    show_legend=False,\n    margin=60,\n    margin_top=170,\n    margin_left=150,\n    margin_right=70,\n    margin_bottom=320,\n    show_x_labels=False,\n    show_y_labels=False,\n)\n\nchart.x_labels = regions\n\nfor product_name, values in products.items():\n    chart.add(product_name, values)\n\n# Save\nchart.render_to_png(f\"plot-{THEME}.png\")\n\nhtml_content = f\"\"\"<!DOCTYPE html>\n<html>\n<head>\n    <meta charset=\"utf-8\">\n    <title>marimekko-basic · python · pygal · anyplot.ai</title>\n    <style>\n        body {{ margin: 0; padding: 20px; background: {PAGE_BG}; }}\n        .container {{ max-width: 100%; margin: 0 auto; }}\n        svg {{ max-width: 100%; height: auto; }}\n    </style>\n</head>\n<body>\n    <div class=\"container\">\n        {chart.render(is_unicode=True)}\n    </div>\n</body>\n</html>\"\"\"\n\nwith open(f\"plot-{THEME}.html\", \"w\", encoding=\"utf-8\") as f:\n    f.write(html_content)\n"}