Skip to content

Create and style clusters with leaflet

Extend clustering with HTML markers and custom property expressions.

<!DOCTYPE html>
<html lang="en">

    <head>
        <title>Create and style clusters with leaflet</title>
        <meta property="og:description" content="Extend clustering with HTML markers and custom property expressions."/>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />

        <!-- mapvina-gl -->
        <link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/mapvina-gl.css" />
        <script src="https://unpkg.com/[email protected]/dist/mapvina-gl.js"></script>

        <!-- Leaflet -->
        <link rel="stylesheet" href="https://unpkg.com/leaflet@^1.9.3/dist/leaflet.css" />
        <script src="https://unpkg.com/leaflet@^1.9.3/dist/leaflet.js"></script>

        <!-- leaflet marker cluster -->
        <script
            src='https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.5.3/leaflet.markercluster.js'></script>
        <link href='https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.5.3/MarkerCluster.css'
            rel='stylesheet' />
        <link href='https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.5.3/MarkerCluster.Default.css'
            rel='stylesheet' />

        <!-- binding mapvina-gl for leaflet -->
        <script src="https://unpkg.com/@mapvina/mapvina-gl-leaflet@latest/leaflet-mapvina-gl.js"></script>

        <!-- Font Awesome for icons -->
        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />

        <style>
            * {
                box-sizing: border-box;
            }

            body {
                margin: 0;
                padding: 0;
                font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
                background: #f5f5f5;
            }

            #map {
                position: absolute;
                top: 0;
                bottom: 0;
                width: 100%;
                height: 100vh;
            }

            /* Marker Popup */
            .leaflet-popup-content-wrapper {
                border-radius: 8px !important;
                box-shadow: 0 3px 12px rgba(0, 0, 0, 0.2) !important;
            }

            .leaflet-div-icon {
                background: none !important;
                border: none !important;
            }

            .leaflet-popup-content {
                margin: 0 !important;
                min-width: 200px;
            }

            .popup-content {
                padding: 12px 15px;
                font-size: 14px;
            }

            .popup-title {
                font-weight: 600;
                color: #333;
                margin-bottom: 8px;
                display: flex;
                align-items: center;
                gap: 8px;
            }

            .popup-details {
                font-size: 12px;
                color: #666;
                margin-top: 8px;
                padding-top: 8px;
                border-top: 1px solid #eee;
            }

            /* Custom Cluster Icons */
            .marker-cluster {
                background: none !important;
                border: none !important;
                display: flex !important;
                align-items: center !important;
                justify-content: center !important;
            }

            .marker-cluster-small {
                background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
                border: 3px solid white !important;
                border-radius: 50% !important;
                color: white !important;
                font-weight: bold !important;
                text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3) !important;
                transition: all 0.3s ease !important;
                cursor: pointer !important;
                box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25) !important;
                width: 40px !important;
                height: 40px !important;
            }

            .marker-cluster-small:hover {
                transform: scale(1.1);
                box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35) !important;
            }

            .marker-cluster-medium {
                background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%) !important;
                border: 3px solid white !important;
                border-radius: 50% !important;
                color: white !important;
                font-weight: bold !important;
                text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3) !important;
                transition: all 0.3s ease !important;
                cursor: pointer !important;
                box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25) !important;
                width: 45px !important;
                height: 45px !important;
            }

            .marker-cluster-medium:hover {
                transform: scale(1.1);
                box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35) !important;
            }

            .marker-cluster-large {
                background: linear-gradient(135deg, #fa709a 0%, #fee140 100%) !important;
                border: 3px solid white !important;
                border-radius: 50% !important;
                color: #333 !important;
                font-weight: bold !important;
                text-shadow: 1px 1px 2px rgba(255, 255, 255, 0.5) !important;
                transition: all 0.3s ease !important;
                cursor: pointer !important;
                box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25) !important;
                width: 50px !important;
                height: 50px !important;
            }

            .marker-cluster-large:hover {
                transform: scale(1.1);
                box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35) !important;
            }

            /* Custom Marker Icon */
            .custom-marker {
                display: flex;
                align-items: center;
                justify-content: center;
                background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
                color: white;
                border-radius: 50%;
                width: 40px;
                height: 40px;
                box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
                border: 3px solid white;
                font-size: 18px;
                transition: all 0.2s ease;
                cursor: pointer;
            }

            .custom-marker:hover {
                transform: scale(1.15);
                box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
            }

            /* Responsive Design */
            @media (max-width: 768px) {

                .popup-content {
                    padding: 10px 12px;
                    font-size: 13px;
                    min-width: 160px;
                }

                .leaflet-control-zoom {
                    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15) !important;
                    border-radius: 6px !important;
                }

                .leaflet-control-zoom a {
                    font-size: 16px !important;
                    width: 32px !important;
                    height: 32px !important;
                    line-height: 32px !important;
                }
            }

            @media (max-width: 480px) {

                .popup-content {
                    min-width: 140px;
                    font-size: 12px;
                }

                .custom-marker {
                    width: 36px;
                    height: 36px;
                    font-size: 16px;
                }
            }

            /* Animation */
            @keyframes pulse {
                0% {
                    box-shadow: 0 0 0 0 rgba(102, 126, 234, 0.7);
                }
                70% {
                    box-shadow: 0 0 0 8px rgba(102, 126, 234, 0);
                }
                100% {
                    box-shadow: 0 0 0 0 rgba(102, 126, 234, 0);
                }
            }

            .marker-cluster.pulse {
                animation: pulse 2s infinite;
            }

            /* Leaflet Override Styles */
            .leaflet-control-attribution {
                font-size: 11px;
            }

            .leaflet-control-zoom a {
                background: white;
                border-radius: 6px;
                transition: all 0.2s ease;
            }

            .leaflet-control-zoom a:hover {
                background: #f0f0f0;
            }
        </style>
    </head>

    <body>
        <div id="map"></div>

        <script>
            const addressPoints = [
                {
                    lng: 106.6605172,
                    lat: 10.7726822,
                    title: "Phở Bà Ba",
                    type: "restaurant"
                },
                {
                    lng: 106.6723572,
                    lat: 10.7523522,
                    title: "Bim Bim Shop",
                    type: "shop"
                },
                {
                    lng: 106.6725542,
                    lat: 10.7625627,
                    title: "Cơm Tấm Sài Gòn",
                    type: "restaurant"
                },
                {
                    lng: 106.6505175,
                    lat: 10.7726422,
                    title: "Công Ty Cổ Phần ABC",
                    type: "office"
                },
                {
                    lng: 106.6530541,
                    lat: 10.7626242,
                    title: "Fashion Boutique",
                    type: "shop"
                },
                {
                    lng: 106.6603172,
                    lat: 10.7524562,
                    title: "Nhà Hàng Hoa Cúc",
                    type: "restaurant"
                },
                {
                    lng: 106.6501247,
                    lat: 10.7626224,
                    title: "Tech Solutions Ltd",
                    type: "office"
                },
                {
                    lng: 106.6642017,
                    lat: 10.7742425,
                    title: "Cửa Hàng Mỹ Phẩm 18",
                    type: "shop"
                },
                {
                    lng: 106.6601545,
                    lat: 10.7622462,
                    title: "Bánh Mì Ngoại 68",
                    type: "restaurant"
                },
                {
                    lng: 106.6644017,
                    lat: 10.7626220,
                    title: "Văn Phòng Kiến Trúc NAM",
                    type: "office"
                },
                {
                    lng: 106.8605172,
                    lat: 10.7626220,
                    title: "Lẩu Nướng BBQ Land",
                    type: "restaurant"
                },
                {
                    lng: 106.8664012,
                    lat: 10.7629623,
                    title: "Nước Uống Vital Life",
                    type: "shop"
                },
                {
                    lng: 106.8650517,
                    lat: 10.7625627,
                    title: "HapoConsult Office",
                    type: "office"
                },
                {
                    lng: 106.8650517,
                    lat: 10.7726422,
                    title: "Giày Dép Sport 99",
                    type: "shop"
                },
                {
                    lng: 106.7663054,
                    lat: 10.8626242,
                    title: "Cơm Gà Xứ Nghệ",
                    type: "restaurant"
                },
                {
                    lng: 106.7660317,
                    lat: 10.8624562,
                    title: "Công Ty Quản Lý Bất Động Sản",
                    type: "office"
                },
                {
                    lng: 106.7660124,
                    lat: 10.8626224,
                    title: "Siêu Thị Điện Tử VinaMart",
                    type: "shop"
                },
                {
                    lng: 106.7664201,
                    lat: 10.8642425,
                    title: "Tokpokki House",
                    type: "restaurant"
                },
                {
                    lng: 106.7660154,
                    lat: 10.8622462,
                    title: "Công Ty BPO International",
                    type: "office"
                },
                {
                    lng: 106.7664401,
                    lat: 10.8626220,
                    title: "Thời Trang Hàn Quốc Premium",
                    type: "shop"
                },
            ];

            // Initialize Map
            var map = L.map("map", {
                maxZoom: 17,
                zoom: 6,
                center: { lat: 10.762622, lng: 106.660172 },
                zoomControl: true,
                attributionControl: true,
            });

            // Add MapVina GL layer
            L.mapvinaGL({
                style: "https://maps.mapvina.com/styles/v2/streets.json?key=public_key",
            }).addTo(map);

            // Create Custom Icon
            function createCustomIcon(type) {
                const iconMap = {
                    restaurant: { icon: 'fa-utensils', color: '#f5576c' },
                    shop: { icon: 'fa-shopping-bag', color: '#667eea' },
                    office: { icon: 'fa-building', color: '#764ba2' },
                };

                const config = iconMap[type] || { icon: 'fa-map-pin', color: '#667eea' };

                return L.divIcon({
                    html: `<div class="custom-marker" style="background: linear-gradient(135deg, ${config.color} 0%, ${config.color}dd 100%); font-size: 18px;">
                        <i class="fas ${config.icon}"></i>
                    </div>`,
                    iconSize: [40, 40],
                    iconAnchor: [20, 40],
                    popupAnchor: [0, -40],
                    className: 'custom-marker-wrapper'
                });
            }

            // Create Popup Content
            function createPopupContent(data) {
                const typeLabel = {
                    restaurant: '🍽️ Restaurant',
                    shop: '🛍️ Shop',
                    office: '🏢 Office'
                };

                return `<div class="popup-content">
                    <div class="popup-title">
                        <i class="fas fa-check-circle" style="color: #28a745;"></i>
                        ${data.title}
                    </div>
                    <div class="popup-details">
                        <div><strong>Type:</strong> ${typeLabel[data.type] || 'Location'}</div>
                        <div><strong>Lat:</strong> ${data.lat.toFixed(4)}</div>
                        <div><strong>Lng:</strong> ${data.lng.toFixed(4)}</div>
                    </div>
                </div>`;
            }

            // Create Marker Cluster Group with custom options
            var markers = L.markerClusterGroup({
                maxClusterRadius: 80,
                disableClusteringAtZoom: 16,
                animateAddingMarkers: true,
                iconCreateFunction: function (cluster) {
                    const count = cluster.getChildCount();
                    let clusterClass = 'marker-cluster-small';
                    let iconSize = 40;

                    if (count > 50) {
                        clusterClass = 'marker-cluster-large';
                        iconSize = 50;
                    } else if (count > 10) {
                        clusterClass = 'marker-cluster-medium';
                        iconSize = 45;
                    }

                    return L.divIcon({
                        html: `<div class="marker-cluster ${clusterClass}" style="font-size: 13px;">${count}</div>`,
                        iconSize: L.point(iconSize, iconSize),
                        iconAnchor: [iconSize / 2, iconSize / 2],
                    });
                }
            });

            // Add markers to cluster group
            for (let i = 0; i < addressPoints.length; i++) {
                let data = addressPoints[i];

                var marker = L.marker(
                    new L.LatLng(data.lat, data.lng),
                    {
                        title: data.title,
                        icon: createCustomIcon(data.type),
                    }
                );

                marker.bindPopup(createPopupContent(data), {
                    maxWidth: 250,
                    minWidth: 200,
                });

                markers.addLayer(marker);
            }

            map.addLayer(markers);

            // Responsive adjustments
            window.addEventListener('resize', function() {
                map.invalidateSize();
            });
        </script>
    </body>

</html>