PageSourceSearch

https://wikiglo.be/js/ais.js

js wikiglo.be collected 2026-09-28 07:09:50 UTC 7,104 bytes, 229 lines download raw bytes

1// Live AIS providers, tried in order:
2//  1. aisstream.io WebSocket — global coverage, needs a (free) API key the
3//     user supplies via ?aiskey= or the "AIS key" link (localStorage).
4//  2. Digitraffic Finland REST — open data, no key, Baltic coverage, polled.
5// Returns null when neither works; the shipping layer then falls back to
6// simulated vessels.
7
8const DEFAULT_AISSTREAM_WS = "wss://stream.aisstream.io/v0/stream";
9const DIGITRAFFIC_LOCATIONS = "https://meri.digitraffic.fi/api/ais/v1/locations";
10const DIGITRAFFIC_VESSELS = "https://meri.digitraffic.fi/api/ais/v1/vessels";
11const LOCATION_POLL_MS = 60 * 1000;
12const META_POLL_MS = 10 * 60 * 1000;
13const FRESH_MS = 15 * 60 * 1000;
14const KEY_STORAGE = "wikiglobe.aiskey";
15
16export function getAisKey() {
17  const fromUrl = new URLSearchParams(location.search).get("aiskey");
18  if (fromUrl) {
19    try { localStorage.setItem(KEY_STORAGE, fromUrl); } catch { /* private mode */ }
20    return fromUrl;
21  }
22  try { return localStorage.getItem(KEY_STORAGE); } catch { return null; }
23}
24
25export function setAisKey(key) {
26  try {
27    if (key) localStorage.setItem(KEY_STORAGE, key);
28    else localStorage.removeItem(KEY_STORAGE);
29  } catch { /* private mode */ }
30}
31
32// callbacks: onPosition({mmsi, lat, lon, sogKn, cogDeg, headingDeg, ts, name?})
33//            onStatic({mmsi, name?, typeCode?, destination?})
34export async function createLiveAis(callbacks) {
35  const key = getAisKey();
36  const aisstreamUrl = getAisstreamWsUrl();
37  if (key || aisstreamUrl !== DEFAULT_AISSTREAM_WS) {
38    const ws = await tryAisstream(aisstreamUrl, key, callbacks);
39    if (ws) return ws;
40    console.warn("[ais] aisstream connection failed, trying Digitraffic");
41  }
42  return tryDigitraffic(callbacks);
43}
44
45// --- aisstream.io (global, WebSocket) ---------------------------------------
46
47function getAisstreamWsUrl() {
48  const params = new URLSearchParams(location.search);
49  const fromUrl = cleanUrl(params.get("aisstreamWs"));
50  if (fromUrl) {
51    try { localStorage.setItem("wikiglobe.aisstreamWs", fromUrl); } catch { /* private mode */ }
52    return fromUrl;
53  }
54  let stored = null;
55  try {
56    stored = localStorage.getItem("wikiglobe.aisstreamWs");
57  } catch { /* private mode */ }
58  return (
59    cleanUrl(window.WIKI_GLOBE_AISSTREAM_WS) ||
60    cleanUrl(stored) ||
61    DEFAULT_AISSTREAM_WS
62  );
63}
64
65function cleanUrl(value) {
66  return typeof value === "string" && value.trim() ? value.trim() : null;
67}
68
69function tryAisstream(url, key, cb) {
70  return new Promise((resolve) => {
71    let settled = false;
72    let stopped = false;
73    let ws = null;
74    let usableMessages = 0;
75
76    const settle = (value) => {
77      if (!settled) { settled = true; resolve(value); }
78    };
79
80    const connect = () => {
81      if (stopped) return;
82      try {
83        ws = new WebSocket(url);
84      } catch {
85        settle(null);
86        return;
87      }
88      ws.onopen = () => {
89        const payload = {
90          BoundingBoxes: [[[-90, -180], [90, 180]]],
91          FilterMessageTypes: ["PositionReport", "ShipStaticData"],
92        };
93        if (key) payload.APIKey = key;
94        ws.send(JSON.stringify(payload));
95      };
96      ws.onmessage = (ev) => {
97        try {
98          if (handleAisstream(JSON.parse(ev.data), cb)) {
99            usableMessages++;
100            settle({
101              kind: "aisstream",
102              detail: url === DEFAULT_AISSTREAM_WS
103                ? "aisstream.io global AIS"
104                : "AIS proxy global feed",
105              stop,
106            });
107          }
108        } catch { /* skip bad frame */ }
109      };
110      ws.onerror = () => settle(null);
111      ws.onclose = () => {
112        if (!settled) settle(null);
113        else if (!stopped) setTimeout(connect, 10000); // reconnect after drop
114      };
115    };
116
117    const stop = () => {
118      stopped = true;
119      try { ws?.close(); } catch { /* already closed */ }
120    };
121
122    // bad keys often produce a silent connection that never sends data
123    setTimeout(() => {
124      if (!settled || usableMessages === 0) {
125        stop();
126        settle(null);
127      }
128    }, 15000);
129    connect();
130  });
131}
132
133function handleAisstream(msg, cb) {
134  const meta = msg.MetaData;
135  if (!meta?.MMSI) return false;
136  if (msg.MessageType === "PositionReport") {
137    const pr = msg.Message?.PositionReport;
138    if (!pr || pr.Latitude == null) return false;
139    const heading = pr.TrueHeading != null && pr.TrueHeading !== 511 ? pr.TrueHeading : pr.Cog;
140    cb.onPosition({
141      mmsi: meta.MMSI,
142      lat: pr.Latitude,
143      lon: pr.Longitude,
144      sogKn: pr.Sog ?? 0,
145      cogDeg: pr.Cog ?? 0,
146      headingDeg: heading ?? 0,
147      ts: Date.now(),
148      name: (meta.ShipName ?? "").trim() || undefined,
149    });
150    return true;
151  } else if (msg.MessageType === "ShipStaticData") {
152    const sd = msg.Message?.ShipStaticData;
153    if (!sd) return false;
154    cb.onStatic({
155      mmsi: meta.MMSI,
156      name: (sd.Name ?? meta.ShipName ?? "").trim() || undefined,
157      typeCode: sd.Type,
158      destination: (sd.Destination ?? "").trim() || undefined,
159    });
160    return true;
161  }
162  return false;
163}
164
165// --- Digitraffic Finland (Baltic, REST polling) ------------------------------
166
167async function tryDigitraffic(cb) {
168  const initial = await fetchJson(DIGITRAFFIC_LOCATIONS, 20000);
169  if (!initial?.features?.length) return null;
170
171  const applyMeta = (list) => {
172    for (const v of list ?? []) {
173      cb.onStatic({
174        mmsi: v.mmsi,
175        name: (v.name ?? "").trim() || undefined,
176        typeCode: v.shipType,
177        destination: (v.destination ?? "").trim() || undefined,
178      });
179    }
180  };
181
182  const applyLocations = (data) => {
183    const cutoff = Date.now() - FRESH_MS;
184    for (const f of data.features) {
185      const p = f.properties;
186      if (!p || p.timestampExternal < cutoff) continue;
187      const [lon, lat] = f.geometry.coordinates;
188      cb.onPosition({
189        mmsi: p.mmsi,
190        lat, lon,
191        sogKn: p.sog != null && p.sog < 102.3 ? p.sog : 0, // 102.3 = unavailable
192        cogDeg: p.cog ?? 0,
193        headingDeg: p.heading != null && p.heading !== 511 ? p.heading : (p.cog ?? 0),
194        ts: p.timestampExternal,
195      });
196    }
197  };
198
199  // metadata first so names/destinations are ready when positions land
200  applyMeta(await fetchJson(DIGITRAFFIC_VESSELS, 20000));
201  applyLocations(initial);
202
203  const locTimer = setInterval(async () => {
204    const d = await fetchJson(DIGITRAFFIC_LOCATIONS, 30000);
205    if (d?.features) applyLocations(d);
206  }, LOCATION_POLL_MS);
207  const metaTimer = setInterval(async () => {
208    applyMeta(await fetchJson(DIGITRAFFIC_VESSELS, 30000));
209  }, META_POLL_MS);
210
211  return {
212    kind: "digitraffic",
213    detail: "Digitraffic Finland AIS (Baltic/regional)",
214    stop() { clearInterval(locTimer); clearInterval(metaTimer); },
215  };
216}
217
218async function fetchJson(url, timeoutMs) {
219  try {
220    const ctl = new AbortController();
221    const t = setTimeout(() => ctl.abort(), timeoutMs);
222    const res = await fetch(url, { signal: ctl.signal });
223    clearTimeout(t);
224    if (!res.ok) return null;
225    return await res.json();
226  } catch {
227    return null;
228  }
229}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.