knitr::opts_chunk$set( collapse = TRUE, comment = '#>', fig.align = 'center', out.width = '92%', fig.width = 7, fig.height = 4.8 ) make_table <- function(x, caption, digits = 3) { knitr::kable(x, caption = caption, digits = digits) }
"Good team" is not a style. Some good teams bury opponents in offensive-zone time. Some attack with speed. Some win with interior volume. Some create fear from the blue line and circles because every hard shot can become a rebound.
The NHL EDGE wrappers let us compare those styles directly. This article asks:
Do several elite 2024-25 teams manufacture offense in the same way, or do they have distinct tracking-data fingerprints?
We will compare five strong clubs:
CAR) as the territorial-pressure candidate.COL) as the pace candidate.EDM) as the dangerous-volume candidate.FLA) as the force/interior-pressure candidate.WSH) as the counterexample that won plenty without fitting
every tracking stereotype.Before building profiles, look at a few league-leading team EDGE categories.
# Pull team EDGE leaders. edge_leaders <- nhlscraper::team_edge_leaders( season = 20242025, game_type = 2 ) # Build compact leader table. leader_table <- data.frame( metric = c( 'Shots over 90 mph', 'Bursts over 22 mph', 'Distance per 60', 'High-danger shots on goal', 'Offensive-zone time', 'Neutral-zone time', 'Defensive-zone time' ), team = c( edge_leaders[['shotAttemptsOver90']][['team']][['abbrev']], edge_leaders[['burstsOver22']][['team']][['abbrev']], edge_leaders[['distancePer60']][['team']][['abbrev']], edge_leaders[['highDangerSOG']][['team']][['abbrev']], edge_leaders[['offensiveZoneTime']][['team']][['abbrev']], edge_leaders[['neutralZoneTime']][['team']][['abbrev']], edge_leaders[['defensiveZoneTime']][['team']][['abbrev']] ), value = c( as.character(edge_leaders[['shotAttemptsOver90']][['attempts']]), as.character(edge_leaders[['burstsOver22']][['bursts']]), sprintf('%.2f miles', edge_leaders[['distancePer60']][['distanceSkated']][['imperial']]), as.character(edge_leaders[['highDangerSOG']][['sog']]), sprintf('%.3f', edge_leaders[['offensiveZoneTime']][['zoneTime']]), sprintf('%.3f', edge_leaders[['neutralZoneTime']][['zoneTime']]), sprintf('%.3f', edge_leaders[['defensiveZoneTime']][['zoneTime']]) ), stringsAsFactors = FALSE ) make_table( leader_table, caption = 'Selected 2024-25 team EDGE leaders.' )
The leader table is already a warning against one-size-fits-all thinking. The same team does not lead every tracking category.
We will fetch four team-level views: zone time, skating speed, shot speed, and shot location. A few helper functions make the article more resilient when a nested EDGE response is temporarily incomplete.
# Define team set. team_ids <- c(CAR = 12, COL = 21, EDM = 22, FLA = 13, WSH = 15) # Define robust helpers. fetch_with_retry <- function(fetch_fun, validator, tries = 3) { for (i in seq_len(tries)) { value <- try(fetch_fun(), silent = TRUE) if (!inherits(value, 'try-error') && validator(value)) { return(value) } Sys.sleep(i / 4) } NULL } valid_df <- function(x, required_cols) { is.data.frame(x) && nrow(x) > 0 && all(required_cols %in% names(x)) } safe_num <- function(x) { out <- suppressWarnings(as.numeric(x)) ifelse(is.na(out), NA_real_, out) } safe_name <- function(first_name, last_name) { if ( is.na(first_name) || is.na(last_name) || first_name == '' || last_name == '' ) { return(NA_character_) } paste(first_name, last_name) } safe_summary_num <- function(x, path) { value <- tryCatch({ for (nm in path) x <- x[[nm]] x }, error = function(e) NA_real_) safe_num(value) } # Build one profile row. build_team_profile <- function(team_code, team_id) { team_summary <- fetch_with_retry( function() nhlscraper::team_edge_summary( team = team_id, season = 20242025, game_type = 2 ), function(x) is.list(x) ) zone_rows <- fetch_with_retry( function() nhlscraper::team_edge_zone_time( team = team_id, season = 20242025, game_type = 2, category = 'details' ), function(x) valid_df(x, c('strengthCode', 'offensiveZonePctg')) ) skating_rows <- fetch_with_retry( function() nhlscraper::team_edge_skating_speed( team = team_id, season = 20242025, game_type = 2, category = 'details' ), function(x) valid_df(x, c('positionCode', 'maxSkatingSpeed.imperial', 'burstsOver22.value')) ) shot_speed_rows <- fetch_with_retry( function() nhlscraper::team_edge_shot_speed( team = team_id, season = 20242025, game_type = 2, category = 'details' ), function(x) valid_df(x, c('position', 'topShotSpeed.imperial', 'shotAttempts90To100.value')) ) shot_location_rows <- fetch_with_retry( function() nhlscraper::team_edge_shot_location( team = team_id, season = 20242025, game_type = 2, category = 'details' ), function(x) valid_df(x, c('area', 'sog')) ) if ( is.null(zone_rows) || is.null(skating_rows) || is.null(shot_speed_rows) || is.null(shot_location_rows) ) { return(data.frame( team = team_code, points = safe_summary_num(team_summary, c('team', 'points')), wins = safe_summary_num(team_summary, c('team', 'wins')), offensiveZonePctg = NA_real_, maxSkatingSpeed = NA_real_, burstsOver22 = NA_real_, shotAttemptsOver90 = NA_real_, hardestShot = NA_real_, trackedShots = NA_real_, interiorShare = NA_real_, circleShare = NA_real_, pointShare = NA_real_, fastestSkater = NA_character_, hardestShooter = NA_character_, stringsAsFactors = FALSE )) } zone_row <- zone_rows[zone_rows[['strengthCode']] == 'all', , drop = FALSE] skating_row <- skating_rows[skating_rows[['positionCode']] == 'all', , drop = FALSE] shot_speed_row <- shot_speed_rows[shot_speed_rows[['position']] == 'all', , drop = FALSE] if (!nrow(zone_row)) zone_row <- zone_rows[1, , drop = FALSE] if (!nrow(skating_row)) skating_row <- skating_rows[1, , drop = FALSE] if (!nrow(shot_speed_row)) shot_speed_row <- shot_speed_rows[1, , drop = FALSE] interior_mask <- shot_location_rows[['area']] %in% c( 'Crease', 'Low Slot', 'L Net Side', 'R Net Side' ) circle_mask <- shot_location_rows[['area']] %in% c( 'High Slot', 'L Circle', 'R Circle' ) point_mask <- shot_location_rows[['area']] %in% c( 'Center Point', 'L Point', 'R Point', 'Outside L', 'Outside R', 'Beyond Red Line' ) total_shots <- sum(shot_location_rows[['sog']], na.rm = TRUE) data.frame( team = team_code, points = safe_summary_num(team_summary, c('team', 'points')), wins = safe_summary_num(team_summary, c('team', 'wins')), offensiveZonePctg = safe_num(zone_row[['offensiveZonePctg']][1]), maxSkatingSpeed = safe_num(skating_row[['maxSkatingSpeed.imperial']][1]), burstsOver22 = safe_num(skating_row[['burstsOver22.value']][1]), shotAttemptsOver90 = sum( safe_num(shot_speed_row[['shotAttemptsOver100.value']][1]), safe_num(shot_speed_row[['shotAttempts90To100.value']][1]), na.rm = TRUE ), hardestShot = safe_num(shot_speed_row[['topShotSpeed.imperial']][1]), trackedShots = total_shots, interiorShare = sum(shot_location_rows[['sog']][interior_mask], na.rm = TRUE) / total_shots, circleShare = sum(shot_location_rows[['sog']][circle_mask], na.rm = TRUE) / total_shots, pointShare = sum(shot_location_rows[['sog']][point_mask], na.rm = TRUE) / total_shots, fastestSkater = safe_name( skating_row[['maxSkatingSpeed.overlay.player.firstName.default']][1], skating_row[['maxSkatingSpeed.overlay.player.lastName.default']][1] ), hardestShooter = safe_name( shot_speed_row[['topShotSpeed.overlay.player.firstName.default']][1], shot_speed_row[['topShotSpeed.overlay.player.lastName.default']][1] ), stringsAsFactors = FALSE ) } # Build profile table. team_profiles <- Map( build_team_profile, team_code = names(team_ids), team_id = unname(team_ids) ) team_profiles <- do.call(rbind, team_profiles) rownames(team_profiles) <- NULL profile_table <- team_profiles[, c( 'team', 'points', 'wins', 'offensiveZonePctg', 'maxSkatingSpeed', 'burstsOver22', 'shotAttemptsOver90', 'hardestShot', 'interiorShare' )] make_table( profile_table, caption = 'Five-team 2024-25 EDGE profile comparison.', digits = 3 )
Raw metrics have different scales, so a quick within-sample rescale makes the
profiles easier to compare. A score of 1 means best among these five teams; a
score of 0 means lowest among these five teams.
# Rescale profile metrics within sample. rescale01 <- function(x) { rng <- range(x, na.rm = TRUE) if (!all(is.finite(rng)) || diff(rng) == 0) { return(rep(NA_real_, length(x))) } (x - rng[1]) / diff(rng) } scorecard <- data.frame( team = team_profiles[['team']], territory = rescale01(team_profiles[['offensiveZonePctg']]), pace = rescale01(team_profiles[['burstsOver22']]), shotPower = rescale01(team_profiles[['shotAttemptsOver90']]), interior = rescale01(team_profiles[['interiorShare']]), hardestShot = rescale01(team_profiles[['hardestShot']]), stringsAsFactors = FALSE ) make_table( scorecard, caption = 'Within-sample EDGE archetype scores.', digits = 3 )
# Plot archetype scorecard. score_matrix <- t(as.matrix(scorecard[, -1])) colnames(score_matrix) <- scorecard[['team']] graphics::barplot( score_matrix, beside = TRUE, col = c('#264653', '#2a9d8f', '#e9c46a', '#f4a261', '#e76f51'), ylim = c(0, 1.35), ylab = 'Within-Sample Score', xlab = 'Team' ) graphics::legend( 'top', legend = rownames(score_matrix), fill = c('#264653', '#2a9d8f', '#e9c46a', '#f4a261', '#e76f51'), bty = 'n', cex = 0.75, ncol = 3 )
This is the article's main picture. Carolina's offense looks territorial. Colorado's looks fast. Florida's profile carries shot-power and interior pressure. Edmonton brings the dangerous-volume flavor. Washington is the useful reminder that winning teams do not have to max out every tracking trait.
Shot mix gives the scorecard some texture. Interior share is not the same thing as total offense; it is the shape of the tracked attempts.
# Plot shot-location mix. shot_mix <- t(as.matrix(team_profiles[, c( 'interiorShare', 'circleShare', 'pointShare' )])) colnames(shot_mix) <- team_profiles[['team']] rownames(shot_mix) <- c('Interior', 'Circles / slot', 'Points / perimeter') graphics::barplot( shot_mix, beside = FALSE, col = c('#1b4332', '#52b788', '#b7e4c7'), ylim = c(0, 1.18), ylab = 'Share of Tracked Shots', xlab = 'Team' ) graphics::legend( 'top', legend = rownames(shot_mix), fill = c('#1b4332', '#52b788', '#b7e4c7'), bty = 'n', cex = 0.8, horiz = TRUE )
The shot mix keeps the style comparison grounded. A team can be fast without living at the net front. A team can own territory without having the hardest shot profile. The categories overlap, but they do not collapse into one generic "good offense" number.
Tracking data become more interesting when team traits are tied back to players.
# Show players behind extreme traits. player_table <- team_profiles[, c( 'team', 'fastestSkater', 'maxSkatingSpeed', 'hardestShooter', 'hardestShot' )] make_table( player_table, caption = 'Players behind each team profile.' )
This is why the EDGE wrappers are useful beyond leaderboards. They let you move from team identity to the players who create that identity without leaving R.
Elite offense is plural. Carolina can look elite through zone-time dominance. Colorado can look elite through pace. Florida can look elite through force and interior pressure. Edmonton can look elite through dangerous-volume traits. Washington can win enough to belong in the conversation while reminding us that the cleanest tracking profile is not the only path to results.
That is the broader nhlscraper lesson: the NHL EDGE endpoints are not just
novel leaderboards. They are style data. Once scraped into tables, they let you
ask whether teams that land near each other in the standings actually play the
same sport in the same way.
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.