{"spec_id":"mosaic-categorical","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nmosaic-categorical: Mosaic Plot for Categorical Association Analysis\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 89/100 | Updated: 2026-05-19\n\"\"\"\n\nimport os\nimport sys\n\nimport numpy as np\n\n\n# Theme tokens\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\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\nRULE = \"rgba(26,26,23,0.15)\" if THEME == \"light\" else \"rgba(240,239,232,0.15)\"\n\n# Okabe-Ito palette — first series is always brand green\nIMPRINT = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\")\n\n# Temporarily remove current directory from path to avoid pygal.py name collision\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\nsys.path.insert(0, _cwd)\n\n\nclass MosaicPlot(Graph):\n    def __init__(self, *args, **kwargs):\n        self.contingency_data = kwargs.pop(\"contingency_data\", {})\n        self.row_labels = kwargs.pop(\"row_labels\", [])\n        self.col_labels = kwargs.pop(\"col_labels\", [])\n        self.cell_colors = kwargs.pop(\"cell_colors\", list(IMPRINT))\n        self.gap_ratio = kwargs.pop(\"gap_ratio\", 0.02)\n        self.ink = kwargs.pop(\"ink\", INK)\n        self.ink_soft = kwargs.pop(\"ink_soft\", INK_SOFT)\n        self.ink_muted = kwargs.pop(\"ink_muted\", INK_MUTED)\n        self.page_bg = kwargs.pop(\"page_bg\", PAGE_BG)\n        self.rule = kwargs.pop(\"rule\", RULE)\n        super().__init__(*args, **kwargs)\n\n    def _plot(self):\n        if not self.contingency_data or not self.row_labels or not self.col_labels:\n            return\n\n        n_rows = len(self.row_labels)\n        n_cols = len(self.col_labels)\n\n        col_totals = [\n            sum(self.contingency_data.get((row, col), 0) for row in self.row_labels) for col in self.col_labels\n        ]\n        grand_total = sum(col_totals)\n        if grand_total == 0:\n            return\n\n        col_proportions = [t / grand_total for t in col_totals]\n\n        plot_width = self.view.width\n        plot_height = self.view.height\n\n        margin_left = 440\n        margin_right = 80\n        margin_top = 100\n        margin_bottom = 380\n\n        available_width = plot_width - margin_left - margin_right\n        available_height = plot_height - margin_top - margin_bottom\n\n        col_gap = available_width * self.gap_ratio\n        row_gap = available_height * self.gap_ratio\n        drawing_width = available_width - col_gap * (n_cols - 1)\n        drawing_height = available_height - row_gap * (n_rows - 1)\n\n        x_offset = self.view.x(0) + margin_left\n        y_offset = self.view.y(n_rows) + margin_top\n\n        plot_node = self.nodes[\"plot\"]\n        mosaic_group = self.svg.node(plot_node, class_=\"mosaic-plot\")\n\n        current_x = x_offset\n        for j, col in enumerate(self.col_labels):\n            col_width = drawing_width * col_proportions[j]\n            col_total = col_totals[j]\n            if col_total == 0:\n                current_x += col_width + col_gap\n                continue\n\n            row_values = [self.contingency_data.get((row, col), 0) for row in self.row_labels]\n            row_proportions = [v / col_total for v in row_values]\n\n            current_y = y_offset\n            for i, row in enumerate(self.row_labels):\n                cell_height = drawing_height * row_proportions[i]\n                if cell_height < 1:\n                    current_y += cell_height + row_gap\n                    continue\n\n                color = self.cell_colors[i % len(self.cell_colors)]\n                freq = self.contingency_data.get((row, col), 0)\n                pct = row_proportions[i] * 100\n\n                rect = self.svg.node(\n                    mosaic_group,\n                    \"rect\",\n                    x=current_x,\n                    y=current_y,\n                    width=max(col_width, 1),\n                    height=max(cell_height, 1),\n                    rx=6,\n                    ry=6,\n                )\n                rect.set(\"fill\", color)\n                rect.set(\"stroke\", self.page_bg)\n                rect.set(\"stroke-width\", \"5\")\n                rect.set(\"fill-opacity\", \"0.90\")\n\n                # SVG tooltip for interactive HTML — pygal-native feature\n                tooltip = self.svg.node(rect, \"title\")\n                tooltip.text = f\"{row} · {col}: {freq:,} ({pct:.1f}% of {col} users)\"\n\n                if cell_height > 80 and col_width > 110:\n                    label_size = min(44, int(min(col_width, cell_height) * 0.26))\n                    text_x = current_x + col_width / 2\n\n                    if cell_height > 130 and col_width > 160:\n                        # Dual label: count (bold, upper) + percentage (smaller, lower)\n                        count_node = self.svg.node(\n                            mosaic_group, \"text\", x=text_x, y=current_y + cell_height / 2 - label_size * 0.28\n                        )\n                        count_node.set(\"text-anchor\", \"middle\")\n                        count_node.set(\"dominant-baseline\", \"middle\")\n                        count_node.set(\"fill\", \"#ffffff\")\n                        count_node.set(\n                            \"style\",\n                            f\"font-size:{label_size}px;font-weight:700;font-family:sans-serif;letter-spacing:-0.5px\",\n                        )\n                        count_node.text = f\"{freq:,}\"\n\n                        pct_size = max(int(label_size * 0.62), 22)\n                        pct_node = self.svg.node(\n                            mosaic_group, \"text\", x=text_x, y=current_y + cell_height / 2 + label_size * 0.52\n                        )\n                        pct_node.set(\"text-anchor\", \"middle\")\n                        pct_node.set(\"dominant-baseline\", \"middle\")\n                        pct_node.set(\"fill\", \"rgba(255,255,255,0.80)\")\n                        pct_node.set(\"style\", f\"font-size:{pct_size}px;font-family:sans-serif;font-weight:500\")\n                        pct_node.text = f\"{pct:.0f}%\"\n                    else:\n                        # Single count label for smaller cells\n                        count_node = self.svg.node(mosaic_group, \"text\", x=text_x, y=current_y + cell_height / 2)\n                        count_node.set(\"text-anchor\", \"middle\")\n                        count_node.set(\"dominant-baseline\", \"middle\")\n                        count_node.set(\"fill\", \"#ffffff\")\n                        count_node.set(\"style\", f\"font-size:{label_size}px;font-weight:700;font-family:sans-serif\")\n                        count_node.text = f\"{freq:,}\"\n\n                current_y += cell_height + row_gap\n\n            # Column label — bold, prominent\n            col_label_size = 46\n            label_x = current_x + col_width / 2\n            label_y = y_offset + available_height + 76\n            col_label_node = self.svg.node(mosaic_group, \"text\", x=label_x, y=label_y)\n            col_label_node.set(\"text-anchor\", \"middle\")\n            col_label_node.set(\"fill\", self.ink)\n            col_label_node.set(\n                \"style\", f\"font-size:{col_label_size}px;font-weight:700;font-family:sans-serif;letter-spacing:0.5px\"\n            )\n            col_label_node.text = col\n\n            # Hairline separator between label and proportion sublabel\n            sep_y = label_y + 16\n            half_w = min(col_width * 0.35, 100)\n            sep_line = self.svg.node(mosaic_group, \"line\", x1=label_x - half_w, y1=sep_y, x2=label_x + half_w, y2=sep_y)\n            sep_line.set(\"stroke\", self.ink_muted)\n            sep_line.set(\"stroke-width\", \"1.5\")\n            sep_line.set(\"stroke-opacity\", \"0.45\")\n\n            # Proportion sub-label — italic, muted\n            prop_label_y = label_y + 58\n            prop_node = self.svg.node(mosaic_group, \"text\", x=label_x, y=prop_label_y)\n            prop_node.set(\"text-anchor\", \"middle\")\n            prop_node.set(\"fill\", self.ink_muted)\n            prop_node.set(\"style\", \"font-size:34px;font-style:italic;font-family:sans-serif\")\n            prop_node.text = f\"{col_proportions[j] * 100:.1f}% of sessions\"\n\n            current_x += col_width + col_gap\n\n        # Row legend (left side) — larger swatches, refined alignment\n        first_col = self.col_labels[0]\n        first_col_total = col_totals[0] if col_totals[0] > 0 else 1\n        for i, row in enumerate(self.row_labels):\n            color = self.cell_colors[i % len(self.cell_colors)]\n\n            cumulative = sum(\n                self.contingency_data.get((r, first_col), 0) / first_col_total for r in self.row_labels[:i]\n            )\n            row_prop = self.contingency_data.get((row, first_col), 0) / first_col_total\n            center_y = y_offset + drawing_height * (cumulative + row_prop / 2) + i * row_gap\n\n            swatch_size = 40\n            swatch_x = x_offset - 260\n            swatch_y = center_y - swatch_size / 2\n\n            swatch = self.svg.node(\n                mosaic_group, \"rect\", x=swatch_x, y=swatch_y, width=swatch_size, height=swatch_size, rx=6, ry=6\n            )\n            swatch.set(\"fill\", color)\n            swatch.set(\"stroke\", self.ink_soft)\n            swatch.set(\"stroke-width\", \"1.5\")\n            swatch.set(\"fill-opacity\", \"0.90\")\n\n            row_label_size = 38\n            text_x = swatch_x + swatch_size + 16\n            text_y = center_y + row_label_size * 0.36\n            row_label_node = self.svg.node(mosaic_group, \"text\", x=text_x, y=text_y)\n            row_label_node.set(\"text-anchor\", \"start\")\n            row_label_node.set(\"fill\", self.ink)\n            row_label_node.set(\"style\", f\"font-size:{row_label_size}px;font-family:sans-serif;font-weight:500\")\n            row_label_node.text = row\n\n        # X-axis title\n        x_title_x = x_offset + available_width / 2\n        x_title_y = y_offset + available_height + 216\n        x_title_node = self.svg.node(mosaic_group, \"text\", x=x_title_x, y=x_title_y)\n        x_title_node.set(\"text-anchor\", \"middle\")\n        x_title_node.set(\"fill\", self.ink_soft)\n        x_title_node.set(\"style\", \"font-size:50px;font-weight:700;font-family:sans-serif;letter-spacing:1px\")\n        x_title_node.text = \"DEVICE TYPE\"\n\n        # Y-axis title (rotated)\n        y_title_x = x_offset - 400\n        y_title_y = y_offset + available_height / 2\n        y_title_node = self.svg.node(mosaic_group, \"text\", x=y_title_x, y=y_title_y)\n        y_title_node.set(\"text-anchor\", \"middle\")\n        y_title_node.set(\"fill\", self.ink_soft)\n        y_title_node.set(\"style\", \"font-size:50px;font-weight:700;font-family:sans-serif;letter-spacing:1px\")\n        y_title_node.set(\"transform\", f\"rotate(-90, {y_title_x}, {y_title_y})\")\n        y_title_node.text = \"APP CATEGORY\"\n\n        # Key insight annotation — guides viewer to the most striking pattern\n        insight_y = y_offset + available_height + 318\n        insight_node = self.svg.node(mosaic_group, \"text\", x=x_title_x, y=insight_y)\n        insight_node.set(\"text-anchor\", \"middle\")\n        insight_node.set(\"fill\", self.ink_muted)\n        insight_node.set(\"style\", \"font-size:34px;font-style:italic;font-family:sans-serif\")\n        insight_node.text = (\n            \"Key insight: Desktop skews heavily towards Productivity (57%) — nearly 4× the Mobile rate (15%)\"\n        )\n\n    def _compute(self):\n        n_rows = len(self.row_labels) if self.row_labels else 1\n        n_cols = len(self.col_labels) if self.col_labels else 1\n        self._box.xmin = 0\n        self._box.xmax = n_cols\n        self._box.ymin = 0\n        self._box.ymax = n_rows\n\n\n# App engagement data: device type vs. app category\nnp.random.seed(42)\n\ndata = {\n    (\"Social Media\", \"Mobile\"): 450,\n    (\"Gaming\", \"Mobile\"): 320,\n    (\"Productivity\", \"Mobile\"): 180,\n    (\"Entertainment\", \"Mobile\"): 290,\n    (\"Social Media\", \"Tablet\"): 120,\n    (\"Gaming\", \"Tablet\"): 200,\n    (\"Productivity\", \"Tablet\"): 230,\n    (\"Entertainment\", \"Tablet\"): 290,\n    (\"Social Media\", \"Desktop\"): 80,\n    (\"Gaming\", \"Desktop\"): 150,\n    (\"Productivity\", \"Desktop\"): 480,\n    (\"Entertainment\", \"Desktop\"): 130,\n}\n\nrow_labels = [\"Social Media\", \"Gaming\", \"Productivity\", \"Entertainment\"]\ncol_labels = [\"Mobile\", \"Tablet\", \"Desktop\"]\n\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=68,\n    legend_font_size=40,\n    label_font_size=40,\n    value_font_size=32,\n    font_family=\"sans-serif\",\n)\n\nchart = MosaicPlot(\n    width=4800,\n    height=2700,\n    style=custom_style,\n    title=\"App Usage by Device · mosaic-categorical · python · pygal · anyplot.ai\",\n    contingency_data=data,\n    row_labels=row_labels,\n    col_labels=col_labels,\n    cell_colors=list(IMPRINT),\n    gap_ratio=0.015,\n    show_legend=False,\n    margin=100,\n    margin_top=220,\n    margin_bottom=150,\n    show_x_labels=False,\n    show_y_labels=False,\n    ink=INK,\n    ink_soft=INK_SOFT,\n    ink_muted=INK_MUTED,\n    page_bg=PAGE_BG,\n    rule=RULE,\n)\n\n# Dummy series required to trigger pygal's rendering pipeline\nchart.add(\"\", [0])\n\n# Save\nchart.render_to_png(f\"plot-{THEME}.png\")\n\nwith open(f\"plot-{THEME}.html\", \"wb\") as f:\n    f.write(chart.render())\n"}