{"spec_id":"network-basic","library":"plotly","language":"python","code":"\"\"\" anyplot.ai\nnetwork-basic: Basic Network Graph\nLibrary: plotly 6.9.0 | Python 3.13.14\nQuality: 94/100 | Updated: 2026-07-24\n\"\"\"\n\nimport os\nimport sys\n\n\n# Prevent this file from shadowing the installed plotly package\n_here = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if os.path.abspath(p) != _here]\ndel _here\n\nimport numpy as np\nimport plotly.graph_objects as go\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\"\n\n# Imprint palette — first series always #009E73\nGROUP_COLORS = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\"]\nGROUP_NAMES = [\"Core\", \"Services\", \"UI Components\", \"Tooling\"]\n\n# Data: software package dependency graph for a mid-sized monorepo,\n# 20 packages across 4 architectural layers. The layout below (spring\n# simulation + pixel declutter) is fully deterministic — no RNG involved.\n# Each node carries both the full package name (used in the hover tooltip,\n# the annotation, and the docs a reader would actually search for) and a\n# short on-marker label — the full names run long enough (up to 18 chars)\n# that fitting all 20 as external text without collisions isn't viable at\n# this canvas size, so the marker itself shows a short mnemonic instead,\n# matching how the original short-first-name design kept labels legible.\nnodes = [\n    # Core (5 packages — low-level, widely depended upon)\n    {\"id\": 0, \"label\": \"core-utils\", \"short\": \"core\", \"group\": 0},\n    {\"id\": 1, \"label\": \"type-defs\", \"short\": \"types\", \"group\": 0},\n    {\"id\": 2, \"label\": \"config-loader\", \"short\": \"config\", \"group\": 0},\n    {\"id\": 3, \"label\": \"logger\", \"short\": \"logger\", \"group\": 0},\n    {\"id\": 4, \"label\": \"event-bus\", \"short\": \"events\", \"group\": 0},\n    # Services (6 packages — API/backend layer)\n    {\"id\": 5, \"label\": \"http-client\", \"short\": \"http\", \"group\": 1},\n    {\"id\": 6, \"label\": \"auth-service\", \"short\": \"auth\", \"group\": 1},\n    {\"id\": 7, \"label\": \"cache-layer\", \"short\": \"cache\", \"group\": 1},\n    {\"id\": 8, \"label\": \"rate-limiter\", \"short\": \"rate\", \"group\": 1},\n    {\"id\": 9, \"label\": \"graphql-gateway\", \"short\": \"gql\", \"group\": 1},\n    {\"id\": 10, \"label\": \"webhook-dispatcher\", \"short\": \"hooks\", \"group\": 1},\n    # UI Components (4 packages — frontend layer)\n    {\"id\": 11, \"label\": \"button-kit\", \"short\": \"button\", \"group\": 2},\n    {\"id\": 12, \"label\": \"form-fields\", \"short\": \"forms\", \"group\": 2},\n    {\"id\": 13, \"label\": \"chart-widgets\", \"short\": \"charts\", \"group\": 2},\n    {\"id\": 14, \"label\": \"layout-grid\", \"short\": \"grid\", \"group\": 2},\n    # Tooling (5 packages — build/dev tooling)\n    {\"id\": 15, \"label\": \"build-cli\", \"short\": \"build\", \"group\": 3},\n    {\"id\": 16, \"label\": \"lint-rules\", \"short\": \"lint\", \"group\": 3},\n    {\"id\": 17, \"label\": \"test-runner\", \"short\": \"tests\", \"group\": 3},\n    {\"id\": 18, \"label\": \"bundler-plugin\", \"short\": \"bndl\", \"group\": 3},\n    {\"id\": 19, \"label\": \"release-bot\", \"short\": \"rel\", \"group\": 3},\n]\n\nedges = [\n    # Core — foundational packages depend on each other\n    (0, 1),\n    (0, 2),\n    (0, 3),\n    (0, 4),\n    (3, 2),\n    # Services — API layer internal dependencies\n    (5, 6),\n    (5, 7),\n    (5, 8),\n    (6, 9),\n    (7, 9),\n    (8, 10),\n    (9, 10),\n    # UI Components — frontend internal dependencies\n    (11, 12),\n    (11, 14),\n    (12, 13),\n    # Tooling — dev tooling internal dependencies\n    (15, 16),\n    (15, 17),\n    (15, 18),\n    (18, 19),\n    # Cross-layer dependencies (core-utils is the most depended-upon package)\n    (0, 5),  # Services depend on core-utils\n    (0, 11),  # UI depends on core-utils\n    (0, 15),  # Tooling depends on core-utils\n    (4, 9),  # graphql-gateway subscribes to event-bus\n    (4, 13),  # chart-widgets subscribes to event-bus\n    (1, 12),  # form-fields depends on type-defs\n    (3, 17),  # test-runner depends on logger\n    (5, 13),  # chart-widgets fetches data via http-client\n]\n\n# Spring layout — nodes start clustered near their group's compass position\n# so Fruchterman-Reingold only has to refine local structure, not untangle\n# an interleaved ring. This keeps the 4 layers visually separated with the\n# cross-layer bridges reading as clean long edges.\nn = len(nodes)\nn_groups = len(GROUP_NAMES)\ngroup_sizes = {g: sum(1 for node in nodes if node[\"group\"] == g) for g in range(n_groups)}\n# Custom compass layout (not an even circle): Core top, Services left,\n# UI bottom, Tooling lower-right — keeps the upper-right quadrant clear\n# for the legend.\ngroup_angles = np.radians([100, 190, 260, 335])\ngroup_centers = np.column_stack([np.cos(group_angles), np.sin(group_angles)]) * 1.3\n\ngroup_counts = dict.fromkeys(range(n_groups), 0)\npositions = np.zeros((n, 2))\nfor i, node in enumerate(nodes):\n    g = node[\"group\"]\n    seat = group_counts[g]\n    group_counts[g] += 1\n    seat_angle = 2 * np.pi * seat / group_sizes[g]\n    positions[i] = group_centers[g] + np.array([np.cos(seat_angle), np.sin(seat_angle)]) * 0.34\n\nk = 0.6\nfor iteration in range(400):\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            force = (k * k / dist) * (diff / dist)\n            displacement[i] += force\n            displacement[j] -= force\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    cooling = 1 - iteration / 400\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.12 * cooling)\n\npos_min = positions.min(axis=0)\npos_max = positions.max(axis=0)\npositions = (positions - pos_min) / (pos_max - pos_min + 1e-6)\n\n# Node degrees (needed for both marker sizing and the declutter pass below)\ndegrees = {node[\"id\"]: 0 for node in nodes}\nfor src, tgt in edges:\n    degrees[src] += 1\n    degrees[tgt] += 1\n\n# Pixel-space declutter — the compass force-directed layout above gets the\n# topology and cluster separation right but a tightly interconnected group\n# (e.g. Services) can still leave node circles touching. Resolve that\n# directly against the plot's actual pixel geometry before saving. Labels\n# live *inside* the markers (see the node trace below), so the footprint\n# here is just the marker circle — no separate label-width bookkeeping,\n# and no need to ever rescale the layout afterward (a rescale would shrink\n# node spacing without shrinking the fixed-px marker circles, silently\n# reintroducing the exact overlap the declutter just resolved).\n# Canvas is square (2400x2400 final) — a network graph has no preferred\n# horizontal axis.\nPLOT_W_PX, PLOT_H_PX = 340, 410  # xaxis domain=[0, 0.74] of the 460x410 plot area\n\n# Data coordinates ARE pixel coordinates in this plot area (1 data unit =\n# 1 output px at width=600/height=600 before the final scale=4 upsample).\npositions[:, 0] *= PLOT_W_PX\npositions[:, 1] *= PLOT_H_PX\npx_positions = positions\n\n# Single source of truth for marker sizes — degree 1 gets a slightly larger\n# floor (was 30) so longer mnemonics on small-degree nodes ('rate', 'bndl')\n# have enough room inside the circle.\nmarker_sizes = {node[\"id\"]: 34 + degrees[node[\"id\"]] * 8 for node in nodes}\nmarker_r_px = np.array([marker_sizes[node[\"id\"]] for node in nodes], dtype=float) / 2\nfootprint_px = marker_r_px + 10  # small gap so circles never touch edge-to-edge\n\nfor _ in range(600):\n    moved = False\n    for i in range(n):\n        for j in range(i + 1, n):\n            diff = px_positions[i] - px_positions[j]\n            dist = np.linalg.norm(diff)\n            min_dist = footprint_px[i] + footprint_px[j]\n            if dist < min_dist:\n                moved = True\n                direction = diff / dist if dist > 1e-6 else np.array([1.0, 0.0])\n                push = (min_dist - dist) / 2 + 0.5\n                px_positions[i] += direction * push\n                px_positions[j] -= direction * push\n    # Keep every node's own circle fully inside the plot box — clamp, don't\n    # rescale, so marker radii stay valid.\n    for i in range(n):\n        px_positions[i, 0] = np.clip(px_positions[i, 0], footprint_px[i], PLOT_W_PX - footprint_px[i])\n        px_positions[i, 1] = np.clip(px_positions[i, 1], footprint_px[i], PLOT_H_PX - footprint_px[i])\n    if not moved:\n        break\n\n# Recenter the bounding box vertically within the plot area — the compass\n# layout clusters most nodes above center (only the small UI group reaches\n# the bottom extreme), which otherwise leaves the bottom quarter of the\n# square empty. Translating (not rescaling) preserves the exact spacing the\n# declutter pass just resolved, so no collisions are reintroduced.\ntop_extent = (px_positions[:, 1] + marker_r_px).max()\nbottom_extent = (px_positions[:, 1] - marker_r_px).min()\npx_positions[:, 1] += (PLOT_H_PX - top_extent - bottom_extent) / 2\n\npositions = px_positions\npos = {node[\"id\"]: positions[i] for i, node in enumerate(nodes)}\n\n# The single most-connected package is the visual focal point of the graph\nhub_id = max(degrees, key=degrees.get)\nhub_node = next(node for node in nodes if node[\"id\"] == hub_id)\n\n# Edge trace\nedge_x, edge_y = [], []\nfor src, tgt in edges:\n    x0, y0 = pos[src]\n    x1, y1 = pos[tgt]\n    edge_x.extend([x0, x1, None])\n    edge_y.extend([y0, y1, None])\n\nedge_color = \"rgba(80,80,80,0.30)\" if THEME == \"light\" else \"rgba(200,200,200,0.25)\"\nedge_trace = go.Scatter(\n    x=edge_x, y=edge_y, mode=\"lines\", line={\"width\": 2, \"color\": edge_color}, hoverinfo=\"none\", showlegend=False\n)\n\n\n# Halo trace — a soft glow behind the hub node draws the eye to the\n# single most-depended-upon package before any label is read. Dark theme\n# needs a higher opacity than light theme: the same 25%-opacity brand-green\n# over near-black stays visually dark, while over the warm off-white it\n# already reads as a clear pale ring.\nhub_x, hub_y = pos[hub_id]\nhalo_opacity = 0.25 if THEME == \"light\" else 0.55\nhalo_trace = go.Scatter(\n    x=[hub_x],\n    y=[hub_y],\n    mode=\"markers\",\n    marker={\n        \"size\": marker_sizes[hub_id] + 16,\n        \"color\": GROUP_COLORS[hub_node[\"group\"]],\n        \"opacity\": halo_opacity,\n        \"line\": {\"width\": 0},\n    },\n    hoverinfo=\"none\",\n    showlegend=False,\n)\n\n# Node traces — one per group so the legend shows architectural layers.\n# The marker text shows a short mnemonic; the hover tooltip and the\n# focal-point annotation carry the full package name.\nnode_traces = []\nfor group_id, (color, name) in enumerate(zip(GROUP_COLORS, GROUP_NAMES, strict=False)):\n    group_nodes = [node for node in nodes if node[\"group\"] == group_id]\n    node_x = [pos[node[\"id\"]][0] for node in group_nodes]\n    node_y = [pos[node[\"id\"]][1] for node in group_nodes]\n    node_sizes = [marker_sizes[node[\"id\"]] for node in group_nodes]\n    node_short_labels = [node[\"short\"] for node in group_nodes]\n    node_line_widths = [4 if node[\"id\"] == hub_id else 2 for node in group_nodes]\n\n    # Build dependent list for rich hover tooltips (Plotly hovertemplate + customdata)\n    customdata = []\n    for node in group_nodes:\n        nid = node[\"id\"]\n        nbrs = []\n        for src, tgt in edges:\n            if src == nid:\n                nbrs.append(nodes[tgt][\"label\"])\n            elif tgt == nid:\n                nbrs.append(nodes[src][\"label\"])\n        customdata.append([node[\"label\"], degrees[nid], \", \".join(nbrs) if nbrs else \"—\"])\n\n    node_traces.append(\n        go.Scatter(\n            x=node_x,\n            y=node_y,\n            mode=\"markers+text\",\n            marker={\"size\": node_sizes, \"color\": color, \"line\": {\"width\": node_line_widths, \"color\": PAGE_BG}},\n            text=node_short_labels,\n            textposition=\"middle center\",\n            textfont={\"size\": 13, \"color\": \"#FFFFFF\", \"family\": \"Arial Black\"},\n            customdata=customdata,\n            hovertemplate=(\n                \"<b>%{customdata[0]}</b><br>\"\n                f\"Layer: {name}<br>\"\n                \"Connections: %{customdata[1]}<br>\"\n                \"Connected to: %{customdata[2]}\"\n                \"<extra></extra>\"\n            ),\n            name=name,\n            legendgroup=name,\n        )\n    )\n\n# Figure\nfig = go.Figure(data=[edge_trace, halo_trace] + node_traces)\n\nfig.update_layout(\n    autosize=False,\n    title={\n        \"text\": \"network-basic · plotly · anyplot.ai\",\n        \"font\": {\"size\": 22, \"color\": INK},\n        \"x\": 0.5,\n        \"xanchor\": \"center\",\n    },\n    showlegend=True,\n    legend={\n        \"title\": {\"text\": \"Package<br>Layers\", \"font\": {\"size\": 13, \"color\": INK}},\n        \"font\": {\"size\": 11, \"color\": INK_SOFT},\n        \"bgcolor\": ELEVATED_BG,\n        \"bordercolor\": INK_SOFT,\n        \"borderwidth\": 1,\n        \"x\": 0.77,\n        \"y\": 0.98,\n        \"xanchor\": \"left\",\n        \"yanchor\": \"top\",\n    },\n    paper_bgcolor=PAGE_BG,\n    plot_bgcolor=PAGE_BG,\n    font={\"color\": INK},\n    # The plot area is restricted to the left 74% of the canvas — the\n    # remaining right-hand gutter is a dedicated, guaranteed-empty lane for\n    # the legend, so it can never overlap a node regardless of layout.\n    # Data coordinates equal output px 1:1 in this plot area (see the\n    # declutter pass above), so the range is exactly [0, PLOT_*_PX] — no\n    # padding needed, since the clamp step already keeps every node's own\n    # circle fully inside that box.\n    xaxis={\"showgrid\": False, \"zeroline\": False, \"showticklabels\": False, \"range\": [0, PLOT_W_PX], \"domain\": [0, 0.74]},\n    yaxis={\"showgrid\": False, \"zeroline\": False, \"showticklabels\": False, \"range\": [0, PLOT_H_PX]},\n    margin={\"l\": 70, \"r\": 70, \"t\": 100, \"b\": 90},\n    annotations=[\n        {\n            # Lives in the same right-hand gutter as the legend (x >= 0.77),\n            # which the xaxis domain restriction guarantees stays empty of\n            # nodes — safer than a plot-area corner, which a dense layout\n            # can still reach despite the margin.\n            \"x\": 0.77,\n            \"y\": 0.34,\n            \"xref\": \"paper\",\n            \"yref\": \"paper\",\n            \"text\": (f\"<b>{hub_node['label']}</b><br>most depended-upon<br>package ({degrees[hub_id]} deps)\"),\n            \"showarrow\": False,\n            \"font\": {\"size\": 12, \"color\": INK},\n            \"bgcolor\": ELEVATED_BG,\n            \"bordercolor\": INK_SOFT,\n            \"borderwidth\": 1,\n            \"borderpad\": 6,\n            \"align\": \"left\",\n            \"xanchor\": \"left\",\n            \"yanchor\": \"top\",\n        }\n    ],\n)\n\n# Save\n# Hard target: 2400 x 2400 (square — a network graph has no preferred\n# horizontal axis). See prompts/library/plotly.md \"Canvas — hard rule\".\nfig.write_image(f\"plot-{THEME}.png\", width=600, height=600, scale=4)\nfig.write_html(f\"plot-{THEME}.html\", include_plotlyjs=\"cdn\")\n"}