{"spec_id":"network-force-directed","library":"ggplot2","language":"r","code":"#' anyplot.ai\n#' network-force-directed: Force-Directed Graph\n#' Library: ggplot2 3.5.1 | R 4.4.1\n#' Quality: 87/100 | Created: 2026-08-24\nlibrary(ggplot2)\nlibrary(dplyr)\nlibrary(ragg)\n\nset.seed(42)\n\n# --- Theme tokens -----------------------------------------------------------\nTHEME       <- Sys.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG     <- if (THEME == \"light\") \"#FAF8F1\" else \"#1A1A17\"\nINK         <- if (THEME == \"light\") \"#1A1A17\" else \"#F0EFE8\"\nINK_SOFT    <- if (THEME == \"light\") \"#4A4A44\" else \"#B8B7B0\"\nINK_MUTED   <- if (THEME == \"light\") \"#6B6A63\" else \"#A8A79F\"\nIMPRINT_PALETTE <- c(\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\",\n                     \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\")\n\n# --- Data: microservice dependency graph, grouped by architectural layer ---\nlayers <- list(\n  Frontend = c(\"web\", \"mobile\", \"admin\", \"embed\", \"portal\", \"kiosk\", \"docs\", \"sdk\", \"cli\"),\n  Backend  = c(\"auth\", \"users\", \"orders\", \"payments\", \"notify\", \"search\", \"catalog\", \"pricing\", \"shipping\"),\n  Data     = c(\"db-primary\", \"db-replica\", \"cache\", \"queue\", \"warehouse\", \"lake\", \"etl\", \"backup\", \"index\"),\n  Infra    = c(\"gateway\", \"lb\", \"cdn\", \"dns\", \"monitor\", \"logging\", \"secrets\", \"ci\", \"registry\")\n)\n\nnodes <- tibble(\n  id      = unlist(layers, use.names = FALSE),\n  cluster = rep(names(layers), times = lengths(layers))\n)\n\n# Within-layer edges: a ring plus a handful of random chords per layer\nintra_edges <- bind_rows(lapply(layers, function(ids) {\n  n <- length(ids)\n  ring <- tibble(from = ids, to = ids[c(2:n, 1)])\n  chord_i <- sample.int(n, size = round(n * 0.55))\n  chords <- tibble(\n    from = ids[chord_i],\n    to   = ids[sapply(chord_i, function(i) sample(setdiff(seq_len(n), i), 1))]\n  )\n  bind_rows(ring, chords)\n}))\n\n# Cross-layer edges: calls that cross architectural boundaries\nbridge_edges <- tibble(\n  from = c(\"web\", \"mobile\", \"admin\", \"gateway\", \"gateway\", \"gateway\", \"auth\",\n           \"orders\", \"payments\", \"catalog\", \"search\", \"notify\", \"orders\",\n           \"etl\", \"warehouse\", \"ci\", \"monitor\", \"secrets\", \"cdn\", \"lb\"),\n  to   = c(\"gateway\", \"gateway\", \"gateway\", \"auth\", \"users\", \"orders\", \"cache\",\n           \"db-primary\", \"db-primary\", \"index\", \"index\", \"queue\", \"queue\",\n           \"warehouse\", \"lake\", \"registry\", \"logging\", \"auth\", \"web\", \"gateway\")\n)\n\nedges <- bind_rows(intra_edges, bridge_edges) %>%\n  mutate(pair_key = ifelse(from < to, paste(from, to), paste(to, from))) %>%\n  distinct(pair_key, .keep_all = TRUE) %>%\n  filter(from != to) %>%\n  select(from, to) %>%\n  mutate(weight = sample(1:5, n(), replace = TRUE))\n\ndegree <- bind_rows(\n  edges %>% count(id = from),\n  edges %>% count(id = to)\n) %>%\n  group_by(id) %>%\n  summarise(degree = sum(n), .groups = \"drop\")\n\nnodes <- nodes %>%\n  left_join(degree, by = \"id\") %>%\n  mutate(degree = coalesce(degree, 0))\n\n# --- Force-directed layout (Fruchterman-Reingold) ---------------------------\nn_nodes  <- nrow(nodes)\narea     <- 4\nk_ideal  <- sqrt(area / n_nodes)\npos      <- matrix(runif(n_nodes * 2, -1, 1), ncol = 2)\nfrom_idx <- match(edges$from, nodes$id)\nto_idx   <- match(edges$to, nodes$id)\nn_iter   <- 400\ntemp     <- 0.15\n\nfor (iter in seq_len(n_iter)) {\n  dx   <- outer(pos[, 1], pos[, 1], \"-\")\n  dy   <- outer(pos[, 2], pos[, 2], \"-\")\n  dist <- sqrt(dx^2 + dy^2)\n  diag(dist) <- Inf\n  repulse <- (k_ideal^2) / dist\n  disp    <- cbind(rowSums(repulse * dx / dist), rowSums(repulse * dy / dist))\n\n  edge_dx   <- pos[from_idx, 1] - pos[to_idx, 1]\n  edge_dy   <- pos[from_idx, 2] - pos[to_idx, 2]\n  edge_dist <- pmax(sqrt(edge_dx^2 + edge_dy^2), 1e-6)\n  attract   <- (edge_dist^2) / k_ideal\n  attract_x <- (edge_dx / edge_dist) * attract\n  attract_y <- (edge_dy / edge_dist) * attract\n\n  for (e in seq_along(from_idx)) {\n    disp[from_idx[e], 1] <- disp[from_idx[e], 1] - attract_x[e]\n    disp[from_idx[e], 2] <- disp[from_idx[e], 2] - attract_y[e]\n    disp[to_idx[e], 1]   <- disp[to_idx[e], 1] + attract_x[e]\n    disp[to_idx[e], 2]   <- disp[to_idx[e], 2] + attract_y[e]\n  }\n\n  disp_len <- pmax(sqrt(rowSums(disp^2)), 1e-6)\n  step     <- pmin(disp_len, temp)\n  pos      <- pos + (disp / disp_len) * step\n  temp     <- temp * 0.99\n}\n\nnodes$x <- pos[, 1]\nnodes$y <- pos[, 2]\n\n# Re-center each layer's centroid onto a fixed quadrant anchor (matching the\n# wide 16:9 canvas) so the four architectural layers spread across all four\n# corners instead of drifting along a single diagonal and leaving the\n# opposite corners empty. Intra-layer structure from the FR simulation above\n# is preserved; only the whole-cluster offset changes.\nanchor_x <- c(Frontend = -1.4, Backend = 1.4, Data = -1.4, Infra = 1.4)\nanchor_y <- c(Frontend = 0.8, Backend = 0.8, Data = -0.8, Infra = -0.8)\nnodes <- nodes %>%\n  group_by(cluster) %>%\n  mutate(x = x - mean(x) + anchor_x[cluster[1]], y = y - mean(y) + anchor_y[cluster[1]]) %>%\n  ungroup()\n\nedge_positions <- edges %>%\n  left_join(nodes %>% select(id, x, y), by = c(\"from\" = \"id\")) %>%\n  left_join(nodes %>% select(id, xend = x, yend = y), by = c(\"to\" = \"id\"))\n\nhub_nodes <- nodes %>% slice_max(degree, n = 5, with_ties = FALSE)\nnodes$cluster <- factor(nodes$cluster, levels = names(layers))\n\n# --- Plot ---------------------------------------------------------------\np <- ggplot() +\n  geom_segment(\n    data = edge_positions,\n    aes(x = x, y = y, xend = xend, yend = yend, linewidth = weight),\n    color = INK_MUTED, alpha = 0.35, lineend = \"round\"\n  ) +\n  geom_point(\n    data = nodes,\n    aes(x = x, y = y, color = cluster, size = degree)\n  ) +\n  geom_label(\n    data = hub_nodes,\n    aes(x = x, y = y, label = id),\n    color = INK, fill = PAGE_BG, alpha = 0.85, label.size = NA,\n    size = 3.2, fontface = \"bold\", nudge_y = 0.22,\n    label.padding = unit(0.12, \"lines\")\n  ) +\n  scale_color_manual(values = IMPRINT_PALETTE[1:4], name = \"Layer\") +\n  scale_size_continuous(range = c(4, 9), guide = \"none\") +\n  scale_linewidth_continuous(range = c(0.3, 1.4), guide = \"none\") +\n  scale_x_continuous(expand = expansion(mult = 0.1)) +\n  scale_y_continuous(expand = expansion(mult = 0.1)) +\n  coord_equal() +\n  labs(title = \"network-force-directed · r · ggplot2 · anyplot.ai\") +\n  theme_void(base_size = 8) +\n  theme(\n    plot.background   = element_rect(fill = PAGE_BG, color = PAGE_BG),\n    panel.background  = element_rect(fill = PAGE_BG, color = NA),\n    plot.title        = element_text(color = INK, size = 12, hjust = 0.5, margin = margin(b = 12)),\n    legend.position   = \"bottom\",\n    legend.title      = element_text(color = INK, size = 10),\n    legend.text       = element_text(color = INK_SOFT, size = 8),\n    legend.background = element_rect(fill = PAGE_BG, color = NA),\n    legend.key        = element_rect(fill = PAGE_BG, color = NA),\n    plot.margin       = margin(15, 15, 15, 15)\n  )\n\n# --- Save ---------------------------------------------------------------\nggsave(\n  filename = sprintf(\"plot-%s.png\", THEME),\n  plot     = p,\n  device   = ragg::agg_png,\n  width    = 8,\n  height   = 4.5,\n  units    = \"in\",\n  dpi      = 400\n)\n"}