{"spec_id":"stereonet-equal-area","library":"seaborn","language":"python","code":"\"\"\" anyplot.ai\nstereonet-equal-area: Structural Geology Stereonet (Equal-Area Projection)\nLibrary: seaborn 0.13.2 | Python 3.13.13\nQuality: 93/100 | Updated: 2026-06-16\n\"\"\"\n\nimport os\n\nimport matplotlib.pyplot as plt\nimport numpy as np\nimport pandas as pd\nimport seaborn as sns\n\n\n# Theme-adaptive chrome (Imprint palette)\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# Data - field measurements from a geological mapping campaign\nnp.random.seed(42)\n\nbedding_strike = np.random.normal(45, 8, 40) % 360\nbedding_dip = np.clip(np.random.normal(35, 5, 40), 1, 89)\n\njoint1_strike = np.random.normal(315, 10, 30) % 360\njoint1_dip = np.clip(np.random.normal(75, 8, 30), 1, 89)\n\njoint2_strike = np.random.normal(90, 12, 25) % 360\njoint2_dip = np.clip(np.random.normal(60, 10, 25), 1, 89)\n\nfault_strike = np.random.normal(180, 15, 15) % 360\nfault_dip = np.clip(np.random.normal(70, 10, 15), 1, 89)\n\nstrikes = np.concatenate([bedding_strike, joint1_strike, joint2_strike, fault_strike])\ndips = np.concatenate([bedding_dip, joint1_dip, joint2_dip, fault_dip])\nfeature_types = [\"Bedding\"] * 40 + [\"Joint Set 1\"] * 30 + [\"Joint Set 2\"] * 25 + [\"Fault\"] * 15\n\ndf = pd.DataFrame({\"strike\": strikes, \"dip\": dips, \"feature_type\": feature_types})\n\n# Equal-area projection: convert pole trend/plunge to x, y\npole_trend = (df[\"strike\"].values + 270) % 360\npole_plunge = 90 - df[\"dip\"].values\npole_trend_rad = np.radians(pole_trend)\npole_plunge_rad = np.radians(pole_plunge)\npole_r = np.sqrt(2) * np.sin((np.pi / 2 - pole_plunge_rad) / 2)\ndf[\"pole_x\"] = pole_r * np.sin(pole_trend_rad)\ndf[\"pole_y\"] = pole_r * np.cos(pole_trend_rad)\n\n# Scale marker size by dip angle for visual hierarchy (steeper dips = larger markers)\ndf[\"marker_size\"] = 80 + (df[\"dip\"] / 90) * 120\n\n# Imprint palette, canonical order 1->4 (abstract feature types, no semantic cue)\nsns.set_theme(\n    style=\"white\",\n    rc={\"figure.facecolor\": PAGE_BG, \"axes.facecolor\": PAGE_BG, \"text.color\": INK, \"font.family\": \"DejaVu Sans\"},\n)\nfeature_colors = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\"]\npalette = sns.color_palette(feature_colors)\nfeature_names = [\"Bedding\", \"Joint Set 1\", \"Joint Set 2\", \"Fault\"]\npalette_dict = dict(zip(feature_names, palette, strict=True))\n\n# Square canvas: 6 x 6 in @ dpi=400 -> 2400 x 2400 px (hard contract)\nfig, ax = plt.subplots(figsize=(6, 6), dpi=400, facecolor=PAGE_BG)\nax.set_facecolor(PAGE_BG)\nax.set_aspect(\"equal\")\n\n# Subtle outer glow ring for depth\nfor ring_r, ring_alpha in [(1.02, 0.05), (1.04, 0.025)]:\n    glow = plt.Circle((0, 0), ring_r, fill=False, color=INK_SOFT, linewidth=0.4, alpha=ring_alpha)\n    ax.add_patch(glow)\n\n# Primitive circle with refined styling (elevated surface lifts the net off the page)\ntheta_circle = np.linspace(0, 2 * np.pi, 300)\nax.fill(np.sin(theta_circle), np.cos(theta_circle), color=ELEVATED_BG, zorder=0)\nax.plot(np.sin(theta_circle), np.cos(theta_circle), color=INK, linewidth=1.2, zorder=4)\n\n# Grid: small circles (constant plunge)\nfor plunge_deg in range(10, 90, 10):\n    r = np.sqrt(2) * np.sin((np.pi / 2 - np.radians(plunge_deg)) / 2)\n    grid_circle = plt.Circle(\n        (0, 0), r, fill=False, color=INK, alpha=0.15, linewidth=0.4, linestyle=(0, (5, 5)), zorder=1\n    )\n    ax.add_patch(grid_circle)\n\n# Grid: great circles for N-S and E-W reference planes\nfor ref_strike in [0, 90]:\n    ref_strike_rad = np.radians(ref_strike)\n    for ref_dip in range(10, 90, 10):\n        ref_dip_rad = np.radians(ref_dip)\n        alpha = np.linspace(0.01, np.pi - 0.01, 150)\n        vx = np.cos(alpha) * np.sin(ref_strike_rad) + np.sin(alpha) * np.cos(ref_dip_rad) * np.cos(ref_strike_rad)\n        vy = np.cos(alpha) * np.cos(ref_strike_rad) - np.sin(alpha) * np.cos(ref_dip_rad) * np.sin(ref_strike_rad)\n        vz = -np.sin(alpha) * np.sin(ref_dip_rad)\n        gc_plunge = np.arcsin(np.clip(-vz, -1, 1))\n        gc_trend = np.arctan2(vx, vy)\n        gc_r = np.sqrt(2) * np.sin((np.pi / 2 - gc_plunge) / 2)\n        gc_x = gc_r * np.sin(gc_trend)\n        gc_y = gc_r * np.cos(gc_trend)\n        ax.plot(gc_x, gc_y, color=INK, alpha=0.15, linewidth=0.4, linestyle=(0, (5, 5)), zorder=1)\n\n# Tick marks every 10 degrees with graduated lengths\nfor azimuth in range(0, 360, 10):\n    az_rad = np.radians(azimuth)\n    if azimuth % 90 == 0:\n        tick_inner, tick_lw = 0.93, 0.7\n    elif azimuth % 30 == 0:\n        tick_inner, tick_lw = 0.95, 0.5\n    else:\n        tick_inner, tick_lw = 0.97, 0.35\n    ax.plot(\n        [tick_inner * np.sin(az_rad), np.sin(az_rad)],\n        [tick_inner * np.cos(az_rad), np.cos(az_rad)],\n        color=INK,\n        linewidth=tick_lw,\n        zorder=4,\n    )\n\n# Cardinal direction labels with refined typography\nfor az, label in [(0, \"N\"), (90, \"E\"), (180, \"S\"), (270, \"W\")]:\n    az_rad = np.radians(az)\n    fontsize = 15 if label == \"N\" else 13\n    ax.text(\n        1.09 * np.sin(az_rad),\n        1.09 * np.cos(az_rad),\n        label,\n        ha=\"center\",\n        va=\"center\",\n        fontsize=fontsize,\n        fontweight=\"bold\",\n        color=INK,\n        zorder=6,\n    )\n\n# Degree labels every 30 degrees (skip cardinals)\nfor az in range(30, 360, 30):\n    if az in [90, 180, 270]:\n        continue\n    az_rad = np.radians(az)\n    ax.text(\n        1.08 * np.sin(az_rad),\n        1.08 * np.cos(az_rad),\n        f\"{az}°\",\n        ha=\"center\",\n        va=\"center\",\n        fontsize=9,\n        color=INK_MUTED,\n        zorder=6,\n    )\n\n# Density contours per feature type using seaborn kdeplot with hue\nn_collections_before = len(ax.collections)\nn_lines_before = len(ax.lines)\n\nsns.kdeplot(\n    data=df,\n    x=\"pole_x\",\n    y=\"pole_y\",\n    hue=\"feature_type\",\n    hue_order=feature_names,\n    palette=[sns.desaturate(c, 0.45) for c in feature_colors],\n    fill=True,\n    levels=5,\n    thresh=0.2,\n    alpha=0.22,\n    bw_adjust=0.7,\n    ax=ax,\n    zorder=2,\n    legend=False,\n)\nsns.kdeplot(\n    data=df,\n    x=\"pole_x\",\n    y=\"pole_y\",\n    hue=\"feature_type\",\n    hue_order=feature_names,\n    palette=[sns.desaturate(c, 0.6) for c in feature_colors],\n    levels=5,\n    thresh=0.2,\n    linewidths=0.5,\n    bw_adjust=0.7,\n    ax=ax,\n    zorder=2,\n    legend=False,\n)\n\n# Clip density contours to the primitive circle\nclip_circle = plt.Circle((0, 0), 1.0, transform=ax.transData, fill=False)\nax.add_patch(clip_circle)\nclip_circle.set_visible(False)\nfor c in ax.collections[n_collections_before:]:\n    c.set_clip_path(clip_circle)\nfor line in ax.lines[n_lines_before:]:\n    line.set_clip_path(clip_circle)\n\n# Great circles for representative planes (3 per feature type)\nfor feature_type in df[\"feature_type\"].unique():\n    subset = df[df[\"feature_type\"] == feature_type]\n    indices = np.linspace(0, len(subset) - 1, min(3, len(subset)), dtype=int)\n    for idx in indices:\n        row = subset.iloc[idx]\n        s_rad = np.radians(row[\"strike\"])\n        d_rad = np.radians(row[\"dip\"])\n        alpha = np.linspace(0.01, np.pi - 0.01, 200)\n        vx = np.cos(alpha) * np.sin(s_rad) + np.sin(alpha) * np.cos(d_rad) * np.cos(s_rad)\n        vy = np.cos(alpha) * np.cos(s_rad) - np.sin(alpha) * np.cos(d_rad) * np.sin(s_rad)\n        vz = -np.sin(alpha) * np.sin(d_rad)\n        gc_plunge = np.arcsin(np.clip(-vz, -1, 1))\n        gc_trend = np.arctan2(vx, vy)\n        gc_r = np.sqrt(2) * np.sin((np.pi / 2 - gc_plunge) / 2)\n        gc_x = gc_r * np.sin(gc_trend)\n        gc_y = gc_r * np.cos(gc_trend)\n        ax.plot(gc_x, gc_y, color=palette_dict[feature_type], linewidth=1.2, alpha=0.6, zorder=3)\n\n# Poles using seaborn scatterplot with hue, shape and size encoding\nmarker_map = {\"Bedding\": \"o\", \"Joint Set 1\": \"s\", \"Joint Set 2\": \"D\", \"Fault\": \"^\"}\nsns.scatterplot(\n    data=df,\n    x=\"pole_x\",\n    y=\"pole_y\",\n    hue=\"feature_type\",\n    hue_order=feature_names,\n    style=\"feature_type\",\n    style_order=feature_names,\n    markers=marker_map,\n    size=\"marker_size\",\n    sizes=(22, 55),\n    palette=palette_dict,\n    edgecolor=ELEVATED_BG,\n    linewidth=0.5,\n    alpha=0.9,\n    ax=ax,\n    zorder=5,\n    legend=False,\n)\n\n# Style\nax.set_xlim(-1.22, 1.22)\nax.set_ylim(-1.22, 1.22)\nax.set_xlabel(\"\")\nax.set_ylabel(\"\")\nax.set_xticks([])\nax.set_yticks([])\nsns.despine(ax=ax, left=True, bottom=True)\n\n# Build a custom legend with marker shapes matching the plot\nhandles = []\nfor ft in feature_names:\n    handles.append(\n        plt.Line2D(\n            [0],\n            [0],\n            marker=marker_map[ft],\n            color=\"none\",\n            markerfacecolor=palette_dict[ft],\n            markersize=9,\n            markeredgecolor=ELEVATED_BG,\n            markeredgewidth=0.5,\n            label=ft,\n        )\n    )\n\nlegend = ax.legend(\n    handles=handles,\n    title=\"Feature Type\",\n    loc=\"upper left\",\n    fontsize=9,\n    title_fontsize=10,\n    framealpha=0.92,\n    facecolor=ELEVATED_BG,\n    edgecolor=INK_SOFT,\n    fancybox=True,\n    shadow=False,\n    bbox_to_anchor=(-0.02, 1.02),\n)\nlegend.get_title().set_fontweight(\"semibold\")\nlegend.get_title().set_color(INK)\nfor text in legend.get_texts():\n    text.set_color(INK_SOFT)\n\n# Measurement count annotation\nn_total = len(df)\nax.text(\n    0.98,\n    -0.02,\n    f\"n = {n_total} measurements\",\n    transform=ax.transAxes,\n    fontsize=9,\n    color=INK_MUTED,\n    ha=\"right\",\n    va=\"top\",\n    style=\"italic\",\n)\n\nax.set_title(\n    \"stereonet-equal-area · python · seaborn · anyplot.ai\", fontsize=13, fontweight=\"medium\", color=INK, pad=14\n)\n\n# Save (no bbox_inches='tight' -> exact 2400 x 2400 px)\nfig.subplots_adjust(left=0.04, right=0.96, top=0.93, bottom=0.04)\nplt.savefig(f\"plot-{THEME}.png\", dpi=400, facecolor=PAGE_BG)\n"}