Quick and dirty analysis of 10mila 2025 results

data analysis
data
orienteering
web scraping
suunnistus
R
dataviz
10mila
Tekijä
Julkaistu

4. toukokuuta 2025

A Swedish spring classic in orienteering 10mila was raced this weeked in Finspång in Skåne. Similar to 2023, NTNUI won the mens 10 leg overnight relay. I took a little stab at their results system at online.10mila.se/, pulled out the data and did two graphs. You can download the excel data with two sheets from here: 10mila leg times & split times. I included the source code in R in case someone is interested in it.

Here are the positions for top 10 teams at each exchange!

And below a very tall image with positions at each split point at each leg.

Source code

[/kode]
library(httr)
library(rvest)
library(dplyr)
library(ggplot2)
library(hrbrthemes)
library(viridis)


osuus <- 10
looptbl <- tibble(
  leg = 1:10,
  splits = list(list(1,2,3,4), # 1
                list(1,2,3,4), # 2
                list(1,2,3), # 3
                list(1,2,3,4), # 4
                list(1,2,3), # 5
                list(1,2,3), # 6
                list(1,2,3), # 7
                list(1,2,3,4), # 8
                list(1,2,3), # 9
                list(1,2,3,4)) # 10
)

# **********************************************************
# vaihtoajat
d_leg <- tibble()
for (leg in 1:osuus){
# for (leg in 1:10){
  read_html(paste0("http://online.10mila.se/index2.php?classId=1&legNo=",leg)) %>% 
    html_table(fill = TRUE) %>% 
    .[[5]] %>% 
    .[-1:-2,] %>% 
    as_tibble() -> tmp
  names(tmp) <- c("place","name","club","time","diff")
  tmp$leg <- leg
  d_leg <- bind_rows(d_leg,tmp)
}
d_leg %>% 
  mutate(
  min = as.integer(sub(":.+$", "", time)),
  s   = as.integer(sub("^.+:", "", time)),
  secs = min * 60 + s,
  diff_min = as.integer(sub(":.+$", "", diff)),
  diff_s   = as.integer(sub("^.+:", "", diff)),
  diff_secs = diff_min * 60 + diff_s
  ) -> d_leg2

leg_info <- tibble(leg = 1:10)
leg_info %>% 
  mutate(length = case_when(
  leg == 1 ~ 8.9,
  leg == 2 ~ 7.7,
  leg == 3 ~ 8.2,
  leg == 4 ~ 16.3,
  leg == 5 ~ 6.8,
  leg == 6 ~ 6.8,
  leg == 7 ~ 5.8,
  leg == 8 ~ 11.8,
  leg == 9 ~ 13.9,
  leg == 10 ~ 15.1
), 
length_cum = cumsum(length)) -> leg_info

d_leg22 <- left_join(d_leg2,leg_info)

d_leg_0 <- d_leg22 %>% 
  filter(leg == osuus) %>% 
  mutate(length_cum = 0, 
         diff_secs = 0,
         leg = 0)
d_leg_11 <- bind_rows(d_leg_0,
                      d_leg22)

all_legs <- d_leg_11 # for excel

top10 <- d_leg2 %>% filter(leg == osuus) %>% 
  slice(1:10) %>% 
  pull(club)
d_leg_111 <- d_leg_11 %>% filter(club %in% top10)

ipsum_palette <- c("#d18975", "#8fd175", "#3f2d54", "#75b8d1", "#2d543d", "#c9d175", "#d1ab75", "#d175b8", "#758bd1")
ipsum_palette <- c(ipsum_palette,ipsum_palette,ipsum_palette)

ggplot(d_leg_111, aes(x = length_cum, y = diff_secs/60, color = club, fill = club)) + 
  geom_line(alpha = .8) + geom_point(shape = 21, color = "white", alpha = .8, size = 4) + 
  # scale_x_continuous(breaks = leg_info$length_cum , labels = leg_info$leg) + 
  scale_y_reverse() +
  geom_text(aes(label = place), color = "white", family = "Roboto Condensed", size = 2.5, fontface= "bold") +
  theme_ipsum_rc() + 
  ggrepel::geom_text_repel(data = d_leg_111 %>% filter(leg == max(leg)), aes(label = club), nudge_y = -.2, size = 2.7, family = "Roboto Condensed") + 
  theme(legend.position = "none") + 
  # scale_fill_ipsum() + scale_color_ipsum() +
  scale_fill_manual(values = c(rev(ipsum_palette),"black")) + scale_color_manual(values = c(rev(ipsum_palette),"black")) +
   labs(title = "Top 25 teams in 10mila 2025",
        subtitle = "Time difference to lead in each exchange",
        caption = paste0("Data: online.10mila.se\n",Sys.time()),
          x = "distance (km)", y = "minutes behind the lead") -> plot
ggsave(filename = "./kuvat/10mila2025_top_ten_legs_twitter.png", plot, width = 8, heigh = 6, dpi = 120)


# **********************************************************
# väliajat

d_splits <- tibble()
for (leg in 1:osuus){
# for (leg in 1:10){
  splits <- unlist(looptbl$splits[leg])
  split_dat <- tibble()
  for (split1 in splits){
  read_html(paste0("http://online.10mila.se/index2.php?classId=1&legNo=",leg,"&splitNo=",split1)) %>% 
    html_table(fill = TRUE) %>% 
    .[[5]] %>% 
    .[-1:-2,] %>% 
    as_tibble() -> tmp
  names(tmp) <- c("place","name","club","time","diff")
  tmp$leg <- leg
  tmp$split1 <- split1
  split_dat <- bind_rows(split_dat,tmp)
}
  d_splits <- bind_rows(d_splits,split_dat)
}
d_splits %>% 
  mutate(
    min = as.integer(sub(":.+$", "", time)),
    s   = as.integer(sub("^.+:", "", time)),
    secs = min * 60 + s,
    diff_min = as.integer(sub(":.+$", "", diff)),
    diff_s   = as.integer(sub("^.+:", "", diff)),
    diff_secs = diff_min * 60 + diff_s
  ) -> d_splits2

split_info <- d_splits2 %>% count(leg,split1) %>% select(-n)
split_info %>% 
  mutate(length = case_when(
    leg == 1 & split1 ==  1 ~ 2.8,
    leg == 1 & split1 ==  2 ~ 4.9,
    leg == 1 & split1 ==  3 ~ 7.9,
    leg == 1 & split1 ==  4 ~ 8.4,

    leg == 2 & split1 == 1 ~ 2.4,
    leg == 2 & split1 == 2 ~ 4.5,
    leg == 2 & split1 == 3 ~ 6.3,
    leg == 2 & split1 == 4 ~ 7.7,

    leg == 3 & split1 == 1 ~ 2.4,
    leg == 3 & split1 == 2 ~ 4.5,
    leg == 3 & split1 == 3 ~ 7.7,

    leg == 4 & split1 == 1 ~ 6.3,
    leg == 4 & split1 == 2 ~ 8.8,
    leg == 4 & split1 == 3 ~ 11.0,
    leg == 4 & split1 == 4 ~ 15.8,

    leg == 5 & split1 == 1 ~ 1.8,
    leg == 5 & split1 == 2 ~ 4.5,
    leg == 5 & split1 == 3 ~ 6.3,

    leg == 6 & split1 == 1 ~ 1.8,
    leg == 6 & split1 == 2 ~ 4.5,
    leg == 6 & split1 == 3 ~ 6.3,

    leg == 7 & split1 == 1 ~ 1.8,
    leg == 7 & split1 == 2 ~ 3.9,
    leg == 7 & split1 == 3 ~ 5.3,

    leg == 8 & split1 == 1 ~ 3.0,
    leg == 8 & split1 == 2 ~ 6.0,
    leg == 8 & split1 == 3 ~ 8.5,
    leg == 8 & split1 == 4 ~ 11.3,

    leg == 9 & split1 == 1 ~ 5.7,
    leg == 9 & split1 == 2 ~ 9.8,
    leg == 9 & split1 == 2 ~ 13.4,

    leg == 10 & split1 == 1 ~ 5.7,
    leg == 10 & split1 == 2 ~ 7.8,
    leg == 10 & split1 == 3 ~ 10.9,
    leg == 10 & split1 == 4 ~ 14.6,

  )) %>% 
  group_by(leg) %>% 
  mutate(length_cum = cumsum(length)) -> split_info

d_splits_10 <- left_join(d_splits2,split_info)

d_leg0 <- all_legs %>% 
  filter(leg %in% 1:osuus) %>% 
  mutate(length = 0, 
         diff_secs = 0,
         leg = 1)
if (exists("d_leg_10")){
d_leg_11 <- bind_rows(all_legs,
                      d_leg_10)  
} else {
d_leg_11 <- all_legs  
}

d_leg_11_0 <- d_leg_11 %>% 
  mutate(length = 0, leg = leg + 1)

legdata <- bind_rows(d_leg_11,d_leg_11_0)

d_splits_10 <- bind_rows(d_splits_10,legdata)

d_splits_10 <- d_splits_10 %>% filter(leg != 0, leg != 11)

d_splits_10$leg2 <- paste0("Leg ", d_splits_10$leg)
d_splits_10$leg2 <- factor(d_splits_10$leg2, levels = c("Leg 1","Leg 2","Leg 3","Leg 4","Leg 5","Leg 6","Leg 7","Leg 8","Leg 9","Leg 10"))

all_splits <- d_splits_10 # exceliin


d_splits_10 <- d_splits_10 %>% filter(club %in% top10)

d_splits_10 <- d_splits_10 %>% filter(leg %in% 1:osuus)

ggplot(d_splits_10, aes(x = length, 
                        y = diff_secs/60,
                        # y = diff_secs, 
                        color = club, 
                        fill = club)) + 
  geom_line(alpha = .8) + geom_point(shape = 21, color = "white", alpha = .8, size = 4) + 
  ggrepel::geom_text_repel(data = d_splits_10 %>% 
                             group_by(leg2) %>% 
                             filter(length == max(length)), 
                           aes(label = paste0(name,"\n",club)), 
                           nudge_y = -.2, 
                           nudge_x = 1, 
                           size = 2.6, 
                           lineheight = .8, 
                           family = "Roboto Condensed") + 
  facet_wrap(~leg2, ncol = 1, scales = "free") +
  # scale_x_continuous(breaks = 0:18, labels = 0:18, limits = c(0,18)) +
  scale_x_continuous(breaks = 0:20, labels = 0:20, limits = c(0,20)) +
  scale_y_reverse() + 
  geom_text(aes(label = place), color = "white", family = "Roboto Condensed", size = 2.5, fontface= "bold") +
  theme_ipsum_rc() + 
  theme(legend.position = "none") + 
  scale_fill_manual(values = c(rev(ipsum_palette),"black")) + scale_color_manual(values = c(rev(ipsum_palette),"black")) +
  # theme(plot.margin = unit(0,0,0,0)) +
  # theme(margin(t = 0, r = 0, b = 0, l = 0, unit = "pt")) +
  labs(title = glue::glue("Top 10 teams in 10mila 2025 in legs 1-{osuus}"),
       subtitle = "Time difference to lead in each split",
       caption = paste0("Data: online.10mila.se\n",Sys.time()),
       x = "distance from exchange (km)", y = "minutes behind the lead") -> splitspic

ggsave(filename = glue::glue("./kuvat/10mila2025_splits_{osuus}.png"), splitspic, width = 12, height = 5+2.3*osuus, dpi = 120)

# excel_file
lista <- list()
legs <- unique(all_splits$leg2)
for (l in seq(legs)){
  lista[[l]] <- all_splits[all_splits$leg2 == legs[l], ]
}
writexl::write_xlsx(lista, "10mila2025_results.xlsx")

Uudelleenkäyttö

Viittaus

BibTeX-viittaus:
@online{kainu2025,
  author = {Kainu, Markus},
  title = {Quick and dirty analysis of 10mila 2025 results},
  date = {2025-05-04},
  url = {https://markuskainu.fi/posts/2025-05-04-10-mila-finspang/},
  langid = {fi}
}
Viitatkaa tähän teokseen seuraavasti:
Kainu, Markus. 2025. “Quick and dirty analysis of 10mila 2025 results.” May 4, 2025. https://markuskainu.fi/posts/2025-05-04-10-mila-finspang/.