Pin Clustering
Pins that crowd together on a map merge into numbered bubbles. Zoom in and each bubble bursts apart into smaller clusters and single pins, sliding out to where they belong; zoom out and they gather back in. Tap a bubble to dive into it, as on a real map.
How to use it
- Paste the snippet just before
</head>. - Add
data-pin-clusteringto a container. Put the map first (an<img>or inline SVG), then one empty child per pin. - Place each pin with
data-xanddata-y, from 0 to 100 across and down the map.
Options
Add any of these attributes to the same element.
data-x / data-y | On a pin: where it is, 0–100 from the top left. |
data-count | With no pins inside: scatter this many made-up pins in a few busy spots. Default 48. |
data-levels | How many times you can zoom in, each doubling the scale. Default 3. |
data-color | Colour of the bubbles and pins. Default #7c3aed. |
data-controls | Add + and − zoom buttons in the corner. |
data-autoplay | Zoom in on a busy spot and back out by itself, pausing for a few seconds whenever someone taps. |
Pins closer than about two bubble-widths on screen join a cluster, so the grouping changes with the map’s size and font-size. Bubbles grow a little with the number they hold.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Pin Clustering: map pins that sit close together merge into numbered
// bubbles. Zoom in and the bubbles split apart into smaller clusters and
// single pins; zoom out and they gather back together.
// Usage:
// <div data-pin-clustering>
// <img src="map.png" alt="Map of the city">
// <span data-x="31" data-y="42"></span>
// <span data-x="33" data-y="45"></span>
// …
// </div>
// The map comes first, then one child per pin, placed with data-x and
// data-y from 0 to 100 across and down the map. Leave the pins out and set
// data-count to scatter that many made-up pins in a few busy spots.
// Tap or click a bubble to zoom in on it.
// Optional: data-count (made-up pins when there are no children, default
// 48), data-levels (zoom steps, each doubling the scale, default 3),
// data-color (bubble and pin colour, default #7c3aed), data-controls (add
// + and - zoom buttons), data-autoplay (zoom in and out by itself while in
// view, pausing for a few seconds after you use it).
var DEFAULTS = { count: 48, levels: 3, color: '#7c3aed', step: 850, idle: 7000 };
var css =
'.pnc{position:relative;display:block;line-height:0;overflow:hidden;border-radius:.8em;touch-action:manipulation}' +
'.pnc>img,.pnc>svg:first-child{display:block;width:100%;height:auto;transform-origin:0 0}' +
'.pnc-pts{position:absolute;inset:0;line-height:1.2}' +
'.pnc-node{position:absolute;left:0;top:0;width:0;height:0}' +
'.pnc-bub{position:absolute;display:grid;place-items:center;border-radius:50%;background:var(--pnc-c);color:#fff;font-weight:800;font-size:.85em;' +
'box-shadow:0 0 0 .32em color-mix(in srgb,var(--pnc-c) 26%,transparent),0 .15em .5em rgba(30,10,60,.3);font-variant-numeric:tabular-nums;cursor:pointer;user-select:none}' +
'.pnc-pin{position:absolute;left:-.55em;top:-1.5em;width:1.1em;height:1.5em;filter:drop-shadow(0 .1em .08em rgba(30,10,60,.3))}' +
'.pnc-pin svg{display:block;width:100%;height:100%}' +
'.pnc-zoom{position:absolute;right:.6em;top:.6em;display:flex;flex-direction:column;border-radius:.5em;overflow:hidden;box-shadow:0 .15em .6em rgba(30,10,60,.25)}' +
'.pnc-zoom button{width:2.1em;height:2.1em;border:0;background:#fff;color:#1f1530;font:700 1.1em/1 system-ui,sans-serif;cursor:pointer;padding:0}' +
'.pnc-zoom button+button{border-top:1px solid #e5e0ee}' +
'.pnc-zoom button:disabled{color:#c4bdd2;cursor:default}' +
'.pnc-zoom button:focus-visible{outline:2px solid var(--pnc-c);outline-offset:-2px}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
function rng(seed) {
return function () {
seed |= 0; seed = (seed + 0x6d2b79f5) | 0;
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// A few busy spots with pins scattered around them, plus some loners.
function madeUp(n) {
var r = rng(7), pts = [], spots = [];
for (var k = 0; k < 5; k++) spots.push([0.15 + r() * 0.7, 0.18 + r() * 0.64, 0.03 + r() * 0.06]);
for (var i = 0; i < n; i++) {
if (i % 7 === 6) { pts.push([0.06 + r() * 0.88, 0.08 + r() * 0.84]); continue; }
var s = spots[i % spots.length], a = r() * 6.283, d = Math.sqrt(r()) * s[2];
pts.push([clamp(s[0] + Math.cos(a) * d, 0.04, 0.96), clamp(s[1] + Math.sin(a) * d * 1.5, 0.06, 0.94)]);
}
return pts;
}
// Clusters for every zoom level, built from the closest level outwards so
// each cluster splits cleanly into the ones below it.
function build(s) {
var W = s.el.clientWidth, H = s.el.clientHeight || W * 0.625, R = s.radius;
var levels = [];
levels[s.max] = s.pts.map(function (p, i) { return { id: s.max + ':' + i, x: p[0], y: p[1], n: 1, kids: [] }; });
for (var z = s.max - 1; z >= 0; z--) {
var sc = Math.pow(2, z), items = levels[z + 1].slice().sort(function (a, b) { return b.n - a.n; });
var used = [], out = [];
items.forEach(function (it, i) {
if (used[i]) return;
var c = { id: z + ':' + out.length, kids: [], n: 0, x: 0, y: 0 };
items.forEach(function (o, j) {
if (used[j]) return;
var dx = (o.x - it.x) * W * sc, dy = (o.y - it.y) * H * sc;
if (dx * dx + dy * dy > R * R) return;
used[j] = true;
c.kids.push(o); c.n += o.n; c.x += o.x * o.n; c.y += o.y * o.n;
o.parent = c;
});
c.x /= c.n; c.y /= c.n;
out.push(c);
});
levels[z] = out;
}
s.levels = levels;
s.W = W; s.H = H;
}
function pinSvg(color) {
return '<svg viewBox="0 0 22 30" aria-hidden="true"><path d="M11 29S1.5 18 1.5 11a9.5 9.5 0 0 1 19 0C20.5 18 11 29 11 29z" fill="' + color +
'" stroke="#fff" stroke-width="1.6"/><circle cx="11" cy="11" r="3.6" fill="#fff"/></svg>';
}
function nodeEl(s, node) {
if (s.els[node.id]) return s.els[node.id];
var e = document.createElement('div');
e.className = 'pnc-node';
if (node.n > 1) {
var b = document.createElement('span');
var size = 1.7 + Math.log(node.n) / Math.LN2 * 0.32;
b.className = 'pnc-bub';
b.style.width = b.style.height = size + 'em';
b.style.left = b.style.top = -size / 2 + 'em';
b.textContent = node.n;
b.setAttribute('aria-hidden', 'true');
b._pncNode = node;
e.appendChild(b);
} else {
e.innerHTML = '<span class="pnc-pin">' + pinSvg(s.color) + '</span>';
}
s.layer.appendChild(e);
s.els[node.id] = e;
return e;
}
// Where a world point (0-1) is on screen, in px, for the current camera.
function toScreen(s, x, y) {
return [(x - s.cam.lx) * s.cam.sc * s.W, (y - s.cam.ly) * s.cam.sc * s.H];
}
function render(s) {
var z = s.cam.z, a = Math.floor(z + 1e-6), t = z - a;
if (a >= s.max) { a = s.max; t = 0; }
s.cam.sc = Math.pow(2, z);
s.map.style.transform = 'scale(' + s.cam.sc + ') translate(' + (-s.cam.lx * 100) + '%,' + (-s.cam.ly * 100) + '%)';
var shown = {};
function place(node, x, y, op, k) {
var e = nodeEl(s, node), p = toScreen(s, x, y);
shown[node.id] = true;
e.style.display = '';
e.style.opacity = op;
e.style.transform = 'translate(' + p[0].toFixed(1) + 'px,' + p[1].toFixed(1) + 'px) scale(' + k.toFixed(3) + ')';
}
if (t < 0.001) {
s.levels[a].forEach(function (n) { place(n, n.x, n.y, 1, n._pncPop !== undefined ? n._pncPop : 1); });
} else {
var e = t * t * (3 - 2 * t), fade = clamp(t * 2.4, 0, 1);
s.levels[a].forEach(function (p) {
if (p.kids.length > 1) place(p, p.x, p.y, 1 - fade, 1 + fade * 0.25);
});
s.levels[a + 1].forEach(function (n) {
var p = n.parent, alone = p.kids.length === 1;
place(n, p.x + (n.x - p.x) * e, p.y + (n.y - p.y) * e, alone ? 1 : fade, alone ? 1 : 0.6 + 0.4 * fade);
});
}
for (var id in s.els) if (!shown[id]) s.els[id].style.display = 'none';
if (s.zin) {
s.zin.disabled = s.target >= s.max;
s.zout.disabled = s.target <= 0;
}
}
// Zoom one level towards a world point, sliding it to the middle.
function zoom(s, dir, fx, fy, done) {
var from = s.cam.z, to = clamp(Math.round(from) + dir, 0, s.max);
if (to === Math.round(from) && Math.abs(from - to) < 1e-3) { if (done) done(); return; }
s.target = to;
if (fx === undefined) { fx = s.cam.lx + 0.5 / s.cam.sc; fy = s.cam.ly + 0.5 / s.cam.sc; }
var sc0 = s.cam.sc, px0 = (fx - s.cam.lx) * sc0, py0 = (fy - s.cam.ly) * sc0;
if (s.anim) s.anim.pause();
var st = { k: 0 };
function step() {
var k = st.k;
s.cam.z = from + (to - from) * k;
var sc = Math.pow(2, s.cam.z), px = px0 + (0.5 - px0) * k, py = py0 + (0.5 - py0) * k;
s.cam.lx = clamp(fx - px / sc, 0, 1 - 1 / sc);
s.cam.ly = clamp(fy - py / sc, 0, 1 - 1 / sc);
render(s);
}
if (reduced()) { st.k = 1; step(); if (done) done(); return; }
s.anim = anime.animate(st, { k: 1, duration: s.step, ease: 'inOut(2)', onUpdate: step, onComplete: function () { s.anim = null; if (done) done(); } });
}
function interacted(s) { s.idleUntil = Date.now() + DEFAULTS.idle; }
// Autoplay: dive into one busy spot level by level, hold, come back out.
function tour(s) {
clearTimeout(s.timer);
if (!s.visible) return;
var wait = Date.now() < s.idleUntil;
if (wait || s.anim) { s.timer = setTimeout(function () { tour(s); }, 600); return; }
var lvl = Math.round(s.cam.z);
if (s.dir > 0 && lvl >= s.max) { s.dir = -1; s.timer = setTimeout(function () { tour(s); }, 1500); return; }
if (s.dir < 0 && lvl <= 0) {
s.dir = 1;
var big = s.levels[0].slice().sort(function (a, b) { return b.n - a.n; });
s.focus = big[Math.floor(Math.random() * Math.min(3, big.length))];
s.timer = setTimeout(function () { tour(s); }, 1500);
return;
}
var f = s.focus;
if (s.dir > 0 && f.kids.length) {
// Follow the busiest piece of the cluster down a level.
s.focus = f.kids.slice().sort(function (a, b) { return b.n - a.n; })[0];
}
zoom(s, s.dir, s.dir > 0 ? s.focus.x : undefined, s.dir > 0 ? s.focus.y : undefined, function () {
s.timer = setTimeout(function () { tour(s); }, 650);
});
}
function setup(el) {
var d = el.dataset;
var s = (el._pnc = { el: el, els: {}, cam: { z: 0, sc: 1, lx: 0, ly: 0 }, target: 0, dir: -1, idleUntil: 0 });
el.classList.add('pnc');
s.color = d.color || DEFAULTS.color;
s.max = Math.max(1, Math.min(6, parseInt(d.levels, 10) || DEFAULTS.levels));
s.step = +d.step || DEFAULTS.step;
el.style.setProperty('--pnc-c', s.color);
var kids = Array.from(el.children);
s.map = kids[0] && /^(IMG|svg|PICTURE)$/i.test(kids[0].tagName) ? kids.shift() : null;
if (!s.map) {
s.map = document.createElement('div');
s.map.style.cssText = 'width:100%;aspect-ratio:16/10;background:#eef0f6';
el.insertBefore(s.map, el.firstChild);
}
s.pts = [];
kids.forEach(function (k) {
if (k.hasAttribute('data-x')) {
s.pts.push([clamp((+k.getAttribute('data-x') || 0) / 100, 0, 1), clamp((+k.getAttribute('data-y') || 0) / 100, 0, 1)]);
k.remove();
}
});
if (!s.pts.length) s.pts = madeUp(parseInt(d.count, 10) || DEFAULTS.count);
s.layer = document.createElement('div');
s.layer.className = 'pnc-pts';
s.layer.setAttribute('aria-hidden', 'true');
el.appendChild(s.layer);
if (!el.hasAttribute('role')) {
el.setAttribute('role', 'group');
el.setAttribute('aria-label', 'Map with ' + s.pts.length + ' pins');
}
var live = !el.closest('[aria-hidden="true"]');
if (live && d.controls !== undefined) {
var box = document.createElement('div');
box.className = 'pnc-zoom';
box.innerHTML = '<button type="button" aria-label="Zoom in">+</button><button type="button" aria-label="Zoom out">−</button>';
el.appendChild(box);
s.zin = box.children[0]; s.zout = box.children[1];
s.zin.addEventListener('click', function () { interacted(s); zoom(s, 1); });
s.zout.addEventListener('click', function () { interacted(s); s.dir = -1; zoom(s, -1); });
}
if (live) {
s.layer.addEventListener('click', function (e) {
var b = e.target.closest('.pnc-bub');
if (!b || s.anim) return;
interacted(s);
zoom(s, 1, b._pncNode.x, b._pncNode.y);
});
}
s.radius = parseFloat(getComputedStyle(el).fontSize) * 2.6;
build(s);
render(s);
if (typeof ResizeObserver !== 'undefined') {
new ResizeObserver(function () {
if (Math.abs(el.clientWidth - s.W) < 1) return;
s.radius = parseFloat(getComputedStyle(el).fontSize) * 2.6;
for (var id in s.els) s.els[id].remove();
s.els = {};
build(s);
render(s);
}).observe(el);
}
}
function intro(s) {
var nodes = s.levels[0];
nodes.forEach(function (n) { n._pncPop = 0; });
render(s);
var st = { k: 0 };
anime.animate(st, {
k: 1, duration: 700 + nodes.length * 70, ease: 'linear',
onUpdate: function () {
nodes.forEach(function (n, i) {
var local = clamp((st.k * (700 + nodes.length * 70) - i * 70) / 520, 0, 1);
var back = 1 + 2.4 * Math.pow(local - 1, 3) + 1.4 * Math.pow(local - 1, 2);
n._pncPop = local === 0 ? 0 : back;
});
render(s);
},
onComplete: function () { nodes.forEach(function (n) { delete n._pncPop; }); render(s); },
});
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var s = e.target._pnc;
s.visible = e.isIntersecting;
if (!e.isIntersecting) { clearTimeout(s.timer); return; }
if (!s.played) { s.played = true; intro(s); }
if (e.target.dataset.autoplay !== undefined) {
clearTimeout(s.timer);
s.timer = setTimeout(function () { tour(s); }, 1400);
}
});
}, { threshold: 0.2 });
document.querySelectorAll('[data-pin-clustering]').forEach(function (el) {
if (el._pnc) return;
setup(el);
if (!reduced()) io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-pin-clustering data-controls>
<img src="map.png" alt="Map of the city">
<span data-x="31" data-y="42"></span>
<span data-x="33" data-y="45"></span>
<span data-x="70" data-y="28"></span>
</div>