{"spec_id":"network-force-directed","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\nnetwork-force-directed: Force-Directed Graph\nLibrary: letsplot 4.11.0 | Python 3.13.14\nQuality: 86/100 | Updated: 2026-07-01\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom lets_plot import (\n    LetsPlot,\n    aes,\n    coord_fixed,\n    element_blank,\n    element_rect,\n    element_text,\n    geom_point,\n    geom_segment,\n    ggplot,\n    ggsize,\n    labs,\n    layer_tooltips,\n    scale_alpha_identity,\n    scale_color_manual,\n    scale_size_identity,\n    scale_x_continuous,\n    scale_y_continuous,\n    theme,\n)\nfrom lets_plot.export import ggsave\n\n\nLetsPlot.setup_html()\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 = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\n\n# Imprint palette — 3 community hues (positions 1–3)\nIMPRINT = [\"#009E73\", \"#C475FD\", \"#4467A3\"]\n\n# Data: 50-node social network with 3 communities\nnp.random.seed(42)\ncommunity_sizes = [18, 17, 15]\ncommunity_names = [\"Engineering\", \"Marketing\", \"Sales\"]\nnodes = []\nnode_id = 0\nfor comm_idx, size in enumerate(community_sizes):\n    for _ in range(size):\n        nodes.append({\"id\": node_id, \"community\": comm_idx, \"community_name\": community_names[comm_idx]})\n        node_id += 1\n\n# Intra-community edges (dense within each community)\nedges = []\nranges = [(0, 18), (18, 35), (35, 50)]\nfor start, end in ranges:\n    for i in range(start, end):\n        for j in range(i + 1, end):\n            if np.random.random() < 0.3:\n                weight = np.random.uniform(0.5, 1.0)\n                edges.append((i, j, weight))\n\n# Inter-community bridge edges (lighter weights)\nbridges = [(0, 18), (5, 20), (10, 25), (18, 35), (22, 40), (30, 45), (8, 38), (15, 48)]\nfor src, tgt in bridges:\n    edges.append((src, tgt, np.random.uniform(0.2, 0.5)))\n\n# Force-directed layout (Fruchterman-Reingold)\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 positions to [0.05, 0.95]\npos_min = positions.min(axis=0)\npos_max = positions.max(axis=0)\npositions = (positions - pos_min) / (pos_max - pos_min + 1e-6) * 0.9 + 0.05\npos = {node[\"id\"]: positions[i] for i, node in enumerate(nodes)}\n\n# Node degrees\ndegrees = {node[\"id\"]: 0 for node in nodes}\nfor src, tgt, _ in edges:\n    degrees[src] += 1\n    degrees[tgt] += 1\n\n# Edge DataFrame — weight drives thickness and opacity\nedges_df = pd.DataFrame(\n    {\n        \"x\": [pos[src][0] for src, tgt, _ in edges],\n        \"y\": [pos[src][1] for src, tgt, _ in edges],\n        \"xend\": [pos[tgt][0] for src, tgt, _ in edges],\n        \"yend\": [pos[tgt][1] for src, tgt, _ in edges],\n        \"weight\": [w for _, _, w in edges],\n        \"edge_size\": [0.3 + w * 0.9 for _, _, w in edges],\n        \"edge_alpha\": [0.15 + w * 0.35 for _, _, w in edges],\n    }\n)\n\n# Node DataFrame — size scales with degree centrality\nnodes_df = pd.DataFrame(\n    {\n        \"x\": [pos[node[\"id\"]][0] for node in nodes],\n        \"y\": [pos[node[\"id\"]][1] for node in nodes],\n        \"Team\": [node[\"community_name\"] for node in nodes],\n        \"Connections\": [degrees[node[\"id\"]] for node in nodes],\n        \"size\": [7 + degrees[node[\"id\"]] * 1.2 for node in nodes],\n    }\n)\n\n# Title — len(\"network-force-directed · python · letsplot · anyplot.ai\") = 55 < 67, no scaling\nTITLE = \"network-force-directed · python · letsplot · anyplot.ai\"\ntitle_size = 16\n\n# Plot\nplot = (\n    ggplot()\n    + geom_segment(\n        aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\", size=\"edge_size\", alpha=\"edge_alpha\"),\n        data=edges_df,\n        color=EDGE_COLOR,\n        tooltips=\"none\",\n    )\n    + geom_point(\n        aes(x=\"x\", y=\"y\", color=\"Team\", size=\"size\"),\n        data=nodes_df,\n        stroke=1.0,\n        alpha=0.92,\n        tooltips=layer_tooltips().line(\"Team: @Team\").line(\"Connections: @Connections\"),\n    )\n    + scale_color_manual(values=IMPRINT, name=\"Team\")\n    + scale_size_identity(guide=\"none\")\n    + scale_alpha_identity(guide=\"none\")\n    + coord_fixed(ratio=1)\n    + scale_x_continuous(limits=(-0.05, 1.05))\n    + scale_y_continuous(limits=(-0.05, 1.05))\n    + labs(title=TITLE)\n    + ggsize(600, 600)\n    + theme(\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        panel_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        plot_title=element_text(size=title_size, face=\"bold\", color=INK),\n        axis_title=element_blank(),\n        axis_text=element_blank(),\n        axis_ticks=element_blank(),\n        axis_line=element_blank(),\n        panel_grid=element_blank(),\n        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),\n        legend_text=element_text(size=10, color=INK_SOFT),\n        legend_title=element_text(size=12, color=INK),\n        legend_position=(0.02, 0.78),\n        legend_justification=(0, 1),\n    )\n)\n\n# Save — ggsize(600, 600) × scale=4 → 2400 × 2400 px\nggsave(plot, f\"plot-{THEME}.png\", path=\".\", scale=4)\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}