UI Components Hard
SVG Marker Clustering
Cluster nearby SVG markers when zoomed out, expanding them as the viewport scale grows.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
:root {
color-scheme: dark;
--bg: #0b1020;
--panel: #121a2d;
--line: #263555;
--ink: #eef2ff;
--muted: #a8b5d1;
--accent: #78a9ff;
--cluster: #e17bff;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
padding: clamp(1rem, 4vw, 3rem);
background: radial-gradient(900px 520px at 50% -10%, #16213c, var(--bg));
color: var(--ink);
font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
button, input { font: inherit; }
.demo {
width: min(760px, 100%);
margin: auto;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 22px;
padding: clamp(1rem, 3vw, 1.75rem);
box-shadow: 0 24px 70px -30px #000;
}
.demo__head h1 { margin: 0 0 .3rem; font-size: 1.25rem; letter-spacing: -.01em; }
.muted { color: var(--muted); font-size: .9rem; margin: 0 0 1rem; }
.hint { color: #9eb1d4; font-size: .82rem; margin: .6rem 0 0; }
kbd {
background: #1b2438; border: 1px solid var(--line);
border-radius: 5px; padding: 0 .35rem; font: inherit; font-size: .78rem;
}
.map-wrap {
overflow: hidden;
border: 1px solid var(--line);
border-radius: 16px;
background-color: #0b1224;
}
#map {
display: block;
width: 100%;
height: 360px;
touch-action: none;
cursor: grab;
outline: none;
background-image:
repeating-linear-gradient(0deg, #ffffff08 0 1px, transparent 1px 36px),
repeating-linear-gradient(90deg, #ffffff08 0 1px, transparent 1px 36px);
}
#map.is-panning { cursor: grabbing; }
#map:focus-visible { box-shadow: inset 0 0 0 2px var(--accent); }
#land path {
fill: #1a2540;
stroke: #2f4066;
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.marker { cursor: pointer; }
.marker circle {
vector-effect: non-scaling-stroke;
transition: opacity .18s ease;
}
@media (prefers-reduced-motion: reduce) {
.marker circle { transition: none; }
}
.marker--single .dot { fill: var(--accent); stroke: #06101f; stroke-width: 1.5; }
.marker--cluster .dot { fill: var(--cluster); stroke: #ffffff44; stroke-width: 2; }
.marker--cluster .halo { fill: var(--cluster); opacity: .16; }
.marker:hover .halo { opacity: .3; }
.marker text {
fill: #17061f;
font-weight: 700;
text-anchor: middle;
dominant-baseline: central;
pointer-events: none;
paint-order: stroke;
}
.hud {
display: flex;
align-items: center;
gap: .8rem;
flex-wrap: wrap;
margin-top: .9rem;
color: var(--muted);
font-size: .86rem;
}
.hud label { display: flex; align-items: center; gap: .4rem; white-space: nowrap; }
#zoomOut { color: var(--ink); font-variant-numeric: tabular-nums; }
#zoom { flex: 1 1 180px; accent-color: var(--accent); }
.btn {
background: #1b2438;
color: var(--ink);
border: 1px solid var(--line);
border-radius: 10px;
padding: .4rem .8rem;
cursor: pointer;
}
.btn:hover { border-color: var(--accent); color: var(--accent); }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@media (max-width: 600px) {
.demo { border-radius: 16px; }
#map { height: 300px; }
}/**
* SVG marker clustering.
*
* Markers live in map coordinates (0..600 x 0..360). On every view change we
* project them to screen space (scale + translate), bucket them into a grid
* whose cell size is a fixed pixel radius, then merge each bucket into one
* cluster node placed at the centroid of its members. Because the grid is
* defined in SCREEN pixels, zooming in shrinks the map-space footprint of a
* cell, so clusters progressively split into their individual markers.
*/
const SVG_NS = "http://www.w3.org/2000/svg";
const WIDTH = 600;
const HEIGHT = 360;
const CELL_PX = 46; // clustering radius in screen pixels
const MIN_SCALE = 1;
const MAX_SCALE = 10;
const svg = document.getElementById("map");
const scene = document.getElementById("scene");
const landLayer = document.getElementById("land");
const markerLayer = document.getElementById("markers");
const zoomInput = document.getElementById("zoom");
const zoomOut = document.getElementById("zoomOut");
const resetBtn = document.getElementById("reset");
const status = document.getElementById("status");
const view = { scale: 1, x: 0, y: 0 };
/* ---------- abstract "land" shapes (no external assets) ---------- */
const LANDMASSES = [
[[60, 90], [170, 55], [250, 95], [235, 175], [140, 210], [55, 165]],
[[280, 40], [400, 30], [455, 90], [395, 135], [300, 120]],
[[330, 165], [470, 150], [545, 205], [500, 300], [370, 305], [315, 235]],
[[80, 250], [200, 245], [230, 315], [110, 330]],
];
for (const points of LANDMASSES) {
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("d", `M${points.map((p) => p.join(" ")).join("L")}Z`);
landLayer.append(path);
}
/* ---------- deterministic marker set clustered around the landmasses ---------- */
function makeRng(seed) {
let s = seed >>> 0;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
function buildMarkers(count) {
const rng = makeRng(20260718);
const hubs = LANDMASSES.map((poly) => {
const cx = poly.reduce((a, p) => a + p[0], 0) / poly.length;
const cy = poly.reduce((a, p) => a + p[1], 0) / poly.length;
return [cx, cy];
});
const out = [];
for (let i = 0; i < count; i++) {
const [hx, hy] = hubs[Math.floor(rng() * hubs.length)];
const angle = rng() * Math.PI * 2;
// sqrt gives an even areal spread instead of a dense bullseye
const radius = Math.sqrt(rng()) * 62;
const x = Math.min(WIDTH - 8, Math.max(8, hx + Math.cos(angle) * radius));
const y = Math.min(HEIGHT - 8, Math.max(8, hy + Math.sin(angle) * radius));
out.push({ id: i, x, y, label: `Site ${String(i + 1).padStart(3, "0")}` });
}
return out;
}
const MARKERS = buildMarkers(200);
/* ---------- clustering ---------- */
function cluster(markers, scale) {
const cell = CELL_PX / scale; // cell size expressed in map units
const buckets = new Map();
for (const m of markers) {
const key = `${Math.floor(m.x / cell)}:${Math.floor(m.y / cell)}`;
let bucket = buckets.get(key);
if (!bucket) buckets.set(key, (bucket = []));
bucket.push(m);
}
const clusters = [];
for (const members of buckets.values()) {
if (members.length === 1) {
const m = members[0];
clusters.push({ x: m.x, y: m.y, count: 1, label: m.label });
} else {
let sx = 0;
let sy = 0;
for (const m of members) {
sx += m.x;
sy += m.y;
}
clusters.push({
x: sx / members.length,
y: sy / members.length,
count: members.length,
label: `${members.length} sites`,
});
}
}
return clusters;
}
/* ---------- rendering ---------- */
function radiusFor(count) {
if (count === 1) return 4.5;
return 8 + Math.min(12, Math.log2(count) * 3.4);
}
function render() {
const { scale } = view;
const clusters = cluster(MARKERS, scale);
const inv = 1 / scale; // keep marker size constant on screen
const frag = document.createDocumentFragment();
let clustered = 0;
for (const c of clusters) {
if (c.count > 1) clustered++;
const g = document.createElementNS(SVG_NS, "g");
g.setAttribute("class", `marker ${c.count > 1 ? "marker--cluster" : "marker--single"}`);
g.setAttribute("transform", `translate(${c.x.toFixed(2)} ${c.y.toFixed(2)}) scale(${inv})`);
g.setAttribute("tabindex", "-1");
const title = document.createElementNS(SVG_NS, "title");
title.textContent = c.label;
g.append(title);
const r = radiusFor(c.count);
if (c.count > 1) {
const halo = document.createElementNS(SVG_NS, "circle");
halo.setAttribute("class", "halo");
halo.setAttribute("r", (r + 7).toFixed(1));
g.append(halo);
}
const dot = document.createElementNS(SVG_NS, "circle");
dot.setAttribute("class", "dot");
dot.setAttribute("r", r.toFixed(1));
g.append(dot);
if (c.count > 1) {
const text = document.createElementNS(SVG_NS, "text");
text.setAttribute("font-size", Math.max(9, Math.min(13, r * 0.95)).toFixed(1));
text.textContent = c.count;
g.append(text);
}
// Clicking a cluster zooms toward it until it breaks apart.
g.addEventListener("click", () => {
if (c.count > 1) zoomAt(c.x, c.y, Math.min(MAX_SCALE, scale * 1.9));
else announce(`${c.label} selected.`);
});
frag.append(g);
}
markerLayer.replaceChildren(frag);
scene.setAttribute("transform", `translate(${view.x} ${view.y}) scale(${scale})`);
zoomOut.textContent = `${scale.toFixed(1)}×`;
if (zoomInput.value !== String(scale)) zoomInput.value = scale.toFixed(1);
announce(
`${MARKERS.length} markers · ${clusters.length} nodes on screen · ` +
`${clustered} cluster${clustered === 1 ? "" : "s"} at ${scale.toFixed(1)}× zoom.`
);
}
let announceTimer;
function announce(msg) {
clearTimeout(announceTimer);
announceTimer = setTimeout(() => {
status.textContent = msg;
}, 60);
}
/* ---------- view maths ---------- */
function clampPan() {
const min = (s) => Math.min(0, WIDTH - WIDTH * s);
view.x = Math.max(min(view.scale), Math.min(0, view.x));
view.y = Math.max(Math.min(0, HEIGHT - HEIGHT * view.scale), Math.min(0, view.y));
}
/** Zoom so that the map point (mx, my) stays put on screen. */
function zoomAt(mx, my, nextScale) {
const s = Math.max(MIN_SCALE, Math.min(MAX_SCALE, nextScale));
const screenX = view.x + mx * view.scale;
const screenY = view.y + my * view.scale;
view.scale = s;
view.x = screenX - mx * s;
view.y = screenY - my * s;
clampPan();
render();
}
/** Convert a pointer event to map coordinates (pre-transform). */
function toMap(evt) {
const rect = svg.getBoundingClientRect();
const px = ((evt.clientX - rect.left) / rect.width) * WIDTH;
const py = ((evt.clientY - rect.top) / rect.height) * HEIGHT;
return { x: (px - view.x) / view.scale, y: (py - view.y) / view.scale };
}
/* ---------- interaction: drag to pan ---------- */
let drag = null;
svg.addEventListener("pointerdown", (e) => {
drag = { id: e.pointerId, x: e.clientX, y: e.clientY, ox: view.x, oy: view.y };
svg.setPointerCapture(e.pointerId);
svg.classList.add("is-panning");
});
svg.addEventListener("pointermove", (e) => {
if (!drag || e.pointerId !== drag.id) return;
const rect = svg.getBoundingClientRect();
view.x = drag.ox + (e.clientX - drag.x) * (WIDTH / rect.width);
view.y = drag.oy + (e.clientY - drag.y) * (HEIGHT / rect.height);
clampPan();
render();
});
function endDrag(e) {
if (!drag || e.pointerId !== drag.id) return;
drag = null;
svg.classList.remove("is-panning");
}
svg.addEventListener("pointerup", endDrag);
svg.addEventListener("pointercancel", endDrag);
/* wheel zoom, anchored at the cursor */
svg.addEventListener(
"wheel",
(e) => {
e.preventDefault();
const p = toMap(e);
zoomAt(p.x, p.y, view.scale * (e.deltaY < 0 ? 1.12 : 1 / 1.12));
},
{ passive: false }
);
/* keyboard fallback */
svg.addEventListener("keydown", (e) => {
const step = 28;
const keys = {
ArrowLeft: [step, 0],
ArrowRight: [-step, 0],
ArrowUp: [0, step],
ArrowDown: [0, -step],
};
if (keys[e.key]) {
e.preventDefault();
view.x += keys[e.key][0];
view.y += keys[e.key][1];
clampPan();
render();
} else if (e.key === "+" || e.key === "=") {
e.preventDefault();
zoomAt(WIDTH / 2, HEIGHT / 2, view.scale * 1.25);
} else if (e.key === "-" || e.key === "_") {
e.preventDefault();
zoomAt(WIDTH / 2, HEIGHT / 2, view.scale / 1.25);
}
});
zoomInput.addEventListener("input", () => {
const centerX = (WIDTH / 2 - view.x) / view.scale;
const centerY = (HEIGHT / 2 - view.y) / view.scale;
zoomAt(centerX, centerY, parseFloat(zoomInput.value));
});
resetBtn.addEventListener("click", () => {
view.scale = 1;
view.x = 0;
view.y = 0;
render();
svg.focus();
});
render();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SVG Marker Clustering</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="SVG Marker Clustering">
<header class="demo__head">
<h1>SVG marker clustering</h1>
<p class="muted">
200 markers on an inline SVG map. Nearby points merge into clusters at low zoom
and split apart as the scale grows. Drag to pan, scroll or use the slider to zoom.
</p>
</header>
<section class="map-wrap" aria-label="Clustered marker map">
<svg id="map" viewBox="0 0 600 360" role="application"
aria-describedby="status" tabindex="0">
<g id="scene">
<g id="land" aria-hidden="true"></g>
<g id="markers"></g>
</g>
</svg>
</section>
<div class="hud">
<label for="zoom">Zoom <output id="zoomOut">1.0×</output></label>
<input id="zoom" type="range" min="1" max="10" step="0.1" value="1"
aria-describedby="status" />
<button id="reset" class="btn" type="button">Reset view</button>
</div>
<p id="status" class="hint" role="status" aria-live="polite"></p>
<p class="hint">
Keyboard: focus the map, then use arrow keys to pan and <kbd>+</kbd> / <kbd>−</kbd> to zoom.
</p>
</main>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
export function SvgMapClustering() {
const [items, setItems] = useState(["Alpha", "Beta", "Gamma"]);
return (
<section className="demo">
<h2>SVG Marker Clustering</h2>
<p>Vanilla behavior maps cleanly to Pointer Events and DOM state.</p>
<div className="stack">
{items.map((item, index) => (
<button
className="item"
key={item}
onClick={() => setItems([...items.slice(0, index), ...items.slice(index + 1), item])}
>
{item}
</button>
))}
</div>
</section>
);
}SVG Marker Clustering
Cluster nearby SVG markers when zoomed out, expanding them as the viewport scale grows.
Support notes
Inline SVG keeps the map demo portable and avoids a tile server. Replace the abstract shapes with licensed geographic paths and project coordinates before shipping a real map.
Included demo
- Vanilla HTML, CSS, and JavaScript with zero external dependencies.
- React equivalent using the same interaction model.
- A Lab route for trying the behavior in isolation.
Integration checklist
Keep state updates separate from presentation, preserve semantic labels, and add persistence or server callbacks at the boundary where your product needs them.