Files
hermes-skills/skills/software-development/portal-ui-mockups/references/leaflet-integration-pattern.md
T

5.2 KiB

Leaflet.js + OpenStreetMap Map Integration

Replace SVG world maps with real interactive Leaflet.js maps using OpenStreetMap tiles.

Setup

<!-- In <head> -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>

Container div (replaces old SVG viewBox)

<div id="leafletMap"></div>
#leafletMap {
  width: 100%;
  height: 500px;
  border-radius: 12px;
  overflow: hidden;
  border: 1px solid rgba(14, 165, 233, 0.25);
}

Dark theme overrides

Apply these to match a dark mockup theme:

.leaflet-container {
  background: #0a1628;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.leaflet-popup-content-wrapper {
  background: #0f2b4a;
  color: #e2e8f0;
  border: 1px solid rgba(14, 165, 233, 0.25);
  border-radius: 12px;
  padding: 0;
  box-shadow: 0 4px 20px rgba(0,0,0,0.5);
}
.leaflet-popup-tip {
  background: #0f2b4a;
  border: 1px solid rgba(14, 165, 233, 0.25);
}
.leaflet-popup-content {
  margin: 12px 16px;
  font-size: 13px;
  line-height: 1.5;
  min-width: 200px;
}
.leaflet-popup-close-button {
  color: #94a3b8 !important;
}
.leaflet-popup-close-button:hover {
  color: #e2e8f0 !important;
}
.leaflet-control-zoom a {
  background: #0f2b4a !important;
  color: #e2e8f0 !important;
  border-color: rgba(14, 165, 233, 0.25) !important;
}
.leaflet-control-zoom a:hover {
  background: #1a3a5c !important;
}
.leaflet-control-attribution {
  background: rgba(10, 22, 40, 0.8) !important;
  color: #64748b !important;
  font-size: 10px !important;
}
.leaflet-control-attribution a {
  color: #0ea5e9 !important;
}

Region data (real coordinates)

Old SVG pattern used pixel coords + display strings. New pattern uses real lat/lng:

const MAP_REGIONS = [
  { id: 1, name: "Florida East Coast", emoji: "🏖️", lat: 29.0, lng: -80.5 },
  // ...
];

Map initialization + markers

function renderMapView() {
  // Destroy previous instance (important for view-switching UIs)
  if (window._leafletMap) {
    window._leafletMap.remove();
    window._leafletMap = null;
  }

  const map = L.map('leafletMap', {
    center: [20, 0],
    zoom: 2,
    maxZoom: 5,
    minZoom: 2,
    zoomControl: true,
  });

  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
    attribution: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap</a> contributors',
    maxNativeZoom: 5,
  }).addTo(map);

  window._leafletMap = map;

  MAP_REGIONS.forEach(mr => {
    // Color by state (available / yours / taken / waiting)
    const marker = L.circleMarker([mr.lat, mr.lng], {
      radius: 10,
      color: '#0ea5e9',
      fillColor: '#0ea5e9',
      fillOpacity: 0.5,
      opacity: 0.8,
      weight: 2,
    }).addTo(map);

    marker.bindPopup(`<div class="lp-region">Content</div>`, {
      closeButton: true,
      className: 'dark-popup',
      maxWidth: 280,
    });
  });

  // Fix layout after container becomes visible
  setTimeout(() => map.invalidateSize(), 100);
}

State-based marker styling

State Color Radius Opacity
Available #0ea5e9 (teal) 10 0.8
Your pick #f59e0b (amber) 12 0.9
Taken/drafted #dc2626 (red) 8 0.5
Waiting/draft-not-started #64748b (gray) 8 0.4

View-switching lifecycle

When toggling between list and map views, destroy the Leaflet instance on leaving the map view:

function switchView(view) {
  if (view === 'list') {
    if (window._leafletMap) {
      window._leafletMap.remove();
      window._leafletMap = null;
    }
  } else {
    renderMapView();
  }
}

Without this, Leaflet doesn't re-render correctly when its container is shown again — invalidateSize() alone is unreliable after display toggles.

Popup buttons with inline onclick

Buttons inside Leaflet popups can call global functions:

marker.bindPopup(`
  <div class="lp-region">${mr.emoji} ${escapeHtml(mr.name)}</div>
  <div class="lp-stats">👁️${sightings} Sightings</div>
  <button class="map-popup-btn draft" onclick="makePickFromMap(${mr.id})">🦈 Draft</button>
`, { ... });

Programmatic popup open/close

function openMapPopup(regionId) {
  const mr = MAP_REGIONS.find(r => r.id === regionId);
  if (mr && window._leafletMap) {
    window._leafletMap.eachLayer(layer => {
      if (layer instanceof L.CircleMarker &&
          layer.getLatLng().lat === mr.lat &&
          layer.getLatLng().lng === mr.lng) {
        layer.openPopup();
      }
    });
  }
}

function closeMapPopup() {
  if (window._leafletMap) {
    window._leafletMap.closePopup();
  }
}

Removing custom popup overlays

When switching to Leaflet popups, remove the old custom overlay HTML and CSS:

  • HTML to remove: custom overlay divs (#mapPopupOverlay, #mapPopup, #mapPopupContent)
  • CSS to remove: .map-popup-overlay, .map-popup, .map-popup-handle, .map-popup-header, .map-popup-flag, .map-popup-title, .map-popup-stats, .map-popup-stat, .map-popup-footer
  • CSS to keep: .map-popup-btn and its variants — these still style buttons inside Leaflet popups
  • JS to replace: openMapPopup() / closeMapPopup() — redirect to Leaflet API as shown above