{"spec_id":"arc-basic","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\narc-basic: Basic Arc Diagram\nLibrary: bokeh 3.9.0 | Python 3.13.13\nQuality: 90/100 | Updated: 2026-05-30\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, Label, 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 sequential colormap stops: brand green → blue\n# Brief (1): #009E73, Moderate (2): midpoint, Frequent (3): #4467A3\nWEIGHT_COLORS = {1: \"#009E73\", 2: \"#22838B\", 3: \"#4467A3\"}\nWEIGHT_LABELS = {1: \"Brief\", 2: \"Moderate\", 3: \"Frequent\"}\n\n# Data — character interactions in a story chapter\nnodes = [\"Alice\", \"Bob\", \"Carol\", \"David\", \"Eve\", \"Frank\", \"Grace\", \"Henry\"]\nedges = [\n    (0, 1, 3),  # Alice–Bob: frequent\n    (0, 2, 2),  # Alice–Carol: moderate\n    (1, 3, 1),  # Bob–David: brief\n    (2, 4, 2),  # Carol–Eve: moderate\n    (0, 5, 1),  # Alice–Frank: brief\n    (3, 6, 2),  # David–Grace: moderate\n    (4, 7, 1),  # Eve–Henry: brief\n    (0, 7, 3),  # Alice–Henry: frequent (long-range)\n    (1, 4, 2),  # Bob–Eve: moderate\n    (2, 6, 1),  # Carol–Grace: brief\n    (5, 7, 2),  # Frank–Henry: moderate\n    (1, 2, 1),  # Bob–Carol: brief (short-range)\n]\n\nn_nodes = len(nodes)\nx_positions = np.linspace(0.5, 10.5, n_nodes)\ny_baseline = 0.0\n\n# Weighted degree — hub characters get larger, darker nodes\nweighted_degrees = np.zeros(n_nodes)\nfor src, tgt, w in edges:\n    weighted_degrees[src] += w\n    weighted_degrees[tgt] += w\n\nmin_wd, max_wd = weighted_degrees.min(), weighted_degrees.max()\nnode_sizes = 28 + (weighted_degrees - min_wd) / (max_wd - min_wd) * 28\n\n# Node colors on imprint_seq (brand green → blue by degree)\nnode_colors = []\nfor wd in weighted_degrees:\n    t = float(wd - min_wd) / float(max_wd - min_wd)\n    r = int(round(0x00 + t * (0x44 - 0x00)))\n    g = int(round(0x9E + t * (0x67 - 0x9E)))\n    b_val = int(round(0x73 + t * (0xA3 - 0x73)))\n    node_colors.append(f\"#{r:02X}{g:02X}{b_val:02X}\")\n\n# Figure — 3200×1800 landscape, no toolbar (toolbar adds ~30–50 px and breaks PNG height)\np = figure(\n    width=3200,\n    height=1800,\n    title=\"arc-basic · python · bokeh · anyplot.ai\",\n    x_range=(-0.5, 11.5),\n    y_range=(-0.5, 3.5),\n    toolbar_location=None,\n    min_border_bottom=70,\n    min_border_left=60,\n    min_border_top=110,\n    min_border_right=60,\n)\n\n# Theme-adaptive chrome\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = None\np.title.text_font_size = \"50pt\"\np.title.text_color = INK\np.xaxis.visible = False\np.yaxis.visible = False\np.xgrid.visible = False\np.ygrid.visible = False\n\n# Subtitle — centered over the arc area\np.add_layout(\n    Label(\n        x=5.5,\n        y=3.22,\n        text=\"Character Interaction Frequency · Chapter 1\",\n        text_font_size=\"26pt\",\n        text_font_style=\"italic\",\n        text_color=INK_SOFT,\n        text_align=\"center\",\n    )\n)\n\n# Arcs — arc_height = 0.30 × distance keeps all arcs inside y_range\narc_renderers_by_weight = {1: [], 2: [], 3: []}\n\nfor src_idx, tgt_idx, weight in edges:\n    x_src = x_positions[src_idx]\n    x_tgt = x_positions[tgt_idx]\n    distance = abs(x_tgt - x_src)\n    arc_height = distance * 0.30\n    cx0 = x_src + (x_tgt - x_src) / 3\n    cx1 = x_src + 2 * (x_tgt - x_src) / 3\n\n    line_width = 4.0 + weight * 2.5  # 6.5 / 9.0 / 11.5 px — Brief clearly visible\n    alpha = 0.60 + weight * 0.10  # 0.70 / 0.80 / 0.90 — Brief arcs fully legible\n\n    arc_src = ColumnDataSource(\n        data={\n            \"x0\": [x_src],\n            \"y0\": [y_baseline],\n            \"x1\": [x_tgt],\n            \"y1\": [y_baseline],\n            \"cx0\": [cx0],\n            \"cy0\": [arc_height],\n            \"cx1\": [cx1],\n            \"cy1\": [arc_height],\n            \"source_name\": [nodes[src_idx]],\n            \"target_name\": [nodes[tgt_idx]],\n            \"weight_label\": [WEIGHT_LABELS[weight]],\n        }\n    )\n    renderer = p.bezier(\n        x0=\"x0\",\n        y0=\"y0\",\n        x1=\"x1\",\n        y1=\"y1\",\n        cx0=\"cx0\",\n        cy0=\"cy0\",\n        cx1=\"cx1\",\n        cy1=\"cy1\",\n        source=arc_src,\n        line_width=line_width,\n        line_color=WEIGHT_COLORS[weight],\n        line_alpha=alpha,\n    )\n    arc_renderers_by_weight[weight].append(renderer)\n\narc_hover = HoverTool(\n    tooltips=[(\"Connection\", \"@source_name ↔ @target_name\"), (\"Frequency\", \"@weight_label\")], line_policy=\"interp\"\n)\np.add_tools(arc_hover)\n\n# Nodes — size and color encode weighted degree (hub visibility)\nnode_source = ColumnDataSource(\n    data={\n        \"x\": x_positions,\n        \"y\": [y_baseline] * n_nodes,\n        \"name\": nodes,\n        \"size\": node_sizes,\n        \"color\": node_colors,\n        \"connections\": [int(wd) for wd in weighted_degrees],\n    }\n)\nnode_renderer = p.scatter(\n    x=\"x\", y=\"y\", source=node_source, size=\"size\", fill_color=\"color\", line_color=PAGE_BG, line_width=4\n)\nnode_hover = HoverTool(\n    tooltips=[(\"Character\", \"@name\"), (\"Connection Strength\", \"@connections\")], renderers=[node_renderer]\n)\np.add_tools(node_hover)\n\n# Node labels\nfor i, name in enumerate(nodes):\n    p.add_layout(\n        Label(\n            x=x_positions[i],\n            y=-0.20,\n            text=name,\n            text_font_size=\"26pt\",\n            text_align=\"center\",\n            text_baseline=\"top\",\n            text_color=INK,\n        )\n    )\n\n# Legend — larger text + integrated styling to address previous \"slightly small/detached\"\nlegend_items = [\n    LegendItem(label=WEIGHT_LABELS[w], renderers=[arc_renderers_by_weight[w][0]])\n    for w in [3, 2, 1]\n    if arc_renderers_by_weight[w]\n]\nlegend = Legend(\n    items=legend_items,\n    location=\"top_right\",\n    label_text_font_size=\"30pt\",\n    label_text_color=INK_SOFT,\n    border_line_color=INK_SOFT,\n    border_line_alpha=0.5,\n    background_fill_color=ELEVATED_BG,\n    background_fill_alpha=0.95,\n    glyph_width=70,\n    glyph_height=12,\n    spacing=18,\n    padding=24,\n)\np.add_layout(legend)\n\n# Save interactive HTML (catalog 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"}