Week 29: Geospatial Interfaces with Leaflet

Leaflet has the same DOM-ownership shape as D3 (Week 28) plus one extra wrinkle: it reaches for window the moment it initializes, which means Week 21's SSR guidance isn't optional here — it's the first thing that breaks if you skip it.

Phase 7 of 8 Week 29 of 33 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Initialize a Leaflet map safely in an SSR-enabled Angular app
  • Reconcile a signal of location data with Leaflet's own imperative marker API
  • Build two-way sync between the map's viewport and the rest of the app's state

1. SSR-Safe Map Initialization

Leaflet's L.map(...) touches window and measures real DOM elements the instant it's called — on the server, during Week 21's SSR render pass, neither exists. Initialize a map anywhere Angular might execute on the server and the build crashes outright, not just renders incorrectly.

leaflet-map.ts
import { Component, ElementRef, inject, afterNextRender, input } from '@angular/core';
import type * as Leaflet from 'leaflet';

@Component({
  selector: 'app-leaflet-map',
  template: `<div class="map-container" #mapEl></div>`,
})
export class LeafletMap {
  private host = inject(ElementRef<HTMLElement>);
  private map?: Leaflet.Map;
  center = input<[number, number]>([51.505, -0.09]);

  constructor() {
    afterNextRender(async () => {
      const L = await import('leaflet'); // dynamic import keeps Leaflet out
                                          // of the server bundle entirely
      this.map = L.map(this.host.nativeElement.querySelector('.map-container')!)
        .setView(this.center(), 13);

      L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
        attribution: '&copy; OpenStreetMap contributors',
      }).addTo(this.map);
    });
  }
}

Two SSR-safety layers, not one: afterNextRender (Week 21/28) guarantees the code only runs in the browser, and the dynamic import('leaflet') ensures the library itself is never even evaluated during the server build — some mapping libraries throw immediately on module load, before any code you write runs, so gating only the initialization call isn't always sufficient on its own.

2. Wrapping Leaflet in a Component

Beyond SSR safety, the map needs an explicit size — Leaflet, unlike a normal block element, doesn't compute its own layout from content, so a zero-height container renders a blank gray box with no error.

leaflet-map.scss
.map-container {
  width: 100%;
  height: 100%;
  min-height: 320px; // Leaflet needs a real, non-zero size to measure at init
}

If the container's size can change after initial load (a resizable panel, a layout reflow from content loading in elsewhere), call this.map.invalidateSize() — the same ResizeObserver pattern from Week 28's responsive-chart directive applies here directly, just triggering Leaflet's own resize method instead of a redraw.

3. Markers Driven by a Signal

Leaflet markers are imperative objects with no concept of Angular's declarative @for — reconciling a signal of location data against them means diffing by hand: add markers that are new, remove markers that are gone, and leave the rest alone.

leaflet-map.ts — marker sync
locations = input.required<Location[]>();
private markers = new Map<string, Leaflet.Marker>();

constructor() {
  afterNextRender(async () => {
    const L = await import('leaflet');
    // ...map init from Section 1...

    effect(() => {
      const current = new Set(this.locations().map((l) => l.id));

      // remove markers no longer present
      for (const [id, marker] of this.markers) {
        if (!current.has(id)) {
          marker.remove();
          this.markers.delete(id);
        }
      }

      // add markers that are new
      for (const loc of this.locations()) {
        if (!this.markers.has(loc.id)) {
          const marker = L.marker([loc.lat, loc.lng])
            .bindPopup(loc.name)
            .on('click', () => this.selected.set(loc.id))
            .addTo(this.map!);
          this.markers.set(loc.id, marker);
        }
      }
    });
  });
}

This is the same enter/exit diffing D3 does internally (Week 28) — done by hand here because Leaflet doesn't expose a data-join API the way D3 does. The effect() re-runs whenever locations() changes, and the Map keyed by id is what makes the diff efficient instead of tearing down and rebuilding every marker on every change.

4. GeoJSON Layers & Clustering

For boundaries, routes, or large marker counts, Leaflet's built-in L.geoJSON() and the separate leaflet.markercluster plugin handle cases the manual diffing in Section 3 isn't meant for.

adding a GeoJSON boundary layer
L.geoJSON(regionBoundary, {
  style: { color: '#6366f1', weight: 2, fillOpacity: 0.08 },
}).addTo(this.map);

For hundreds or thousands of markers, plain Leaflet markers degrade badly — reach for markercluster at that scale rather than optimizing the hand-rolled diffing from Section 3 further; clustering is a genuinely different rendering strategy (grouping nearby markers into a single cluster icon that expands on zoom), not just a performance tweak to the same approach.

5. Two-Way Viewport Sync

A map screen usually needs state to flow both directions: selecting an item in a list pans the map to it, and moving the map should update what's considered "in view" for the rest of the app.

leaflet-map.ts — bidirectional sync
selected = model<string | null>(null); // Week 4's two-way signal API

// signal -> map: selecting an item pans to it
effect(() => {
  const id = this.selected();
  const loc = this.locations().find((l) => l.id === id);
  if (loc && this.map) this.map.panTo([loc.lat, loc.lng]);
});

// map -> signal: moving the map updates a "bounds" output for the rest of the app
constructor() {
  afterNextRender(async () => {
    // ...init...
    this.map!.on('moveend', () => {
      this.zone.run(() => this.bounds.set(this.map!.getBounds()));
    });
  });
}

Using model() for selected means a parent component can bind it with [(selected)] exactly like any other two-way signal input from Week 4 — the map component doesn't need to know whether a list, a search box, or something else is driving the selection.

6. Cleanup on Destroy

A Leaflet map holds real browser resources — tile image requests, event listeners on window and the container element — none of which Angular knows to release on its own.

leaflet-map.ts
ngOnDestroy() {
  this.map?.remove(); // detaches all Leaflet event listeners and tile requests
}

map.remove() is Leaflet's own teardown method — it's not optional the way some cleanup calls are convenience-only; skipping it on a map component that mounts and unmounts repeatedly (a modal, a routed detail view) is a reliable way to leak memory and stray network requests, the same category of bug Week 15's subscription-leak coverage warned about.

7. Hands-on Exercise

Hands-on

Build a "nearby locations" map + list, kept in sync

A split-screen layout: a list of locations on one side, a Leaflet map on the other.

Requirements:

  1. Build LeafletMap from Sections 1-3, taking a locations signal input of at least 8 points and rendering a marker per location with the diffing approach.
  2. Build a sibling list component showing the same locations; clicking a list item pans the map to it and opens its popup (Section 5, signal → map direction).
  3. Clicking a marker on the map selects the corresponding item in the list (Section 5, map → signal direction, via the marker's click handler updating the same selected model).
  4. Add a GeoJSON layer (Section 4) for one simple boundary or route relevant to your data, styled distinctly from the markers.
  5. Confirm the map cleans up correctly: mount the map component behind a route or a toggle, navigate away, and verify (via dev tools' network tab) that tile requests stop after ngOnDestroy runs.
Hint

If the map renders as a gray box with visible seams instead of tiles, it's almost always the Section 2 sizing issue — check that .map-container has a real measured height at the moment L.map() runs, not just a CSS rule that depends on a parent that hasn't laid out yet.

8. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does initializing Leaflet during SSR crash the build, rather than just rendering wrong?

Leaflet reaches for window and measures real DOM elements the moment L.map() is called — neither exists in Angular's server-side rendering environment (Week 21), so the call throws immediately rather than degrading gracefully.

Q2

Why use a dynamic import('leaflet') in addition to afterNextRender?

Some mapping libraries touch browser globals during module evaluation itself, not only when a function is called — a static top-level import would still break the server build even inside an afterNextRender callback. A dynamic import keeps the module out of the server bundle entirely, so it's never evaluated server-side at all.

Q3

Why does the marker-sync effect() diff by id instead of just clearing and re-adding every marker on every change?

Rebuilding every marker on every signal change is wasteful and visibly flickers open popups or in-progress marker animations even for locations that didn't change. Diffing by id only adds truly new markers and removes truly gone ones, leaving unchanged markers — and their open state — untouched.

Q4

What does map.remove() actually clean up, and why does skipping it matter for a map that mounts and unmounts repeatedly?

It detaches Leaflet's internal event listeners (on both the container and window) and stops in-flight tile requests. Without it, a map component behind a modal or a routed view that mounts repeatedly accumulates listeners and requests on every mount — a genuine memory and network leak, not just untidy cleanup.