{"spec_id":"ternary-basic","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nternary-basic: Basic Ternary Plot\nLibrary: pygal 3.1.3 | Python 3.13.14\nQuality: 92/100 | Updated: 2026-08-04\n\"\"\"\n\nimport math\nimport os\nimport re\n\nimport cairosvg\nimport pygal\nfrom pygal.style import Style\n\n\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\nIMPRINT_PALETTE = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\")\nBRAND = IMPRINT_PALETTE[0]\n\nH = math.sqrt(3) / 2\n\n# (copper, tin, zinc) proportions, % — two groups so the plot can show a visual\n# hierarchy: established named alloy families vs. exploratory interior points\n# that survey the rest of the compositional space (a common ternary-diagram\n# practice, distinct from a real commercial alloy).\nnamed_compositions = [\n    (88, 12, 0),  # traditional bronze\n    (94, 6, 0),  # statuary bronze\n    (78, 22, 0),  # bell bronze (bell metal)\n    (90, 5, 5),  # gunmetal\n    (85, 5, 10),  # admiralty gunmetal\n    (70, 0, 30),  # cartridge brass\n]\nexploratory_compositions = [\n    (60, 5, 35),\n    (65, 25, 10),\n    (55, 35, 10),\n    (50, 10, 40),\n    (33, 34, 33),\n    (30, 35, 35),\n    (24, 38, 38),\n    (40, 40, 20),\n    (20, 65, 15),\n    (28, 22, 50),\n]\n\n# Barycentric (copper, tin, zinc) -> cartesian (x, y) on the unit equilateral triangle.\nnamed_points = [(0.5 * (2 * s[1] + s[0]) / 100, H * s[0] / 100) for s in named_compositions]\nexploratory_points = [(0.5 * (2 * s[1] + s[0]) / 100, H * s[0] / 100) for s in exploratory_compositions]\n\nvertex_copper = (0.5, H)\nvertex_tin = (1.0, 0.0)\nvertex_zinc = (0.0, 0.0)\n\n# Each entry is its own line segment — pygal's XY chart has no gap marker for a\n# single series (a bare (None, None) point is silently dropped, not treated as a\n# break, which would wrongly connect consecutive segments into one zig-zag path).\ngrid_segments = []\nfor pct in [0.2, 0.4, 0.6, 0.8]:\n    grid_segments.append([(0.5 * (2 * (1 - pct) + pct), H * pct), (0.5 * pct, H * pct)])\n    grid_segments.append([(0.5 * (2 * pct + (1 - pct)), H * (1 - pct)), (pct, 0.0)])\n    grid_segments.append([(0.5 * (1 - pct), H * (1 - pct)), (0.5 * (2 * (1 - pct)), 0.0)])\n\ntick_len = 0.03\ntick_segments = []\nfor pct in [0.2, 0.4, 0.6, 0.8]:\n    x_left, y_left = 0.5 * pct, H * pct\n    tick_segments.append([(x_left, y_left), (x_left - tick_len, y_left)])\n\n    x_right, y_right = 0.5 * (2 - pct), H * pct\n    tick_segments.append([(x_right, y_right), (x_right + tick_len, y_right)])\n\n    x_base = pct\n    tick_segments.append([(x_base, 0.0), (x_base, -tick_len)])\n\ntitle = \"Bronze Alloy Composition · ternary-basic · python · pygal · anyplot.ai\"\ntitle_font_size = max(44, round(66 * min(1.0, 67 / len(title))))\n\n# One chart.add() call per series (boundary, each grid segment, the two data\n# groups, each tick segment) — Style.colors is indexed by that same series order.\nseries_colors = [INK] + [INK_MUTED] * len(grid_segments) + [BRAND, BRAND] + [INK] * len(tick_segments)\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=tuple(series_colors),\n    title_font_size=title_font_size,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    stroke_width=2.5,\n    opacity=0.85,\n)\n\nchart = pygal.XY(\n    width=2400,\n    height=2400,\n    style=custom_style,\n    show_legend=True,\n    legend_at_bottom=True,\n    show_x_guides=False,\n    show_y_guides=False,\n    show_x_labels=False,\n    show_y_labels=False,\n    x_title=\"\",\n    y_title=\"\",\n    title=title,\n    stroke=False,\n    include_x_axis=False,\n    xrange=(-0.15, 1.15),\n    yrange=(-0.20, 1.05),\n    explicit_size=True,\n    margin_top=290,\n    margin_right=50,\n    margin_bottom=120,\n    margin_left=50,\n)\n\nchart.add(\n    None, [vertex_zinc, vertex_tin, vertex_copper, vertex_zinc], stroke=True, show_dots=False, stroke_style={\"width\": 4}\n)\n\nfor segment in grid_segments:\n    chart.add(None, segment, stroke=True, show_dots=False, stroke_style={\"width\": 1.5, \"dasharray\": \"8,5\"})\n\n# Larger dots for named alloy families, smaller for exploratory survey points —\n# a size-based hierarchy (same brand color, so palette compliance is untouched\n# and both groups stay theme-invariant) that makes the two-category story in\n# the data visible in the image itself.\nchart.add(\"Named Alloys\", named_points, stroke=False, dots_size=27)\nchart.add(\"Survey Points\", exploratory_points, stroke=False, dots_size=14)\n\nfor segment in tick_segments:\n    chart.add(None, segment, stroke=True, show_dots=False, stroke_style={\"width\": 2.5})\n\nsvg_content = chart.render().decode(\"utf-8\")\n\n# Derive the exact data-space -> pixel-space affine transform from the boundary\n# triangle pygal already rendered (serie-0), instead of hand-calibrating offsets.\n# The series live inside a translated <g> (\"plot overlay\"); its offset must be\n# folded in since the labels below are inserted as untransformed top-level SVG.\noverlay_dx, overlay_dy = (\n    float(v) for v in re.search(r'translate\\(([\\d.\\-]+), ?([\\d.\\-]+)\\)\"\\s*class=\"plot overlay\"', svg_content).groups()\n)\n\nboundary_segment = svg_content[\n    svg_content.index('<g class=\"series serie-0') : svg_content.index('<g class=\"series serie-1')\n]\npath_d = re.search(r'<path d=\"([^\"]+)\"', boundary_segment).group(1)\nzinc_px, tin_px, copper_px, _ = re.findall(r\"(-?\\d+\\.\\d+) (-?\\d+\\.\\d+)\", path_d)\nzinc_px = tuple(float(v) for v in zinc_px)\ntin_px = tuple(float(v) for v in tin_px)\ncopper_px = tuple(float(v) for v in copper_px)\n\norigin_x, origin_y = zinc_px[0] + overlay_dx, zinc_px[1] + overlay_dy\nscale_x = tin_px[0] - zinc_px[0]\nscale_y = (zinc_px[1] - copper_px[1]) / H\n\n\ndef to_px(x, y):\n    return origin_x + scale_x * x, origin_y - scale_y * y\n\n\nvertex_labels = [\n    (\"COPPER (%)\", (0.5, H + 0.07), \"middle\"),\n    (\"TIN (%)\", (1.0 + 0.05, -0.05), \"start\"),\n    (\"ZINC (%)\", (0.0 - 0.05, -0.05), \"end\"),\n]\n\nvertex_labels_svg = \"\"\nfor name, (x, y), anchor in vertex_labels:\n    px, py = to_px(x, y)\n    vertex_labels_svg += (\n        f'  <text x=\"{px:.1f}\" y=\"{py:.1f}\" text-anchor=\"{anchor}\" font-size=\"52\" '\n        f'font-weight=\"bold\" fill=\"{INK}\" font-family=\"sans-serif\">{name}</text>\\n'\n    )\n\npct_labels_svg = \"\"\nfor pct in [20, 40, 60, 80]:\n    frac = pct / 100.0\n\n    px, py = to_px(0.5 * frac - 0.055, H * frac)\n    pct_labels_svg += (\n        f'  <text x=\"{px:.1f}\" y=\"{py:.1f}\" text-anchor=\"end\" font-size=\"42\" '\n        f'fill=\"{INK_MUTED}\" font-family=\"sans-serif\">{pct}</text>\\n'\n    )\n\n    px, py = to_px(0.5 * (2 - frac) + 0.045, H * frac)\n    pct_labels_svg += (\n        f'  <text x=\"{px:.1f}\" y=\"{py:.1f}\" text-anchor=\"start\" font-size=\"42\" '\n        f'fill=\"{INK_MUTED}\" font-family=\"sans-serif\">{pct}</text>\\n'\n    )\n\n    px, py = to_px(frac, -0.05)\n    pct_labels_svg += (\n        f'  <text x=\"{px:.1f}\" y=\"{py:.1f}\" text-anchor=\"middle\" font-size=\"42\" '\n        f'fill=\"{INK_MUTED}\" font-family=\"sans-serif\">{pct}</text>\\n'\n    )\n\nsvg_content = svg_content.replace(\"</svg>\", vertex_labels_svg + pct_labels_svg + \"</svg>\")\n\nwith open(f\"plot-{THEME}.html\", \"w\") as f:\n    f.write(svg_content)\n\ncairosvg.svg2png(bytestring=svg_content.encode(\"utf-8\"), write_to=f\"plot-{THEME}.png\")\n"}