Did it matter where the paper landed?

publishing
transparency
citations
data viz
I pulled citation histories for all 30 of my published papers and colour-coded them by journal type. Top-3 journals raise the floor, not the ceiling — and the most-cited paper in my record is the one that got lucky.
Author

Aaron Erlich

Published

August 10, 2026

A few weeks ago I put my entire Publication Record online. A colleague followed up with a question: did it actually matter where things landed?

Of course, this is a very difficult causal inference problem that I am not going to answer. But, I pulled Google Scholar citation histories for all 30 published papers and colour-coded them by the kind of journal that took them: the top 3 (AJPS/APSR/JOP), other general political science, subfield journals, area studies, interdisciplinary, and outside political science entirely.

I provide three charts because they answer different questions.

1. The raw picture, and why it’s nearly useless

One line per paper, running in calendar time. Vintage dominates this chart. “Total citations” mostly measures age: useful as a reminder, not as an answer. A 2015 APSR paper has had eleven years to accumulate, and everything else sits squashed underneath it. That paper was my early-career luck, and luck is something many have come to recognize as important in academia.

2. Aligned on publication — where the real pattern is

Here every paper restarts at its own year zero, faceted by journal type. Now the comparison between papers makes more sense, and one pattern survives it: the top 3 have a much higher floor.

At three years post-publication, my AJPS/APSR/JOP papers sit at 18, 26 and 55 citations. My subfield-journal papers at the same age run from 1 to 104.

The ceiling is not higher in the top 3. The basement is. For me, at least, that is not “publishing in the top 3 gets you cited more.” It is closer to “publishing in the top 3 seems to protect you from being ignored.”

Two things on that chart are worth explaining, because most citation graphics hide both:

Negative years are real. A paper circulates as a working paper long before it appears in an issue, and Scholar merges those citations into the published record. Four of my papers were cited before they were published; one of them five years before. Most citation tools quietly fold those into year zero, which both inflates the publication-year count and erases the fact that the working paper was doing work.

The last year is dashed. 2026 is incomplete. Drawing it solid would make every curve appear to flatten at exactly the same moment, which looks like a finding and is an artifact.

3. The arc — when papers actually peak

Cumulative curves can only go up, so they cannot show you when a paper peaked. This one can: it is citations per year, the rate rather than the running total.

It is also where the most unique paper in my record shows up. “Is pro-Kremlin Disinformation Effective? Evidence from Ukraine” came out in the International Journal of Press/Politics in November 2021 — three months before the full-scale invasion. It went 1 → 10 → 44 → 49 citations a year. It is my second-most-cited paper, and it is in a subfield journal.

It has also already turned. Year four is down to 39 and falling. Compare the APSR paper, which peaked around year six at 24 citations a year and was still pulling 14 a decade out. One caught a topic shock; the other got a slow general-audience burn.

I would rather have the second. But I did not choose either — that is rather the point.

What I got wrong when I first looked at this

Two things I was confident about did not survive contact with the data.

“One paper is a quarter of my citations” is a fact about me. My top paper is 21% of my total and my top three are 46%, and I assumed that lopsidedness said something. So I pulled the same numbers for ten co-author political scientists at my career stage or a little more senior. Median top-paper share: 23%. Median top-three share: 45%. I sit fifth of ten. The most concentrated person in the group has a single paper carrying 45% of their citations. Skew is not a personal quirk; it is the shape of the distribution for everyone.

I have a low median paper citation count. Those same profiles showed my median paper at 4 citations against a peer median of 12, and 55% of my entries under five citations against a peer median of 37%. That looked like a real and uncomfortable finding — normal top end, thin bottom end.

My first instinct was that my papers are just newer. That turned out to be wrong: restricting everyone to papers at least five years old did not improve the metric.

The actual explanation is duller and more important. I add lots of stuff to my Scholar profile. Indeed, my Scholar profile has 53 entries, but only 30 are published journal articles. The rest are working papers, an R package, conference drafts. Strip those out and my median goes from 4 to 8 and my under-five share from 55% to 33%. My peers’ profiles have the same problem in wildly different amounts — one of them lists 114 entries, another 27.

So the median-citations-per-paper comparison was mostly measuring how people curate their Scholar profiles. I have no way to clean nine other people’s profiles the way I can clean my own, which means I cannot make that comparison honestly at all. I have dropped it.

A few caveats

This blog post analyzes 30 papers across six categories, and several of those categories hold two or three papers. The area studies panel has exactly two. It describes just one publication record (mine).

It does not identify a causal effect of journal placement. Topic, coauthors and timing are doing enormous work that I cannot separate out, and the Ukraine paper is a good argument against reading any of this as strategy. If there is a lesson, it is about floors rather than ceilings, and even that rests on three papers.

Draw your own

None of this is specific to me, so, with the help of Claude Code, I packaged it up. Put in your ORCID (or search your name) and it draws the same three charts for your record, with your venues tiered against journals in your own topic area — so it works whether you are a political scientist, an economist or a biologist.

It runs entirely in your browser. Your identifier is sent to OpenAlex and nowhere else; there is no server of mine involved and nothing is logged. The first load pulls down a copy of R compiled to WebAssembly, so give it a moment.

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| viewerHeight: 720
#| components: [viewer]
## Citation-arcs widget — the browser-side (shinylive/webR) cut of
## github.com/aserlich/scholar-arcs. Self-contained on purpose: shinylive
## compiles one file, and the reader's browser runs it with no server involved.
##
## Deliberately uses jsonlite::fromJSON(url) rather than httr2. webR patches R's
## url() connections onto the browser's fetch, so that path works in WebAssembly;
## curl-based transports are far less reliable there. OpenAlex sends
## `access-control-allow-origin: *`, so the browser is allowed to read it.
##
## Trimmed relative to the full package: no Google Scholar (impossible in a
## browser — Scholar sends no CORS headers), no venue-tier editor, no peer
## sampling. Those live in the R package.

library(shiny)
library(bslib)
library(dplyr)
library(tidyr)
library(purrr)
library(tibble)
library(ggplot2)
library(jsonlite)

MAILTO <- "scholararcs@example.org"
HUES <- c("#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300")

oa <- function(path, query = list()) {
  q <- paste(names(query), vapply(query, utils::URLencode, character(1), reserved = TRUE),
             sep = "=", collapse = "&")
  url <- sprintf("https://api.openalex.org/%s?%s&mailto=%s", path, q, MAILTO)
  tryCatch(jsonlite::fromJSON(url, simplifyVector = FALSE), error = function(e) NULL)
}
oid <- function(x) if (is.null(x)) NA_character_ else sub("^https://openalex.org/", "", x)

# Returns list(id, name) on success, "malformed" / "notfound" / "unreachable" on
# failure — the caller needs to tell them apart. Reporting a network blip as "no
# such ORCID" sends people off checking an identifier that was fine.
#
# Uses the filter form rather than authors/https://orcid.org/{id}: that variant
# embeds a full URL inside the path, and intermediaries that normalise "//" break
# it. The filter form has no such trap.
resolve_orcid <- function(orcid) {
  orcid <- sub("^https?://orcid\\.org/", "", trimws(orcid))
  if (!grepl("^[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{3}[0-9X]$", orcid)) return("malformed")
  for (attempt in 1:2) {
    r <- oa("authors", list(filter = paste0("orcid:", orcid), per_page = "1"))
    if (!is.null(r$results)) {
      if (!length(r$results)) return("notfound")
      a <- r$results[[1]]
      return(list(id = oid(a$id), name = a$display_name %||% "?"))
    }
    Sys.sleep(1)
  }
  "unreachable"
}

search_authors <- function(name) {
  r <- oa("authors", list(search = name, per_page = "8"))
  if (is.null(r$results) || !length(r$results)) return(NULL)
  map_dfr(r$results, function(a) {
    inst <- a$last_known_institutions
    tibble(id = oid(a$id), name = a$display_name %||% "?",
           institution = if (length(inst)) (inst[[1]]$display_name %||% "—") else "—",
           works = as.integer(a$works_count %||% 0), cites = as.integer(a$cited_by_count %||% 0))
  })
}

fetch_works <- function(author_id) {
  r <- oa("works", list(
    filter = sprintf("author.id:%s,type:article", author_id),
    select = "id,display_name,publication_year,cited_by_count,counts_by_year,primary_location",
    `per-page` = "200"))
  if (is.null(r$results) || !length(r$results)) return(NULL)
  map_dfr(r$results, function(w) {
    cby <- w$counts_by_year %||% list()
    tibble(
      paper_id = oid(w$id),
      title    = w$display_name %||% "?",
      pub_year = as.integer(w$publication_year %||% NA),
      total    = as.integer(w$cited_by_count %||% 0),
      venue    = w$primary_location$source$display_name %||% NA_character_,
      counts   = list(if (length(cby))
        tibble(year = map_int(cby, ~ as.integer(.x$year)),
               cites = map_dbl(cby, ~ as.numeric(.x$cited_by_count)))
        else tibble(year = integer(), cites = numeric())))
  }) |> filter(!is.na(pub_year))
}

# One row per paper x year. Citations predating publication keep a negative
# offset; the current calendar year is carried but flagged partial.
build_series <- function(w) {
  this_year <- as.integer(format(Sys.Date(), "%Y"))
  long <- map_dfr(seq_len(nrow(w)), function(i) {
    cy <- w$counts[[i]]
    if (!nrow(cy)) cy <- tibble(year = w$pub_year[i], cites = 0)
    mutate(cy, paper_id = w$paper_id[i])
  }) |> filter(year <= this_year)

  w |> select(paper_id, title, pub_year, venue) |>
    left_join(summarise(group_by(long, paper_id), first = min(year), .groups = "drop"),
              by = "paper_id") |>
    mutate(start = pmin(pub_year, coalesce(first, pub_year)),
           year = map2(start, this_year, ~ seq.int(.x, .y))) |>
    unnest_longer(year) |>
    left_join(long, by = c("paper_id", "year")) |>
    mutate(cites = coalesce(cites, 0)) |>
    arrange(paper_id, year) |>
    group_by(paper_id) |>
    mutate(cum_cites = cumsum(cites)) |>
    ungroup() |>
    mutate(years_since_pub = year - pub_year,
           partial = year >= this_year,
           tier = "All papers")
}

# Tier venues against journals in the author's own topic area. Ranking against
# anything else -- a name search, or a sample of articles -- gives nonsense; see
# the package for the three attempts that failed.
tier_of <- function(w, author_id) {
  r <- oa("works", list(filter = sprintf("author.id:%s,type:article", author_id),
                        select = "primary_topic", `per-page` = "100"))
  tids <- if (!is.null(r$results))
    unique(na.omit(map_chr(r$results, ~ oid(.x$primary_topic$id)))) else character()
  if (!length(tids)) return(NULL)
  tids <- head(tids, 5)
  s <- oa("sources", list(
    filter = sprintf("topics.id:%s,type:journal,works_count:>50,summary_stats.2yr_mean_citedness:>0",
                     paste(tids, collapse = "|")),
    select = "display_name,summary_stats", `per-page` = "200", sort = "works_count:desc"))
  ref <- if (!is.null(s$results))
    na.omit(map_dbl(s$results, ~ as.numeric(.x$summary_stats$`2yr_mean_citedness` %||% NA))) else numeric()
  if (length(ref) < 20) return(NULL)
  brk <- quantile(ref, c(0.90, 0.75, 0.50), na.rm = TRUE)
  labs <- c("Top decile", "Top quartile", "Upper half", "Rest")

  vn <- unique(na.omit(w$venue))
  vs <- oa("sources", list(filter = paste0("display_name.search:", paste(vn[1], collapse = "")),
                           select = "display_name", `per-page` = "1"))  # warm-up, ignored
  cite_of <- setNames(rep(NA_real_, length(vn)), vn)
  # Look venues up in bulk by name; OpenAlex has no multi-name filter, so pull the
  # author's own sources from their works instead.
  sr <- oa("works", list(filter = sprintf("author.id:%s,type:article", author_id),
                         select = "primary_location", `per-page` = "200"))
  sids <- unique(na.omit(map_chr(sr$results %||% list(), ~ oid(.x$primary_location$source$id))))
  if (!length(sids)) return(NULL)
  st <- oa("sources", list(filter = paste0("ids.openalex:", paste(sids, collapse = "|")),
                           select = "display_name,summary_stats", `per-page` = "200"))
  if (is.null(st$results)) return(NULL)
  lut <- map_dfr(st$results, ~ tibble(
    venue = .x$display_name %||% NA_character_,
    citedness = as.numeric(.x$summary_stats$`2yr_mean_citedness` %||% NA)))
  lut |> mutate(tier = vapply(citedness, function(c) {
    if (is.na(c)) return("Rest")
    labs[which(c >= brk)[1] %||% length(labs)] }, character(1)),
    tier = ifelse(is.na(tier), "Rest", tier)) |>
    select(venue, tier)
}

pal <- function(g) setNames(rep(HUES, length.out = nlevels(g)), levels(g))

base_theme <- theme_minimal(base_size = 12) +
  theme(panel.grid.minor = element_blank(),
        panel.grid.major = element_line(colour = "#e1e0d9", linewidth = 0.3),
        strip.text = element_text(face = "bold", size = 9, hjust = 0),
        plot.title = element_text(face = "bold"),
        plot.subtitle = element_text(size = 8, colour = "#52514e"))

layers_of <- function(s, xv) list(
  solid  = filter(s, !partial),
  bridge = s |> group_by(paper_id) |> filter(any(partial)) |>
    filter(years_since_pub >= max(c(years_since_pub[!partial], -Inf))) |> ungroup(),
  ends   = s |> filter(!partial) |> group_by(paper_id) |>
    slice_max({{ xv }}, n = 1, with_ties = FALSE) |> ungroup())

ui <- page_sidebar(
  title = "Citation arcs",
  theme = bs_theme(version = 5, primary = "#2a78d6"),
  sidebar = sidebar(
    width = 300,
    textInput("orcid", "Your ORCID", placeholder = "0000-0001-6571-9081"),
    actionButton("go", "Draw my arcs", class = "btn-primary btn-sm"),
    hr(),
    textInput("nm", "…or search by name"),
    actionButton("srch", "Search", class = "btn-sm"),
    uiOutput("cands"),
    hr(),
    div(class = "small text-muted",
        "Runs entirely in your browser using OpenAlex — nothing is sent anywhere else. ",
        "OpenAlex reports fewer citations than Google Scholar but ranks papers similarly.")),
  uiOutput("msg"),
  navset_card_tab(
    nav_panel("Aligned on publication",
      # position:relative anchors the absolutely-positioned tooltip to the plot,
      # so it floats over the chart at the cursor instead of pushing layout below.
      div(style = "position:relative;",
          plotOutput("p2", height = "520px",
                     hover = hoverOpts("h2", delay = 60, delayType = "debounce")),
          uiOutput("tip2")),
      helpText("Every paper restarted at its own year zero. x < 0 means it was cited as a preprint. ",
               "Hover a line to see which paper it is.")),
    nav_panel("The arc",
      div(style = "position:relative;",
          plotOutput("p3", height = "520px",
                     hover = hoverOpts("h3", delay = 60, delayType = "debounce")),
          uiOutput("tip3")),
      helpText("Citations per year, not cumulative — the only view that shows when a paper peaked.")),
    nav_panel("Calendar time",
      div(style = "position:relative;",
          plotOutput("p1", height = "520px",
                     hover = hoverOpts("h1", delay = 60, delayType = "debounce")),
          uiOutput("tip1")),
      helpText("Dominated by age: older papers have had longer to accumulate."))))

server <- function(input, output, session) {
  rv <- reactiveValues(s = NULL, msg = NULL, cands = NULL)
  say <- function(t, ok = TRUE) rv$msg <- list(t = t, ok = ok)

  output$msg <- renderUI({
    m <- rv$msg; if (is.null(m)) return(NULL)
    div(class = paste("alert py-2", if (m$ok) "alert-info" else "alert-danger"), m$t)
  })

  load_author <- function(id, label) {
    withProgress(message = "Fetching from OpenAlex", value = 0.3, {
      w <- fetch_works(id)
      if (is.null(w) || !nrow(w)) return(say("No journal articles found for that profile.", FALSE))
      setProgress(0.7, detail = "tiering venues")
      tv <- tryCatch(tier_of(w, id), error = function(e) NULL)
      s <- build_series(w)
      if (!is.null(tv) && nrow(tv)) {
        s <- s |> left_join(tv, by = "venue") |>
          mutate(tier = coalesce(tier.y, "Rest")) |> select(-tier.x, -tier.y)
      }
      lv <- intersect(c("Top decile", "Top quartile", "Upper half", "Rest", "All papers"),
                      unique(s$tier))
      rv$s <- mutate(s, tier = factor(tier, levels = lv))
      say(sprintf("%s — %d papers, %d citations.", label, n_distinct(w$paper_id), sum(w$total)))
    })
  }

  observeEvent(input$go, {
    req(nzchar(input$orcid))
    a <- resolve_orcid(input$orcid)
    if (identical(a, "malformed"))
      return(say("That does not look like an ORCID. They run 0000-0000-0000-0000.", FALSE))
    if (identical(a, "notfound"))
      return(say("That ORCID is valid but OpenAlex has no author record attached to it. Try searching your name instead.", FALSE))
    if (identical(a, "unreachable"))
      return(say("Could not reach OpenAlex just now — your ORCID is probably fine. Try again in a moment.", FALSE))
    load_author(a$id, a$name)
  })

  observeEvent(input$srch, {
    req(nzchar(input$nm))
    c <- search_authors(input$nm)
    if (is.null(c)) return(say("No authors matched.", FALSE))
    rv$cands <- c
    output$cands <- renderUI(tagList(
      helpText("Pick your profile — check the work count; OpenAlex sometimes merges two people."),
      radioButtons("cand", NULL, choiceValues = c$id, selected = character(0),
        choiceNames = lapply(seq_len(nrow(c)), function(i) HTML(sprintf(
          "<b>%s</b><br><span class='small text-muted'>%s · %d works</span>",
          c$name[i], c$institution[i], c$works[i])))),
      actionButton("pick", "Use this profile", class = "btn-primary btn-sm")))
  })

  observeEvent(input$pick, {
    req(input$cand)
    r <- filter(rv$cands, id == input$cand)
    load_author(r$id[1], r$name[1])
  })

  facet_if <- function(s) if (n_distinct(s$tier) > 1) facet_wrap(~tier, ncol = 2) else NULL

  # Tooltip that floats over the plot at the cursor. Returns NULL when nothing is
  # under the pointer, so it occupies no layout and reserves no blank space.
  # nearPoints() resolves the facet itself, since `tier` is a column of the data.
  hover_tip <- function(hv, df, xv, yv, valfmt) {
    if (is.null(hv) || is.null(hv$coords_css)) return(NULL)
    np <- nearPoints(df, hv, xvar = xv, yvar = yv, threshold = 30, maxpoints = 1)
    if (!nrow(np)) return(NULL)

    # Flip to the left of the cursor near the right edge, and upward near the
    # bottom, so the tooltip never runs off the plot.
    ratio <- hv$img_css_ratio$x %||% 1
    w_css <- (hv$range$right %||% 0) / ratio
    h_css <- (hv$range$bottom %||% 0) / (hv$img_css_ratio$y %||% 1)
    flip_x <- w_css > 0 && hv$coords_css$x > w_css * 0.62
    flip_y <- h_css > 0 && hv$coords_css$y > h_css * 0.80

    pos <- paste0(
      if (flip_x) sprintf("right:%.0fpx;", w_css - hv$coords_css$x + 14)
      else        sprintf("left:%.0fpx;",  hv$coords_css$x + 14),
      if (flip_y) sprintf("bottom:%.0fpx;", h_css - hv$coords_css$y + 14)
      else        sprintf("top:%.0fpx;",    hv$coords_css$y + 14))

    div(style = paste0(
          "position:absolute; z-index:200; pointer-events:none; ", pos,
          "max-width:24rem; background:rgba(255,255,255,.97); ",
          "border:1px solid #c3c2b7; border-left:3px solid #2a78d6; border-radius:4px; ",
          "padding:.4rem .6rem; font-size:.78rem; line-height:1.35; ",
          "box-shadow:0 2px 8px rgba(0,0,0,.18);"),
        tags$b(np$title[1]),
        tags$br(),
        tags$span(style = "color:#52514e;",
                  sprintf("%s · %d · %s",
                          np$venue[1] %||% "venue unknown", np$pub_year[1], valfmt(np))))
  }

  hov_df <- reactive(filter(req(rv$s), !partial))

  output$tip1 <- renderUI(hover_tip(
    input$h1, hov_df(), "year", "cum_cites",
    function(p) sprintf("%d citations by %d", p$cum_cites[1], p$year[1])))

  output$tip2 <- renderUI(hover_tip(
    input$h2, hov_df(), "years_since_pub", "cum_cites",
    function(p) sprintf("%d citations %d yrs after publication",
                        p$cum_cites[1], p$years_since_pub[1])))

  output$tip3 <- renderUI(hover_tip(
    input$h3, hov_df(), "years_since_pub", "cites",
    function(p) sprintf("%d citations in year %d", p$cites[1], p$years_since_pub[1])))

  output$p1 <- renderPlot({
    s <- req(rv$s); L <- layers_of(s, year)
    ggplot(mapping = aes(year, cum_cites, group = paper_id, colour = tier)) +
      geom_line(data = L$solid, linewidth = 0.7) +
      geom_line(data = L$bridge, linewidth = 0.7, linetype = "22") +
      geom_point(data = L$ends, size = 1.1) +
      scale_colour_manual(values = pal(s$tier), name = NULL) +
      labs(title = "Cumulative citations, calendar time", x = NULL, y = "Cumulative citations",
           subtitle = "Dashed tail = the current, incomplete year") +
      base_theme + theme(legend.position = "top")
  }, res = 100)

  output$p2 <- renderPlot({
    s <- req(rv$s); L <- layers_of(s, years_since_pub)
    ctx <- s |> filter(!partial) |> select(paper_id, years_since_pub, cum_cites)
    ggplot(mapping = aes(years_since_pub, cum_cites, group = paper_id, colour = tier)) +
      geom_line(data = ctx, aes(years_since_pub, cum_cites, group = paper_id),
                inherit.aes = FALSE, colour = "#e1e0d9", linewidth = 0.35) +
      geom_vline(xintercept = 0, colour = "#c3c2b7", linewidth = 0.4) +
      geom_line(data = L$solid, linewidth = 0.7) +
      geom_line(data = L$bridge, linewidth = 0.7, linetype = "22") +
      geom_point(data = L$ends, size = 1.1) +
      facet_if(s) +
      scale_colour_manual(values = pal(s$tier), guide = "none") +
      labs(title = "Cumulative citations by years since publication",
           subtitle = "Grey = all papers · x < 0 = cited before publication",
           x = "Years since publication", y = "Cumulative citations") +
      base_theme
  }, res = 100)

  output$p3 <- renderPlot({
    s <- req(rv$s) |> filter(!partial)
    ctx <- select(s, paper_id, years_since_pub, cites)
    med <- s |> filter(years_since_pub >= 0) |>
      group_by(tier, years_since_pub) |>
      summarise(m = median(cites), n = n_distinct(paper_id), .groups = "drop") |>
      arrange(tier, years_since_pub) |> group_by(tier) |> filter(cumall(n >= 3)) |> ungroup()
    ggplot(mapping = aes(years_since_pub, cites, group = paper_id, colour = tier)) +
      geom_line(data = ctx, aes(years_since_pub, cites, group = paper_id),
                inherit.aes = FALSE, colour = "#e1e0d9", linewidth = 0.35) +
      geom_vline(xintercept = 0, colour = "#c3c2b7", linewidth = 0.4) +
      geom_line(data = s, linewidth = 0.7) +
      geom_point(data = s |> group_by(paper_id) |> filter(n() == 1) |> ungroup(), size = 1.1) +
      { if (nrow(med)) geom_line(data = med, aes(years_since_pub, m), inherit.aes = FALSE,
                                 colour = "#0b0b0b", linewidth = 0.9, linetype = "22") } +
      facet_if(s) +
      scale_colour_manual(values = pal(s$tier), guide = "none") +
      labs(title = "The arc: citations per year",
           subtitle = "Annual rate · dashed black = tier median while n ≥ 3 · partial year excluded",
           x = "Years since publication", y = "Citations that year") +
      base_theme
  }, res = 100)
}

shinyApp(ui, server)

A caveat on the numbers it shows you. This runs on OpenAlex, not Google Scholar, because Scholar has no API and blocks anything that is not a person at a browser. OpenAlex will report fewer citations than you are used to seeing — about two thirds, on my record. It ranks papers almost identically though (Spearman 0.94), and all three charts are about shape rather than level, so the picture holds even though the axis is smaller.

If you want the Scholar numbers you recognise, you have to run it on your own machine, and the Citation Arcs page has the two-minute setup — along with the list of things I already know are weak, if you would rather tell me what broke.

The code is at github.com/aserlich/scholar-arcs. The R package there does more than the widget: a Google Scholar backend for exact numbers when you run it on your own machine, an editable venue grouping, PNG and CSV export, and a peer-comparison view that samples researchers in your area so you can find out whether your own “one paper is a quarter of my citations” is unusual. (Mine was not.)

How this was built

I should say plainly that I did not write most of this code. The analysis, the charts and the package were built with Claude Code, with me making the calls that actually mattered: which journals belong in which group, that year zero should be first-published-online rather than the print date, that the comparison had to be anonymised, and which claims were strong enough to keep. Several of the findings above exist because the model pushed back on what I asked for — the “my median is low because my papers are just new” explanation was mine, and it was wrong; the data said so, and the claim had to go.

I could have written all of this R code. Many of us in quantitative political science could. What I did not have was three uninterrupted days to write it, and that is the actual constraint on most of the work like this academics would like to do but never do. The scarce input was not the R. It was the time to design and write the code.

That is the shift worth noticing. Not that the analysis became possible, but that it became cheap enough to be worth doing on a Sunday for a blog post.

Tell me what is wrong with it

I would genuinely like this to be better, and there are several things I know are weak:

  • Venue tiering is the shakiest part. It ranks each journal against journals in your topic area by two-year mean citedness. I tried three reference populations before one behaved — the failures are documented in the code — and I am not confident the surviving one is right for fields with different citation cultures. If it puts your journals somewhere absurd, that is a bug worth reporting.
  • OpenAlex sometimes merges two researchers into one profile. If your results look like someone else’s career is mixed into yours, that is why, and I would like to know how often it happens.
  • Small groups give medians over one or two papers. The charts draw them anyway. I have not found a presentation that makes the thinness visible without cluttering everything else.
  • The peer comparison samples on topic and output only. Career stage, subfield and coauthorship norms all matter and none are matched on.

Issues and pull requests are welcome at the repository, or just tell me what broke.

All of it is in R, generated from the same tidy submission log behind the Publication Record.