{"spec_id":"chord-basic","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nchord-basic: Basic Chord Diagram\nLibrary: altair 6.2.1 | Python 3.13.13\nQuality: 90/100 | Updated: 2026-06-17\n\"\"\"\n\nimport os\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\n\n# Theme-adaptive chrome (Imprint palette tokens)\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# Data - Migration flows between continents (thousands of people, bidirectional)\nflows_data = [\n    {\"source\": \"Europe\", \"target\": \"North America\", \"value\": 45},\n    {\"source\": \"North America\", \"target\": \"Europe\", \"value\": 30},\n    {\"source\": \"Europe\", \"target\": \"Asia\", \"value\": 25},\n    {\"source\": \"Asia\", \"target\": \"Europe\", \"value\": 35},\n    {\"source\": \"Asia\", \"target\": \"North America\", \"value\": 40},\n    {\"source\": \"North America\", \"target\": \"Asia\", \"value\": 20},\n    {\"source\": \"Africa\", \"target\": \"Europe\", \"value\": 55},\n    {\"source\": \"Europe\", \"target\": \"Africa\", \"value\": 15},\n    {\"source\": \"Africa\", \"target\": \"North America\", \"value\": 25},\n    {\"source\": \"South America\", \"target\": \"North America\", \"value\": 50},\n    {\"source\": \"North America\", \"target\": \"South America\", \"value\": 18},\n    {\"source\": \"South America\", \"target\": \"Europe\", \"value\": 22},\n    {\"source\": \"Oceania\", \"target\": \"Asia\", \"value\": 30},\n    {\"source\": \"Asia\", \"target\": \"Oceania\", \"value\": 25},\n    {\"source\": \"Oceania\", \"target\": \"Europe\", \"value\": 12},\n]\n\ndf = pd.DataFrame(flows_data)\n\n# Internal layout domain (square). Mapped to a small Altair view + padded to 2400x2400.\nW, H = 1200, 1200\nCX, CY = W / 2, H / 2 + 20\nR_OUTER, R_INNER, R_CHORD = 440, 410, 398\n\n# Entity ordering and Imprint palette (canonical order 1->6, colorblind-safe)\nentities = [\"Europe\", \"North America\", \"Asia\", \"Africa\", \"South America\", \"Oceania\"]\ncolors = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\"]\ncolor_scale = alt.Scale(domain=entities, range=colors)\n\n# Compute entity totals and angular positions\nentity_totals = {e: df[df[\"source\"] == e][\"value\"].sum() + df[df[\"target\"] == e][\"value\"].sum() for e in entities}\ntotal_flow = sum(entity_totals.values())\ngap = 0.04\navailable_angle = 2 * np.pi - gap * len(entities)\n\nentity_arcs = {}\nangle = -np.pi / 2\nfor e in entities:\n    arc_len = (entity_totals[e] / total_flow) * available_angle\n    entity_arcs[e] = (angle, angle + arc_len, arc_len)\n    angle += arc_len + gap\n\n# Build outer arc polygons\narcs_rows = []\nfor e in entities:\n    start, end, _ = entity_arcs[e]\n    angles = np.linspace(start, end, 50)\n    xs = np.concatenate([CX + R_OUTER * np.cos(angles), CX + R_INNER * np.cos(angles[::-1])])\n    ys = np.concatenate([CY + R_OUTER * np.sin(angles), CY + R_INNER * np.sin(angles[::-1])])\n    for i, (x, y) in enumerate(zip(xs, ys, strict=True)):\n        arcs_rows.append({\"entity\": e, \"x\": x, \"y\": y, \"order\": i})\n\narcs_df = pd.DataFrame(arcs_rows)\n\n# Track chord offsets within each entity arc\nsource_off = {}\ntarget_off = {}\nfor e in entities:\n    start, _, arc_len = entity_arcs[e]\n    frac = df[df[\"source\"] == e][\"value\"].sum() / entity_totals[e] if entity_totals[e] > 0 else 0.5\n    source_off[e] = start\n    target_off[e] = start + frac * arc_len\n\n# Visual hierarchy: top 30% of flows are \"major\"\nvalue_threshold = df[\"value\"].quantile(0.7)\n\n# Build chord polygons with quadratic bezier curves\nN_BEZ = 35\nchords_rows = []\ncenter = np.array([CX, CY])\n\nfor _, row in df.iterrows():\n    src, tgt, val = row[\"source\"], row[\"target\"], row[\"value\"]\n    _, _, s_len = entity_arcs[src]\n    _, _, t_len = entity_arcs[tgt]\n\n    sw = (val / entity_totals[src]) * s_len\n    tw = (val / entity_totals[tgt]) * t_len\n    sa = source_off[src]\n    source_off[src] += sw\n    ta = target_off[tgt]\n    target_off[tgt] += tw\n\n    t_param = np.linspace(0, 1, N_BEZ)\n    angles_s = np.linspace(sa, sa + sw, 10)\n    angles_t = np.linspace(ta, ta + tw, 10)\n\n    arc_s = np.column_stack([CX + R_CHORD * np.cos(angles_s), CY + R_CHORD * np.sin(angles_s)])\n    arc_t = np.column_stack([CX + R_CHORD * np.cos(angles_t), CY + R_CHORD * np.sin(angles_t)])\n\n    t1 = (1 - t_param) ** 2\n    t2 = 2 * (1 - t_param) * t_param\n    t3 = t_param**2\n    p_se = np.array([CX + R_CHORD * np.cos(sa + sw), CY + R_CHORD * np.sin(sa + sw)])\n    p_ts = np.array([CX + R_CHORD * np.cos(ta), CY + R_CHORD * np.sin(ta)])\n    p_te = np.array([CX + R_CHORD * np.cos(ta + tw), CY + R_CHORD * np.sin(ta + tw)])\n    p_ss = np.array([CX + R_CHORD * np.cos(sa), CY + R_CHORD * np.sin(sa)])\n    bez_1 = np.outer(t1, p_se) + np.outer(t2, center) + np.outer(t3, p_ts)\n    bez_2 = np.outer(t1, p_te) + np.outer(t2, center) + np.outer(t3, p_ss)\n    pts = np.vstack([arc_s, bez_1, arc_t, bez_2])\n\n    chord_id = f\"{src}->{tgt}\"\n    is_major = val >= value_threshold\n    for i in range(len(pts)):\n        chords_rows.append(\n            {\n                \"chord_id\": chord_id,\n                \"source\": src,\n                \"target\": tgt,\n                \"value\": int(val),\n                \"x\": pts[i, 0],\n                \"y\": pts[i, 1],\n                \"order\": i,\n                \"major\": is_major,\n                \"flow_label\": f\"{src} → {tgt}: {int(val)}k\",\n            }\n        )\n\nchords_df = pd.DataFrame(chords_rows)\n\n# Label positions outside arcs\nlabels_rows = []\nfor e in entities:\n    start, end, _ = entity_arcs[e]\n    mid = (start + end) / 2\n    r_label = R_OUTER + 45\n    deg = np.degrees(mid) % 360\n    labels_rows.append(\n        {\n            \"entity\": e,\n            \"x\": CX + r_label * np.cos(mid),\n            \"y\": CY + r_label * np.sin(mid),\n            \"align\": \"right\" if 90 < deg < 270 else \"left\",\n            \"total\": f\"({entity_totals[e]}k)\",\n        }\n    )\n\nlabels_df = pd.DataFrame(labels_rows)\n\n# Shared scales\nx_scale = alt.Scale(domain=[0, W])\ny_scale = alt.Scale(domain=[0, H])\n\n# Interactive selection: hover over a chord to highlight it\nhover = alt.selection_point(fields=[\"chord_id\"], on=\"pointerover\", empty=\"all\")\n\n# Outer arc ring\narcs_layer = (\n    alt.Chart(arcs_df)\n    .mark_line(filled=True, strokeWidth=0)\n    .encode(\n        x=alt.X(\"x:Q\", scale=x_scale, axis=None),\n        y=alt.Y(\"y:Q\", scale=y_scale, axis=None),\n        color=alt.Color(\"entity:N\", scale=color_scale, legend=None),\n        detail=\"entity:N\",\n        order=\"order:Q\",\n    )\n)\n\n# Chord encoding with interactive hover highlighting\nchord_base_encode = {\n    \"x\": alt.X(\"x:Q\", scale=x_scale, axis=None),\n    \"y\": alt.Y(\"y:Q\", scale=y_scale, axis=None),\n    \"color\": alt.Color(\"source:N\", scale=color_scale, legend=None),\n    \"detail\": \"chord_id:N\",\n    \"order\": \"order:Q\",\n    \"tooltip\": [\n        alt.Tooltip(\"source:N\", title=\"From\"),\n        alt.Tooltip(\"target:N\", title=\"To\"),\n        alt.Tooltip(\"value:Q\", title=\"Flow (thousands)\"),\n    ],\n}\n\n# Major chords (dominant flows) - higher opacity, highlighted on hover\nmajor_chords = (\n    alt.Chart(chords_df[chords_df[\"major\"]])\n    .mark_line(filled=True, strokeWidth=0)\n    .encode(**chord_base_encode, opacity=alt.condition(hover, alt.value(0.85), alt.value(0.6)))\n    .add_params(hover)\n)\n\n# Minor chords - lower opacity, but kept legible against the surface\nminor_chords = (\n    alt.Chart(chords_df[~chords_df[\"major\"]])\n    .mark_line(filled=True, strokeWidth=0)\n    .encode(**chord_base_encode, opacity=alt.condition(hover, alt.value(0.7), alt.value(0.45)))\n    .add_params(hover)\n)\n\n# Labels split by alignment\nlabel_enc = {\n    \"x\": alt.X(\"x:Q\", scale=x_scale, axis=None),\n    \"y\": alt.Y(\"y:Q\", scale=y_scale, axis=None),\n    \"text\": \"entity:N\",\n    \"color\": alt.Color(\"entity:N\", scale=color_scale, legend=None),\n}\n\nlabels_left = (\n    alt.Chart(labels_df[labels_df[\"align\"] == \"left\"])\n    .mark_text(fontSize=20, fontWeight=\"bold\", align=\"left\")\n    .encode(**label_enc)\n)\n\nlabels_right = (\n    alt.Chart(labels_df[labels_df[\"align\"] == \"right\"])\n    .mark_text(fontSize=20, fontWeight=\"bold\", align=\"right\")\n    .encode(**label_enc)\n)\n\n# Flow total annotations under entity labels (theme-adaptive tertiary ink)\ntotal_enc = {\n    \"x\": alt.X(\"x:Q\", scale=x_scale, axis=None),\n    \"y\": alt.Y(\"y:Q\", scale=y_scale, axis=None),\n    \"text\": \"total:N\",\n}\n\ntotals_left = (\n    alt.Chart(labels_df[labels_df[\"align\"] == \"left\"])\n    .mark_text(fontSize=14, align=\"left\", dy=18, color=INK_MUTED)\n    .encode(**total_enc)\n)\n\ntotals_right = (\n    alt.Chart(labels_df[labels_df[\"align\"] == \"right\"])\n    .mark_text(fontSize=14, align=\"right\", dy=18, color=INK_MUTED)\n    .encode(**total_enc)\n)\n\n# Top-flow callout (top-left corner)\ntop2 = df.nlargest(2, \"value\")\nannot_rows = []\nfor i, (_, r) in enumerate(top2.iterrows()):\n    annot_rows.append({\"x\": 40, \"y\": H - 60 - i * 26, \"text\": f\"{r['source']} → {r['target']}: {r['value']}k\"})\n\nannot_df = pd.DataFrame(annot_rows)\nannot_title = (\n    alt.Chart(pd.DataFrame([{\"x\": 40, \"y\": H - 28, \"text\": \"Top Flows\"}]))\n    .mark_text(fontSize=16, fontWeight=\"bold\", align=\"left\", color=INK)\n    .encode(x=alt.X(\"x:Q\", scale=x_scale, axis=None), y=alt.Y(\"y:Q\", scale=y_scale, axis=None), text=\"text:N\")\n)\ncenter_annot = (\n    alt.Chart(annot_df)\n    .mark_text(fontSize=14, align=\"left\", color=INK_SOFT)\n    .encode(x=alt.X(\"x:Q\", scale=x_scale, axis=None), y=alt.Y(\"y:Q\", scale=y_scale, axis=None), text=\"text:N\")\n)\n\n# Direct color-coded labeling replaces a detached legend: each region name is\n# printed in its own entity color next to its arc (see labels_left/right above),\n# which is the color key — cleaner than a redundant separate legend box.\n\n# Compose all layers\nchart = (\n    alt.layer(\n        minor_chords,\n        major_chords,\n        arcs_layer,\n        labels_left,\n        labels_right,\n        totals_left,\n        totals_right,\n        annot_title,\n        center_annot,\n    )\n    .properties(\n        width=535,\n        height=535,\n        padding={\"left\": 0, \"right\": 0, \"top\": 0, \"bottom\": 0},\n        title=alt.Title(\n            text=\"chord-basic · python · altair · anyplot.ai\",\n            subtitle=\"Migration between continents — dominant corridors highlighted\",\n            fontSize=22,\n            subtitleFontSize=13,\n            color=INK,\n            subtitleColor=INK_SOFT,\n            anchor=\"middle\",\n            offset=16,\n        ),\n    )\n    .configure(background=PAGE_BG, view=alt.ViewConfig(strokeWidth=0, stroke=None, fill=PAGE_BG))\n)\n\n# Save PNG (square target 2400x2400), then PAD-only to exact target. Never crop.\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\nchart.save(f\"plot-{THEME}.html\")\n\nTW, TH = 2400, 2400\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n"}