Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 41 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ This proof-of-concept tracker monitors vessels like **ZHONG DA 79** - a Chinese
- **Multi-source AIS** - Support for AISStream, AISHub, and Marinesia APIs
- **Ship markers with heading** - Vessel icons rotate based on course
- **Vessel-type color coding** - Different colors for cargo, tanker, passenger, military, etc.
- **Track with timestamps** - Click vessel to see historical track with time markers
- Green marker = track start, Red marker = latest position
- Hover for timestamp, speed, and course at each point
- **Viewport-optimized rendering** - Handles 10,000+ live vessels without browser lag
- **SQLite WAL mode** - Better concurrent database access

Expand Down Expand Up @@ -510,12 +513,23 @@ Situational awareness for undersea cable and pipeline incidents, with anchor dra

### Baltic Sea Infrastructure

| Infrastructure | Type | Route | Protection Zone |
|----------------|------|-------|-----------------|
| C-Lion1 | Telecom Cable | Helsinki-Rostock | 5 nm |
| Estlink-2 | Power Cable | Estonia-Finland | 3 nm |
| Estlink-1 | Power Cable | Estonia-Finland | 3 nm |
| Balticconnector | Gas Pipeline | Estonia-Finland | 5 nm |
**33 infrastructure assets** loaded from `data/infrastructure.json`:

| Type | Count | Examples |
|------|-------|----------|
| Telecom/Fiber Cables | 12 | C-Lion1, BCS East-West Interlink, EESF-1/2, Eastern Light |
| Power Cables (HVDC) | 10 | Estlink-1/2, NordBalt, SwePol, Fenno-Skan 1/2 |
| Gas Pipelines | 4 | Balticconnector, Nord Stream 1/2, Europipe II |
| Offshore Wind Farms | 7 | Kriegers Flak, Arkona, Wikinger |

**Recent Incidents Tracked:**
| Infrastructure | Date | Vessel | Status |
|----------------|------|--------|--------|
| C-Lion1 | Dec 31, 2025 | Under investigation | Damaged |
| Estlink-2 | Dec 25, 2025 | Eagle S | Damaged |
| EESF-1 | Dec 25, 2025 | Eagle S | Damaged |
| BCS East-West Interlink | Nov 17, 2024 | Yi Peng 3 | Damaged |
| Balticconnector | Oct 8, 2023 | Newnew Polar Bear | Damaged |

### Detection Capabilities

Expand Down Expand Up @@ -546,8 +560,13 @@ Situational awareness for undersea cable and pipeline incidents, with anchor dra

- **Infrastructure overlay** - Colored lines for cables/pipelines with labels
- **Protection zones** - Dashed circles showing exclusion areas
- **Toggle button** - Enable/disable infrastructure layer
- **Toggle button** - Enable/disable infrastructure layer (blue cable icon)
- **Legend panel** - Color key in bottom-left corner when layer active
- 🟣 Purple = Telecom/Fiber cables
- 🟡 Yellow = Power cables (HVDC)
- 🟠 Orange (dashed) = Gas pipelines
- **Click for details** - Popup with operator, capacity, incident notes
- **Track timestamps** - Hover on track markers to see position time, speed, course

### Usage Example

Expand Down Expand Up @@ -781,19 +800,32 @@ Free API integration for vessel event data - AIS gaps, encounters, loitering, po

1. Register at https://globalfishingwatch.org/our-apis/ (free)
2. Get API token
3. Configure via API:
3. Configure via one of these methods:

**Option A: UI Entry (Recommended)**
- Click any vessel in the watchlist
- Scroll to "Global Fishing Watch" section
- Paste your token and click Save
- Reload the page

**Option B: API Configuration**
```bash
curl -X POST http://localhost:8080/api/gfw/configure \
-H "Content-Type: application/json" \
-d '{"token": "YOUR_GFW_TOKEN"}'
```

Or set environment variable:
**Option C: Environment Variable**
```bash
export GFW_API_TOKEN=your_token_here
```

**Option D: Config File**
Create `gfw_config.json`:
```json
{"api_token": "your_token_here"}
```

### API Endpoints

| Method | Endpoint | Description |
Expand Down
32 changes: 32 additions & 0 deletions gfw_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -671,11 +671,43 @@ def get_gfw_client() -> GFWClient:
return _client


def reload_token() -> str:
"""Reload GFW token from config file."""
global GFW_TOKEN
token = os.environ.get("GFW_API_TOKEN", "")
if not token and os.path.exists(CONFIG_PATH):
try:
with open(CONFIG_PATH) as f:
config = json.load(f)
token = config.get('api_token', '')
except:
pass
GFW_TOKEN = token
return token


def is_configured() -> bool:
"""Check if GFW API token is configured."""
global GFW_TOKEN
# Reload from file if not set (in case config was updated)
if not GFW_TOKEN:
reload_token()
return bool(GFW_TOKEN)


def save_token(token: str) -> bool:
"""Save GFW API token to config file."""
global GFW_TOKEN
try:
with open(CONFIG_PATH, 'w') as f:
json.dump({'api_token': token}, f)
GFW_TOKEN = token
return True
except Exception as e:
print(f"Error saving GFW token: {e}")
return False


def search_vessel(query: str = None, mmsi: str = None,
imo: str = None, name: str = None) -> dict:
"""Search for vessel identity."""
Expand Down
1 change: 1 addition & 0 deletions server.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@
get_dark_fleet_indicators as gfw_get_dark_fleet_indicators,
check_sts_zone as gfw_check_sts_zone,
save_token as gfw_save_token,
reload_token as gfw_reload_token,
get_sar_detections as gfw_get_sar_detections,
find_dark_vessels as gfw_find_dark_vessels
)
Expand Down
172 changes: 163 additions & 9 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,47 @@
<body>
<div id="map"></div>

<!-- Infrastructure Legend -->
<div id="infra-legend" style="
position: absolute;
bottom: 30px;
left: 10px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
z-index: 1000;
font-size: 11px;
max-width: 200px;
display: none;
">
<div style="padding: 8px 12px; border-bottom: 1px solid #eee; font-weight: 600; display: flex; justify-content: space-between; align-items: center;">
<span>🔌 Infrastructure</span>
<button onclick="toggleInfraLegend()" style="background:none;border:none;cursor:pointer;font-size:14px;color:#666">×</button>
</div>
<div style="padding: 8px 12px;">
<div style="display:flex;align-items:center;margin-bottom:6px">
<div style="width:20px;height:3px;background:#9b59b6;margin-right:8px"></div>
<span>Telecom/Fiber Cable</span>
</div>
<div style="display:flex;align-items:center;margin-bottom:6px">
<div style="width:20px;height:3px;background:#f1c40f;margin-right:8px"></div>
<span>Power Cable (HVDC)</span>
</div>
<div style="display:flex;align-items:center;margin-bottom:6px">
<div style="width:20px;height:3px;background:#e67e22;margin-right:8px;border-style:dashed"></div>
<span>Gas Pipeline</span>
</div>
<div style="display:flex;align-items:center;margin-bottom:6px">
<div style="width:12px;height:12px;border-radius:50%;background:#f1c40f33;border:1px dashed #f1c40f;margin-right:8px"></div>
<span>Protection Zone</span>
</div>
<div style="margin-top:8px;padding-top:8px;border-top:1px solid #eee;color:#666;font-size:10px">
<span id="infra-count">0</span> assets loaded<br>
Click cable for details
</div>
</div>
</div>

<div class="top-bar">
<div class="logo">
<div class="logo-icon">A</div>
Expand Down Expand Up @@ -1628,12 +1669,60 @@
api(`/osint?vessel_id=${id}`)
]);

// Draw track
if (trackLine) map.removeLayer(trackLine);
// Draw track with timestamp markers
if (trackLine) {
if (Array.isArray(trackLine)) {
trackLine.forEach(layer => map.removeLayer(layer));
} else {
map.removeLayer(trackLine);
}
}
if (track?.length > 1) {
trackLine = L.polyline(track.map(p => [p.latitude, p.longitude]), {
color: getThreatColor(v.threat_level), weight: 2, opacity: 0.7, dashArray: '6'
const trackColor = getThreatColor(v.threat_level);
const line = L.polyline(track.map(p => [p.latitude, p.longitude]), {
color: trackColor, weight: 2, opacity: 0.7, dashArray: '6'
}).addTo(map);

// Add timestamp markers along the track
const markers = [];
const markerInterval = Math.max(1, Math.floor(track.length / 10)); // ~10 markers max

track.forEach((p, idx) => {
// Show markers at regular intervals, plus first and last
const isEndpoint = idx === 0 || idx === track.length - 1;
const isInterval = idx % markerInterval === 0;

if (isEndpoint || isInterval) {
const ts = p.timestamp ? new Date(p.timestamp) : null;
const timeStr = ts ? ts.toLocaleString('en-GB', {
day: '2-digit', month: 'short', year: '2-digit',
hour: '2-digit', minute: '2-digit'
}) : 'Unknown';

const size = isEndpoint ? 8 : 5;
const marker = L.circleMarker([p.latitude, p.longitude], {
radius: size,
color: trackColor,
fillColor: isEndpoint ? (idx === 0 ? '#27ae60' : '#e74c3c') : trackColor,
fillOpacity: 0.9,
weight: 1
}).addTo(map);

const label = idx === 0 ? 'START' : (idx === track.length - 1 ? 'LATEST' : '');
marker.bindTooltip(`
<div style="font-size:11px;white-space:nowrap">
${label ? `<strong>${label}</strong><br>` : ''}
<strong>${timeStr}</strong><br>
${p.speed_knots ? `Speed: ${p.speed_knots.toFixed(1)} kts<br>` : ''}
${p.course ? `Course: ${p.course.toFixed(0)}°` : ''}
</div>
`, { permanent: false, direction: 'top' });

markers.push(marker);
}
});

trackLine = [line, ...markers];
}

renderVesselPanel(v, events, osint);
Expand Down Expand Up @@ -1868,6 +1957,15 @@
function closeVesselPanel() {
document.getElementById('vessel-panel').classList.remove('active');
selectedVessel = null;
// Clear track line and markers
if (trackLine) {
if (Array.isArray(trackLine)) {
trackLine.forEach(layer => map.removeLayer(layer));
} else {
map.removeLayer(trackLine);
}
trackLine = null;
}
}

// Search
Expand Down Expand Up @@ -3268,6 +3366,15 @@
balticInfrastructure = result.infrastructure || [];
renderInfrastructure();
console.log(`Loaded ${balticInfrastructure.length} infrastructure assets`);

// Update legend count and show it
const countEl = document.getElementById('infra-count');
if (countEl) countEl.textContent = balticInfrastructure.length;

const legend = document.getElementById('infra-legend');
if (legend && showInfrastructure && balticInfrastructure.length > 0) {
legend.style.display = 'block';
}
} catch(e) {
console.log('Infrastructure data not available:', e);
}
Expand Down Expand Up @@ -3372,12 +3479,23 @@ <h4 style="margin:0 0 8px 0;color:${color}">${asset.name}</h4>
function toggleInfrastructure() {
showInfrastructure = !showInfrastructure;
const btn = document.getElementById('toggle-infrastructure');
const legend = document.getElementById('infra-legend');
if (btn) {
btn.classList.toggle('active', showInfrastructure);
}
if (legend) {
legend.style.display = showInfrastructure ? 'block' : 'none';
}
renderInfrastructure();
}

function toggleInfraLegend() {
const legend = document.getElementById('infra-legend');
if (legend) {
legend.style.display = legend.style.display === 'none' ? 'block' : 'none';
}
}

async function loadInfraAnalysis(vesselId) {
const display = document.getElementById('infra-analysis-display');
if (!display) return;
Expand Down Expand Up @@ -3602,12 +3720,18 @@ <h4 style="margin:0 0 8px 0;color:${color}">${asset.name}</h4>
const status = await api('/gfw/status');
if (!status.configured) {
display.innerHTML = `
<div style="text-align:center;padding:12px">
<div style="padding:8px">
<div style="font-size:11px;color:#666;margin-bottom:8px">GFW API not configured</div>
<a href="https://globalfishingwatch.org/our-apis/" target="_blank"
style="font-size:10px;color:#1565c0;text-decoration:underline">
Get free API token
</a>
<input type="text" id="gfw-token-input" placeholder="Paste API token here..."
style="width:100%;padding:6px;font-size:11px;border:1px solid #ddd;border-radius:4px;margin-bottom:8px;box-sizing:border-box">
<div style="display:flex;gap:8px;align-items:center">
<button onclick="saveGFWToken()" style="padding:4px 12px;font-size:10px;background:#1565c0;color:white;border:none;border-radius:4px;cursor:pointer">Save</button>
<a href="https://globalfishingwatch.org/our-apis/" target="_blank"
style="font-size:10px;color:#1565c0;text-decoration:underline">
Get free token
</a>
</div>
<div id="gfw-save-status" style="font-size:10px;margin-top:6px"></div>
</div>
`;
return;
Expand All @@ -3620,6 +3744,36 @@ <h4 style="margin:0 0 8px 0;color:${color}">${asset.name}</h4>
}
}

async function saveGFWToken() {
const input = document.getElementById('gfw-token-input');
const status = document.getElementById('gfw-save-status');
const token = input?.value?.trim();

if (!token) {
status.innerHTML = '<span style="color:#e74c3c">Please enter a token</span>';
return;
}

status.innerHTML = '<span style="color:#666">Saving...</span>';

try {
const result = await fetch('/api/gfw/configure', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({token: token})
}).then(r => r.json());

if (result.success) {
status.innerHTML = '<span style="color:#27ae60">✓ Token saved! Reload the page to use GFW.</span>';
input.style.borderColor = '#27ae60';
} else {
status.innerHTML = `<span style="color:#e74c3c">${result.error || 'Failed to save'}</span>`;
}
} catch(e) {
status.innerHTML = '<span style="color:#e74c3c">Error saving token</span>';
}
}

function renderGFWData(data) {
const display = document.getElementById('gfw-display');
if (!display) return;
Expand Down