PageSourceSearch

https://wikiglo.be/js/layers/shipping.js

js wikiglo.be collected 2026-09-28 07:09:32 UTC 17,457 bytes, 480 lines download raw bytes

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.