OnlyMapJS Latest release
GitHub Docs

← Gallery / Transport & logistics

Bay Area shipping

Simulated AIS vessels crossing San Francisco Bay over a websocket. Upsert by key, coalesce bursts with flush, reconnect automatically.

This example needs a live backend. On static hosting it renders as a reference only.

Open full screen ↗ View source ↗
<!DOCTYPE html>
<!--
  Vessel tracking over a websocket, speaking aisstream.io's real message
  format — here against a simulated feed of 24 vessels in San Francisco Bay,
  so the page runs without an API key. To point it at the actual service,
  change `data` to wss://stream.aisstream.io/v0/stream and add your key to
  the subscription in the source plugin's onOpen.

  The map: ships glide around the bay. Each socket message upserts its ship
  by MMSI (key="mmsi"), bursts coalesce into ONE snapshot per flush="250ms",
  and the stats widget re-renders on the same data:ships watch token. Hover
  a ship for its name, speed and heading.
-->
<html>
<head>
  <meta charset="utf-8" />
  <title>OnlyMapJS — Bay Area shipping</title>
  <!-- Import the library and register the source plugin in ONE module
       script, so the plugin exists before the map opens the stream. -->
  <link rel="stylesheet" href="https://unpkg.com/@nika-js/onlymap/dist/onlymapjs.css" />
  <script type="module">
    import { OmMap } from "https://unpkg.com/@nika-js/onlymap/dist/onlymap.standalone.js";
    // A source plugin owns the domain decoding; the transport — WebSocket,
    // reconnect, flush — stays the library's.
    OmMap.registerSource("ais", {
      // For the real aisstream.io service, subscribe here:
      // onOpen: (send) => send(JSON.stringify({ APIKey: "...", BoundingBoxes: [[[37.7, -122.6], [37.9, -122.3]]] })),
      decode: (raw) => {
        const m = raw;
        if (m?.MessageType !== "PositionReport") return null;
        const p = m.Message.PositionReport;
        return { mmsi: m.MetaData.MMSI, name: m.MetaData.ShipName, lon: p.Longitude, lat: p.Latitude, sog: p.Sog, cog: p.Cog };
      },
    });
  </script>
  <style>
    html, body { margin: 0; height: 100%; font-family: system-ui, sans-serif; }
    om-map { display: block; height: 100vh; }
  </style>
</head>
<body>
  <div data-om-needs-server style="background:#fef3c7;color:#78350f;font:13px system-ui,sans-serif;padding:8px 14px;text-align:center">⚠ This example needs a backend (a live feed / auth endpoint) and won't run on static hosting — read the source as a reference, or see the comment at the top of this file for the real service.</div>

  <om-map center="[-122.42, 37.83]" zoom="12" basemap="maplibre">

    <!-- Each vessel is a pointed arrow rotated to its course. The SVG atlas
         points north; deck's get-angle rotates COUNTER-clockwise while COG
         is clockwise-from-north, so the angle is -$cog. mask:true lets
         get-color tint the white arrow by speed. A `wss:` URL opens a
         stream instead of a fetch; write it host-relative (`wss:/path`)
         and the runtime fills in host and scheme from the page. -->
    <om-layer id="ships" type="IconLayer"
                data="wss:/__ais" source="ais" key="mmsi" flush="250ms"
                label="Ships (live AIS)" color="#08519c"
                icon-atlas="../../data/vehicle.svg"
                icon-mapping='{"vehicle": {"x": 0, "y": 0, "width": 64, "height": 64, "mask": true, "anchorX": 32, "anchorY": 32}}'
                get-icon="'vehicle'"
                get-position="[$lon, $lat]"
                get-angle="-$cog"
                get-color="scale($sog, sequential, ['#9ecae1', '#08306b'], domain=[0, 25])"
                get-size="scale($sog, sqrt, [16, 30], domain=[0, 25])"
                pickable></om-layer>

    <om-widget type="legend" title="Layers" position="top-right"></om-widget>

    <!-- Re-renders on every flush (data:ships) — live fleet stats. -->
    <om-widget position="top-left" watch="data:ships">
      <style>
        .panel { background: #fff; padding: 10px 14px; border-radius: 8px; font: 12px system-ui;
                 box-shadow: 0 2px 8px rgba(0,0,0,.15); }
        .row { display: flex; justify-content: space-between; gap: 18px; margin: 3px 0; }
      </style>
      <div class="panel">
        <strong>Fleet (live)</strong>
        <div class="row"><span>Ships</span><b id="count">—</b></div>
        <div class="row"><span>Mean speed</span><b id="mean">—</b></div>
        <div class="row"><span>Fastest</span><b id="max">—</b></div>
      </div>
      <script type="om/widget">
        this.watch = ["data:ships"];
        this.render = (ctx) => {
          const s = ctx.stats("ships", "sog");
          if (!s.count) return;
          this.$("#count").textContent = s.count;
          this.$("#mean").textContent = s.mean.toFixed(1) + " kn";
          this.$("#max").textContent = s.max.toFixed(1) + " kn";
        };
      </script>
    </om-widget>

    <om-behavior on="hover" layer="ships" action="show-tooltip"
                   template="#ship-tooltip"></om-behavior>

  </om-map>

  <template id="ship-tooltip">
    <div style="background:#222; color:#fff; padding:4px 8px; border-radius:4px; font:12px system-ui, sans-serif;">
      <b>{{name}}</b> — {{sog}} kn, heading {{cog}}°
    </div>
  </template>

</body>
</html>

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