{"spec_id":"map-projections","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nmap-projections: World Map with Different Projections\nLibrary: bokeh 3.9.0 | Python 3.13.13\nQuality: 90/100 | Updated: 2026-05-23\n\"\"\"\n\nimport json\nimport os\nimport sys\nimport time\nimport urllib.request\nfrom pathlib import Path\n\n\n# Prevent this file (bokeh.py) from shadowing the installed bokeh package\n_this_dir = os.path.dirname(os.path.abspath(__file__))\nif _this_dir in sys.path:\n    sys.path.remove(_this_dir)\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.layouts import column, gridplot\nfrom bokeh.models import Div, Legend, LegendItem\nfrom bokeh.plotting import figure\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\nnp.random.seed(42)\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\"\nBRAND = \"#009E73\"\n\n# --- Natural Earth 110m country outlines ---\n_NE_URL = (\n    \"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_admin_0_countries.geojson\"\n)\n_NE_CACHE = Path(__file__).parent / \"countries_110m.json\"\nif not _NE_CACHE.exists():\n    with urllib.request.urlopen(_NE_URL, timeout=30) as _r:\n        _NE_CACHE.write_bytes(_r.read())\nwith _NE_CACHE.open() as _f:\n    _WORLD = json.load(_f)\n\n# Extract outer rings from every polygon / sub-polygon\n_rings = []\nfor _feat in _WORLD[\"features\"]:\n    _g = _feat[\"geometry\"]\n    if _g[\"type\"] == \"Polygon\":\n        _outer = [_g[\"coordinates\"][0]]\n    else:  # MultiPolygon\n        _outer = [_poly[0] for _poly in _g[\"coordinates\"]]\n    for _ring in _outer:\n        _rings.append((np.array([p[0] for p in _ring], dtype=float), np.array([p[1] for p in _ring], dtype=float)))\n\n\ndef _moll_theta(lat_r):\n    \"\"\"Mollweide auxiliary angle via Newton's method.\"\"\"\n    th = np.asarray(lat_r, dtype=float).copy()\n    lr = np.asarray(lat_r, dtype=float)\n    for _ in range(12):\n        th += -(2 * th + np.sin(2 * th) - np.pi * np.sin(lr)) / (2 + 2 * np.cos(2 * th) + 1e-12)\n    return th\n\n\ndef _country_patches_equirect(lat_clip=85.0):\n    xs, ys = [], []\n    for lons_d, lats_d in _rings:\n        xs.append(np.radians(lons_d).tolist())\n        ys.append(np.radians(np.clip(lats_d, -lat_clip, lat_clip)).tolist())\n    return xs, ys\n\n\ndef _country_patches_mercator(lat_clip=82.0):\n    xs, ys = [], []\n    for lons_d, lats_d in _rings:\n        lr = np.clip(np.radians(lats_d), np.radians(-lat_clip), np.radians(lat_clip))\n        lr = np.clip(lr, -np.pi / 2 * 0.97, np.pi / 2 * 0.97)\n        xs.append(np.radians(lons_d).tolist())\n        ys.append(np.log(np.tan(np.pi / 4 + lr / 2)).tolist())\n    return xs, ys\n\n\ndef _country_patches_sinusoidal(lat_clip=85.0):\n    xs, ys = [], []\n    for lons_d, lats_d in _rings:\n        lr = np.radians(np.clip(lats_d, -lat_clip, lat_clip))\n        xs.append((np.radians(lons_d) * np.cos(lr)).tolist())\n        ys.append(lr.tolist())\n    return xs, ys\n\n\ndef _country_patches_mollweide(lat_clip=85.0):\n    xs, ys = [], []\n    for lons_d, lats_d in _rings:\n        lr = np.radians(np.clip(lats_d, -lat_clip, lat_clip))\n        th = _moll_theta(lr)\n        xs.append(((2 * np.sqrt(2) / np.pi) * np.radians(lons_d) * np.cos(th)).tolist())\n        ys.append((np.sqrt(2) * np.sin(th)).tolist())\n    return xs, ys\n\n\nc1_xs, c1_ys = _country_patches_equirect()\nc2_xs, c2_ys = _country_patches_mercator()\nc3_xs, c3_ys = _country_patches_sinusoidal()\nc4_xs, c4_ys = _country_patches_mollweide()\n\n# Graticule intervals and Tissot positions\nlats_deg = np.arange(-90, 91, 30)\nlons_deg = np.arange(-180, 181, 30)\ntissot_lats = [-60, -30, 0, 30, 60]\ntissot_lons = [-150, -90, -30, 30, 90, 150]\ntissot_r = 8  # degrees\n\nPANEL_W = 1595\nPANEL_H = 848\n\n# =============================================================================\n# PANEL 1: Equirectangular (Plate Carrée)\n# =============================================================================\n\ngrat1_x, grat1_y = [], []\nfor lon_d in lons_deg:\n    lats = np.linspace(-85, 85, 80)\n    grat1_x.extend(np.radians(np.full_like(lats, lon_d)).tolist() + [np.nan])\n    grat1_y.extend(np.radians(lats).tolist() + [np.nan])\nfor lat_d in lats_deg:\n    lons = np.linspace(-180, 180, 160)\n    grat1_x.extend(np.radians(lons).tolist() + [np.nan])\n    grat1_y.extend(np.radians(np.full_like(lons, lat_d)).tolist() + [np.nan])\n\ntissot1_x, tissot1_y = [], []\nfor lat_d in tissot_lats:\n    for lon_d in tissot_lons:\n        a = np.linspace(0, 2 * np.pi, 50)\n        clons = lon_d + tissot_r * np.cos(a)\n        clats = np.clip(lat_d + tissot_r * np.sin(a), -85, 85)\n        tissot1_x.extend(np.radians(clons).tolist() + [np.nan])\n        tissot1_y.extend(np.radians(clats).tolist() + [np.nan])\n\np1 = figure(\n    width=PANEL_W,\n    height=PANEL_H,\n    title=\"Equirectangular (Plate Carrée)\",\n    toolbar_location=None,\n    min_border_left=50,\n    min_border_right=30,\n    min_border_top=90,\n    min_border_bottom=40,\n)\np1.background_fill_color = PAGE_BG\np1.border_fill_color = PAGE_BG\np1.outline_line_color = INK_SOFT\np1.outline_line_width = 2\np1.title.text_color = INK\np1.title.text_font_size = \"30pt\"\np1.title.text_font_style = \"bold\"\np1.title.align = \"center\"\np1.xaxis.visible = False\np1.yaxis.visible = False\np1.xgrid.visible = False\np1.ygrid.visible = False\n\np1.patches(\n    xs=c1_xs, ys=c1_ys, fill_color=INK_SOFT, fill_alpha=0.15, line_color=INK_SOFT, line_width=0.7, line_alpha=0.6\n)\ngrat1 = p1.line(x=grat1_x, y=grat1_y, line_color=INK_SOFT, line_width=1.5, line_alpha=0.5)\ntissot1 = p1.line(x=tissot1_x, y=tissot1_y, line_color=BRAND, line_width=2.5, line_alpha=0.9)\n\n# Lat/lon labels\np1.text(\n    x=[np.radians(-175)] * 5,\n    y=[np.radians(d) for _, d in [(\"60°N\", 60), (\"30°N\", 30), (\"0°\", 0), (\"30°S\", -30), (\"60°S\", -60)]],\n    text=[\"60°N\", \"30°N\", \"0°\", \"30°S\", \"60°S\"],\n    text_color=INK_SOFT,\n    text_font_size=\"22pt\",\n    text_align=\"left\",\n    text_baseline=\"middle\",\n)\np1.text(\n    x=[np.radians(d) for _, d in [(\"90°W\", -90), (\"0°\", 0), (\"90°E\", 90)]],\n    y=[np.radians(-84)] * 3,\n    text=[\"90°W\", \"0°\", \"90°E\"],\n    text_color=INK_SOFT,\n    text_font_size=\"22pt\",\n    text_align=\"center\",\n    text_baseline=\"top\",\n)\n\nlegend1 = Legend(\n    items=[\n        LegendItem(label=\"Graticule (30° intervals)\", renderers=[grat1]),\n        LegendItem(label=\"Tissot indicatrix\", renderers=[tissot1]),\n    ],\n    location=\"bottom_right\",\n    label_text_font_size=\"24pt\",\n    label_text_color=INK_SOFT,\n    background_fill_color=ELEVATED_BG,\n    border_line_color=INK_SOFT,\n    spacing=6,\n    padding=12,\n)\np1.add_layout(legend1)\n\n# =============================================================================\n# PANEL 2: Mercator\n# =============================================================================\n\ngrat2_x, grat2_y = [], []\nfor lon_d in lons_deg:\n    lats = np.linspace(-82, 82, 80)\n    lat_clipped = np.clip(np.radians(lats), -np.pi / 2 * 0.99, np.pi / 2 * 0.99)\n    grat2_x.extend(np.radians(np.full_like(lats, lon_d)).tolist() + [np.nan])\n    grat2_y.extend(np.log(np.tan(np.pi / 4 + lat_clipped / 2)).tolist() + [np.nan])\nfor lat_d in np.arange(-60, 61, 30):\n    lons = np.linspace(-180, 180, 160)\n    lat_clipped = np.clip(np.radians(lat_d), -np.pi / 2 * 0.99, np.pi / 2 * 0.99)\n    y_val = np.log(np.tan(np.pi / 4 + lat_clipped / 2))\n    grat2_x.extend(np.radians(lons).tolist() + [np.nan])\n    grat2_y.extend(np.full(160, y_val).tolist() + [np.nan])\n\ntissot2_x, tissot2_y = [], []\nfor lat_d in [-60, -30, 0, 30, 60]:\n    for lon_d in tissot_lons:\n        a = np.linspace(0, 2 * np.pi, 50)\n        clons = lon_d + tissot_r * np.cos(a)\n        clats = np.clip(lat_d + tissot_r * np.sin(a), -82, 82)\n        lat_clipped2 = np.clip(np.radians(clats), -np.pi / 2 * 0.99, np.pi / 2 * 0.99)\n        tissot2_x.extend(np.radians(clons).tolist() + [np.nan])\n        tissot2_y.extend(np.log(np.tan(np.pi / 4 + lat_clipped2 / 2)).tolist() + [np.nan])\n\np2 = figure(\n    width=PANEL_W,\n    height=PANEL_H,\n    title=\"Mercator\",\n    toolbar_location=None,\n    min_border_left=50,\n    min_border_right=30,\n    min_border_top=90,\n    min_border_bottom=40,\n)\np2.background_fill_color = PAGE_BG\np2.border_fill_color = PAGE_BG\np2.outline_line_color = INK_SOFT\np2.outline_line_width = 2\np2.title.text_color = INK\np2.title.text_font_size = \"30pt\"\np2.title.text_font_style = \"bold\"\np2.title.align = \"center\"\np2.xaxis.visible = False\np2.yaxis.visible = False\np2.xgrid.visible = False\np2.ygrid.visible = False\n\np2.patches(\n    xs=c2_xs, ys=c2_ys, fill_color=INK_SOFT, fill_alpha=0.15, line_color=INK_SOFT, line_width=0.7, line_alpha=0.6\n)\ngrat2 = p2.line(x=grat2_x, y=grat2_y, line_color=INK_SOFT, line_width=1.5, line_alpha=0.5)\ntissot2 = p2.line(x=tissot2_x, y=tissot2_y, line_color=BRAND, line_width=2.5, line_alpha=0.9)\n\nmerc_y_labels = [\n    np.log(np.tan(np.pi / 4 + np.clip(np.radians(d), -np.pi / 2 * 0.99, np.pi / 2 * 0.99) / 2))\n    for _, d in [(\"60°N\", 60), (\"30°N\", 30), (\"0°\", 0), (\"30°S\", -30), (\"60°S\", -60)]\n]\np2.text(\n    x=[np.radians(-175)] * 5,\n    y=merc_y_labels,\n    text=[\"60°N\", \"30°N\", \"0°\", \"30°S\", \"60°S\"],\n    text_color=INK_SOFT,\n    text_font_size=\"22pt\",\n    text_align=\"left\",\n    text_baseline=\"middle\",\n)\nmerc_y_bottom = np.log(np.tan(np.pi / 4 + np.clip(np.radians(-79), -np.pi / 2 * 0.99, np.pi / 2 * 0.99) / 2))\np2.text(\n    x=[np.radians(d) for _, d in [(\"90°W\", -90), (\"0°\", 0), (\"90°E\", 90)]],\n    y=[merc_y_bottom] * 3,\n    text=[\"90°W\", \"0°\", \"90°E\"],\n    text_color=INK_SOFT,\n    text_font_size=\"22pt\",\n    text_align=\"center\",\n    text_baseline=\"top\",\n)\n\nlegend2 = Legend(\n    items=[\n        LegendItem(label=\"Graticule (30° intervals)\", renderers=[grat2]),\n        LegendItem(label=\"Tissot indicatrix (grows poleward)\", renderers=[tissot2]),\n    ],\n    location=\"bottom_right\",\n    label_text_font_size=\"24pt\",\n    label_text_color=INK_SOFT,\n    background_fill_color=ELEVATED_BG,\n    border_line_color=INK_SOFT,\n    spacing=6,\n    padding=12,\n)\np2.add_layout(legend2)\n\n# =============================================================================\n# PANEL 3: Sinusoidal\n# =============================================================================\n\ngrat3_x, grat3_y = [], []\nfor lon_d in lons_deg:\n    lats = np.linspace(-85, 85, 80)\n    lrs = np.radians(lats)\n    grat3_x.extend((np.radians(lon_d) * np.cos(lrs)).tolist() + [np.nan])\n    grat3_y.extend(lrs.tolist() + [np.nan])\nfor lat_d in lats_deg:\n    lons = np.linspace(-180, 180, 160)\n    lr = np.radians(lat_d)\n    grat3_x.extend((np.radians(lons) * np.cos(lr)).tolist() + [np.nan])\n    grat3_y.extend(np.full(160, lr).tolist() + [np.nan])\n\ntissot3_x, tissot3_y = [], []\nfor lat_d in tissot_lats:\n    for lon_d in tissot_lons:\n        a = np.linspace(0, 2 * np.pi, 50)\n        clons = lon_d + tissot_r * np.cos(a)\n        clats = np.clip(lat_d + tissot_r * np.sin(a), -85, 85)\n        lrs = np.radians(clats)\n        tissot3_x.extend((np.radians(clons) * np.cos(lrs)).tolist() + [np.nan])\n        tissot3_y.extend(lrs.tolist() + [np.nan])\n\np3 = figure(\n    width=PANEL_W,\n    height=PANEL_H,\n    title=\"Sinusoidal\",\n    toolbar_location=None,\n    min_border_left=50,\n    min_border_right=30,\n    min_border_top=90,\n    min_border_bottom=40,\n)\np3.background_fill_color = PAGE_BG\np3.border_fill_color = PAGE_BG\np3.outline_line_color = INK_SOFT\np3.outline_line_width = 2\np3.title.text_color = INK\np3.title.text_font_size = \"30pt\"\np3.title.text_font_style = \"bold\"\np3.title.align = \"center\"\np3.xaxis.visible = False\np3.yaxis.visible = False\np3.xgrid.visible = False\np3.ygrid.visible = False\n\np3.patches(\n    xs=c3_xs, ys=c3_ys, fill_color=INK_SOFT, fill_alpha=0.15, line_color=INK_SOFT, line_width=0.7, line_alpha=0.6\n)\ngrat3 = p3.line(x=grat3_x, y=grat3_y, line_color=INK_SOFT, line_width=1.5, line_alpha=0.5)\ntissot3 = p3.line(x=tissot3_x, y=tissot3_y, line_color=BRAND, line_width=2.5, line_alpha=0.9)\n\n# Lat labels (left edge of each parallel)\nsin_edge_x = [\n    np.radians(-180) * np.cos(np.radians(d))\n    for _, d in [(\"60°N\", 60), (\"30°N\", 30), (\"0°\", 0), (\"30°S\", -30), (\"60°S\", -60)]\n]\nsin_edge_y = [np.radians(d) for _, d in [(\"60°N\", 60), (\"30°N\", 30), (\"0°\", 0), (\"30°S\", -30), (\"60°S\", -60)]]\np3.text(\n    x=sin_edge_x,\n    y=sin_edge_y,\n    text=[\"60°N\", \"30°N\", \"0°\", \"30°S\", \"60°S\"],\n    text_color=INK_SOFT,\n    text_font_size=\"22pt\",\n    text_align=\"right\",\n    text_baseline=\"middle\",\n)\n# Lon labels along the equator (widest part of sinusoidal projection)\np3.text(\n    x=[np.radians(d) for _, d in [(\"90°W\", -90), (\"0°\", 0), (\"90°E\", 90)]],\n    y=[np.radians(-84)] * 3,\n    text=[\"90°W\", \"0°\", \"90°E\"],\n    text_color=INK_SOFT,\n    text_font_size=\"22pt\",\n    text_align=\"center\",\n    text_baseline=\"top\",\n)\n\nlegend3 = Legend(\n    items=[\n        LegendItem(label=\"Graticule (30° intervals)\", renderers=[grat3]),\n        LegendItem(label=\"Tissot indicatrix (equal-area)\", renderers=[tissot3]),\n    ],\n    location=\"bottom_right\",\n    label_text_font_size=\"24pt\",\n    label_text_color=INK_SOFT,\n    background_fill_color=ELEVATED_BG,\n    border_line_color=INK_SOFT,\n    spacing=6,\n    padding=12,\n)\np3.add_layout(legend3)\n\n# =============================================================================\n# PANEL 4: Mollweide\n# =============================================================================\n\ngrat4_x, grat4_y = [], []\nfor lon_d in lons_deg:\n    lats = np.linspace(-85, 85, 80)\n    lat_rad = np.radians(lats)\n    theta = lat_rad.copy()\n    for _ in range(12):\n        theta += -(2 * theta + np.sin(2 * theta) - np.pi * np.sin(lat_rad)) / (2 + 2 * np.cos(2 * theta) + 1e-12)\n    grat4_x.extend(((2 * np.sqrt(2) / np.pi) * np.radians(lon_d) * np.cos(theta)).tolist() + [np.nan])\n    grat4_y.extend((np.sqrt(2) * np.sin(theta)).tolist() + [np.nan])\nfor lat_d in lats_deg:\n    lons = np.linspace(-180, 180, 160)\n    lat_rad = np.full(160, np.radians(lat_d))\n    theta = lat_rad.copy()\n    for _ in range(12):\n        theta += -(2 * theta + np.sin(2 * theta) - np.pi * np.sin(lat_rad)) / (2 + 2 * np.cos(2 * theta) + 1e-12)\n    grat4_x.extend(((2 * np.sqrt(2) / np.pi) * np.radians(lons) * np.cos(theta)).tolist() + [np.nan])\n    grat4_y.extend((np.sqrt(2) * np.sin(theta)).tolist() + [np.nan])\n\ntissot4_x, tissot4_y = [], []\nfor lat_d in tissot_lats:\n    for lon_d in tissot_lons:\n        a = np.linspace(0, 2 * np.pi, 50)\n        clons = lon_d + tissot_r * np.cos(a)\n        clats = np.clip(lat_d + tissot_r * np.sin(a), -85, 85)\n        lat_rad = np.radians(clats)\n        theta = lat_rad.copy()\n        for _ in range(12):\n            theta += -(2 * theta + np.sin(2 * theta) - np.pi * np.sin(lat_rad)) / (2 + 2 * np.cos(2 * theta) + 1e-12)\n        tissot4_x.extend(((2 * np.sqrt(2) / np.pi) * np.radians(clons) * np.cos(theta)).tolist() + [np.nan])\n        tissot4_y.extend((np.sqrt(2) * np.sin(theta)).tolist() + [np.nan])\n\np4 = figure(\n    width=PANEL_W,\n    height=PANEL_H,\n    title=\"Mollweide\",\n    toolbar_location=None,\n    min_border_left=50,\n    min_border_right=30,\n    min_border_top=90,\n    min_border_bottom=40,\n)\np4.background_fill_color = PAGE_BG\np4.border_fill_color = PAGE_BG\np4.outline_line_color = INK_SOFT\np4.outline_line_width = 2\np4.title.text_color = INK\np4.title.text_font_size = \"30pt\"\np4.title.text_font_style = \"bold\"\np4.title.align = \"center\"\np4.xaxis.visible = False\np4.yaxis.visible = False\np4.xgrid.visible = False\np4.ygrid.visible = False\n\np4.patches(\n    xs=c4_xs, ys=c4_ys, fill_color=INK_SOFT, fill_alpha=0.15, line_color=INK_SOFT, line_width=0.7, line_alpha=0.6\n)\ngrat4 = p4.line(x=grat4_x, y=grat4_y, line_color=INK_SOFT, line_width=1.5, line_alpha=0.5)\ntissot4 = p4.line(x=tissot4_x, y=tissot4_y, line_color=BRAND, line_width=2.5, line_alpha=0.9)\n\n# Lat labels at the left edge of the Mollweide ellipse\nmoll_edge_x, moll_edge_y = [], []\nfor lat_val in [60, 30, 0, -30, -60]:\n    lr = np.array([np.radians(lat_val)])\n    th = lr.copy()\n    for _ in range(12):\n        th += -(2 * th + np.sin(2 * th) - np.pi * np.sin(lr)) / (2 + 2 * np.cos(2 * th) + 1e-12)\n    moll_edge_x.append(float((2 * np.sqrt(2) / np.pi) * np.radians(-180) * np.cos(th)[0]))\n    moll_edge_y.append(float(np.sqrt(2) * np.sin(th)[0]))\n\np4.text(\n    x=moll_edge_x,\n    y=moll_edge_y,\n    text=[\"60°N\", \"30°N\", \"0°\", \"30°S\", \"60°S\"],\n    text_color=INK_SOFT,\n    text_font_size=\"22pt\",\n    text_align=\"right\",\n    text_baseline=\"middle\",\n)\n# Lon labels along the equatorial axis (widest row of the Mollweide ellipse)\n_moll_lon_x = [(2 * np.sqrt(2) / np.pi) * np.radians(d) for d in [-90, 0, 90]]\np4.text(\n    x=_moll_lon_x,\n    y=[-1.47, -1.47, -1.47],\n    text=[\"90°W\", \"0°\", \"90°E\"],\n    text_color=INK_SOFT,\n    text_font_size=\"22pt\",\n    text_align=\"center\",\n    text_baseline=\"top\",\n)\n\nlegend4 = Legend(\n    items=[\n        LegendItem(label=\"Graticule (30° intervals)\", renderers=[grat4]),\n        LegendItem(label=\"Tissot indicatrix (equal-area)\", renderers=[tissot4]),\n    ],\n    location=\"bottom_right\",\n    label_text_font_size=\"24pt\",\n    label_text_color=INK_SOFT,\n    background_fill_color=ELEVATED_BG,\n    border_line_color=INK_SOFT,\n    spacing=6,\n    padding=12,\n)\np4.add_layout(legend4)\n\n# =============================================================================\n# Layout and save\n# =============================================================================\n\ngrid = gridplot([[p1, p2], [p3, p4]], merge_tools=False, toolbar_location=None)\n\ntitle_div = Div(\n    text=(\n        f\"<style>body,html{{background:{PAGE_BG};margin:0;padding:0;}}</style>\"\n        f\"<div style='background:{PAGE_BG};text-align:center;padding:14px 0 6px 0;\"\n        f\"font-family:sans-serif;'>\"\n        f\"<span style='font-size:40pt;font-weight:bold;color:{INK};'>\"\n        f\"map-projections · python · bokeh · anyplot.ai</span><br>\"\n        f\"<span style='font-size:20pt;color:{INK_SOFT};'>\"\n        f\"Green Tissot indicatrices reveal distortion — size shows area error, \"\n        f\"shape shows angular error. Graticule at 30° intervals.</span></div>\"\n    ),\n    width=3200,\n)\n\nlayout = column(title_div, grid, spacing=0)\n\noutput_file(f\"plot-{THEME}.html\")\nsave(layout)\n\nW, H = 3200, 1800\nWIN_H = H + 200\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{WIN_H}\",\n    \"--hide-scrollbars\",\n    \"--force-device-scale-factor=1\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\ndriver.set_window_size(W, WIN_H)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n\n# Crop / pad to exactly 3200×1800\nfrom PIL import Image as _PILImage\n\n\n_img = _PILImage.open(f\"plot-{THEME}.png\")\n_iw, _ih = _img.size\nif _iw != W or _ih != H:\n    _canvas = _PILImage.new(\"RGB\", (W, H), PAGE_BG)\n    _canvas.paste(_img.crop((0, 0, min(_iw, W), min(_ih, H))), (0, 0))\n    _canvas.save(f\"plot-{THEME}.png\")\n"}