NIKA OnlyMapJS Latest release
Discord GitHub Docs

← 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.

Open full screen ↗ View source ↗
<!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>