{"spec_id":"cartogram-area-distortion","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\ncartogram-area-distortion: Cartogram with Area Distortion by Data Value\nLibrary: bokeh 3.9.1 | Python 3.13.13\nQuality: 88/100 | Updated: 2026-06-08\n\"\"\"\n\nimport os\nimport sys\nimport time\nfrom pathlib import Path\n\n\n# Prevent this script (bokeh.py) from shadowing the installed bokeh package when\n# Python adds its own directory to sys.path[0] on direct invocation.\nsys.path = [p for p in sys.path if os.path.abspath(p or os.getcwd()) != os.path.dirname(os.path.abspath(__file__))]\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import BasicTicker, ColorBar, ColumnDataSource, Label, LinearColorMapper, Range1d\nfrom bokeh.plotting import figure\nfrom bokeh.transform import transform\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\n# Theme tokens — Imprint palette, theme-adaptive chrome\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 sequential colormap: brand green (#009E73) → blue (#4467A3), 256 stops\n_c0 = np.array([0x00, 0x9E, 0x73])\n_c1 = np.array([0x44, 0x67, 0xA3])\nIMPRINT_SEQ256 = [\n    \"#{:02X}{:02X}{:02X}\".format(*np.round(_c0 + (_c1 - _c0) * (t / 255.0)).astype(int)) for t in range(256)\n]\n\n# Data — US states: (longitude, latitude, population in millions)\nstates = {\n    \"WA\": (-122.0, 47.5, 7.7),\n    \"OR\": (-120.5, 44.0, 4.2),\n    \"CA\": (-119.5, 37.0, 39.0),\n    \"NV\": (-116.8, 39.0, 3.2),\n    \"ID\": (-114.5, 44.0, 2.0),\n    \"MT\": (-109.5, 47.0, 1.1),\n    \"WY\": (-107.5, 43.0, 0.6),\n    \"UT\": (-111.5, 39.5, 3.4),\n    \"CO\": (-105.5, 39.0, 5.8),\n    \"AZ\": (-111.5, 34.0, 7.4),\n    \"NM\": (-106.0, 34.5, 2.1),\n    \"ND\": (-100.5, 47.5, 0.8),\n    \"SD\": (-100.0, 44.5, 0.9),\n    \"NE\": (-99.5, 41.5, 2.0),\n    \"KS\": (-98.5, 38.5, 2.9),\n    \"OK\": (-97.5, 35.5, 4.0),\n    \"TX\": (-99.0, 31.0, 30.5),\n    \"MN\": (-94.5, 46.0, 5.7),\n    \"IA\": (-93.5, 42.0, 3.2),\n    \"MO\": (-92.5, 38.5, 6.2),\n    \"AR\": (-92.5, 34.8, 3.0),\n    \"LA\": (-92.0, 31.0, 4.6),\n    \"WI\": (-89.5, 44.5, 5.9),\n    \"IL\": (-89.0, 40.0, 12.6),\n    \"MI\": (-84.5, 44.5, 10.0),\n    \"IN\": (-86.0, 40.0, 6.8),\n    \"OH\": (-82.5, 40.5, 11.8),\n    \"KY\": (-85.5, 37.8, 4.5),\n    \"TN\": (-86.0, 35.8, 7.1),\n    \"MS\": (-89.5, 32.5, 2.9),\n    \"AL\": (-86.8, 32.8, 5.1),\n    \"GA\": (-83.5, 33.0, 11.0),\n    \"FL\": (-81.5, 28.0, 22.6),\n    \"SC\": (-80.5, 34.0, 5.3),\n    \"NC\": (-79.0, 35.5, 10.7),\n    \"VA\": (-78.5, 37.5, 8.6),\n    \"WV\": (-80.5, 38.5, 1.8),\n    \"PA\": (-77.5, 41.0, 13.0),\n    \"NY\": (-75.5, 43.0, 19.7),\n    \"NJ\": (-74.5, 40.0, 9.3),\n    \"DE\": (-75.5, 39.0, 1.0),\n    \"MD\": (-76.6, 39.0, 6.2),\n    \"CT\": (-72.7, 41.6, 3.6),\n    \"RI\": (-71.5, 41.7, 1.1),\n    \"MA\": (-71.8, 42.3, 7.0),\n    \"VT\": (-72.6, 44.0, 0.6),\n    \"NH\": (-71.5, 43.5, 1.4),\n    \"ME\": (-69.0, 45.0, 1.4),\n    \"AK\": (-124.0, 30.0, 0.7),\n    \"HI\": (-118.0, 28.0, 1.4),\n}\n\nnames = list(states.keys())\nlons = [states[s][0] for s in names]\nlats = [states[s][1] for s in names]\npopulations = [states[s][2] for s in names]\n\n# Circle area proportional to population; raised min_size so small states remain visible\nmin_pop, max_pop = min(populations), max(populations)\nmin_size, max_size = 22, 92\nsizes = [min_size + (max_size - min_size) * np.sqrt((p - min_pop) / (max_pop - min_pop)) for p in populations]\nref_outline_size = 32  # uniform reference circles — geographic footprint baseline\n\nsource = ColumnDataSource(\n    data={\n        \"lon\": lons,\n        \"lat\": lats,\n        \"population\": populations,\n        \"size\": sizes,\n        \"ref_size\": [ref_outline_size] * len(names),\n        \"name\": names,\n        \"pop_label\": [f\"{p:.1f}M\" for p in populations],\n    }\n)\n\ncolor_mapper = LinearColorMapper(palette=IMPRINT_SEQ256, low=min_pop, high=max_pop)\n\n# Title fontsize scaled by character count (floor 34pt)\ntitle_str = \"cartogram-area-distortion · python · bokeh · anyplot.ai\"\ntitle_fontsize = max(34, round(50 * (67 / len(title_str) if len(title_str) > 67 else 1.0)))\n\n# Figure — 3200×1800 landscape; toolbar_location=None prevents extra height in PNG\np = figure(\n    width=3200,\n    height=1800,\n    title=title_str,\n    x_axis_label=\"Longitude (°W)\",\n    y_axis_label=\"Latitude (°N)\",\n    x_range=Range1d(-128, -65),\n    y_range=Range1d(25, 51.5),\n    toolbar_location=None,\n    tools=\"hover\",\n    tooltips=[(\"State\", \"@name\"), (\"Population\", \"@pop_label\")],\n    min_border_bottom=160,\n    min_border_left=180,\n    min_border_top=110,\n    min_border_right=80,\n)\n\n# Reference circles: uniform size shows geographic footprint for comparison\n# Increased alpha (0.7) from previous (0.45) for a clear comparison baseline\np.scatter(\n    x=\"lon\",\n    y=\"lat\",\n    size=\"ref_size\",\n    source=source,\n    fill_color=None,\n    line_color=INK_SOFT,\n    line_width=2.0,\n    line_dash=\"dashed\",\n    line_alpha=0.7,\n)\n\n# Cartogram circles: area scaled by population, Imprint sequential colormap\np.scatter(\n    x=\"lon\",\n    y=\"lat\",\n    size=\"size\",\n    source=source,\n    fill_color=transform(\"population\", color_mapper),\n    fill_alpha=0.9,\n    line_color=PAGE_BG,\n    line_width=1.5,\n)\n\n# Focal emphasis: bold stroke on the 5 most populous states (CA, TX, FL, NY, PA)\ntop5_states = {\"CA\", \"TX\", \"FL\", \"NY\", \"PA\"}\ntop5_idx = [i for i, n in enumerate(names) if n in top5_states]\ntop5_source = ColumnDataSource(\n    data={\n        \"lon\": [lons[i] for i in top5_idx],\n        \"lat\": [lats[i] for i in top5_idx],\n        \"population\": [populations[i] for i in top5_idx],\n        \"size\": [sizes[i] for i in top5_idx],\n        \"name\": [names[i] for i in top5_idx],\n        \"pop_label\": [f\"{populations[i]:.1f}M\" for i in top5_idx],\n    }\n)\np.scatter(\n    x=\"lon\",\n    y=\"lat\",\n    size=\"size\",\n    source=top5_source,\n    fill_color=transform(\"population\", color_mapper),\n    fill_alpha=0.9,\n    line_color=INK,\n    line_width=4.5,\n)\n\n# State abbreviation labels for states with population > 6M\nlabel_offsets = {\n    \"CA\": (0, 1.8),\n    \"TX\": (0, -2.0),\n    \"FL\": (1.5, -1.5),\n    \"NY\": (2.0, 1.5),\n    \"PA\": (-2.0, -2.0),\n    \"IL\": (0, -1.5),\n    \"OH\": (-2.5, -1.0),\n    \"MI\": (0, 1.0),\n    \"GA\": (0, -1.5),\n    \"NC\": (2.0, -1.0),\n    \"VA\": (2.0, -1.5),\n}\nskip_labels = {\"NJ\", \"MA\"}  # too crowded in the Northeast\nfor i, name in enumerate(names):\n    if populations[i] > 6.0 and name not in skip_labels:\n        dx, dy = label_offsets.get(name, (0, 0))\n        p.add_layout(\n            Label(\n                x=lons[i] + dx,\n                y=lats[i] + dy,\n                text=name,\n                text_font_size=\"24pt\",\n                text_align=\"center\",\n                text_baseline=\"middle\",\n                text_color=INK,\n                text_font_style=\"bold\",\n            )\n        )\n\n# Size legend — upper-left corner\nsize_legend_x = -127.5\nsize_legend_y = 48.8\np.add_layout(\n    Label(\n        x=size_legend_x,\n        y=size_legend_y + 1.1,\n        text=\"Population\",\n        text_font_size=\"26pt\",\n        text_color=INK_SOFT,\n        text_font_style=\"bold\",\n    )\n)\nfor j, lp in enumerate([5.0, 15.0, 30.0]):\n    ls = min_size + (max_size - min_size) * np.sqrt((lp - min_pop) / (max_pop - min_pop))\n    ly = size_legend_y - j * 2.4\n    fill_hex = IMPRINT_SEQ256[min(255, int((lp - min_pop) / (max_pop - min_pop) * 255))]\n    p.scatter(\n        x=[size_legend_x + 0.8],\n        y=[ly],\n        size=ls,\n        fill_color=fill_hex,\n        fill_alpha=0.9,\n        line_color=PAGE_BG,\n        line_width=1.5,\n    )\n    p.add_layout(\n        Label(\n            x=size_legend_x + 3.5,\n            y=ly,\n            text=f\"{lp:.0f}M\",\n            text_font_size=\"24pt\",\n            text_color=INK_SOFT,\n            text_baseline=\"middle\",\n        )\n    )\n\n# Reference outline annotation\np.add_layout(\n    Label(\n        x=-127.5,\n        y=40.8,\n        text=\"- - = geographic area (reference)\",\n        text_font_size=\"22pt\",\n        text_color=INK_MUTED,\n        text_font_style=\"italic\",\n    )\n)\n\n# AK and HI inset labels\nfor abbr in (\"AK\", \"HI\"):\n    p.add_layout(\n        Label(\n            x=states[abbr][0],\n            y=states[abbr][1] - 1.8,\n            text=abbr,\n            text_font_size=\"24pt\",\n            text_align=\"center\",\n            text_color=INK_SOFT,\n        )\n    )\n\n# Subtitle — storytelling annotation preserved from previous version\np.add_layout(\n    Label(\n        x=-128.0,\n        y=50.8,\n        text=\"Circle area ∝ population — California (39M) dwarfs Wyoming (0.6M) by 65×\",\n        text_font_size=\"26pt\",\n        text_color=INK_MUTED,\n        text_font_style=\"italic\",\n    )\n)\n\n# Color bar — enlarged tick labels and title for 3200×1800 legibility\ncolor_bar = ColorBar(\n    color_mapper=color_mapper,\n    ticker=BasicTicker(desired_num_ticks=6),\n    label_standoff=20,\n    major_label_text_font_size=\"30pt\",\n    major_label_text_color=INK_SOFT,\n    title=\"Population (millions)\",\n    title_text_font_size=\"32pt\",\n    title_text_color=INK,\n    width=70,\n    padding=80,\n    margin=40,\n    background_fill_color=ELEVATED_BG,\n    border_line_color=INK_SOFT,\n)\np.add_layout(color_bar, \"right\")\n\n# Typography\np.title.text_font_size = f\"{title_fontsize}pt\"\np.title.text_color = INK\np.title.text_font_style = \"bold\"\np.xaxis.axis_label_text_font_size = \"42pt\"\np.yaxis.axis_label_text_font_size = \"42pt\"\np.xaxis.major_label_text_font_size = \"34pt\"\np.yaxis.major_label_text_font_size = \"34pt\"\np.xaxis.axis_label_text_color = INK\np.yaxis.axis_label_text_color = INK\np.xaxis.major_label_text_color = INK_SOFT\np.yaxis.major_label_text_color = INK_SOFT\np.xaxis.axis_line_color = INK_SOFT\np.yaxis.axis_line_color = INK_SOFT\np.xaxis.major_tick_line_color = INK_SOFT\np.yaxis.major_tick_line_color = INK_SOFT\np.xaxis.minor_tick_line_color = None\np.yaxis.minor_tick_line_color = None\n\n# Background and borders\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = None\n\n# Grid — subtle\np.xgrid.grid_line_color = INK\np.ygrid.grid_line_color = INK\np.xgrid.grid_line_alpha = 0.12\np.ygrid.grid_line_alpha = 0.12\n\n# Save interactive HTML then screenshot to PNG via headless Chrome\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\nW, H = 3200, 1800\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\ndriver.set_window_size(W, H)\n\n# Chrome headless has ~139 px of browser chrome overhead; resize so the\n# viewport (window.innerHeight) is exactly H, not H minus that overhead.\nvh = driver.execute_script(\"return window.innerHeight\")\nif vh != H:\n    driver.set_window_size(W, H + (H - vh))\n\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}