{"spec_id":"network-basic","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nnetwork-basic: Basic Network Graph\nLibrary: pygal 3.1.3 | Python 3.13.14\nQuality: 94/100 | Updated: 2026-07-24\n\"\"\"\n\nimport os\nimport sys\n\n\n# Script filename shadows the installed `pygal` package when run as `python pygal.py`;\n# dropping the script directory from sys.path lets the real package resolve.\nsys.path.pop(0)\n\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\n\n\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\nIMPRINT = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\")\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\": 0},\n    {\"id\": 1, \"label\": \"Bob\", \"group\": 0},\n    {\"id\": 2, \"label\": \"Carol\", \"group\": 0},\n    {\"id\": 3, \"label\": \"David\", \"group\": 0},\n    {\"id\": 4, \"label\": \"Eve\", \"group\": 0},\n    {\"id\": 5, \"label\": \"Frank\", \"group\": 1},\n    {\"id\": 6, \"label\": \"Grace\", \"group\": 1},\n    {\"id\": 7, \"label\": \"Henry\", \"group\": 1},\n    {\"id\": 8, \"label\": \"Ivy\", \"group\": 1},\n    {\"id\": 9, \"label\": \"Jack\", \"group\": 1},\n    {\"id\": 10, \"label\": \"Kate\", \"group\": 2},\n    {\"id\": 11, \"label\": \"Leo\", \"group\": 2},\n    {\"id\": 12, \"label\": \"Mia\", \"group\": 2},\n    {\"id\": 13, \"label\": \"Noah\", \"group\": 2},\n    {\"id\": 14, \"label\": \"Olivia\", \"group\": 2},\n    {\"id\": 15, \"label\": \"Paul\", \"group\": 3},\n    {\"id\": 16, \"label\": \"Quinn\", \"group\": 3},\n    {\"id\": 17, \"label\": \"Ryan\", \"group\": 3},\n    {\"id\": 18, \"label\": \"Sara\", \"group\": 3},\n    {\"id\": 19, \"label\": \"Tom\", \"group\": 3},\n]\n\n# Edges: Friendship connections (within and between groups)\nedges = [\n    # Group 0 internal connections\n    (0, 1),\n    (0, 2),\n    (1, 2),\n    (1, 3),\n    (2, 4),\n    (3, 4),\n    # Group 1 internal connections\n    (5, 6),\n    (5, 7),\n    (6, 8),\n    (7, 8),\n    (7, 9),\n    (8, 9),\n    # Group 2 internal connections\n    (10, 11),\n    (10, 12),\n    (11, 13),\n    (12, 13),\n    (12, 14),\n    (13, 14),\n    # Group 3 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)\n\n# Initialize positions clustered by group for better community structure (centered)\ngroup_centers = {0: (-0.4, 0.4), 1: (0.4, 0.4), 2: (-0.4, -0.4), 3: (0.4, -0.4)}\npositions = np.zeros((n, 2))\nfor i, node in enumerate(nodes):\n    cx, cy = group_centers[node[\"group\"]]\n    positions[i] = [cx + np.random.rand() * 0.25 - 0.125, cy + np.random.rand() * 0.25 - 0.125]\n\nk = 0.35  # Optimal distance parameter (slightly smaller for tighter clusters)\n\nfor iteration in range(200):\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 (stronger to keep communities tight)\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) * 1.2\n        displacement[src] -= force\n        displacement[tgt] += force\n\n    # Apply displacement with cooling\n    cooling = 1 - iteration / 200\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.08 * cooling)\n\n# Normalize positions to [0, 1], then stretch anisotropically to fill the\n# 16:9 landscape canvas (equal x/y ranges left the left/right thirds empty)\npos_min = positions.min(axis=0)\npos_max = positions.max(axis=0)\npositions = (positions - pos_min) / (pos_max - pos_min + 1e-6)\npositions[:, 0] = positions[:, 0] * 12 + 2  # X: [2, 14] of a (0, 16) xrange\npositions[:, 1] = positions[:, 1] * 8.4 + 0.3  # Y: [0.3, 8.7] of a (0, 9) range\npos = {node[\"id\"]: positions[i] for i, node in enumerate(nodes)}\n\n# Calculate node degrees to encode connection count as dot radius\ndegrees = {node[\"id\"]: 0 for node in nodes}\nfor src, tgt in edges:\n    degrees[src] += 1\n    degrees[tgt] += 1\nNODE_BASE_R = 12\nNODE_R_PER_DEGREE = 5\n\n# Custom style: theme-adaptive chrome, Imprint data colors\n# Edge series come first (intra-community, then cross-community bridges) so\n# their colors land in the neutral gray slots ahead of the 4 community colors\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=(\"#888888\", \"#BBBBBB\") + IMPRINT,\n    # pygal auto-picks black/white per-series for value/label text based on\n    # series color brightness, which puts near-black text on the near-black\n    # dark background. Force it to the theme-adaptive ink color instead.\n    value_colors=(INK,) * 6,\n    title_font_size=66,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    # Also sets the label's pixel offset from its node (see label placement\n    # below) - kept large enough to clear the biggest (degree-scaled) nodes.\n    value_font_size=40,\n    value_label_font_size=32,\n    stroke_width=2.5,\n    opacity=1,\n    opacity_hover=1,\n)\n\n# Create XY chart with centered layout\nchart = pygal.XY(\n    width=3200,\n    height=1800,\n    style=custom_style,\n    title=\"network-basic · python · pygal · anyplot.ai\",\n    show_legend=True,\n    x_title=\"\",\n    y_title=\"\",\n    show_x_guides=False,\n    show_y_guides=False,\n    show_x_labels=False,\n    show_y_labels=False,\n    stroke=True,\n    dots_size=NODE_BASE_R,\n    stroke_style={\"width\": 2, \"linecap\": \"butt\"},\n    legend_at_bottom=True,\n    legend_at_bottom_columns=4,\n    range=(0, 9),\n    xrange=(0, 16),\n    print_labels=True,\n    print_values=False,\n)\n\n# Split edges into intra-community links and cross-community \"bridges\", and\n# render each as its own series (solid vs. dashed/thinner) so the bridging\n# structure reads visually instead of as a uniform mesh of gray lines.\n# Each edge is represented as two points connected, with None to break between edges\nintra_edges = [(s, t) for s, t in edges if nodes[s][\"group\"] == nodes[t][\"group\"]]\nbridge_edges = [(s, t) for s, t in edges if nodes[s][\"group\"] != nodes[t][\"group\"]]\n\nintra_points = []\nfor src, tgt in intra_edges:\n    intra_points.append(tuple(pos[src]))\n    intra_points.append(tuple(pos[tgt]))\n    intra_points.append(None)  # Break the line for next edge\n\nbridge_points = []\nfor src, tgt in bridge_edges:\n    bridge_points.append(tuple(pos[src]))\n    bridge_points.append(tuple(pos[tgt]))\n    bridge_points.append(None)  # Break the line for next edge\n\n# Add edges (using None title to exclude from legend)\nchart.add(None, intra_points, stroke=True, show_dots=False, fill=False, stroke_style={\"width\": 2.5, \"linecap\": \"round\"})\nchart.add(\n    None,\n    bridge_points,\n    stroke=True,\n    show_dots=False,\n    fill=False,\n    stroke_style={\"width\": 1.6, \"linecap\": \"round\", \"dasharray\": \"10, 8\"},\n)\n\n# Group nodes by community; dot radius scales with degree (connection count)\ngroup_names = [\"Close Friends\", \"Coworkers\", \"Neighbors\", \"College Friends\"]\nfor group_idx in range(4):\n    group_nodes = [node for node in nodes if node[\"group\"] == group_idx]\n    node_points = []\n    for node in group_nodes:\n        x, y = pos[node[\"id\"]]\n        degree = degrees[node[\"id\"]]\n        radius = NODE_BASE_R + degree * NODE_R_PER_DEGREE\n        node_points.append({\"value\": (x, y), \"label\": node[\"label\"], \"node\": {\"r\": radius}})\n    chart.add(group_names[group_idx], node_points, stroke=False)\n\n# Save themed outputs\nchart.render_to_file(f\"plot-{THEME}.svg\")\nchart.render_to_png(f\"plot-{THEME}.png\")\n\nwith open(f\"plot-{THEME}.html\", \"wb\") as f:\n    f.write(chart.render())\n"}