Palisades Fire · Los Angeles · January 2025
In January 2025 the Palisades Fire burned ~23,400 acres of western Los Angeles and destroyed thousands of buildings. This notebook rebuilds the picture from fully open data — and turns it into a custom fullscreen web application, defined right here as an anywidget ESM module and driven by the analysis below.
What this page demonstrates:
~30,000 building footprints from Overture Maps GeoParquet on S3, queried with DuckDB
Per-building damage classes from the Microsoft AI for Good damage assessment (via HDX)
Before/after satellite imagery — Maxar Open Data ARD COGs tiled live through titiler.xyz, no backend of our own
The official NIFC/WFIGS fire perimeter
A bespoke app shell (
fire_explorer/) — custom layout, chart, filters and camera controls — embedding amanywidgetsMapCompareswipe of twolonboardmaps, all fully interactive without a running kernel
Scroll to the card at the bottom and hit ⛶ — or open the app directly.
Maxar Open Data imagery is licensed CC-BY-NC-4.0 (non-commercial, attribution required).
# Every source is open and keyless; loaders are cache-first (see la_fires_data.py).
from la_fires_data import load_perimeter, load_buildings_with_damage, load_maxar_selection
perimeter = load_perimeter()
buildings = load_buildings_with_damage()
maxar = load_maxar_selection()
print(f"{len(buildings):,} Overture buildings in the AOI")
print(f"{len(maxar['pre'])} pre-fire + {len(maxar['post'])} post-fire Maxar COGs (all 0% cloud)")
buildings["damage"].value_counts()29,875 Overture buildings in the AOI
6 pre-fire + 12 post-fire Maxar COGs (all 0% cloud)
damage
unassessed 13537
no_damage 11013
destroyed 3827
affected 934
major 344
minor 220
Name: count, dtype: int64From damage probabilities to classes¶
Microsoft’s model emits a per-footprint damage probability (plus a binary
damaged flag), derived from Maxar post-event imagery. We spatially join those
footprints onto the Overture buildings and bin the probability into classes:
destroyed (≥ 0.75), major (≥ 0.50), minor (≥ 0.25), affected (> 0),
no damage.
One honesty note baked into the app: the assessment strip covers only the eastern, urban part of our area of interest — every building outside it is labeled not assessed, not “undamaged”.
# Damage-class palette (validated for contrast + CVD separation on the app's dark surface)
CLASS_DEFS = [
("destroyed", "Destroyed", "#d03b3b", (208, 59, 59, 235)),
("major", "Major damage", "#ec835a", (236, 131, 90, 225)),
("minor", "Minor damage", "#fab219", (250, 178, 25, 215)),
("affected", "Affected", "#5b9bf5", (91, 155, 245, 205)),
("no_damage", "No damage", "#8a94a0", (138, 148, 160, 120)),
("unassessed", "Not assessed", "#4a525c", (74, 82, 92, 70)),
]
RGBA = {k: rgba for k, _, _, rgba in CLASS_DEFS}
counts = buildings["damage"].value_counts().to_dict()
assessed = sum(v for k, v in counts.items() if k != "unassessed")
damaged_total = sum(counts.get(k, 0) for k in ("major", "minor", "affected"))
classes = []
for key, label, color, _ in CLASS_DEFS:
n = int(counts.get(key, 0))
pct = round(100 * n / assessed, 1) if key != "unassessed" else None
classes.append({"key": key, "label": label, "color": color, "count": n, "pct": pct})
destroyed = counts["destroyed"]
tiles = [
{"label": "Buildings analyzed", "value": f"{len(buildings):,}", "sub": "Overture footprints in the AOI"},
{"label": "Assessed for damage", "value": f"{assessed:,}", "sub": "Microsoft AI for Good"},
{"label": "Destroyed", "value": f"{destroyed:,}", "color": "#d03b3b",
"sub": f"{100 * destroyed / assessed:.0f}% of assessed"},
{"label": "Damaged", "value": f"{damaged_total:,}", "color": "#ec835a", "sub": "major · minor · affected"},
]
{c["label"]: c["count"] for c in classes}{'Destroyed': 3827,
'Major damage': 344,
'Minor damage': 220,
'Affected': 934,
'No damage': 11013,
'Not assessed': 13537}The map stack¶
Three ingredients, all rendered client-side by deck.gl via lonboard:
Imagery — each Maxar ARD quadkey COG becomes a
BitmapTileLayerwhose tiles are cut on the fly by titiler.xyz straight from the COG on S3 (6 pre-fire, 12 post-fire).Buildings — one
PolygonLayerfor all ~30k footprints, colored by damage class, GPU-filterable by class (DataFilterExtension) and extrudable to Overture’s real building heights.Perimeter — the WFIGS perimeter as an outlined
PathLayer.
The before/after swipe is manywidgets.lonboard.MapCompare, which keeps the two
cameras in sync on both sides of the divider.
import numpy as np
from urllib.parse import quote
from lonboard import Map, PolygonLayer, PathLayer, BitmapTileLayer
from lonboard.basemap import CartoBasemap, MaplibreBasemap
from lonboard.controls import NavigationControl
from manywidgets.lonboard import MapCompare
def tile_layers(items):
layers = []
for it in items:
template = (
"https://titiler.xyz/cog/tiles/WebMercatorQuad/{z}/{x}/{y}?url="
+ quote(it["url"], safe="")
)
layers.append(BitmapTileLayer(
data=template, extent=it["bounds"], tile_size=256, min_zoom=12, max_zoom=19,
))
return layers
# Buildings: one layer per damage class (chips toggle layer visibility --
# the proven static-export pattern; extension-based category filters are not).
# Draw order: least severe first so destroyed footprints stay on top.
DRAW_ORDER = ["unassessed", "no_damage", "affected", "minor", "major", "destroyed"]
class_layers = {}
for key, _, _, rgba in CLASS_DEFS:
sub = buildings.loc[buildings["damage"] == key, ["geometry", "height"]].reset_index(drop=True)
class_layers[key] = PolygonLayer.from_geopandas(
sub[["geometry"]],
get_fill_color=list(rgba),
stroked=False,
extruded=False,
get_elevation=sub["height"].fillna(6.0).astype("float32").to_numpy(),
)
peri_lines = perimeter[["geometry"]].copy()
peri_lines["geometry"] = peri_lines.geometry.boundary
peri_lines = peri_lines.explode(index_parts=False)
perimeter_layer = PathLayer.from_geopandas(
peri_lines, get_color=[255, 138, 76, 230], get_width=2.5,
width_units="pixels", width_min_pixels=1.5,
)
# Cameras: start over destroyed buildings that HAVE pre-fire imagery coverage
dest_pts = buildings.loc[buildings["damage"] == "destroyed"].geometry.to_crs(32611).centroid.to_crs(4326)
med = dest_pts.y.median()
south = dest_pts[dest_pts.y < med]
north = dest_pts[dest_pts.y >= med]
pre_b = np.array([it["bounds"] for it in maxar["pre"]])
pre_union = (pre_b[:, 0].min(), pre_b[:, 1].min(), pre_b[:, 2].max(), pre_b[:, 3].max())
in_pre = dest_pts[(dest_pts.x > pre_union[0]) & (dest_pts.x < pre_union[2])
& (dest_pts.y > pre_union[1]) & (dest_pts.y < pre_union[3])]
anchor = in_pre if len(in_pre) else dest_pts
view0 = dict(longitude=float(anchor.x.mean()), latitude=float(anchor.y.mean()),
zoom=13.4, pitch=0, bearing=0)
peri_c = perimeter.geometry.to_crs(32611).centroid.to_crs(4326).iloc[0]
locations = [
{"label": "Fire overview", "longitude": float(peri_c.x), "latitude": float(peri_c.y), "zoom": 11.8, "pitch": 0},
{"label": "Palisades Village", "longitude": float(south.x.mean()), "latitude": float(south.y.mean()), "zoom": 14.4, "pitch": 0},
{"label": "The Highlands", "longitude": float(north.x.mean()), "latitude": float(north.y.mean()), "zoom": 14.0, "pitch": 0},
]
pre_map = Map(
tile_layers(maxar["pre"]),
basemap=MaplibreBasemap(style=CartoBasemap.DarkMatterNoLabels),
view_state=view0, height="100%", controls=[],
)
post_map = Map(
[*tile_layers(maxar["post"]), *[class_layers[k] for k in DRAW_ORDER], perimeter_layer],
basemap=MaplibreBasemap(style=CartoBasemap.DarkMatterNoLabels),
view_state=view0, height="100%", controls=[NavigationControl()],
)
compare = MapCompare(before=pre_map, after=post_map, height="100%", position=0.45)
print("layers ready:", len(pre_map.layers), "pre /", len(post_map.layers), "post")layers ready: 6 pre / 19 post
A custom app in the fullscreen overlay¶
FireExplorer (in notebooks/fire_explorer/) is not built from manywidgets
components — it is a bespoke anywidget whose ESM module is a small web application:
its own dark chrome, stat tiles, a damage chart, filter chips, layer switches and
camera presets. It embeds the MapCompare widget through the same renderChild
mechanism the built-in layout containers use, and drives the lonboard layers
directly from JavaScript (GPU category filter, 3D extrusion, fly-to camera moves) —
so everything keeps working in the kernel-free static export of this page.
manywidgets.Fullscreen supplies the takeover surface: inline you get a compact
teaser card; the ⛶ button (or the ?fullscreen=la-fires deep link) opens the
full app.
from manywidgets import Fullscreen
from fire_explorer import FireExplorer
meta = {
"kicker": "manywidgets · custom fullscreen app",
"title": "Anatomy of a Firestorm",
"subtitle": ("Palisades Fire, Los Angeles · January 2025 — 30,000 Overture buildings, "
"Microsoft AI damage assessment, Maxar before/after imagery. "
"Computed in a notebook; no backend."),
"before_label": "Before · Dec 21, 2024",
"after_label": "After · Jan 13, 2025",
"attribution": "© Maxar Open Data (CC-BY-NC-4.0) · tiles titiler.xyz · © Overture Maps · Microsoft AI for Good",
"credits": [
{"label": "Maxar Open Data — LA wildfires event", "url": "https://registry.opendata.aws/maxar-open-data/"},
{"label": "Microsoft damage assessment (HDX)", "url": "https://data.humdata.org/dataset/palisades-fire-building-damage-assessment"},
{"label": "Overture Maps buildings", "url": "https://docs.overturemaps.org/"},
{"label": "NIFC WFIGS perimeter", "url": "https://data-nifc.opendata.arcgis.com/"},
],
"note": "Pre-fire imagery (Dec 21) covers the northern burn area; the south has post-fire coverage only.",
"pre_bounds": [float(x) for x in pre_union],
"license": "Maxar imagery: CC-BY-NC-4.0 — non-commercial, attribution required.",
}
app = FireExplorer(
mode="app", meta=meta, classes=classes, stats={"tiles": tiles},
locations=locations, selected=[c["key"] for c in classes],
compare=compare, class_layers=[class_layers[c["key"]] for c in classes],
perimeter_layer=perimeter_layer, maps=[pre_map, post_map],
)
teaser = FireExplorer(
mode="teaser", meta=meta,
stats={"tiles": [
{"label": "buildings analyzed", "value": f"{len(buildings):,}"},
{"label": "destroyed", "value": f"{destroyed:,}", "color": "#d03b3b"},
{"label": "of assessed buildings destroyed", "value": f"{100 * destroyed / assessed:.0f}%", "color": "#ec835a"},
]},
)
fs = Fullscreen(
teaser, fullscreen=app, widget_id="la-fires",
style={
"--mw-panel-padding-x": "0px",
"--mw-panel-padding-y": "0px",
"--mw-color-overlay": "rgba(5, 8, 12, 0.94)",
"--mw-color-surface": "#0f1216",
"--mw-control-max-width": "100%",
},
)
fsHow this works (and why it’s reproducible)¶
No kernel needed to view this. The page you’re reading is a static export: every widget’s state (including the Arrow tables behind the lonboard layers) was captured when the notebook was executed, and all interactivity — the swipe, the class filter, 3D extrusion, camera moves — is client-side JavaScript talking to the serialized widget models.
The imagery is never stored. Map tiles are cut on demand from Maxar’s cloud-optimized GeoTIFFs on S3 by the public titiler.xyz instance.
Re-run it yourself.
just setup && just execute && just preview— the data loaders re-fetch everything from the open endpoints (or reuse the local cache) and the same notebook produces the same app.Deep link:
?fullscreen=la-firesopens the app directly.
Attribution: Imagery © Maxar (Open Data program, CC-BY-NC-4.0) · Building footprints © Overture Maps Foundation (ODbL) · Damage assessment © Microsoft AI for Good Lab · Fire perimeter: NIFC/WFIGS · Dynamic tiling: titiler by Development Seed.