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.