← OnlyMapJS Gallery / Layers
Cluster points
Twenty thousand power plants with cluster="true": count bubbles coloured by the commonest fuel inside, splitting as you zoom; click one to expand it.
<!DOCTYPE html>
<!--
Twenty thousand power plants, clustered: points that would overlap on screen
merge into count bubbles that split apart as you zoom in. One attribute
turns it on; the layer keeps its own symbology for the points that stand
alone, and a click on a bubble flies to where it splits. From z12 a second
layer over the same file takes over with an icon per plant — zoom bands via
visible-zoom-range.
-->
<html>
<head>
<meta charset="utf-8" />
<title>OnlyMapJS — Cluster points</title>
<link rel="stylesheet" href="https://unpkg.com/@nika-js/onlymap/dist/onlymapjs.css" />
<script type="module" src="https://unpkg.com/@nika-js/onlymap/dist/onlymap.standalone.js"></script>
<style>
html, body { margin: 0; height: 100%; font-family: system-ui, sans-serif; }
om-map { display: block; height: 100vh; }
</style>
</head>
<body>
<om-map center="[10, 30]" zoom="2" basemap="positron" hover-pick="continuous">
<!-- cluster="true" is the whole toggle. The three cluster-* attributes
below are the defaults written out, so you can see what to change.
cluster-properties carries aggregates onto each bubble: mode:fuel
becomes $mode_fuel (the commonest fuel inside) and sum:capacity_mw
becomes $sum_capacity_mw, beside $count. The bubble fill reuses the
points' own fuel palette on $mode_fuel, so a bubble reads as "mostly
hydro" the way a point reads as "hydro".
get-cluster-radius divides by $count_max — the largest cluster at the
CURRENT zoom — so the biggest bubble is always 36 px and the rest
scale by the square root of their share, at every zoom. That is also
the default; it is spelled out here to show the fields it uses. -->
<om-layer id="plants" type="ScatterplotLayer"
data="../../data/global-power-plants.csv"
label="Power plants" color="#2563eb"
get-position="[$lon, $lat]"
get-radius="scale($capacity_mw, sqrt, [3, 14], domain=[0, 5000])"
get-fill-color="$fuel == 'Hydro' ? '#0ea5e9'
: $fuel == 'Solar' ? '#f59e0b'
: $fuel == 'Wind' ? '#10b981'
: $fuel == 'Nuclear' ? '#8b5cf6'
: '#64748b'"
radius-units="pixels"
stroked get-line-color="[255, 255, 255]" line-width-min-pixels="1"
visible-zoom-range="[0, 12]"
cluster="true"
cluster-radius="50"
cluster-max-zoom="11"
cluster-min-points="2"
cluster-properties="sum:capacity_mw,mode:fuel"
get-cluster-radius="scale($count / $count_max, sqrt, [12, 36], domain=[0, 1])"
get-cluster-fill-color="$mode_fuel == 'Hydro' ? '#0ea5e9'
: $mode_fuel == 'Solar' ? '#f59e0b'
: $mode_fuel == 'Wind' ? '#10b981'
: $mode_fuel == 'Nuclear' ? '#8b5cf6'
: '#64748b'"
pickable></om-layer>
<!-- Zoom bands: visible-zoom-range="[min, max]" (min inclusive, max
exclusive) on ANY layer. The circles above own z0–11.99, clustered;
cluster-max-zoom="11" means nothing is still bubbled at the hand-over.
From z12 this IconLayer takes over with one glyph per plant — same
file (fetched once), a pin per fuel, a star for nuclear. -->
<om-layer id="plants-near" type="IconLayer"
data="../../data/global-power-plants.csv"
label="Power plants (z12+)" color="#2563eb"
icon-atlas="../../data/map-pins.svg"
icon-mapping='{"pin": {"x": 0, "y": 0, "width": 128, "height": 128, "anchorX": 64, "anchorY": 118, "mask": true},
"star": {"x": 128, "y": 0, "width": 128, "height": 128, "anchorX": 64, "anchorY": 106, "mask": true}}'
get-position="[$lon, $lat]"
get-icon="$fuel == 'Nuclear' ? 'star' : 'pin'"
get-color="$fuel == 'Hydro' ? '#0ea5e9'
: $fuel == 'Solar' ? '#f59e0b'
: $fuel == 'Wind' ? '#10b981'
: $fuel == 'Nuclear' ? '#8b5cf6'
: '#64748b'"
get-size="scale($capacity_mw, sqrt, [22, 48], domain=[0, 5000])"
size-units="pixels"
visible-zoom-range="[12, 24]"
pickable></om-layer>
<om-widget type="legend" title="Fuel" position="top-right"></om-widget>
<om-widget type="zoom-controls" position="bottom-right"></om-widget>
<!-- The toggle: one button that flips the layer's cluster attribute
through the generic set-layer-attribute action. ctx.layers reports
`cluster: true` while it is on, and the "layers" watch re-renders
the button after every change. -->
<om-widget position="top-left">
<style>
button { font: 600 13px system-ui, sans-serif; padding: 8px 12px; border: 0; border-radius: 8px;
background: #1d4ed8; color: #fff; cursor: pointer; box-shadow: 0 2px 10px rgba(0,0,0,.2); }
button.off { background: #fff; color: #1f2937; }
</style>
<button id="toggle">Clustering: on</button>
<script type="om/widget">
this.watch = ["layers"];
this.render = (ctx) => {
const on = ctx.layers.find((l) => l.id === "plants")?.cluster === true;
const button = this.$("#toggle");
button.textContent = on ? "Clustering: on" : "Clustering: off";
button.classList.toggle("off", !on);
button.onclick = () => ctx.emit("set-layer-attribute", { layer: "plants", name: "cluster", value: on ? "false" : "true" });
};
</script>
</om-widget>
<!-- What this page shows, in the reader's words. A static widget: no
type, no script — its content just renders in the slot. -->
<om-widget position="top-left">
<style>
.about { background: #fff; color: #1f2937; padding: 10px 12px; border-radius: 8px; max-width: 34ch;
font: 12px/1.45 system-ui, sans-serif; box-shadow: 0 2px 10px rgba(0,0,0,.2); }
.about h4 { margin: 0 0 4px; font-size: 12px; }
.about ul { margin: 0; padding-left: 16px; }
.about li + li { margin-top: 3px; }
.about code { font-size: 11px; background: #f1f5f9; padding: 0 3px; border-radius: 3px; }
</style>
<div class="about">
<h4>What this page shows</h4>
<ul>
<li><strong>Clustering as a toggle:</strong> <code>cluster="true"</code> on the layer merges overlapping plants into count bubbles. Switch it off above to see all 21,000 points.</li>
<li><strong>Aggregates:</strong> <code>cluster-properties="sum:capacity_mw,mode:fuel"</code> — each bubble is coloured by the fuel most of its plants use.</li>
<li><strong>Sizes that survive zoom:</strong> the biggest bubble is always 36 px, the rest scale by share (<code>$count / $count_max</code>).</li>
<li><strong>Picks:</strong> hover a bubble for its summary, click it to zoom to where it splits; click a single plant for its card.</li>
<li><strong>Zoom bands:</strong> past z12 an icon layer over the same file takes over (<code>visible-zoom-range</code>).</li>
</ul>
</div>
</om-widget>
<!-- Two kinds of pick, two cards. `pick="point"` / `pick="cluster"` on a
behavior chooses which picks fire it: a single plant opens its card
on click; a bubble shows a tooltip on hover, and a click on it flies
to where it splits (cluster-click="expand", the default). -->
<om-overlay id="detail" anchor-from="selection" visible="false">
<style>
.card { background: #fff; padding: 8px 12px; border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,.2); font-size: 13px; white-space: nowrap; }
.card em { color: #64748b; font-style: normal; }
</style>
<div class="card">
<strong>{{name}}</strong><br />
{{capacity_mw}} MW <em>· {{fuel}}</em>
</div>
</om-overlay>
<om-behavior on="click" layer="plants" pick="point" action="show-overlay" target="detail"></om-behavior>
<om-behavior on="click" layer="plants-near" action="show-overlay" target="detail"></om-behavior>
<template id="bubble-tip">
<div style="background: #1f2937; color: #fff; padding: 6px 10px; border-radius: 6px; font: 12px system-ui, sans-serif; white-space: nowrap;">
<strong>{{count}} plants</strong> · mostly {{mode_fuel}} · click to expand
</div>
</template>
<om-behavior on="hover" layer="plants" pick="cluster" action="show-tooltip" template="#bubble-tip"></om-behavior>
<om-fallback>
<p style="font: 15px system-ui, sans-serif; padding: 24px; max-width: 42ch">
<strong>This map needs JavaScript.</strong><br />
Open this file in a web browser such as Chrome, Safari, or Firefox.
</p>
</om-fallback>
</om-map>
</body>
</html>