Hexagon Layer — Data Aggregation
Aggregate thousands of data points into a 3D hexagonal grid. Each hexagon's height and color represent the number of points (accidents, events, etc.) within that cell. Adjust radius, coverage, and upper percentile in real time.
How It Works
Point data is binned into a hexagonal grid computed in JavaScript. Each hexagon's height and color map to the number of points inside it. Sliders update the grid live using setData() on the GeoJSON source.
Key steps
1. Generate dummy accident points near UK cities
javascript
const hotspots = [
{ lng: -0.12, lat: 51.50, weight: 80 }, // London
{ lng: -2.24, lat: 53.48, weight: 40 }, // Manchester
// ...
];2. Build a hex grid and bin points into cells
javascript
function buildHexGrid(radiusKm, coverage, upperPercentile, maxHeight) {
const r = radiusKm * 1000; // convert to metres
// For each hex cell centre, count points inside using axial coordinates
// Apply upper percentile clipping so outliers don't dominate
// Return GeoJSON FeatureCollection with height + color per feature
}3. Render as 3D fill-extrusion layer
javascript
map.addLayer({
id: 'hex-fill',
type: 'fill-extrusion',
source: 'hexagons',
paint: {
'fill-extrusion-color': ['get', 'color'], // data-driven color
'fill-extrusion-height': ['get', 'height'], // data-driven height
'fill-extrusion-opacity': 0.85,
}
});4. Update live when sliders change
javascript
slider.addEventListener('input', () => {
const newGeojson = buildHexGrid(radius, coverage, upperPercentile, maxHeight);
map.getSource('hexagons').setData(newGeojson);
});Complete Example
html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="https://cdn.mapmetrics-atlas.net/versions/latest/mapmetrics-gl.css" rel="stylesheet" />
<script src="https://cdn.mapmetrics-atlas.net/versions/latest/mapmetrics-gl.js"></script>
<style>
body { margin: 0; font-family: system-ui, sans-serif; }
#map { height: 100vh; width: 100%; }
#controls {
position: absolute; top: 10px; left: 10px; z-index: 1;
background: rgba(255,255,255,0.95); padding: 14px 16px;
border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.2);
display: flex; flex-direction: column; gap: 10px; min-width: 220px;
}
.ctrl-row { display: flex; flex-direction: column; gap: 3px; font-size: 13px; color: #374151; }
#stats { position: absolute; bottom: 40px; left: 10px; z-index: 1;
background: rgba(0,0,0,0.6); color: white; padding: 6px 12px;
border-radius: 6px; font-size: 12px; }
</style>
</head>
<body>
<div id="map"></div>
<div id="controls">
<strong style="font-size:14px;">Hexagon Controls</strong>
<div class="ctrl-row">
<span>Radius: <strong><span id="val-radius">40</span> km</strong></span>
<input id="sl-radius" type="range" min="10" max="100" value="40" step="5" />
</div>
<div class="ctrl-row">
<span>Coverage: <strong><span id="val-coverage">0.85</span></strong></span>
<input id="sl-coverage" type="range" min="0.3" max="1" value="0.85" step="0.05" />
</div>
<div class="ctrl-row">
<span>Upper percentile: <strong><span id="val-upper">100</span>%</strong></span>
<input id="sl-upper" type="range" min="50" max="100" value="100" step="1" />
</div>
<div class="ctrl-row">
<span>Max height: <strong><span id="val-height">200</span>k</strong></span>
<input id="sl-height" type="range" min="50" max="500" value="200" step="50" />
</div>
</div>
<div id="stats"></div>
<script>
// Dummy accident data weighted around UK cities
const seed = (n) => { let x = Math.sin(n) * 10000; return x - Math.floor(x); };
const points = [];
const hotspots = [
{ lng: -0.12, lat: 51.50, weight: 80 },
{ lng: -2.24, lat: 53.48, weight: 40 },
{ lng: -1.90, lat: 52.48, weight: 35 },
{ lng: -4.25, lat: 55.86, weight: 30 },
{ lng: -1.55, lat: 53.80, weight: 30 },
{ lng: -3.19, lat: 55.95, weight: 20 },
{ lng: -1.47, lat: 52.19, weight: 15 },
{ lng: -2.99, lat: 53.41, weight: 15 },
{ lng: -1.08, lat: 53.96, weight: 12 },
{ lng: -3.93, lat: 51.62, weight: 12 },
];
let pidx = 0;
hotspots.forEach(h => {
for (let i = 0; i < h.weight * 4; i++) {
points.push([
h.lng + (seed(pidx++) - 0.5) * 1.6,
h.lat + (seed(pidx++) - 0.5) * 0.8,
]);
}
});
for (let i = 0; i < 100; i++) {
points.push([-6 + seed(pidx++) * 8, 50 + seed(pidx++) * 9]);
}
const DEG_TO_RAD = Math.PI / 180;
function lngLatToM(lng, lat, oLng, oLat) {
return [
(lng - oLng) * DEG_TO_RAD * 6371000 * Math.cos(oLat * DEG_TO_RAD),
(lat - oLat) * DEG_TO_RAD * 6371000
];
}
function mToLngLat(x, y, oLng, oLat) {
return [
oLng + (x / (6371000 * Math.cos(oLat * DEG_TO_RAD))) / DEG_TO_RAD,
oLat + (y / 6371000) / DEG_TO_RAD
];
}
function hexPoly(cx, cy, r, cov, oLng, oLat) {
const R = r * cov;
const v = [];
for (let a = 0; a < 6; a++) {
const ang = (Math.PI / 180) * (60 * a);
v.push(mToLngLat(cx + R * Math.cos(ang), cy + R * Math.sin(ang), oLng, oLat));
}
v.push(v[0]);
return v;
}
function buildHexGrid(radiusKm, coverage, upperPercentile, maxHeight) {
const r = radiusKm * 1000;
const oLng = -2, oLat = 54.5;
const pts = points.map(p => lngLatToM(p[0], p[1], oLng, oLat));
const colStep = r * Math.sqrt(3);
const rowStep = r * 1.5;
const cells = [];
for (let row = 0, y = -500000; y < 600000; y += rowStep, row++) {
const xOff = (row % 2 === 0) ? 0 : colStep / 2;
for (let x = -600000 + xOff; x < 400000; x += colStep) {
let count = 0;
for (const [px, py] of pts) {
const dx = px - x, dy = py - y;
if (Math.abs(dx) <= colStep / 2 && Math.abs(dy) <= r) {
const q = (2/3 * dx) / r;
const s = (-1/3 * dx + Math.sqrt(3)/3 * dy) / r;
const t = (-1/3 * dx - Math.sqrt(3)/3 * dy) / r;
if (Math.max(Math.abs(q), Math.abs(s), Math.abs(t)) <= 1) count++;
}
}
if (count > 0) cells.push({ x, y, count });
}
}
if (!cells.length) return { type: 'FeatureCollection', features: [] };
const sorted = cells.map(c => c.count).sort((a, b) => a - b);
const maxCount = sorted[Math.min(Math.floor(upperPercentile / 100 * sorted.length) - 1, sorted.length - 1)];
return {
type: 'FeatureCollection',
features: cells.map(({ x, y, count }) => {
const t = maxCount > 0 ? Math.min(count, maxCount) / maxCount : 0;
const height = t * maxHeight * 1000;
const rv = Math.round(t < 0.5 ? t * 2 * 255 : 255);
const gv = Math.round(t < 0.5 ? t * 2 * 200 : (1 - (t - 0.5) * 2) * 200);
const bv = Math.round(t < 0.5 ? 255 - t * 2 * 255 : 0);
const centre = mToLngLat(x, y, oLng, oLat);
return {
type: 'Feature',
geometry: { type: 'Polygon', coordinates: [hexPoly(x, y, r, coverage, oLng, oLat)] },
properties: { count, height, color: `rgb(${rv},${gv},${bv})`, t,
centreLng: centre[0].toFixed(4), centreLat: centre[1].toFixed(4) }
};
})
};
}
const map = new mapmetricsgl.Map({
container: 'map',
style: 'YOUR_STYLE_URL_WITH_TOKEN',
center: [-2, 54.5],
zoom: 5,
pitch: 45,
canvasContextAttributes: { antialias: true },
});
map.addControl(new mapmetricsgl.NavigationControl(), 'top-right');
let radiusKm = 40, coverage = 0.85, upperPercentile = 100, maxHeight = 200;
map.on('load', () => {
map.addSource('hexagons', { type: 'geojson', data: buildHexGrid(radiusKm, coverage, upperPercentile, maxHeight) });
map.addLayer({
id: 'hex-fill', type: 'fill-extrusion', source: 'hexagons',
paint: {
'fill-extrusion-color': ['get', 'color'],
'fill-extrusion-height': ['get', 'height'],
'fill-extrusion-base': 0,
'fill-extrusion-opacity': 0.85,
}
});
function update() {
const geo = buildHexGrid(radiusKm, coverage, upperPercentile, maxHeight);
map.getSource('hexagons').setData(geo);
document.getElementById('stats').textContent =
`${geo.features.length} hexagons · ${geo.features.reduce((s,f) => s+f.properties.count,0)} points`;
}
update();
// Hover popup
const popup = new mapmetricsgl.Popup({ closeButton: false, closeOnClick: false, offset: 10 });
map.on('mousemove', 'hex-fill', (e) => {
map.getCanvas().style.cursor = 'pointer';
const p = e.features[0].properties;
popup.setLngLat(e.lngLat).setHTML(`
<div style="font-family:system-ui,sans-serif;font-size:13px;line-height:1.6;">
<strong style="font-size:14px;">🚗 Accident Cluster</strong><br/>
<span style="color:#6b7280;">Accidents in cell:</span> <strong>${p.count}</strong><br/>
<span style="color:#6b7280;">Intensity:</span> <strong>${Math.round(p.t * 100)}%</strong><br/>
<span style="color:#6b7280;">Centre:</span> ${p.centreLat}°N, ${p.centreLng}°E
</div>
`).addTo(map);
});
map.on('mouseleave', 'hex-fill', () => {
map.getCanvas().style.cursor = '';
popup.remove();
});
const sliders = [
['sl-radius', 'val-radius', v => radiusKm = parseInt(v), v => v + ' km'],
['sl-coverage', 'val-coverage', v => coverage = parseFloat(v), v => v],
['sl-upper', 'val-upper', v => upperPercentile = parseInt(v), v => v + '%'],
['sl-height', 'val-height', v => maxHeight = parseInt(v), v => v + 'k'],
];
sliders.forEach(([id, vid, setter, fmt]) => {
document.getElementById(id).addEventListener('input', function() {
setter(this.value);
document.getElementById(vid).textContent = fmt(this.value);
update();
});
});
});
</script>
</body>
</html>jsx
import React, { useEffect, useRef, useState, useCallback } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
// Generate dummy accident points weighted around UK cities
const seed = (n) => { let x = Math.sin(n) * 10000; return x - Math.floor(x); };
const points = [];
const hotspots = [
{ lng: -0.12, lat: 51.50, weight: 80 },
{ lng: -2.24, lat: 53.48, weight: 40 },
{ lng: -1.90, lat: 52.48, weight: 35 },
{ lng: -4.25, lat: 55.86, weight: 30 },
{ lng: -1.55, lat: 53.80, weight: 30 },
{ lng: -3.19, lat: 55.95, weight: 20 },
{ lng: -1.47, lat: 52.19, weight: 15 },
{ lng: -2.99, lat: 53.41, weight: 15 },
];
let pidx = 0;
hotspots.forEach(h => {
for (let i = 0; i < h.weight * 4; i++) {
points.push([h.lng + (seed(pidx++) - 0.5) * 1.6, h.lat + (seed(pidx++) - 0.5) * 0.8]);
}
});
for (let i = 0; i < 100; i++) points.push([-6 + seed(pidx++) * 8, 50 + seed(pidx++) * 9]);
const DEG_TO_RAD = Math.PI / 180;
function lngLatToM(lng, lat, oLng, oLat) {
return [
(lng - oLng) * DEG_TO_RAD * 6371000 * Math.cos(oLat * DEG_TO_RAD),
(lat - oLat) * DEG_TO_RAD * 6371000,
];
}
function mToLngLat(x, y, oLng, oLat) {
return [
oLng + (x / (6371000 * Math.cos(oLat * DEG_TO_RAD))) / DEG_TO_RAD,
oLat + (y / 6371000) / DEG_TO_RAD,
];
}
function hexPoly(cx, cy, r, cov, oLng, oLat) {
const R = r * cov;
const v = [];
for (let a = 0; a < 6; a++) {
const ang = (Math.PI / 180) * (60 * a);
v.push(mToLngLat(cx + R * Math.cos(ang), cy + R * Math.sin(ang), oLng, oLat));
}
v.push(v[0]);
return v;
}
function buildHexGrid(radiusKm, coverage, upperPercentile, maxHeight) {
const r = radiusKm * 1000;
const oLng = -2, oLat = 54.5;
const pts = points.map(p => lngLatToM(p[0], p[1], oLng, oLat));
const colStep = r * Math.sqrt(3), rowStep = r * 1.5;
const cells = [];
for (let row = 0, y = -500000; y < 600000; y += rowStep, row++) {
const xOff = (row % 2 === 0) ? 0 : colStep / 2;
for (let x = -600000 + xOff; x < 400000; x += colStep) {
let count = 0;
for (const [px, py] of pts) {
const dx = px - x, dy = py - y;
if (Math.abs(dx) <= colStep / 2 && Math.abs(dy) <= r) {
const q = (2/3 * dx) / r;
const s = (-1/3 * dx + Math.sqrt(3)/3 * dy) / r;
const t2 = (-1/3 * dx - Math.sqrt(3)/3 * dy) / r;
if (Math.max(Math.abs(q), Math.abs(s), Math.abs(t2)) <= 1) count++;
}
}
if (count > 0) cells.push({ x, y, count });
}
}
if (!cells.length) return { type: 'FeatureCollection', features: [] };
const sorted = cells.map(c => c.count).sort((a, b) => a - b);
const maxCount = sorted[Math.min(Math.floor(upperPercentile / 100 * sorted.length) - 1, sorted.length - 1)];
return {
type: 'FeatureCollection',
features: cells.map(({ x, y, count }) => {
const t = maxCount > 0 ? Math.min(count, maxCount) / maxCount : 0;
const rv = Math.round(t < 0.5 ? t * 2 * 255 : 255);
const gv = Math.round(t < 0.5 ? t * 2 * 200 : (1 - (t - 0.5) * 2) * 200);
const bv = Math.round(t < 0.5 ? 255 - t * 2 * 255 : 0);
const centre = mToLngLat(x, y, oLng, oLat);
return {
type: 'Feature',
geometry: { type: 'Polygon', coordinates: [hexPoly(x, y, r, coverage, oLng, oLat)] },
properties: { count, height: t * maxHeight * 1000, color: `rgb(${rv},${gv},${bv})`, t,
centreLng: centre[0].toFixed(4), centreLat: centre[1].toFixed(4) },
};
}),
};
}
const HexagonLayer = () => {
const mapContainer = useRef(null);
const map = useRef(null);
const [radiusKm, setRadiusKm] = useState(40);
const [coverage, setCoverage] = useState(0.85);
const [upperPercentile, setUpperPercentile] = useState(100);
const [maxHeight, setMaxHeight] = useState(200);
const [stats, setStats] = useState('');
const updateMap = useCallback((r, c, u, h) => {
if (!map.current?.getSource('hexagons')) return;
const geo = buildHexGrid(r, c, u, h);
map.current.getSource('hexagons').setData(geo);
setStats(`${geo.features.length} hexagons · ${geo.features.reduce((s, f) => s + f.properties.count, 0)} points`);
}, []);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: 'YOUR_STYLE_URL_WITH_TOKEN',
center: [-2, 54.5],
zoom: 5,
pitch: 45,
canvasContextAttributes: { antialias: true },
});
map.current.addControl(new mapmetricsgl.NavigationControl(), 'top-right');
map.current.on('load', () => {
const geo = buildHexGrid(40, 0.85, 100, 200);
map.current.addSource('hexagons', { type: 'geojson', data: geo });
map.current.addLayer({
id: 'hex-fill', type: 'fill-extrusion', source: 'hexagons',
paint: {
'fill-extrusion-color': ['get', 'color'],
'fill-extrusion-height': ['get', 'height'],
'fill-extrusion-base': 0,
'fill-extrusion-opacity': 0.85,
},
});
setStats(`${geo.features.length} hexagons · ${geo.features.reduce((s, f) => s + f.properties.count, 0)} points`);
// Hover popup
const popup = new mapmetricsgl.Popup({ closeButton: false, closeOnClick: false, offset: 10 });
map.current.on('mousemove', 'hex-fill', (e) => {
map.current.getCanvas().style.cursor = 'pointer';
const p = e.features[0].properties;
popup.setLngLat(e.lngLat).setHTML(`
<div style="font-family:system-ui,sans-serif;font-size:13px;line-height:1.6;">
<strong style="font-size:14px;">🚗 Accident Cluster</strong><br/>
<span style="color:#6b7280;">Accidents in cell:</span> <strong>${p.count}</strong><br/>
<span style="color:#6b7280;">Intensity:</span> <strong>${Math.round(p.t * 100)}%</strong><br/>
<span style="color:#6b7280;">Centre:</span> ${p.centreLat}°N, ${p.centreLng}°E
</div>
`).addTo(map.current);
});
map.current.on('mouseleave', 'hex-fill', () => {
map.current.getCanvas().style.cursor = '';
popup.remove();
});
});
return () => { map.current?.remove(); map.current = null; };
}, []);
const handle = (setter, key) => (e) => {
const v = key === 'coverage' ? parseFloat(e.target.value) : parseInt(e.target.value);
setter(v);
const r = key === 'radiusKm' ? v : radiusKm;
const c = key === 'coverage' ? v : coverage;
const u = key === 'upperPercentile' ? v : upperPercentile;
const h = key === 'maxHeight' ? v : maxHeight;
updateMap(r, c, u, h);
};
const sliderStyle = { width: '100%', cursor: 'pointer' };
const rowStyle = { display: 'flex', flexDirection: 'column', gap: '3px', fontSize: '13px', color: '#374151' };
return (
<div>
<div ref={mapContainer} style={{ width: '100%', height: '500px' }} />
<div style={{ marginTop: '12px', display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(180px,1fr))', gap: '12px' }}>
<div style={rowStyle}>
<span>Radius: <strong>{radiusKm} km</strong></span>
<input type="range" min="10" max="100" value={radiusKm} step="5" style={sliderStyle} onChange={handle(setRadiusKm, 'radiusKm')} />
</div>
<div style={rowStyle}>
<span>Coverage: <strong>{coverage}</strong></span>
<input type="range" min="0.3" max="1" value={coverage} step="0.05" style={sliderStyle} onChange={handle(setCoverage, 'coverage')} />
</div>
<div style={rowStyle}>
<span>Upper percentile: <strong>{upperPercentile}%</strong></span>
<input type="range" min="50" max="100" value={upperPercentile} step="1" style={sliderStyle} onChange={handle(setUpperPercentile, 'upperPercentile')} />
</div>
<div style={rowStyle}>
<span>Max height: <strong>{maxHeight}k</strong></span>
<input type="range" min="50" max="500" value={maxHeight} step="50" style={sliderStyle} onChange={handle(setMaxHeight, 'maxHeight')} />
</div>
</div>
<div style={{ marginTop: '8px', fontSize: '12px', color: '#6b7280' }}>{stats}</div>
</div>
);
};
export default HexagonLayer;For more information, visit the MapMetrics GitHub repository.