{"spec_id":"network-force-directed","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nnetwork-force-directed: Force-Directed Graph\nLibrary: altair 6.2.2 | Python 3.13.14\nQuality: 88/100 | Updated: 2026-07-01\n\"\"\"\n\nimport os\nimport sys\n\n\n# Prevent this file from shadowing the installed altair package (same filename as the library).\n_here = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if not (p and os.path.abspath(p) == _here)]\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\n\n# Theme 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\"\nEDGE_COLOR = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\nBRIDGE_STROKE = \"#DDCC77\"  # amber accent for cross-community bridge nodes\n\n# Imprint categorical palette (first series is always #009E73)\nIMPRINT = [\"#009E73\", \"#C475FD\", \"#4467A3\"]\n\n# Data: a 50-node organisational network with three communities\nnp.random.seed(42)\n\ncommunity_sizes = [18, 17, 15]\ncommunity_names = [\"Engineering\", \"Marketing\", \"Sales\"]\n\nnodes = []\nnode_id = 0\nfor comm_idx, size in enumerate(community_sizes):\n    for _ in range(size):\n        nodes.append({\"id\": node_id, \"community\": community_names[comm_idx]})\n        node_id += 1\n\n# Intra-community edges (dense) + inter-community bridges (sparse)\nintra_edges = []\nfor start, end in [(0, 18), (18, 35), (35, 50)]:\n    for i in range(start, end):\n        for j in range(i + 1, end):\n            if np.random.random() < 0.3:\n                intra_edges.append((i, j))\n\nbridge_edges = [(0, 18), (5, 20), (10, 25), (18, 35), (22, 40), (30, 45), (8, 38), (15, 48)]\nedges = intra_edges + bridge_edges\n\n# Fruchterman-Reingold force-directed layout\nn = len(nodes)\npositions = np.random.rand(n, 2) * 2 - 1\nk = 0.5\niterations = 200\n\nfor iteration in range(iterations):\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            repulsive = (k * k / dist) * (diff / dist)\n            displacement[i] += repulsive\n            displacement[j] -= repulsive\n    for src, tgt in edges:\n        diff = positions[src] - positions[tgt]\n        dist = max(np.linalg.norm(diff), 0.01)\n        attractive = (dist * dist / k) * (diff / dist)\n        displacement[src] -= attractive\n        displacement[tgt] += attractive\n    temperature = 1 - iteration / iterations\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.15 * temperature)\n\n# Normalize to 95% of canvas to maximize space utilization\npos_min = positions.min(axis=0)\npos_max = positions.max(axis=0)\npositions = (positions - pos_min) / (pos_max - pos_min + 1e-6) * 0.95 + 0.025\n\n# Node-level summary\ndegrees = {node[\"id\"]: 0 for node in nodes}\nfor src, tgt in edges:\n    degrees[src] += 1\n    degrees[tgt] += 1\n\n# Bridge nodes have cross-community connections\nbridge_node_ids = set()\nfor src, tgt in bridge_edges:\n    bridge_node_ids.add(src)\n    bridge_node_ids.add(tgt)\n\nnode_df = pd.DataFrame(\n    {\n        \"id\": [node[\"id\"] for node in nodes],\n        \"x\": positions[:, 0],\n        \"y\": positions[:, 1],\n        \"community\": [node[\"community\"] for node in nodes],\n        \"degree\": [degrees[node[\"id\"]] for node in nodes],\n        \"is_bridge\": [node[\"id\"] in bridge_node_ids for node in nodes],\n    }\n)\n\n# Edge segments (long-form, two rows per edge)\nedge_data = []\nfor src, tgt in edges:\n    edge_data.append({\"edge_id\": f\"{src}-{tgt}\", \"x\": positions[src][0], \"y\": positions[src][1], \"order\": 0})\n    edge_data.append({\"edge_id\": f\"{src}-{tgt}\", \"x\": positions[tgt][0], \"y\": positions[tgt][1], \"order\": 1})\nedge_df = pd.DataFrame(edge_data)\n\n# Label only the four most-connected nodes to avoid clutter\nhub_df = node_df.nlargest(4, \"degree\").copy()\nhub_df[\"label\"] = \"Hub \" + hub_df[\"id\"].astype(str)\n\n# Edges layer — slightly thicker, compensated with lower opacity\nedges_chart = (\n    alt.Chart(edge_df)\n    .mark_line(strokeWidth=1.3, opacity=0.45)\n    .encode(\n        x=alt.X(\"x:Q\", axis=None),\n        y=alt.Y(\"y:Q\", axis=None),\n        detail=\"edge_id:N\",\n        order=\"order:O\",\n        color=alt.value(EDGE_COLOR),\n    )\n)\n\n# Nodes layer — bridge nodes highlighted with amber stroke\nnodes_chart = (\n    alt.Chart(node_df)\n    .mark_circle(strokeWidth=1.8, opacity=0.95)\n    .encode(\n        x=alt.X(\"x:Q\", axis=None),\n        y=alt.Y(\"y:Q\", axis=None),\n        size=alt.Size(\n            \"degree:Q\",\n            scale=alt.Scale(range=[60, 400]),\n            legend=alt.Legend(title=\"Connections\", titleFontSize=10, labelFontSize=10),\n        ),\n        color=alt.Color(\n            \"community:N\",\n            scale=alt.Scale(domain=community_names, range=IMPRINT),\n            legend=alt.Legend(title=\"Team\", titleFontSize=10, labelFontSize=10, symbolSize=100),\n        ),\n        stroke=alt.condition(\"datum.is_bridge\", alt.value(BRIDGE_STROKE), alt.value(PAGE_BG)),\n        tooltip=[alt.Tooltip(\"community:N\", title=\"Team\"), alt.Tooltip(\"degree:Q\", title=\"Connections\")],\n    )\n)\n\n# Hub labels — separate layer per hub enables per-label dx/dy to fan out dense clusters\n_hub_offsets = {0: (-15, -18), 10: (15, -22), 14: (0, -18), 45: (0, -18)}\nhub_label_layers = []\nfor hid in hub_df[\"id\"].tolist():\n    dx_off, dy_off = _hub_offsets.get(hid, (0, -18))\n    hub_label_layers.append(\n        alt.Chart(hub_df[hub_df[\"id\"] == hid])\n        .mark_text(fontSize=11, fontWeight=\"bold\", color=INK, dx=dx_off, dy=dy_off)\n        .encode(x=alt.X(\"x:Q\", axis=None), y=alt.Y(\"y:Q\", axis=None), text=\"label:N\")\n    )\n\nchart = (\n    alt.layer(edges_chart, nodes_chart, *hub_label_layers)\n    .properties(\n        width=620,\n        height=320,\n        padding={\"left\": 0, \"right\": 0, \"top\": 0, \"bottom\": 0},\n        background=PAGE_BG,\n        title=alt.Title(\n            \"network-force-directed · python · altair · anyplot.ai\", fontSize=16, color=INK, anchor=\"start\", offset=10\n        ),\n    )\n    .configure_view(fill=PAGE_BG, strokeWidth=0, continuousWidth=620, continuousHeight=320)\n    .configure_legend(\n        fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK, padding=8, cornerRadius=4\n    )\n)\n\n# Save PNG and pad to exact 3200 × 1800 landscape target\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\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}×{_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\nchart.save(f\"plot-{THEME}.html\")\n"}