{"spec_id":"scatter-ashby-material","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nscatter-ashby-material: Ashby Material Selection Chart\nLibrary: altair 6.1.0 | Python 3.13.13\nQuality: 91/100 | Updated: 2026-06-03\n\"\"\"\n\nimport importlib\nimport os\nimport sys\n\nfrom PIL import Image\n\n\n# Drop script directory from sys.path so the `altair` package resolves, not this file\nsys.path[:] = [p for p in sys.path if os.path.abspath(p or \".\") != os.path.dirname(os.path.abspath(__file__))]\nalt = importlib.import_module(\"altair\")\nnp = importlib.import_module(\"numpy\")\npd = importlib.import_module(\"pandas\")\nConvexHull = importlib.import_module(\"scipy.spatial\").ConvexHull\n\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\"\n\n# Imprint palette — 6 positions for 6 material families, canonical order\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\"]\nFONT = \"Helvetica Neue, Helvetica, Arial, sans-serif\"\n\nnp.random.seed(42)\n\n# Material family data with realistic property ranges\nfamilies = {\n    \"Metals\": {\n        \"density\": (2700, 8900),\n        \"modulus\": (45, 400),\n        \"materials\": [\n            \"Aluminum\",\n            \"Steel\",\n            \"Titanium\",\n            \"Copper\",\n            \"Nickel\",\n            \"Zinc\",\n            \"Magnesium\",\n            \"Brass\",\n            \"Bronze\",\n            \"Tungsten\",\n            \"Cast Iron\",\n            \"Stainless Steel\",\n            \"Inconel\",\n            \"Tin\",\n        ],\n    },\n    \"Polymers\": {\n        \"density\": (900, 1500),\n        \"modulus\": (0.2, 4.0),\n        \"materials\": [\n            \"Polyethylene\",\n            \"Polypropylene\",\n            \"PVC\",\n            \"Nylon\",\n            \"Polycarbonate\",\n            \"ABS\",\n            \"PMMA\",\n            \"PET\",\n            \"Polystyrene\",\n            \"PTFE\",\n            \"Epoxy\",\n            \"Polyurethane\",\n        ],\n    },\n    \"Ceramics\": {\n        \"density\": (2200, 4500),\n        \"modulus\": (200, 450),\n        \"materials\": [\n            \"Alumina\",\n            \"Silicon Carbide\",\n            \"Zirconia\",\n            \"Silicon Nitride\",\n            \"Glass\",\n            \"Porcelain\",\n            \"Boron Carbide\",\n            \"Tungsten Carbide\",\n            \"Silica\",\n            \"Magnesia\",\n        ],\n    },\n    \"Composites\": {\n        \"density\": (1400, 2200),\n        \"modulus\": (15, 200),\n        \"materials\": [\n            \"CFRP\",\n            \"GFRP\",\n            \"Kevlar Composite\",\n            \"Boron-Epoxy\",\n            \"Wood-Polymer\",\n            \"Metal Matrix\",\n            \"Ceramic Matrix\",\n            \"Carbon-Carbon\",\n            \"Basalt Fiber\",\n        ],\n    },\n    \"Elastomers\": {\n        \"density\": (900, 1300),\n        \"modulus\": (0.001, 0.1),\n        \"materials\": [\n            \"Natural Rubber\",\n            \"Silicone\",\n            \"Neoprene\",\n            \"Butyl Rubber\",\n            \"EPDM\",\n            \"Nitrile Rubber\",\n            \"Polyisoprene\",\n            \"SBR\",\n        ],\n    },\n    \"Foams\": {\n        \"density\": (20, 500),\n        \"modulus\": (0.001, 1.0),\n        \"materials\": [\n            \"Polyurethane Foam\",\n            \"Polystyrene Foam\",\n            \"PVC Foam\",\n            \"Metal Foam\",\n            \"Cork\",\n            \"Ceramic Foam\",\n            \"Phenolic Foam\",\n            \"Melamine Foam\",\n        ],\n    },\n}\n\nrows = []\nfor family, props in families.items():\n    d_lo, d_hi = props[\"density\"]\n    m_lo, m_hi = props[\"modulus\"]\n    for mat in props[\"materials\"]:\n        density = 10 ** np.random.uniform(np.log10(d_lo), np.log10(d_hi))\n        modulus = 10 ** np.random.uniform(np.log10(m_lo), np.log10(m_hi))\n        rows.append({\"material\": mat, \"family\": family, \"density\": round(density, 1), \"modulus\": round(modulus, 4)})\n\ndf = pd.DataFrame(rows)\n\nfamily_order = [\"Metals\", \"Polymers\", \"Ceramics\", \"Composites\", \"Elastomers\", \"Foams\"]\nfamily_sizes = {f: len(families[f][\"materials\"]) for f in family_order}\nmax_size = max(family_sizes.values())\n\n# Build padded convex-hull envelopes per family in log space (scipy replaces manual impl)\nenvelope_rows = []\nfor family, group in df.groupby(\"family\"):\n    log_x = np.log10(group[\"density\"].values)\n    log_y = np.log10(group[\"modulus\"].values)\n    cx, cy = log_x.mean(), log_y.mean()\n    pts = np.column_stack([log_x, log_y])\n\n    if len(pts) >= 3:\n        hull = ConvexHull(pts)\n        hull_pts = pts[hull.vertices]\n    else:\n        hull_pts = pts\n\n    # Sort hull vertices by angle for a proper closed polygon\n    angles = np.arctan2(hull_pts[:, 1] - cy, hull_pts[:, 0] - cx)\n    hull_pts = hull_pts[np.argsort(angles)]\n\n    # Pad outward from centroid for visual breathing room\n    pad = 0.22\n    padded = []\n    for hx, hy in hull_pts:\n        dx, dy = hx - cx, hy - cy\n        dist = np.hypot(dx, dy) or 1e-6\n        padded.append((hx + pad * dx / dist, hy + pad * dy / dist))\n    padded.append(padded[0])  # close polygon\n\n    fill_alpha = 0.10 + 0.10 * (family_sizes[family] / max_size)\n    for i, (xi, yi) in enumerate(padded):\n        envelope_rows.append(\n            {\"family\": family, \"density\": 10**xi, \"modulus\": 10**yi, \"pt_order\": i, \"fill_alpha\": round(fill_alpha, 3)}\n        )\n\ndf_envelope = pd.DataFrame(envelope_rows)\n\n# Family label positions (geometric center in log space with nudge offsets)\nlabel_nudge = {\n    \"Metals\": (0.0, -0.22),  # centred horizontally to avoid envelope edge\n    \"Polymers\": (-0.15, 0.35),\n    \"Ceramics\": (-0.35, 0.45),\n    \"Composites\": (-0.3, -0.35),\n    \"Elastomers\": (0.25, 0.3),\n    \"Foams\": (-0.25, -0.2),\n}\nfamily_centers = []\nfor family, group in df.groupby(\"family\"):\n    log_cx = np.mean(np.log10(group[\"density\"].values))\n    log_cy = np.mean(np.log10(group[\"modulus\"].values))\n    dx, dy = label_nudge.get(family, (0, 0))\n    family_centers.append(\n        {\"family\": family, \"density_center\": 10 ** (log_cx + dx), \"modulus_center\": 10 ** (log_cy + dy)}\n    )\ndf_labels = pd.DataFrame(family_centers)\n\n# Scales\ncolor_scale = alt.Scale(domain=family_order, range=IMPRINT_PALETTE)\nx_scale = alt.Scale(type=\"log\", domain=[10, 20000])\ny_scale = alt.Scale(type=\"log\", domain=[0.0005, 1000])\n\nhighlight = alt.selection_point(fields=[\"family\"], on=\"pointerover\", empty=False)\n\n# Envelope regions\nenvelopes = (\n    alt.Chart(df_envelope)\n    .mark_line(filled=True, strokeWidth=1.5, interpolate=\"basis-closed\")\n    .encode(\n        x=alt.X(\"density:Q\", scale=x_scale),\n        y=alt.Y(\"modulus:Q\", scale=y_scale),\n        color=alt.Color(\"family:N\", scale=color_scale, legend=None),\n        fill=alt.Fill(\"family:N\", scale=color_scale, legend=None),\n        fillOpacity=\"fill_alpha:Q\",\n        strokeOpacity=alt.value(0.45),\n        order=\"pt_order:O\",\n        detail=\"family:N\",\n    )\n)\n\n# Scatter points with interactive hover highlight\npoints = (\n    alt.Chart(df)\n    .mark_circle(stroke=PAGE_BG, strokeWidth=0.8)\n    .encode(\n        x=alt.X(\"density:Q\", scale=x_scale, title=\"Density (kg/m³)\"),\n        y=alt.Y(\"modulus:Q\", scale=y_scale, title=\"Young’s Modulus (GPa)\"),\n        color=alt.Color(\n            \"family:N\",\n            scale=color_scale,\n            legend=alt.Legend(\n                title=\"Material Family\",\n                titleFontSize=10,\n                titleFont=FONT,\n                labelFontSize=10,\n                labelFont=FONT,\n                symbolSize=100,\n                orient=\"right\",\n                symbolOpacity=0.85,\n            ),\n        ),\n        size=alt.condition(highlight, alt.value(120), alt.value(80)),\n        opacity=alt.condition(highlight, alt.value(0.95), alt.value(0.75)),\n        tooltip=[\n            alt.Tooltip(\"material:N\", title=\"Material\"),\n            alt.Tooltip(\"family:N\", title=\"Family\"),\n            alt.Tooltip(\"density:Q\", title=\"Density (kg/m³)\", format=\",.0f\"),\n            alt.Tooltip(\"modulus:Q\", title=\"Modulus (GPa)\", format=\".3f\"),\n        ],\n    )\n    .add_params(highlight)\n)\n\n# Family labels with page-bg halo for readability\nlabel_bg = (\n    alt.Chart(df_labels)\n    .mark_text(fontSize=11, fontWeight=\"bold\", font=FONT, opacity=0.9)\n    .encode(\n        x=alt.X(\"density_center:Q\", scale=x_scale),\n        y=alt.Y(\"modulus_center:Q\", scale=y_scale),\n        text=\"family:N\",\n        color=alt.value(PAGE_BG),\n    )\n)\n\nlabels = (\n    alt.Chart(df_labels)\n    .mark_text(fontSize=11, fontWeight=\"bold\", font=FONT, opacity=0.9)\n    .encode(\n        x=alt.X(\"density_center:Q\", scale=x_scale),\n        y=alt.Y(\"modulus_center:Q\", scale=y_scale),\n        text=\"family:N\",\n        color=alt.Color(\"family:N\", scale=color_scale, legend=None),\n    )\n)\n\n# Performance index guide lines: E/rho = constant\nguide_densities = np.logspace(np.log10(10), np.log10(20000), 50)\nguide_rows = []\nfor ratio, lbl in [(0.01, \"E/ρ = 0.01\"), (0.1, \"E/ρ = 0.1\")]:\n    for d in guide_densities:\n        m = ratio * d / 1000\n        if 0.0005 <= m <= 1000:\n            guide_rows.append({\"density\": d, \"modulus\": m, \"guide\": lbl})\ndf_guides = pd.DataFrame(guide_rows)\n\nguides = (\n    alt.Chart(df_guides)\n    .mark_line(strokeDash=[6, 4], strokeWidth=1.3, opacity=0.4)\n    .encode(\n        x=alt.X(\"density:Q\", scale=x_scale),\n        y=alt.Y(\"modulus:Q\", scale=y_scale),\n        detail=\"guide:N\",\n        color=alt.value(INK_MUTED),\n    )\n)\n\nguide_label_pts = []\nfor ratio, lbl in [(0.01, \"E/ρ = 0.01\"), (0.1, \"E/ρ = 0.1\")]:\n    d_pos = 15000\n    m_pos = ratio * d_pos / 1000\n    if 0.0005 <= m_pos <= 1000:\n        guide_label_pts.append({\"density\": d_pos, \"modulus\": m_pos, \"guide\": lbl})\ndf_guide_labels = pd.DataFrame(guide_label_pts)\n\nguide_labels = (\n    alt.Chart(df_guide_labels)\n    .mark_text(fontSize=10, fontStyle=\"italic\", font=FONT, opacity=0.65, angle=328, dy=-12)\n    .encode(\n        x=alt.X(\"density:Q\", scale=x_scale),\n        y=alt.Y(\"modulus:Q\", scale=y_scale),\n        text=\"guide:N\",\n        color=alt.value(INK_SOFT),\n    )\n)\n\ntitle_str = \"scatter-ashby-material · python · altair · anyplot.ai\"\n\nchart = (\n    alt.layer(envelopes, guides, guide_labels, points, label_bg, labels)\n    .properties(\n        width=620,\n        height=320,\n        title=alt.Title(\n            title_str,\n            fontSize=16,\n            fontWeight=500,\n            font=FONT,\n            color=INK,\n            subtitle=\"Young’s Modulus vs Density — material family selection guide\",\n            subtitleFontSize=12,\n            subtitleColor=INK_SOFT,\n            subtitleFont=FONT,\n        ),\n        background=PAGE_BG,\n        padding={\"left\": 10, \"right\": 10, \"top\": 10, \"bottom\": 10},\n    )\n    .resolve_scale(color=\"shared\", fill=\"independent\")\n    .configure_axis(\n        labelFontSize=10,\n        titleFontSize=12,\n        labelFont=FONT,\n        titleFont=FONT,\n        gridOpacity=0.15,\n        grid=True,\n        gridColor=INK,\n        domainColor=INK_SOFT,\n        tickColor=INK_SOFT,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n    )\n    .configure_view(strokeWidth=0, fill=PAGE_BG)\n    .configure_legend(\n        titleFontSize=10,\n        labelFontSize=10,\n        symbolSize=100,\n        padding=10,\n        offset=8,\n        cornerRadius=4,\n        fillColor=ELEVATED_BG,\n        strokeColor=INK_SOFT,\n        titleColor=INK,\n        labelColor=INK_SOFT,\n    )\n    .configure_title(color=INK, subtitleColor=INK_SOFT)\n)\n\n# Save PNG and pad to exact 3200×1800 target (vl-convert may land slightly short)\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\nTW, TH = 3200, 1800\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n\nchart.save(f\"plot-{THEME}.html\")\n"}