I’ve been wanting to understand Europe’s wildfire season better for a while. This summer it hit closer to home: I’m French, and the fires in Aude really stuck with me—along with the big outbreaks in Spain. Rather than skim headlines, I wanted a grounded view of where things burned, how much, when, and what actually burned.
So I pulled together a simple, reproducible dataset and built a few maps and charts for Summer 2025 (June–August) across Europe. The core comes from EFFIS rapid perimeters—the publicly mapped fire polygons that are typically ≥ 30–50 ha. I tag each perimeter to a country by maximum overlap with national borders and compute burned area directly from geometry in an equal-area projection (EPSG:3035). Where useful, I compare 2025 to 2017, 2022, and 2023 to see how this season stacks up.
What’s in the data (local files):
data/effis_layer/modis.ba.poly.shp — EFFIS burn perimeters with dates and land-cover shares (e.g., broad-leaved, conifer, mixed, sclerophyllous, agriculture, artificial).
Important caveats: rapid perimeters undercount small fires; mapping is updated over time; country assignment uses geometry overlap; and any comparison with official stats will differ because of thresholds, timing, and criteria. Think of this as a transparent, apples-to-apples look at mapped polygons, not a replacement for national reporting.
With that, let’s explore: a Europe-wide “hero” map of 2025 burn scars, monthly panels, a daily activity timeline, country top-10, multi-year comparisons, and a “what burned?” breakdown by land cover.
1 Setup
All following block will maybe be complete and so redundant with the ones before, I wanted to make it easier to understand in case someone would want to use the code for other purposes.
Show code
# wildfires_2025_pretty_plus_composition.R# Local-only workflow with land-cover composition# Data layout expected:# data/effis_layer/modis.ba.poly.shp (+ .dbf/.shx/…)# data/report_2023.xlsx (optional, country totals)# -------------------- Packages --------------------pkgs <-c("sf","dplyr","stringr","lubridate","ggplot2","readxl","forcats","rnaturalearth","rnaturalearthdata","janitor","scales","patchwork","tidyr")to_i <- pkgs[!(pkgs %in%rownames(installed.packages()))]if(length(to_i)) install.packages(to_i, Ncpus =max(1, parallel::detectCores()-1))suppressPackageStartupMessages({library(sf); library(dplyr); library(stringr); library(lubridate); library(ggplot2)library(readxl); library(forcats); library(rnaturalearth); library(janitor)library(scales); library(patchwork); library(tidyr)})lab_si_ha <- scales::label_number(scale_cut = scales::cut_si("ha"))# -------------------- Paths --------------------shp_path <-"DATA/effis_layer/modis.ba.poly.shp"ctry_tot_2023 <-"DATA/report_2023.xlsx"stopifnot(file.exists(shp_path))# -------------------- Helpers --------------------to_num <-function(x){if (is.numeric(x)) return(x) x <-trimws(as.character(x)) x <-gsub("\u00A0|\\s", "", x) # remove spaces / NBSP x <-gsub(",", ".", x) # EU decimal comma -> dot readr::parse_number(x, locale = readr::locale(decimal_mark ="."))}parse_date_any <-function(x){if (inherits(x,"Date")) return(x)if (inherits(x,"POSIXt")) return(as.Date(x)) s <-as.character(x); s <-substr(s, 1, 19) y <-suppressWarnings(lubridate::parse_date_time( s, orders =c("Ymd","Y-m-d","dmy","d-m-Y","m/d/Y","d/m/Y","Ymd HMS","Y-m-d H:M:S") ))as.Date(y)}# -------------------- Europe polygons (EU27 + EFTA + UK) via iso_a2_eh --------------------eu_keep <-c("AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU","IE","IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE", # EU27"NO","IS","CH","LI", # EFTA"GB", # UK"BA","RS","MK","ME","XK","AL")europe_bbox <-st_as_sfc(st_bbox(c( xmin =-15, xmax =40, # longitudes ymin =34, ymax =72# latitudes ), crs =4326))eu_poly <- rnaturalearth::ne_countries(scale ="large", returnclass ="sf") |>filter(iso_a2_eh %in% eu_keep) |>st_intersection(europe_bbox) |># keep only mainland Europest_transform(3035)eu_u <-st_union(eu_poly) # union for stable clipping# -------------------- Read burnt-area perimeters --------------------ba_all <-suppressWarnings(st_read(shp_path, quiet =TRUE)) |> janitor::clean_names()# Detect & parse datecand_names <-tolower(names(ba_all))wanted <-c("firedate","lastupdate","acq_date","acqdate","date","startdate","start_date")hit <-intersect(wanted, cand_names)if (!length(hit)) stop("No date-like column found in shapefile; available: ", paste(names(ba_all), collapse=", "))date_col <- hit[1]ba_all <- ba_all |>mutate(ba_date =parse_date_any(.data[[date_col]])) |>filter(!is.na(ba_date))# Ensure an IDif (!"id"%in%names(ba_all)) ba_all <- ba_all |>mutate(id =row_number())# -------------------- Filter Summer 2025 and project --------------------summer_start <-as.Date("2025-06-01"); summer_end <-as.Date("2025-08-31")ba_2025 <- ba_all |>filter(ba_date >= summer_start, ba_date <= summer_end)ba_2025_3035 <- ba_2025 |>st_make_valid() |>st_transform(3035)# Clip to EU union (not bbox, so France/corsica etc. stay)ba_2025_eu <-st_intersection(ba_2025_3035, eu_u)# Compute area from geometry (ha)ba_2025_eu <- ba_2025_eu |>mutate(area_ha =as.numeric(st_area(geometry)) /10000)# -------------------- Robust country tagging: max overlap --------------------inter <-st_intersection( ba_2025_eu |> dplyr::select(id, area_ha), eu_poly |> dplyr::select(name_long, iso_a2)) |> dplyr::mutate(overlap_ha =as.numeric(st_area(geometry))/10000)winner <- inter |>st_drop_geometry() |>group_by(id) |>slice_max(overlap_ha, n =1, with_ties =FALSE) |>ungroup() |>select(id, name_long, iso_a2)ba_2025_tagged <- ba_2025_eu |>left_join(winner, by ="id") |>filter(!is.na(name_long))
2 What has burned, land cover composition
Show code
# -------------------- Land-cover composition --------------------lc_cols <-c("broadlea","conifer","mixed","scleroph","transit","agriareas","artifsurf","othernatlc","otherlc")present_lc <-intersect(lc_cols, names(ba_2025_tagged))if (length(present_lc)) { ba_2025_tagged <- ba_2025_tagged |>mutate(across(all_of(present_lc), to_num),across(all_of(present_lc), ~ .x/100)) # perc -> proportionfor (v in present_lc) { ba_2025_tagged[[paste0(v, "_ha")]] <- ba_2025_tagged[[v]] * ba_2025_tagged$area_ha } agg_cols <-c("area_ha", paste0(present_lc, "_ha")) ctry_comp_2025 <- ba_2025_tagged |>st_drop_geometry() |>group_by(name_long) |>summarise(across(all_of(agg_cols), ~sum(.x, na.rm =TRUE)), .groups ="drop") |>mutate(across(ends_with("_ha") &!matches("^area_ha$"), ~ .x / area_ha, .names ="{.col}_share")) topN_comp <- ctry_comp_2025 |>arrange(desc(area_ha)) |>slice_head(n =10) |>pull(name_long) comp_long <- ctry_comp_2025 |>filter(name_long %in% topN_comp) |>select(name_long, ends_with("_ha_share")) |>pivot_longer(-name_long, names_to ="class", values_to ="share") |>mutate(class =gsub("_ha_share$", "", class),class =factor(class,levels = present_lc,labels =c("Broad-leaved forest","Coniferous forest","Mixed forest","Sclerophyllous veg.","Transitional woodland-shrub","Agricultural areas","Artificial surfaces","Other natural LC","Other LC")),name_long = forcats::fct_reorder(name_long, ctry_comp_2025$area_ha[match(name_long, ctry_comp_2025$name_long)]))# Color-blind-safe, semantically meaningful palette pal_lc <-c("Broad-leaved forest"="#2E7D32", # deep leaf green"Coniferous forest"="#00512D", # dark pine"Mixed forest"="#4CAF50", # mid forest green"Sclerophyllous veg."="#E64A19", # hot Mediterranean orange"Transitional woodland-shrub"="#8C6D31", # olive/brown scrub"Agricultural areas"="#DDAA33", # golden fields"Artificial surfaces"="#6E6E6E", # urban grey"Other natural LC"="#3E8EC4", # light blue (natural/open)"Other LC"="#9B59B6"# lavender (misc/unknown) ) p_comp_stacked <-ggplot(comp_long, aes(x = share, y = name_long, fill = class)) +geom_col(width =0.72, color =NA) +scale_fill_manual(values = pal_lc, breaks =names(pal_lc), name ="Land cover") +scale_x_continuous(labels = scales::label_percent()) +labs(title ="What burned? Land-cover composition of 2025 summer burn scars (Top-10 countries)",subtitle ="Share of each burned polygon’s area by land-cover type, aggregated per country (Jun–Aug).",x ="Share of burned area", y =NULL ) +theme_minimal(base_size =12) +theme(legend.position ="right",panel.grid.major.y =element_blank(),panel.grid.minor =element_blank() )}p_comp_stacked
2.1 What the land-cover classes mean (quick guide)
Broad-leaved forest – Deciduous trees (oaks, beeches, etc.).
Coniferous forest – Needle-leaf trees (pines, spruces, firs), including plantations.
Mixed forest – Mixture of broad-leaved and coniferous stands.
Other natural LC – Natural cover not in the above (e.g., heath, sparse vegetation, dunes, rocky cover).
Other LC – Residual/unknown/rare classes.
Several types have burned, not a clear cut.
2.2 Geographic distribution
It is reasonable to hypothesize that these events are occurring predominantly in Southern Europe, where Spain and Portugal appear especially exposed, and where France, too, has shown increasing vulnerability this year.
Show code
# -------------------- Core maps and bars --------------------p_base <-ggplot() +geom_sf(data = eu_poly, fill ="grey95", color ="grey70", linewidth =0.15) +coord_sf() # no xlim/ylim cropping# 1) Hero: fill polygons so burned areas pophero <- p_base +geom_sf(data = ba_2025_tagged,fill ="red", alpha =0.35, color ="red", linewidth =0.1) +labs(title ="Europe — Burn-scar perimeters (Jun–Aug 2025)",subtitle ="EFFIS rapid perimeters (≥ ~30–50 ha), country tagged by max overlap",caption ="Perimeters: EFFIS (local shapefile)." ) +theme_minimal(base_size =11) +theme(panel.grid =element_blank(), axis.text =element_blank(), axis.title =element_blank())# 2) Optional: outline version (useful when fills are very light)hero_outline <- p_base +geom_sf(data = ba_2025_tagged,aes(geometry = geometry),fill =NA, color ="red", linewidth =0.2, alpha =0.7) +labs(title ="Burn-scar perimeters (outlines) — Summer 2025",subtitle ="EFFIS rapid perimeters (≥ ~30–50 ha), country tagged by max overlap",caption ="EFFIS perimeters (local).") +theme_minimal(base_size =11) +theme(panel.grid =element_blank(), axis.text =element_blank(), axis.title =element_blank())hero_outline
The spatial distribution of burn scars confirms that wildfires are far from evenly spread across Europe. Instead, they cluster heavily in Southern and Eastern Europe, with striking densities in Portugal, Spain, southern Italy, and Greece. These regions stand out as recurrent hotspots, shaped by a combination of Mediterranean climatic stressors (hot, dry summers), land-use legacies (abandoned agricultural land, dense forests), and structural vulnerabilities (urban–wildland interfaces).
France, particularly in the south, also exhibits notable scars, reinforcing the idea that wildfire risk is no longer confined to Iberia and the Mediterranean islands but is extending more consistently across the northern Mediterranean arc. By contrast, Central and Northern Europe show only scattered events, suggesting a lower baseline exposure, though isolated incidents do occur, pointing to the potential for more diffuse risks under future warming.
This pattern lends weight to the hypothesis that Southern and Eastern Europe are systematically more exposed to large-scale fires, both because of their climatic profile and because of socio-environmental conditions. At the same time, the geographic spread into France and even some parts of the Balkans suggests that wildfire vulnerability is becoming a more pan-European concern, rather than one limited to the Mediterranean fringe.
3 Fires timing across summer
When does it happend? It could be that most fire risks come late in the summer, once the heat has dragged on for weeks, water is running low, and the landscape is at its driest and most vulnerable.
# --- deps (once) ---if (!requireNamespace("maptiles", quietly =TRUE)) install.packages("maptiles")if (!requireNamespace("terra", quietly =TRUE)) install.packages("terra")if (!requireNamespace("tidyterra", quietly =TRUE)) install.packages("tidyterra")if (!requireNamespace("ggspatial", quietly =TRUE)) install.packages("ggspatial")library(maptiles)library(terra)library(tidyterra)library(ggspatial)# 1) Monthly stats for in-panel labelsmonthly_stats <- ba_2025_tagged |> sf::st_drop_geometry() |> dplyr::count(month, name ="n_fires") |> dplyr::left_join( ba_2025_tagged |> dplyr::group_by(month) |> dplyr::summarise(burned_ha =sum(area_ha, na.rm =TRUE), .groups ="drop"),by ="month" ) |> dplyr::mutate(lab =paste0("Fires: ", scales::comma(n_fires),"\nArea: ", scales::label_number(scale_cut = scales::cut_si("ha"))(burned_ha) ) )# 2) Fetch a satellite basemap (Esri World Imagery), cache locally# NOTE: needs internet the first time; then uses the cache.bb_ll <- sf::st_bbox(sf::st_transform(eu_poly, 4326))sat <- maptiles::get_tiles(x = sf::st_as_sfc(bb_ll),provider ="Esri.WorldImagery",zoom =4, # bump to 5 if you want more detailcachedir ="DATA/cache")# 3) Reproject tiles to your map CRS (EPSG:3035)sat_3035 <- terra::project(sat, "EPSG:3035")# 4) Label anchor positions inside each facet (top-left corner)bb <- sf::st_bbox(eu_poly) # already 3035stats_pos <- monthly_stats |> dplyr::mutate(x = bb["xmin"] +0.03* (bb["xmax"] - bb["xmin"]),y = bb["ymax"] -0.03* (bb["ymax"] - bb["ymin"]) )# 5) Build the fancy monthly facet mapfacet <-ggplot() +# Satellite background (RGB) tidyterra::geom_spatraster_rgb(data = sat_3035, maxcell =3e6, interpolate =TRUE) +# A faint veil so scars pop on top of imageryannotate("rect", xmin =-Inf, xmax =Inf, ymin =-Inf, ymax =Inf,fill = scales::alpha("white", 0.06), colour =NA) +# Country outlines for claritygeom_sf(data = eu_poly, fill =NA, color ="white", linewidth =0.18, alpha =0.9) +# Burn scars (fill + hairline outline)geom_sf(data = ba_2025_tagged,fill ="#E31A1C", alpha =0.35,color ="#E31A1C", linewidth =0.06,inherit.aes =FALSE ) +facet_wrap(~ month, ncol =3, drop =FALSE) +coord_sf(expand =FALSE, xlim =c(bb["xmin"], bb["xmax"]), ylim =c(bb["ymin"], bb["ymax"])) +# Monthly labels: counts + areageom_label(data = stats_pos,aes(x = x, y = y, label = lab),inherit.aes =FALSE,size =3.1, label.size =0,label.padding = grid::unit(3, "pt"),fill = scales::alpha("white", 0.85), colour ="grey20" ) +# Map furniture ggspatial::annotation_scale(location ="bl", width_hint =0.25, text_cex =0.6, line_width =0.2) + ggspatial::annotation_north_arrow(location ="br", height = grid::unit(0.55, "cm"),style = north_arrow_fancy_orienteering ) +labs(title ="Monthly burn-scar perimeters — Summer 2025",subtitle ="Background: Esri World Imagery (satellite). Summer = June–August (meteorological).",caption ="Perimeters: EFFIS rapid mapping (≈ ≥30–50 ha). Areas computed in EPSG:3035. Coverage: Europe." ) +theme_minimal(base_size =11) +theme(panel.grid =element_blank(),axis.text =element_blank(),axis.title =element_blank(),panel.spacing = grid::unit(8, "pt"),strip.text =element_text(face ="bold"),legend.position ="none",plot.subtitle =element_text(colour ="grey25"),plot.caption =element_text(colour ="grey30") )facet
This progression supports the idea that the longer ecosystems endure sustained summer heat, the more vulnerable they become. Late summer thus appears to represent a critical tipping point: vegetation is driest, soils are depleted of moisture, and firefighting resources are stretched thin after months of activity.
3.2 Fire occurrence across time
Show code
# --- Daily occurrences for Summer 2025 (EU clipped) --------------------------library(dplyr); library(tidyr); library(ggplot2)summer_start <-as.Date("2025-06-01")summer_end <-as.Date("2025-08-31")# Count perimeters/day (subset to EU without cutting geom → no sf warning)daily_2025 <- ba_all %>%filter(ba_date >= summer_start, ba_date <= summer_end) %>% sf::st_transform(3035) %>% sf::st_filter(eu_u) %>%# subset, no attribute warning sf::st_drop_geometry() %>%count(ba_date, name ="fires") %>%complete(ba_date =seq(summer_start, summer_end, by ="day"),fill =list(fires =0)) %>%arrange(ba_date)# 7-day rolling meanif (!requireNamespace("slider", quietly =TRUE)) install.packages("slider")library(slider)daily_2025 <- daily_2025 %>%mutate(roll7 =slide_dbl(fires, mean, .before =3, .after =3, .complete =FALSE))# Cumulative (simple!)daily_2025 <- daily_2025 %>%mutate(cum_fires =cumsum(fires))# Alternating month bandsmonth_bands <- daily_2025 %>%mutate(month = lubridate::floor_date(ba_date, "month")) %>%group_by(month) %>%summarise(xmin =min(ba_date), xmax =max(ba_date) +1, .groups ="drop") %>%mutate(even =row_number() %%2==0)# Secondary axis scaling for cumulativemax_daily <-max(daily_2025$fires, na.rm =TRUE)sfactor <-max(daily_2025$cum_fires, na.rm =TRUE) / (max_daily *1.15)daily_2025 <- daily_2025 %>%mutate(cum_scaled = cum_fires / sfactor)# Top spikes to annotatepeaks <- daily_2025 %>%slice_max(fires, n =3, with_ties =FALSE)col_bar <-"grey85"col_mean <-"#D64A05"col_cum <-"#1F78B4"p_daily_occurrence <-ggplot(daily_2025, aes(x = ba_date)) +geom_rect(data = month_bands,aes(xmin = xmin, xmax = xmax, ymin =-Inf, ymax =Inf, fill = even),inherit.aes =FALSE, alpha =0.28, show.legend =FALSE) +scale_fill_manual(values =c("TRUE"="white", "FALSE"="grey98")) +geom_col(aes(y = fires), width =0.9, fill = col_bar, color ="grey80", linewidth =0.15) +geom_ribbon(aes(ymin =pmax(0, roll7 -0.5), ymax = roll7 +0.5),fill = scales::alpha(col_mean, 0.12), color =NA) +geom_line(aes(y = roll7), linewidth =0.9, color = col_mean) +geom_line(aes(y = cum_scaled), linewidth =0.8, color = col_cum) +scale_y_continuous(name ="Perimeters per day",sec.axis =sec_axis(~ . * sfactor, name ="Cumulative since 1 Jun (count)") ) +scale_x_date(date_breaks ="1 month", date_labels ="%b") + {if (requireNamespace("ggrepel", quietly =TRUE)) { ggrepel::geom_text_repel(data = peaks,aes(y = fires, label = scales::comma(fires)),nudge_y =4, size =3.5, color ="grey20", segment.color ="grey70",seed =1, box.padding =0.2, min.segment.length =0 ) } else {geom_text(data = peaks, aes(y = fires, label = scales::comma(fires)),nudge_y =4, size =3.5, color ="grey20") } } +labs(title ="Daily wildfire perimeters detected — Summer 2025 (EU)",subtitle ="Bars: daily counts; Orange line: 7-day mean;\n Blue line: cumulative since 1 Jun (right axis). Summer = June–August.",x =NULL, y =NULL,caption ="EFFIS rapid perimeters (≈ ≥30–50 ha), Europe. Counts reflect detections; areas/geometry not used here." ) +theme_minimal(base_size =12) +theme(panel.grid.minor =element_blank(),panel.grid.major.x =element_blank(),axis.text.x =element_text(margin =margin(t =4)),plot.caption =element_text(color ="grey30"),plot.subtitle =element_text(color ="grey25") )p_daily_occurrence
The daily fire perimeter data suggests that wildfire activity in Europe during summer 2025 did not follow a simple, linear escalation but rather unfolded in successive waves. A first surge appeared in early July, followed by a second in late July and then a third, even more intense, in early August. This pattern points to an interaction between cumulative vulnerability, as ecosystems dried out under sustained heat, and short-term climatic triggers such as heatwaves or strong wind episodes. The cumulative line makes clear that each wave left the landscape more fragile, so that by early August conditions were primed for the largest outbreaks. One could hypothesize, then, that late summer remains the most dangerous period, but that the actual timing of peaks depends heavily on weather extremes layered onto a background of progressive drying.
3.3 Dynamic, per day time variation
Just discover one can make gif, animated graphs in R, so I am playing with it a little!
To put the wildfire season of 2025 into perspective, it is useful to begin with a closer look at which countries were most severely affected this year. Preliminary figures show that the largest burned areas were concentrated in Southern Europe, particularly in Spain, Portugal, Italy, and Greece, with France also recording substantial losses. These cases illustrate once again how the Mediterranean arc stands at the center of Europe’s wildfire challenge.
The next step is to place 2025 in historical context by comparing it to 2017, which remains one of the most destructive fire seasons in recent European memory. This benchmark year provides a valuable point of reference because it combined extreme climatic conditions with extensive fire damage across the Iberian Peninsula and beyond, leaving a lasting mark on European fire policy debates.
Beyond this pairwise comparison, a broader picture emerges when 2025 is considered alongside more recent fire seasons, notably 2022 and 2023. These years, like 2017, represent moments of particularly high fire activity in the dataset, and together they offer a way to gauge whether 2025 signals an exceptional episode or rather fits into a recurrent pattern of intensifying summer fire crises. In this sense, 2017 serves as a historical baseline, while 2022 and 2023 highlight the persistence of large-scale fires under ongoing climate stress.
4.1 Most burned countries in 2025
Based on prior analysis, I would assume Spain and Portugal.
Show code
ctry_2025 <- ba_2025_tagged |>st_drop_geometry() |>group_by(name_long) |>summarise(burned_ha =sum(area_ha, na.rm =TRUE), n_fires =n(), .groups="drop") |>arrange(desc(burned_ha)) |>slice_head(n =10) |>mutate(name_long = forcats::fct_reorder(name_long, burned_ha))# --- deps for flags & color extraction (once) ---if (!requireNamespace("countrycode", quietly =TRUE)) install.packages("countrycode")if (!requireNamespace("ggimage", quietly =TRUE)) install.packages("ggimage")if (!requireNamespace("curl", quietly =TRUE)) install.packages("curl")if (!requireNamespace("fs", quietly =TRUE)) install.packages("fs")if (!requireNamespace("png", quietly =TRUE)) install.packages("png")library(countrycode); library(ggimage); library(curl); library(fs); library(png)# --- robust ISO2 for the Top-10 countries (fall back to eu_poly if needed) ---name_to_iso2 <- tibble::tibble(name_long =as.character(ctry_2025$name_long)) |> dplyr::distinct() |> dplyr::mutate(iso2 =countrycode( name_long, origin ="country.name", destination ="iso2c",custom_match =c("Czechia"="CZ","United Kingdom"="GB","North Macedonia"="MK","Moldova"="MD","Kosovo"="XK") ) )map_iso_from_poly <- eu_poly |> sf::st_drop_geometry() |> dplyr::distinct(name_long, iso_a2) |> dplyr::rename(iso2_poly = iso_a2)name_to_iso2 <- name_to_iso2 |> dplyr::left_join(map_iso_from_poly, by ="name_long") |> dplyr::mutate(iso2 = dplyr::coalesce(iso2, iso2_poly),iso2_lower =tolower(iso2)) |> dplyr::select(name_long, iso2_lower)# --- download small PNG flags locally (once) ---flags_dir <-"assets/flags_rect"fs::dir_create(flags_dir)fetch_flags <-function(iso2_vec) { iso2_vec <-unique(na.omit(iso2_vec)) tibble::tibble(iso2_lower = iso2_vec) |> dplyr::mutate(flag_path = purrr::map_chr(iso2_lower, function(code){ dest <-file.path(flags_dir, sprintf("%s.png", code))if (!file.exists(dest)) { url <-sprintf("https://flagcdn.com/w40/%s.png", code)try(curl::curl_download(url, dest, quiet =TRUE), silent =TRUE) }if (file.exists(dest)) dest elseNA_character_ }))}flag_paths <-fetch_flags(name_to_iso2$iso2_lower)# --- extract a dominant (non-white) color from each flag (for the point) ---dominant_flag_color <-function(path) { res <-try({ arr <- png::readPNG(path) rgb <-cbind(as.vector(arr[,,1]), as.vector(arr[,,2]), as.vector(arr[,,3])) a <-if (dim(arr)[3] >=4) as.vector(arr[,,4]) elserep(1, nrow(rgb)) keep <- a >0.8 rgb <- rgb[keep, , drop =FALSE]# drop near-white pixels keep2 <-rowMeans(rgb) <0.95 rgb <- rgb[keep2, , drop =FALSE]if (nrow(rgb) <50) rgb <- rgb[keep, , drop =FALSE]set.seed(1) km <- stats::kmeans(rgb, centers =min(3, nrow(rgb)), iter.max =15) dom <- km$centers[which.max(tabulate(km$cluster)), ] grDevices::rgb(dom[1], dom[2], dom[3]) }, silent =TRUE)if (inherits(res, "try-error")) "#444444"else res}flag_cols <- flag_paths |> dplyr::mutate(col =vapply(flag_path, dominant_flag_color, character(1)))# --- join ISO, flag paths, and colors to ctry_2025; compute right-side positions ---bars_df <- ctry_2025 |> dplyr::mutate(name_long =as.character(name_long)) |> dplyr::left_join(name_to_iso2, by ="name_long") |> dplyr::left_join(flag_paths, by ="iso2_lower") |> dplyr::left_join(flag_cols, by =c("iso2_lower","flag_path")) |> dplyr::mutate(col = dplyr::coalesce(col, "#444444"))max_x <-max(bars_df$burned_ha, na.rm =TRUE)pad_flag <-0.1* max_x # push flags out to the right so they never touch barsbars_df <- bars_df |> dplyr::mutate(x_flag = burned_ha + pad_flag,name_long = forcats::fct_reorder(name_long, burned_ha) # keep your Top-10 order )# --- plot: neutral bars, colored point at the bar end, flag on the right ---bars_flagged <-ggplot(bars_df, aes(x = burned_ha, y = name_long)) +geom_col(fill ="grey90") +geom_point(aes(color = col), size =3) +scale_color_identity() + ggimage::geom_image(data = dplyr::filter(bars_df, !is.na(flag_path)),aes(x = x_flag, y = name_long, image = flag_path),inherit.aes =FALSE, size =0.065, asp =2/3 ) +scale_x_continuous(labels = lab_si_ha, expand =expansion(mult =c(0, 0.30))) +labs(title ="Top-10 countries — total burned area (Jun–Aug 2025)",subtitle ="Summer is from early June till late August (meteorological).\n Each line shows the change in polygon-summed burned area between 2017 and 2025.",x ="Burned area (ha)", y =NULL,caption ="Areas computed from polygon geometry (EPSG:3035).\n Countries assigned by maximum overlap with national borders (Europe)." ) +theme_minimal(base_size =11) +theme(plot.margin =margin(5.5, 90, 10, 5.5, "pt")) +coord_cartesian(clip ="off")bars_flagged
France not even in Top 10…
4.2 Comparison with year 2017
Show code
# -------------------- Multi-year comparison (geometry-first) --------------------get_burnt_summer_local_geom <-function(y, eu_poly, ba_all){ out <- ba_all |>filter(ba_date >=as.Date(paste0(y,"-06-01")), ba_date <=as.Date(paste0(y,"-08-31"))) |>st_make_valid() |>st_transform(3035) eu_u <-st_union(eu_poly) out <-st_intersection(out, eu_u) |>mutate(area_ha =as.numeric(st_area(geometry))/10000) inter <-st_intersection(out |>select(id, area_ha), eu_poly |>select(name_long, iso_a2)) |>mutate(overlap_ha =as.numeric(st_area(geometry))/10000) winner <- inter |>st_drop_geometry() |>group_by(id) |>slice_max(overlap_ha, n =1, with_ties =FALSE) |>ungroup() |>select(id, name_long, iso_a2) out |>left_join(winner, by ="id") |>filter(!is.na(name_long)) |>mutate(year = y)}yrs <-c(2017, 2022, 2023, 2025)multi <-bind_rows(lapply(yrs, get_burnt_summer_local_geom, eu_poly = eu_poly, ba_all = ba_all))ctry_multi <- multi |>st_drop_geometry() |>group_by(name_long, year) |>summarise(burned_ha =sum(area_ha, na.rm =TRUE), .groups ="drop")dd <- ctry_multi |>filter(year %in%c(2017, 2025)) |> tidyr::pivot_wider(names_from = year, values_from = burned_ha, values_fill =0) |>mutate(delta =`2025`-`2017`) |>arrange(desc(`2025`)) |>slice_head(n =20) |>mutate(name_long = forcats::fct_reorder(name_long, `2025`))# --- deps for flags & color extraction ---if (!requireNamespace("ggimage", quietly =TRUE)) install.packages("ggimage")if (!requireNamespace("countrycode", quietly =TRUE)) install.packages("countrycode")if (!requireNamespace("curl", quietly =TRUE)) install.packages("curl")if (!requireNamespace("fs", quietly =TRUE)) install.packages("fs")if (!requireNamespace("png", quietly =TRUE)) install.packages("png")library(ggimage); library(countrycode); library(curl); library(fs); library(png)# label formatter if not definedif (!exists("lab_si_ha")) lab_si_ha <- scales::label_number(scale_cut = scales::cut_si("ha"))# --------- ISO2 per country (robust) ----------codes <- tibble::tibble(name_long =as.character(dd$name_long)) |> dplyr::distinct() |> dplyr::mutate(iso2 =countrycode( name_long, origin ="country.name", destination ="iso2c",custom_match =c("Czechia"="CZ", "United Kingdom"="GB", "North Macedonia"="MK","Moldova"="MD", "Kosovo"="XK") ) )# fallback to map names if any missingcodes_map <- eu_poly |> sf::st_drop_geometry() |> dplyr::distinct(name_long, iso_a2) |> dplyr::rename(iso2_poly = iso_a2)codes <- codes |> dplyr::left_join(codes_map, by ="name_long") |> dplyr::mutate(iso2 = dplyr::coalesce(iso2, iso2_poly),iso2_lower =tolower(iso2)) |> dplyr::select(name_long, iso2_lower)# --------- download tiny flags locally (once) ----------flags_dir <-"assets/flags_rect"fs::dir_create(flags_dir)fetch_flags <-function(iso2_vec) { iso2_vec <-unique(na.omit(iso2_vec)) tibble::tibble(iso2_lower = iso2_vec) |> dplyr::mutate(flag_path = purrr::map_chr(iso2_lower, function(code){ dest <-file.path(flags_dir, sprintf("%s.png", code))if (!file.exists(dest)) { url <-sprintf("https://flagcdn.com/w40/%s.png", code)try(curl::curl_download(url, dest, quiet =TRUE), silent =TRUE) }if (file.exists(dest)) dest elseNA_character_ }))}flag_paths <-fetch_flags(codes$iso2_lower)# --------- extract a dominant (non-white) color from each flag ----------dominant_flag_color <-function(path) { res <-try({ arr <- png::readPNG(path) # H x W x 4 (RGBA, in 0..1) h <-dim(arr)[1]; w <-dim(arr)[2] rgb <-cbind(as.vector(arr[,,1]), as.vector(arr[,,2]), as.vector(arr[,,3])) a <-if (dim(arr)[3] >=4) as.vector(arr[,,4]) elserep(1, nrow(rgb)) keep <- a >0.8 rgb <- rgb[keep, , drop =FALSE]# drop near-white pixels keep2 <-rowMeans(rgb) <0.95 rgb <- rgb[keep2, , drop =FALSE]if (nrow(rgb) <50) rgb <- rgb[keep, , drop =FALSE]set.seed(1) km <- stats::kmeans(rgb, centers =min(3, nrow(rgb)), iter.max =15) dom <- km$centers[which.max(tabulate(km$cluster)), ] grDevices::rgb(dom[1], dom[2], dom[3]) }, silent =TRUE)if (inherits(res, "try-error")) "#444444"else res}flag_cols <- flag_paths |> dplyr::mutate(col =vapply(flag_path, dominant_flag_color, character(1)))# --------- build plotting DF with flag positions and colors ----------dd_plot <- dd |> dplyr::left_join(codes, by ="name_long") |> dplyr::left_join(flag_paths, by ="iso2_lower") |> dplyr::left_join(flag_cols, by =c("iso2_lower","flag_path")) |> dplyr::mutate(col = dplyr::coalesce(col, "#444444"))# left margin positions (put flag & name at negative x)max_x <-max(c(dd_plot$`2017`, dd_plot$`2025`), na.rm =TRUE)x_left <--0.25* max_x # flag centerx_name <--0.05* max_x # country name (to the right of flag)# --------- plot: flags & names on the left; lines/points colored by flag color ----------p_dumbbell_25_17 <-ggplot(dd_plot) +# dumbbell linegeom_segment(aes(x =`2017`, xend =`2025`, y = name_long, yend = name_long, colour = col),linewidth =0.9, alpha =0.9, lineend ="round", show.legend =FALSE) +# pointsgeom_point(aes(x =`2017`, y = name_long, colour = col), size =2.4, show.legend =FALSE) +geom_point(aes(x =`2025`, y = name_long, colour = col), size =3.1, show.legend =FALSE) +# flag + name on the left ggimage::geom_image(aes(y = name_long, x = x_left, image = flag_path),size =0.1, asp =2/3, inherit.aes =FALSE, na.rm =TRUE) +geom_text(aes(y = name_long, x = x_name, label = name_long),size =3.7, hjust =1, inherit.aes =FALSE) +# axes & labelsscale_colour_identity() +scale_x_continuous(labels = lab_si_ha, expand =expansion(mult =c(0, 0.03))) +labs(title ="Burned area — Summer 2025 vs 2017 (Top 20 countries)",subtitle ="Summer is from early June till late August (meteorological).\n Each line shows the change in polygon-summed burned area between 2017 and 2025.",x ="Burned area (ha)", y =NULL,caption ="Areas computed from perimeter geometry in an equal-area projection (EPSG:3035).\n Differences vs. official statistics are expected due to thresholds, timing, and mapping criteria." ) +theme_minimal(base_size =12) +theme(panel.grid.major.y =element_blank(),panel.grid.minor =element_blank(),axis.text.y =element_blank(), # hide default country labels (we draw our own on the left)axis.ticks.y =element_blank(),plot.margin =margin(5.5, 5.5, 10, 110, "pt") # extra left room for flags ) +coord_cartesian(xlim =c(-0.18* max_x, NA), clip ="off") # show left decorationsp_dumbbell_25_17
This figure gives a clear comparative picture of wildfire damage in 2025 versus 2017 across the top twenty most affected countries.
Spain stands out as the most striking case: although it already suffered catastrophic losses in 2017, burned areas in 2025 are even higher, making it by far the leading country in terms of destruction. Portugal, which was at the center of the 2017 crisis, shows much smaller losses in 2025, suggesting that the geographical core of fire severity can shift markedly from one episode to another. Greece and Italy also record substantial burned areas in both years, though their relative positions differ: Italy’s losses are larger in 2017, whereas Greece’s appear more balanced across the two years.
Beyond the Mediterranean, several Balkan countries, including Serbia, Montenegro, North Macedonia, and Albania, experienced notable fire damage in 2017 and again in 2025, highlighting the structural exposure of this region. France, which was not a major outlier in 2017, shows higher burned areas in 2025, aligning with broader evidence that fire risk is expanding northward along the Mediterranean arc.
Overall, the comparison suggests that while the Mediterranean South remains the epicenter of wildfire activity, the relative weight of countries within this zone can change significantly from year to year. The data also reinforces the importance of 2017 as a benchmark season: it illustrates both the exceptional scale of fire damage in Iberia and provides a useful baseline against which to measure the evolving geography of wildfire risk.
4.3 Comparison with other former years
Show code
# ---- Emoji flag helper (robust) ----flag_from_iso2 <-function(iso2) { iso2 <-toupper(as.character(iso2)) out <-rep(NA_character_, length(iso2)) ok <-!is.na(iso2) &nchar(iso2) ==2if (any(ok)) { chars <-strsplit(iso2[ok], "", fixed =TRUE) out[ok] <-vapply( chars,function(cc) {if (length(cc) !=2) return(NA_character_)paste0(intToUtf8(127397+utf8ToInt(cc[1])),intToUtf8(127397+utf8ToInt(cc[2]))) },FUN.VALUE =character(1) ) } out}# --- build a robust ISO2 table (works even if map names differ) ---if (!requireNamespace("countrycode", quietly =TRUE)) install.packages("countrycode")library(countrycode)name_to_iso2 <- tibble::tibble(name_long =unique(ctry_multi$name_long)) |> dplyr::mutate(iso2 =countrycode( name_long, origin ="country.name", destination ="iso2c",custom_match =c("Czechia"="CZ", "United Kingdom"="GB","North Macedonia"="MK", "Kosovo"="XK", "Moldova"="MD") ) )# fallback to map if any NAmap_iso_from_poly <- eu_poly |> sf::st_drop_geometry() |> dplyr::distinct(name_long, iso_a2) |> dplyr::rename(iso2_poly = iso_a2)name_to_iso2 <- name_to_iso2 |> dplyr::left_join(map_iso_from_poly, by ="name_long") |> dplyr::mutate(iso2 = dplyr::coalesce(iso2, iso2_poly),iso2_lower =tolower(iso2)) |> dplyr::select(name_long, iso2_lower)topN_multi <- ctry_multi |>filter(year ==2025) |>arrange(desc(burned_ha)) |>slice_head(n =10) |>pull(name_long)# --- order so Top-1 (by 2025) is at the TOP ---order_2025 <- ctry_multi |> dplyr::filter(year ==2025, name_long %in% topN_multi) |> dplyr::arrange(dplyr::desc(burned_ha)) |> dplyr::pull(name_long)df_plot <- ctry_multi |> dplyr::filter(name_long %in% topN_multi) |> dplyr::mutate(name_long = forcats::fct_relevel(name_long, !!!rev(order_2025)),year =factor(year) )# --- right-side layout: positions are beyond the LONGEST bar (any year) ---max_x <-max(df_plot$burned_ha, na.rm =TRUE)pad_label <-0.1* max_x # where the numeric label goes (beyond all bars)gap_flag <-0.06* max_x # gap between label and flaggap_name <-0.03* max_x # gap between flag and country namevals_2025 <- df_plot |> dplyr::filter(year ==2025) |> dplyr::select(name_long, val_2025 = burned_ha)layout_df <- df_plot |> dplyr::group_by(name_long) |> dplyr::summarise(x_max =max(burned_ha, na.rm =TRUE), .groups ="drop") |> dplyr::left_join(vals_2025, by ="name_long") |> dplyr::left_join(name_to_iso2, by ="name_long") |> dplyr::mutate(x_label = x_max + pad_label, # numeric label (right-justified)x_flag = x_label + gap_flag, # flag position (to the right)x_country = x_flag + gap_name, # country name (even further right)label_txt = scales::label_number(scale_cut = scales::cut_si("ha"))(val_2025) )# --- make sure local PNG flags exist (ggimage) ---if (!requireNamespace("ggimage", quietly =TRUE)) install.packages("ggimage")if (!requireNamespace("curl", quietly =TRUE)) install.packages("curl")if (!requireNamespace("fs", quietly =TRUE)) install.packages("fs")library(ggimage); library(curl); library(fs)flags_dir <-"assets/flags_rect"fs::dir_create(flags_dir)fetch_flags <-function(iso2_vec) { iso2_vec <-unique(na.omit(iso2_vec)) tibble::tibble(iso2_lower = iso2_vec) |> dplyr::mutate(flag_path = purrr::map_chr(iso2_lower, function(code){ dest <-file.path(flags_dir, sprintf("%s.png", code))if (!file.exists(dest)) { url <-sprintf("https://flagcdn.com/w40/%s.png", code)try(curl::curl_download(url, dest, quiet =TRUE), silent =TRUE) }if (file.exists(dest)) dest elseNA_character_ }) )}layout_df <- layout_df |> dplyr::left_join(fetch_flags(layout_df$iso2_lower), by ="iso2_lower")# --- colors & scales ---lab_si_ha <- scales::label_number(scale_cut = scales::cut_si("ha"))col_map <-c(`2017`="grey80", `2022`="grey65", `2023`="grey55", `2025`="#D64A05")# --- plot (labels aligned with flags, no names on the left) ---p_bars_multi <-ggplot(df_plot, aes(y = name_long, x = burned_ha, fill = year)) +geom_col(position =position_dodge(width =0.7), width =0.65) +scale_fill_manual(values = col_map, name ="Year") +scale_x_continuous(labels = lab_si_ha, expand =expansion(mult =c(0, 0.55))) +labs(title ="Top-10 (by 2025) — Summer burned area across years",subtitle ="Summer is from early June till late August (meteorological).",x ="Burned area (ha)", y =NULL,caption =paste("Notes — EFFIS rapid perimeters (≈ ≥30–50 ha), Jun–Aug.","Areas from polygon geometry (EPSG:3035). Countries by max overlap (EU27 + EFTA + UK).","Differences vs official stats reflect thresholds, timing, mapping criteria.", sep=" " ) ) +theme_minimal(base_size =13) +theme(legend.position ="right",panel.grid.major.y=element_blank(),panel.grid.minor =element_blank(),axis.text.y =element_blank(), # <- remove country names on the leftaxis.ticks.y =element_blank(),plot.margin =margin(5.5, 140, 14, 5.5, "pt"),plot.caption =element_text(size =9, colour ="grey25", lineheight =1.1) ) +coord_cartesian(clip ="off") +# value labels for 2025, right-justified and aligned with flagsgeom_text(data = layout_df,aes(y = name_long, x = x_label, label = label_txt),inherit.aes =FALSE, hjust =1, vjust =0.5, size =3.7, colour = col_map["2025"] ) +# flags (local PNGs) ggimage::geom_image(data = dplyr::filter(layout_df, !is.na(flag_path)),aes(y = name_long, x = x_flag, image = flag_path),inherit.aes =FALSE, size =0.07, asp =2/3 ) +# country names on the right (optional; keep if you want)geom_text(data = layout_df,aes(y = name_long, x = x_country, label = name_long),inherit.aes =FALSE, hjust =0, vjust =0.5, size =4 )p_bars_multi
This chart helps situate the 2025 wildfire season within a broader multi-year perspective, comparing it directly to other recent crisis years — 2017, 2022, and 2023.
Spain dominates the picture: its 2025 burned area, close to 377 thousand hectares, clearly surpasses earlier peaks, making it the single largest national loss in the dataset. Portugal, which was the emblematic case in 2017 with devastating fires, records lower figures in 2025 but still appears among the most affected countries. Italy, Greece, and Albania also stand out, with 2025 values broadly in the same range as the peaks of previous years, showing how these countries consistently face recurrent exposure.
What is striking here is not only the magnitude of 2025 for Spain, but also the geographic breadth of high-impact years. Different countries emerge at different times — Portugal in 2017, Spain in 2025, Greece in both 2022 and 2023 — suggesting that extreme fire seasons are not confined to a single geography but rotate within the Mediterranean and Balkan regions depending on climatic and local conditions.
By including 2022 and 2023 alongside the 2017 benchmark, this comparison highlights that 2025 is not an isolated anomaly but rather part of a recurring pattern of severe pan-European fire years, each with its own epicenter. The choice of these four years reflects their exceptional character in the record, offering a way to assess whether wildfire risk is intensifying or shifting across Southern and Eastern Europe over time.
4.4 Notes & caveats
Or the disclaimer part!!!
The figures and maps presented here are based on EFFIS rapid perimeter data (≥ 30–50 ha), which rely on satellite detection and automated mapping procedures. While this provides timely and consistent coverage at the European scale, it inevitably differs from official national fire statistics, which may use different thresholds, definitions of burned area, or incorporate smaller fires not captured in these datasets. As such, the numbers reported here should be interpreted as projections or estimates, not as exact totals.
Another limitation lies in the temporal framing: the analysis defines “summer” as June to August (meteorological summer), whereas fire agencies may use different seasonal boundaries. This choice improves comparability across years, but it may exclude late-season fires (e.g., September outbreaks), which can be significant in certain regions. Similarly, attribution of burn scars to countries is based on maximum spatial overlap, which may under- or over-estimate totals in border areas.
Finally, it is important to stress that these results are intended for analytical and exploratory purposes. They provide a comparative overview of wildfire dynamics in Europe but should not be treated as official statistics. For policy, management, or operational decisions, readers should always refer to authoritative national and European reporting systems.
Source Code
---title: "Summer 2025 Wildfires in Europe: Where Did It Burn?"subtitle: "Local EFFIS perimeters (June–August)"author: "Pierre Beaucoral"date: todayformat: html: theme: light: minty dark: darkly respect-user-color-scheme: true self-contained: true highlight-style: tango number-sections: true code-fold: true # per-chunk folding code-summary: "Show code" code-tools: true # adds “Show All Code / Hide All Code” toolbar lightbox: true # click images to zoom toc: true # optional: show floating table of contents toc-depth: 3 smooth-scroll: true link-external-newwindow: true df-print: paged lightbox: true # click images to zoom fig-dpi: 144 # fallback quality if pngs are usedexecute: echo: true warning: false message: false---I’ve been wanting to understand Europe’s wildfire season better for a while. This summer it hit closer to home: I’m French, and the fires in **Aude** really stuck with me—along with the big outbreaks in **Spain**. Rather than skim headlines, I wanted a grounded view of *where* things burned, *how much*, *when*, and *what actually burned*.So I pulled together a simple, reproducible dataset and built a few maps and charts for **Summer 2025 (June–August)** across **Europe**. The core comes from **EFFIS rapid perimeters**—the publicly mapped fire polygons that are typically ≥ 30–50 ha. I tag each perimeter to a country by **maximum overlap** with national borders and compute burned area directly from geometry in an equal-area projection (EPSG:3035). Where useful, I compare 2025 to **2017, 2022, and 2023** to see how this season stacks up.> **What’s in the data (local files):**>> - `data/effis_layer/modis.ba.poly.shp` — EFFIS burn perimeters with dates and land-cover shares (e.g., **broad-leaved**, **conifer**, **mixed**, **sclerophyllous**, **agriculture**, **artificial**).>> - **Important caveats:** rapid perimeters undercount small fires; mapping is updated over time; country assignment uses geometry overlap; and any comparison with official stats will differ because of thresholds, timing, and criteria. Think of this as a transparent, apples-to-apples look at *mapped polygons*, not a replacement for national reporting.>> > With that, let’s explore: a Europe-wide “hero” map of 2025 burn scars, monthly panels, a daily activity timeline, country top-10, multi-year comparisons, and a “what burned?” breakdown by land cover.# Setup> All following block will maybe be complete and so redundant with the ones before, I wanted to make it easier to understand in case someone would want to use the code for other purposes.```{r setup, message=FALSE, warning=FALSE}# wildfires_2025_pretty_plus_composition.R# Local-only workflow with land-cover composition# Data layout expected:# data/effis_layer/modis.ba.poly.shp (+ .dbf/.shx/…)# data/report_2023.xlsx (optional, country totals)# -------------------- Packages --------------------pkgs <-c("sf","dplyr","stringr","lubridate","ggplot2","readxl","forcats","rnaturalearth","rnaturalearthdata","janitor","scales","patchwork","tidyr")to_i <- pkgs[!(pkgs %in%rownames(installed.packages()))]if(length(to_i)) install.packages(to_i, Ncpus =max(1, parallel::detectCores()-1))suppressPackageStartupMessages({library(sf); library(dplyr); library(stringr); library(lubridate); library(ggplot2)library(readxl); library(forcats); library(rnaturalearth); library(janitor)library(scales); library(patchwork); library(tidyr)})lab_si_ha <- scales::label_number(scale_cut = scales::cut_si("ha"))# -------------------- Paths --------------------shp_path <-"DATA/effis_layer/modis.ba.poly.shp"ctry_tot_2023 <-"DATA/report_2023.xlsx"stopifnot(file.exists(shp_path))# -------------------- Helpers --------------------to_num <-function(x){if (is.numeric(x)) return(x) x <-trimws(as.character(x)) x <-gsub("\u00A0|\\s", "", x) # remove spaces / NBSP x <-gsub(",", ".", x) # EU decimal comma -> dot readr::parse_number(x, locale = readr::locale(decimal_mark ="."))}parse_date_any <-function(x){if (inherits(x,"Date")) return(x)if (inherits(x,"POSIXt")) return(as.Date(x)) s <-as.character(x); s <-substr(s, 1, 19) y <-suppressWarnings(lubridate::parse_date_time( s, orders =c("Ymd","Y-m-d","dmy","d-m-Y","m/d/Y","d/m/Y","Ymd HMS","Y-m-d H:M:S") ))as.Date(y)}# -------------------- Europe polygons (EU27 + EFTA + UK) via iso_a2_eh --------------------eu_keep <-c("AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU","IE","IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE", # EU27"NO","IS","CH","LI", # EFTA"GB", # UK"BA","RS","MK","ME","XK","AL")europe_bbox <-st_as_sfc(st_bbox(c( xmin =-15, xmax =40, # longitudes ymin =34, ymax =72# latitudes ), crs =4326))eu_poly <- rnaturalearth::ne_countries(scale ="large", returnclass ="sf") |>filter(iso_a2_eh %in% eu_keep) |>st_intersection(europe_bbox) |># keep only mainland Europest_transform(3035)eu_u <-st_union(eu_poly) # union for stable clipping# -------------------- Read burnt-area perimeters --------------------ba_all <-suppressWarnings(st_read(shp_path, quiet =TRUE)) |> janitor::clean_names()# Detect & parse datecand_names <-tolower(names(ba_all))wanted <-c("firedate","lastupdate","acq_date","acqdate","date","startdate","start_date")hit <-intersect(wanted, cand_names)if (!length(hit)) stop("No date-like column found in shapefile; available: ", paste(names(ba_all), collapse=", "))date_col <- hit[1]ba_all <- ba_all |>mutate(ba_date =parse_date_any(.data[[date_col]])) |>filter(!is.na(ba_date))# Ensure an IDif (!"id"%in%names(ba_all)) ba_all <- ba_all |>mutate(id =row_number())# -------------------- Filter Summer 2025 and project --------------------summer_start <-as.Date("2025-06-01"); summer_end <-as.Date("2025-08-31")ba_2025 <- ba_all |>filter(ba_date >= summer_start, ba_date <= summer_end)ba_2025_3035 <- ba_2025 |>st_make_valid() |>st_transform(3035)# Clip to EU union (not bbox, so France/corsica etc. stay)ba_2025_eu <-st_intersection(ba_2025_3035, eu_u)# Compute area from geometry (ha)ba_2025_eu <- ba_2025_eu |>mutate(area_ha =as.numeric(st_area(geometry)) /10000)# -------------------- Robust country tagging: max overlap --------------------inter <-st_intersection( ba_2025_eu |> dplyr::select(id, area_ha), eu_poly |> dplyr::select(name_long, iso_a2)) |> dplyr::mutate(overlap_ha =as.numeric(st_area(geometry))/10000)winner <- inter |>st_drop_geometry() |>group_by(id) |>slice_max(overlap_ha, n =1, with_ties =FALSE) |>ungroup() |>select(id, name_long, iso_a2)ba_2025_tagged <- ba_2025_eu |>left_join(winner, by ="id") |>filter(!is.na(name_long))```# What has burned, land cover composition```{r land_cover, message=FALSE, warning=FALSE, fig.width=14}# -------------------- Land-cover composition --------------------lc_cols <-c("broadlea","conifer","mixed","scleroph","transit","agriareas","artifsurf","othernatlc","otherlc")present_lc <-intersect(lc_cols, names(ba_2025_tagged))if (length(present_lc)) { ba_2025_tagged <- ba_2025_tagged |>mutate(across(all_of(present_lc), to_num),across(all_of(present_lc), ~ .x/100)) # perc -> proportionfor (v in present_lc) { ba_2025_tagged[[paste0(v, "_ha")]] <- ba_2025_tagged[[v]] * ba_2025_tagged$area_ha } agg_cols <-c("area_ha", paste0(present_lc, "_ha")) ctry_comp_2025 <- ba_2025_tagged |>st_drop_geometry() |>group_by(name_long) |>summarise(across(all_of(agg_cols), ~sum(.x, na.rm =TRUE)), .groups ="drop") |>mutate(across(ends_with("_ha") &!matches("^area_ha$"), ~ .x / area_ha, .names ="{.col}_share")) topN_comp <- ctry_comp_2025 |>arrange(desc(area_ha)) |>slice_head(n =10) |>pull(name_long) comp_long <- ctry_comp_2025 |>filter(name_long %in% topN_comp) |>select(name_long, ends_with("_ha_share")) |>pivot_longer(-name_long, names_to ="class", values_to ="share") |>mutate(class =gsub("_ha_share$", "", class),class =factor(class,levels = present_lc,labels =c("Broad-leaved forest","Coniferous forest","Mixed forest","Sclerophyllous veg.","Transitional woodland-shrub","Agricultural areas","Artificial surfaces","Other natural LC","Other LC")),name_long = forcats::fct_reorder(name_long, ctry_comp_2025$area_ha[match(name_long, ctry_comp_2025$name_long)]))# Color-blind-safe, semantically meaningful palette pal_lc <-c("Broad-leaved forest"="#2E7D32", # deep leaf green"Coniferous forest"="#00512D", # dark pine"Mixed forest"="#4CAF50", # mid forest green"Sclerophyllous veg."="#E64A19", # hot Mediterranean orange"Transitional woodland-shrub"="#8C6D31", # olive/brown scrub"Agricultural areas"="#DDAA33", # golden fields"Artificial surfaces"="#6E6E6E", # urban grey"Other natural LC"="#3E8EC4", # light blue (natural/open)"Other LC"="#9B59B6"# lavender (misc/unknown) ) p_comp_stacked <-ggplot(comp_long, aes(x = share, y = name_long, fill = class)) +geom_col(width =0.72, color =NA) +scale_fill_manual(values = pal_lc, breaks =names(pal_lc), name ="Land cover") +scale_x_continuous(labels = scales::label_percent()) +labs(title ="What burned? Land-cover composition of 2025 summer burn scars (Top-10 countries)",subtitle ="Share of each burned polygon’s area by land-cover type, aggregated per country (Jun–Aug).",x ="Share of burned area", y =NULL ) +theme_minimal(base_size =12) +theme(legend.position ="right",panel.grid.major.y =element_blank(),panel.grid.minor =element_blank() )}p_comp_stacked```> ## What the land-cover classes mean (quick guide)>> - **Broad-leaved forest** – Deciduous trees (oaks, beeches, etc.).>> - **Coniferous forest** – Needle-leaf trees (pines, spruces, firs), including plantations.>> - **Mixed forest** – Mixture of broad-leaved and coniferous stands.>> - **Sclerophyllous vegetation** – Fire-adapted Mediterranean shrubs/woodland (maquis/garrigue); evergreen, waxy leaves.>> - **Transitional woodland–shrub** – Open, regenerating or degraded woodlands, shrub mosaics, young stands.>> - **Agricultural areas** – Arable land, permanent crops, pastures.>> - **Artificial surfaces** – Urban/built-up areas, infrastructure.>> - **Other natural LC** – Natural cover not in the above (e.g., heath, sparse vegetation, dunes, rocky cover).>> - **Other LC** – Residual/unknown/rare classes.Several types have burned, not a clear cut.## Geographic distributionIt is reasonable to hypothesize that these events are occurring predominantly in Southern Europe, where Spain and Portugal appear especially exposed, and where France, too, has shown increasing vulnerability this year.```{r map, message=FALSE, warning=FALSE}# -------------------- Core maps and bars --------------------p_base <-ggplot() +geom_sf(data = eu_poly, fill ="grey95", color ="grey70", linewidth =0.15) +coord_sf() # no xlim/ylim cropping# 1) Hero: fill polygons so burned areas pophero <- p_base +geom_sf(data = ba_2025_tagged,fill ="red", alpha =0.35, color ="red", linewidth =0.1) +labs(title ="Europe — Burn-scar perimeters (Jun–Aug 2025)",subtitle ="EFFIS rapid perimeters (≥ ~30–50 ha), country tagged by max overlap",caption ="Perimeters: EFFIS (local shapefile)." ) +theme_minimal(base_size =11) +theme(panel.grid =element_blank(), axis.text =element_blank(), axis.title =element_blank())# 2) Optional: outline version (useful when fills are very light)hero_outline <- p_base +geom_sf(data = ba_2025_tagged,aes(geometry = geometry),fill =NA, color ="red", linewidth =0.2, alpha =0.7) +labs(title ="Burn-scar perimeters (outlines) — Summer 2025",subtitle ="EFFIS rapid perimeters (≥ ~30–50 ha), country tagged by max overlap",caption ="EFFIS perimeters (local).") +theme_minimal(base_size =11) +theme(panel.grid =element_blank(), axis.text =element_blank(), axis.title =element_blank())hero_outline```The spatial distribution of burn scars confirms that wildfires are far from evenly spread across Europe. Instead, they cluster heavily in **Southern and Eastern Europe**, with striking densities in **Portugal, Spain, southern Italy, and Greece**. These regions stand out as recurrent hotspots, shaped by a combination of **Mediterranean climatic stressors** (hot, dry summers), **land-use legacies** (abandoned agricultural land, dense forests), and **structural vulnerabilities** (urban–wildland interfaces).France, particularly in the south, also exhibits notable scars, reinforcing the idea that wildfire risk is no longer confined to Iberia and the Mediterranean islands but is extending more consistently across the northern Mediterranean arc. By contrast, **Central and Northern Europe** show only scattered events, suggesting a lower baseline exposure, though isolated incidents do occur, pointing to the potential for more diffuse risks under future warming.> This pattern lends weight to the hypothesis that **Southern and Eastern Europe are systematically more exposed** to large-scale fires, both because of their climatic profile and because of socio-environmental conditions. At the same time, the geographic spread into France and even some parts of the Balkans suggests that wildfire vulnerability is becoming a more **pan-European concern**, rather than one limited to the Mediterranean fringe.# Fires timing across summerWhen does it happend? It could be that most fire risks come late in the summer, once the heat has dragged on for weeks, water is running low, and the landscape is at its driest and most vulnerable.## Fire by summer months 2025```{r map_month, message=FALSE, warning=FALSE}ba_2025_tagged <- ba_2025_tagged |>mutate(month =factor(lubridate::month(ba_date, label =TRUE, abbr =TRUE),levels =c("Jun","Jul","Aug")))# ensure month exists & orderedba_2025_tagged <- ba_2025_tagged |> dplyr::mutate(month =factor(lubridate::month(ba_date, label =TRUE, abbr =TRUE),levels =c("Jun","Jul","Aug")))table(ba_2025_tagged$month, useNA ="ifany")# --- deps (once) ---if (!requireNamespace("maptiles", quietly =TRUE)) install.packages("maptiles")if (!requireNamespace("terra", quietly =TRUE)) install.packages("terra")if (!requireNamespace("tidyterra", quietly =TRUE)) install.packages("tidyterra")if (!requireNamespace("ggspatial", quietly =TRUE)) install.packages("ggspatial")library(maptiles)library(terra)library(tidyterra)library(ggspatial)# 1) Monthly stats for in-panel labelsmonthly_stats <- ba_2025_tagged |> sf::st_drop_geometry() |> dplyr::count(month, name ="n_fires") |> dplyr::left_join( ba_2025_tagged |> dplyr::group_by(month) |> dplyr::summarise(burned_ha =sum(area_ha, na.rm =TRUE), .groups ="drop"),by ="month" ) |> dplyr::mutate(lab =paste0("Fires: ", scales::comma(n_fires),"\nArea: ", scales::label_number(scale_cut = scales::cut_si("ha"))(burned_ha) ) )# 2) Fetch a satellite basemap (Esri World Imagery), cache locally# NOTE: needs internet the first time; then uses the cache.bb_ll <- sf::st_bbox(sf::st_transform(eu_poly, 4326))sat <- maptiles::get_tiles(x = sf::st_as_sfc(bb_ll),provider ="Esri.WorldImagery",zoom =4, # bump to 5 if you want more detailcachedir ="DATA/cache")# 3) Reproject tiles to your map CRS (EPSG:3035)sat_3035 <- terra::project(sat, "EPSG:3035")# 4) Label anchor positions inside each facet (top-left corner)bb <- sf::st_bbox(eu_poly) # already 3035stats_pos <- monthly_stats |> dplyr::mutate(x = bb["xmin"] +0.03* (bb["xmax"] - bb["xmin"]),y = bb["ymax"] -0.03* (bb["ymax"] - bb["ymin"]) )# 5) Build the fancy monthly facet mapfacet <-ggplot() +# Satellite background (RGB) tidyterra::geom_spatraster_rgb(data = sat_3035, maxcell =3e6, interpolate =TRUE) +# A faint veil so scars pop on top of imageryannotate("rect", xmin =-Inf, xmax =Inf, ymin =-Inf, ymax =Inf,fill = scales::alpha("white", 0.06), colour =NA) +# Country outlines for claritygeom_sf(data = eu_poly, fill =NA, color ="white", linewidth =0.18, alpha =0.9) +# Burn scars (fill + hairline outline)geom_sf(data = ba_2025_tagged,fill ="#E31A1C", alpha =0.35,color ="#E31A1C", linewidth =0.06,inherit.aes =FALSE ) +facet_wrap(~ month, ncol =3, drop =FALSE) +coord_sf(expand =FALSE, xlim =c(bb["xmin"], bb["xmax"]), ylim =c(bb["ymin"], bb["ymax"])) +# Monthly labels: counts + areageom_label(data = stats_pos,aes(x = x, y = y, label = lab),inherit.aes =FALSE,size =3.1, label.size =0,label.padding = grid::unit(3, "pt"),fill = scales::alpha("white", 0.85), colour ="grey20" ) +# Map furniture ggspatial::annotation_scale(location ="bl", width_hint =0.25, text_cex =0.6, line_width =0.2) + ggspatial::annotation_north_arrow(location ="br", height = grid::unit(0.55, "cm"),style = north_arrow_fancy_orienteering ) +labs(title ="Monthly burn-scar perimeters — Summer 2025",subtitle ="Background: Esri World Imagery (satellite). Summer = June–August (meteorological).",caption ="Perimeters: EFFIS rapid mapping (≈ ≥30–50 ha). Areas computed in EPSG:3035. Coverage: Europe." ) +theme_minimal(base_size =11) +theme(panel.grid =element_blank(),axis.text =element_blank(),axis.title =element_blank(),panel.spacing = grid::unit(8, "pt"),strip.text =element_text(face ="bold"),legend.position ="none",plot.subtitle =element_text(colour ="grey25"),plot.caption =element_text(colour ="grey30") )facet```This progression supports the idea that **the longer ecosystems endure sustained summer heat, the more vulnerable they become**. Late summer thus appears to represent a critical tipping point: vegetation is driest, soils are depleted of moisture, and firefighting resources are stretched thin after months of activity.## Fire occurrence across time```{r time, message=FALSE, warning=FALSE, fig.width=14}# --- Daily occurrences for Summer 2025 (EU clipped) --------------------------library(dplyr); library(tidyr); library(ggplot2)summer_start <-as.Date("2025-06-01")summer_end <-as.Date("2025-08-31")# Count perimeters/day (subset to EU without cutting geom → no sf warning)daily_2025 <- ba_all %>%filter(ba_date >= summer_start, ba_date <= summer_end) %>% sf::st_transform(3035) %>% sf::st_filter(eu_u) %>%# subset, no attribute warning sf::st_drop_geometry() %>%count(ba_date, name ="fires") %>%complete(ba_date =seq(summer_start, summer_end, by ="day"),fill =list(fires =0)) %>%arrange(ba_date)# 7-day rolling meanif (!requireNamespace("slider", quietly =TRUE)) install.packages("slider")library(slider)daily_2025 <- daily_2025 %>%mutate(roll7 =slide_dbl(fires, mean, .before =3, .after =3, .complete =FALSE))# Cumulative (simple!)daily_2025 <- daily_2025 %>%mutate(cum_fires =cumsum(fires))# Alternating month bandsmonth_bands <- daily_2025 %>%mutate(month = lubridate::floor_date(ba_date, "month")) %>%group_by(month) %>%summarise(xmin =min(ba_date), xmax =max(ba_date) +1, .groups ="drop") %>%mutate(even =row_number() %%2==0)# Secondary axis scaling for cumulativemax_daily <-max(daily_2025$fires, na.rm =TRUE)sfactor <-max(daily_2025$cum_fires, na.rm =TRUE) / (max_daily *1.15)daily_2025 <- daily_2025 %>%mutate(cum_scaled = cum_fires / sfactor)# Top spikes to annotatepeaks <- daily_2025 %>%slice_max(fires, n =3, with_ties =FALSE)col_bar <-"grey85"col_mean <-"#D64A05"col_cum <-"#1F78B4"p_daily_occurrence <-ggplot(daily_2025, aes(x = ba_date)) +geom_rect(data = month_bands,aes(xmin = xmin, xmax = xmax, ymin =-Inf, ymax =Inf, fill = even),inherit.aes =FALSE, alpha =0.28, show.legend =FALSE) +scale_fill_manual(values =c("TRUE"="white", "FALSE"="grey98")) +geom_col(aes(y = fires), width =0.9, fill = col_bar, color ="grey80", linewidth =0.15) +geom_ribbon(aes(ymin =pmax(0, roll7 -0.5), ymax = roll7 +0.5),fill = scales::alpha(col_mean, 0.12), color =NA) +geom_line(aes(y = roll7), linewidth =0.9, color = col_mean) +geom_line(aes(y = cum_scaled), linewidth =0.8, color = col_cum) +scale_y_continuous(name ="Perimeters per day",sec.axis =sec_axis(~ . * sfactor, name ="Cumulative since 1 Jun (count)") ) +scale_x_date(date_breaks ="1 month", date_labels ="%b") + {if (requireNamespace("ggrepel", quietly =TRUE)) { ggrepel::geom_text_repel(data = peaks,aes(y = fires, label = scales::comma(fires)),nudge_y =4, size =3.5, color ="grey20", segment.color ="grey70",seed =1, box.padding =0.2, min.segment.length =0 ) } else {geom_text(data = peaks, aes(y = fires, label = scales::comma(fires)),nudge_y =4, size =3.5, color ="grey20") } } +labs(title ="Daily wildfire perimeters detected — Summer 2025 (EU)",subtitle ="Bars: daily counts; Orange line: 7-day mean;\n Blue line: cumulative since 1 Jun (right axis). Summer = June–August.",x =NULL, y =NULL,caption ="EFFIS rapid perimeters (≈ ≥30–50 ha), Europe. Counts reflect detections; areas/geometry not used here." ) +theme_minimal(base_size =12) +theme(panel.grid.minor =element_blank(),panel.grid.major.x =element_blank(),axis.text.x =element_text(margin =margin(t =4)),plot.caption =element_text(color ="grey30"),plot.subtitle =element_text(color ="grey25") )p_daily_occurrence```> The daily fire perimeter data suggests that wildfire activity in Europe during summer 2025 did not follow a simple, linear escalation but rather unfolded in successive waves. A first surge appeared in early July, followed by a second in late July and then a third, even more intense, in early August. This pattern points to an interaction between cumulative vulnerability, as ecosystems dried out under sustained heat, and short-term climatic triggers such as heatwaves or strong wind episodes. The cumulative line makes clear that each wave left the landscape more fragile, so that by early August conditions were primed for the largest outbreaks. One could hypothesize, then, that late summer remains the most dangerous period, but that the actual timing of peaks depends heavily on weather extremes layered onto a background of progressive drying.## Dynamic, per day time variation> Just discover one can make gif, animated graphs in R, so I am playing with it a little!```{r dynamic_time, message=FALSE, warning=FALSE}# --- Animated reveal of daily counts -----------------------------------------# Optional: install gganimate + gifskiif (!requireNamespace("gganimate", quietly =TRUE)) install.packages("gganimate")if (!requireNamespace("gifski", quietly =TRUE)) install.packages("gifski")library(gganimate)daily_2025 <- daily_2025 |>mutate(cum_fires =cumsum(fires)) # nice to show cumulative, toop_daily_anim <-ggplot(daily_2025, aes(x = ba_date)) +geom_col(aes(y = fires)) +geom_line(aes(y = roll7), linewidth =0.7) +scale_x_date(date_breaks ="2 weeks", date_labels ="%d %b") +labs(title ="Daily wildfire perimeters — {frame_along}",subtitle ="Bars: daily counts; Line: 7-day mean, cumulative",x =NULL, y ="Perimeters per day",caption ="EFFIS rapid perimeters (≥ ~30–50 ha), EU clip" ) +theme_minimal(base_size =12) +transition_reveal(along = ba_date)# Render to GIF (adjust width/height/fps as desired)p_daily_anim```## Animated day by day new fire occurrence```{r dynamic_map, message=FALSE, warning=FALSE}# Requires: library(gganimate) loaded successfully# 1) stable ID for groupingba_2025_tagged <- ba_2025_tagged |> dplyr::mutate(gid = dplyr::row_number()) # one row = one polygon# 2) a tight viewport around the scars (optional)bb <- sf::st_bbox(ba_2025_tagged)# 3) build plot: static base, animated layer with group = gidp_map_daily <-ggplot() +geom_sf(data = eu_poly, inherit.aes =FALSE,fill ="grey95", color ="grey70", linewidth =0.2) +geom_sf(data = ba_2025_tagged,aes(group = gid), # <- importantfill ="red", alpha =0.55,color ="red", linewidth =0.05,inherit.aes =FALSE) +coord_sf(xlim =c(bb["xmin"], bb["xmax"]),ylim =c(bb["ymin"], bb["ymax"]), expand =FALSE) +labs(title ="Daily wildfire perimeters — {format(as.Date(closest_state), '%d %b %Y')}",subtitle ="New perimeters on this day",caption ="EFFIS rapid perimeters (≥ ~30–50 ha), EU27+EFTA+UK" ) +theme_minimal(base_size =12) +theme(panel.grid =element_blank(),axis.text =element_blank(), axis.title =element_blank()) +# 4) animate by discrete dates; no tweening between states gganimate::transition_states(ba_date, transition_length =0, state_length =1, wrap =FALSE) + gganimate::enter_fade() + gganimate::exit_fade() + gganimate::ease_aes("linear")# Renderp_map_daily# anim_save("map_daily_new_2025.gif")```## Animated day by day fire occurrence```{r dynamic_map_stays, message=FALSE, warning=FALSE}# stable id for grouping (if not already set)if (!"gid"%in%names(ba_2025_tagged)) { ba_2025_tagged <- ba_2025_tagged |> dplyr::mutate(gid = dplyr::row_number())}bb <- sf::st_bbox(ba_2025_tagged)p_map_cumulative <-ggplot() +geom_sf(data = eu_poly, inherit.aes =FALSE,fill ="grey95", color ="grey70", linewidth =0.2) +geom_sf(data = ba_2025_tagged,aes(group = gid),fill ="red", alpha =0.55,color ="red", linewidth =0.05,inherit.aes =FALSE) +coord_sf(xlim =c(bb["xmin"], bb["xmax"]),ylim =c(bb["ymin"], bb["ymax"]), expand =FALSE) +labs(title ="Cumulative wildfire perimeters — {format(as.Date(closest_state), '%d %b %Y')}",subtitle ="Current day + fading trail of earlier days",caption ="EFFIS rapid perimeters (≥ ~30–50 ha), EU27+EFTA+UK" ) +theme_minimal(base_size =12) +theme(panel.grid =element_blank(),axis.text =element_blank(), axis.title =element_blank()) +# KEY: use states (discrete days) + shadow for the trailtransition_states(ba_date, transition_length =0, state_length =1, wrap =FALSE) +shadow_mark(past =TRUE, future =FALSE, alpha =0.15, size =0.02) +enter_fade() +exit_fade() +ease_aes("linear")p_map_cumulativeanim_save("map_daily_cumulative_2025.gif")```# Differences with former summerTo put the wildfire season of 2025 into perspective, it is useful to begin with a closer look at which countries were most severely affected this year. Preliminary figures show that the largest burned areas were concentrated in Southern Europe, particularly in Spain, Portugal, Italy, and Greece, with France also recording substantial losses. These cases illustrate once again how the Mediterranean arc stands at the center of Europe’s wildfire challenge.The next step is to place 2025 in historical context by comparing it to **2017**, which remains one of the most destructive fire seasons in recent European memory. This benchmark year provides a valuable point of reference because it combined extreme climatic conditions with extensive fire damage across the Iberian Peninsula and beyond, leaving a lasting mark on European fire policy debates.Beyond this pairwise comparison, a broader picture emerges when 2025 is considered alongside more recent fire seasons, notably **2022 and 2023**. These years, like 2017, represent moments of particularly high fire activity in the dataset, and together they offer a way to gauge whether 2025 signals an exceptional episode or rather fits into a recurrent pattern of intensifying summer fire crises. In this sense, 2017 serves as a historical baseline, while 2022 and 2023 highlight the persistence of large-scale fires under ongoing climate stress.## Most burned countries in 2025Based on prior analysis, I would assume Spain and Portugal.```{r top_countries, message=FALSE, warning=FALSE, fig.width=14}ctry_2025 <- ba_2025_tagged |>st_drop_geometry() |>group_by(name_long) |>summarise(burned_ha =sum(area_ha, na.rm =TRUE), n_fires =n(), .groups="drop") |>arrange(desc(burned_ha)) |>slice_head(n =10) |>mutate(name_long = forcats::fct_reorder(name_long, burned_ha))# --- deps for flags & color extraction (once) ---if (!requireNamespace("countrycode", quietly =TRUE)) install.packages("countrycode")if (!requireNamespace("ggimage", quietly =TRUE)) install.packages("ggimage")if (!requireNamespace("curl", quietly =TRUE)) install.packages("curl")if (!requireNamespace("fs", quietly =TRUE)) install.packages("fs")if (!requireNamespace("png", quietly =TRUE)) install.packages("png")library(countrycode); library(ggimage); library(curl); library(fs); library(png)# --- robust ISO2 for the Top-10 countries (fall back to eu_poly if needed) ---name_to_iso2 <- tibble::tibble(name_long =as.character(ctry_2025$name_long)) |> dplyr::distinct() |> dplyr::mutate(iso2 =countrycode( name_long, origin ="country.name", destination ="iso2c",custom_match =c("Czechia"="CZ","United Kingdom"="GB","North Macedonia"="MK","Moldova"="MD","Kosovo"="XK") ) )map_iso_from_poly <- eu_poly |> sf::st_drop_geometry() |> dplyr::distinct(name_long, iso_a2) |> dplyr::rename(iso2_poly = iso_a2)name_to_iso2 <- name_to_iso2 |> dplyr::left_join(map_iso_from_poly, by ="name_long") |> dplyr::mutate(iso2 = dplyr::coalesce(iso2, iso2_poly),iso2_lower =tolower(iso2)) |> dplyr::select(name_long, iso2_lower)# --- download small PNG flags locally (once) ---flags_dir <-"assets/flags_rect"fs::dir_create(flags_dir)fetch_flags <-function(iso2_vec) { iso2_vec <-unique(na.omit(iso2_vec)) tibble::tibble(iso2_lower = iso2_vec) |> dplyr::mutate(flag_path = purrr::map_chr(iso2_lower, function(code){ dest <-file.path(flags_dir, sprintf("%s.png", code))if (!file.exists(dest)) { url <-sprintf("https://flagcdn.com/w40/%s.png", code)try(curl::curl_download(url, dest, quiet =TRUE), silent =TRUE) }if (file.exists(dest)) dest elseNA_character_ }))}flag_paths <-fetch_flags(name_to_iso2$iso2_lower)# --- extract a dominant (non-white) color from each flag (for the point) ---dominant_flag_color <-function(path) { res <-try({ arr <- png::readPNG(path) rgb <-cbind(as.vector(arr[,,1]), as.vector(arr[,,2]), as.vector(arr[,,3])) a <-if (dim(arr)[3] >=4) as.vector(arr[,,4]) elserep(1, nrow(rgb)) keep <- a >0.8 rgb <- rgb[keep, , drop =FALSE]# drop near-white pixels keep2 <-rowMeans(rgb) <0.95 rgb <- rgb[keep2, , drop =FALSE]if (nrow(rgb) <50) rgb <- rgb[keep, , drop =FALSE]set.seed(1) km <- stats::kmeans(rgb, centers =min(3, nrow(rgb)), iter.max =15) dom <- km$centers[which.max(tabulate(km$cluster)), ] grDevices::rgb(dom[1], dom[2], dom[3]) }, silent =TRUE)if (inherits(res, "try-error")) "#444444"else res}flag_cols <- flag_paths |> dplyr::mutate(col =vapply(flag_path, dominant_flag_color, character(1)))# --- join ISO, flag paths, and colors to ctry_2025; compute right-side positions ---bars_df <- ctry_2025 |> dplyr::mutate(name_long =as.character(name_long)) |> dplyr::left_join(name_to_iso2, by ="name_long") |> dplyr::left_join(flag_paths, by ="iso2_lower") |> dplyr::left_join(flag_cols, by =c("iso2_lower","flag_path")) |> dplyr::mutate(col = dplyr::coalesce(col, "#444444"))max_x <-max(bars_df$burned_ha, na.rm =TRUE)pad_flag <-0.1* max_x # push flags out to the right so they never touch barsbars_df <- bars_df |> dplyr::mutate(x_flag = burned_ha + pad_flag,name_long = forcats::fct_reorder(name_long, burned_ha) # keep your Top-10 order )# --- plot: neutral bars, colored point at the bar end, flag on the right ---bars_flagged <-ggplot(bars_df, aes(x = burned_ha, y = name_long)) +geom_col(fill ="grey90") +geom_point(aes(color = col), size =3) +scale_color_identity() + ggimage::geom_image(data = dplyr::filter(bars_df, !is.na(flag_path)),aes(x = x_flag, y = name_long, image = flag_path),inherit.aes =FALSE, size =0.065, asp =2/3 ) +scale_x_continuous(labels = lab_si_ha, expand =expansion(mult =c(0, 0.30))) +labs(title ="Top-10 countries — total burned area (Jun–Aug 2025)",subtitle ="Summer is from early June till late August (meteorological).\n Each line shows the change in polygon-summed burned area between 2017 and 2025.",x ="Burned area (ha)", y =NULL,caption ="Areas computed from polygon geometry (EPSG:3035).\n Countries assigned by maximum overlap with national borders (Europe)." ) +theme_minimal(base_size =11) +theme(plot.margin =margin(5.5, 90, 10, 5.5, "pt")) +coord_cartesian(clip ="off")bars_flagged```> France not even in Top 10...## Comparison with year 2017```{r compare_2017, message=FALSE, warning=FALSE, fig.width=14}# -------------------- Multi-year comparison (geometry-first) --------------------get_burnt_summer_local_geom <-function(y, eu_poly, ba_all){ out <- ba_all |>filter(ba_date >=as.Date(paste0(y,"-06-01")), ba_date <=as.Date(paste0(y,"-08-31"))) |>st_make_valid() |>st_transform(3035) eu_u <-st_union(eu_poly) out <-st_intersection(out, eu_u) |>mutate(area_ha =as.numeric(st_area(geometry))/10000) inter <-st_intersection(out |>select(id, area_ha), eu_poly |>select(name_long, iso_a2)) |>mutate(overlap_ha =as.numeric(st_area(geometry))/10000) winner <- inter |>st_drop_geometry() |>group_by(id) |>slice_max(overlap_ha, n =1, with_ties =FALSE) |>ungroup() |>select(id, name_long, iso_a2) out |>left_join(winner, by ="id") |>filter(!is.na(name_long)) |>mutate(year = y)}yrs <-c(2017, 2022, 2023, 2025)multi <-bind_rows(lapply(yrs, get_burnt_summer_local_geom, eu_poly = eu_poly, ba_all = ba_all))ctry_multi <- multi |>st_drop_geometry() |>group_by(name_long, year) |>summarise(burned_ha =sum(area_ha, na.rm =TRUE), .groups ="drop")dd <- ctry_multi |>filter(year %in%c(2017, 2025)) |> tidyr::pivot_wider(names_from = year, values_from = burned_ha, values_fill =0) |>mutate(delta =`2025`-`2017`) |>arrange(desc(`2025`)) |>slice_head(n =20) |>mutate(name_long = forcats::fct_reorder(name_long, `2025`))# --- deps for flags & color extraction ---if (!requireNamespace("ggimage", quietly =TRUE)) install.packages("ggimage")if (!requireNamespace("countrycode", quietly =TRUE)) install.packages("countrycode")if (!requireNamespace("curl", quietly =TRUE)) install.packages("curl")if (!requireNamespace("fs", quietly =TRUE)) install.packages("fs")if (!requireNamespace("png", quietly =TRUE)) install.packages("png")library(ggimage); library(countrycode); library(curl); library(fs); library(png)# label formatter if not definedif (!exists("lab_si_ha")) lab_si_ha <- scales::label_number(scale_cut = scales::cut_si("ha"))# --------- ISO2 per country (robust) ----------codes <- tibble::tibble(name_long =as.character(dd$name_long)) |> dplyr::distinct() |> dplyr::mutate(iso2 =countrycode( name_long, origin ="country.name", destination ="iso2c",custom_match =c("Czechia"="CZ", "United Kingdom"="GB", "North Macedonia"="MK","Moldova"="MD", "Kosovo"="XK") ) )# fallback to map names if any missingcodes_map <- eu_poly |> sf::st_drop_geometry() |> dplyr::distinct(name_long, iso_a2) |> dplyr::rename(iso2_poly = iso_a2)codes <- codes |> dplyr::left_join(codes_map, by ="name_long") |> dplyr::mutate(iso2 = dplyr::coalesce(iso2, iso2_poly),iso2_lower =tolower(iso2)) |> dplyr::select(name_long, iso2_lower)# --------- download tiny flags locally (once) ----------flags_dir <-"assets/flags_rect"fs::dir_create(flags_dir)fetch_flags <-function(iso2_vec) { iso2_vec <-unique(na.omit(iso2_vec)) tibble::tibble(iso2_lower = iso2_vec) |> dplyr::mutate(flag_path = purrr::map_chr(iso2_lower, function(code){ dest <-file.path(flags_dir, sprintf("%s.png", code))if (!file.exists(dest)) { url <-sprintf("https://flagcdn.com/w40/%s.png", code)try(curl::curl_download(url, dest, quiet =TRUE), silent =TRUE) }if (file.exists(dest)) dest elseNA_character_ }))}flag_paths <-fetch_flags(codes$iso2_lower)# --------- extract a dominant (non-white) color from each flag ----------dominant_flag_color <-function(path) { res <-try({ arr <- png::readPNG(path) # H x W x 4 (RGBA, in 0..1) h <-dim(arr)[1]; w <-dim(arr)[2] rgb <-cbind(as.vector(arr[,,1]), as.vector(arr[,,2]), as.vector(arr[,,3])) a <-if (dim(arr)[3] >=4) as.vector(arr[,,4]) elserep(1, nrow(rgb)) keep <- a >0.8 rgb <- rgb[keep, , drop =FALSE]# drop near-white pixels keep2 <-rowMeans(rgb) <0.95 rgb <- rgb[keep2, , drop =FALSE]if (nrow(rgb) <50) rgb <- rgb[keep, , drop =FALSE]set.seed(1) km <- stats::kmeans(rgb, centers =min(3, nrow(rgb)), iter.max =15) dom <- km$centers[which.max(tabulate(km$cluster)), ] grDevices::rgb(dom[1], dom[2], dom[3]) }, silent =TRUE)if (inherits(res, "try-error")) "#444444"else res}flag_cols <- flag_paths |> dplyr::mutate(col =vapply(flag_path, dominant_flag_color, character(1)))# --------- build plotting DF with flag positions and colors ----------dd_plot <- dd |> dplyr::left_join(codes, by ="name_long") |> dplyr::left_join(flag_paths, by ="iso2_lower") |> dplyr::left_join(flag_cols, by =c("iso2_lower","flag_path")) |> dplyr::mutate(col = dplyr::coalesce(col, "#444444"))# left margin positions (put flag & name at negative x)max_x <-max(c(dd_plot$`2017`, dd_plot$`2025`), na.rm =TRUE)x_left <--0.25* max_x # flag centerx_name <--0.05* max_x # country name (to the right of flag)# --------- plot: flags & names on the left; lines/points colored by flag color ----------p_dumbbell_25_17 <-ggplot(dd_plot) +# dumbbell linegeom_segment(aes(x =`2017`, xend =`2025`, y = name_long, yend = name_long, colour = col),linewidth =0.9, alpha =0.9, lineend ="round", show.legend =FALSE) +# pointsgeom_point(aes(x =`2017`, y = name_long, colour = col), size =2.4, show.legend =FALSE) +geom_point(aes(x =`2025`, y = name_long, colour = col), size =3.1, show.legend =FALSE) +# flag + name on the left ggimage::geom_image(aes(y = name_long, x = x_left, image = flag_path),size =0.1, asp =2/3, inherit.aes =FALSE, na.rm =TRUE) +geom_text(aes(y = name_long, x = x_name, label = name_long),size =3.7, hjust =1, inherit.aes =FALSE) +# axes & labelsscale_colour_identity() +scale_x_continuous(labels = lab_si_ha, expand =expansion(mult =c(0, 0.03))) +labs(title ="Burned area — Summer 2025 vs 2017 (Top 20 countries)",subtitle ="Summer is from early June till late August (meteorological).\n Each line shows the change in polygon-summed burned area between 2017 and 2025.",x ="Burned area (ha)", y =NULL,caption ="Areas computed from perimeter geometry in an equal-area projection (EPSG:3035).\n Differences vs. official statistics are expected due to thresholds, timing, and mapping criteria." ) +theme_minimal(base_size =12) +theme(panel.grid.major.y =element_blank(),panel.grid.minor =element_blank(),axis.text.y =element_blank(), # hide default country labels (we draw our own on the left)axis.ticks.y =element_blank(),plot.margin =margin(5.5, 5.5, 10, 110, "pt") # extra left room for flags ) +coord_cartesian(xlim =c(-0.18* max_x, NA), clip ="off") # show left decorationsp_dumbbell_25_17```> This figure gives a clear comparative picture of wildfire damage in **2025 versus 2017** across the top twenty most affected countries.Spain stands out as the most striking case: although it already suffered catastrophic losses in 2017, burned areas in 2025 are even higher, making it by far the leading country in terms of destruction. Portugal, which was at the center of the 2017 crisis, shows much smaller losses in 2025, suggesting that the geographical core of fire severity can shift markedly from one episode to another. Greece and Italy also record substantial burned areas in both years, though their relative positions differ: Italy’s losses are larger in 2017, whereas Greece’s appear more balanced across the two years.Beyond the Mediterranean, several Balkan countries, including Serbia, Montenegro, North Macedonia, and Albania, experienced notable fire damage in 2017 and again in 2025, highlighting the structural exposure of this region. France, which was not a major outlier in 2017, shows higher burned areas in 2025, aligning with broader evidence that fire risk is expanding northward along the Mediterranean arc.Overall, the comparison suggests that while the **Mediterranean South remains the epicenter of wildfire activity**, the relative weight of countries within this zone can change significantly from year to year. The data also reinforces the importance of 2017 as a benchmark season: it illustrates both the exceptional scale of fire damage in Iberia and provides a useful baseline against which to measure the evolving geography of wildfire risk.## Comparison with other former years```{r year_compare, message=FALSE, warning=FALSE, fig.width=14}# ---- Emoji flag helper (robust) ----flag_from_iso2 <-function(iso2) { iso2 <-toupper(as.character(iso2)) out <-rep(NA_character_, length(iso2)) ok <-!is.na(iso2) &nchar(iso2) ==2if (any(ok)) { chars <-strsplit(iso2[ok], "", fixed =TRUE) out[ok] <-vapply( chars,function(cc) {if (length(cc) !=2) return(NA_character_)paste0(intToUtf8(127397+utf8ToInt(cc[1])),intToUtf8(127397+utf8ToInt(cc[2]))) },FUN.VALUE =character(1) ) } out}# --- build a robust ISO2 table (works even if map names differ) ---if (!requireNamespace("countrycode", quietly =TRUE)) install.packages("countrycode")library(countrycode)name_to_iso2 <- tibble::tibble(name_long =unique(ctry_multi$name_long)) |> dplyr::mutate(iso2 =countrycode( name_long, origin ="country.name", destination ="iso2c",custom_match =c("Czechia"="CZ", "United Kingdom"="GB","North Macedonia"="MK", "Kosovo"="XK", "Moldova"="MD") ) )# fallback to map if any NAmap_iso_from_poly <- eu_poly |> sf::st_drop_geometry() |> dplyr::distinct(name_long, iso_a2) |> dplyr::rename(iso2_poly = iso_a2)name_to_iso2 <- name_to_iso2 |> dplyr::left_join(map_iso_from_poly, by ="name_long") |> dplyr::mutate(iso2 = dplyr::coalesce(iso2, iso2_poly),iso2_lower =tolower(iso2)) |> dplyr::select(name_long, iso2_lower)topN_multi <- ctry_multi |>filter(year ==2025) |>arrange(desc(burned_ha)) |>slice_head(n =10) |>pull(name_long)# --- order so Top-1 (by 2025) is at the TOP ---order_2025 <- ctry_multi |> dplyr::filter(year ==2025, name_long %in% topN_multi) |> dplyr::arrange(dplyr::desc(burned_ha)) |> dplyr::pull(name_long)df_plot <- ctry_multi |> dplyr::filter(name_long %in% topN_multi) |> dplyr::mutate(name_long = forcats::fct_relevel(name_long, !!!rev(order_2025)),year =factor(year) )# --- right-side layout: positions are beyond the LONGEST bar (any year) ---max_x <-max(df_plot$burned_ha, na.rm =TRUE)pad_label <-0.1* max_x # where the numeric label goes (beyond all bars)gap_flag <-0.06* max_x # gap between label and flaggap_name <-0.03* max_x # gap between flag and country namevals_2025 <- df_plot |> dplyr::filter(year ==2025) |> dplyr::select(name_long, val_2025 = burned_ha)layout_df <- df_plot |> dplyr::group_by(name_long) |> dplyr::summarise(x_max =max(burned_ha, na.rm =TRUE), .groups ="drop") |> dplyr::left_join(vals_2025, by ="name_long") |> dplyr::left_join(name_to_iso2, by ="name_long") |> dplyr::mutate(x_label = x_max + pad_label, # numeric label (right-justified)x_flag = x_label + gap_flag, # flag position (to the right)x_country = x_flag + gap_name, # country name (even further right)label_txt = scales::label_number(scale_cut = scales::cut_si("ha"))(val_2025) )# --- make sure local PNG flags exist (ggimage) ---if (!requireNamespace("ggimage", quietly =TRUE)) install.packages("ggimage")if (!requireNamespace("curl", quietly =TRUE)) install.packages("curl")if (!requireNamespace("fs", quietly =TRUE)) install.packages("fs")library(ggimage); library(curl); library(fs)flags_dir <-"assets/flags_rect"fs::dir_create(flags_dir)fetch_flags <-function(iso2_vec) { iso2_vec <-unique(na.omit(iso2_vec)) tibble::tibble(iso2_lower = iso2_vec) |> dplyr::mutate(flag_path = purrr::map_chr(iso2_lower, function(code){ dest <-file.path(flags_dir, sprintf("%s.png", code))if (!file.exists(dest)) { url <-sprintf("https://flagcdn.com/w40/%s.png", code)try(curl::curl_download(url, dest, quiet =TRUE), silent =TRUE) }if (file.exists(dest)) dest elseNA_character_ }) )}layout_df <- layout_df |> dplyr::left_join(fetch_flags(layout_df$iso2_lower), by ="iso2_lower")# --- colors & scales ---lab_si_ha <- scales::label_number(scale_cut = scales::cut_si("ha"))col_map <-c(`2017`="grey80", `2022`="grey65", `2023`="grey55", `2025`="#D64A05")# --- plot (labels aligned with flags, no names on the left) ---p_bars_multi <-ggplot(df_plot, aes(y = name_long, x = burned_ha, fill = year)) +geom_col(position =position_dodge(width =0.7), width =0.65) +scale_fill_manual(values = col_map, name ="Year") +scale_x_continuous(labels = lab_si_ha, expand =expansion(mult =c(0, 0.55))) +labs(title ="Top-10 (by 2025) — Summer burned area across years",subtitle ="Summer is from early June till late August (meteorological).",x ="Burned area (ha)", y =NULL,caption =paste("Notes — EFFIS rapid perimeters (≈ ≥30–50 ha), Jun–Aug.","Areas from polygon geometry (EPSG:3035). Countries by max overlap (EU27 + EFTA + UK).","Differences vs official stats reflect thresholds, timing, mapping criteria.", sep=" " ) ) +theme_minimal(base_size =13) +theme(legend.position ="right",panel.grid.major.y=element_blank(),panel.grid.minor =element_blank(),axis.text.y =element_blank(), # <- remove country names on the leftaxis.ticks.y =element_blank(),plot.margin =margin(5.5, 140, 14, 5.5, "pt"),plot.caption =element_text(size =9, colour ="grey25", lineheight =1.1) ) +coord_cartesian(clip ="off") +# value labels for 2025, right-justified and aligned with flagsgeom_text(data = layout_df,aes(y = name_long, x = x_label, label = label_txt),inherit.aes =FALSE, hjust =1, vjust =0.5, size =3.7, colour = col_map["2025"] ) +# flags (local PNGs) ggimage::geom_image(data = dplyr::filter(layout_df, !is.na(flag_path)),aes(y = name_long, x = x_flag, image = flag_path),inherit.aes =FALSE, size =0.07, asp =2/3 ) +# country names on the right (optional; keep if you want)geom_text(data = layout_df,aes(y = name_long, x = x_country, label = name_long),inherit.aes =FALSE, hjust =0, vjust =0.5, size =4 )p_bars_multi```> This chart helps situate the **2025 wildfire season** within a broader multi-year perspective, comparing it directly to other recent crisis years — 2017, 2022, and 2023.Spain dominates the picture: its 2025 burned area, close to 377 thousand hectares, clearly surpasses earlier peaks, making it the single largest national loss in the dataset. Portugal, which was the emblematic case in 2017 with devastating fires, records lower figures in 2025 but still appears among the most affected countries. Italy, Greece, and Albania also stand out, with 2025 values broadly in the same range as the peaks of previous years, showing how these countries consistently face recurrent exposure.What is striking here is not only the magnitude of 2025 for Spain, but also the **geographic breadth of high-impact years**. Different countries emerge at different times — Portugal in 2017, Spain in 2025, Greece in both 2022 and 2023 — suggesting that extreme fire seasons are not confined to a single geography but rotate within the Mediterranean and Balkan regions depending on climatic and local conditions.By including 2022 and 2023 alongside the 2017 benchmark, this comparison highlights that 2025 is not an isolated anomaly but rather part of a recurring pattern of **severe pan-European fire years**, each with its own epicenter. The choice of these four years reflects their exceptional character in the record, offering a way to assess whether wildfire risk is intensifying or shifting across Southern and Eastern Europe over time.## Notes & caveats> Or the disclaimer part!!!The figures and maps presented here are based on **EFFIS rapid perimeter data** (≥ 30–50 ha), which rely on satellite detection and automated mapping procedures. While this provides timely and consistent coverage at the European scale, it inevitably differs from official national fire statistics, which may use different thresholds, definitions of burned area, or incorporate smaller fires not captured in these datasets. As such, the numbers reported here should be interpreted as **projections or estimates**, not as exact totals.Another limitation lies in the temporal framing: the analysis defines “summer” as June to August (meteorological summer), whereas fire agencies may use different seasonal boundaries. This choice improves comparability across years, but it may exclude late-season fires (e.g., September outbreaks), which can be significant in certain regions. Similarly, attribution of burn scars to countries is based on maximum spatial overlap, which may under- or over-estimate totals in border areas.Finally, it is important to stress that these results are intended for **analytical and exploratory purposes**. They provide a comparative overview of wildfire dynamics in Europe but should not be treated as official statistics. For policy, management, or operational decisions, readers should always refer to authoritative national and European reporting systems.