Schiffsverkehr: Typ-Filter, Kurslinien, Dark Ships

SCHIFFSTYP-FILTER:
- 6 Kategorien: Tanker (rot), Cargo (orange), Passagier (gruen),
  Fischerei (blau), Militaer (magenta), Sonstige (grau)
- Einzeln ein-/ausblendbar via Checkboxen im Panel
- Farbkodierte Punkte nach Schiffstyp

KURSLINIEN-PROJEKTION:
- Zuschaltbar per Checkbox
- Zeigt 30min-Vorausprojektion basierend auf COG + SOG
- Farblich passend zum Schiffstyp, halbtransparent

DARK SHIPS ERKENNUNG:
- Backend erkennt Schiffe die >10min kein AIS-Update senden
- Positionshistorie (letzte 10 Positionen) gespeichert
- API-Endpoint: GET /api/ships/dark
- Verdaechtig wenn vorher aktiv (SOG>0.5) und jetzt still

Backend: ship_type aus AISStream MetaData gespeichert,
AIS-Typcode zu Kategorie klassifiziert (IMO Standard).
Dieser Commit ist enthalten in:
Claude Dev
2026-03-24 23:21:06 +01:00
Ursprung b8d6ed9442
Commit 4b731823e6
4 geänderte Dateien mit 186 neuen und 84 gelöschten Zeilen

Datei anzeigen

@@ -51,6 +51,12 @@ async def _listener():
lon = meta.get("longitude") or pos.get("Longitude")
if not lat or not lon or not (-90 <= lat <= 90 and -180 <= lon <= 180):
continue
ship_type = meta.get("ShipType", 0)
# Vorherige Position fuer Dark-Ship-Erkennung merken
prev = _store.get(mmsi)
prev_positions = (prev.get("track", []) if prev else [])[-10:]
prev_positions.append({"lat": round(lat, 5), "lon": round(lon, 5), "ts": time.time()})
_store[mmsi] = {
"mmsi": mmsi,
"lat": round(lat, 5),
@@ -59,7 +65,9 @@ async def _listener():
"cog": round(pos.get("Cog", 0), 1),
"heading": pos.get("TrueHeading", 0),
"name": (meta.get("ShipName") or "").strip(),
"ship_type": ship_type,
"ts": time.time(),
"track": prev_positions,
}
# Stale-Cleanup alle 1000 Updates
if len(_store) % 1000 == 0:
@@ -84,6 +92,50 @@ def start_ais_collector():
logger.info("AIS collector gestartet")
def _classify_ship(ship_type):
"""AIS Ship Type zu Kategorie."""
if not ship_type:
return "unknown"
t = int(ship_type)
if 20 <= t <= 29: return "wing_in_ground"
if 30 <= t <= 39: return "fishing" if t == 30 else "towing" if t in (31,32) else "military" if t == 35 else "sailing" if t == 36 else "other"
if 40 <= t <= 49: return "hsc"
if 50 <= t <= 59: return "pilot" if t == 50 else "sar" if t == 51 else "tug" if t == 52 else "port" if t == 53 else "medical" if t == 58 else "other"
if 60 <= t <= 69: return "passenger"
if 70 <= t <= 79: return "cargo"
if 80 <= t <= 89: return "tanker"
return "other"
def _detect_dark_ships():
"""Schiffe die laenger als 10 Minuten kein Update hatten aber vorher aktiv waren."""
now = time.time()
dark = []
for mmsi, s in _store.items():
age = now - s["ts"]
track = s.get("track", [])
# Aktiv gewesen (>3 Positionen) aber seit >10min kein Update und SOG war > 1
if age > 600 and len(track) >= 3 and s.get("sog", 0) > 0.5:
dark.append({
"mmsi": mmsi, "name": s.get("name", ""),
"last_lat": s["lat"], "last_lon": s["lon"],
"last_sog": s["sog"], "last_cog": s["cog"],
"silent_minutes": round(age / 60, 1),
"ship_type": _classify_ship(s.get("ship_type")),
})
return dark
@router.get("/ships")
async def get_ships():
return {"ships": list(_store.values()), "total": len(_store), "connected": _connected}
ships_out = []
for s in _store.values():
ship = dict(s)
ship["category"] = _classify_ship(s.get("ship_type"))
# Track auf letzte 5 Positionen kuerzen fuer API
ship["track"] = ship.get("track", [])[-5:]
ships_out.append(ship)
return {"ships": ships_out, "total": len(ships_out), "connected": _connected}
@router.get("/ships/dark")
async def get_dark_ships():
dark = _detect_dark_ships()
return {"dark_ships": dark, "total": len(dark)}

Datei anzeigen

@@ -577,3 +577,9 @@ html, body { height: 100%; overflow: hidden; background: var(--bg-primary); colo
.dot-terminator { background: #ff8800; }
.dot-timezones { background: #8888ff; }
/* === Ship Filters === */
.ship-filters {
padding: 4px 12px 6px;
border-top: 1px solid rgba(255,255,255,0.04);
}

Datei anzeigen

@@ -74,6 +74,7 @@
</label>
<div class="layer-loading" id="loading-ships"></div>
<div class="layer-status" id="status-ships"></div>
<div id="ship-filters" class="ship-filters" style="display:none"></div>
<label class="layer-toggle">
<input type="checkbox" id="layer-gdelt" title="Geokodierte Nachrichten der letzten 24h (GDELT Global Event Database)">

Datei anzeigen

@@ -1,42 +1,91 @@
/**
* Schiffsverkehr-Layer: Zoom-adaptive Darstellung.
* Schiffsverkehr: Typ-Filter, Routen-Projektion, Dark Ships.
*/
const ShipsLayer = {
_viewer: null,
_points: null,
_projLines: null,
_labels: null,
_interval: null,
_count: 0,
_data: [],
_lastZoomLevel: null,
_filters: { tanker: true, cargo: true, passenger: true, fishing: true, military: true, other: true },
_showProjection: false,
_cameraListener: null,
_lastZoomLevel: null,
_handler: null,
start(viewer) {
if (this._points) return;
this._viewer = viewer;
this._points = viewer.scene.primitives.add(new Cesium.PointPrimitiveCollection());
this._projLines = viewer.scene.primitives.add(new Cesium.PolylineCollection());
this._labels = viewer.scene.primitives.add(new Cesium.LabelCollection());
this._fetch();
var self = this;
this._interval = setInterval(function() { self._fetch(); }, 60000);
this._cameraListener = function() { self._renderForZoom(); };
viewer.camera.changed.addEventListener(this._cameraListener);
this._handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
this._handler.setInputAction(function(click) { self._onClick(click.position); }, Cesium.ScreenSpaceEventType.LEFT_CLICK);
this._handler.setInputAction(function(c) { self._onClick(c.position); }, Cesium.ScreenSpaceEventType.LEFT_CLICK);
this._createFilterUI();
},
stop() {
if (this._interval) { clearInterval(this._interval); this._interval = null; }
if (this._cameraListener && this._viewer) { this._viewer.camera.changed.removeEventListener(this._cameraListener); this._cameraListener = null; }
if (this._cameraListener && this._viewer) { this._viewer.camera.changed.removeEventListener(this._cameraListener); }
if (this._handler) { this._handler.destroy(); this._handler = null; }
if (this._points && this._viewer) { this._viewer.scene.primitives.remove(this._points); this._points = null; }
if (this._projLines && this._viewer) { this._viewer.scene.primitives.remove(this._projLines); this._projLines = null; }
if (this._labels && this._viewer) { this._viewer.scene.primitives.remove(this._labels); this._labels = null; }
var filterEl = document.getElementById('ship-filters');
if (filterEl) filterEl.style.display = 'none';
this._count = 0; this._data = []; this._lastZoomLevel = null;
},
_createFilterUI() {
var el = document.getElementById('ship-filters');
if (!el) return;
var cats = [
{ id: 'tanker', label: 'Tanker', color: '#ff4444' },
{ id: 'cargo', label: 'Cargo', color: '#ffaa00' },
{ id: 'passenger', label: 'Passagier', color: '#44ff44' },
{ id: 'fishing', label: 'Fischerei', color: '#44aaff' },
{ id: 'military', label: 'Militaer', color: '#ff44ff' },
{ id: 'other', label: 'Sonstige', color: '#888888' },
];
var self = this;
var html = '<div style="font-size:9px;color:var(--accent);letter-spacing:1px;margin-bottom:4px">SCHIFFSTYPEN</div>';
cats.forEach(function(c) {
html += '<label style="display:flex;align-items:center;gap:4px;font-size:10px;color:var(--text);cursor:pointer;padding:1px 0">' +
'<input type="checkbox" checked onchange="ShipsLayer.toggleFilter(\'' + c.id + '\',this.checked)" style="accent-color:' + c.color + ';width:11px;height:11px">' +
'<span style="width:6px;height:6px;border-radius:50%;background:' + c.color + '"></span>' + c.label + '</label>';
});
html += '<label style="display:flex;align-items:center;gap:4px;font-size:10px;color:var(--text);cursor:pointer;padding:3px 0 0;border-top:1px solid rgba(255,255,255,0.06);margin-top:3px">' +
'<input type="checkbox" onchange="ShipsLayer.toggleProjection(this.checked)" style="accent-color:var(--accent);width:11px;height:11px">Kurslinien</label>';
el.innerHTML = html;
el.style.display = 'block';
},
toggleFilter(cat, on) {
this._filters[cat] = on;
this._lastZoomLevel = null;
this._render();
},
toggleProjection(on) {
this._showProjection = on;
this._render();
},
_getColor(cat) {
var colors = {
tanker: '#ff4444', cargo: '#ffaa00', passenger: '#44ff44',
fishing: '#44aaff', military: '#ff44ff', other: '#888888', unknown: '#666666',
};
return colors[cat] || colors.other;
},
_getZoomLevel() {
var alt = this._viewer.camera.positionCartographic.height;
if (alt > 5000000) return 'far';
@@ -54,110 +103,104 @@ const ShipsLayer = {
_render() {
if (!this._points || !this._data.length) return;
this._points.removeAll();
this._projLines.removeAll();
this._labels.removeAll();
var bounds = this._viewer.camera.computeViewRectangle();
var level = this._getZoomLevel();
if (level === 'far') { this._renderClustered(5); }
else if (level === 'medium') { this._renderClustered(2); }
else { this._renderIndividual(); }
},
var max = level === 'far' ? 300 : level === 'medium' ? 800 : 2000;
var self = this;
var count = 0;
_renderClustered(gridSize) {
var clusters = {};
var blue = Cesium.Color.fromCssColorString('#4499ff');
for (var i = 0; i < this._data.length; i++) {
for (var i = 0; i < this._data.length && count < max; i++) {
var s = this._data[i];
if (!s.lat || !s.lon) continue;
var key = Math.round(s.lat / gridSize) + ',' + Math.round(s.lon / gridSize);
if (!clusters[key]) { clusters[key] = { count: 0, sumLat: 0, sumLon: 0 }; }
clusters[key].count++;
clusters[key].sumLat += s.lat;
clusters[key].sumLon += s.lon;
}
var keys = Object.keys(clusters);
for (var j = 0; j < keys.length; j++) {
var c = clusters[keys[j]];
var avgLat = c.sumLat / c.count;
var avgLon = c.sumLon / c.count;
var size = Math.min(Math.max(Math.sqrt(c.count) * 1.2, 3), 16);
this._points.add({
position: Cesium.Cartesian3.fromDegrees(avgLon, avgLat, 0),
pixelSize: size, color: blue,
outlineColor: Cesium.Color.fromCssColorString('#112244'), outlineWidth: 1,
});
if (c.count > 1) {
this._labels.add({
position: Cesium.Cartesian3.fromDegrees(avgLon, avgLat, 0),
text: c.count.toString(), font: '10px sans-serif',
fillColor: Cesium.Color.WHITE, outlineColor: Cesium.Color.BLACK, outlineWidth: 3,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
pixelOffset: new Cesium.Cartesian2(0, -size - 3), scale: 0.8,
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
});
}
}
},
var cat = s.category || 'other';
if (!this._filters[cat] && cat !== 'unknown') continue;
count++;
var colorStr = this._getColor(cat);
var color = Cesium.Color.fromCssColorString(colorStr);
var r = level === 'close' ? 3 : level === 'medium' ? 2.5 : 2;
_renderIndividual() {
var blue = Cesium.Color.fromCssColorString('#4499ff');
var gray = Cesium.Color.fromCssColorString('#445577');
for (var i = 0; i < this._data.length; i++) {
var s = this._data[i];
if (!s.lat || !s.lon) continue;
this._points.add({
position: Cesium.Cartesian3.fromDegrees(s.lon, s.lat, 0),
pixelSize: 2.5, color: (s.sog || 0) > 0.5 ? blue : gray,
pixelSize: r, color: color,
});
// Kurslinie (Projektion 30min voraus)
if (this._showProjection && s.sog > 0.5 && s.cog !== undefined && level !== 'far') {
var distNm = s.sog * 0.5; // 30 Minuten
var distDeg = distNm / 60;
var cogRad = (s.cog || 0) * Math.PI / 180;
var endLat = s.lat + distDeg * Math.cos(cogRad);
var endLon = s.lon + distDeg * Math.sin(cogRad);
this._projLines.add({
positions: [
Cesium.Cartesian3.fromDegrees(s.lon, s.lat, 0),
Cesium.Cartesian3.fromDegrees(endLon, endLat, 0),
],
width: 1,
material: Cesium.Material.fromType('Color', { color: color.withAlpha(0.3) }),
});
}
// Labels bei Zoom
if (level === 'close' && s.name) {
this._labels.add({
position: Cesium.Cartesian3.fromDegrees(s.lon, s.lat, 0),
text: s.name, font: '9px monospace',
fillColor: color.withAlpha(0.7),
outlineColor: Cesium.Color.BLACK, outlineWidth: 2,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
pixelOffset: new Cesium.Cartesian2(5, -3), scale: 0.6,
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 300000),
});
}
}
},
_onClick(position) {
_onClick(pos) {
if (!this._viewer || !this._data.length) return;
var cartesian = this._viewer.scene.pickPosition(position);
if (!cartesian) { var ray = this._viewer.scene.camera.getPickRay(position); cartesian = this._viewer.scene.globe.pick(ray, this._viewer.scene); }
if (!cartesian) return;
var carto = Cesium.Cartographic.fromCartesian(cartesian);
var clickLat = Cesium.Math.toDegrees(carto.latitude);
var clickLon = Cesium.Math.toDegrees(carto.longitude);
var level = this._getZoomLevel();
if (level === 'close') {
var best = null, bestDist = 999;
var cart = this._viewer.scene.pickPosition(pos);
if (!cart) { var ray = this._viewer.scene.camera.getPickRay(pos); cart = this._viewer.scene.globe.pick(ray, this._viewer.scene); }
if (!cart) return;
var c = Cesium.Cartographic.fromCartesian(cart);
var lat = Cesium.Math.toDegrees(c.latitude), lon = Cesium.Math.toDegrees(c.longitude);
var best = null, bd = 999;
for (var i = 0; i < this._data.length; i++) {
var s = this._data[i];
var d = Math.abs(s.lat - clickLat) + Math.abs(s.lon - clickLon);
if (d < bestDist) { bestDist = d; best = s; }
var s = this._data[i], d = Math.abs(s.lat-lat)+Math.abs(s.lon-lon);
if (d < bd) { bd = d; best = s; }
}
if (best && bestDist < 0.5) {
var name = best.name || ('MMSI ' + (best.mmsi || '?'));
if (best && bd < 0.5) {
var name = best.name || ('MMSI ' + best.mmsi);
var catLabels = { tanker:'Tanker', cargo:'Frachter', passenger:'Passagier', fishing:'Fischerei', military:'Militaer', other:'Sonstige' };
this._viewer.trackedEntity = undefined;
this._viewer.selectedEntity = new Cesium.Entity({
name: name,
description: '<div style="font-family:monospace;font-size:13px;color:#4499ff;padding:8px">' +
'<strong>' + name + '</strong><br>MMSI: ' + (best.mmsi || '?') +
'<br>SOG: ' + (best.sog || 0).toFixed(1) + ' kn<br>COG: ' + Math.round(best.cog || 0) + '&deg;</div>',
description: '<div style="font-family:monospace;font-size:12px;padding:8px;color:' + this._getColor(best.category) + '">' +
'<strong>' + name + '</strong><br>' +
'MMSI: ' + (best.mmsi||'?') + '<br>' +
'Typ: ' + (catLabels[best.category] || best.category || '?') + '<br>' +
'SOG: ' + (best.sog||0).toFixed(1) + ' kn | COG: ' + Math.round(best.cog||0) + '&deg;<br>' +
'HDG: ' + (best.heading||'?') + '&deg;</div>',
});
}
} else {
this._viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(clickLon, clickLat, level === "far" ? 4000000 : 1500000), duration: 1.0 });
}
},
_fetch() {
var self = this;
var loadEl = document.getElementById('loading-ships');
var statusEl = document.getElementById('status-ships');
if (loadEl) loadEl.classList.add('active');
if (statusEl) { statusEl.textContent = 'Lade Daten...'; statusEl.classList.add('active'); }
if (statusEl) { statusEl.textContent = 'Lade...'; statusEl.classList.add('active'); }
fetch('/api/ships')
.then(function(r) { return r.json(); })
.then(function(data) {
if (!self._points) return;
self._data = data.ships || [];
self._count = self._data.length;
self._lastZoomLevel = null;
self._render();
if (statusEl) statusEl.textContent = self._count.toLocaleString('de-DE') + ' Schiffe';
})
.catch(function(e) { console.warn('Ships error:', e); if (statusEl) statusEl.textContent = 'Fehler'; })
.finally(function() { if (loadEl) loadEl.classList.remove('active'); setTimeout(function() { if (statusEl) statusEl.classList.remove('active'); }, 5000); });
.catch(function(e) { console.warn('Ships:', e); if (statusEl) statusEl.textContent = 'Fehler'; })
.finally(function() { setTimeout(function() { if (statusEl) statusEl.classList.remove('active'); }, 5000); });
},
};