{"spec_id":"network-basic","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nnetwork-basic: Basic Network Graph\nLibrary: altair 6.2.2 | Python 3.13.14\nQuality: 87/100 | Updated: 2026-07-24\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 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# Imprint categorical palette (first 4 slots, canonical order — groups are abstract)\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\n\n# Set seed for reproducibility\nnp.random.seed(42)\n\n# Data: A small social network with 20 people in 4 communities\nnodes = [\n    {\"id\": 0, \"label\": \"Alice\", \"group\": \"Group A\"},\n    {\"id\": 1, \"label\": \"Bob\", \"group\": \"Group A\"},\n    {\"id\": 2, \"label\": \"Carol\", \"group\": \"Group A\"},\n    {\"id\": 3, \"label\": \"David\", \"group\": \"Group A\"},\n    {\"id\": 4, \"label\": \"Eve\", \"group\": \"Group A\"},\n    {\"id\": 5, \"label\": \"Frank\", \"group\": \"Group B\"},\n    {\"id\": 6, \"label\": \"Grace\", \"group\": \"Group B\"},\n    {\"id\": 7, \"label\": \"Henry\", \"group\": \"Group B\"},\n    {\"id\": 8, \"label\": \"Ivy\", \"group\": \"Group B\"},\n    {\"id\": 9, \"label\": \"Jack\", \"group\": \"Group B\"},\n    {\"id\": 10, \"label\": \"Kate\", \"group\": \"Group C\"},\n    {\"id\": 11, \"label\": \"Leo\", \"group\": \"Group C\"},\n    {\"id\": 12, \"label\": \"Mia\", \"group\": \"Group C\"},\n    {\"id\": 13, \"label\": \"Noah\", \"group\": \"Group C\"},\n    {\"id\": 14, \"label\": \"Olivia\", \"group\": \"Group C\"},\n    {\"id\": 15, \"label\": \"Paul\", \"group\": \"Group D\"},\n    {\"id\": 16, \"label\": \"Quinn\", \"group\": \"Group D\"},\n    {\"id\": 17, \"label\": \"Ryan\", \"group\": \"Group D\"},\n    {\"id\": 18, \"label\": \"Sara\", \"group\": \"Group D\"},\n    {\"id\": 19, \"label\": \"Tom\", \"group\": \"Group D\"},\n]\n\n# Edges: Friendship connections (within and between groups)\nedges = [\n    # Group A internal connections\n    (0, 1),\n    (0, 2),\n    (1, 2),\n    (1, 3),\n    (2, 4),\n    (3, 4),\n    # Group B internal connections\n    (5, 6),\n    (5, 7),\n    (6, 8),\n    (7, 8),\n    (7, 9),\n    (8, 9),\n    # Group C internal connections\n    (10, 11),\n    (10, 12),\n    (11, 13),\n    (12, 13),\n    (12, 14),\n    (13, 14),\n    # Group D internal connections\n    (15, 16),\n    (15, 17),\n    (16, 18),\n    (17, 18),\n    (17, 19),\n    (18, 19),\n    # Cross-group connections (bridges between communities)\n    (0, 5),\n    (4, 10),\n    (9, 15),\n    (14, 19),\n    (2, 6),\n    (8, 11),\n    (13, 16),\n]\n\n# Calculate spring layout (force-directed algorithm)\nn = len(nodes)\npositions = np.random.rand(n, 2) * 2 - 1\nk = 0.4  # Optimal distance parameter\n\nfor iteration in range(150):\n    displacement = np.zeros((n, 2))\n\n    # Repulsive forces between all node pairs\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\n    # Attractive forces for edges\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\n    # Apply displacement with cooling\n    cooling = 1 - iteration / 150\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.1 * cooling)\n\n# Rotate positions so the network's principal axis aligns with the wide (x)\n# canvas dimension. The spring layout otherwise tends to settle into a\n# diagonal band, which — even after per-axis normalization — leaves large\n# empty triangular regions in two corners of the landscape canvas.\ncentered = positions - positions.mean(axis=0)\ncov = np.cov(centered.T)\neigvals, eigvecs = np.linalg.eigh(cov)\nprincipal = eigvecs[:, np.argmax(eigvals)]\nangle = np.arctan2(principal[1], principal[0])\nrotation = np.array([[np.cos(-angle), -np.sin(-angle)], [np.sin(-angle), np.cos(-angle)]])\npositions = centered @ rotation.T\n\n# Normalize positions to [0.1, 0.9] range\npos_min = positions.min(axis=0)\npos_max = positions.max(axis=0)\npositions = (positions - pos_min) / (pos_max - pos_min + 1e-6) * 0.8 + 0.1\npos = {node[\"id\"]: positions[i] for i, node in enumerate(nodes)}\n\n# Calculate node degrees for sizing\ndegrees = {node[\"id\"]: 0 for node in nodes}\nfor src, tgt in edges:\n    degrees[src] += 1\n    degrees[tgt] += 1\n\n# Anti-collision label layout: start each label just below its node, in\n# pixel space (the 620x320 view is much wider than tall, so a fixed offset\n# in data units isn't visually isotropic), then run a short repulsion pass\n# so labels that would otherwise collide (Jack/Leo, Noah/Olivia, the\n# Quinn/Ryan/Sara chain, ...) push apart from each other in both x and y\n# instead of overlapping. A spring-back pull toward each label's own base\n# offset (plus a hard clamp on drift distance) keeps every label anchored\n# close to its own node, so it never reads as ambiguous or lands on top of\n# a neighboring marker.\ncoords = np.array([pos[node[\"id\"]] for node in nodes])\nVIEW_W, VIEW_H, DOMAIN_SPAN = 620, 320, 1.1\npx_per_x, px_per_y = VIEW_W / DOMAIN_SPAN, VIEW_H / DOMAIN_SPAN\nnode_px = coords * np.array([px_per_x, px_per_y])\nbase_px_offset = np.tile(np.array([0.0, -24.0]), (n, 1))\nlabel_px_offset = base_px_offset.copy()\nmin_label_dist_px = 62.0\nmax_drift_px = 46.0\nfor _ in range(60):\n    label_px = node_px + label_px_offset\n    disp = np.zeros((n, 2))\n    for i in range(n):\n        for j in range(i + 1, n):\n            diff = label_px[i] - label_px[j]\n            dist = max(np.linalg.norm(diff), 0.5)\n            if dist < min_label_dist_px:\n                push = (min_label_dist_px - dist) * 0.5 * (diff / dist)\n                disp[i] += push\n                disp[j] -= push\n    label_px_offset += disp\n    label_px_offset += (base_px_offset - label_px_offset) * 0.05\n    drift_norm = np.linalg.norm(label_px_offset, axis=1, keepdims=True)\n    too_far = drift_norm[:, 0] > max_drift_px\n    if too_far.any():\n        label_px_offset[too_far] = label_px_offset[too_far] / drift_norm[too_far] * max_drift_px\nlabel_offset = label_px_offset / np.array([px_per_x, px_per_y])\nlabel_dx = label_offset[:, 0]\nlabel_dy = label_offset[:, 1]\n\n# Create nodes dataframe. label_x/label_y carry the anti-collision offset.\nnodes_df = pd.DataFrame(\n    [\n        {\n            \"id\": node[\"id\"],\n            \"label\": node[\"label\"],\n            \"group\": node[\"group\"],\n            \"x\": pos[node[\"id\"]][0],\n            \"y\": pos[node[\"id\"]][1],\n            \"label_x\": pos[node[\"id\"]][0] + label_dx[i],\n            \"label_y\": pos[node[\"id\"]][1] + label_dy[i],\n            \"degree\": degrees[node[\"id\"]],\n        }\n        for i, node in enumerate(nodes)\n    ]\n)\n\n# Create edges dataframe with coordinates for each edge segment\nedges_df = pd.DataFrame(\n    [{\"edge_id\": i, \"x\": pos[src][0], \"y\": pos[src][1], \"order\": 0} for i, (src, _) in enumerate(edges)]\n    + [{\"edge_id\": i, \"x\": pos[tgt][0], \"y\": pos[tgt][1], \"order\": 1} for i, (_, tgt) in enumerate(edges)]\n)\n\n# Draw edges as lines (muted, theme-adaptive — structural, not data-categorical)\nedges_chart = (\n    alt.Chart(edges_df)\n    .mark_line(strokeWidth=1.5, opacity=0.45, color=INK_MUTED)\n    .encode(\n        x=alt.X(\"x:Q\", scale=alt.Scale(domain=[-0.05, 1.05]), axis=None),\n        y=alt.Y(\"y:Q\", scale=alt.Scale(domain=[-0.05, 1.05]), axis=None),\n        detail=\"edge_id:N\",\n        order=\"order:O\",\n    )\n)\n\n# Draw nodes as points (size based on degree; PAGE_BG stroke halos each node\n# against overlapping edges/labels and stays correct in both themes)\nnodes_chart = (\n    alt.Chart(nodes_df)\n    .mark_circle(stroke=PAGE_BG, strokeWidth=3, opacity=0.95)\n    .encode(\n        x=alt.X(\"x:Q\", scale=alt.Scale(domain=[-0.05, 1.05]), axis=None),\n        y=alt.Y(\"y:Q\", scale=alt.Scale(domain=[-0.05, 1.05]), axis=None),\n        size=alt.Size(\"degree:Q\", scale=alt.Scale(domain=[2, 6], range=[150, 450]), legend=None),\n        color=alt.Color(\n            \"group:N\",\n            scale=alt.Scale(domain=[\"Group A\", \"Group B\", \"Group C\", \"Group D\"], range=IMPRINT_PALETTE[:4]),\n            legend=alt.Legend(title=\"Communities\", symbolSize=300),\n        ),\n        tooltip=[\"label:N\", \"group:N\", \"degree:Q\"],\n    )\n)\n\n# Draw node labels (label_x/label_y already carry the anti-collision offset)\nlabels_chart = (\n    alt.Chart(nodes_df)\n    .mark_text(fontSize=16, fontWeight=\"bold\", color=INK)\n    .encode(x=alt.X(\"label_x:Q\"), y=alt.Y(\"label_y:Q\"), text=\"label:N\")\n)\n\n# Combine layers with theme-adaptive chrome\nchart = (\n    (edges_chart + nodes_chart + labels_chart)\n    .properties(\n        width=620,  # inner-view — see prompts/library/altair.md \"Canvas — hard rule\"\n        height=320,\n        background=PAGE_BG,\n        title=alt.Title(\"network-basic · python · altair · anyplot.ai\", fontSize=16),\n    )\n    .configure_view(fill=PAGE_BG, stroke=None, continuousWidth=620, continuousHeight=320)\n    .configure_title(color=INK)\n    .configure_legend(\n        fillColor=ELEVATED_BG,\n        strokeColor=INK_SOFT,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        labelFontSize=10,\n        titleFontSize=10,\n    )\n)\n\n# Save as PNG and HTML\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\nchart.save(f\"plot-{THEME}.html\")\n\n# Pad the saved PNG up to the exact canonical target (3200x1800). Never crop —\n# cropping would clip title/legend text at the edges. See \"Canvas\" in\n# prompts/library/altair.md.\nTW, TH = 3200, 1800\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}x{_h}, exceeds target {TW}x{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"}