1// Shipping layer: live AIS vessel positions (aisstream.io or Digitraffic, 2// see ais.js) with dead reckoning between reports, falling back to simulated 3// vessels when no live feed is reachable. The curated lane arcs remain as a 4// background reference layer either way. 5// The routes toggle draws each vessel's path to its reported destination 6// (AIS destination text resolved against a port gazetteer). 7 8import { getShippingLanes, loadShippingLaneData } from "../shipping-lanes.js"; 9import { vesselName } from "../demo-data.js"; 10import { createLiveAis } from "../ais.js"; 11import { flagFromMmsi, shipTypeName, resolveDestination, loadMaritimeReferenceData } from "../ais-data.js"; 12 13const LANE_HEIGHT = 1500; // metres â keeps segments clear of the ellipsoid 14const VESSEL_HEIGHT = 1200; 15const SAMPLE_KM = 120; // lane densification step 16const PULSE_SPEED_KMS = 120; // visual flow speed of lane pulses 17const SIM_SPEED_KMS = 5; // ~16 kn sped up 600x so motion is perceptible 18 19const MAX_LIVE_VESSELS = 4000; 20const AIS_RETRY_MS = 5 * 60 * 1000; // re-try live feeds while simulating 21const STALE_MS = 20 * 60 * 1000; 22const UPDATE_SLICES = 30; // frames per dead-reckoning sweep 23const MAX_ROUTES = 400; 24const ROUTE_REBUILD_MS = 90 * 1000; 25const ROUTES_PER_FRAME = 10; 26 27const LANE_COLOR = Cesium.Color.fromCssColorString("#3fd9ff").withAlpha(0.4); 28const LANE_COLOR_DIM = Cesium.Color.fromCssColorString("#3fd9ff").withAlpha(0.16); 29const POLAR_COLOR = Cesium.Color.fromCssColorString("#9fd4ff").withAlpha(0.42); 30const POLAR_COLOR_DIM = Cesium.Color.fromCssColorString("#9fd4ff").withAlpha(0.18); 31const PULSE_COLOR = Cesium.Color.fromCssColorString("#bdf3ff"); 32const VESSEL_COLOR = Cesium.Color.fromCssColorString("#7cfc9a"); 33const ROUTE_COLOR = Cesium.Color.fromCssColorString("#ffac4d").withAlpha(0.55); 34 35export class ShippingLayer { 36 constructor(viewer) { 37 this.viewer = viewer; 38 this.lines = viewer.scene.primitives.add(new Cesium.PolylineCollection()); 39 this.routeLines = viewer.scene.primitives.add(new Cesium.PolylineCollection()); 40 this.pulsePoints = viewer.scene.primitives.add(new Cesium.PointPrimitiveCollection()); 41 this.vesselPoints = viewer.scene.primitives.add(new Cesium.PointPrimitiveCollection()); 42 this.lanes = []; 43 this.pulses = []; 44 this.vessels = new Map(); // live vessels by MMSI 45 this.meta = new Map(); // static AIS data by MMSI (may precede position) 46 this.simVessels = []; 47 this.list = []; // iteration cache over live vessels 48 this.listDirty = false; 49 this.cursor = 0; 50 this.visible = true; 51 this.routesVisible = false; 52 this.routeQueue = []; 53 this.lastRouteBuild = 0; 54 this.lastSweep = 0; 55 this.lastTick = 0; 56 this.mode = "sim"; 57 this.source = "loading"; 58 this.statusDetail = "Loading live AIS"; 59 } 60 61 async init() { 62 await Promise.all([loadShippingLaneData(), loadMaritimeReferenceData()]); 63 this._buildLanes(LANE_COLOR, POLAR_COLOR); 64 65 this._aisCallbacks = { 66 onPosition: (u) => this._upsertLive(u), 67 onStatic: (u) => this._mergeStatic(u), 68 }; 69 this.live = await createLiveAis(this._aisCallbacks); 70 71 if (this.live) { 72 this._enterLiveMode(); 73 } else { 74 console.warn("[shipping] no live AIS feed reachable, using simulated vessels"); 75 this.mode = "sim"; 76 this.source = "demo"; 77 this.statusDetail = "No live AIS feed reachable; using simulated vessels"; 78 this._buildSimVessels(); 79 this.retryTimer = setInterval(() => this._retryLive(), AIS_RETRY_MS); 80 } 81 } 82 83 _enterLiveMode() { 84 this.mode = "live"; 85 this.source = "live"; 86 this.statusDetail = this.live?.detail ?? "Live AIS"; 87 // live vessels are the foreground now; mute the reference lanes 88 this.lines.removeAll(); 89 this._buildLanes(LANE_COLOR_DIM, POLAR_COLOR_DIM); 90 } 91 92 // Periodic re-attempt while simulating, so a session that started offline 93 // (or before the user pasted an AIS key) can still promote to live data. 94 async _retryLive() { 95 if (this._retryInFlight || this.live) return; 96 this._retryInFlight = true; 97 try { 98 const live = await createLiveAis(this._aisCallbacks); 99 if (!live) return; 100 this.live = live; 101 clearInterval(this.retryTimer); 102 this.retryTimer = null; 103 // drop the simulated fleet one point at a time â live positions may 104 // already be streaming into the same collection 105 for (const v of this.simVessels) this.vesselPoints.remove(v.point); 106 this.simVessels = []; 107 this.routeQueue = []; 108 this.routeLines.removeAll(); 109 this.lastRouteBuild = 0; 110 this._enterLiveMode(); 111 console.info("[shipping] live AIS feed recovered, simulated vessels retired"); 112 } finally { 113 this._retryInFlight = false;
114 } 115 } 116 117 _buildLanes(color, polarColor) { 118 const firstBuild = this.lanes.length === 0; 119 if (firstBuild) { 120 for (const def of getShippingLanes()) this.lanes.push(densifyLane(def)); 121 } 122 // materials are shared per style so the collection batches the network 123 // into a handful of draw calls instead of one per lane 124 const glow = (c) => Cesium.Material.fromType("PolylineGlow", { 125 color: c, glowPower: 0.22, taperPower: 1.0, 126 }); 127 const mats = { 128 "major": glow(color), 129 "middle": glow(color.withAlpha(color.alpha * 0.55)), 130 "polar-major": glow(polarColor), 131 "polar-middle": glow(polarColor.withAlpha(polarColor.alpha * 0.55)), 132 }; 133 for (const lane of this.lanes) { 134 const middle = lane.type === "middle"; 135 this.lines.add({ 136 positions: lane.positions, 137 width: middle ? 2.6 : 4.5, 138 material: mats[`${lane.polar ? "polar-" : ""}${middle ? "middle" : "major"}`], 139 id: { kind: "lane", lane }, 140 }); 141 // pulses ride the major corridors only â the middle tier would add 142 // hundreds of per-frame point updates for little visual gain 143 if (firstBuild && !middle) { 144 const pulseCount = clamp(Math.round(lane.lengthKm / 2500), 2, 8); 145 for (let i = 0; i < pulseCount; i++) { 146 this.pulses.push({ 147 lane, 148 frac: i / pulseCount, 149 point: this.pulsePoints.add({ 150 position: lane.positions[0], 151 pixelSize: 5, 152 color: PULSE_COLOR, 153 scaleByDistance: new Cesium.NearFarScalar(2.0e5, 1.4, 4.0e7, 0.55), 154 id: { kind: "lane", lane }, 155 }), 156 }); 157 } 158 } 159 } 160 } 161 162 // --- live vessels ---------------------------------------------------------- 163 164 _upsertLive(u) { 165 let v = this.vessels.get(u.mmsi); 166 if (!v) { 167 if (this.vessels.size >= MAX_LIVE_VESSELS) return; 168 v = { 169 kind: "vessel", 170 live: true, 171 mmsi: u.mmsi, 172 flag: flagFromMmsi(u.mmsi), 173 typeName: shipTypeName(null), 174 }; 175 const m = this.meta.get(u.mmsi); 176 if (m) this._applyMeta(v, m); 177 v.point = this.vesselPoints.add({ 178 position: Cesium.Cartesian3.fromDegrees(u.lon, u.lat, VESSEL_HEIGHT), 179 pixelSize: 3.5, 180 color: VESSEL_COLOR, 181 outlineColor: Cesium.Color.BLACK.withAlpha(0.6), 182 outlineWidth: 1, 183 scaleByDistance: new Cesium.NearFarScalar(2.0e5, 2.0, 4.0e7, 0.6), 184 id: { kind: "vessel", vessel: v }, 185 }); 186 this.vessels.set(u.mmsi, v); 187 this.listDirty = true; 188 } 189 v.lat = u.lat; 190 v.lon = u.lon; 191 v.sogKn = u.sogKn; 192 v.cogDeg = u.cogDeg; 193 v.headingDeg = u.headingDeg; 194 v.ts = u.ts; 195 if (u.name && !v.name) v.name = u.name; 196 v.point.position = Cesium.Cartesian3.fromDegrees(v.lon, v.lat, VESSEL_HEIGHT); 197 } 198 199 _mergeStatic(u) { 200 this.meta.set(u.mmsi, u); 201 const v = this.vessels.get(u.mmsi); 202 if (v) this._applyMeta(v, u); 203 } 204 205 _applyMeta(v, m) { 206 if (m.name) v.name = m.name; 207 if (m.typeCode != null) v.typeName = shipTypeName(m.typeCode); 208 if (m.destination !== undefined) v.destination = m.destination; 209 } 210 211 // --- simulated fallback ------------------------------------------------------ 212 213 _buildSimVessels() { 214 let idx = 0; 215 const simTypes = [ 216 [70, "Cargo ship"], [70, "Cargo ship"], [80, "Tanker"], [60, "Passenger ship"], 217 ]; 218 const simFlags = ["Panama", "Liberia", "Marshall Islands", "Hong Kong", 219 "Singapore", "Malta", "Bahamas", "Greece", "Denmark", "Norway"]; 220 for (const lane of this.lanes) { 221 const [endA, endB] = lane.endpoints.split(" â "); 222 const count = clamp(Math.round(lane.lengthKm / 3500), 1, 4); 223 for (let i = 0; i < count; i++) { 224 const dir = i % 2 === 0 ? 1 : -1; 225 const v = { 226 kind: "vessel", 227 live: false, 228 name: vesselName(idx), 229 typeName: simTypes[idx % simTypes.length][1], 230 flag: simFlags[idx % simFlags.length], 231 lane, 232 frac: (i + 0.5) / count, 233 dir, 234 sogKn: 14 + (idx % 5), 235 destination: dir === 1 ? (endB ?? "â") : (endA ?? "â"), 236 endA, endB, 237 }; 238 v.point = this.vesselPoints.add({ 239 position: lane.positions[0], 240 pixelSize: 5, 241 color: VESSEL_COLOR,
242 outlineColor: Cesium.Color.BLACK.withAlpha(0.6), 243 outlineWidth: 1, 244 scaleByDistance: new Cesium.NearFarScalar(2.0e5, 1.6, 4.0e7, 0.7), 245 id: { kind: "vessel", vessel: v }, 246 }); 247 this.simVessels.push(v); 248 idx++; 249 } 250 } 251 } 252 253 // --- per-frame ----------------------------------------------------------------- 254 255 tick(nowMs) { 256 if (!this.visible) return; 257 const dt = this.lastTick ? Math.min((nowMs - this.lastTick) / 1000, 0.5) : 0; 258 this.lastTick = nowMs; 259 if (dt === 0) return; 260 261 for (const p of this.pulses) { 262 p.frac = (p.frac + (PULSE_SPEED_KMS * dt) / p.lane.lengthKm) % 1; 263 p.point.position = positionOnLane(p.lane, p.frac); 264 } 265 266 if (this.mode === "sim") { 267 for (const v of this.simVessels) { 268 v.frac += (v.dir * SIM_SPEED_KMS * dt) / v.lane.lengthKm; 269 if (v.frac >= 1) { v.frac = 1; v.dir = -1; v.destination = v.endA ?? "â"; } 270 else if (v.frac <= 0) { v.frac = 0; v.dir = 1; v.destination = v.endB ?? "â"; } 271 v.point.position = positionOnLane(v.lane, v.frac); 272 v.headingDeg = headingOnLane(v.lane, v.frac, v.dir); 273 } 274 } else { 275 this._tickLive(nowMs); 276 } 277 278 // incremental destination-route construction 279 if (this.routesVisible) { 280 if (nowMs - this.lastRouteBuild > ROUTE_REBUILD_MS) this._queueRoutes(nowMs); 281 let budget = ROUTES_PER_FRAME; 282 while (budget-- > 0 && this.routeQueue.length > 0) { 283 this._buildRoute(this.routeQueue.shift()); 284 } 285 } 286 } 287 288 _tickLive(nowMs) { 289 if (this.listDirty) { 290 this.list = [...this.vessels.values()]; 291 this.listDirty = false; 292 this.cursor = 0; 293 } 294 const n = this.list.length; 295 if (n === 0) return; 296 297 // dead-reckon a slice of vessels from their last report 298 const batch = Math.ceil(n / UPDATE_SLICES); 299 for (let i = 0; i < batch; i++) { 300 const v = this.list[this.cursor]; 301 this.cursor = (this.cursor + 1) % n; 302 if (!this.vessels.has(v.mmsi)) continue; // evicted mid-sweep 303 if ((v.sogKn ?? 0) > 0.3) { 304 const dtSec = Math.min((nowMs - v.ts) / 1000, STALE_MS / 1000); 305 const [lat2, lon2] = deadReckon(v.lat, v.lon, v.sogKn * 0.5144 * dtSec, v.cogDeg); 306 v.point.position = Cesium.Cartesian3.fromDegrees(lon2, lat2, VESSEL_HEIGHT); 307 } 308 } 309 310 // evict vessels that stopped reporting 311 if (nowMs - this.lastSweep > 60 * 1000) { 312 this.lastSweep = nowMs; 313 for (const [mmsi, v] of this.vessels) { 314 if (nowMs - v.ts > STALE_MS) { 315 this.vesselPoints.remove(v.point); 316 this.vessels.delete(mmsi); 317 this.listDirty = true; 318 } 319 } 320 } 321 } 322 323 // --- destination routes ----------------------------------------------------------- 324 325 _queueRoutes(nowMs) { 326 this.routeLines.removeAll(); 327 this.lastRouteBuild = nowMs; 328 if (this.mode === "sim") { 329 this.routeQueue = this.simVessels.slice(); 330 } else { 331 const moving = []; 332 for (const v of this.vessels.values()) { 333 if (!v.destination || (v.sogKn ?? 0) < 0.5) continue; 334 const port = resolveDestination(v.destination); 335 if (!port) continue; 336 v.port = port; 337 moving.push(v); 338 if (moving.length >= MAX_ROUTES) break; 339 } 340 this.routeQueue = moving; 341 } 342 } 343 344 _buildRoute(v) { 345 let positions; 346 if (v.live) { 347 const geodesic = new Cesium.EllipsoidGeodesic( 348 Cesium.Cartographic.fromDegrees(v.lon, v.lat), 349 Cesium.Cartographic.fromDegrees(v.port.lon, v.port.lat) 350 ); 351 const distKm = geodesic.surfaceDistance / 1000; 352 if (distKm < 4) return; 353 const samples = clamp(Math.ceil(distKm / 100), 8, 64); 354 positions = []; 355 for (let i = 0; i <= samples; i++) { 356 const c = geodesic.interpolateUsingFraction(i / samples); 357 positions.push(Cesium.Cartesian3.fromRadians(c.longitude, c.latitude, LANE_HEIGHT)); 358 } 359 } else { 360 // simulated vessels follow their lane to its end 361 const idx = Math.round(v.frac * (v.lane.positions.length - 1)); 362 positions = v.dir === 1 363 ? v.lane.positions.slice(idx) 364 : v.lane.positions.slice(0, idx + 1).reverse(); 365 if (positions.length < 2) return; 366 } 367 this.routeLines.add({ 368 positions, 369 width: 1.6, 370 material: Cesium.Material.fromType("PolylineDash", { color: ROUTE_COLOR, dashLength: 14 }), 371 id: { kind: "vessel", vessel: v }, 372 }); 373 } 374 375 // --- visibility & status --------------------------------------------------------- 376 377 setVisible(v) { 378 this.visible = v; 379 this.lines.show = v; 380 this.pulsePoints.show = v; 381 this.vesselPoints.show = v; 382 this.routeLines.show = v && this.routesVisible; 383 } 384 385 setRoutesVisible(v) { 386 this.routesVisible = v; 387 this.routeLines.show = v && this.visible; 388 if (v) this._queueRoutes(Date.now()); 389 else { this.routeQueue = []; this.routeLines.removeAll(); this.lastRouteBuild = 0; } 390 } 391 392 counts() { 393 if (!this.visible) return { count: 0, detail: this.statusDetail, source: this.source }; 394 const vesselCount = this.mode === "sim" ? this.simVessels.length : this.vessels.size; 395 return { 396 count: vesselCount, 397 detail: `${this.statusDetail} · ${vesselCount} vessels · ${this.lanes.length} reference lanes`, 398 source: this.source, 399 }; 400 } 401} 402 403// --- geometry helpers --------------------------------------------------------------- 404 405function clamp(x, lo, hi) { 406 return Math.max(lo, Math.min(hi, x)); 407} 408 409// Sample a lane's waypoint chain into ~SAMPLE_KM segments along geodesics, 410// precomputing per-point headings for simulated vessel tooltips. 411function densifyLane(def) { 412 const positions = []; 413 const headings = []; 414 const cartos = def.waypoints.map(([lon, lat]) => Cesium.Cartographic.fromDegrees(lon, lat)); 415 let lengthM = 0; 416 let prevCarto = null; 417 for (let i = 0; i < cartos.length - 1; i++) {
418 const geo = new Cesium.EllipsoidGeodesic(cartos[i], cartos[i + 1]); 419 const segM = geo.surfaceDistance; 420 const steps = Math.max(1, Math.ceil(segM / (SAMPLE_KM * 1000))); 421 for (let s = 0; s < steps; s++) { 422 const c = geo.interpolateUsingFraction(s / steps); 423 positions.push(Cesium.Cartesian3.fromRadians(c.longitude, c.latitude, LANE_HEIGHT)); 424 if (prevCarto) headings.push(bearingDeg(prevCarto, c)); 425 prevCarto = c; 426 } 427 lengthM += segM; 428 } 429 const last = cartos[cartos.length - 1]; 430 positions.push(Cesium.Cartesian3.fromRadians(last.longitude, last.latitude, LANE_HEIGHT)); 431 if (prevCarto) headings.push(bearingDeg(prevCarto, last)); 432 headings.push(headings[headings.length - 1] ?? 0); 433 return { 434 name: def.name, 435 polar: def.polar, 436 type: def.type ?? "major", 437 positions, 438 headings, 439 lengthKm: lengthM / 1000, 440 endpoints: def.name.match(/\(([^)]+)\)/)?.[1] ?? "", 441 }; 442} 443 444const scratchLerp = new Cesium.Cartesian3(); 445 446function positionOnLane(lane, frac) { 447 const t = frac * (lane.positions.length - 1); 448 const i = Math.min(Math.floor(t), lane.positions.length - 2); 449 return Cesium.Cartesian3.lerp(lane.positions[i], lane.positions[i + 1], t - i, scratchLerp); 450} 451 452function headingOnLane(lane, frac, dir) { 453 const i = Math.min(Math.round(frac * (lane.positions.length - 1)), lane.headings.length - 1); 454 const h = lane.headings[i]; 455 return dir === 1 ? h : (h + 180) % 360; 456} 457 458function bearingDeg(fromCarto, toCarto) { 459 const dLon = toCarto.longitude - fromCarto.longitude; 460 const y = Math.sin(dLon) * Math.cos(toCarto.latitude); 461 const x = Math.cos(fromCarto.latitude) * Math.sin(toCarto.latitude) - 462 Math.sin(fromCarto.latitude) * Math.cos(toCarto.latitude) * Math.cos(dLon); 463 return (Cesium.Math.toDegrees(Math.atan2(y, x)) + 360) % 360; 464} 465 466function deadReckon(latDeg, lonDeg, distM, trackDeg) { 467 const R = 6371000; 468 const d = distM / R; 469 const brng = (trackDeg * Math.PI) / 180; 470 const la1 = (latDeg * Math.PI) / 180; 471 const lo1 = (lonDeg * Math.PI) / 180; 472 const la2 = Math.asin( 473 Math.sin(la1) * Math.cos(d) + Math.cos(la1) * Math.sin(d) * Math.cos(brng) 474 ); 475 const lo2 = lo1 + Math.atan2( 476 Math.sin(brng) * Math.sin(d) * Math.cos(la1), 477 Math.cos(d) - Math.sin(la1) * Math.sin(la2) 478 ); 479 return [(la2 * 180) / Math.PI, ((((lo2 * 180) / Math.PI) + 540) % 360) - 180]; 480}
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.