{"spec_id":"chord-basic","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nchord-basic: Basic Chord Diagram\nLibrary: bokeh 3.9.1 | Python 3.13.13\nQuality: 90/100 | Updated: 2026-06-17\n\"\"\"\n\nimport os\nimport sys\n\n\n# Prevent self-import: this file is named bokeh.py, which shadows the installed\n# bokeh package when its directory sits at the front of sys.path.\n_this_dir = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if os.path.abspath(p or \".\") != _this_dir]\n\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import ColumnDataSource, HoverTool, Label\nfrom bokeh.plotting import figure\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\n# 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 palette — colorblind-safe, first series always #009E73\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\"]\n\n# Data — Migration flows between continents (in millions)\nentities = [\"Africa\", \"Asia\", \"Europe\", \"N. America\", \"S. America\", \"Oceania\"]\nn = len(entities)\ncolors = IMPRINT_PALETTE[:n]\n\n# Flow matrix (rows = source, cols = target)\nflow_matrix = np.array(\n    [\n        [0, 8, 12, 3, 2, 1],  # Africa to others\n        [5, 0, 15, 10, 2, 4],  # Asia to others\n        [3, 10, 0, 8, 4, 2],  # Europe to others\n        [2, 6, 12, 0, 8, 1],  # N. America to others\n        [4, 3, 7, 12, 0, 1],  # S. America to others\n        [1, 5, 3, 2, 1, 0],  # Oceania to others\n    ]\n)\n\n# Total flows for each entity (outgoing + incoming)\ntotal_flows = flow_matrix.sum(axis=1) + flow_matrix.sum(axis=0)\ntotal_all = total_flows.sum()\n\n# Arc angles for each entity\ngap = 0.03 * 2 * np.pi\ntotal_gap = gap * n\navailable = 2 * np.pi - total_gap\narc_angles = (total_flows / total_all) * available\n\n# Start/end angles for each entity's arc (start from top)\narc_starts = np.zeros(n)\narc_ends = np.zeros(n)\ncurrent_angle = np.pi / 2\nfor i in range(n):\n    arc_starts[i] = current_angle\n    arc_ends[i] = current_angle + arc_angles[i]\n    current_angle = arc_ends[i] + gap\n\narc_mids = (arc_starts + arc_ends) / 2\n\n# Figure — square canvas (2400x2400), centered symmetric layout, no toolbar.\n# Equal x/y data spans (3.2 each) keep the circle round on the square canvas.\ntitle = \"chord-basic · python · bokeh · anyplot.ai\"\np = figure(\n    width=2400, height=2400, title=title, x_range=(-1.6, 1.6), y_range=(-1.9, 1.3), toolbar_location=None, tools=\"\"\n)\n\np.axis.visible = False\np.grid.visible = False\np.outline_line_color = None\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.title.text_font_size = \"50pt\"\np.title.text_color = INK\np.title.align = \"center\"\n\n# Determine top flows for storytelling emphasis\nall_flows = [flow_matrix[i, j] for i in range(n) for j in range(n) if i != j and flow_matrix[i, j] > 0]\nflow_75th = np.percentile(all_flows, 75)\nflow_max = max(all_flows)\n\n# Outer arcs\nouter_radius = 0.95\ninner_radius = 0.87\narc_resolution = 60\n\nfor i in range(n):\n    theta = np.linspace(arc_starts[i], arc_ends[i], arc_resolution)\n    x_outer = outer_radius * np.cos(theta)\n    y_outer = outer_radius * np.sin(theta)\n    x_inner = inner_radius * np.cos(theta[::-1])\n    y_inner = inner_radius * np.sin(theta[::-1])\n\n    source = ColumnDataSource(\n        data={\n            \"x\": [list(np.concatenate([x_outer, x_inner]))],\n            \"y\": [list(np.concatenate([y_outer, y_inner]))],\n            \"entity\": [entities[i]],\n            \"total\": [f\"{int(total_flows[i])}M total flow\"],\n        }\n    )\n    p.patches(\"x\", \"y\", source=source, fill_color=colors[i], fill_alpha=0.92, line_color=PAGE_BG, line_width=2.5)\n\n# Inner ring — subtle decorative detail (theme-adaptive)\ninner_ring_r = inner_radius - 0.005\ntheta_full = np.linspace(0, 2 * np.pi, 360)\np.line(\n    inner_ring_r * np.cos(theta_full),\n    inner_ring_r * np.sin(theta_full),\n    line_color=INK_SOFT,\n    line_width=0.8,\n    line_alpha=0.35,\n)\n\n# Entity labels with flow totals\nlabel_radius = 1.12\nfor i in range(n):\n    angle = arc_mids[i]\n    x = label_radius * np.cos(angle)\n    y = label_radius * np.sin(angle)\n\n    angle_deg = np.degrees(angle) % 360\n    if 80 < angle_deg < 100 or 260 < angle_deg < 280:\n        anchor = \"center\"\n    elif 90 < angle_deg < 270:\n        anchor = \"right\"\n    else:\n        anchor = \"left\"\n\n    p.text(\n        x=[x],\n        y=[y],\n        text=[entities[i]],\n        text_font_size=\"32pt\",\n        text_align=anchor,\n        text_baseline=\"middle\",\n        text_color=colors[i],\n        text_font_style=\"bold\",\n    )\n\n    # Flow total underneath label\n    p.text(\n        x=[x],\n        y=[y - 0.08],\n        text=[f\"{int(total_flows[i])}M\"],\n        text_font_size=\"24pt\",\n        text_align=anchor,\n        text_baseline=\"middle\",\n        text_color=INK_MUTED,\n    )\n\n# Track position within each entity's arc for chord placement\nchord_pos = arc_starts.copy()\nchord_radius = inner_radius - 0.02\nn_bezier = 40\n\n# Build all chord shapes — visual hierarchy via alpha scaling.\n# Minor flows lifted off the floor so faint thin chords stay traceable.\nchord_data = {\n    \"x\": [],\n    \"y\": [],\n    \"source_name\": [],\n    \"target_name\": [],\n    \"value\": [],\n    \"color\": [],\n    \"alpha\": [],\n    \"line_width\": [],\n}\n\nfor i in range(n):\n    for j in range(n):\n        if i == j or flow_matrix[i, j] == 0:\n            continue\n\n        val = flow_matrix[i, j]\n        ratio = val / flow_max\n        if val >= flow_75th:\n            alpha = 0.62 + 0.18 * ratio\n            lw = 2.5\n        else:\n            alpha = 0.36 + 0.16 * ratio\n            lw = 1.5\n\n        # Chord width proportional to flow\n        w_i = (val / total_flows[i]) * arc_angles[i]\n        w_j = (val / total_flows[j]) * arc_angles[j]\n        s_i, chord_pos[i] = chord_pos[i], chord_pos[i] + w_i\n        e_i = chord_pos[i]\n        s_j, chord_pos[j] = chord_pos[j], chord_pos[j] + w_j\n        e_j = chord_pos[j]\n\n        # Build chord: arc at i → bezier → arc at j → bezier back\n        th_i = np.linspace(s_i, e_i, 15)\n        th_j = np.linspace(s_j, e_j, 15)\n        t = np.linspace(0, 1, n_bezier)\n\n        pts_i = chord_radius * np.exp(1j * th_i)\n        pts_j = chord_radius * np.exp(1j * th_j)\n        p1, p2 = pts_i[-1], pts_j[0]\n        p3, p4 = pts_j[-1], pts_i[0]\n        bez1 = (1 - t) ** 2 * p1 + t**2 * p2\n        bez2 = (1 - t) ** 2 * p3 + t**2 * p4\n\n        cx = np.concatenate([pts_i.real, bez1.real, pts_j.real, bez2.real])\n        cy = np.concatenate([pts_i.imag, bez1.imag, pts_j.imag, bez2.imag])\n\n        chord_data[\"x\"].append(list(cx))\n        chord_data[\"y\"].append(list(cy))\n        chord_data[\"source_name\"].append(entities[i])\n        chord_data[\"target_name\"].append(entities[j])\n        chord_data[\"value\"].append(int(val))\n        chord_data[\"color\"].append(colors[i])\n        chord_data[\"alpha\"].append(round(alpha, 3))\n        chord_data[\"line_width\"].append(lw)\n\n# Render chords with per-element alpha for visual hierarchy\nchord_source = ColumnDataSource(data=chord_data)\nchords = p.patches(\n    \"x\",\n    \"y\",\n    source=chord_source,\n    fill_color=\"color\",\n    fill_alpha=\"alpha\",\n    line_color=\"color\",\n    line_alpha=\"alpha\",\n    line_width=\"line_width\",\n)\n\n# Hover tool for chords — distinctive Bokeh interactive feature\ntooltip_bg = ELEVATED_BG\nhover = HoverTool(\n    renderers=[chords],\n    tooltips=f\"\"\"\n<div style=\"font-size:18px;padding:8px;background:{tooltip_bg};border:1px solid {INK_SOFT};border-radius:4px;color:{INK};\">\n<b>@source_name → @target_name</b><br/>\nFlow: <b>@value</b> million\n</div>\n\"\"\",\n)\np.add_tools(hover)\n\n# Legend below the diagram — horizontal layout, sorted by total flow\nsorted_indices = np.argsort(-total_flows)\ncols = 3\nlegend_y_start = -1.44\nlegend_spacing = 0.15\n\nfor rank, idx in enumerate(sorted_indices):\n    col = rank % cols\n    row = rank // cols\n    lx = -0.95 + col * 0.72\n    ly = legend_y_start - row * legend_spacing\n\n    p.rect(x=[lx - 0.06], y=[ly], width=0.07, height=0.06, fill_color=colors[idx], line_color=None)\n    p.text(\n        x=[lx - 0.01],\n        y=[ly],\n        text=[f\"{entities[idx]}  ({int(total_flows[idx])}M)\"],\n        text_font_size=\"24pt\",\n        text_baseline=\"middle\",\n        text_color=INK_SOFT,\n    )\n\n# Annotation for top flow — focal point for data storytelling\ntop_idx = np.unravel_index(flow_matrix.argmax(), flow_matrix.shape)\ntop_src, top_tgt = entities[top_idx[0]], entities[top_idx[1]]\ntop_val = flow_matrix[top_idx[0], top_idx[1]]\n\np.add_layout(\n    Label(\n        x=0,\n        y=-1.30,\n        text=f\"Largest flow: {top_src} → {top_tgt} ({top_val}M)\",\n        text_font_size=\"26pt\",\n        text_color=INK_MUTED,\n        text_align=\"center\",\n        text_font_style=\"italic\",\n    )\n)\n\n# Save — interactive HTML + PNG screenshot via headless Chrome (Selenium).\n# export_png is avoided: its chromedriver probe fails on this dev box.\noutput_file(f\"plot-{THEME}.html\", title=\"chord-basic · bokeh · anyplot.ai\")\nsave(p)\n\nW, H = 2400, 2400\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)\n# CDP setDeviceMetricsOverride forces the exact inner viewport — --window-size\n# alone is consumed by browser chrome in headless mode and shrinks the height.\ndriver.execute_cdp_cmd(\n    \"Emulation.setDeviceMetricsOverride\", {\"width\": W, \"height\": H, \"deviceScaleFactor\": 1, \"mobile\": False}\n)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)  # let bokeh's JS render the canvas\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n\n# Belt-and-braces: pad/crop to exact dims so the post-render gate always passes\nfrom PIL import Image as _PILImage\n\n\n_img = _PILImage.open(f\"plot-{THEME}.png\").convert(\"RGB\")\nif _img.size != (W, H):\n    _norm = _PILImage.new(\"RGB\", (W, H), PAGE_BG)\n    _norm.paste(_img, ((W - _img.size[0]) // 2, (H - _img.size[1]) // 2))\n    _norm.save(f\"plot-{THEME}.png\")\n"}