OnlyMapJS Latest release
GitHub Docs

← Gallery / Interaction

Filter on the GPU

Cut 21,337 points live with filter-field and filter-range. The DataFilterExtension drops rows in the shader, so ctx.stats always agrees.

Open full screen ↗ View source ↗
<!DOCTYPE html>
<!--
  `filter-field` names a numeric column and `filter-range` is the window kept.
  Rows outside it are dropped in the shader — the data is never re-parsed, no
  array is rebuilt, and nothing is uploaded again. That is why a slider over
  twenty thousand points stays smooth where a JavaScript filter would not.

  A filter is a plain prop rather than an accessor, so moving it never
  triggers deck.gl's updateTriggers path. Reach for it whenever the reader's
  question is "show me only the ones where…".

  The map: 21,337 power stations worldwide, every one rated 10 MW or more.
  Drag the capacity slider to watch the small stations fall away and the
  fleet's real backbone appear — and watch the panel agree with it, because
  both read the same filter.
-->
<html>
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>OnlyMapJS — Filter on the GPU</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>
    :root { color-scheme: dark; }
    html, body { margin: 0; height: 100%; font-family: system-ui, sans-serif; }
    om-map {
      display: block; height: 100vh;
      --om-widget-bg: #11141b;
      --om-widget-fg: #f4f7fc;
      --om-widget-muted: #8a93a5;
      --om-widget-border: #252c38;
      --om-widget-hover-bg: #1c222b;
    }
  </style>
</head>
<body>

  <om-map center="[8, 34]" zoom="2.1" basemap="dark-matter">

    <!-- filter-range is the STARTING window, not a hard limit — author it wide
         and let the widget clamp it to the values actually present.

         filter-category is the second, categorical filter: name a text column
         and list the values to keep. It runs in the same shader pass as the
         range, so a layer can carry both at once. Unlike the range it has no
         built-in control and the filter-layer action does not touch it, so
         treat a category list as authored state.

         A ternary chain on get-fill-color is what the legend reads back to
         build its key, so the palette is written once, here. -->
    <om-layer id="stations" type="ScatterplotLayer"
              data="../../data/global-power-plants.csv"
              label="Power stations" color="#fbbf24"
              get-position="[$lon, $lat]"
              get-fill-color="$fuel == 'Nuclear' ? '#22d3ee'
                            : $fuel == 'Coal' ? '#f43f5e'
                            : $fuel == 'Gas' ? '#fbbf24'
                            : $fuel == 'Hydro' ? '#4ade80'
                            : $fuel == 'Wind' ? '#a78bfa'
                            : $fuel == 'Solar' ? '#fde68a'
                            : '#7c8798'"
              get-radius="scale($capacity_mw, sqrt, [1.6, 26], domain=[10, 22500])"
              radius-units="pixels" opacity="0.86" pickable
              filter-field="capacity_mw" filter-range="[10, 22500]"></om-layer>

    <!-- The widget emits filter-layer, which rewrites the layer's own
         filter-range attribute — so the MANIFEST stays the source of truth and
         an undo, a story step or a behavior moving the same filter re-syncs
         this slider. Keep every filter on a map the same kind: a filter here
         and a hand-rolled range control elsewhere will disagree. -->
    <om-widget type="filter" layer="stations" field="capacity_mw" title="Capacity (MW)"
               watch="layers data:stations" position="top-right"></om-widget>

    <om-widget type="legend" title="Fuel" position="top-right" style="margin-top: 130px;"></om-widget>

    <!-- ctx.stats reads the SAME declarative filter the shader is applying, so
         a count here can never drift from what is drawn. Pass
         { filtered: false } for a total that holds still while the slider
         moves — this panel shows both, side by side. Watch `layers`: moving
         the window changes filter-range, not the underlying data. -->
    <om-widget position="top-left" watch="layers data:stations">
      <style>
        .panel { width: 232px; box-sizing: border-box; background: rgba(17,20,27,.92); color: #eef2f8;
                 border: 1px solid #252c38; border-radius: 9px; padding: 11px 14px;
                 font: 12px/1.5 system-ui, sans-serif; }
        .panel h3 { margin: 0 0 8px; font: 650 13px system-ui, sans-serif; color: #fbbf24; }
        .row { display: flex; justify-content: space-between; gap: 14px; margin: 3px 0; color: #8a93a5; }
        .row b { color: #f4f7fc; font-variant-numeric: tabular-nums; }
      </style>
      <section class="panel" aria-live="polite">
        <h3>Inside the window</h3>
        <div class="row"><span>Stations shown</span><b id="shown">—</b></div>
        <div class="row"><span>Of all stations</span><b id="total">—</b></div>
        <div class="row"><span>Capacity shown</span><b id="gw">—</b></div>
        <div class="row"><span>Largest</span><b id="max">—</b></div>
      </section>
      <script type="om/widget">
        this.watch = ["layers", "data:stations"];
        this.render = (ctx) => {
          const kept = ctx.stats("stations", "capacity_mw");
          const all = ctx.stats("stations", "capacity_mw", { filtered: false });
          if (!all.count) return;
          this.$("#shown").textContent = kept.count.toLocaleString();
          this.$("#total").textContent = all.count.toLocaleString();
          this.$("#gw").textContent = kept.count ? ((kept.mean * kept.count) / 1000).toFixed(0) + " GW" : "—";
          this.$("#max").textContent = kept.count ? kept.max.toLocaleString() + " MW" : "—";
        };
      </script>
    </om-widget>

    <om-behavior on="hover" layer="stations" action="show-tooltip" template="#station-tip"></om-behavior>

    <om-widget type="attribution"
               text="Power stations © World Resources Institute, Global Power Plant Database v1.3, CC BY 4.0"
               position="bottom-start"></om-widget>

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

  <template id="station-tip">
    <div style="padding:7px 10px; border-radius:7px; background:#11141b; color:#f4f7fc;
                border:1px solid #252c38; box-shadow:0 3px 12px rgba(0,0,0,.55);
                font:12px/1.45 system-ui, sans-serif;">
      <b>{{name}}</b><br />
      <span style="color:#8a93a5">{{capacity_mw}} MW · {{fuel}} · {{country}}</span>
    </div>
  </template>

</body>
</html>

The snippet above loads the latest release. The demo above it is pinned to v0.5.6, so it keeps working when a new version ships.