1209 lines
52 KiB
HTML
1209 lines
52 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||
<title>SeaFare Montana</title>
|
||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
|
||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||
<script src="https://unpkg.com/supercluster@8.0.1/dist/supercluster.min.js"></script>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
:root {
|
||
--bg: var(--tg-theme-bg-color, #0a0e17);
|
||
--bg2: var(--tg-theme-secondary-bg-color, #111827);
|
||
--text: var(--tg-theme-text-color, #e0e6ed);
|
||
--hint: var(--tg-theme-hint-color, #7b8a9e);
|
||
--link: var(--tg-theme-link-color, #5eaaef);
|
||
--btn: var(--tg-theme-button-color, #00bfff);
|
||
--btn-text: var(--tg-theme-button-text-color, #ffffff);
|
||
--accent: #00bfff;
|
||
--card: rgba(17, 24, 39, 0.95);
|
||
--border: rgba(255,255,255,0.08);
|
||
--safe-top: var(--tg-content-safe-area-inset-top, 0px);
|
||
--safe-bottom: var(--tg-safe-area-inset-bottom, 0px);
|
||
}
|
||
html, body { height: 100%; overflow: hidden; }
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
background: var(--bg); color: var(--text);
|
||
}
|
||
|
||
/* Tabs */
|
||
.app { display: flex; flex-direction: column; height: 100vh; }
|
||
.tab-content { flex: 1; position: relative; overflow: hidden; }
|
||
.tab-pane { display: none; position: absolute; inset: 0; }
|
||
.tab-pane.active { display: flex; flex-direction: column; }
|
||
.bottom-nav {
|
||
display: flex; background: var(--bg2); border-top: 1px solid var(--border);
|
||
padding-bottom: var(--safe-bottom); z-index: 1000;
|
||
}
|
||
.nav-item {
|
||
flex: 1; text-align: center; padding: 8px 0 6px; cursor: pointer;
|
||
color: var(--hint); font-size: 11px; transition: color 0.2s;
|
||
}
|
||
.nav-item.active { color: var(--accent); }
|
||
.nav-item svg { display: block; margin: 0 auto 2px; width: 24px; height: 24px; }
|
||
|
||
/* Map */
|
||
#map { flex: 1; z-index: 1; }
|
||
.map-search {
|
||
position: absolute; top: calc(8px + var(--safe-top)); left: 10px; right: 10px;
|
||
z-index: 1001;
|
||
}
|
||
.map-search input {
|
||
width: 100%; padding: 10px 14px; border-radius: 12px; border: none;
|
||
background: var(--card); color: var(--text); font-size: 14px;
|
||
backdrop-filter: blur(10px); outline: none;
|
||
}
|
||
.map-search input::placeholder { color: var(--hint); }
|
||
|
||
/* Vessel popup (compact Leaflet popup near marker) */
|
||
.leaflet-popup-content-wrapper { background: var(--card) !important; color: var(--text) !important; border-radius: 10px !important; box-shadow: 0 4px 20px rgba(0,0,0,0.5) !important; padding: 0 !important; }
|
||
.leaflet-popup-content { margin: 0 !important; min-width: 210px; max-width: 260px; }
|
||
.leaflet-popup-tip { background: var(--card) !important; }
|
||
.leaflet-popup-close-button { color: var(--hint) !important; font-size: 18px !important; top: 4px !important; right: 6px !important; }
|
||
.vp { padding: 12px 14px; font-size: 14px; line-height: 1.5; }
|
||
.vp-name { font-size: 15px; font-weight: 700; color: var(--accent); margin-bottom: 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 220px; }
|
||
.vp-row { display: flex; justify-content: space-between; gap: 10px; padding: 2px 0; }
|
||
.vp-lbl { color: var(--hint); font-size: 13px; white-space: nowrap; }
|
||
.vp-val { color: #fff; font-size: 13px; font-weight: 600; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 150px; }
|
||
.vp-btn { display: block; width: 100%; margin-top: 10px; padding: 8px; border: 1px solid var(--accent); border-radius: 8px; background: transparent; color: var(--accent); font-size: 13px; font-weight: 600; cursor: pointer; text-align: center; }
|
||
.vp-btn:active { background: var(--accent); color: var(--bg); }
|
||
|
||
/* Chat */
|
||
.chat-container { display: flex; flex-direction: column; height: 100%; padding-top: var(--safe-top); }
|
||
.chat-messages {
|
||
flex: 1; overflow-y: auto; padding: 12px; display: flex;
|
||
flex-direction: column; gap: 8px;
|
||
}
|
||
.msg { max-width: 85%; padding: 10px 14px; border-radius: 16px; font-size: 14px; line-height: 1.5; word-wrap: break-word; }
|
||
.msg-user { align-self: flex-end; background: var(--btn); color: var(--btn-text); border-bottom-right-radius: 4px; }
|
||
.msg-bot { align-self: flex-start; background: var(--bg2); color: var(--text); border-bottom-left-radius: 4px; }
|
||
.msg-bot pre { background: rgba(0,0,0,0.3); padding: 8px; border-radius: 8px; overflow-x: auto; font-size: 12px; margin: 4px 0; }
|
||
.msg-typing { align-self: flex-start; background: var(--bg2); padding: 12px 18px; border-radius: 16px; }
|
||
.msg-typing span { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--hint); margin: 0 2px; animation: typing 1.2s infinite; }
|
||
.msg-typing span:nth-child(2) { animation-delay: 0.2s; }
|
||
.msg-typing span:nth-child(3) { animation-delay: 0.4s; }
|
||
@keyframes typing { 0%,60%,100% { opacity: 0.3; transform: translateY(0); } 30% { opacity: 1; transform: translateY(-4px); } }
|
||
.chat-chips { display: flex; gap: 6px; padding: 8px 12px; overflow-x: auto; flex-shrink: 0; }
|
||
.chat-chip {
|
||
white-space: nowrap; padding: 6px 12px; border-radius: 16px; font-size: 12px;
|
||
background: var(--bg2); color: var(--accent); border: 1px solid var(--border);
|
||
cursor: pointer; flex-shrink: 0;
|
||
}
|
||
.chat-input-area {
|
||
display: flex; gap: 8px; padding: 8px 12px;
|
||
padding-bottom: calc(8px + var(--safe-bottom));
|
||
background: var(--bg); border-top: 1px solid var(--border);
|
||
}
|
||
.chat-input-area textarea {
|
||
flex: 1; resize: none; border: none; border-radius: 20px; padding: 10px 14px;
|
||
background: var(--bg2); color: var(--text); font-size: 14px;
|
||
font-family: inherit; outline: none; max-height: 100px;
|
||
}
|
||
.chat-input-area textarea::placeholder { color: var(--hint); }
|
||
.chat-send {
|
||
width: 40px; height: 40px; border-radius: 50%; border: none;
|
||
background: var(--btn); color: var(--btn-text); font-size: 18px;
|
||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||
flex-shrink: 0; align-self: flex-end;
|
||
}
|
||
|
||
/* Profile */
|
||
.profile-container {
|
||
height: 100%; overflow-y: auto; padding: 16px;
|
||
padding-top: calc(16px + var(--safe-top));
|
||
padding-bottom: calc(16px + var(--safe-bottom));
|
||
}
|
||
.profile-header {
|
||
text-align: center; padding: 20px 0;
|
||
}
|
||
.profile-avatar {
|
||
width: 64px; height: 64px; border-radius: 50%; background: var(--btn);
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 24px; font-weight: 700; color: var(--btn-text); margin: 0 auto 10px;
|
||
}
|
||
.profile-name { font-size: 18px; font-weight: 600; }
|
||
.profile-role { font-size: 13px; color: var(--hint); margin-top: 2px; text-transform: capitalize; }
|
||
.profile-card {
|
||
background: var(--bg2); border-radius: 12px; padding: 16px; margin-top: 16px;
|
||
}
|
||
.profile-card-title { font-size: 12px; color: var(--hint); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px; }
|
||
.profile-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--border); }
|
||
.profile-row:last-child { border-bottom: none; }
|
||
.profile-row-label { color: var(--hint); font-size: 14px; }
|
||
.profile-row-val { color: var(--text); font-size: 14px; font-weight: 500; }
|
||
.profile-watches { margin-top: 8px; }
|
||
.watch-item {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
padding: 10px 0; border-bottom: 1px solid var(--border);
|
||
}
|
||
.watch-item:last-child { border-bottom: none; }
|
||
.watch-name { font-size: 14px; font-weight: 500; }
|
||
.watch-dest { font-size: 12px; color: var(--hint); }
|
||
.watch-remove { background: none; border: none; color: #ef4444; font-size: 12px; cursor: pointer; }
|
||
.profile-btn {
|
||
display: block; width: 100%; padding: 12px; margin-top: 16px; border: none;
|
||
border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer;
|
||
text-align: center;
|
||
}
|
||
.profile-btn-primary { background: var(--btn); color: var(--btn-text); }
|
||
.profile-btn-outline { background: transparent; color: var(--accent); border: 1px solid var(--accent); }
|
||
|
||
/* Not linked screen */
|
||
.not-linked {
|
||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||
height: 100%; padding: 40px 24px; text-align: center;
|
||
}
|
||
.not-linked-icon { font-size: 64px; margin-bottom: 20px; }
|
||
.not-linked h2 { font-size: 20px; margin-bottom: 10px; }
|
||
.not-linked p { color: var(--hint); font-size: 14px; line-height: 1.6; margin-bottom: 24px; }
|
||
.not-linked a {
|
||
display: inline-block; padding: 12px 32px; background: var(--btn); color: var(--btn-text);
|
||
border-radius: 10px; text-decoration: none; font-weight: 600; font-size: 15px;
|
||
}
|
||
|
||
/* Loading */
|
||
.loading-screen {
|
||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||
height: 100vh; gap: 16px;
|
||
}
|
||
.spinner { width: 40px; height: 40px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
/* Map filters */
|
||
.map-filters {
|
||
position: absolute; top: calc(48px + var(--safe-top)); left: 10px; right: 10px;
|
||
z-index: 1001; display: flex; gap: 6px; overflow-x: auto; padding: 4px 0;
|
||
-webkit-overflow-scrolling: touch; scrollbar-width: none;
|
||
}
|
||
.map-filters::-webkit-scrollbar { display: none; }
|
||
.filter-chip {
|
||
white-space: nowrap; padding: 4px 8px; border-radius: 12px; font-size: 10px;
|
||
font-weight: 600; cursor: pointer; flex-shrink: 0; border: 1.5px solid;
|
||
transition: opacity 0.2s, transform 0.1s; user-select: none;
|
||
}
|
||
.filter-chip.active { opacity: 1; }
|
||
.filter-chip.inactive { opacity: 0.35; }
|
||
.filter-chip:active { transform: scale(0.95); }
|
||
.filter-count {
|
||
position: absolute; top: calc(74px + var(--safe-top)); left: 14px;
|
||
z-index: 1001; font-size: 10px; color: var(--hint);
|
||
background: rgba(10,14,23,0.7); padding: 2px 6px; border-radius: 6px;
|
||
}
|
||
/* Vessel arrow markers */
|
||
.vessel-marker { background: none !important; border: none !important; }
|
||
.cluster-marker {
|
||
display: flex; align-items: center; justify-content: center;
|
||
border-radius: 50%; color: #fff; font-weight: 700;
|
||
text-shadow: 0 1px 2px rgba(0,0,0,0.6); cursor: pointer;
|
||
border: 2px solid rgba(255,255,255,0.5);
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||
transition: transform 0.15s ease;
|
||
}
|
||
.cluster-marker:hover { transform: scale(1.15); border-color: #fff; }
|
||
|
||
/* Leaflet overrides */
|
||
.leaflet-popup-content-wrapper { background: var(--card) !important; color: var(--text) !important; border-radius: 12px !important; }
|
||
.leaflet-popup-tip { background: var(--card) !important; }
|
||
.leaflet-control-zoom a { background: var(--card) !important; color: var(--text) !important; border-color: var(--border) !important; }
|
||
.leaflet-control-attribution { display: none !important; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- Loading -->
|
||
<div id="loadingScreen" class="loading-screen">
|
||
<div class="spinner"></div>
|
||
<div style="color:var(--hint); font-size:14px">SeaFare Montana</div>
|
||
</div>
|
||
|
||
<!-- Not linked -->
|
||
<div id="notLinkedScreen" class="not-linked" style="display:none">
|
||
<div class="not-linked-icon">⚓</div>
|
||
<h2 id="nlTitle">Register First</h2>
|
||
<p id="nlText">To use the SeaFare bot, please register on our website and link your Telegram account in the profile settings.</p>
|
||
<a id="nlLink" href="https://seafare.efir.org" target="_blank" id="nlBtn">Open Website</a>
|
||
</div>
|
||
|
||
<!-- Main App -->
|
||
<div id="mainApp" class="app" style="display:none">
|
||
<!-- Map Tab -->
|
||
<div class="tab-content">
|
||
<div id="tabMap" class="tab-pane active">
|
||
<div class="map-search">
|
||
<input type="text" id="mapSearch" placeholder="Search vessel or port...">
|
||
</div>
|
||
<div class="map-filters" id="mapFilters"></div>
|
||
<div class="filter-count" id="filterCount"></div>
|
||
<div id="map"></div>
|
||
</div>
|
||
|
||
<!-- Chat Tab -->
|
||
<div id="tabChat" class="tab-pane">
|
||
<div class="chat-container">
|
||
<div class="chat-messages" id="chatMessages">
|
||
<div class="msg msg-bot">Hello! I'm your maritime AI assistant. Ask me about vessels, routes, contacts, or anything shipping-related.</div>
|
||
</div>
|
||
<div class="chat-chips" id="chatChips"></div>
|
||
<div class="chat-input-area">
|
||
<textarea id="chatInput" rows="1" placeholder="Ask anything..." onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendChat()}"></textarea>
|
||
<button class="chat-send" onclick="sendChat()">➤</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Profile Tab -->
|
||
<div id="tabProfile" class="tab-pane">
|
||
<div class="profile-container">
|
||
<div class="profile-header">
|
||
<div class="profile-avatar" id="profAvatar">?</div>
|
||
<div class="profile-name" id="profName">—</div>
|
||
<div class="profile-role" id="profRole">—</div>
|
||
</div>
|
||
<div class="profile-card">
|
||
<div class="profile-card-title">Wallet</div>
|
||
<div class="profile-row">
|
||
<span class="profile-row-label">Balance</span>
|
||
<span class="profile-row-val" id="profBalance">$0.00</span>
|
||
</div>
|
||
</div>
|
||
<div class="profile-card">
|
||
<div class="profile-card-title">Vessel Watches</div>
|
||
<div class="profile-watches" id="profWatches">
|
||
<div style="color:var(--hint); font-size:13px; padding:8px 0">No active watches</div>
|
||
</div>
|
||
</div>
|
||
<div class="profile-card">
|
||
<div class="profile-card-title">Trade Routes</div>
|
||
<div class="profile-row-val" id="profRoutes" style="font-size:13px; line-height:1.8">—</div>
|
||
</div>
|
||
<div class="profile-card">
|
||
<div class="profile-card-title">Cargo Types</div>
|
||
<div class="profile-row-val" id="profCargo" style="font-size:13px; line-height:1.8">—</div>
|
||
</div>
|
||
<button class="profile-btn profile-btn-outline" onclick="openFullSite()">Open Full Website</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bottom Nav -->
|
||
<div class="bottom-nav">
|
||
<div class="nav-item active" onclick="switchTab('map')" id="navMap">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 6v16l7-4 8 4 7-4V2l-7 4-8-4-7 4z"/><path d="M8 2v16"/><path d="M16 6v16"/></svg>
|
||
Map
|
||
</div>
|
||
<div class="nav-item" onclick="switchTab('chat')" id="navChat">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||
Chat
|
||
</div>
|
||
<div class="nav-item" onclick="switchTab('profile')" id="navProfile">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||
Profile
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
window.onerror = function(msg, url, line, col, err) {
|
||
console.error("JS Error:", msg, "at", url, line);
|
||
var ls = document.getElementById("loadingScreen");
|
||
if (ls && ls.style.display !== "none") {
|
||
ls.innerHTML = "<div style=\"color:#ff6b6b;padding:20px;text-align:center;font-size:14px;\">Error: " + msg + "<br>Line: " + line + "</div>";
|
||
}
|
||
return false;
|
||
};
|
||
// =============================================================================
|
||
// Telegram WebApp Init
|
||
// =============================================================================
|
||
let tg;
|
||
try {
|
||
tg = window.Telegram.WebApp;
|
||
tg.ready();
|
||
tg.expand();
|
||
if (tg.isVersionAtLeast && tg.isVersionAtLeast('8.0')) {
|
||
try { tg.requestFullscreen(); } catch(e) {}
|
||
}
|
||
if (tg.setHeaderColor) tg.setHeaderColor(tg.themeParams?.secondary_bg_color || '#111827');
|
||
if (tg.setBackgroundColor) tg.setBackgroundColor(tg.themeParams?.bg_color || '#0a0e17');
|
||
} catch(e) {
|
||
console.error('TG SDK init error:', e);
|
||
tg = { initData: '', initDataUnsafe: {}, themeParams: {}, ready: function(){}, expand: function(){}, close: function(){}, MainButton: { show:function(){}, hide:function(){}, onClick:function(){}, setText:function(){} }, BackButton: { show:function(){}, hide:function(){}, onClick:function(){} }, isVersionAtLeast: function(){return false;}, setHeaderColor:function(){}, setBackgroundColor:function(){} };
|
||
}
|
||
|
||
const API_BASE = '';
|
||
let authToken = null;
|
||
let currentUser = null;
|
||
let currentTab = 'map';
|
||
let leafletMap = null;
|
||
let vesselMarkers = {};
|
||
let selectedVessel = null;
|
||
let vesselPopup = null;
|
||
// Supercluster state
|
||
let _supercluster = null;
|
||
let _allVesselsGeoJSON = [];
|
||
let _clusterMarkers = [];
|
||
let _bulkLoaded = false;
|
||
let _bulkRefreshTimer = null;
|
||
const CLUSTER_ZOOM = 10;
|
||
const TYPE_CAT_NAMES = {0:'tanker',1:'bulk',2:'container',3:'cargo',4:'passenger',5:'roro',6:'fishing',7:'tug',8:'offshore',9:'other'};
|
||
let _mapLoadEpoch = 0;
|
||
let _clusterMarkersById = {};
|
||
let _lastClusterZoom = -1;
|
||
let chatHistory = [];
|
||
let mapRefreshTimer = null;
|
||
|
||
// =============================================================================
|
||
// i18n
|
||
// =============================================================================
|
||
const _i18n = {
|
||
en: {
|
||
tab_map: 'Map', tab_chat: 'Chat', tab_profile: 'Profile',
|
||
search_placeholder: 'Search vessel or port...',
|
||
chat_welcome: "Hello! I'm your maritime AI assistant. Ask me about vessels, routes, contacts, or anything shipping-related.",
|
||
chip1: 'Vessels near Istanbul', chip1_q: 'Vessels near Istanbul',
|
||
chip2: 'Freight rates', chip2_q: 'Freight rate Black Sea',
|
||
chip3: 'Track vessel', chip3_q: 'Track vessel',
|
||
chip4: 'Contacts', chip4_q: 'Find contacts for MAERSK',
|
||
ask_placeholder: 'Ask anything...',
|
||
wallet: 'Wallet', balance: 'Balance',
|
||
watches: 'Vessel Watches', no_watches: 'No active watches',
|
||
trade_routes: 'Trade Routes', cargo_types: 'Cargo Types',
|
||
open_website: 'Open Full Website',
|
||
ai_details: 'AI Details', track_btn: 'Track',
|
||
nl_title: 'Register First',
|
||
nl_text: 'To use the SeaFare bot, register on our website and link your Telegram in profile settings.',
|
||
nl_link: 'Open Website',
|
||
type: 'Type', flag: 'Flag', destination: 'Destination',
|
||
speed: 'Speed', course: 'Course',
|
||
tell_about: 'Tell me about vessel ',
|
||
track_vessel: 'Track vessel ',
|
||
track_notify: ' and notify me when it arrives',
|
||
search_vessel: 'Search vessel ',
|
||
error: 'Error: ', member: 'member',
|
||
},
|
||
ru: {
|
||
tab_map: 'Карта', tab_chat: 'Чат', tab_profile: 'Профиль',
|
||
search_placeholder: 'Поиск судна или порта...',
|
||
chat_welcome: 'Привет! Я морской AI-помощник. Спросите о судах, маршрутах, фрахтовых ставках или чём-либо в судоходстве.',
|
||
chip1: 'Суда у Стамбула', chip1_q: 'Суда рядом со Стамбулом',
|
||
chip2: 'Фрахтовые ставки', chip2_q: 'Фрахтовые ставки Чёрное море',
|
||
chip3: 'Отследить судно', chip3_q: 'Отследить судно',
|
||
chip4: 'Загруженность порта', chip4_q: 'Загруженность порта Новороссийск',
|
||
ask_placeholder: 'Спросите что угодно...',
|
||
wallet: 'Кошелёк', balance: 'Баланс',
|
||
watches: 'Отслеживания', no_watches: 'Нет активных отслеживаний',
|
||
trade_routes: 'Торговые маршруты', cargo_types: 'Типы грузов',
|
||
open_website: 'Открыть сайт',
|
||
ai_details: 'Подробнее', track_btn: 'Отследить',
|
||
nl_title: 'Сначала регистрация',
|
||
nl_text: 'Чтобы пользоваться ботом SeaFare, зарегистрируйтесь на сайте и привяжите Telegram в настройках профиля.',
|
||
nl_link: 'Открыть сайт',
|
||
type: 'Тип', flag: 'Флаг', destination: 'Назначение',
|
||
speed: 'Скорость', course: 'Курс',
|
||
tell_about: 'Расскажи о судне ',
|
||
track_vessel: 'Отследить судно ',
|
||
track_notify: ' и уведомить о прибытии',
|
||
search_vessel: 'Найти судно ',
|
||
error: 'Ошибка: ', member: 'участник',
|
||
},
|
||
es: {
|
||
tab_map: 'Mapa', tab_chat: 'Chat', tab_profile: 'Perfil',
|
||
search_placeholder: 'Buscar buque o puerto...',
|
||
chat_welcome: '¡Hola! Soy tu asistente marítimo de IA. Pregúntame sobre buques, rutas, fletes o cualquier tema naviero.',
|
||
chip1: 'Buques en Estambul', chip1_q: 'Buques cerca de Estambul',
|
||
chip2: 'Tarifas de flete', chip2_q: 'Tarifas de flete Mar Negro',
|
||
chip3: 'Rastrear buque', chip3_q: 'Rastrear buque',
|
||
chip4: 'Congestión portuaria', chip4_q: 'Congestión portuaria Novorossiysk',
|
||
ask_placeholder: 'Pregunta lo que sea...',
|
||
wallet: 'Billetera', balance: 'Saldo',
|
||
watches: 'Seguimientos', no_watches: 'Sin seguimientos activos',
|
||
trade_routes: 'Rutas comerciales', cargo_types: 'Tipos de carga',
|
||
open_website: 'Abrir sitio web',
|
||
ai_details: 'Detalles IA', track_btn: 'Rastrear',
|
||
nl_title: 'Regístrese primero',
|
||
nl_text: 'Para usar el bot SeaFare, regístrese en nuestro sitio web y vincule su Telegram en la configuración del perfil.',
|
||
nl_link: 'Abrir sitio web',
|
||
type: 'Tipo', flag: 'Bandera', destination: 'Destino',
|
||
speed: 'Velocidad', course: 'Rumbo',
|
||
tell_about: 'Cuéntame sobre el buque ',
|
||
track_vessel: 'Rastrear buque ',
|
||
track_notify: ' y notificarme cuando llegue',
|
||
search_vessel: 'Buscar buque ',
|
||
error: 'Error: ', member: 'miembro',
|
||
}
|
||
};
|
||
let _lang = 'en';
|
||
function t(k) { return (_i18n[_lang] && _i18n[_lang][k]) || _i18n.en[k] || k; }
|
||
|
||
function applyTranslations() {
|
||
// Bottom nav
|
||
document.getElementById('navMap').lastChild.textContent = t('tab_map');
|
||
document.getElementById('navChat').lastChild.textContent = t('tab_chat');
|
||
document.getElementById('navProfile').lastChild.textContent = t('tab_profile');
|
||
// Map
|
||
document.getElementById('mapSearch').placeholder = t('search_placeholder');
|
||
// Chat
|
||
const welcomeMsg = document.querySelector('#chatMessages .msg-bot');
|
||
if (welcomeMsg) welcomeMsg.textContent = t('chat_welcome');
|
||
document.getElementById('chatInput').placeholder = t('ask_placeholder');
|
||
// Chat chips (rebuild with translated text + queries)
|
||
const chipsEl = document.getElementById('chatChips');
|
||
const chipData = [
|
||
{ label: t('chip1'), query: t('chip1_q') },
|
||
{ label: t('chip2'), query: t('chip2_q') },
|
||
{ label: t('chip3'), query: t('chip3_q') },
|
||
{ label: t('chip4'), query: t('chip4_q') },
|
||
];
|
||
chipsEl.innerHTML = chipData.map(c =>
|
||
`<div class="chat-chip" onclick="quickChat('${c.query.replace(/'/g, "\\'")}')">${c.label}</div>`
|
||
).join('');
|
||
// Vessel card buttons
|
||
var _bd = document.querySelector('.btn-details'); if(_bd) _bd.textContent = t('ai_details');
|
||
var _bt = document.querySelector('.btn-track'); if(_bt) _bt.textContent = t('track_btn');
|
||
// Profile
|
||
document.querySelectorAll('.profile-card-title').forEach(el => {
|
||
const txt = el.textContent.trim();
|
||
if (txt === 'Wallet') el.textContent = t('wallet');
|
||
else if (txt === 'Vessel Watches') el.textContent = t('watches');
|
||
else if (txt === 'Trade Routes') el.textContent = t('trade_routes');
|
||
else if (txt === 'Cargo Types') el.textContent = t('cargo_types');
|
||
});
|
||
var _bl = document.querySelector('.profile-row-label'); if(_bl) _bl.textContent = t('balance');
|
||
const watchesEmpty = document.querySelector('#profWatches > div');
|
||
if (watchesEmpty) watchesEmpty.textContent = t('no_watches');
|
||
var _po = document.querySelector('.profile-btn-outline'); if(_po) _po.textContent = t('open_website');
|
||
// Not linked
|
||
document.getElementById('nlTitle').textContent = t('nl_title');
|
||
document.getElementById('nlText').textContent = t('nl_text');
|
||
document.getElementById('nlLink').textContent = t('nl_link');
|
||
}
|
||
|
||
// =============================================================================
|
||
// Auth
|
||
// =============================================================================
|
||
async function authenticate() {
|
||
// 1. Try Telegram WebApp auth (inside Telegram)
|
||
if (tg.initData) {
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/auth/telegram-webapp', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ initData: tg.initData })
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
authToken = data.token;
|
||
currentUser = data.user;
|
||
return true;
|
||
}
|
||
window._authError = data.error || 'unknown';
|
||
return false;
|
||
} catch (e) {
|
||
window._authError = e.message;
|
||
return false;
|
||
}
|
||
}
|
||
// 2. Fallback: try web session token (opened in browser, not Telegram)
|
||
var webToken = localStorage.getItem('authToken');
|
||
if (webToken) {
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/profile', {
|
||
headers: { 'Authorization': 'Bearer ' + webToken }
|
||
});
|
||
if (resp.ok) {
|
||
authToken = webToken;
|
||
const pd = await resp.json();
|
||
currentUser = pd.profile || {};
|
||
return true;
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
window._authError = 'not_in_telegram';
|
||
return false;
|
||
}
|
||
|
||
async function init() {
|
||
// Detect language from Telegram
|
||
const tgLang = (tg.initDataUnsafe?.user?.language_code || 'en').substring(0, 2);
|
||
_lang = ['ru', 'es'].includes(tgLang) ? tgLang : 'en';
|
||
try { applyTranslations(); } catch(e) { console.warn('applyTranslations:', e.message); }
|
||
|
||
const ok = await authenticate();
|
||
document.getElementById('loadingScreen').style.display = 'none';
|
||
if (!ok) {
|
||
// If opened in browser (not Telegram) and no web session — redirect to main site
|
||
if (window._authError === 'not_in_telegram') {
|
||
window.location.href = '/';
|
||
return;
|
||
}
|
||
document.getElementById('notLinkedScreen').style.display = 'flex';
|
||
return;
|
||
}
|
||
document.getElementById('mainApp').style.display = 'flex';
|
||
await loadChatHistory();
|
||
initMap();
|
||
loadProfile();
|
||
|
||
// BackButton navigation
|
||
tg.BackButton.onClick(function() {
|
||
if (currentTab !== 'map') {
|
||
switchTab('map');
|
||
} else {
|
||
tg.close();
|
||
}
|
||
});
|
||
}
|
||
|
||
// =============================================================================
|
||
// Tab Navigation
|
||
// =============================================================================
|
||
function switchTab(tab) {
|
||
currentTab = tab;
|
||
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
|
||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||
document.getElementById('tab' + tab.charAt(0).toUpperCase() + tab.slice(1)).classList.add('active');
|
||
document.getElementById('nav' + tab.charAt(0).toUpperCase() + tab.slice(1)).classList.add('active');
|
||
|
||
if (tab === 'map') {
|
||
tg.BackButton.hide();
|
||
if (leafletMap) setTimeout(() => leafletMap.invalidateSize(), 100);
|
||
startMapRefresh();
|
||
} else {
|
||
tg.BackButton.show();
|
||
stopMapRefresh();
|
||
}
|
||
|
||
if (tab === 'profile') loadProfile();
|
||
if (tg.HapticFeedback) tg.HapticFeedback.selectionChanged();
|
||
}
|
||
|
||
// =============================================================================
|
||
// Map
|
||
// =============================================================================
|
||
function initMap() {
|
||
leafletMap = L.map('map', {
|
||
zoomControl: false,
|
||
attributionControl: false,
|
||
}).setView([37.5, 24.0], 6);
|
||
|
||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||
maxZoom: 18,
|
||
}).addTo(leafletMap);
|
||
|
||
L.control.zoom({ position: 'bottomright' }).addTo(leafletMap);
|
||
|
||
// Init Supercluster
|
||
if (typeof Supercluster !== 'undefined') {
|
||
_supercluster = new Supercluster({
|
||
radius: 60, maxZoom: CLUSTER_ZOOM - 1, minZoom: 2,
|
||
map: (props) => {
|
||
const counts = {};
|
||
for (let i = 0; i < 10; i++) counts['c' + i] = 0;
|
||
counts['c' + props.type_cat] = 1;
|
||
return counts;
|
||
},
|
||
reduce: (acc, props) => {
|
||
for (let i = 0; i < 10; i++) acc['c' + i] += props['c' + i];
|
||
}
|
||
});
|
||
}
|
||
|
||
let _mvTimeout = null;
|
||
leafletMap.on('moveend', () => {
|
||
clearTimeout(_mvTimeout);
|
||
_mvTimeout = setTimeout(() => {
|
||
if (!leafletMap) return;
|
||
const zoom = leafletMap.getZoom();
|
||
if (zoom >= CLUSTER_ZOOM) {
|
||
loadMapVessels().then(() => {
|
||
if (leafletMap && leafletMap.getZoom() >= CLUSTER_ZOOM) clearClusters();
|
||
});
|
||
} else if (_bulkLoaded) {
|
||
renderClusters();
|
||
clearIndividualMarkers();
|
||
}
|
||
}, 300);
|
||
});
|
||
|
||
initFilters();
|
||
|
||
// Handle clicks inside vessel popup (standard Leaflet pattern)
|
||
leafletMap.on('popupopen', function() {
|
||
var btn = document.querySelector('.vp-btn');
|
||
if (btn) {
|
||
btn.onclick = function() { askAboutVessel(); };
|
||
}
|
||
});
|
||
|
||
|
||
// Search
|
||
document.getElementById('mapSearch').addEventListener('keydown', function(e) {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
searchOnMap(this.value.trim());
|
||
}
|
||
});
|
||
|
||
loadBulkVessels();
|
||
}
|
||
|
||
function clearClusters() {
|
||
_clusterMarkers.forEach(m => leafletMap.removeLayer(m));
|
||
_clusterMarkers = [];
|
||
_clusterMarkersById = {};
|
||
_lastClusterZoom = -1;
|
||
}
|
||
function clearIndividualMarkers() {
|
||
Object.values(vesselMarkers).forEach(m => { if (leafletMap.hasLayer(m)) leafletMap.removeLayer(m); });
|
||
vesselMarkers = {};
|
||
}
|
||
|
||
async function loadBulkVessels() {
|
||
if (!leafletMap) return;
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/map/vessels/all', {
|
||
headers: authToken ? { 'Authorization': 'Bearer ' + authToken } : {}
|
||
});
|
||
if (!resp.ok) { loadMapVessels(); return; }
|
||
const data = await resp.json();
|
||
if (!data.success || !data.vessels) { loadMapVessels(); return; }
|
||
_allVesselsGeoJSON = [];
|
||
for (const v of data.vessels) {
|
||
if (v[1] == null || v[2] == null) continue;
|
||
_allVesselsGeoJSON.push({
|
||
type: 'Feature',
|
||
geometry: { type: 'Point', coordinates: [v[2], v[1]] },
|
||
properties: {
|
||
mmsi: v[0], type_cat: v[3] != null ? v[3] : 9,
|
||
heading: v[4], speed: v[5], course: v[6], name: v[7] || ''
|
||
}
|
||
});
|
||
}
|
||
if (_supercluster && _allVesselsGeoJSON.length > 0) {
|
||
_supercluster.load(_allVesselsGeoJSON);
|
||
_bulkLoaded = true;
|
||
if (leafletMap.getZoom() < CLUSTER_ZOOM) {
|
||
clearIndividualMarkers();
|
||
renderClusters();
|
||
}
|
||
}
|
||
if (_bulkRefreshTimer) clearInterval(_bulkRefreshTimer);
|
||
_bulkRefreshTimer = setInterval(loadBulkVessels, 120000);
|
||
if (leafletMap.getZoom() >= CLUSTER_ZOOM) loadMapVessels();
|
||
} catch (e) {
|
||
console.warn('Bulk load failed:', e);
|
||
loadMapVessels();
|
||
}
|
||
}
|
||
|
||
function renderClusters() {
|
||
if (!_supercluster || !_bulkLoaded || !leafletMap) return;
|
||
const b = leafletMap.getBounds();
|
||
const bbox = [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()];
|
||
const zoom = leafletMap.getZoom();
|
||
|
||
// Zoom changed → full rebuild
|
||
if (zoom !== _lastClusterZoom) {
|
||
clearClusters();
|
||
_lastClusterZoom = zoom;
|
||
}
|
||
|
||
const clusters = _supercluster.getClusters(bbox, zoom);
|
||
const desiredKeys = new Set();
|
||
let totalShown = 0;
|
||
|
||
clusters.forEach(c => {
|
||
const coords = c.geometry.coordinates;
|
||
const props = c.properties;
|
||
if (props.cluster) {
|
||
const key = 'c_' + props.cluster_id;
|
||
let filteredCount = 0;
|
||
for (let i = 0; i < 10; i++) {
|
||
const catName = TYPE_CAT_NAMES[i];
|
||
const filterCat = VESSEL_FILTER_LABELS[catName] ? catName : 'other';
|
||
if (activeFilters.has(filterCat)) filteredCount += (props['c' + i] || 0);
|
||
}
|
||
if (filteredCount === 0) return;
|
||
desiredKeys.add(key);
|
||
totalShown += filteredCount;
|
||
if (_clusterMarkersById[key]) return; // diff: skip existing
|
||
|
||
let maxCat = 9, maxCount = 0;
|
||
for (let i = 0; i < 10; i++) {
|
||
const catName = TYPE_CAT_NAMES[i];
|
||
const filterCat = VESSEL_FILTER_LABELS[catName] ? catName : 'other';
|
||
if (activeFilters.has(filterCat) && (props['c' + i] || 0) > maxCount) {
|
||
maxCount = props['c' + i]; maxCat = i;
|
||
}
|
||
}
|
||
const color = VESSEL_COLORS[TYPE_CAT_NAMES[maxCat]] || VESSEL_COLORS.other;
|
||
const size = Math.min(56, Math.max(22, 18 + Math.sqrt(filteredCount) * 3));
|
||
const label = filteredCount >= 1000 ? (filteredCount / 1000).toFixed(1) + 'K' : String(filteredCount);
|
||
const fontSize = filteredCount >= 1000 ? 9 : (filteredCount >= 100 ? 10 : 11);
|
||
const icon = L.divIcon({
|
||
html: `<div class="cluster-marker" style="width:${size}px;height:${size}px;background:radial-gradient(circle at 35% 35%,${color}ee,${color}99);font-size:${fontSize}px">${label}</div>`,
|
||
className: '', iconSize: [size, size], iconAnchor: [size/2, size/2]
|
||
});
|
||
const m = L.marker([coords[1], coords[0]], { icon: icon });
|
||
const clusterId = props.cluster_id;
|
||
m.on('click', () => {
|
||
const expZoom = _supercluster.getClusterExpansionZoom(clusterId);
|
||
leafletMap.setView([coords[1], coords[0]], Math.min(expZoom, CLUSTER_ZOOM));
|
||
});
|
||
m.addTo(leafletMap);
|
||
_clusterMarkers.push(m);
|
||
_clusterMarkersById[key] = m;
|
||
} else {
|
||
const key = 's_' + (props.mmsi || coords.join(','));
|
||
const catName = TYPE_CAT_NAMES[props.type_cat] || 'other';
|
||
const filterCat = VESSEL_FILTER_LABELS[catName] ? catName : 'other';
|
||
if (!activeFilters.has(filterCat)) return;
|
||
desiredKeys.add(key);
|
||
totalShown++;
|
||
if (_clusterMarkersById[key]) return; // diff: skip existing
|
||
const v = { _cat: catName, heading: props.heading, course: props.course, name: props.name, mmsi: props.mmsi };
|
||
const m = L.marker([coords[1], coords[0]], { icon: createVesselIcon(v) });
|
||
m.on('click', () => { leafletMap.setView([coords[1], coords[0]], CLUSTER_ZOOM); });
|
||
m.addTo(leafletMap);
|
||
_clusterMarkers.push(m);
|
||
_clusterMarkersById[key] = m;
|
||
}
|
||
});
|
||
|
||
// Remove markers no longer in viewport
|
||
for (const key of Object.keys(_clusterMarkersById)) {
|
||
if (!desiredKeys.has(key)) {
|
||
leafletMap.removeLayer(_clusterMarkersById[key]);
|
||
const idx = _clusterMarkers.indexOf(_clusterMarkersById[key]);
|
||
if (idx !== -1) _clusterMarkers.splice(idx, 1);
|
||
delete _clusterMarkersById[key];
|
||
}
|
||
}
|
||
|
||
document.getElementById('filterCount').textContent = totalShown + ' / ' + _allVesselsGeoJSON.length;
|
||
}
|
||
|
||
function startMapRefresh() {
|
||
if (_bulkLoaded && !_bulkRefreshTimer) {
|
||
_bulkRefreshTimer = setInterval(loadBulkVessels, 120000);
|
||
}
|
||
}
|
||
function stopMapRefresh() {
|
||
if (_bulkRefreshTimer) { clearInterval(_bulkRefreshTimer); _bulkRefreshTimer = null; }
|
||
if (mapRefreshTimer) { clearInterval(mapRefreshTimer); mapRefreshTimer = null; }
|
||
}
|
||
|
||
async function loadMapVessels() {
|
||
if (!leafletMap) return;
|
||
const epoch = ++_mapLoadEpoch;
|
||
const b = leafletMap.getBounds();
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/map/vessels?' +
|
||
`lat_min=${b.getSouth().toFixed(4)}&lat_max=${b.getNorth().toFixed(4)}` +
|
||
`&lon_min=${b.getWest().toFixed(4)}&lon_max=${b.getEast().toFixed(4)}&limit=500`, {
|
||
headers: authToken ? { 'Authorization': 'Bearer ' + authToken } : {}
|
||
});
|
||
if (!resp.ok) return;
|
||
if (epoch !== _mapLoadEpoch) return;
|
||
const data = await resp.json();
|
||
if (!data.success || epoch !== _mapLoadEpoch) return;
|
||
allVesselsCache = data.vessels || [];
|
||
updateMarkers(allVesselsCache);
|
||
} catch (e) { console.error('Map load error:', e); }
|
||
}
|
||
|
||
const VESSEL_COLORS = {
|
||
tanker:'#d32f2f', bulk:'#ef6c00', container:'#388e3c', cargo:'#f9a825',
|
||
general:'#f9a825', passenger:'#1565c0', roro:'#00838f', offshore:'#ad1457',
|
||
tug:'#00acc1', fishing:'#6a1b9a', highspeed:'#fdd835', pleasure:'#7b1fa2',
|
||
military:'#546e7a', sailing:'#ec407a', other:'#78909c'
|
||
};
|
||
|
||
const VESSEL_FILTER_LABELS = {
|
||
tanker: {en:'Tanker', ru:'Танкер', es:'Tanque'},
|
||
bulk: {en:'Bulk', ru:'Балкер', es:'Granelero'},
|
||
container: {en:'Container', ru:'Контейнер', es:'Contenedor'},
|
||
cargo: {en:'Cargo', ru:'Карго', es:'Carga'},
|
||
passenger: {en:'Passenger', ru:'Пассаж.', es:'Pasaje'},
|
||
roro: {en:'Ro-Ro', ru:'Ро-Ро', es:'Ro-Ro'},
|
||
fishing: {en:'Fishing', ru:'Рыба', es:'Pesca'},
|
||
tug: {en:'Tug', ru:'Буксир', es:'Remolc.'},
|
||
offshore: {en:'Offshore', ru:'Офшор', es:'Offshore'},
|
||
other: {en:'Other', ru:'Другие', es:'Otros'},
|
||
};
|
||
|
||
let activeFilters = new Set(Object.keys(VESSEL_FILTER_LABELS)); // all active by default
|
||
let allVesselsCache = []; // last loaded vessels for re-filtering
|
||
|
||
function initFilters() {
|
||
const bar = document.getElementById('mapFilters');
|
||
bar.innerHTML = '';
|
||
for (const [cat, labels] of Object.entries(VESSEL_FILTER_LABELS)) {
|
||
const chip = document.createElement('div');
|
||
chip.className = 'filter-chip active';
|
||
chip.dataset.cat = cat;
|
||
chip.textContent = labels[_lang] || labels.en;
|
||
const color = VESSEL_COLORS[cat] || VESSEL_COLORS.other;
|
||
chip.style.background = color + '22';
|
||
chip.style.borderColor = color;
|
||
chip.style.color = color;
|
||
chip.onclick = () => toggleFilter(cat, chip);
|
||
bar.appendChild(chip);
|
||
}
|
||
}
|
||
|
||
function toggleFilter(cat, chip) {
|
||
if (activeFilters.has(cat)) {
|
||
activeFilters.delete(cat);
|
||
chip.classList.remove('active');
|
||
chip.classList.add('inactive');
|
||
} else {
|
||
activeFilters.add(cat);
|
||
chip.classList.remove('inactive');
|
||
chip.classList.add('active');
|
||
}
|
||
applyFilters();
|
||
if (tg.HapticFeedback) tg.HapticFeedback.selectionChanged();
|
||
}
|
||
|
||
function applyFilters() {
|
||
if (leafletMap && leafletMap.getZoom() < CLUSTER_ZOOM && _bulkLoaded) {
|
||
renderClusters();
|
||
return;
|
||
}
|
||
updateMarkers(allVesselsCache);
|
||
}
|
||
|
||
function vesselTypeCategory(code, cat) {
|
||
// Prefer server-side type_category
|
||
if (cat && cat !== 'other' && VESSEL_COLORS[cat]) return cat;
|
||
code = parseInt(code) || 0;
|
||
if (code >= 70 && code < 80) return 'cargo';
|
||
if (code >= 80 && code < 90) return 'tanker';
|
||
if (code >= 60 && code < 70) return 'passenger';
|
||
if (code >= 30 && code < 40) return 'fishing';
|
||
if (code >= 50 && code < 60) return 'tug';
|
||
if (code >= 40 && code < 50) return 'highspeed';
|
||
return 'other';
|
||
}
|
||
|
||
function createVesselIcon(v) {
|
||
const cat = v._cat || 'other';
|
||
const color = VESSEL_COLORS[cat] || VESSEL_COLORS.other;
|
||
const h = (v.heading != null && v.heading !== 511) ? v.heading : null;
|
||
const rot = h || v.course || 0;
|
||
const svg = `<svg width="18" height="22" viewBox="0 0 20 24" xmlns="http://www.w3.org/2000/svg" style="transform:rotate(${rot}deg)"><path d="M10 0L18 20L10 16L2 20Z" fill="${color}" stroke="#000" stroke-width="0.8" opacity="0.9"/></svg>`;
|
||
return L.divIcon({ html: svg, className: 'vessel-marker', iconSize: [18,22], iconAnchor: [9,11] });
|
||
}
|
||
|
||
function updateMarkers(vessels) {
|
||
const seen = new Set();
|
||
let visCount = 0;
|
||
for (const v of vessels) {
|
||
const key = v.mmsi || v.name;
|
||
if (!key) continue;
|
||
const lat = parseFloat(v.lat || v.latitude);
|
||
const lon = parseFloat(v.lon || v.longitude);
|
||
if (isNaN(lat) || isNaN(lon)) continue;
|
||
|
||
const cat = vesselTypeCategory(v.type_code || v.ship_type, v.type_category);
|
||
v._cat = cat;
|
||
// Normalize category for filter matching
|
||
const filterCat = VESSEL_FILTER_LABELS[cat] ? cat : 'other';
|
||
const visible = activeFilters.has(filterCat);
|
||
|
||
seen.add(key);
|
||
if (vesselMarkers[key]) {
|
||
if (visible) {
|
||
vesselMarkers[key].setLatLng([lat, lon]);
|
||
vesselMarkers[key].setIcon(createVesselIcon(v));
|
||
if (!leafletMap.hasLayer(vesselMarkers[key])) vesselMarkers[key].addTo(leafletMap);
|
||
visCount++;
|
||
} else {
|
||
if (leafletMap.hasLayer(vesselMarkers[key])) leafletMap.removeLayer(vesselMarkers[key]);
|
||
}
|
||
} else if (visible) {
|
||
const m = L.marker([lat, lon], { icon: createVesselIcon(v) });
|
||
m.vesselData = v;
|
||
m.on('click', function() { showVesselCard(this.vesselData); });
|
||
m.addTo(leafletMap);
|
||
vesselMarkers[key] = m;
|
||
visCount++;
|
||
}
|
||
if (vesselMarkers[key]) vesselMarkers[key].vesselData = v;
|
||
}
|
||
// Remove stale markers
|
||
for (const key of Object.keys(vesselMarkers)) {
|
||
if (!seen.has(key)) {
|
||
leafletMap.removeLayer(vesselMarkers[key]);
|
||
delete vesselMarkers[key];
|
||
}
|
||
}
|
||
// Update count
|
||
document.getElementById('filterCount').textContent = visCount + ' / ' + vessels.length;
|
||
}
|
||
|
||
function showVesselCard(v) {
|
||
selectedVessel = v;
|
||
if (vesselPopup) { leafletMap.closePopup(vesselPopup); vesselPopup = null; }
|
||
const name = v.name || v.mmsi || '\u2014';
|
||
const rows = [];
|
||
const typeName = v.type_name || v.ship_type_name || v.type_category || v._cat || '';
|
||
if (typeName) rows.push([t('type'), typeName]);
|
||
const flag = (v.flag || v.country || '').trim();
|
||
if (flag) rows.push([t('flag'), flag]);
|
||
const dwt = v.dwt || v.deadweight;
|
||
if (dwt) rows.push(['DWT', Number(dwt).toLocaleString()]);
|
||
const dest = (v.destination || '').trim();
|
||
if (dest) rows.push([t('destination'), dest]);
|
||
if (v.speed != null && v.speed !== '') rows.push([t('speed'), v.speed + ' kn']);
|
||
if (v.course != null && v.course !== '') rows.push([t('course'), v.course + '\u00b0']);
|
||
if (v.mmsi) rows.push(['MMSI', v.mmsi]);
|
||
if (v.imo) rows.push(['IMO', v.imo]);
|
||
const rowsHtml = rows.map(function(r) {
|
||
return '<div class="vp-row"><span class="vp-lbl">' + r[0] + '</span><span class="vp-val">' + r[1] + '</span></div>';
|
||
}).join('');
|
||
const html = '<div class="vp"><div class="vp-name">' + name + '</div>' + rowsHtml +
|
||
'<div class="vp-btn">AI Details</div></div>';
|
||
const lat = parseFloat(v.lat || v.latitude);
|
||
const lon = parseFloat(v.lon || v.longitude);
|
||
vesselPopup = L.popup({ closeButton: true, maxWidth: 260, minWidth: 210, offset: [0, -8] })
|
||
.setLatLng([lat, lon])
|
||
.setContent(html)
|
||
.openOn(leafletMap);
|
||
if (tg.HapticFeedback) tg.HapticFeedback.impactOccurred('light');
|
||
}
|
||
|
||
function closeVesselCard() {
|
||
if (vesselPopup) { leafletMap.closePopup(vesselPopup); vesselPopup = null; }
|
||
selectedVessel = null;
|
||
}
|
||
|
||
function askAboutVessel() {
|
||
if (!selectedVessel) return;
|
||
var name = selectedVessel.name || selectedVessel.mmsi;
|
||
var imo = selectedVessel.imo;
|
||
var mmsi = selectedVessel.mmsi;
|
||
var query = 'Vessel details: ' + name;
|
||
if (imo) query += ' IMO ' + imo;
|
||
else if (mmsi) query += ' MMSI ' + mmsi;
|
||
// Close popup and clear selection
|
||
if (vesselPopup) { leafletMap.closePopup(vesselPopup); vesselPopup = null; }
|
||
selectedVessel = null;
|
||
// Switch to chat and send
|
||
switchTab('chat');
|
||
sendChatMessage(query);
|
||
}
|
||
|
||
function trackVessel() {
|
||
if (!selectedVessel) return;
|
||
const name = selectedVessel.name || selectedVessel.mmsi;
|
||
closeVesselCard();
|
||
switchTab('chat');
|
||
sendChatMessage(t('track_vessel') + name + t('track_notify'));
|
||
}
|
||
|
||
async function searchOnMap(query) {
|
||
if (!query) return;
|
||
// Use chat API to search, then parse coordinates from response
|
||
switchTab('chat');
|
||
sendChatMessage(t('search_vessel') + query);
|
||
}
|
||
|
||
// =============================================================================
|
||
// Chat
|
||
// =============================================================================
|
||
async function loadChatHistory() {
|
||
try {
|
||
const resp = await fetch(API_BASE + '/api/v1/chat/history?limit=30', {
|
||
headers: { 'Authorization': 'Bearer ' + authToken }
|
||
});
|
||
if (!resp.ok) return;
|
||
const data = await resp.json();
|
||
if (!data.messages || !data.messages.length) return;
|
||
const messagesEl = document.getElementById('chatMessages');
|
||
messagesEl.innerHTML = '';
|
||
for (const msg of data.messages) {
|
||
const div = document.createElement('div');
|
||
div.className = msg.role === 'user' ? 'msg msg-user' : 'msg msg-bot';
|
||
if (msg.role === 'user') {
|
||
div.textContent = msg.message;
|
||
} else {
|
||
div.innerHTML = formatResponse(msg.message);
|
||
}
|
||
messagesEl.appendChild(div);
|
||
chatHistory.push({ role: msg.role, content: msg.message });
|
||
}
|
||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||
document.getElementById('chatChips').style.display = 'none';
|
||
} catch (e) { console.error('History load error:', e); }
|
||
}
|
||
|
||
let chatBusy = false;
|
||
|
||
function sendChat() {
|
||
const input = document.getElementById('chatInput');
|
||
const text = input.value.trim();
|
||
if (!text || chatBusy) return;
|
||
input.value = '';
|
||
input.style.height = 'auto';
|
||
sendChatMessage(text);
|
||
}
|
||
|
||
function quickChat(text) {
|
||
sendChatMessage(text);
|
||
if (tg.HapticFeedback) tg.HapticFeedback.impactOccurred('light');
|
||
}
|
||
|
||
async function sendChatMessage(text) {
|
||
if (chatBusy) return;
|
||
chatBusy = true;
|
||
document.getElementById('chatChips').style.display = 'none';
|
||
const messagesEl = document.getElementById('chatMessages');
|
||
|
||
// User message
|
||
const userDiv = document.createElement('div');
|
||
userDiv.className = 'msg msg-user';
|
||
userDiv.textContent = text;
|
||
messagesEl.appendChild(userDiv);
|
||
|
||
// Typing indicator
|
||
const typingDiv = document.createElement('div');
|
||
typingDiv.className = 'msg-typing';
|
||
typingDiv.innerHTML = '<span></span><span></span><span></span>';
|
||
messagesEl.appendChild(typingDiv);
|
||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||
|
||
chatHistory.push({ role: 'user', content: text });
|
||
|
||
try {
|
||
const lang = (tg.initDataUnsafe?.user?.language_code || 'en').substring(0, 2);
|
||
const resp = await fetch(API_BASE + '/api/v1/chat', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer ' + authToken
|
||
},
|
||
body: JSON.stringify({ message: text, lang: lang })
|
||
});
|
||
if (!resp.ok) {
|
||
let errMsg = 'HTTP ' + resp.status;
|
||
try { const ej = await resp.json(); errMsg = ej.error || ej.response || errMsg; } catch(_) {}
|
||
throw new Error(errMsg);
|
||
}
|
||
const data = await resp.json();
|
||
typingDiv.remove();
|
||
|
||
const botDiv = document.createElement('div');
|
||
botDiv.className = 'msg msg-bot';
|
||
botDiv.innerHTML = formatResponse(data.response || 'No response');
|
||
messagesEl.appendChild(botDiv);
|
||
|
||
chatHistory.push({ role: 'assistant', content: data.response || '' });
|
||
if (tg.HapticFeedback) tg.HapticFeedback.notificationOccurred('success');
|
||
} catch (e) {
|
||
typingDiv.remove();
|
||
const errDiv = document.createElement('div');
|
||
errDiv.className = 'msg msg-bot';
|
||
errDiv.textContent = t('error') + e.message;
|
||
messagesEl.appendChild(errDiv);
|
||
}
|
||
chatBusy = false;
|
||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||
}
|
||
|
||
function formatResponse(text) {
|
||
// Handle SHOWMAP markers → buttons
|
||
text = text.replace(/\{\{SHOWMAP~([^~]+)~([^~]+)~([^~]+)~([^}]+)\}\}/g,
|
||
(_, lat, lon, zoom, label) => {
|
||
return `<button onclick="flyToMap(${lat},${lon},${zoom})" style="display:inline-block;padding:6px 12px;margin:4px 0;background:var(--btn);color:var(--btn-text);border:none;border-radius:8px;font-size:12px;cursor:pointer">${label} 🗺</button>`;
|
||
});
|
||
// Basic markdown
|
||
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||
text = text.replace(/\n/g, '<br>');
|
||
// Code blocks
|
||
text = text.replace(/```([\s\S]*?)```/g, '<pre>$1</pre>');
|
||
text = text.replace(/`([^`]+)`/g, '<code style="background:rgba(0,0,0,0.3);padding:2px 4px;border-radius:4px">$1</code>');
|
||
return text;
|
||
}
|
||
|
||
function flyToMap(lat, lon, zoom) {
|
||
switchTab('map');
|
||
if (leafletMap) {
|
||
leafletMap.setView([lat, lon], zoom || 10);
|
||
setTimeout(loadMapVessels, 500);
|
||
}
|
||
}
|
||
|
||
// =============================================================================
|
||
// Profile
|
||
// =============================================================================
|
||
async function loadProfile() {
|
||
if (!authToken) return;
|
||
try {
|
||
const [profResp, walResp] = await Promise.all([
|
||
fetch(API_BASE + '/api/v1/profile', { headers: { 'Authorization': 'Bearer ' + authToken } }),
|
||
fetch(API_BASE + '/api/v1/wallet', { headers: { 'Authorization': 'Bearer ' + authToken } }).catch(() => null),
|
||
]);
|
||
const profData = await profResp.json();
|
||
if (profData.success && profData.profile) {
|
||
const p = profData.profile;
|
||
const name = currentUser?.name || p.company_name || 'User';
|
||
document.getElementById('profAvatar').textContent = name.charAt(0).toUpperCase();
|
||
document.getElementById('profName').textContent = name;
|
||
document.getElementById('profRole').textContent = p.role || t('member');
|
||
document.getElementById('profRoutes').textContent =
|
||
(p.trade_routes && p.trade_routes.length) ? p.trade_routes.join(', ') : '—';
|
||
document.getElementById('profCargo').textContent =
|
||
(p.cargo_types && p.cargo_types.length) ? p.cargo_types.map(c => c.replace(/_/g,' ')).join(', ') : '—';
|
||
}
|
||
if (walResp) {
|
||
const walData = await walResp.json();
|
||
if (walData.success) {
|
||
document.getElementById('profBalance').textContent = '$' + (parseFloat(walData.balance) || 0).toFixed(2);
|
||
}
|
||
}
|
||
// Load watches
|
||
loadWatches();
|
||
} catch (e) { console.error('Profile load error:', e); }
|
||
}
|
||
|
||
async function loadWatches() {
|
||
// Watches are Telegram-specific, loaded via chat command
|
||
// For now show static message
|
||
}
|
||
|
||
function openFullSite() {
|
||
tg.openLink('https://seafare.efir.org');
|
||
}
|
||
|
||
// =============================================================================
|
||
// Auto-resize textarea
|
||
// =============================================================================
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const ta = document.getElementById('chatInput');
|
||
if (ta) {
|
||
ta.addEventListener('input', function() {
|
||
this.style.height = 'auto';
|
||
this.style.height = Math.min(this.scrollHeight, 100) + 'px';
|
||
});
|
||
}
|
||
});
|
||
|
||
// =============================================================================
|
||
// Start
|
||
// =============================================================================
|
||
init().catch(function(e) {
|
||
console.error("Init error:", e);
|
||
var ls = document.getElementById("loadingScreen");
|
||
if (ls) ls.style.display = "none";
|
||
var nl = document.getElementById("notLinkedScreen");
|
||
if (nl) nl.style.display = "flex";
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|