{"spec_id":"scatter-ashby-material","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nscatter-ashby-material: Ashby Material Selection Chart\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 84/100 | Updated: 2026-06-03\n\"\"\"\n\nimport os\nimport sys\n\n\n# Remove the script's own directory from sys.path so 'import pygal' finds the\n# installed package, not this file (which is also named pygal.py).\n_script_dir = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if os.path.abspath(p) != _script_dir and p != \"\"]\n\nimport math\nimport xml.etree.ElementTree as ET\n\nimport cairosvg\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\nfrom scipy.spatial import ConvexHull\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_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint palette — canonical order, first 7 slots for 7 material families\nIMPRINT_PALETTE = (\n    \"#009E73\",  # Metals — brand green\n    \"#C475FD\",  # Ceramics — lavender\n    \"#4467A3\",  # Polymers — blue\n    \"#BD8233\",  # Composites — ochre\n    \"#AE3030\",  # Elastomers — matte red\n    \"#2ABCCD\",  # Foams — cyan\n    \"#954477\",  # Natural Materials — rose\n    \"#99B314\",  # (palette slot 8, unused)\n)\n\n# Data — Density (kg/m³) vs Young's Modulus (GPa) for common engineering materials\nnp.random.seed(42)\n\nfamilies = {\n    \"Metals\": {\n        \"density\": [7850, 2700, 4500, 8900, 8900, 7130, 1740, 19300, 8500, 8800, 7200, 7900, 8440, 7300],\n        \"modulus\": [200, 69, 116, 117, 200, 108, 45, 411, 100, 110, 170, 193, 205, 50],\n    },\n    \"Ceramics\": {\n        \"density\": [3950, 3210, 5680, 3180, 2520, 2500, 2400, 3580, 15600, 4930],\n        \"modulus\": [370, 450, 200, 310, 460, 70, 65, 300, 680, 450],\n    },\n    \"Polymers\": {\n        \"density\": [960, 910, 1140, 1190, 1200, 1370, 1050, 1300, 1050, 1400, 2170, 1250],\n        \"modulus\": [1.1, 1.5, 2.8, 3.1, 2.4, 2.8, 2.3, 3.6, 3.2, 3.3, 0.5, 3.5],\n    },\n    \"Composites\": {\n        \"density\": [1550, 2000, 1380, 1600, 1900, 2100, 2900, 1100],\n        \"modulus\": [140, 40, 76, 70, 25, 210, 120, 8],\n    },\n    \"Elastomers\": {\n        \"density\": [930, 1100, 1240, 920, 1200, 860, 1850, 940],\n        \"modulus\": [0.003, 0.007, 0.005, 0.001, 0.025, 0.004, 0.008, 0.004],\n    },\n    \"Foams\": {\n        \"density\": [30, 25, 80, 300, 35, 500, 120, 160],\n        \"modulus\": [0.025, 0.012, 0.07, 1.0, 0.035, 3.5, 0.03, 3.5],\n    },\n    \"Natural Materials\": {\n        \"density\": [700, 500, 700, 1900, 160, 860, 1200, 1850],\n        \"modulus\": [12, 9, 18, 20, 3.5, 0.3, 3.5, 15],\n    },\n}\n\nfamily_names = list(families.keys())\nFAMILY_COLORS = IMPRINT_PALETTE[: len(family_names)]\n\n# Style — Imprint palette + theme-adaptive chrome\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=FAMILY_COLORS,\n    opacity=0.80,\n    opacity_hover=0.95,\n    title_font_size=66,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    tooltip_font_size=36,\n)\n\n# Chart — 3200×1800 landscape canvas (hard rule)\nTITLE = \"scatter-ashby-material · python · pygal · anyplot.ai\"\nchart = pygal.XY(\n    width=3200,\n    height=1800,\n    style=custom_style,\n    title=TITLE,\n    x_title=\"Density (kg/m³)\",\n    y_title=\"Young's Modulus (GPa)\",\n    show_legend=True,\n    legend_at_bottom=True,\n    legend_at_bottom_columns=4,\n    legend_box_size=36,\n    stroke=False,\n    dots_size=10,\n    show_x_guides=True,\n    show_y_guides=True,\n    logarithmic=True,\n    x_value_formatter=lambda x: f\"{x:,.0f}\",\n    value_formatter=lambda x: f\"{x:.3g}\",\n    margin_top=40,\n    margin_bottom=80,\n    margin_left=40,\n    margin_right=40,\n    print_values=False,\n    truncate_legend=20,\n    truncate_label=16,\n    show_minor_x_labels=False,\n)\n\n# Restrict x-axis to decade tick marks only — set all sub-decade ticks explicitly\n# so show_minor_x_labels=False can suppress non-major ones\nchart.x_labels = [\n    10,\n    20,\n    30,\n    40,\n    50,\n    60,\n    70,\n    80,\n    90,\n    100,\n    200,\n    300,\n    400,\n    500,\n    600,\n    700,\n    800,\n    900,\n    1000,\n    2000,\n    3000,\n    4000,\n    5000,\n    6000,\n    7000,\n    8000,\n    9000,\n    10000,\n    20000,\n]\nchart.x_labels_major = [30, 100, 300, 1000, 3000, 10000]\n\n# Add data with slight jitter for visual separation within families\njitter = np.random.normal(1.0, 0.03, (250, 2))\nidx = 0\nfor family_name, fdata in families.items():\n    points = []\n    for d, m in zip(fdata[\"density\"], fdata[\"modulus\"], strict=False):\n        jd = d * jitter[idx % 250, 0]\n        jm = m * jitter[idx % 250, 1]\n        idx += 1\n        points.append(\n            {\"value\": (round(jd, 1), round(jm, 5)), \"label\": f\"{family_name} — ρ={jd:,.0f} kg/m³, E={jm:.3g} GPa\"}\n        )\n    chart.add(family_name, points)\n\n# Save interactive HTML before post-processing — preserves pygal's JS tooltips\nwith open(f\"plot-{THEME}.html\", \"wb\") as f:\n    f.write(chart.render())\n\n# Render SVG for post-processing (PNG only)\nsvg_bytes = chart.render()\nsvg_string = svg_bytes.decode(\"utf-8\")\n\nET.register_namespace(\"\", \"http://www.w3.org/2000/svg\")\nET.register_namespace(\"xlink\", \"http://www.w3.org/1999/xlink\")\nroot = ET.fromstring(svg_string)\n\n# Extract circle positions per series\nseries_circles = {}\nfor g in root.iter(\"{http://www.w3.org/2000/svg}g\"):\n    cls = g.get(\"class\", \"\")\n    if cls.startswith(\"series serie-\"):\n        parts = cls.split()\n        serie_idx = int(parts[1].replace(\"serie-\", \"\"))\n        circles = []\n        for circle in g.iter(\"{http://www.w3.org/2000/svg}circle\"):\n            cx = circle.get(\"cx\")\n            cy = circle.get(\"cy\")\n            if cx and cy:\n                circles.append((float(cx), float(cy)))\n        if circles and serie_idx not in series_circles:\n            series_circles[serie_idx] = circles\n\n# Build convex hull regions group\nhulls_group = ET.Element(\"{http://www.w3.org/2000/svg}g\")\nhulls_group.set(\"class\", \"hull-regions\")\n\nfor serie_idx, circles in series_circles.items():\n    if serie_idx >= len(family_names):\n        continue\n    color = FAMILY_COLORS[serie_idx]\n    pts = np.array(circles)\n\n    try:\n        hull = ConvexHull(pts)\n        verts = pts[hull.vertices]\n        centroid = verts.mean(axis=0)\n        dirs = verts - centroid\n        norms = np.linalg.norm(dirs, axis=1, keepdims=True)\n        norms = np.where(norms == 0, 1, norms)\n        expanded = verts + (dirs / norms) * 22  # 22-px outward padding\n        points_str = \" \".join(f\"{x:.1f},{y:.1f}\" for x, y in expanded)\n        poly = ET.SubElement(hulls_group, \"{http://www.w3.org/2000/svg}polygon\")\n        poly.set(\"points\", points_str)\n        poly.set(\"fill\", color)\n        poly.set(\"fill-opacity\", \"0.14\")\n        poly.set(\"stroke\", color)\n        poly.set(\"stroke-opacity\", \"0.55\")\n        poly.set(\"stroke-width\", \"3\")\n        poly.set(\"stroke-linejoin\", \"round\")\n    except Exception:\n        # Fallback: padded bounding box\n        x0, x1 = pts[:, 0].min() - 18, pts[:, 0].max() + 18\n        y0, y1 = pts[:, 1].min() - 18, pts[:, 1].max() + 18\n        poly = ET.SubElement(hulls_group, \"{http://www.w3.org/2000/svg}polygon\")\n        poly.set(\"points\", f\"{x0:.1f},{y0:.1f} {x1:.1f},{y0:.1f} {x1:.1f},{y1:.1f} {x0:.1f},{y1:.1f}\")\n        poly.set(\"fill\", color)\n        poly.set(\"fill-opacity\", \"0.14\")\n        poly.set(\"stroke\", color)\n        poly.set(\"stroke-opacity\", \"0.55\")\n        poly.set(\"stroke-width\", \"3\")\n\n# Insert hulls group behind the series dots — find parent of first series group\nseries_parent = None\nfor g in root.iter(\"{http://www.w3.org/2000/svg}g\"):\n    for child in list(g):\n        if child.get(\"class\", \"\").startswith(\"series serie-0\"):\n            series_parent = g\n            break\n    if series_parent is not None:\n        break\n\nif series_parent is not None:\n    first_series_idx = 0\n    for i, child in enumerate(list(series_parent)):\n        if child.get(\"class\", \"\").startswith(\"series\"):\n            first_series_idx = i\n            break\n    series_parent.insert(first_series_idx, hulls_group)\nelse:\n    root.append(hulls_group)\n\n# Family name labels at cluster centroids\nlabels_group = ET.SubElement(root, \"{http://www.w3.org/2000/svg}g\")\nlabels_group.set(\"class\", \"family-labels\")\n\nlabel_offsets = {\n    \"Metals\": (0, -58),\n    \"Ceramics\": (0, -58),\n    \"Polymers\": (-100, -55),\n    \"Composites\": (80, 70),\n    \"Elastomers\": (-100, -50),\n    \"Foams\": (120, -50),\n    \"Natural Materials\": (150, -55),\n}\n\nfor serie_idx, circles in series_circles.items():\n    if serie_idx >= len(family_names):\n        continue\n    name = family_names[serie_idx]\n    color = FAMILY_COLORS[serie_idx]\n    pts = np.array(circles)\n    cx_med = float(np.median(pts[:, 0]))\n    cy_med = float(np.median(pts[:, 1]))\n    ox, oy = label_offsets.get(name, (0, -42))\n\n    text_el = ET.SubElement(labels_group, \"{http://www.w3.org/2000/svg}text\")\n    text_el.set(\"x\", f\"{cx_med + ox:.1f}\")\n    text_el.set(\"y\", f\"{cy_med + oy:.1f}\")\n    text_el.set(\"font-family\", \"Helvetica, Arial, sans-serif\")\n    text_el.set(\"font-size\", \"28\")\n    text_el.set(\"font-weight\", \"bold\")\n    text_el.set(\"fill\", color)\n    text_el.set(\"text-anchor\", \"middle\" if ox == 0 else \"start\")\n    text_el.set(\"stroke\", PAGE_BG)\n    text_el.set(\"stroke-width\", \"6\")\n    text_el.set(\"paint-order\", \"stroke\")\n    text_el.text = name\n\n# E/ρ performance index guide lines (log-log: slope=1 lines)\nall_cx = [cx for circles in series_circles.values() for cx, cy in circles]\nall_cy = [cy for circles in series_circles.values() for cx, cy in circles]\n\nif all_cx and all_cy:\n    svg_x_min, svg_x_max = min(all_cx), max(all_cx)\n    svg_y_min, svg_y_max = min(all_cy), max(all_cy)\n\n    log_x_min = math.log10(min(d for f in families.values() for d in f[\"density\"]) * 0.9)\n    log_x_max = math.log10(max(d for f in families.values() for d in f[\"density\"]) * 1.1)\n    log_y_min = math.log10(min(m for f in families.values() for m in f[\"modulus\"]) * 0.9)\n    log_y_max = math.log10(max(m for f in families.values() for m in f[\"modulus\"]) * 1.1)\n\n    guides_group = ET.SubElement(root, \"{http://www.w3.org/2000/svg}g\")\n    guides_group.set(\"class\", \"guide-lines\")\n\n    for c_val, label_text in [(0.01, \"E/ρ = 0.01\"), (0.0001, \"E/ρ = 10⁻⁴\")]:\n        log_c = math.log10(c_val)\n        lx1, ly1 = log_x_min, log_x_min + log_c\n        if ly1 < log_y_min:\n            lx1, ly1 = log_y_min - log_c, log_y_min\n        lx2, ly2 = log_x_max, log_x_max + log_c\n        if ly2 > log_y_max:\n            lx2, ly2 = log_y_max - log_c, log_y_max\n        if ly1 > log_y_max or ly2 < log_y_min:\n            continue\n\n        sx1 = svg_x_min + (lx1 - log_x_min) / (log_x_max - log_x_min) * (svg_x_max - svg_x_min)\n        sy1 = svg_y_max - (ly1 - log_y_min) / (log_y_max - log_y_min) * (svg_y_max - svg_y_min)\n        sx2 = svg_x_min + (lx2 - log_x_min) / (log_x_max - log_x_min) * (svg_x_max - svg_x_min)\n        sy2 = svg_y_max - (ly2 - log_y_min) / (log_y_max - log_y_min) * (svg_y_max - svg_y_min)\n\n        line_el = ET.SubElement(guides_group, \"{http://www.w3.org/2000/svg}line\")\n        line_el.set(\"x1\", f\"{sx1:.1f}\")\n        line_el.set(\"y1\", f\"{sy1:.1f}\")\n        line_el.set(\"x2\", f\"{sx2:.1f}\")\n        line_el.set(\"y2\", f\"{sy2:.1f}\")\n        line_el.set(\"stroke\", INK_MUTED)\n        line_el.set(\"stroke-width\", \"3\")\n        line_el.set(\"stroke-dasharray\", \"14,7\")\n        line_el.set(\"opacity\", \"0.55\")\n\n        text_el = ET.SubElement(guides_group, \"{http://www.w3.org/2000/svg}text\")\n        text_el.set(\"x\", f\"{sx2 - 12:.1f}\")\n        text_el.set(\"y\", f\"{sy2 - 10:.1f}\")\n        text_el.set(\"font-family\", \"Helvetica, Arial, sans-serif\")\n        text_el.set(\"font-size\", \"22\")\n        text_el.set(\"fill\", INK_MUTED)\n        text_el.set(\"text-anchor\", \"end\")\n        text_el.set(\"font-style\", \"italic\")\n        text_el.text = label_text\n\n# Serialize\nfinal_svg = ET.tostring(root, encoding=\"unicode\", xml_declaration=False)\n\n# Save PNG\ncairosvg.svg2png(bytestring=final_svg.encode(\"utf-8\"), write_to=f\"plot-{THEME}.png\")\n"}