MSBA 580 // Week 9 // Classified Holonet Transmission
THE PHANTOM
NETWORK
A Star Wars field guide to nodes, edges, aliases, hidden influence, and the machinery of rebellion.
Prepared by Kilgore Trout // Educational fan-fiction analysis

Mission briefing

Networks are made of two ingredients:

  • Vertices (nodes): the people, organizations, planets, products, or pages.
  • Edges (links): the relationships connecting those vertices.

For marketers, network analysis can identify influential customers, tightly connected communities, likely referrals, and paths through which information may spread.

For this fan-fiction version of the lab, you are Darth Jar Jar: publicly a clumsy representative from Naboo, secretly the architect of a galaxy-spanning influence network. The Star Wars framing changes the labels, not the mathematics. The same graph methods apply to customers, social-media accounts, websites, organizations, and supply chains.

Reproducibility check

This table records the package versions used to knit the report. If results ever change after an update, this is the first place to check.

package_versions <- tibble(
  package = required_packages,
  version = vapply(
    required_packages,
    function(pkg) as.character(utils::packageVersion(pkg)),
    character(1)
  )
)

render_table(
  package_versions,
  caption = "Package versions used for this analysis",
  digits = 3
)
Package versions used for this analysis
package version
tidyverse 2.0.0
igraph 2.3.3
knitr 1.51
kableExtra 1.4.1
scales 1.4.0
networkD3 0.4.1
htmlwidgets 1.6.4

First, the basics: Darth Jar Jar’s inner circle

Why the original lines 86–88 needed special attention

The original lab created the starter graph in one long graph_from_literal() statement and then immediately plotted it. That can work, but it is difficult to audit and easy to break with a misspelled name such as Farther.

This version replaces the fragile one-line graph with:

  1. A clearly labeled vertex table.
  2. A clearly labeled edge table.
  3. Validation checks before plotting.
  4. A fixed random seed for a repeatable layout.
# -------------------------------------------------------------------------
# STARTER VERTEX TABLE
# -------------------------------------------------------------------------
# Each row represents one character in Darth Jar Jar's hidden network.
darth_vertices <- tribble(
  ~name,               ~faction,          ~public_role,
  "Darth Jar Jar",     "Shadow Sith",     "Naboo representative",
  "Darth Sidious",     "Shadow Sith",     "Supreme Chancellor",
  "Count Dooku",       "Separatists",      "Separatist leader",
  "General Grievous",  "Separatists",      "Military commander",
  "Queen Amidala",     "Naboo",            "Naboo leader",
  "Boss Nass",         "Gungan Council",   "Gungan leader",
  "Anakin Skywalker",  "Jedi",             "Jedi Knight",
  "Obi-Wan Kenobi",    "Jedi",             "Jedi Master"
)

# -------------------------------------------------------------------------
# STARTER EDGE TABLE
# -------------------------------------------------------------------------
# Each row represents one relationship. An explicit edge table is easier to
# inspect than a dense graph literal and is the preferred pattern when a
# network will later receive attributes.
darth_edges <- tribble(
  ~from,               ~to,                ~relationship,
  "Darth Jar Jar",     "Darth Sidious",    "secret alliance",
  "Darth Jar Jar",     "Count Dooku",      "covert coordination",
  "Darth Sidious",     "Count Dooku",      "Sith command",
  "Darth Jar Jar",     "Queen Amidala",    "public trust",
  "Darth Jar Jar",     "Boss Nass",        "Gungan influence",
  "Queen Amidala",     "Boss Nass",        "Naboo alliance",
  "Darth Jar Jar",     "Anakin Skywalker", "subtle manipulation",
  "Darth Sidious",     "Anakin Skywalker", "apprentice grooming",
  "Anakin Skywalker",  "Obi-Wan Kenobi",   "Jedi mentorship",
  "Count Dooku",       "General Grievous", "military command",
  "Darth Sidious",     "General Grievous", "strategic control"
)

# Build an undirected graph because these starter relationships are being
# treated as mutual connections for introductory network analysis.
g5 <- igraph::graph_from_data_frame(
  d = darth_edges,
  directed = FALSE,
  vertices = darth_vertices
)

# Fail early with an informative error if the graph was built incorrectly.
stopifnot(
  igraph::vcount(g5) == nrow(darth_vertices),
  igraph::ecount(g5) == nrow(darth_edges),
  igraph::is_simple(g5)
)

# Degree is the number of direct connections for each character.
starter_degree <- igraph::degree(g5, mode = "all")

# Force-directed layouts include randomness. Setting a seed makes the same
# graph appear in the same approximate position each time the report knits.
set.seed(580)
starter_layout <- igraph::layout_with_fr(g5)

# Map node size to degree so more-connected characters look larger.
starter_size <- scales::rescale(
  starter_degree,
  to = c(24, 48)
)

# Create one repeatable color for each faction.
faction_levels <- sort(unique(igraph::V(g5)$faction))
faction_colors <- setNames(
  scales::hue_pal()(length(faction_levels)),
  faction_levels
)

plot(
  g5,
  layout = starter_layout,
  vertex.size = starter_size,
  vertex.color = faction_colors[igraph::V(g5)$faction],
  vertex.frame.color = "white",
  vertex.label = stringr::str_wrap(igraph::V(g5)$name, width = 14),
  vertex.label.color = "black",
  vertex.label.cex = 0.85,
  edge.width = 2,
  edge.color = "grey65",
  main = "Darth Jar Jar's Inner Circle\nNode size = number of direct connections"
)

legend(
  "topleft",
  legend = names(faction_colors),
  col = faction_colors,
  pch = 19,
  pt.cex = 1.4,
  bty = "n",
  title = "Faction"
)

The starter network demonstrates several foundational concepts:

  • Chain: Obi-Wan connects to Anakin, who connects to Darth Jar Jar.
  • Triangle: Darth Jar Jar, Darth Sidious, and Count Dooku are mutually connected.
  • Multiple paths: Darth Jar Jar can reach General Grievous through either Count Dooku or Darth Sidious.
  • Degree: Darth Jar Jar has the most direct connections and is therefore the most locally connected character in this small graph.
starter_degree_table <- tibble(
  character = names(starter_degree),
  degree = as.integer(starter_degree)
) |>
  arrange(desc(degree), character)

render_table(
  starter_degree_table,
  caption = "Direct connections in Darth Jar Jar's starter network",
  digits = 0
)
Direct connections in Darth Jar Jar’s starter network
character degree
Darth Jar Jar 5
Darth Sidious 4
Anakin Skywalker 3
Count Dooku 3
Boss Nass 2
General Grievous 2
Queen Amidala 2
Obi-Wan Kenobi 1
Understanding Checkpoint
Which character has the highest degree in the starter network?



The simulated Holonet dataset

The original lab modeled Facebook users and friendships. Here we simulate a smaller Holonet influence network with the same structure:

  • Vertices: individual accounts with descriptive attributes.
  • Edges: undirected social or communication connections.
  • Network shape: preferential attachment, where already-connected accounts are more likely to attract new connections.

This preserves the original marketing lesson: social networks usually contain a small number of hubs and many less-connected users.

Step 1: Simulate the vertex table

set.seed(580)

n_users <- 150

known_aliases <- c(
  "Darth Jar Jar", "Darth Sidious", "Queen Amidala", "Boss Nass",
  "Count Dooku", "General Grievous", "Anakin Skywalker", "Obi-Wan Kenobi",
  "Ahsoka Tano", "Mace Windu", "Yoda", "Bail Organa", "Mon Mothma",
  "Nute Gunray", "Wat Tambor", "Jango Fett", "Boba Fett", "Cad Bane",
  "R2-D2", "C-3PO"
)

vertices <- tibble(
  # id must be unique because graph_from_data_frame() uses it as the
  # symbolic vertex name.
  id = seq_len(n_users),

  # Use recognizable aliases first, then reproducible generic accounts.
  alias = if_else(
    id <= length(known_aliases),
    known_aliases[id],
    sprintf("Holonet Agent %03d", id)
  ),

  # Attributes support later joins and segmentation exercises.
  birthday = sample(
    seq.Date(
      from = as.Date("1985-01-01"),
      to = as.Date("2005-12-31"),
      by = "day"
    ),
    size = n_users,
    replace = TRUE
  ),

  homeworld = sample(
    c(
      "Naboo", "Coruscant", "Tatooine", "Mandalore",
      "Kashyyyk", "Kamino", "Ryloth", "Corellia"
    ),
    size = n_users,
    replace = TRUE
  ),

  faction_id = sample(
    c(
      "Galactic Republic", "Jedi Order", "Separatists",
      "Gungan Council", "Crime Syndicate", "Independent"
    ),
    size = n_users,
    replace = TRUE
  ),

  academy_id = sample(
    c(
      "Jedi Temple", "Royal Academy", "Gungan Grand Army",
      "Mandalorian Training", "Trade Federation", "None"
    ),
    size = n_users,
    replace = TRUE
  )
)

# Derive the birth month after the base table is created.
# Birth month is more useful than an exact date in this small simulation
# because it creates enough matches to demonstrate the join reliably.
vertices <- vertices |>
  mutate(
    birth_month_number = as.integer(format(birthday, "%m")),
    birth_month = factor(
      month.abb[birth_month_number],
      levels = month.abb
    )
  )

render_table(
  vertices |>
    select(id, alias, birthday, birth_month, homeworld, faction_id, academy_id) |>
    slice_head(n = 8),
  caption = "First 8 Holonet accounts in the simulated vertex table",
  digits = 0
)
First 8 Holonet accounts in the simulated vertex table
id alias birthday birth_month homeworld faction_id academy_id
1 Darth Jar Jar 1989-08-29 Aug Coruscant Gungan Council Jedi Temple
2 Darth Sidious 1991-01-04 Jan Coruscant Gungan Council None
3 Queen Amidala 1998-04-19 Apr Naboo Galactic Republic Mandalorian Training
4 Boss Nass 1986-02-27 Feb Corellia Jedi Order Royal Academy
5 Count Dooku 1988-04-09 Apr Corellia Crime Syndicate Jedi Temple
6 General Grievous 1998-07-07 Jul Kashyyyk Independent Royal Academy
7 Anakin Skywalker 1987-09-12 Sep Kashyyyk Separatists None
8 Obi-Wan Kenobi 1986-01-13 Jan Naboo Gungan Council Royal Academy

Step 2: Simulate the edge table

set.seed(580)

# Preferential attachment generates a realistic hub-and-spoke structure.
g_sim <- igraph::sample_pa(
  n = n_users,
  power = 1.1,
  m = 3,
  directed = FALSE
)

# Remove any loops or duplicate edges as a defensive reproducibility step.
g_sim <- igraph::simplify(
  g_sim,
  remove.multiple = TRUE,
  remove.loops = TRUE
)

edges <- igraph::as_data_frame(g_sim, what = "edges") |>
  transmute(
    src = as.integer(from),
    dst = as.integer(to)
  ) |>
  arrange(src, dst)

stopifnot(
  all(edges$src %in% vertices$id),
  all(edges$dst %in% vertices$id),
  !any(edges$src == edges$dst)
)

render_table(
  edges |> slice_head(n = 8),
  caption = "First 8 simulated Holonet connections",
  digits = 0
)
First 8 simulated Holonet connections
src dst
1 2
1 3
1 4
1 5
1 6
1 7
1 9
1 10
Understanding Checkpoint
Which table stores relationships between accounts?


Step 3: Build and validate the graph

fb_graph <- igraph::graph_from_data_frame(
  d = edges,
  vertices = vertices,
  directed = FALSE
)

# These checks confirm that no IDs were lost during graph construction.
stopifnot(
  igraph::vcount(fb_graph) == nrow(vertices),
  igraph::ecount(fb_graph) == nrow(edges),
  igraph::is_simple(fb_graph),
  igraph::is_connected(fb_graph)
)

fb_graph
## IGRAPH 30c5a9b UN-- 150 444 -- 
## + attr: name (v/c), alias (v/c), birthday (v/n), homeworld (v/c),
## | faction_id (v/c), academy_id (v/c), birth_month_number (v/n),
## | birth_month (v/x)
## + edges from 30c5a9b (vertex names):
##  [1] 1--2   1--3   1--4   1--5   1--6   1--7   1--9   1--10  1--11  1--12 
## [11] 1--19  1--20  1--21  1--25  1--26  1--28  1--29  1--33  1--47  1--50 
## [21] 1--57  1--72  1--77  1--79  1--87  1--89  1--96  1--97  1--101 1--102
## [31] 1--104 1--116 1--121 1--131 1--133 1--139 1--140 1--148 1--150 2--3  
## [41] 2--4   2--6   2--7   2--8   2--10  2--14  2--17  2--19  2--24  2--27 
## [51] 2--30  2--35  2--38  2--44  2--45  2--48  2--61  2--62  2--63  2--64 
## + ... omitted several edges

Inspect edge triplets

A triplet is an edge plus the attributes of both endpoints. This is useful when the question depends on who is connected and what those two accounts have in common.

edge_table <- graph_edges_integer(fb_graph)

vertex_a <- vertices |>
  rename_with(~ paste0("a_", .x))

vertex_b <- vertices |>
  rename_with(~ paste0("b_", .x))

triplets <- edge_table |>
  left_join(
    vertex_a,
    by = c("from" = "a_id")
  ) |>
  left_join(
    vertex_b,
    by = c("to" = "b_id")
  )

render_table(
  triplets |>
    select(
      from, a_alias, a_homeworld, a_faction_id,
      to, b_alias, b_homeworld, b_faction_id
    ) |>
    slice_head(n = 5),
  caption = "Sample triplets: one connection plus both accounts' attributes",
  digits = 0
)
Sample triplets: one connection plus both accounts’ attributes
from a_alias a_homeworld a_faction_id to b_alias b_homeworld b_faction_id
1 Darth Jar Jar Coruscant Gungan Council 2 Darth Sidious Coruscant Gungan Council
1 Darth Jar Jar Coruscant Gungan Council 3 Queen Amidala Naboo Galactic Republic
1 Darth Jar Jar Coruscant Gungan Council 4 Boss Nass Corellia Jedi Order
1 Darth Jar Jar Coruscant Gungan Council 5 Count Dooku Corellia Crime Syndicate
1 Darth Jar Jar Coruscant Gungan Council 6 General Grievous Kashyyyk Independent

Step 4: Network algorithms and marketing applications

1. Find connected accounts with the same birth month

The original exercise searched for exact shared birthdays. In a 150-node simulation, exact matching dates among connected accounts can easily produce an empty table. Comparing birth month retains the personalization concept while creating a stable demonstration.

same_birth_month <- edge_table |>
  left_join(
    vertices |>
      select(id, alias, birth_month) |>
      rename(
        alias_a = alias,
        birth_month_a = birth_month
      ),
    by = c("from" = "id")
  ) |>
  left_join(
    vertices |>
      select(id, alias, birth_month) |>
      rename(
        alias_b = alias,
        birth_month_b = birth_month
      ),
    by = c("to" = "id")
  ) |>
  filter(birth_month_a == birth_month_b) |>
  transmute(
    account_a = alias_a,
    account_b = alias_b,
    shared_birth_month = birth_month_a
  ) |>
  arrange(shared_birth_month, account_a, account_b)

render_table(
  same_birth_month |> slice_head(n = 10),
  caption = "Connected Holonet accounts sharing a birth month",
  digits = 0
)
Connected Holonet accounts sharing a birth month
account_a account_b shared_birth_month
C-3PO Holonet Agent 037 Jan
Darth Sidious Holonet Agent 044 Jan
Darth Sidious Holonet Agent 070 Jan
Darth Sidious Obi-Wan Kenobi Jan
Holonet Agent 044 Holonet Agent 086 Jan
Obi-Wan Kenobi Holonet Agent 040 Jan
Obi-Wan Kenobi Holonet Agent 091 Jan
Boss Nass Wat Tambor Feb
Holonet Agent 049 Holonet Agent 138 Feb
Holonet Agent 073 Holonet Agent 122 Feb

Marketing translation: shared attributes can support personalized promotions, community invitations, or audience segments.

2. Count triangles

A triangle is a set of three mutually connected accounts. Triangle-heavy users often sit inside cohesive communities where recommendations and information may spread efficiently.

triangle_counts <- tibble(
  id = as.integer(igraph::V(fb_graph)$name),
  triangle_count = as.integer(igraph::count_triangles(fb_graph))
) |>
  left_join(
    vertices |> select(id, alias, faction_id),
    by = "id"
  ) |>
  arrange(desc(triangle_count), alias)

render_table(
  triangle_counts |> slice_head(n = 10),
  caption = "Accounts participating in the most closed triangles",
  digits = 0
)
Accounts participating in the most closed triangles
id triangle_count alias faction_id
1 50 Darth Jar Jar Gungan Council
3 36 Queen Amidala Galactic Republic
2 35 Darth Sidious Gungan Council
6 29 General Grievous Independent
4 15 Boss Nass Jedi Order
11 14 Yoda Separatists
10 13 Mace Windu Crime Syndicate
21 9 Holonet Agent 021 Gungan Council
29 7 Holonet Agent 029 Crime Syndicate
16 7 Jango Fett Independent
Understanding Checkpoint
What does a high triangle count usually suggest?


3. Find friends of friends correctly in an undirected network

The original friend-of-friend pipeline converted every edge into a low-ID/high-ID pair and joined only one orientation. That misses valid two-step paths in an undirected graph.

The improved version first expands each edge into both directions, then:

  1. Finds two accounts connected to the same mutual friend.
  2. Removes self-pairs.
  3. Standardizes pair order.
  4. Removes pairs that are already directly connected.
  5. Counts mutual friends.
  6. Retains pairs that share an academy.
# Canonical direct-edge table used to remove already-connected pairs.
direct_edges <- edge_table |>
  transmute(
    a = pmin(from, to),
    b = pmax(from, to)
  ) |>
  distinct()

# Expand each undirected edge into two directed neighbor records.
adjacency <- bind_rows(
  edge_table |> transmute(account = from, mutual_friend = to),
  edge_table |> transmute(account = to, mutual_friend = from)
) |>
  distinct()

# Self-join through the shared mutual friend.
fof_candidates <- adjacency |>
  inner_join(
    adjacency,
    by = "mutual_friend",
    suffix = c("_a", "_b"),
    relationship = "many-to-many"
  ) |>
  transmute(
    a = pmin(account_a, account_b),
    b = pmax(account_a, account_b),
    mutual_friend = mutual_friend
  ) |>
  filter(a != b) |>
  distinct() |>
  anti_join(
    direct_edges,
    by = c("a", "b")
  ) |>
  count(
    a,
    b,
    name = "mutual_friend_count",
    sort = TRUE
  )

friends_of_friends <- fof_candidates |>
  left_join(
    vertices |>
      select(id, alias, academy_id) |>
      rename(
        alias_a = alias,
        academy_a = academy_id
      ),
    by = c("a" = "id")
  ) |>
  left_join(
    vertices |>
      select(id, alias, academy_id) |>
      rename(
        alias_b = alias,
        academy_b = academy_id
      ),
    by = c("b" = "id")
  ) |>
  filter(academy_a == academy_b) |>
  select(
    account_a = alias_a,
    account_b = alias_b,
    shared_academy = academy_a,
    mutual_friend_count
  ) |>
  arrange(desc(mutual_friend_count), account_a, account_b)

render_table(
  friends_of_friends |> slice_head(n = 10),
  caption = "Suggested introductions: mutual connections plus shared academy",
  digits = 0
)
Suggested introductions: mutual connections plus shared academy
account_a account_b shared_academy mutual_friend_count
Boss Nass General Grievous Royal Academy 7
Queen Amidala Holonet Agent 021 Mandalorian Training 5
Darth Jar Jar Holonet Agent 044 Jedi Temple 4
Darth Sidious Cad Bane None 4
Darth Sidious Holonet Agent 032 None 4
Darth Sidious Holonet Agent 047 None 4
Mace Windu Yoda Mandalorian Training 4
Yoda Holonet Agent 116 Mandalorian Training 4
Boss Nass Holonet Agent 062 Royal Academy 3
Boss Nass R2-D2 Royal Academy 3

Marketing translation: a recommendation is more credible when it combines network proximity with a relevant shared attribute.

4. Rank influential accounts with PageRank

Degree counts direct connections. PageRank also considers the importance of the accounts supplying those connections.

pagerank_vector <- igraph::page_rank(
  fb_graph,
  directed = FALSE,
  damping = 0.85
)$vector

influencers <- tibble(
  id = as.integer(names(pagerank_vector)),
  pagerank = as.numeric(pagerank_vector),
  degree = as.integer(igraph::degree(fb_graph))
) |>
  left_join(
    vertices |> select(id, alias, faction_id),
    by = "id"
  ) |>
  arrange(desc(pagerank), desc(degree), alias)

render_table(
  influencers |>
    select(alias, faction_id, degree, pagerank) |>
    slice_head(n = 10),
  caption = "Top Holonet accounts by PageRank",
  digits = 5
)
Top Holonet accounts by PageRank
alias faction_id degree pagerank
Darth Jar Jar Gungan Council 39 0.03853
Darth Sidious Gungan Council 35 0.03499
Queen Amidala Galactic Republic 28 0.02750
General Grievous Independent 25 0.02438
Yoda Separatists 23 0.02350
Holonet Agent 021 Gungan Council 17 0.01743
Mace Windu Crime Syndicate 17 0.01724
Cad Bane Jedi Order 16 0.01664
Boss Nass Jedi Order 16 0.01609
R2-D2 Jedi Order 14 0.01515
Understanding Checkpoint
What mainly drives a high PageRank score?


Section 5: Darth Jar Jar’s directed influence map

This section demonstrates PageRank in a directed network where arrows represent the flow of intelligence, pressure, or influence. The graph data support both the static contact map and the interactive D3 exploration that follows.

render_table(
  darth_rank |> slice_head(n = 10),
  caption = "Darth Jar Jar influence network ranked by weighted PageRank",
  digits = 5
)
Darth Jar Jar influence network ranked by weighted PageRank
node faction in_degree out_degree pagerank
Darth Jar Jar Shadow Sith 5 8 0.26878
Galactic Senate Galactic Institution 4 1 0.17897
Count Dooku Separatists 3 2 0.09938
Jedi Council Jedi 2 1 0.07829
General Grievous Separatists 1 0 0.07027
Anakin Skywalker Jedi 2 1 0.05406
Darth Sidious Shadow Sith 1 3 0.05209
Gungan Council Naboo 1 1 0.04517
Holonet News Galactic Institution 1 1 0.04517
Queen Amidala Naboo 1 2 0.04517
map_factions <- sort(unique(igraph::V(darth_graph)$faction))
map_colors <- setNames(
  scales::hue_pal()(length(map_factions)),
  map_factions
)

plot(
  darth_graph,
  layout = darth_layout,
  vertex.size = scales::rescale(
    igraph::V(darth_graph)$pagerank,
    to = c(22, 52)
  ),
  vertex.color = map_colors[igraph::V(darth_graph)$faction],
  vertex.frame.color = "white",
  vertex.label = stringr::str_wrap(igraph::V(darth_graph)$name, width = 15),
  vertex.label.cex = 0.78,
  vertex.label.color = "black",
  edge.width = scales::rescale(
    igraph::E(darth_graph)$weight,
    to = c(1, 5)
  ),
  edge.arrow.size = 0.45,
  edge.color = "grey55",
  main = paste(
    "Darth Jar Jar's Directed Influence Map",
    "Node size = weighted PageRank; arrow width = influence strength",
    sep = "\n"
  )
)

legend(
  "topleft",
  legend = names(map_colors),
  col = map_colors,
  pch = 19,
  pt.cex = 1.4,
  bty = "n",
  title = "Faction"
)

Interpretation: Darth Jar Jar appears important not merely because he sends many links, but because influential institutions and characters also direct information and access back toward him.

Section 6: Interactive D3 network map

networkD3 expects:

  • A node table indexed from zero.
  • A link table whose source and target columns use those zero-based indices.
  • Numeric node-size and link-value fields.
Darth Jar Jar’s Holonet Influence Map
nodes_d3 <- tibble(
  name = igraph::V(darth_graph)$name,
  faction = igraph::V(darth_graph)$faction,
  pagerank = as.numeric(igraph::V(darth_graph)$pagerank),

  # Precompute an easy-to-read radius range.
  node_size = scales::rescale(
    as.numeric(igraph::V(darth_graph)$pagerank),
    to = c(8, 24)
  )
)

links_d3 <- igraph::as_data_frame(darth_graph, what = "edges") |>
  transmute(
    # networkD3 requires zero-based indices rather than names.
    source = match(from, nodes_d3$name) - 1,
    target = match(to, nodes_d3$name) - 1,
    value = weight,
    channel = channel
  )

stopifnot(
  all(links_d3$source >= 0),
  all(links_d3$target >= 0),
  max(links_d3$source) < nrow(nodes_d3),
  max(links_d3$target) < nrow(nodes_d3)
)

darth_network_widget <- networkD3::forceNetwork(
  Links = as.data.frame(links_d3),
  Nodes = as.data.frame(nodes_d3),
  Source = "source",
  Target = "target",
  Value = "value",
  NodeID = "name",
  Group = "faction",
  Nodesize = "node_size",

  # Use htmlwidgets::JS explicitly. Calling JS() without its namespace can
  # fail when htmlwidgets has not been attached.
  radiusCalculation = htmlwidgets::JS("d.nodesize"),

  linkDistance = 165,
  charge = -500,
  opacity = 0.94,
  opacityNoHover = 1,
  zoom = TRUE,
  arrows = TRUE,
  fontSize = 12,
  fontFamily = "Trebuchet MS",
  bounded = TRUE,
  legend = TRUE,
  height = 700
)

htmlwidgets::onRender(
  darth_network_widget,
  "function(el, x) {
     var root = d3.select(el);

     root.selectAll('.nodetext')
       .style('fill', '#ffffff')
       .style('font-weight', '800')
       .style('paint-order', 'stroke')
       .style('stroke', '#07111f')
       .style('stroke-width', '3px')
       .style('stroke-linejoin', 'round')
       .style('opacity', 1);

     root.selectAll('.legend text')
       .style('fill', '#ffffff')
       .style('font-size', '12px')
       .style('font-weight', '800')
       .style('paint-order', 'stroke')
       .style('stroke', '#07111f')
       .style('stroke-width', '3px')
       .style('stroke-linejoin', 'round');

     root.selectAll('.legend rect')
       .style('stroke', '#edf3fb')
       .style('stroke-width', '1px');
   }"
)

Interactive interpretation: drag Darth Jar Jar away from the center. The network’s force layout pulls him back toward the institutions and characters with which he exchanges the strongest influence.

Understanding Checkpoint
What does networkD3 add to the static igraph plot?


Methodologies applied: decoding Luthen’s shadow network

Spoiler transmission: this section draws on both seasons of Andor and the political-spy network leading toward Rogue One.

This demonstration uses a deliberately small, classroom-scale evidence model. The relationships are anchored in official character and episode descriptions, but the calculated scores are analytic outputs from this selected model—not canonical power levels or definitive rankings. The objective is to show how an analyst moves from scattered observations to defensible conclusions.

The question we are trying to answer

How can a network reveal what kind of operative a character is—even when that character has few visible connections, multiple aliases, or deliberately hidden relationships?

The answer is not “count the most lines on screen.” We build the evidence chronologically, preserve direction and context, resolve identities, measure structure, remove key nodes, and only then translate the pattern into a conclusion.

The six-phase intelligence pipeline

1

Collect observable evidence

Record who communicates with whom, who issues orders, who supplies intelligence, who funds operations, and who is pursuing whom. Each observation becomes a candidate edge rather than an immediate conclusion.

2

Resolve identities before counting

Cassian Andor, Kassa, Clem, Keef Girgo, Tourist, and Varian Skye are different names used for the same character. Merge those names before calculating the network, or one highly connected character will be mistaken for several minor characters.

3

Define what every arrow means

An arrow may represent intelligence flow, command, political access, personal trust, coordination, or adversarial pursuit. Direction matters: Lonni feeding information to Luthen is analytically different from Luthen pressuring or handling Lonni.

4

Measure several kinds of importance

Degree measures visible contacts. PageRank rewards connections to important actors. Betweenness identifies brokers sitting on shortest paths. Two-step reach shows how far a message can travel quickly. No single metric is allowed to tell the whole story.

5

Stress-test the network

Remove Luthen, Lonni, Cassian, Saw, or Kleya one at a time. If communities split or routes disappear, the removed character was supplying structural cohesion, intelligence access, or operational redundancy.

6

Translate measurements into cautious claims

The final statement must separate canon from inference: ‘Lonni is canonically an ISB double agent’ is observed evidence; ‘Lonni behaves like a high-risk bridge whose loss isolates an intelligence channel’ is a network-derived conclusion.

One character, many names

Kassa Cassian Andor Clem Keef Girgo Tourist Varian Skye

Character names merged before calculating centrality
character_name story_context network_treatment
Kassa Kenari childhood Count as Cassian Andor
Cassian Andor Ferrix and the rebellion Use as the combined analysis node
Clem Aldhani mission Count as Cassian Andor
Keef Girgo Niamos and Narkina 5 Count as Cassian Andor
Tourist Niamos arrest Count as Cassian Andor
Varian Skye Ghorman operation Count as Cassian Andor
Why alias resolution changes the analytic story
model nodes_used_for_one_person highest_visible_degree communities_visible_together analytic_result
Aliases left fragmented 5 3 1 Centrality and reach are understated
Aliases resolved to Cassian Andor 1 10 6 Cross-community field role becomes visible

Observed The evidence model connects Lonni to Luthen, places Luthen in contact with Mon Mothma and Saw Gerrera, and combines Cassian Andor, Kassa, Clem, Keef Girgo, Tourist, and Varian Skye as one character node.

Derived Centrality, brokerage, reach, community position, and node-removal effects below are produced by this classroom network model.

Boundary A missing edge means “not included in this evidence model,” not “these characters never interacted.”

The contact map: what the structure makes visible

Read the map from the outside inward: local groups form recognizable clusters, while brokers occupy the connective tissue between clusters. Red arrows are pursuit, not cooperation; they are shown for situational awareness but excluded from collaboration-community calculations.

Interactive field console

Drag a node, zoom into a cluster, and watch the force layout pull brokers back toward the communities they connect. This interaction turns an abstract metric into a physical intuition: a structurally important node is difficult to move away from the network without stretching many relationships.

The metric panel

Selected structural signals from the Andor-era evidence model
character role direct_connections two_step_reach betweenness pagerank new_components_if_removed
Luthen Rael strategist and handler 6 13 0.724 0.157 2
Cassian Andor field operative 6 10 0.352 0.157 2
Lonni Jung embedded intelligence source 2 8 0.343 0.051 1
Saw Gerrera autonomous cell leader 3 8 0.133 0.080 1
Mon Mothma political and financial bridge 2 7 0.133 0.061 1
Dedra Meero counter-insurgency investigator 2 3 0.133 0.056 1
Kleya Marki operations and continuity 2 9 0.000 0.057 0

Degree

Plain read: How many doors can this person open directly?
Expert read: Local connectivity; useful but vulnerable to visible-activity bias.

Betweenness

Plain read: How often must information pass through this person?
Expert read: Brokerage across shortest weighted paths; a proxy for coordination power and bottleneck risk.

PageRank

Plain read: Are the people connected to this person important too?
Expert read: Recursive prestige in a directed, weighted network.

Removal impact

Plain read: What breaks when this person disappears?
Expert read: Counterfactual fragmentation and resilience testing.

What we can responsibly conclude

Luthen Rael

Network inference
Plain-language conclusion: He is the switchboard. Political money, spies, field cells, and autonomous rebels connect through him.
Technical interpretation: High brokerage and removal impact identify a structural coordinator whose value comes from connecting otherwise separated communities.
Confidence: High within this selected model

Lonni Jung

Network inference
Plain-language conclusion: He does not need many friends to matter. He is the concealed wire carrying information out of the ISB.
Technical interpretation: Low-to-moderate degree can coexist with high bridge value. His position links an Imperial command environment to the Axis network, creating both intelligence value and single-channel risk.
Confidence: High for bridge interpretation

Cassian Andor

Network inference
Plain-language conclusion: Once his aliases are merged, he stops looking like several minor strangers and becomes a field connector spanning multiple worlds and cells.
Technical interpretation: Entity resolution recovers cross-community reach and prevents severe centrality attenuation. Alias fragmentation is a data-quality error, not merely a labeling inconvenience.
Confidence: High for identity-resolution lesson

Saw Gerrera

Network inference
Plain-language conclusion: Saw is powerful inside his own camp but deliberately difficult to absorb into someone else’s chain of command.
Technical interpretation: A locally cohesive partisan cluster with relatively few external bridges signals autonomy, modularity, and coordination friction rather than irrelevance.
Confidence: Moderate; depends on sampled ties

Kleya Marki

Network inference
Plain-language conclusion: Kleya is the continuity mechanism: the network can keep moving when Luthen cannot personally touch every operation.
Technical interpretation: Strong reciprocal ties to the hub and field operatives create operational redundancy and reduce hub overload.
Confidence: Moderate to high

Mon Mothma

Network inference
Plain-language conclusion: She connects respectable politics and resources to a covert network without resembling a battlefield hub.
Technical interpretation: Brokerage quality matters more than raw degree: a small number of cross-domain ties can supply disproportionate strategic value.
Confidence: High for bridge-type interpretation

Dedra Meero

Network inference
Plain-language conclusion: She is an adversarial mirror of the rebel network—assembling fragments until separate incidents appear to share a hidden center.
Technical interpretation: Her analytic task is link prediction and entity resolution under missing data. Adversarial edges describe detection pressure, not cooperative centrality.
Confidence: High as a methodological analogy

The expert-level lesson hidden inside the simple story

The most important character is not always the one with the most connections. A double agent can have low degree but enormous brokerage value. A political financier can have only a few edges yet connect domains that otherwise never touch. A field operative can appear statistically unimportant until aliases are resolved. A militant cell leader can be highly influential locally while remaining structurally distant from the larger coalition.

This is why serious network analysis combines entity resolution, edge semantics, direction, centrality, community structure, and counterfactual removal. The picture is not the conclusion; it is the interface through which the conclusion becomes understandable.

Canon anchors used for this demonstration

  • The official Star Wars Databank identifies Lonni Jung as an ISB supervisor secretly feeding intelligence to Luthen Rael.
  • Luthen’s official profile describes his contact with Mon Mothma, Saw Gerrera, Cassian, and a wider spy network.
  • Cassian Andor, Kassa, Clem, Keef Girgo, Tourist, and Varian Skye are combined into one character node before calculating centrality.
  • Combining the names prevents one character’s relationships from being split across several artificial nodes.

Practical marketing applications

  1. Influencer identification: compare degree and PageRank rather than relying only on follower counts.
  2. Community campaigns: use triangles and local clustering to find cohesive groups.
  3. Recommendation systems: combine mutual connections with meaningful shared attributes.
  4. Information diffusion: use directed graphs to understand who influences whom.
  5. Interactive stakeholder tools: use D3 maps when decision-makers need to inspect specific accounts and relationships.

Key improvements made to the original lab

  • Replaced the fragile line-86 graph literal with explicit vertex and edge tables.
  • Corrected the Farther typo by removing the ambiguous family-network labels.
  • Added package checks without automatic installation.
  • Added package-version reporting and sessionInfo() for reproducibility.
  • Namespaced igraph conversion functions to avoid package conflicts.
  • Added graph validation with stopifnot().
  • Made force-directed layouts reproducible with set.seed().
  • Replaced HTML-inappropriate latex_options with Bootstrap table options.
  • Repaired the undirected friend-of-friend logic by expanding both edge directions.
  • Replaced sparse exact-birthday matching with a stable birth-month example.
  • Used htmlwidgets::JS() explicitly in the D3 graph.
  • Integrated Section 5 directly into the required project flow.
  • Added visible comments explaining purpose, assumptions, and output.

Final knowledge check

Understanding Checkpoint
Which technique is closest to a People You May Know recommendation?



Reproducibility record

sessionInfo()
## R version 4.6.1 (2026-06-24)
## Platform: x86_64-pc-linux-gnu
## Running under: Ubuntu 24.04.4 LTS
## 
## Matrix products: default
## BLAS:   /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3 
## LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.26.so;  LAPACK version 3.12.0
## 
## locale:
##  [1] LC_CTYPE=C.UTF-8       LC_NUMERIC=C           LC_TIME=C.UTF-8       
##  [4] LC_COLLATE=C.UTF-8     LC_MONETARY=C.UTF-8    LC_MESSAGES=C.UTF-8   
##  [7] LC_PAPER=C.UTF-8       LC_NAME=C              LC_ADDRESS=C          
## [10] LC_TELEPHONE=C         LC_MEASUREMENT=C.UTF-8 LC_IDENTIFICATION=C   
## 
## time zone: UTC
## tzcode source: system (glibc)
## 
## attached base packages:
## [1] stats     graphics  grDevices utils     datasets  methods   base     
## 
## other attached packages:
##  [1] htmlwidgets_1.6.4 networkD3_0.4.1   scales_1.4.0      kableExtra_1.4.1 
##  [5] igraph_2.3.3      lubridate_1.9.5   forcats_1.0.1     stringr_1.6.0    
##  [9] dplyr_1.2.1       purrr_1.2.2       readr_2.2.0       tidyr_1.3.2      
## [13] tibble_3.3.1      ggplot2_4.0.3     tidyverse_2.0.0  
## 
## loaded via a namespace (and not attached):
##  [1] sass_0.4.10        generics_0.1.4     xml2_1.6.0         stringi_1.8.7     
##  [5] hms_1.1.4          digest_0.6.39      magrittr_2.0.5     timechange_0.4.0  
##  [9] evaluate_1.0.5     grid_4.6.1         RColorBrewer_1.1-3 fastmap_1.2.0     
## [13] jsonlite_2.0.0     checkdown_0.0.13   viridisLite_0.4.3  textshaping_1.0.5 
## [17] jquerylib_0.1.4    cli_3.6.6          rlang_1.3.0        litedown_0.10     
## [21] commonmark_2.0.0   withr_3.0.3        cachem_1.1.0       yaml_2.3.12       
## [25] otel_0.2.0         tools_4.6.1        tzdb_0.5.0         vctrs_0.7.3       
## [29] R6_2.6.1           lifecycle_1.0.5    pkgconfig_2.0.3    bslib_0.11.0      
## [33] pillar_1.11.1      gtable_0.3.6       glue_1.8.1         systemfonts_1.3.2 
## [37] xfun_0.60          tidyselect_1.2.1   data.tree_1.2.0    rstudioapi_0.19.0 
## [41] knitr_1.51         farver_2.1.2       htmltools_0.5.9    rmarkdown_2.31    
## [45] svglite_2.2.2      compiler_4.6.1     S7_0.2.2           markdown_2.0

References