In January 2025, the Palisades Fire burned through Pacific Palisades and Malibu in Los Angeles County during a Santa Ana wind event and destroyed more than 6,800 structures, making it one of the most destructive wildfires in California’s history. Almost two years later, many of those homeowners are now deciding how to rebuild, and the city is deciding what the rebuilt neighborhoods should look like.
Most advice about protecting a home from wildfire is about the home itself (home hardening) or the area around it (defensible space), but once a fire reaches a neighborhood, the biggest fuel source near a house is often the property next door. A burning home throws embers and radiant heat at its neighbors, and if the homes are close enough, the fire can move from house to house without any wildland fuel at all. For the Palisades Fire, this report asks: were homes that sat closer to other buildings destroyed more often?
After the 2018 Camp Fire in Paradise, homes more than 18 meters from a destroyed structure survived at a much higher rate than homes closer to one (Knapp et al., 2021), and in a study of the 2025 Palisades and Eaton fires, the number of nearby structures was the strongest predictor of which homes were lost, stronger than the trees around them (Kenny et al., 2026). The pattern isn’t the same in every landscape, however; across whole regions of Southern California, homes in low-density, scattered development were more likely to be lost than homes in dense neighborhoods because they sit next to more wildland (Syphard et al., 2012). Spacing between homes matters most once the fire is already inside a neighborhood, which is where most of the Palisades homes were lost.
The interactive map draws each home as a circle sized by how much space it had around it, so we can see where homes were packed together and how they fared.
CAL FIRE’s Office of the State Fire Marshal inspects every structure inside a fire’s footprint after the fire and publishes the results as the CAL FIRE Damage Inspection (DINS) data on ArcGIS Online. I used the same copy as Labs 2 and 3, downloaded on October 16th, 2026, which has 132,522 structures from fires between 2013 and 2025. Each row is one inspected structure, and each has a longitude and latitude, so the data is already in the form of points.
The fields used in this report are:
To show where the fire burned, I also used the fire’s final perimeter from NIFC’s WFIGS Interagency Fire Perimeters layer, downloaded on October 8th, 2026. Its area of 23,442 acres matches the official acreage and lines up with CAL FIRE’s own perimeter for the fire.
Besides the packages from earlier labs, sf turns the coordinates into point geometry and measures distances between buildings, leaflet builds the interactive map, and htmltools makes text safe to put inside a popup.
library(tidyverse) # data wrangling and ggplot2, including stringr and purrr
library(janitor) # clean_names() for tidy column names
library(sf) # simple features: spatial vector data
library(leaflet) # interactive maps
library(htmltools) # htmlEscape() for safe popup text
library(here) # builds file paths from project root
dins <- read_csv(here("GEOG588", "lab4", "data", "dins_raw.csv")) |>
clean_names() # `* Damage` becomes `damage`, `Year Built (parcel)` becomes `year_built_parcel`
perimeter <- st_read(here("GEOG588", "lab4", "data", "palisades_perimeter.geojson"),
quiet = TRUE) # quiet hides the file summary sf prints
The perimeter is already in WGS84, the same CRS as DINS, so it’s ready to map.
The choices that shape the analysis are set here at the top, so they’re easy to find and change:
fire_name <- "Palisades"
utm_crs <- 32611 # UTM zone 11N, which measures in meters
min_gap_m <- 1 # structures closer than this are duplicate points, not neighbors
search_m <- 500 # look for a home's nearest neighbor within this distance
max_radius_m <- 25 # largest circle drawn on the map, so isolated homes don't dominate
damage_levels <- c(
"No Damage",
"Affected (>0-10%)",
"Minor (10-25%)",
"Major (25-50%)",
"Destroyed (>50%)"
)
Spacing is measured from each home to the nearest other structure of any kind, because a burning garage, apartment building, or store can ignite a home just as well as another house. I kept two tables: every structure the inspectors recorded in the fire (for the calculations), and the single-family homes (for the map).
structures <- dins |>
filter(incident_name == fire_name) |>
st_as_sf(coords = c("longitude", "latitude"), crs = 4326) |>
st_transform(utm_crs) # meters, so distances are easy to compute
homes <- structures |>
filter(
structure_category == "Single Residence", # includes mobile homes
damage != "Inaccessible" # not inspected, so no outcome to map
) |>
mutate(
damage = factor(damage, levels = damage_levels), # order from least to most damage
address = str_to_title(replace_na(site_address_parcel, "Address not recorded")),
year_built = na_if(year_built_parcel, 0), # 0 means the year is unknown
# the outcome for the map and chart: destroyed, or still standing
destroyed = damage == "Destroyed (>50%)",
outcome = factor(if_else(destroyed, "Destroyed", "Survived"),
levels = c("Survived", "Destroyed")),
# a three-way status for the hover labels only
status = case_when(
destroyed ~ "destroyed",
damage == "No Damage" ~ "survived",
.default = "damaged"
)
)
Step by step:
filter() keeps every structure in the Palisades Fire,
and st_as_sf() turns each one’s longitude and latitude into
a point. st_transform() then reprojects the points to UTM
zone 11N, so the coordinates are in meters.filter() keeps single-family homes the
inspectors were able to reach. Inaccessible structures stay in
structures, since they can still be a home’s nearest
neighbor.mutate(), turning damage into a factor
gives it the right order; without it, R sorts the classes
alphabetically. Missing addresses get a placeholder, and a year built of
0 becomes NA.destroyed and outcome split homes into
destroyed and everything else, the same split as Lab 3, because a home
with minor damage is still standing. status keeps the
partly damaged homes separate, for the hover labels.That leaves 9,457 homes, out of 12,137 structures:
homes |>
st_drop_geometry() |>
count(damage) |>
mutate(percent = n / sum(n) * 100) |>
knitr::kable(digits = 0, col.names = c("Damage", "Homes", "% of homes"))
| Damage | Homes | % of homes |
|---|---|---|
| No Damage | 3263 | 35 |
| Affected (>0-10%) | 581 | 6 |
| Minor (10-25%) | 134 | 1 |
| Major (25-50%) | 51 | 1 |
| Destroyed (>50%) | 5428 | 57 |
The fire destroyed 57% of the homes it reached.
For each home, I wanted the distance to the closest other structure.
st_is_within_distance() first finds, for each home, the row
numbers of all structures within 500 meters. That list includes the home
itself, at a distance of 0. The small function below then measures the
distance from the home to each of those structures and keeps the
shortest one.
In terms of data cleaning, DINS sometimes has two points at the same spot, such as a home recorded twice, so distances under 1 meter are dropped as duplicates rather than counted as neighbors, and because the data is in UTM, the distance is in meters.
home_xy <- st_coordinates(homes) # x and y in meters, one row per home
structure_xy <- st_coordinates(structures)
# for each home, the row numbers of the structures within search_m (including itself)
candidates <- st_is_within_distance(homes, structures, dist = search_m)
# distance from home i to its nearest other structure
nearest_gap <- function(i) {
rows <- candidates[[i]]
gaps <- sqrt((structure_xy[rows, 1] - home_xy[i, 1])^2 +
(structure_xy[rows, 2] - home_xy[i, 2])^2)
gaps <- gaps[gaps >= min_gap_m] # drops the home itself and duplicate points
if (length(gaps) == 0) NA_real_ else min(gaps) # NA if nothing is within search_m
}
homes <- homes |>
mutate(
spacing_m = map_dbl(seq_len(n()), nearest_gap),
spacing_class = cut(
spacing_m,
breaks = c(0, 10, 15, 20, 30, 50, Inf),
labels = c("Under 10", "10-15", "15-20", "20-30", "30-50", "50+"),
right = FALSE # 10 m goes in "10-15", not "Under 10"
)
)
map_dbl() runs nearest_gap() once for each
home and returns the results as one column of numbers.
cut() then sorts those distances into classes. The class
breaks are my own choice: narrow at the short end, where most homes are,
and wider above 30 meters, where there are fewer homes.
The median home was 18 meters from its nearest neighbor. That distance is measured between DINS points, which sit near the middle of each building, so the gap between walls would naturally be shorter. A home 15 meters from its neighbor’s point might have only a few meters of open ground between the two buildings.
spacing_summary <- homes |>
st_drop_geometry() |>
filter(!is.na(spacing_class)) |>
group_by(spacing_class) |>
summarize(homes = n(), pct_destroyed = mean(destroyed) * 100)
spacing_summary |>
knitr::kable(digits = 0, format.args = list(big.mark = ","),
col.names = c("Distance to nearest structure (m)", "Homes", "% destroyed"))
| Distance to nearest structure (m) | Homes | % destroyed |
|---|---|---|
| Under 10 | 307 | 83 |
| 10-15 | 2,334 | 78 |
| 15-20 | 2,877 | 63 |
| 20-30 | 2,441 | 43 |
| 30-50 | 1,208 | 32 |
| 50+ | 290 | 33 |
ggplot(spacing_summary, aes(spacing_class, pct_destroyed)) +
geom_col(fill = "#D7191C", width = 0.7) + # same red as destroyed homes on the map
geom_text(aes(label = str_glue("{round(pct_destroyed)}%")),
vjust = -0.4, size = 3.5) + # percent above each bar
geom_text(aes(y = 2, label = str_glue("n = {scales::comma(homes)}")),
vjust = 0, size = 3, color = "white") + # home count inside each bar
scale_y_continuous(limits = c(0, 100), expand = expansion(mult = c(0, 0.02))) +
labs(
x = "Distance to nearest structure (m)",
y = "Homes destroyed (%)",
caption = "Data: CAL FIRE Damage Inspection (DINS)"
) +
theme_minimal(base_size = 12) +
theme(panel.grid.major.x = element_blank()) # vertical gridlines add nothing to bars
Figure 1. Percent of single-family homes destroyed in the 2025 Palisades Fire, by distance from each home to the nearest other structure (n = 9,457 homes). Data: CAL FIRE Damage Inspection (DINS), downloaded October 16th, 2026.
The share of homes destroyed falls steadily with distance, from 83% for homes less than 10 meters from another structure to 32% for homes 30 to 50 meters away, and beyond 50 meters, it stops falling (33%). That flattening fits Syphard et al. (2012), as the most isolated homes are often on the edge of the neighborhood where they face the wildland fire directly.
Closely spaced homes might also be in the parts of the fire that burned hardest, in which case spacing would look influential even if it did nothing. One way to check that assumption is to compare only homes on “mixed” blocks, where some neighbors burned and some didn’t, since those homes went through roughly the same fire. I counted a home as being on a mixed block if 25% to 75% of the other homes within 50 meters were destroyed.
# for each home, the row numbers of the other homes within 50 meters
neighbors <- st_is_within_distance(homes, dist = 50, remove_self = TRUE)
homes <- homes |>
mutate(
# share of each home's neighbors that were destroyed (NaN if it has no neighbors)
neighbors_destroyed = map_dbl(neighbors, \(rows) mean(homes$destroyed[rows])),
mixed_block = replace_na(between(neighbors_destroyed, 0.25, 0.75), FALSE)
)
mixed_summary <- homes |>
st_drop_geometry() |>
filter(mixed_block, !is.na(spacing_class)) |>
group_by(spacing_class) |>
summarize(homes = n(), pct_destroyed = mean(destroyed) * 100)
mixed_summary |>
knitr::kable(digits = 0, format.args = list(big.mark = ","),
col.names = c("Distance to nearest structure (m)",
"Homes on mixed blocks", "% destroyed"))
| Distance to nearest structure (m) | Homes on mixed blocks | % destroyed |
|---|---|---|
| Under 10 | 28 | 54 |
| 10-15 | 293 | 63 |
| 15-20 | 723 | 56 |
| 20-30 | 776 | 53 |
| 30-50 | 273 | 47 |
On mixed blocks, the pattern is much weaker but still there: homes 10 to 15 meters from another structure were destroyed 63% of the time, compared with 47% for homes 30 to 50 meters away, a gap of 16 percentage points instead of 46 across the whole fire. The “Under 10” class has only 28 homes on mixed blocks, too few to read much into, and there’s no “50+” class at all, because a mixed block requires neighbors within 50 meters.
Most of the difference in the bar chart comes from where closely spaced homes were, as the dense parts of the neighborhood were also where the fire burned through whole blocks. Even among neighbors who faced roughly the same fire, the homes with more space around them still did better, however, which matches Kenny et al. (2026), who found structure density was the strongest predictor of loss in a model that also included tree canopy and wind.
Each home’s hover label and click popup are built as columns before
mapping. Because leaflet reads popups as HTML, htmlEscape()
is applied to text from the data so that a character such as
< in an address is displayed rather than read as
markup.
homes_map <- homes |>
st_transform(4326) |> # back to longitude and latitude for leaflet
mutate(
spacing_text = if_else(is.na(spacing_m),
str_glue("over {search_m} m"),
str_glue("{round(spacing_m)} m")),
label = str_glue("{address}: {spacing_text} to nearest structure ({status})"),
popup = str_glue(
"<b>{htmlEscape(address)}</b><br>",
"Outcome: {outcome} ({htmlEscape(damage)})<br>",
"Nearest structure: {spacing_text}<br>",
"Year built: {replace_na(as.character(year_built), 'Unknown')}"
),
# circle radius in meters: half the distance to the nearest structure, capped
radius_m = pmin(replace_na(spacing_m / 2, max_radius_m), max_radius_m)
) |>
arrange(desc(radius_m)) # draw small circles last so they sit on top of big ones
Each home is drawn as a circle whose radius is half the distance to
its nearest structure, in meters on the ground. Small, crowded circles
mark tightly packed homes. The radius is capped at 25 meters so that the
few isolated homes do not cover the map. I used
addCircles(), which sizes circles in meters, rather than
addCircleMarkers() from earlier labs, which sizes them in
screen pixels, so each circle covers the same ground at every zoom
level.
Color shows outcome, the main variable. Survived and destroyed are categories rather than amounts, so they use two contrasting hues, and I used blue and red so they remain distinguishable for those with color blindness. The fire perimeter is a semi-transparent gray fill without an outline, which keeps it visually beneath the homes. The basemap combines three Esri layers that do not require an API key: a light gray base, a semi-transparent hillshade to show the canyons and ridges, and street labels drawn above the circles.
outcome_colors <- colorFactor(palette = c("#2C7BB6", "#D7191C"),
levels = levels(homes$outcome))
esri_gray_base <- "https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}"
esri_gray_reference <- "https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Reference/MapServer/tile/{z}/{y}/{x}"
esri_hillshade <- "https://server.arcgisonline.com/ArcGIS/rest/services/Elevation/World_Hillshade/MapServer/tile/{z}/{y}/{x}"
The label tiles sit in their own map pane above the circles so street
names remain readable, and the onRender() call at the end
disables mouse interaction on that pane so the labels do not block
hovers and clicks on the circles beneath them.
leaflet(homes_map) |>
# a pane for the labels above the circles (the default layer pane is z-index 400)
addMapPane("labels", zIndex = 650) |>
# 1. light basemap without labels
addTiles(urlTemplate = esri_gray_base,
attribution = "Basemap: Esri, HERE, Garmin, OpenStreetMap contributors",
options = tileOptions(maxNativeZoom = 16, maxZoom = 19)) |>
# 2. hillshade, semi-transparent, so the terrain shows through
addTiles(urlTemplate = esri_hillshade,
attribution = "Hillshade: Esri, USGS, NGA",
options = tileOptions(opacity = 0.3, maxNativeZoom = 15, maxZoom = 19)) |>
# 3. street and place labels, in the pane above the circles
addTiles(urlTemplate = esri_gray_reference,
options = tileOptions(pane = "labels", maxNativeZoom = 16, maxZoom = 19)) |>
# 4. fire perimeter under the circles: gray fill, no outline, not clickable
addPolygons(data = perimeter,
stroke = FALSE,
fillColor = "#565555",
fillOpacity = 0.35,
options = pathOptions(interactive = FALSE)) |>
# 5. homes as "personal space" circles
addCircles(
radius = ~radius_m, # meters on the ground, so circles scale with zoom
fillColor = ~outcome_colors(outcome),
fillOpacity = 0.75,
color = "white", # thin white outline to separate touching circles
weight = 0.5,
label = ~label, # shown on hover
popup = ~popup # shown on click
) |>
# legends and scale bar
addLegend(
position = "bottomright",
pal = outcome_colors,
values = ~outcome,
title = "Outcome",
opacity = 0.9
) |>
addLegend(
position = "bottomright",
colors = "#565555",
labels = "Fire perimeter",
opacity = 0.35
) |>
addScaleBar(position = "bottomleft") |>
# let hovers and clicks pass through the label tiles to the circles below
htmlwidgets::onRender("function(el, x) { this.getPane('labels').style.pointerEvents = 'none'; }")
Figure 2. Single-family homes inspected by CAL FIRE after the January 2025 Palisades Fire (n = 9,457). Each circle’s radius is half the distance from the home to its nearest other structure, capped at 25 meters, and color shows whether the home survived or was destroyed. The gray area is the fire’s final perimeter. Data: CAL FIRE Damage Inspection (DINS), downloaded October 16th, 2026; fire perimeter from NIFC WFIGS Interagency Fire Perimeters, downloaded October 8th, 2026.
At the full extent, destroyed homes form large, contiguous patches across the flatter, more densely built parts of Pacific Palisades, and most surviving homes lie near the edges of the burned area, where the fire either stopped or was stopped.
At street scale, the circles show the spacing behind Figure 1. In the densest blocks, the circles are small and nearly all red. Where homes are spread out along the canyon roads, the circles are larger and more of them are blue.
This agrees with the studies cited in the introduction (Knapp et al., 2021; Kenny et al., 2026). Those studies measured spacing from building outlines and imagery, whereas this report measures between DINS points, so the distances here are longer than theirs, but the direction of the relationship is the same.
In the Palisades Fire, homes close to other buildings were destroyed far more often than homes with space around them: 83% of homes less than 10 meters from their nearest neighbor were destroyed, compared with 32% of homes 30 to 50 meters away. Much of that gap reflects where the dense blocks were, but a smaller gap of 16 percentage points remains among neighbors on mixed blocks. This is consistent with findings from the Camp and Eaton fires, and it suggests that once a fire is inside a neighborhood, each home’s risk depends partly on its neighbors. Neighborhood-scale measures, such as hardening the homes on both sides of a narrow gap, keeping sheds and fences from bridging the space between homes, and setting minimum spacing for new homes, deserve attention in this area, and this underpins the importance of community-scale mitigation programs like Firewise and Fire Safe Councils.