Service Locations Map

This example demonstrates an interactive map widget using Leaflet.js to display service locations with popups, navigation, and real-time updates.

Key Features

  • Interactive Leaflet.js map with zoom and pan controls

  • Custom markers for each service location

  • Information popups with service details and images

  • Navigation integration to service detail screens

  • Automatic map bounds fitting all service locations

  • Real-time updates when service data changes

  • Responsive design that works on all devices

  • Error handling with user-friendly messages

Use Cases

Perfect for applications requiring:

  • Service location directories and finders

  • Store locators and branch finders

  • Event venue mapping

  • Real estate property maps

  • Delivery service coverage areas

  • Field service management

  • Tourism and travel guides

Implementation Overview

This widget uses the Other Tables Data pattern, fetching data from a "Service Locations" or "Services" datatable. Key implementation features:

  • Leaflet.js integration for professional mapping

  • GeoJSON coordinate handling for location data

  • Popup content generation with images and details

  • Automatic map bounds calculation

  • Real-time data synchronization

Code Example

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Service Locations Map</title>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <script
      src="https://cdn.jsdelivr.net/npm/[email protected]/umd/react.production.min.js"
      crossorigin
    ></script>
    <script
      src="https://cdn.jsdelivr.net/npm/[email protected]/umd/react-dom.production.min.js"
      crossorigin
    ></script>
    <script
      src="https://cdn.jsdelivr.net/npm/@babel/standalone/babel.min.js"
      crossorigin
    ></script>
    <script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
    <link
      href="https://unpkg.com/[email protected]/dist/leaflet.css"
      rel="stylesheet"
    />
    <style>
      html,
      body,
      #root {
        height: 100%;
        width: 100%;
        margin: 0;
        padding: 0;
      }
      .map-container {
        height: 100%;
        width: 100%;
        position: relative;
      }
      .map-loading {
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        background: rgba(255, 255, 255, 0.9);
        padding: 1rem;
        border-radius: 8px;
        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
        z-index: 1000;
      }
      .map-error {
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        background: #fee;
        color: #c33;
        padding: 1rem;
        border-radius: 8px;
        border: 1px solid #fcc;
        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
        z-index: 1000;
        text-align: center;
        max-width: 300px;
      }
      .popup-content {
        display: flex;
        gap: 10px;
        align-items: flex-start;
      }
      .popup-content img {
        width: 80px;
        height: 60px;
        object-fit: cover;
        border-radius: 4px;
      }
      .popup-text {
        flex: 1;
        min-width: 0;
      }
      .popup-text h4 {
        margin: 0 0 0.5rem 0;
        font-size: 1rem;
        font-weight: 600;
      }
      .popup-text p {
        margin: 0.25rem 0;
        font-size: 0.9rem;
        color: #666;
      }
      .popup-button {
        margin-top: 0.5rem;
        padding: 0.25rem 0.5rem;
        background: #3273dc;
        color: white;
        border: none;
        border-radius: 4px;
        cursor: pointer;
        font-size: 0.8rem;
      }
      .popup-button:hover {
        background: #2366d1;
      }
      @media (max-width: 768px) {
        .popup-content {
          flex-direction: column;
        }
        .popup-content img {
          width: 100%;
          height: 120px;
        }
      }
    </style>
  </head>
  <body>
    <div id="root"></div>
    <script type="text/babel">
      const buzzyFrameAPI = new BuzzyFrameAPI();

      const MAP_CONFIG = {
        tileLayer: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
        maxZoom: 19,
        defaultZoom: 10,
        defaultCenter: [40.7128, -74.006],
      };

      function ServiceLocationsMap() {
        const { useState, useEffect, useRef } = React;

        const [services, setServices] = useState([]);
        const [loading, setLoading] = useState(true);
        const [error, setError] = useState('');
        const [map, setMap] = useState(null);
        const [markersLayer, setMarkersLayer] = useState(null);
        const [serviceDetailScreenID, setServiceDetailScreenID] =
          useState(null);
        const [serviceDataTableID, setServiceDataTableID] = useState(null);

        const mapRef = useRef(null);

        useEffect(() => {
          async function initialiseAll() {
            try {
              console.log('[init] Calling buzzyFrameAPI.initialise()...');
              const init = await buzzyFrameAPI.initialise();

              const { resourceJSON } = init || {};
              const serviceDataTable = resourceJSON?.children?.find(
                child =>
                  child.title === 'Service Locations' ||
                  child.title === 'Services',
              );

              if (serviceDataTable) {
                setServiceDataTableID(serviceDataTable._id);

                buzzyFrameAPI.addMicroappListener({
                  microAppID: serviceDataTable._id,
                  listener: async () => {
                    console.log(
                      '[listener] Service data changed, refreshing...',
                    );
                    await fetchServices();
                  },
                });

                await fetchServices();
              } else {
                setError(
                  'Service data table not found. Please ensure there is a table named "Service Locations" or "Services".',
                );
                setLoading(false);
              }

              try {
                const detailScreenID = await buzzyFrameAPI.getScreenID({
                  screenName: 'Service Detail',
                });
                setServiceDetailScreenID(detailScreenID);
              } catch (err) {
                console.warn('Service detail screen not found:', err);
              }
            } catch (err) {
              setError('Error during initialisation: ' + (err.message || err));
              console.error('[error]', err);
              setLoading(false);
            }
          }

          initialiseAll();
        }, []);

        useEffect(() => {
          if (mapRef.current && !map) {
            try {
              const newMap = L.map(mapRef.current, {
                scrollWheelZoom: false,
                zoomControl: true,
              });

              L.tileLayer(MAP_CONFIG.tileLayer, {
                maxZoom: MAP_CONFIG.maxZoom,
                attribution: '© OpenStreetMap contributors',
              }).addTo(newMap);

              const newMarkersLayer = L.layerGroup().addTo(newMap);

              setMap(newMap);
              setMarkersLayer(newMarkersLayer);
            } catch (err) {
              setError('Error initializing map: ' + (err.message || err));
              console.error('[error]', err);
            }
          }
        }, [mapRef.current]);

        async function fetchServices() {
          if (!serviceDataTableID) return;

          setLoading(true);
          setError('');
          console.log('[fetchServices] Fetching services...');

          try {
            const serviceData = await buzzyFrameAPI.fetchDataTableRows({
              microAppID: serviceDataTableID,
              subLimit: 50,
            });

            const validServices = serviceData.filter(
              service => service.location,
            );
            console.log(
              '[fetchServices] Got',
              validServices.length,
              'valid services',
            );
            setServices(validServices);
          } catch (err) {
            setError('Error fetching services: ' + (err.message || err));
            console.error('[error]', err);
          } finally {
            setLoading(false);
          }
        }

        useEffect(() => {
          if (map && markersLayer && services.length > 0) {
            markersLayer.clearLayers();

            const bounds = L.latLngBounds();
            let hasValidMarkers = false;

            services.forEach(service => {
              try {
                const location =
                  typeof service.location === 'string' && service.location
                    ? JSON.parse(service.location)
                    : service.location || {};

                const coordinates = location?.coordinates;
                if (
                  coordinates &&
                  Array.isArray(coordinates) &&
                  coordinates.length >= 2
                ) {
                  const [lng, lat] = coordinates;

                  if (
                    typeof lat === 'number' &&
                    typeof lng === 'number' &&
                    lat >= -90 &&
                    lat <= 90 &&
                    lng >= -180 &&
                    lng <= 180
                  ) {
                    const marker = L.marker([lat, lng]);

                    const popupContent = createPopupContent(service);
                    marker.bindPopup(popupContent);

                    marker.bindTooltip(service.name || 'Service Location', {
                      permanent: false,
                      direction: 'top',
                    });

                    marker.addTo(markersLayer);
                    bounds.extend([lat, lng]);
                    hasValidMarkers = true;
                  }
                }
              } catch (err) {
                console.warn(
                  'Error processing service location:',
                  service.name,
                  err,
                );
              }
            });

            if (hasValidMarkers) {
              map.fitBounds(bounds, { padding: [20, 20] });
            } else {
              map.setView(MAP_CONFIG.defaultCenter, MAP_CONFIG.defaultZoom);
            }
          }
        }, [map, markersLayer, services]);

        function createPopupContent(service) {
          const div = document.createElement('div');
          div.className = 'popup-content';

          const image =
            service.image && service.image.length > 0
              ? `<img src="${service.image[0]}" alt="${
                  service.name || 'Service'
                }" />`
              : '';

          const description = service.description || service.notes || '';
          const address = service.address || '';

          div.innerHTML = `
            ${image}
            <div class="popup-text">
              <h4>${service.name || 'Unnamed Service'}</h4>
              ${description ? `<p>${description}</p>` : ''}
              ${address ? `<p><strong>Address:</strong> ${address}</p>` : ''}
              ${
                serviceDetailScreenID
                  ? '<button class="popup-button" onclick="openServiceDetails(\'' +
                    service._id +
                    '\')">View Details</button>'
                  : ''
              }
            </div>
          `;

          return div;
        }

        function openServiceDetails(serviceID) {
          if (serviceDetailScreenID) {
            buzzyFrameAPI.navigate({
              screenID: serviceDetailScreenID,
              rowID: serviceID,
            });
          }
        }

        window.openServiceDetails = openServiceDetails;

        return (
          <div className="map-container">
            {loading && (
              <div className="map-loading">
                <span
                  className="material-symbols-outlined"
                  style={{ marginRight: '0.5rem' }}>
                  map
                </span>
                Loading service locations...
              </div>
            )}
            {error && (
              <div className="map-error">
                <span
                  className="material-symbols-outlined"
                  style={{
                    display: 'block',
                    fontSize: '2rem',
                    marginBottom: '0.5rem',
                  }}>
                  error
                </span>
                {error}
              </div>
            )}
            <div
              ref={mapRef}
              style={{ height: '100%', width: '100%' }}
              role="application"
              aria-label="Interactive map showing service locations"
            />
          </div>
        );
      }

      ReactDOM.render(<ServiceLocationsMap />, document.getElementById('root'));
    </script>
  </body>
</html>

Key Concepts

Leaflet.js Map Integration

  • Professional Mapping: Uses industry-standard Leaflet.js for reliable map functionality

  • Custom Markers: Places markers at service locations with tooltips

  • Interactive Popups: Rich popups with images, descriptions, and action buttons

  • Map Controls: Zoom controls and pan functionality built-in

Location Data Handling

  • GeoJSON Support: Handles standard GeoJSON coordinate format [longitude, latitude]

  • Data Validation: Validates coordinates to ensure they're within valid ranges

  • Error Handling: Graceful handling of invalid or missing location data

  • Flexible Data Sources: Works with "Service Locations" or "Services" tables

Advanced Map Features

  • Auto-fitting Bounds: Automatically zooms to show all service locations

  • Responsive Popups: Popups adapt to mobile and desktop layouts

  • Navigation Integration: Popup buttons navigate to service detail screens

  • Real-time Updates: Map updates immediately when service data changes

User Experience

  • Loading States: Clear loading indicators while data is fetched

  • Error Messages: User-friendly error messages for various failure scenarios

  • Mobile Optimization: Touch-friendly controls and responsive design

  • Accessibility: ARIA labels and keyboard navigation support

This map widget demonstrates how code widgets can integrate sophisticated mapping libraries to create professional location-based applications with rich interactivity and real-time data updates.

Last updated