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:
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)">
|
||||
|
||||
@@ -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;
|
||||
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; }
|
||||
}
|
||||
if (best && bestDist < 0.5) {
|
||||
var name = best.name || ('MMSI ' + (best.mmsi || '?'));
|
||||
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) + '°</div>',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this._viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(clickLon, clickLat, level === "far" ? 4000000 : 1500000), duration: 1.0 });
|
||||
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], d = Math.abs(s.lat-lat)+Math.abs(s.lon-lon);
|
||||
if (d < bd) { bd = d; best = s; }
|
||||
}
|
||||
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: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) + '°<br>' +
|
||||
'HDG: ' + (best.heading||'?') + '°</div>',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
_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); });
|
||||
},
|
||||
};
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren