{"spec_id":"network-basic","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nnetwork-basic: Basic Network Graph\nLibrary: bokeh 3.9.1 | Python 3.13.14\nQuality: 90/100 | Updated: 2026-07-24\n\"\"\"\n\nimport os\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import ColumnDataSource, HoverTool, Legend, LegendItem\nfrom bokeh.plotting import figure\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\"\n\n# Imprint categorical palette — first 4 positions for the 4 communities\nIMPRINT = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\"]\n\n# Data: A small social network with 20 people in 4 friend groups\nnp.random.seed(42)\nnodes = [\n    {\"id\": 0, \"label\": \"Alice\", \"group\": 0},\n    {\"id\": 1, \"label\": \"Bob\", \"group\": 0},\n    {\"id\": 2, \"label\": \"Carol\", \"group\": 0},\n    {\"id\": 3, \"label\": \"David\", \"group\": 0},\n    {\"id\": 4, \"label\": \"Eve\", \"group\": 0},\n    {\"id\": 5, \"label\": \"Frank\", \"group\": 1},\n    {\"id\": 6, \"label\": \"Grace\", \"group\": 1},\n    {\"id\": 7, \"label\": \"Henry\", \"group\": 1},\n    {\"id\": 8, \"label\": \"Ivy\", \"group\": 1},\n    {\"id\": 9, \"label\": \"Jack\", \"group\": 1},\n    {\"id\": 10, \"label\": \"Kate\", \"group\": 2},\n    {\"id\": 11, \"label\": \"Leo\", \"group\": 2},\n    {\"id\": 12, \"label\": \"Mia\", \"group\": 2},\n    {\"id\": 13, \"label\": \"Noah\", \"group\": 2},\n    {\"id\": 14, \"label\": \"Olivia\", \"group\": 2},\n    {\"id\": 15, \"label\": \"Paul\", \"group\": 3},\n    {\"id\": 16, \"label\": \"Quinn\", \"group\": 3},\n    {\"id\": 17, \"label\": \"Ryan\", \"group\": 3},\n    {\"id\": 18, \"label\": \"Sara\", \"group\": 3},\n    {\"id\": 19, \"label\": \"Tom\", \"group\": 3},\n]\n\nedges = [\n    # Group 0 internal\n    (0, 1),\n    (0, 2),\n    (1, 2),\n    (1, 3),\n    (2, 4),\n    (3, 4),\n    # Group 1 internal\n    (5, 6),\n    (5, 7),\n    (6, 8),\n    (7, 8),\n    (7, 9),\n    (8, 9),\n    # Group 2 internal\n    (10, 11),\n    (10, 12),\n    (11, 13),\n    (12, 13),\n    (12, 14),\n    (13, 14),\n    # Group 3 internal\n    (15, 16),\n    (15, 17),\n    (16, 18),\n    (17, 18),\n    (17, 19),\n    (18, 19),\n    # Cross-group bridges\n    (0, 5),\n    (4, 10),\n    (9, 15),\n    (14, 19),\n    (2, 6),\n    (8, 11),\n    (13, 16),\n]\ngroup_of = {node[\"id\"]: node[\"group\"] for node in nodes}\nis_bridge = {(src, tgt): group_of[src] != group_of[tgt] for src, tgt in edges}\n\n# Spring layout (force-directed algorithm)\nn = len(nodes)\ngroup_centers = {0: (0.35, 0.60), 1: (0.65, 0.60), 2: (0.35, 0.40), 3: (0.65, 0.40)}\npositions = np.zeros((n, 2))\nfor i, node in enumerate(nodes):\n    cx, cy = group_centers[node[\"group\"]]\n    angle = np.random.rand() * 2 * np.pi\n    radius = np.random.rand() * 0.12\n    positions[i] = [cx + radius * np.cos(angle), cy + radius * np.sin(angle)]\n\nk = 0.18\nfor iteration in range(200):\n    displacement = np.zeros((n, 2))\n    for i in range(n):\n        for j in range(i + 1, n):\n            diff = positions[i] - positions[j]\n            dist = max(np.linalg.norm(diff), 0.01)\n            force = (k * k / dist) * (diff / dist)\n            displacement[i] += force\n            displacement[j] -= force\n    for src, tgt in edges:\n        diff = positions[src] - positions[tgt]\n        dist = max(np.linalg.norm(diff), 0.01)\n        force = (dist * dist / k) * (diff / dist)\n        displacement[src] -= force\n        displacement[tgt] += force\n    cooling = 1 - iteration / 200\n    for i in range(n):\n        disp_norm = np.linalg.norm(displacement[i])\n        if disp_norm > 0:\n            positions[i] += (displacement[i] / disp_norm) * min(disp_norm, 0.08 * cooling)\n\npos_min = positions.min(axis=0)\npos_max = positions.max(axis=0)\npos_range = pos_max - pos_min + 1e-6\npositions = (positions - pos_min) / pos_range * 0.70 + 0.15\npos = {node[\"id\"]: positions[i] for i, node in enumerate(nodes)}\n\ndegrees = {node[\"id\"]: 0 for node in nodes}\nfor src, tgt in edges:\n    degrees[src] += 1\n    degrees[tgt] += 1\n\ngroup_names = [\"Group A\", \"Group B\", \"Group C\", \"Group D\"]\n\n# Per-community hull (padded bounding ellipse around each group's nodes)\nhull_pad = 0.075\nhulls = []\nfor group_id in range(4):\n    group_ids = [node[\"id\"] for node in nodes if node[\"group\"] == group_id]\n    gx = [pos[gid][0] for gid in group_ids]\n    gy = [pos[gid][1] for gid in group_ids]\n    hulls.append(\n        {\n            \"cx\": (min(gx) + max(gx)) / 2,\n            \"cy\": (min(gy) + max(gy)) / 2,\n            \"w\": (max(gx) - min(gx)) + 2 * hull_pad,\n            \"h\": (max(gy) - min(gy)) + 2 * hull_pad,\n        }\n    )\n\n# Plot\np = figure(\n    width=3200,\n    height=1800,\n    title=\"network-basic · bokeh · anyplot.ai\",\n    x_range=(-0.02, 1.02),\n    y_range=(-0.02, 1.02),\n    toolbar_location=None,  # bokeh's default toolbar adds ~30-50px above the canvas,\n    # which would shrink the saved PNG below the mandated 3200x1800\n    min_border_top=130,\n    min_border_bottom=40,\n    min_border_left=40,\n    min_border_right=40,\n)\n\n# Theme-adaptive chrome\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = INK_SOFT\np.title.text_font_size = \"50pt\"\np.title.align = \"center\"\np.title.text_color = INK\np.axis.visible = False\np.grid.visible = False\n\n# Community hulls (soft translucent regions behind edges/nodes)\nfor hull, color in zip(hulls, IMPRINT, strict=True):\n    p.ellipse(\n        x=hull[\"cx\"],\n        y=hull[\"cy\"],\n        width=hull[\"w\"],\n        height=hull[\"h\"],\n        fill_color=color,\n        fill_alpha=0.12,\n        line_color=color,\n        line_alpha=0.25,\n        line_width=1.5,\n    )\n\n# Edges — intra-community ties are solid, cross-group bridges are dashed and\n# thinner so the two connection types read as visually distinct at a glance\nfor src, tgt in edges:\n    x0, y0 = pos[src]\n    x1, y1 = pos[tgt]\n    if is_bridge[(src, tgt)]:\n        p.line([x0, x1], [y0, y1], line_width=1.8, line_color=INK_SOFT, line_alpha=0.35, line_dash=[8, 5])\n    else:\n        p.line([x0, x1], [y0, y1], line_width=2.4, line_color=INK_SOFT, line_alpha=0.45)\n\n# Nodes by group\nlegend_items = []\nrenderers_for_hover = []\nfor group_id, (color, name) in enumerate(zip(IMPRINT, group_names, strict=True)):\n    group_nodes = [node for node in nodes if node[\"group\"] == group_id]\n    node_x = [pos[node[\"id\"]][0] for node in group_nodes]\n    node_y = [pos[node[\"id\"]][1] for node in group_nodes]\n    node_sizes = [34 + degrees[node[\"id\"]] * 7 for node in group_nodes]\n    node_labels = [node[\"label\"] for node in group_nodes]\n    node_degrees = [degrees[node[\"id\"]] for node in group_nodes]\n\n    source = ColumnDataSource(\n        data={\"x\": node_x, \"y\": node_y, \"size\": node_sizes, \"label\": node_labels, \"connections\": node_degrees}\n    )\n    renderer = p.scatter(\n        x=\"x\", y=\"y\", size=\"size\", source=source, fill_color=color, line_color=PAGE_BG, line_width=2, fill_alpha=0.9\n    )\n    legend_items.append(LegendItem(label=name, renderers=[renderer]))\n    renderers_for_hover.append(renderer)\n\n# Node labels: greedy 8-direction placement. Each label tries N/NE/E/SE/S/SW/W/NW\n# offsets from its own node and keeps whichever direction lands farthest from every\n# other node and every already-placed label — this is what actually prevents collisions\n# in a force-directed layout, where a fixed \"always above\" or \"always radial\" rule still\n# stacks labels wherever two nodes happen to sit close together (as bridge-region nodes do).\nlabel_offset = 0.065\nplaced_labels = []\nall_positions = list(pos.values())\nlabel_order = sorted(nodes, key=lambda nd: -degrees[nd[\"id\"]])\nfor node in label_order:\n    x, y = pos[node[\"id\"]]\n    node_size = 34 + degrees[node[\"id\"]] * 7\n    reach = label_offset + node_size / 2000\n    best_xy, best_dxdy, best_score = None, None, -1.0\n    for angle_deg in (90, 135, 45, 270, 0, 180, 315, 225):\n        dx, dy = np.cos(np.radians(angle_deg)), np.sin(np.radians(angle_deg))\n        lx, ly = x + reach * dx, y + reach * dy\n        if ly > 0.95 and dy > 0:  # keep labels from clipping the top edge under the title\n            continue\n        rivals = all_positions + placed_labels\n        score = min(((lx - ox) ** 2 + (ly - oy) ** 2) ** 0.5 for ox, oy in rivals) if rivals else 1.0\n        if score > best_score:\n            best_score, best_xy, best_dxdy = score, (lx, ly), (dx, dy)\n    x_label, y_label = best_xy\n    dx, dy = best_dxdy\n    placed_labels.append((x_label, y_label))\n    align = \"left\" if dx > 0.25 else (\"right\" if dx < -0.25 else \"center\")\n    baseline = \"bottom\" if dy > 0.25 else (\"top\" if dy < -0.25 else \"middle\")\n    p.text(\n        x=[x_label],\n        y=[y_label],\n        text=[node[\"label\"]],\n        text_font_size=\"24pt\",\n        text_font_style=\"bold\",\n        text_color=INK,\n        text_align=align,\n        text_baseline=baseline,\n        background_fill_color=PAGE_BG,\n        background_fill_alpha=0.7,\n    )\n\n# Hover tool\nhover = HoverTool(tooltips=[(\"Name\", \"@label\"), (\"Connections\", \"@connections\")], renderers=renderers_for_hover)\np.add_tools(hover)\n\n# Legend\nlegend = Legend(items=legend_items, location=\"center\", title=\"Communities\")\nlegend.title_text_font_size = \"36pt\"\nlegend.title_text_color = INK\nlegend.label_text_font_size = \"30pt\"\nlegend.label_text_color = INK_SOFT\nlegend.background_fill_color = ELEVATED_BG\nlegend.background_fill_alpha = 0.95\nlegend.border_line_color = INK_SOFT\nlegend.border_line_width = 2\nlegend.padding = 26\nlegend.spacing = 18\nlegend.glyph_height = 42\nlegend.glyph_width = 42\nlegend.margin = 26\np.add_layout(legend, \"right\")\n\n# Save the interactive HTML artifact\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\n# Screenshot with headless Chrome via Selenium (export_png uses snap chromedriver which fails)\n# CDP setDeviceMetricsOverride forces the exact inner viewport — --window-size alone is\n# consumed by browser chrome in headless mode and shrinks the rendered height.\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.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)\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"}