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