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>