{"spec_id":"chord-basic","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nchord-basic: Basic Chord Diagram\nLibrary: pygal 3.1.0 | Python 3.13.14\nQuality: 93/100 | Updated: 2026-06-17\n\"\"\"\n\nimport math\nimport os\nimport re\n\nimport cairosvg\nimport pygal\nfrom pygal.style import Style\n\n\n# Theme tokens (see prompts/default-style-guide.md \"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 — first series ALWAYS #009E73, then canonical order.\n# Theme-independent so each continent keeps its identity across light/dark.\nIMPRINT_PALETTE = (\n    \"#009E73\",  # Africa     — brand green\n    \"#C475FD\",  # Asia       — lavender\n    \"#4467A3\",  # Europe     — blue\n    \"#BD8233\",  # N. America — ochre\n    \"#AE3030\",  # S. America — matte red\n    \"#2ABCCD\",  # Oceania    — cyan\n)\n\n# Data: migration flows between 6 continents (thousands of people / year).\n# Each (source, target, value) is one directed flow; both directions appear.\ncontinents = [\"Africa\", \"Asia\", \"Europe\", \"N. America\", \"S. America\", \"Oceania\"]\nn = len(continents)\n\nflows = [\n    (0, 1, 8),\n    (0, 2, 25),\n    (0, 3, 12),\n    (0, 4, 5),\n    (0, 5, 3),\n    (1, 0, 6),\n    (1, 2, 20),\n    (1, 3, 35),\n    (1, 4, 8),\n    (1, 5, 18),\n    (2, 0, 4),\n    (2, 1, 12),\n    (2, 3, 22),\n    (2, 4, 15),\n    (2, 5, 10),\n    (3, 0, 2),\n    (3, 1, 10),\n    (3, 2, 18),\n    (3, 4, 14),\n    (3, 5, 6),\n    (4, 0, 3),\n    (4, 1, 7),\n    (4, 2, 28),\n    (4, 3, 20),\n    (4, 5, 4),\n    (5, 0, 2),\n    (5, 1, 15),\n    (5, 2, 12),\n    (5, 3, 8),\n    (5, 4, 3),\n]\n\n# Arc allocation proportional to total flow (in + out) per continent\ntotals = [0] * n\nfor s, t, v in flows:\n    totals[s] += v\n    totals[t] += v\ngrand_total = sum(totals)\n\n# Square canvas — hard rule for symmetric circular plots (prompts/library/pygal.md)\nW = H = 2400\n\n# Style — pygal carries every theme token; sizes are native source pixels.\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=IMPRINT_PALETTE,\n    font_family=\"DejaVu Sans, Helvetica, Arial, sans-serif\",\n    title_font_family=\"DejaVu Sans, Helvetica, Arial, sans-serif\",\n    legend_font_family=\"DejaVu Sans, Helvetica, Arial, sans-serif\",\n    title_font_size=66,\n    legend_font_size=44,\n    stroke_width=0,\n)\n\n# Chart — pygal owns the title + bottom legend (one swatch per continent) and\n# emits the interactive SVG/HTML. The chord geometry is injected afterwards in\n# absolute pixel coordinates, so it never depends on pygal's internal padding.\nchart = pygal.XY(\n    width=W,\n    height=H,\n    style=custom_style,\n    title=\"chord-basic · python · pygal · anyplot.ai\",\n    show_legend=True,\n    legend_at_bottom=True,\n    legend_at_bottom_columns=6,\n    legend_box_size=40,\n    show_x_labels=False,\n    show_y_labels=False,\n    show_x_guides=False,\n    show_y_guides=False,\n    stroke=False,\n    dots_size=0,\n    margin=40,\n)\n\n# One invisible anchor point per continent gives the legend its colored swatch\n# without drawing anything in the plot area (and without a \"No data\" watermark).\nfor name in continents:\n    chart.add(name, [(5, 5)], stroke=False, show_dots=False)\n\nsvg = chart.render().decode(\"utf-8\")\n\n# --- Locate title baseline + legend top so the diagram fits between them ---\ntitle_ys = [float(y) for y in re.findall(r'<text x=\"[\\d.]+\" y=\"([\\d.]+)\" class=\"title\"', svg)]\ntitle_bottom = (max(title_ys) if title_ys else 80) + 70\nm_leg = re.search(r'<g transform=\"translate\\([\\d.]+, ([\\d.]+)\\)\" class=\"legends\"', svg)\nlegend_top = float(m_leg.group(1)) if m_leg else H - 120\n\n# Diagram geometry — all derived from canvas + parsed chrome, no magic numbers\ncx = W / 2\ncy = (title_bottom + legend_top) / 2\nmax_r_v = (legend_top - title_bottom) / 2 - 20\nmax_r_h = W / 2 - 340  # leave room for the widest side labels (\"S. America\")\nr_label = min(max_r_v, max_r_h)\nr_outer = r_label - 80  # colored band outer edge\nband = r_outer * 0.075  # band thickness\nr_inner = r_outer - band  # chords attach to the inner edge\n\n# Arc spans (clockwise from top), proportional to each continent's total flow\ngap = 0.045  # radians of whitespace between arcs\navailable = 2 * math.pi - gap * n\nspans = [available * totals[i] / grand_total for i in range(n)]\narc_start = []  # higher angle (clockwise sweep decreases the angle)\nangle = math.pi / 2  # start at the top\nfor i in range(n):\n    arc_start.append(angle)\n    angle -= spans[i] + gap\n\nsvg_elems = []\n\n# Subtle hub disc to ground the circular composition\nsvg_elems.append(f'<circle cx=\"{cx:.1f}\" cy=\"{cy:.1f}\" r=\"{r_inner - 6:.1f}\" fill=\"{ELEVATED_BG}\" stroke=\"none\"/>')\n\n# Filled chord ribbons — width proportional to flow, colored by source,\n# opacity scaled by magnitude so dominant corridors read first.\nchord_r = r_inner\narc_pos = list(arc_start)\nmax_val = max(v for _, _, v in flows)\nfor s, t, v in flows:\n    s_ext = spans[s] * v / totals[s]\n    t_ext = spans[t] * v / totals[t]\n    s_a1, s_a2 = arc_pos[s], arc_pos[s] - s_ext\n    t_a1, t_a2 = arc_pos[t], arc_pos[t] - t_ext\n    arc_pos[s] = s_a2\n    arc_pos[t] = t_a2\n\n    sx1, sy1 = cx + chord_r * math.cos(s_a1), cy - chord_r * math.sin(s_a1)\n    sx2, sy2 = cx + chord_r * math.cos(s_a2), cy - chord_r * math.sin(s_a2)\n    tx1, ty1 = cx + chord_r * math.cos(t_a1), cy - chord_r * math.sin(t_a1)\n    tx2, ty2 = cx + chord_r * math.cos(t_a2), cy - chord_r * math.sin(t_a2)\n\n    # Inset the quadratic control point toward — but short of — the hub: thick\n    # corridors dive deep to the center, thin ones stay shallow, so the many\n    # low-magnitude ribbons no longer pile up on a single point at the hub.\n    depth = 0.55 + 0.35 * (v / max_val)\n    c1x, c1y = (sx1 + tx1) / 2, (sy1 + ty1) / 2\n    c2x, c2y = (tx2 + sx2) / 2, (ty2 + sy2) / 2\n    q1x, q1y = c1x + depth * (cx - c1x), c1y + depth * (cy - c1y)\n    q2x, q2y = c2x + depth * (cx - c2x), c2y + depth * (cy - c2y)\n\n    opacity = 0.30 + 0.55 * (v / max_val)\n    path = (\n        f\"M {sx1:.1f},{sy1:.1f} \"\n        f\"Q {q1x:.1f},{q1y:.1f} {tx1:.1f},{ty1:.1f} \"\n        f\"A {chord_r:.1f},{chord_r:.1f} 0 0,1 {tx2:.1f},{ty2:.1f} \"\n        f\"Q {q2x:.1f},{q2y:.1f} {sx2:.1f},{sy2:.1f} \"\n        f\"A {chord_r:.1f},{chord_r:.1f} 0 0,0 {sx1:.1f},{sy1:.1f} Z\"\n    )\n    tip = f\"{continents[s]} → {continents[t]}: {v}k/yr\"\n    svg_elems.append(\n        f'<path d=\"{path}\" fill=\"{IMPRINT_PALETTE[s]}\" fill-opacity=\"{opacity:.2f}\" '\n        f'stroke=\"none\"><title>{tip}</title></path>'\n    )\n\n# Node arcs — filled annular sectors on the perimeter, one per continent\nfor i in range(n):\n    a0, a1 = arc_start[i], arc_start[i] - spans[i]\n    ox1, oy1 = cx + r_outer * math.cos(a0), cy - r_outer * math.sin(a0)\n    ox2, oy2 = cx + r_outer * math.cos(a1), cy - r_outer * math.sin(a1)\n    ix2, iy2 = cx + r_inner * math.cos(a1), cy - r_inner * math.sin(a1)\n    ix1, iy1 = cx + r_inner * math.cos(a0), cy - r_inner * math.sin(a0)\n    sector = (\n        f\"M {ox1:.1f},{oy1:.1f} \"\n        f\"A {r_outer:.1f},{r_outer:.1f} 0 0,1 {ox2:.1f},{oy2:.1f} \"\n        f\"L {ix2:.1f},{iy2:.1f} \"\n        f\"A {r_inner:.1f},{r_inner:.1f} 0 0,0 {ix1:.1f},{iy1:.1f} Z\"\n    )\n    tip = f\"{continents[i]}: {totals[i]}k/yr total flow\"\n    svg_elems.append(\n        f'<path d=\"{sector}\" fill=\"{IMPRINT_PALETTE[i]}\" stroke=\"{PAGE_BG}\" '\n        f'stroke-width=\"3\"><title>{tip}</title></path>'\n    )\n\n# Continent labels just outside their arc, colored for identity\nfor i, name in enumerate(continents):\n    mid = arc_start[i] - spans[i] / 2\n    lx, ly = cx + (r_outer + 46) * math.cos(mid), cy - (r_outer + 46) * math.sin(mid)\n    deg = math.degrees(mid) % 360\n    if deg <= 80 or deg >= 280:\n        anchor = \"start\"\n    elif 100 <= deg <= 260:\n        anchor = \"end\"\n    else:\n        anchor = \"middle\"\n    svg_elems.append(\n        f'<text x=\"{lx:.1f}\" y=\"{ly:.1f}\" fill=\"{IMPRINT_PALETTE[i]}\" '\n        f'font-size=\"56\" font-weight=\"bold\" text-anchor=\"{anchor}\" '\n        f'dominant-baseline=\"central\" '\n        f'font-family=\"DejaVu Sans, Helvetica, Arial, sans-serif\">{name}</text>'\n    )\n\n# Inject the diagram just before the closing tag and write outputs\nsvg = svg.replace(\"</svg>\", \"<g>\" + \"\".join(svg_elems) + \"</g></svg>\")\n\nwith open(f\"plot-{THEME}.svg\", \"w\") as f:\n    f.write(svg)\n\ncairosvg.svg2png(bytestring=svg.encode(\"utf-8\"), write_to=f\"plot-{THEME}.png\", output_width=W, output_height=H)\n\n# Interactive HTML — embed the composed SVG so it matches the PNG exactly\nwith open(f\"plot-{THEME}.html\", \"w\") as f:\n    f.write(\n        '<!DOCTYPE html>\\n<html>\\n<head>\\n<meta charset=\"utf-8\">\\n'\n        \"<title>chord-basic · python · pygal · anyplot.ai</title>\\n\"\n        f\"<style>body{{margin:0;background:{PAGE_BG}}}\"\n        \"svg{width:100%;height:auto;display:block}</style>\\n</head>\\n<body>\\n\"\n        f\"{svg}\\n</body>\\n</html>\"\n    )\n"}