Files
WebAisMap/static/js/app.js
T
Grigo 03075f1ef1 Initial import: WebAisMap
Closes TG-4

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 07:56:45 +03:00

6078 lines
268 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ===================== Tab switching =====================
const APP_BUILD = '2026-04-30h';
try { console.log('[AISMap] app.js build', APP_BUILD, 'loaded at', new Date().toISOString()); } catch (e) {}
let currentTab = 'map';
// ===================== ais_hub state (fed by WebSocket /ws) =====================
// Всё AIS/GPS состояние хранится здесь и наполняется live-событиями ais_hub.
// Рендер-функции (updateVessels/…/updateSlots) читают отсюда вместо /api/*.
const AisHub = {
vessels: new Map(), // mmsi -> vessel (наш внутренний shape)
baseStations: new Map(), // mmsi -> base station
atons: new Map(), // mmsi -> buoy/AtoN
ownship: null, // последний GPS-fix из ais_hub (наш shape)
stats: null, // сырой /api/v1/stats
sysinfo: null, // локальный /api/sysinfo
slots: {A: null, B: null}, // per-channel slot bitmap/occupancy + detail
slotDetail: {A: null, B: null},
rssiHistory: {A: [], B: []}, // история noise_floor/threshold per minute
livePower: {A: [], B: []}, // мгновенная мощность ~10 Гц
signalEvents: {A: [], B: []}, // события декодов (sparkline сигнала)
snapshotLoaded: false,
wsOpen: false,
lastEventTs: 0,
};
window.AisHub = AisHub;
const AIS_LIVE_POWER_MAX = 600;
const AIS_SIGNAL_EVENTS_MAX = 300;
const AIS_RSSI_HISTORY_MAX = 120;
// ITU-R M.1371-5 / Message 21 Table 74: тип средства навигационного оборудования (СНО / AtoN).
const AIS_ATON_TYPE_LABELS = {
0: 'Тип СНО не указан',
1: 'Опорная точка',
2: 'RACON (радиолокационный ответчик)',
3: 'Стационарное морское сооружение',
4: 'Аварийный буй обозначения затонувшего объекта',
5: 'Огонь без секторов',
6: 'Огонь с секторами',
7: 'Передний створный огонь',
8: 'Задний створный огонь',
9: 'Знак: кардинальный северный',
10: 'Знак: кардинальный восточный',
11: 'Знак: кардинальный южный',
12: 'Знак: кардинальный западный',
13: 'Знак левой стороны фарватера',
14: 'Знак правой стороны фарватера',
15: 'Знак предпочтительного канала, левая сторона',
16: 'Знак предпочтительного канала, правая сторона',
17: 'Знак изолированной опасности',
18: 'Знак чистой воды',
19: 'Специальный знак',
20: 'Плавучий кардинальный знак северный',
21: 'Плавучий кардинальный знак восточный',
22: 'Плавучий кардинальный знак южный',
23: 'Плавучий кардинальный знак западный',
24: 'Плавучий знак левой стороны',
25: 'Плавучий знак правой стороны',
26: 'Плавучий знак предпочтительного канала, левая сторона',
27: 'Плавучий знак предпочтительного канала, правая сторона',
28: 'Плавучий знак изолированной опасности',
29: 'Плавучий знак чистой воды',
30: 'Специальный плавучий знак',
31: 'Плавучий маяк / LANBY / буровая установка',
};
function atonTypeLabel(code) {
if (code == null || code === '') return null;
const n = parseInt(code, 10);
if (!isNaN(n) && Object.prototype.hasOwnProperty.call(AIS_ATON_TYPE_LABELS, n)) {
return AIS_ATON_TYPE_LABELS[n];
}
const s = String(code).trim();
return s || null;
}
// Infer vessel class from observed AIS message types.
function _vesselClassFromMsgTypes(msgTypes) {
if (!Array.isArray(msgTypes)) return null;
let sawA = false, sawB = false, sawBS = false, sawATON = false;
for (const t of msgTypes) {
const n = parseInt(t, 10);
if (n === 1 || n === 2 || n === 3 || n === 5) sawA = true;
else if (n === 18 || n === 19 || n === 24) sawB = true;
else if (n === 4 || n === 11) sawBS = true;
else if (n === 21) sawATON = true;
}
if (sawA) return 'A';
if (sawB) return 'B';
if (sawBS) return 'BS';
if (sawATON) return 'N';
return null;
}
// MergedTarget (ais_hub) → наш вессел shape (shipname/course/speed/timestamp/…).
function mergedTargetToVessel(t) {
if (!t || typeof t !== 'object') return null;
const dyn = t.dynamic || {};
const dims = t.dims || {};
const voy = t.voyage || {};
const sig = t.signal || {};
const name = (t.name != null) ? String(t.name).trim() : null;
const callsign = (t.callsign != null) ? String(t.callsign).trim() : null;
const ts = t.last_seen != null ? t.last_seen
: (t.last_dynamic_ts || t.last_static_ts || 0);
return {
mmsi: t.mmsi,
shipname: name || null,
callsign: callsign || null,
imo: t.imo != null ? t.imo : null,
shiptype: t.ship_type != null ? t.ship_type : null,
vessel_class: _vesselClassFromMsgTypes(t.msg_types),
lat: dyn.lat != null ? dyn.lat : null,
lon: dyn.lon != null ? dyn.lon : null,
course: dyn.cog != null ? dyn.cog : null,
speed: dyn.sog != null ? dyn.sog : null,
heading: dyn.heading != null ? dyn.heading : null,
nav_status: dyn.nav_status != null ? dyn.nav_status : null,
rot: dyn.rot != null ? dyn.rot : null,
to_bow: dims.a != null ? dims.a : null,
to_stern: dims.b != null ? dims.b : null,
to_port: dims.c != null ? dims.c : null,
to_starboard: dims.d != null ? dims.d : null,
eta: voy.eta != null ? voy.eta : null,
draught: voy.draught != null ? voy.draught : null,
destination: voy.destination != null ? voy.destination : null,
signal_db: sig.last_db != null ? sig.last_db : null,
signal_ts: sig.last_ts != null ? sig.last_ts : null,
timestamp: ts ? Math.floor(ts) : 0,
last_seen: ts,
};
}
// ais_hub ownship (GET /api/v1/ownship / ownship.update) → наш shape.
function ownshipToLocal(o) {
if (!o || typeof o !== 'object') return null;
return {
lat: o.lat != null ? o.lat : null,
lon: o.lon != null ? o.lon : null,
course: o.cog != null ? o.cog : null,
speed: o.sog != null ? o.sog : null,
heading: null,
satellites: o.sats != null ? o.sats : null,
fix_quality: o.fix_quality != null ? o.fix_quality : null,
timestamp: o.ts != null ? Math.floor(o.ts) : null,
source: 'nmea',
};
}
// base_station.update → наш shape для маркера.
function baseStationToLocal(b) {
if (!b || typeof b !== 'object') return null;
return {
mmsi: b.mmsi,
lat: b.lat != null ? b.lat : null,
lon: b.lon != null ? b.lon : null,
virtual: !!(b.virtual || b.virtual_station),
synthetic: !!b.synthetic,
timestamp: b.ts != null ? Math.floor(b.ts) : (b.timestamp != null ? Math.floor(b.timestamp) : 0),
};
}
// aton.update → наш buoy shape.
function atonToLocal(a) {
if (!a || typeof a !== 'object') return null;
const dims = a.dims || {};
const atonType = a.type != null ? a.type : (a.aton_type != null ? a.aton_type : (a.aid_type != null ? a.aid_type : null));
return {
mmsi: a.mmsi,
lat: a.lat != null ? a.lat : null,
lon: a.lon != null ? a.lon : null,
name: a.name || null,
aton_type: atonType,
aton_type_label: atonTypeLabel(atonType),
virtual: !!(a.virtual || a.virtual_aid || a.virtual_aton),
synthetic: !!a.synthetic,
off_position: !!(a.off_position || a.off_pos),
to_bow: a.to_bow != null ? a.to_bow : (dims.a != null ? dims.a : (a.a != null ? a.a : null)),
to_stern: a.to_stern != null ? a.to_stern : (dims.b != null ? dims.b : (a.b != null ? a.b : null)),
to_port: a.to_port != null ? a.to_port : (dims.c != null ? dims.c : (a.c != null ? a.c : null)),
to_starboard: a.to_starboard != null ? a.to_starboard : (dims.d != null ? dims.d : (a.d != null ? a.d : null)),
timestamp: a.ts != null ? Math.floor(a.ts) : (a.timestamp != null ? Math.floor(a.timestamp) : 0),
};
}
// occupancy object (GET /api/v1/slots → data.occupancy[A|B]) or slots.update event.
function slotsOccupancyToLocal(s) {
if (!s || typeof s !== 'object') return null;
return {
utc_minute: s.utc_minute != null ? s.utc_minute : null,
total: s.slots_total != null ? s.slots_total : 2250,
occupied: s.occupied_count != null ? s.occupied_count : 0,
noise_floor_dbm: null,
threshold_dbm: null,
slot0_unix_ms: s.slot0_unix_ms != null ? s.slot0_unix_ms : null,
first_occupied_unix_ms: s.first_occupied_unix_ms != null ? s.first_occupied_unix_ms : null,
bitmap: s.bitmap_hex || null,
timestamp: s.ts != null ? Math.floor(s.ts) : Math.floor(Date.now() / 1000),
};
}
// slots.detail event or REST detail[A|B] — список занятых слотов с уровнем сигнала.
function slotsDetailToLocal(d) {
if (!d || typeof d !== 'object') return null;
const signals = {};
const entries = Array.isArray(d.entries) ? d.entries : [];
for (const e of entries) {
if (!e || e.slot == null) continue;
signals[e.slot] = (e.level_db != null) ? Math.round(e.level_db * 10) / 10 : null;
}
return {
utc_minute: d.utc_minute != null ? d.utc_minute : null,
signals,
timestamp: d.ts != null ? Math.floor(d.ts) : Math.floor(Date.now() / 1000),
};
}
// Throttled redraw coordinator: несколько target.update'ов подряд → один redraw.
const AisHubRedraw = (function () {
let pending = { vessels: false, baseStations: false, atons: false, ownship: false, stats: false, slots: false };
let timer = null;
function flush() {
timer = null;
const p = pending;
pending = { vessels: false, baseStations: false, atons: false, ownship: false, stats: false, slots: false };
try {
if (p.ownship && typeof updateOwnShipDisplay === 'function') updateOwnShipDisplay();
if (p.vessels && typeof updateVessels === 'function') updateVessels();
if (p.baseStations && typeof updateBaseStations === 'function') updateBaseStations();
if (p.atons && typeof updateBuoys === 'function') updateBuoys();
if (p.stats && currentTab === 'stats' && typeof updateStats === 'function') updateStats();
if (p.slots && typeof slotsOpen !== 'undefined' && slotsOpen && typeof updateSlots === 'function') updateSlots();
if (typeof adjustSidebarHeight === 'function') adjustSidebarHeight();
} catch (e) { try { console.error('[AISMap] redraw failed', e); } catch (_) {} }
}
return {
request(kind) {
if (kind && pending.hasOwnProperty(kind)) pending[kind] = true;
if (timer == null) timer = setTimeout(flush, 200);
},
flushNow: flush,
};
})();
// Обработка одного события от ais_hub (target.update / ownship.update / …).
function handleAisHubEvent(ev) {
if (!ev || typeof ev !== 'object') return;
const t = ev.type;
const d = ev.data;
AisHub.lastEventTs = Date.now();
if (t === 'state.snapshot' && d) {
// Полная замена витрин.
AisHub.vessels.clear();
if (Array.isArray(d.vessels)) {
for (const mt of d.vessels) {
const v = mergedTargetToVessel(mt);
if (v && v.mmsi != null) AisHub.vessels.set(v.mmsi, v);
}
}
AisHub.baseStations.clear();
if (Array.isArray(d.base_stations)) {
for (const b of d.base_stations) {
const bs = baseStationToLocal(b);
if (bs && bs.mmsi != null) AisHub.baseStations.set(bs.mmsi, bs);
}
}
AisHub.atons.clear();
if (Array.isArray(d.atons)) {
for (const a of d.atons) {
const an = atonToLocal(a);
if (an && an.mmsi != null) AisHub.atons.set(an.mmsi, an);
}
}
AisHub.ownship = d.ownship ? ownshipToLocal(d.ownship) : null;
AisHub.stats = d.stats || null;
if (d.slots) {
const occ = d.slots.occupancy || {};
const det = d.slots.detail || {};
AisHub.slots.A = occ.A ? slotsOccupancyToLocal(occ.A) : null;
AisHub.slots.B = occ.B ? slotsOccupancyToLocal(occ.B) : null;
AisHub.slotDetail.A = det.A ? slotsDetailToLocal(det.A) : null;
AisHub.slotDetail.B = det.B ? slotsDetailToLocal(det.B) : null;
}
AisHub.snapshotLoaded = true;
AisHubRedraw.request('vessels');
AisHubRedraw.request('baseStations');
AisHubRedraw.request('atons');
AisHubRedraw.request('ownship');
AisHubRedraw.request('stats');
AisHubRedraw.request('slots');
return;
}
if (t === 'target.update' && d) {
const v = mergedTargetToVessel(d);
if (v && v.mmsi != null) {
AisHub.vessels.set(v.mmsi, v);
AisHubRedraw.request('vessels');
}
return;
}
if (t === 'ownship.update' && d) {
AisHub.ownship = ownshipToLocal(d);
AisHubRedraw.request('ownship');
return;
}
if (t === 'base_station.update' && d) {
const bs = baseStationToLocal(d);
if (bs && bs.mmsi != null) {
AisHub.baseStations.set(bs.mmsi, bs);
AisHubRedraw.request('baseStations');
}
return;
}
if (t === 'aton.update' && d) {
const an = atonToLocal(d);
if (an && an.mmsi != null) {
AisHub.atons.set(an.mmsi, an);
AisHubRedraw.request('atons');
}
return;
}
if (t === 'stats.update' && d) {
AisHub.stats = d;
AisHubRedraw.request('stats');
return;
}
if (t === 'slots.update' && d && (d.channel === 'A' || d.channel === 'B')) {
const ch = d.channel;
AisHub.slots[ch] = slotsOccupancyToLocal(d);
// Поддерживаем «per-minute» историю RSSI (для графиков), noise/threshold
// ais_hub нам не присылает → используем пустые плейсхолдеры, оставляем «ts».
const hist = AisHub.rssiHistory[ch];
hist.push({ ts: (d.ts || Math.floor(Date.now() / 1000)), nf: null, th: null });
if (hist.length > AIS_RSSI_HISTORY_MAX) hist.splice(0, hist.length - AIS_RSSI_HISTORY_MAX);
AisHubRedraw.request('slots');
return;
}
if (t === 'slots.detail' && d && (d.channel === 'A' || d.channel === 'B')) {
const ch = d.channel;
AisHub.slotDetail[ch] = slotsDetailToLocal(d);
AisHubRedraw.request('slots');
return;
}
if (t === 'signal.update' && d && (d.channel === 'A' || d.channel === 'B')) {
const ch = d.channel;
const events = Array.isArray(d.events) ? d.events : [];
const store = AisHub.signalEvents[ch];
for (const e of events) {
if (!e) continue;
store.push({
ts: e.unix_ms != null ? e.unix_ms / 1000 : Math.floor(Date.now() / 1000),
slot: e.slot != null ? e.slot : null,
mmsi: e.mmsi != null ? e.mmsi : null,
signal: e.level_db != null ? Math.round(e.level_db * 10) / 10 : null,
});
}
if (store.length > AIS_SIGNAL_EVENTS_MAX) store.splice(0, store.length - AIS_SIGNAL_EVENTS_MAX);
AisHubRedraw.request('slots');
return;
}
if (t === 'radio.update' && d) {
if (d.source === 'aiscatcher_rssi' || d.power_a_db != null || d.power_b_db != null) {
const ts = ev.ts || (Date.now() / 1000);
if (d.power_a_db != null) {
AisHub.livePower.A.push({ ts, power: Math.round(d.power_a_db * 10) / 10 });
if (AisHub.livePower.A.length > AIS_LIVE_POWER_MAX) AisHub.livePower.A.shift();
}
if (d.power_b_db != null) {
AisHub.livePower.B.push({ ts, power: Math.round(d.power_b_db * 10) / 10 });
if (AisHub.livePower.B.length > AIS_LIVE_POWER_MAX) AisHub.livePower.B.shift();
}
AisHubRedraw.request('slots');
}
return;
}
}
// WebSocket-клиент с переподключением (1→2→5→10 с).
const AisHubWS = (function () {
let ws = null;
let backoff = 1000;
let reconnectTimer = null;
function open() {
if (reconnectTimer != null) { clearTimeout(reconnectTimer); reconnectTimer = null; }
if (ws) {
try {
ws.onopen = ws.onclose = ws.onerror = ws.onmessage = null;
if (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN) {
ws.close(1000, 'reconnect');
}
} catch (_) {}
ws = null;
}
try {
const proto = (location.protocol === 'https:') ? 'wss:' : 'ws:';
const url = proto + '//' + location.host + '/ws';
ws = new WebSocket(url);
} catch (e) {
scheduleReconnect();
return;
}
ws.onopen = () => {
AisHub.wsOpen = true;
backoff = 1000;
try { console.log('[AISMap] ws open'); } catch (_) {}
};
ws.onmessage = (m) => {
let ev;
try { ev = JSON.parse(m.data); } catch (e) { return; }
handleAisHubEvent(ev);
};
ws.onerror = () => {};
ws.onclose = () => {
AisHub.wsOpen = false;
try { console.warn('[AISMap] ws close, reconnect in', backoff, 'ms'); } catch (_) {}
scheduleReconnect();
};
}
function scheduleReconnect() {
if (reconnectTimer != null) return;
reconnectTimer = setTimeout(() => { reconnectTimer = null; open(); }, backoff);
backoff = Math.min(backoff * 2, 10000);
}
return { open };
})();
const tabs = document.querySelectorAll('.nav-tab');
const pages = document.querySelectorAll('.tab-page');
const hamburger = document.getElementById('hamburger');
const navTabs = document.getElementById('nav-tabs');
// ===================== VisualViewport safe insets (WebView/browser UI) =====================
function updateVisualViewportInsets(){
const vv = window.visualViewport;
if (!vv) return;
// Approximate occluded areas in CSS px
const top = Math.max(0, vv.offsetTop || 0);
const bottom = Math.max(0, (window.innerHeight - (vv.height + (vv.offsetTop || 0))) || 0);
const left = Math.max(0, vv.offsetLeft || 0);
const right = Math.max(0, (window.innerWidth - (vv.width + (vv.offsetLeft || 0))) || 0);
const root = document.documentElement;
root.style.setProperty('--vv-top', top.toFixed(0) + 'px');
root.style.setProperty('--vv-bottom', bottom.toFixed(0) + 'px');
root.style.setProperty('--vv-left', left.toFixed(0) + 'px');
root.style.setProperty('--vv-right', right.toFixed(0) + 'px');
}
try{
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', updateVisualViewportInsets);
window.visualViewport.addEventListener('scroll', updateVisualViewportInsets);
window.addEventListener('resize', updateVisualViewportInsets);
updateVisualViewportInsets();
}
}catch(e){}
// ===================== Session settings (cookie) =====================
function _cookieOpts() {
const secure = (location && location.protocol === 'https:') ? '; Secure' : '';
return '; Path=/; SameSite=Lax' + secure;
}
function cookieGet(name) {
try {
const key = encodeURIComponent(name) + '=';
const parts = String(document.cookie || '').split(/;\s*/);
for (const p of parts) {
if (p.startsWith(key)) return decodeURIComponent(p.slice(key.length));
}
} catch (e) {}
return null;
}
function cookieSet(name, value) {
try {
document.cookie = encodeURIComponent(name) + '=' + encodeURIComponent(String(value)) + _cookieOpts();
return true;
} catch (e) {}
return false;
}
const SSK = 'aismap_';
function sGet(key, def) {
const v = cookieGet(SSK + key);
return (v == null || v === '') ? def : v;
}
function sSet(key, value) {
return cookieSet(SSK + key, value);
}
function switchTab(tab) {
currentTab = tab;
tabs.forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
pages.forEach(p => p.classList.toggle('active', p.id === 'page-' + tab));
navTabs.classList.remove('open');
if (tab === 'map') {
setTimeout(() => map.invalidateSize(), 50);
}
if (tab === 'config') {
if (!cfgLoaded) loadConfig();
loadServiceStatus();
}
if (tab === 'console') {
initConsole().then(() => {
if (window._fitTerminal) setTimeout(() => window._fitTerminal(), 80);
});
}
if (tab === 'transponder') {
loadTransponderPage();
}
if (tab === 'targets') {
try { renderTargetsTab(); } catch (_) {}
}
}
tabs.forEach(t => t.addEventListener('click', () => switchTab(t.dataset.tab)));
hamburger.addEventListener('click', () => navTabs.classList.toggle('open'));
// ===================== Transponder (Class B) =====================
async function tpParseJsonResponse(r) {
const text = await r.text();
if (!text || !text.trim()) {
return { _empty: true, _status: r.status };
}
try {
return JSON.parse(text);
} catch (e) {
const snippet = text.replace(/\s+/g, ' ').slice(0, 180);
throw new Error('Ответ не JSON (HTTP ' + r.status + '): ' + snippet);
}
}
function tpCollectConfig() {
return {
mmsi: parseInt(document.getElementById('tp-mmsi').value, 10) || 0,
shipname: document.getElementById('tp-shipname').value,
callsign: document.getElementById('tp-callsign').value,
ship_type: parseInt(document.getElementById('tp-ship-type').value, 10) || 0,
to_bow: parseInt(document.getElementById('tp-to-bow').value, 10) || 0,
to_stern: parseInt(document.getElementById('tp-to-stern').value, 10) || 0,
to_port: parseInt(document.getElementById('tp-to-port').value, 10) || 0,
to_starboard: parseInt(document.getElementById('tp-to-starboard').value, 10) || 0,
vendorid: document.getElementById('tp-vendorid').value,
model: parseInt(document.getElementById('tp-model').value, 10) || 0,
serial: parseInt(document.getElementById('tp-serial').value, 10) || 0,
use_gps_motion: document.getElementById('tp-use-gps').checked,
nrzi_slot_channel: document.getElementById('tp-slot-channel').value,
nrzi_slot: parseInt(document.getElementById('tp-slot-number').value, 10) || 0,
nrzi_encoder: document.getElementById('tp-nrzi-encoder').value,
nrzi_mode: document.getElementById('tp-nrzi-mode').value,
include_nrzi_preamble: document.getElementById('tp-preamble').checked,
nrzi_preamble_bits: Math.max(0, Math.min(128, parseInt(document.getElementById('tp-preamble-bits').value, 10) || 0)),
nrzi_pad_payload_to_octet: document.getElementById('tp-pad-payload').checked,
nrzi_pad_nrz_bits: Math.max(0, Math.min(4096, parseInt(document.getElementById('tp-pad-nrz-bits').value, 10) || 0)),
tx_gpio_pulse_auto: document.getElementById('tp-gpio-auto').checked,
tx_gpio_pulse_delay_ms: Math.max(50, Math.min(100, parseInt(document.getElementById('tp-gpio-delay-ms').value, 10) || 75)),
tx_gpio_pulse_script: document.getElementById('tp-gpio-script').value,
};
}
function tpApplyConfig(c) {
if (!c) return;
document.getElementById('tp-mmsi').value = c.mmsi != null ? c.mmsi : '';
document.getElementById('tp-shipname').value = c.shipname || '';
document.getElementById('tp-callsign').value = c.callsign || '';
const stSel = document.getElementById('tp-ship-type');
if (stSel) {
const stv = c.ship_type != null ? parseInt(c.ship_type, 10) : 0;
if (typeof window.ensureShipTypeOption === 'function') {
window.ensureShipTypeOption(Number.isFinite(stv) ? stv : 0);
}
stSel.value = String(Number.isFinite(stv) ? Math.max(0, Math.min(255, stv)) : 0);
}
document.getElementById('tp-to-bow').value = c.to_bow != null ? c.to_bow : '';
document.getElementById('tp-to-stern').value = c.to_stern != null ? c.to_stern : '';
document.getElementById('tp-to-port').value = c.to_port != null ? c.to_port : '';
document.getElementById('tp-to-starboard').value = c.to_starboard != null ? c.to_starboard : '';
document.getElementById('tp-vendorid').value = c.vendorid || '';
document.getElementById('tp-model').value = c.model != null ? c.model : '';
document.getElementById('tp-serial').value = c.serial != null ? c.serial : '';
document.getElementById('tp-use-gps').checked = !!c.use_gps_motion;
const tch = document.getElementById('tp-slot-channel');
if (tch) tch.value = (c.nrzi_slot_channel === 'B') ? 'B' : 'A';
const tsn = document.getElementById('tp-slot-number');
if (tsn) {
const sn = c.nrzi_slot != null ? Math.max(0, Math.min(2249, parseInt(c.nrzi_slot, 10) || 0)) : 0;
tsn.value = sn;
}
const enc = document.getElementById('tp-nrzi-encoder');
if (enc) enc.value = (c.nrzi_encoder === 'ais_phy') ? 'ais_phy' : 'aistx';
document.getElementById('tp-nrzi-mode').value = (c.nrzi_mode === 'expanded') ? 'expanded' : 'packed';
document.getElementById('tp-preamble').checked = c.include_nrzi_preamble !== false;
document.getElementById('tp-pad-payload').checked = c.nrzi_pad_payload_to_octet !== false;
const tpn = document.getElementById('tp-pad-nrz-bits');
if (tpn) {
const pn = c.nrzi_pad_nrz_bits != null ? parseInt(c.nrzi_pad_nrz_bits, 10) : 256;
tpn.value = isNaN(pn) ? 256 : Math.max(0, Math.min(4096, pn));
}
const tpb = document.getElementById('tp-preamble-bits');
if (tpb) {
const pb = c.nrzi_preamble_bits != null ? parseInt(c.nrzi_preamble_bits, 10) : 24;
tpb.value = isNaN(pb) ? 24 : Math.max(0, Math.min(128, pb));
}
const gAuto = document.getElementById('tp-gpio-auto');
if (gAuto) gAuto.checked = !!c.tx_gpio_pulse_auto;
const gDel = document.getElementById('tp-gpio-delay-ms');
if (gDel) {
const gd = c.tx_gpio_pulse_delay_ms != null ? parseInt(c.tx_gpio_pulse_delay_ms, 10) : 75;
gDel.value = isNaN(gd) ? 75 : Math.max(50, Math.min(100, gd));
}
const gScr = document.getElementById('tp-gpio-script');
if (gScr) gScr.value = c.tx_gpio_pulse_script != null ? c.tx_gpio_pulse_script : '';
if (typeof window.shipDimsEditorRefresh === 'function') window.shipDimsEditorRefresh();
}
function tpFormatSendResult(res) {
if (!res) return '';
const parts = [];
if (res.dest) parts.push(res.dest);
if (res.slot_channel != null && res.slot != null) parts.push('ch ' + res.slot_channel + ' slot ' + res.slot);
if (res.nrzi_bytes) parts.push('UDP: ' + res.nrzi_bytes + ' B');
if (res.payload_bytes != null) parts.push('NRZI payload: ' + res.payload_bytes + ' B');
if (res.udp_bytes != null) parts.push('UDP кадр: ' + res.udp_bytes + ' B');
if (res.gpio_pulse && res.gpio_pulse.length) {
const gp = res.gpio_pulse.map(function (p) {
if (p.ok) return 'ok';
return 'fail: ' + (p.error || p.stderr || p.returncode);
}).join('; ');
parts.push('GPIO: ' + gp);
}
if (res.errors && res.errors.length) parts.push(res.errors.join('; '));
return parts.join(' · ');
}
function tpFormatPreview(prev) {
if (!prev || !prev.nrzi_hex) return '';
const lines = [];
if (prev.dest) lines.push('Назначение UDP: ' + prev.dest);
if (prev.slot_channel != null && prev.slot != null) {
lines.push('Заголовок кадра: канал ' + prev.slot_channel + ', слот ' + prev.slot + ' (как тест слота)');
}
const phy = prev.phy;
if (phy) {
lines.push('=== PHY (отладка) ===');
lines.push('Преамбула: ' + (phy.include_preamble ? phy.preamble_bits + ' бит 1010… (как gr-aistx)' : 'выкл (только HDLC+NRZI)'));
lines.push('NRZI режим: ' + (phy.nrzi_mode || ''));
if (phy.fcs) lines.push('FCS: ' + phy.fcs);
if (phy.hdlc) lines.push('HDLC: ' + phy.hdlc);
if (phy.nrzi) lines.push('NRZI: ' + phy.nrzi);
if (phy.packed) lines.push('Packed: ' + phy.packed);
if (phy.nrzi_encoder) lines.push('Кодер: ' + phy.nrzi_encoder);
if (phy.aistx_phy_note) lines.push(phy.aistx_phy_note);
if (phy.pad_payload_to_octet != null) {
lines.push('Добор payload до октета: ' + (phy.pad_payload_to_octet ? 'да' : 'нет'));
}
if (phy.pad_nrz_total_bits != null && phy.pad_nrz_total_bits > 0) {
lines.push('Добор NRZ перед NRZI до: ' + phy.pad_nrz_total_bits + ' бит');
} else if (phy.pad_nrz_total_bits === null || phy.pad_nrz_total_bits === 0) {
lines.push('Добор NRZ перед NRZI: выкл');
}
const pm = phy.per_message || {};
['18', '19', '24A', '24B'].forEach(k => {
const s = pm[k];
if (!s) return;
const p0 = s.payload_bits_input != null ? s.payload_bits_input : s.payload_bits;
const p1 = s.payload_bits_after_pad != null ? s.payload_bits_after_pad : p0;
const ptxt = p0 === p1 ? (p0 + ' бит') : (p0 + '→' + p1 + ' бит');
const stuff = s.bits_between_flags_stuffed != null ? s.bits_between_flags_stuffed : '—';
const hdlc = s.hdlc_frame_bits != null ? s.hdlc_frame_bits + ' бит' : (s.hdlc_note || '—');
lines.push(k + ': payload ' + ptxt + ', stuff ' + stuff + ', HDLC ' + hdlc +
', NRZI ' + s.nrzi_packed_bytes + ' B, UDP ' + s.udp_total_bytes + ' B');
});
if (phy.test_slot_note) lines.push(phy.test_slot_note);
}
lines.push('=== Только NRZI (hex) ===');
const hx = prev.nrzi_hex || {};
['18', '19', '24A', '24B'].forEach(k => {
const h = hx[k];
lines.push(k + ': ' + (h ? h.length + ' hex — ' + h.slice(0, 120) + (h.length > 120 ? '…' : '') : '-'));
});
const fr = prev.udp_frame_hex || {};
lines.push('=== Полный UDP payload: канал+слот+NRZI (hex) ===');
['18', '19', '24A', '24B'].forEach(k => {
const h = fr[k];
lines.push(k + ': ' + (h ? h.length + ' hex — ' + h.slice(0, 140) + (h.length > 140 ? '…' : '') : '-'));
});
return lines.join('\n');
}
function tpSetMsg(el, text, ok) {
if (!el) return;
el.textContent = text || '';
el.classList.remove('ok', 'err');
if (text) el.classList.add(ok ? 'ok' : 'err');
}
async function loadTransponderPage() {
const msg = document.getElementById('tp-msg');
try {
const r = await fetch('/api/transponder');
const d = await tpParseJsonResponse(r);
if (d._empty || d.ok === false) {
tpSetMsg(msg, d.error || 'Пустой ответ API', false);
return;
}
tpApplyConfig(d.config);
const encSel = document.getElementById('tp-nrzi-encoder');
if (encSel) {
const ok = !!d.aistx_phy_available;
Array.from(encSel.options).forEach(function(o) {
if (o.value === 'aistx') o.disabled = !ok;
});
if (!ok && encSel.value === 'aistx') encSel.value = 'ais_phy';
}
const own = d.ownship || {};
const hint = document.getElementById('tp-ownship-hint');
if (hint) {
if (own.lat != null && own.lon != null) {
hint.textContent = own.lat.toFixed(5) + ', ' + own.lon.toFixed(5) +
' | COG ' + (own.course != null ? own.course : '—') +
' | SOG ' + (own.speed != null ? own.speed : '—');
} else {
hint.textContent = 'Нет фикса';
}
}
tpSetMsg(msg, '', true);
} catch (e) {
tpSetMsg(msg, 'Загрузка: ' + e.message, false);
}
}
async function tpDoPreview() {
const out = document.getElementById('tp-preview-out');
const msg = document.getElementById('tp-msg');
const sn = parseInt(document.getElementById('tp-slot-number').value, 10);
if (isNaN(sn) || sn < 0 || sn > 2249) {
tpSetMsg(msg, 'Слот 02249', false);
return;
}
tpSetMsg(msg, 'Превью…', true);
try {
const r = await fetch('/api/transponder/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tpCollectConfig()),
});
const d = await tpParseJsonResponse(r);
if (!d.ok) {
tpSetMsg(msg, d.error || 'Ошибка', false);
return;
}
out.textContent = tpFormatPreview(d.preview);
tpSetMsg(msg, 'Превью обновлено', true);
} catch (e) {
tpSetMsg(msg, e.message, false);
}
}
async function tpDoSave() {
const msg = document.getElementById('tp-msg');
try {
const r = await fetch('/api/transponder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tpCollectConfig()),
});
const d = await tpParseJsonResponse(r);
if (!d.ok && d.error) {
tpSetMsg(msg, d.error, false);
return;
}
if (d.config) tpApplyConfig(d.config);
tpSetMsg(msg, 'Сохранено', true);
} catch (e) {
tpSetMsg(msg, e.message, false);
}
}
async function tpDoGpioPulseOnce() {
const msg = document.getElementById('tp-msg');
tpSetMsg(msg, 'Импульс GPIO…', true);
try {
const r = await fetch('/api/transponder/gpio_pulse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tpCollectConfig()),
});
const d = await tpParseJsonResponse(r);
if (!d.ok) {
tpSetMsg(msg, d.error || 'Ошибка GPIO', false);
return;
}
const p = d.pulse || {};
tpSetMsg(msg, 'GPIO: ok (code ' + (p.returncode != null ? p.returncode : 0) + ')', true);
} catch (e) {
tpSetMsg(msg, e.message, false);
}
}
async function tpDoSend(which) {
const msg = document.getElementById('tp-msg');
const sn = parseInt(document.getElementById('tp-slot-number').value, 10);
if (isNaN(sn) || sn < 0 || sn > 2249) {
tpSetMsg(msg, 'Слот 02249', false);
return;
}
const body = Object.assign({}, tpCollectConfig(), { which });
tpSetMsg(msg, 'Отправка…', true);
try {
const r = await fetch('/api/transponder/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const d = await tpParseJsonResponse(r);
if (!d.ok) {
tpSetMsg(msg, d.error || 'Ошибка', false);
return;
}
const res = d.result || {};
const line = tpFormatSendResult(res);
tpSetMsg(msg, line || 'Готово', !(res.errors && res.errors.length));
} catch (e) {
tpSetMsg(msg, e.message, false);
}
}
(function initTransponderUi() {
const save = document.getElementById('tp-save');
const preview = document.getElementById('tp-preview');
if (!save || !preview) return;
save.addEventListener('click', () => tpDoSave());
preview.addEventListener('click', () => tpDoPreview());
document.getElementById('tp-send-18').addEventListener('click', () => tpDoSend('18'));
document.getElementById('tp-send-19').addEventListener('click', () => tpDoSend('19'));
document.getElementById('tp-send-24a').addEventListener('click', () => tpDoSend('24A'));
document.getElementById('tp-send-24b').addEventListener('click', () => tpDoSend('24B'));
document.getElementById('tp-send-broadcast').addEventListener('click', () => tpDoSend('broadcast'));
const gpioOnce = document.getElementById('tp-gpio-pulse-once');
if (gpioOnce) gpioOnce.addEventListener('click', () => tpDoGpioPulseOnce());
const rawBtn = document.getElementById('tp-send-raw');
if (rawBtn) rawBtn.addEventListener('click', () => tpDoSendRaw());
})();
async function tpDoSendRaw() {
const msg = document.getElementById('tp-msg');
const hex = document.getElementById('tp-raw-hex').value;
const channel = document.getElementById('tp-slot-channel').value;
const slot = parseInt(document.getElementById('tp-slot-number').value, 10);
if (isNaN(slot) || slot < 0 || slot > 2249) {
tpSetMsg(msg, 'Слот 02249', false);
return;
}
if (!hex.trim()) {
tpSetMsg(msg, 'Введите hex', false);
return;
}
tpSetMsg(msg, 'Отправка…', true);
try {
const r = await fetch('/api/transponder/send_raw', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.assign({}, tpCollectConfig(), { nrzi_hex: hex, channel, slot })),
});
const d = await tpParseJsonResponse(r);
if (!d.ok) {
tpSetMsg(msg, d.error || 'Ошибка', false);
return;
}
const res = d.result || {};
const line = tpFormatSendResult(res);
tpSetMsg(msg, line || 'Готово', !(res.errors && res.errors.length));
} catch (e) {
tpSetMsg(msg, e.message, false);
}
}
function loadStylesheet(href) {
return new Promise((resolve, reject) => {
const l = document.createElement('link');
l.rel = 'stylesheet';
l.href = href;
l.onload = () => resolve();
l.onerror = () => reject(new Error('css'));
document.head.appendChild(l);
});
}
function loadScript(src) {
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = src;
s.onload = resolve;
s.onerror = () => reject(new Error('script'));
document.head.appendChild(s);
});
}
let _consoleInitPromise = null;
function initConsole() {
if (window._consoleInited) {
if (window._fitTerminal) setTimeout(() => window._fitTerminal(), 80);
return Promise.resolve();
}
if (_consoleInitPromise) return _consoleInitPromise;
_consoleInitPromise = doInitConsole();
return _consoleInitPromise;
}
async function doInitConsole() {
try {
const elUnavailable = document.getElementById('terminal-unavailable');
const elWrap = document.getElementById('terminal-wrap');
const elStatus = document.getElementById('console-status');
let cfg;
try {
const r = await fetch('/api/terminal');
cfg = await r.json();
} catch (e) {
elUnavailable.style.display = 'block';
elUnavailable.textContent = 'Не удалось получить настройки терминала.';
elStatus.textContent = 'ошибка';
return;
}
if (!cfg.pty) {
elUnavailable.style.display = 'block';
elUnavailable.innerHTML = 'Интерактивная консоль доступна только на устройстве под Linux (PTY). На Windows shell в браузере не подключается.';
elStatus.textContent = 'недоступно';
window._consoleInited = true;
return;
}
if (!cfg.ws) {
elUnavailable.style.display = 'block';
elUnavailable.textContent = 'Установите зависимость: pip install flask-sock';
elStatus.textContent = 'нет WS';
window._consoleInited = true;
return;
}
try {
await loadStylesheet('/static/xterm/xterm.css');
await loadScript('/static/xterm/xterm.min.js');
await loadScript('/static/xterm/xterm-addon-fit.min.js');
} catch (e) {
elUnavailable.style.display = 'block';
elUnavailable.textContent = 'Не удалось загрузить xterm.js (проверьте сеть / CDN).';
elStatus.textContent = 'ошибка';
return;
}
const Term = window.Terminal;
/* UMD xterm-addon-fit: window.FitAddon — это модуль { FitAddon: class }, не конструктор */
const FitAddonMod = window.FitAddon;
const FitAddonCtor = (FitAddonMod && typeof FitAddonMod.FitAddon === 'function')
? FitAddonMod.FitAddon
: (typeof FitAddonMod === 'function' ? FitAddonMod : null);
if (!Term || !FitAddonCtor) {
elUnavailable.style.display = 'block';
elUnavailable.textContent = 'xterm: неверные глобалы после загрузки скриптов.';
elStatus.textContent = 'ошибка';
return;
}
elUnavailable.style.display = 'none';
elWrap.style.display = 'block';
const term = new Term({
cursorBlink: true,
fontSize: 14,
fontFamily: "'JetBrains Mono', 'Cascadia Mono', 'Consolas', monospace",
theme: {
background: '#0d1117',
foreground: '#e0e0e0',
cursor: '#d2ff1a',
cursorAccent: '#1a1a2e',
selectionBackground: '#264f78'
}
});
const fitAddon = new FitAddonCtor();
term.loadAddon(fitAddon);
term.open(elWrap);
fitAddon.fit();
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(proto + '//' + location.host + '/ws/terminal');
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
elStatus.textContent = 'подключено';
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
};
ws.onclose = () => { elStatus.textContent = 'отключено'; };
ws.onerror = () => { elStatus.textContent = 'ошибка соединения'; };
ws.onmessage = (ev) => {
const data = ev.data;
if (data instanceof ArrayBuffer) {
term.write(new Uint8Array(data));
} else if (typeof Blob !== 'undefined' && data instanceof Blob) {
data.arrayBuffer().then(buf => term.write(new Uint8Array(buf)));
} else {
term.write(data);
}
};
term.onData(data => {
if (ws.readyState === WebSocket.OPEN) ws.send(data);
});
term.onResize(({ cols, rows }) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
}
});
window._fitTerminal = () => { try { fitAddon.fit(); } catch(e) {} };
window.addEventListener('resize', () => {
if (currentTab === 'console' && window._fitTerminal) window._fitTerminal();
});
window._consoleInited = true;
} finally {
_consoleInitPromise = null;
}
}
// ===================== Map init =====================
L.Icon.Default.imagePath = '/static/leaflet/images/';
const EMPTY_TILE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
/** Текст внутри SVG <text> (не HTML). */
function _vtSvgEscapeText(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// Векторная подложка в тон веб-UI: суша светлее, вода темнее — заметнее граница с фоном #0e1a2b
var _vtLand = '#323f58';
var _vtWater = '#0c1424';
var _vtWaterStroke = '#1a3d5c';
var _vtCoastStroke = '#5c7fa3';
var _vtWaterway = '#3d5d78';
var _vtRoad = '#5a6578';
var _vtBuilding = '#2c3548';
var _vtGreen = '#2a3d34';
var _vtPark = '#263d30';
var _vtLabelFill = '#c9d1d9';
var _vtLabelHalo = '#0e1a2b';
/**
* Leaflet.VectorGrid рисует подписи точек только через L.icon + iconUrl.
* Дубли по границам тайлов — ограничение MVT; уменьшаем шум: высокий minZoom, только нужные классы.
*/
function _vtTextLabelIcon(name, zoom, minZoom) {
if (zoom < minZoom || !name || typeof name !== 'string') return null;
const fs = zoom <= 7 ? 10 : zoom <= 10 ? 11 : 12;
const padX = 6;
// 0.52 занижало ширину для кириллицы / жирного шрифта → текст обрезался по viewBox SVG.
let nonAscii = 0;
for (let i = 0; i < name.length; i++) {
if (name.charCodeAt(i) > 127) nonAscii++;
}
const perChar = fs * (nonAscii > 0 ? 0.72 : 0.62);
const strokePad = 8;
const w = Math.min(640, Math.max(20, Math.ceil(name.length * perChar + padX * 2 + strokePad)));
const h = fs + 10;
const esc = _vtSvgEscapeText(name);
const svg = '<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + h + '" overflow="visible">' +
'<text x="' + padX + '" y="' + (fs + 4) + '" text-anchor="start" font-size="' + fs + 'px" font-family="system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-weight="600" fill="' + _vtLabelFill + '" stroke="' + _vtLabelHalo + '" stroke-width="2.5" stroke-linejoin="round" paint-order="stroke fill">' + esc + '</text></svg>';
const url = 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(svg);
return L.icon({
iconUrl: url,
iconSize: [w, h],
iconAnchor: [0, h],
});
}
function _vtPlaceName(properties) {
if (!properties || typeof properties !== 'object') return '';
return (
properties.name ||
properties.name_en ||
properties['name:latin'] ||
properties['name:en'] ||
properties['name:ru'] ||
properties['name:de'] ||
''
);
}
/** Только крупные населённые пункты (без деревень, кварталов, POI). */
function _vtCityPlaceLabelIcon(properties, zoom) {
const cls = String(properties.class || '').toLowerCase();
if (cls) {
if (cls !== 'city' && cls !== 'town') return null;
if (properties.rank != null && Number(properties.rank) > 10) return null;
return _vtTextLabelIcon(_vtPlaceName(properties), zoom, 7);
}
// Простые тайлы без class: только с z≥8, отсекаем типичные мелкие типы
if (zoom < 8) return null;
const sub = String(properties.subclass || properties.type || '').toLowerCase();
if (['village', 'hamlet', 'suburb', 'quarter', 'neighbourhood', 'locality', 'isolated_dwelling'].indexOf(sub) >= 0) {
return null;
}
return _vtTextLabelIcon(_vtPlaceName(properties), zoom, 8);
}
/** Подписи только рек / ручьёв / каналов (не озёра/моря). */
function _vtRiverNameLabelIcon(properties, zoom) {
const cls = String(properties.class || '').toLowerCase();
if (['river', 'stream', 'canal'].indexOf(cls) < 0) return null;
if (properties.rank != null && Number(properties.rank) > 4) return null;
return _vtTextLabelIcon(_vtPlaceName(properties), zoom, 10);
}
function _vtWaterNameLayerStyle(p, z) {
const icon = _vtRiverNameLabelIcon(p, z);
if (!icon) return [];
return {icon: icon};
}
function _vtWaterwayLineStyle(p, z) {
var w = z < 6 ? 0.4 : z < 9 ? 0.65 : 0.95;
return {weight: w, color: _vtWaterway, opacity: 0.82, fill: false, fillOpacity: 0};
}
function _vtRoadLineStyle(p, z) {
var w = z < 6 ? 0.5 : z < 8 ? 0.85 : 1.15;
return {weight: w, color: _vtRoad, opacity: 0.7, fill: false, fillOpacity: 0};
}
/** Береговая линия (LineString). Несколько имён слоёв — у разных сборок planet/OSM по-разному. */
function _vtCoastlineLineStyle(p, z) {
var w = z < 8 ? 0.85 : 1.15;
return {
fill: false,
fillOpacity: 0,
weight: w,
color: _vtCoastStroke,
opacity: 0.88,
lineCap: 'round',
lineJoin: 'round',
};
}
/**
* Слой water в planet-часто смешанный: Polygon + LineString + Point.
* Точки — в основном seamark (мосты, ограничения по высоте), паромы, slipway: дают «кружки цвета воды».
* В VectorGrid PointSymbolizer: _radius = style.radius || defaultRadius — при radius: 0 срабатывает дефолт ~10px.
*/
function _vtWaterLayerPointLikeProps(p) {
if (!p || typeof p !== 'object') return false;
if (p.amenity === 'ferry_terminal' || p.leisure === 'slipway' || p.railway === 'ferry_terminal' || p.ferry === 'yes') {
return true;
}
for (var k in p) {
if (Object.prototype.hasOwnProperty.call(p, k) && k.indexOf('seamark:') === 0) {
return true;
}
}
return false;
}
function _vtWaterLayerStyle(properties) {
if (_vtWaterLayerPointLikeProps(properties)) {
return [];
}
return {
fill: true,
weight: 0.45,
color: _vtWaterStroke,
opacity: 0.85,
fillColor: _vtWater,
fillOpacity: 1,
radius: 0.01,
};
}
const map = L.map('map', {
background: '#0e1a2b',
maxZoom: 19,
// Critical for performance with many polylines/polygons at high zoom.
preferCanvas: true,
// leaflet-rotate plugin
rotate: true,
// leaflet-rotate.js enables this by default; we use our own compass UI.
rotateControl: false,
bearing: 0,
}).setView([55.751244, 37.618423], 10);
// Put AIS/aux markers into a pane that does NOT rotate with the map.
// leaflet-rotate creates `norotatePane`; if not present we fall back to default markerPane.
let AIS_MARKER_PANE = null;
try {
const nr = map.getPane && map.getPane('norotatePane');
if (nr) {
AIS_MARKER_PANE = 'aisMarkerPane';
if (!map.getPane(AIS_MARKER_PANE)) {
map.createPane(AIS_MARKER_PANE, nr);
map.getPane(AIS_MARKER_PANE).style.zIndex = 610; // above tiles/overlays, under popups
}
}
} catch (e) { AIS_MARKER_PANE = null; }
function _ensureAisMarkerPane(mk) {
try {
if (!AIS_MARKER_PANE || !mk || !mk.options || mk.options.pane === AIS_MARKER_PANE) return;
// Pane is effectively applied on add; re-add to move existing markers.
const wasOnMap = !!(map && map.hasLayer && map.hasLayer(mk));
if (wasOnMap) mk.remove();
mk.options.pane = AIS_MARKER_PANE;
if (wasOnMap) mk.addTo(map);
} catch (e) {}
}
// ===================== Compass + map rotation =====================
// Default is ON: the map rotates so the heading points up, which is the most
// useful mode on a vessel. Users can toggle it off via the compass button in
// #map-controls, the dial in #ownship-panel, or the HUD compass click.
let rotateMapByCompass = true;
try {
const _rmc = sGet('rotateMapByCompass', null);
if (_rmc != null) rotateMapByCompass = !!_rmc;
} catch (e) {}
let _lastOwnshipHeadingForRotate = null;
let _ownshipCompassUi = { wrap: null, btn: null, dial: null, arrow: null };
function _mapBearingDegSafe() {
try { return (map && typeof map.getBearing === 'function') ? (map.getBearing() || 0) : 0; } catch (e) { return 0; }
}
function _applyMapRotation() {
try {
if (!map || typeof map.setBearing !== 'function') return;
const hd = (rotateMapByCompass && _lastOwnshipHeadingForRotate != null && !isNaN(_lastOwnshipHeadingForRotate))
? _lastOwnshipHeadingForRotate
: null;
// heading-up: rotate map counter to heading
map.setBearing(hd == null ? 0 : -hd);
} catch (e) {}
}
function _setOwnshipCompassUi() {
const wrap = _ownshipCompassUi.wrap;
if (!wrap) return;
wrap.classList.toggle('compass--rotate-on', !!rotateMapByCompass);
const hd = (_lastOwnshipHeadingForRotate != null && !isNaN(_lastOwnshipHeadingForRotate)) ? _lastOwnshipHeadingForRotate : null;
// Compass indicates north: when map rotated by -hd, north appears at +hd on screen.
const a = (rotateMapByCompass && hd != null) ? hd : 0;
if (_ownshipCompassUi.arrow) _ownshipCompassUi.arrow.style.transform = 'rotate(' + (Math.round(a * 10) / 10) + 'deg)';
if (_ownshipCompassUi.dial) _ownshipCompassUi.dial.classList.toggle('compass--no-heading', hd == null);
}
// Shared toggle for "heading-up" (map rotates by compass). Used by:
// - #ownship-panel compass button + dial (desktop)
// - #map-controls #mc-compass (everywhere)
// - Clicking the HUD compass (#nhud-compass) as a quick shortcut
function _toggleRotateMapByCompass() {
rotateMapByCompass = !rotateMapByCompass;
try { sSet('rotateMapByCompass', rotateMapByCompass); } catch (e) {}
try { _setOwnshipCompassUi(); } catch (e) {}
try { _reflectCompassToggleUi(); } catch (e) {}
try { _applyMapRotation(); } catch (e) {}
}
// Reflect the current state on the #map-controls compass button (active ring)
// and the HUD compass element (clickable cursor).
function _reflectCompassToggleUi() {
const mc = document.getElementById('mc-compass');
if (mc) {
mc.classList.toggle('active', !!rotateMapByCompass);
mc.title = rotateMapByCompass
? 'Heading-up: ВКЛ (нажмите, чтобы North-up)'
: 'North-up (нажмите, чтобы вращать карту по компасу)';
}
const hc = document.getElementById('nhud-compass');
if (hc) {
hc.classList.toggle('is-rotating-map', !!rotateMapByCompass);
}
}
function _initOwnshipCompassUiOnce() {
try {
if (_ownshipCompassUi.wrap) return;
const btn = document.getElementById('btn-rotate-map');
const dial = document.getElementById('os-compass-dial');
const arrow = document.getElementById('os-compass-arrow');
if (!btn || !dial || !arrow) return;
_ownshipCompassUi = { wrap: dial.closest('.os-compass') || dial, btn, dial, arrow };
btn.addEventListener('click', (e) => { e.preventDefault(); _toggleRotateMapByCompass(); });
dial.addEventListener('dblclick', (e) => { e.preventDefault(); _toggleRotateMapByCompass(); });
_setOwnshipCompassUi();
} catch (e) {}
}
try { _initOwnshipCompassUiOnce(); } catch (e) {}
// Initial UI reflection for controls that exist at page load.
document.addEventListener('DOMContentLoaded', () => {
try { _reflectCompassToggleUi(); } catch (e) {}
});
// Also attach a click handler on the HUD compass once it's in the DOM.
document.addEventListener('DOMContentLoaded', () => {
const hc = document.getElementById('nhud-compass');
if (hc && !hc.dataset.hcBound) {
hc.dataset.hcBound = '1';
hc.style.cursor = 'pointer';
hc.addEventListener('click', (e) => { e.preventDefault(); _toggleRotateMapByCompass(); });
}
});
// Apply initial rotation state right after map init (in case GPS already has a fix
// from a saved state or Android compass starts quickly).
try { setTimeout(() => { try { _applyMapRotation(); } catch (_) {} }, 100); } catch (e) {}
// When the map bearing changes (programmatically or via gestures), keep ownship marker
// in sync even if no new GPS data arrived (e.g. during follow/compass updates).
try {
map.on('rotate', () => {
try { if (ownShipMarker) setOwnShipRotation(ownShipMarker, _lastOwnshipHeadingForRotate); } catch (e) {}
});
} catch (e) {}
const _vtBaseLayerStyles = {
// Локальные / planet_small слои (land, water, coastline, place)
land: {fill: true, weight: 0, fillColor: _vtLand, fillOpacity: 1},
water: _vtWaterLayerStyle,
coastline: _vtCoastlineLineStyle,
coastlines: _vtCoastlineLineStyle,
coast_line: _vtCoastlineLineStyle,
ocean_coastline:_vtCoastlineLineStyle,
osm_coastline: _vtCoastlineLineStyle,
water_point: [],
ocean_point: [],
marine_point: [],
// place: function (properties, zoom) {
// const icon = _vtCityPlaceLabelIcon(properties, zoom);
// if (!icon) return [];
// return {icon: icon};
// },
place: [],
// Линии воды / дорог (явный stroke, без синего Leaflet по умолчанию)
waterway: _vtWaterwayLineStyle,
stream: _vtWaterwayLineStyle,
hydrology: _vtWaterwayLineStyle,
transportation: _vtRoadLineStyle,
road: _vtRoadLineStyle,
roads: _vtRoadLineStyle,
street: _vtRoadLineStyle,
streets: _vtRoadLineStyle,
highway: _vtRoadLineStyle,
bridge: _vtRoadLineStyle,
tunnel: _vtRoadLineStyle,
railway: _vtRoadLineStyle,
rail: _vtRoadLineStyle,
landcover: {fill: true, weight: 0, fillColor: _vtGreen, fillOpacity: 0.45},
landuse: {fill: true, weight: 0, fillColor: _vtLand, fillOpacity: 0.5},
park: {fill: true, weight: 0, fillColor: _vtPark, fillOpacity: 0.5},
transportation_name: [],
// water_name: _vtWaterNameLayerStyle,
// waterway_name: _vtWaterNameLayerStyle,
water_name: [],
waterway_name: [],
building: {fill: true, weight: 0, fillColor: _vtBuilding, fillOpacity: 0.65},
// boundary: function(p, z) {
// var admin = p.admin_level || 2;
// if (admin > 4) return [];
// return {weight: admin <= 2 ? 1.2 : 0.65, color: '#5c6b80', opacity: 0.45, dashArray: '6,3', fill: false, fillOpacity: 0};
// },
boundary: {weight: 1, color: '#5c6b80', opacity: 0.35, fill: false},
housenumber: [],
poi: [],
mountain_peak: [],
aerodrome_label:[],
aeroway: {weight: 0.75, color: _vtRoad, opacity: 0.5, fill: false, fillOpacity: 0},
globallandcover:{fill: true, weight: 0, fillColor: _vtGreen, fillOpacity: 0.35},
};
const _vtLayerStyles = new Proxy(_vtBaseLayerStyles, {
get(target, prop) {
if (Object.prototype.hasOwnProperty.call(target, prop)) {
return target[prop];
}
if (typeof prop !== 'string') {
return target[prop];
}
// Слой есть в тайле, но нет в _vtBaseLayerStyles: не рисуем.
// Иначе точки превращаются в светлые CircleMarker («белые кружки» по всей карте).
return function () {
return [];
};
},
});
// Общие tweaks для всех TileLayer-ов: keepBuffer=4 — меньше просадок при pan'е,
// updateWhenZooming=false + updateWhenIdle=true — не шлём сотню запросов во время pinch-zoom.
const _tileTweaks = {
keepBuffer: 4,
updateWhenZooming: false,
updateWhenIdle: true,
};
const vectorLayer = L.vectorGrid.protobuf('/vtiles/{z}/{x}/{y}.pbf', Object.assign({}, _tileTweaks, {
maxZoom: 19,
// Локальные MVT сгенерированы до z13: при z>13 тот же растр тайла масштабируется (overzoom).
maxNativeZoom: 13,
rendererFactory: L.canvas.tile,
vectorTileLayerStyles: _vtLayerStyles,
interactive: false
}));
const rasterLayer = L.tileLayer('/tiles/{z}/{x}/{y}.png', Object.assign({}, _tileTweaks, {
maxZoom: 19,
errorTileUrl: EMPTY_TILE
}));
vectorLayer.addTo(map);
// Panes for z-index control (Leaflet default panes: overlayPane ~400, markerPane ~600)
try {
if (!map.getPane('vesselDetailPane')) {
map.createPane('vesselDetailPane');
map.getPane('vesselDetailPane').style.zIndex = 650; // above overlays, below popups
}
} catch (e) {}
// Shared Canvas renderer for vector overlays (trails/vectors/hulls/antenna).
// This avoids SVG DOM bloat and reduces input jank at high zoom.
const _overlayCanvasRenderer = L.canvas({ padding: 0.5 });
const osmLayer = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', Object.assign({}, _tileTweaks, {
maxZoom: 19,
attribution: '&copy; OpenStreetMap contributors',
crossOrigin: true
}));
const cartoPositron = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', Object.assign({}, _tileTweaks, {
maxZoom: 20,
subdomains: 'abcd',
attribution: '&copy; OpenStreetMap contributors &copy; CARTO',
crossOrigin: true
}));
const cartoDark = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', Object.assign({}, _tileTweaks, {
maxZoom: 20,
subdomains: 'abcd',
attribution: '&copy; OpenStreetMap contributors &copy; CARTO',
crossOrigin: true
}));
const esriWorldImagery = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', Object.assign({}, _tileTweaks, {
maxZoom: 19,
attribution: 'Tiles &copy; Esri',
crossOrigin: true
}));
const baseLayers = {
'Вектор': vectorLayer,
'Локальные': rasterLayer,
'OSM': osmLayer,
'CARTO Light': cartoPositron,
'CARTO Dark': cartoDark,
'Esri Спутник': esriWorldImagery
};
L.control.layers(baseLayers, null, {position: 'topright'}).addTo(map);
function baseLayerNameByObj(layer) {
for (const [name, ly] of Object.entries(baseLayers)) {
if (ly === layer) return name;
}
return null;
}
function applyBaseLayerByName(name) {
const target = baseLayers[name];
if (!target) return false;
for (const ly of Object.values(baseLayers)) {
if (map.hasLayer(ly)) map.removeLayer(ly);
}
target.addTo(map);
return true;
}
try {
const savedBase = sGet('baseLayer', '');
if (savedBase && savedBase !== 'Вектор') applyBaseLayerByName(savedBase);
} catch (e) {}
map.on('baselayerchange', (e) => {
const nm = baseLayerNameByObj(e && e.layer);
if (nm) sSet('baseLayer', nm);
});
const vesselMarkers = new Map();
const iconWidth = 17, iconHeight = 30;
const iconAnchorX = iconWidth / 2, iconAnchorY = iconHeight;
// ===================== Base stations & buoys (separate layers) =====================
const baseStationMarkers = new Map(); // mmsi -> marker
const buoyMarkers = new Map(); // mmsi -> marker
let lastBaseStations = [];
let lastBuoys = [];
let lastVisibleVessels = [];
let lastAnyVessels = [];
function _mapGetByMmsi(src, mmsi) {
if (!src || mmsi == null) return null;
if (src.has(mmsi)) return src.get(mmsi);
const key = String(mmsi);
for (const [k, v] of src.entries()) {
if (String(k) === key) return v;
}
return null;
}
function _markerForTargetMmsi(mmsi) {
return _mapGetByMmsi(vesselMarkers, mmsi) ||
_mapGetByMmsi(baseStationMarkers, mmsi) ||
_mapGetByMmsi(buoyMarkers, mmsi);
}
function _decorateAisTarget(v) {
if (!v) return null;
const out = Object.assign({}, v);
if (out.vessel_class === 'BS' || out.kind === 'base_station') {
out.vessel_class = 'BS';
out.kind = 'base_station';
out.shipname = out.shipname || 'Базовая станция';
out.virtual = !!out.virtual;
out.synthetic = !!out.synthetic;
} else if (out.vessel_class === 'N' || out.kind === 'buoy') {
out.vessel_class = 'N';
out.kind = 'buoy';
out.shipname = out.shipname || out.name || 'Буёк / СНО';
out.aton_type_label = out.aton_type_label || atonTypeLabel(out.aton_type);
out.virtual = !!out.virtual;
out.synthetic = !!out.synthetic;
out.off_position = !!out.off_position;
} else {
out.kind = out.kind || 'vessel';
}
const os = getOwnShipPos();
if (os && out.lat != null && out.lon != null) {
out._distNM = haversineNM(os.lat, os.lon, out.lat, out.lon);
}
return out;
}
function getAisTargetByMmsi(mmsi) {
const v = _mapGetByMmsi(vesselLastData, mmsi) || _mapGetByMmsi(AisHub.vessels, mmsi);
if (v) return _decorateAisTarget(Object.assign({ kind: 'vessel' }, v));
const bs = _mapGetByMmsi(AisHub.baseStations, mmsi) ||
(lastBaseStations || []).find(x => String(x && x.mmsi) === String(mmsi));
if (bs) return _decorateAisTarget(Object.assign({ vessel_class: 'BS', kind: 'base_station' }, bs));
const aton = _mapGetByMmsi(AisHub.atons, mmsi) ||
(lastBuoys || []).find(x => String(x && x.mmsi) === String(mmsi));
if (aton) return _decorateAisTarget(Object.assign({ vessel_class: 'N', kind: 'buoy' }, aton));
return null;
}
function _aidModeClass(item) {
if (item && item.virtual) return 'ais-aid--virtual';
if (item && item.synthetic) return 'ais-aid--synthetic';
return 'ais-aid--real';
}
function _aidModeLabel(item) {
if (item && item.virtual) return 'виртуальное';
if (item && item.synthetic) return 'синтетическое';
return 'реальное';
}
function _atonIconMeta(type) {
const c = parseInt(type, 10);
const fixed = c >= 5 && c <= 19;
const floating = c === 4 || (c >= 20 && c <= 31);
const nature = fixed ? 'fixed' : (floating ? 'floating' : 'generic');
switch (c) {
case 2: return { style: 'racon', label: 'R', nature };
case 3: return { style: 'structure', label: 'ПЛ', nature };
case 4: return { style: 'wreck', label: 'АВ', nature };
case 5:
case 6: return { style: 'light', label: 'ОГ', nature };
case 7: return { style: 'leading', label: 'ПС', nature };
case 8: return { style: 'leading', label: 'ЗС', nature };
case 9:
case 20: return { style: 'cardinal-n', label: 'С', nature };
case 10:
case 21: return { style: 'cardinal-e', label: 'В', nature };
case 11:
case 22: return { style: 'cardinal-s', label: 'Ю', nature };
case 12:
case 23: return { style: 'cardinal-w', label: 'З', nature };
case 13:
case 24: return { style: 'lateral-port', label: 'Л', nature };
case 14:
case 25: return { style: 'lateral-starboard', label: 'П', nature };
case 15:
case 26: return { style: 'preferred-port', label: 'ЛК', nature };
case 16:
case 27: return { style: 'preferred-starboard', label: 'ПК', nature };
case 17:
case 28: return { style: 'isolated-danger', label: '!', nature };
case 18:
case 29: return { style: 'safe-water', label: 'ЧВ', nature };
case 19:
case 30: return { style: 'special', label: '*', nature };
case 31: return { style: 'light-vessel', label: 'ПМ', nature };
case 1: return { style: 'reference', label: 'ОТ', nature };
default: return { style: 'generic', label: 'СНО', nature };
}
}
function getBaseStationDivIcon(b) {
const mode = _aidModeClass(b);
const label = _aidModeLabel(b);
const key = 'bs|' + mode;
const icon = L.divIcon({
className: 'ais-bs-divicon ' + mode,
iconSize: [34, 34],
iconAnchor: [17, 17],
html: '<div class="ais-bs-symbol" title="Базовая станция AIS, ' + escHtml(label) + '">' +
'<span class="ais-bs-mast"></span><span class="ais-bs-waves"></span></div>',
});
icon._aisIconKey = key;
return icon;
}
function getBuoyDivIcon(b) {
const meta = _atonIconMeta(b && b.aton_type);
const mode = _aidModeClass(b);
const off = b && b.off_position ? ' ais-aid--offposition' : '';
const key = 'aton|' + meta.style + '|' + meta.nature + '|' + mode + off;
const title = (b && (b.aton_type_label || atonTypeLabel(b.aton_type))) || 'Тип СНО не указан';
const icon = L.divIcon({
className: 'ais-aton-divicon ' + mode + off,
iconSize: [32, 32],
iconAnchor: [16, 16],
html: '<div class="ais-aton-symbol ais-aton-symbol--' + meta.style + ' ais-aton-symbol--' + meta.nature +
'" title="' + escHtml(title + ', ' + _aidModeLabel(b)) + '">' +
'<span class="ais-aton-label">' + escHtml(meta.label) + '</span></div>',
});
icon._aisIconKey = key;
return icon;
}
function updateBaseStations() {
try {
const now = Math.floor(Date.now() / 1000);
const list = Array.from(AisHub.baseStations.values())
.filter(b => !_isTargetExpiredByTimestamp(b, now));
lastBaseStations = list.map(b => Object.assign({}, b, {
vessel_class: 'BS',
kind: 'base_station',
shipname: 'Базовая станция',
callsign: null,
shiptype: null,
virtual: !!b.virtual,
synthetic: !!b.synthetic,
}));
const visible = new Set();
for (const b of list) {
if (!b || b.lat == null || b.lon == null) continue;
const mmsi = b.mmsi;
visible.add(mmsi);
if (baseStationMarkers.has(mmsi)) {
const mk = baseStationMarkers.get(mmsi);
_ensureAisMarkerPane(mk);
mk.setLatLng([b.lat, b.lon]);
const icon = getBaseStationDivIcon(b);
if (mk._aisIconKey !== icon._aisIconKey) {
mk.setIcon(icon);
mk._aisIconKey = icon._aisIconKey;
}
} else {
const icon = getBaseStationDivIcon(b);
const mk = L.marker([b.lat, b.lon], { icon, zIndexOffset: 500, pane: AIS_MARKER_PANE || undefined }).addTo(map);
mk._aisIconKey = icon._aisIconKey;
mk.on('click', () => { try { VesselInfoWindow.open(mmsi); } catch (_) {} });
baseStationMarkers.set(mmsi, mk);
}
}
for (const [mmsi, mk] of baseStationMarkers.entries()) {
if (!visible.has(mmsi)) {
if (String(mmsi) === String(selectedMmsi)) selectedMmsi = null;
try { if (String(VesselInfoWindow.currentMmsi()) === String(mmsi)) VesselInfoWindow.close(); } catch (_) {}
map.removeLayer(mk);
baseStationMarkers.delete(mmsi);
}
}
for (const [mmsi, b] of AisHub.baseStations.entries()) {
if (_isTargetExpiredByTimestamp(b, now)) AisHub.baseStations.delete(mmsi);
}
} catch (e) { /* ignore */ }
}
function updateBuoys() {
try {
const now = Math.floor(Date.now() / 1000);
const list = Array.from(AisHub.atons.values())
.filter(b => !_isTargetExpiredByTimestamp(b, now));
lastBuoys = list.map(b => Object.assign({}, b, {
vessel_class: 'N',
kind: 'buoy',
shipname: b && b.name ? b.name : 'Буёк / СНО',
aton_type_label: atonTypeLabel(b && b.aton_type),
callsign: null,
shiptype: null,
virtual: !!(b && b.virtual),
synthetic: !!(b && b.synthetic),
off_position: !!(b && b.off_position),
}));
const visible = new Set();
for (const b of list) {
if (!b || b.lat == null || b.lon == null) continue;
const mmsi = b.mmsi;
visible.add(mmsi);
const typeLabel = atonTypeLabel(b.aton_type);
b.aton_type_label = typeLabel;
if (buoyMarkers.has(mmsi)) {
const mk = buoyMarkers.get(mmsi);
_ensureAisMarkerPane(mk);
mk.setLatLng([b.lat, b.lon]);
const icon = getBuoyDivIcon(b);
if (mk._aisIconKey !== icon._aisIconKey) {
mk.setIcon(icon);
mk._aisIconKey = icon._aisIconKey;
}
} else {
const icon = getBuoyDivIcon(b);
const mk = L.marker([b.lat, b.lon], { icon, zIndexOffset: 450, pane: AIS_MARKER_PANE || undefined }).addTo(map);
mk._aisIconKey = icon._aisIconKey;
mk.on('click', () => { try { VesselInfoWindow.open(mmsi); } catch (_) {} });
buoyMarkers.set(mmsi, mk);
}
}
for (const [mmsi, mk] of buoyMarkers.entries()) {
if (!visible.has(mmsi)) {
if (String(mmsi) === String(selectedMmsi)) selectedMmsi = null;
try { if (String(VesselInfoWindow.currentMmsi()) === String(mmsi)) VesselInfoWindow.close(); } catch (_) {}
map.removeLayer(mk);
buoyMarkers.delete(mmsi);
}
}
for (const [mmsi, b] of AisHub.atons.entries()) {
if (_isTargetExpiredByTimestamp(b, now)) AisHub.atons.delete(mmsi);
}
} catch (e) { /* ignore */ }
}
// ===================== Vessel motion overlays (vector + trail) =====================
const VESSEL_PREDICT_SECONDS = 60;
const VESSEL_PREDICT_STEP_S = 5;
const VESSEL_TRAIL_KEEP_POINTS = 40;
const VESSEL_TRAIL_MIN_MOVE_M = 8; // reduce noise/jitter
const vesselVectors = new Map(); // mmsi -> L.Polyline
const vesselTrails = new Map(); // mmsi -> L.Polyline
const vesselHistory = new Map(); // mmsi -> Array<{lat:number, lon:number, ts:number}>
const vesselTrailRefMode = new Map(); // mmsi -> 'antenna' | 'center'
function isFiniteNumber(x) {
return typeof x === 'number' && Number.isFinite(x);
}
function destPointMeters(lat, lon, bearingDeg, distM) {
const R = 6371000;
const brng = bearingDeg * Math.PI / 180;
const φ1 = lat * Math.PI / 180, λ1 = lon * Math.PI / 180;
const δ = distM / R;
const sinφ1 = Math.sin(φ1), cosφ1 = Math.cos(φ1);
const sinδ = Math.sin(δ), cosδ = Math.cos(δ);
const sinφ2 = sinφ1 * cosδ + cosφ1 * sinδ * Math.cos(brng);
const φ2 = Math.asin(sinφ2);
const λ2 = λ1 + Math.atan2(Math.sin(brng) * sinδ * cosφ1, cosδ - sinφ1 * sinφ2);
return { lat: φ2 * 180 / Math.PI, lon: λ2 * 180 / Math.PI };
}
function haversineMetersLatLon(lat1, lon1, lat2, lon2) {
const R = 6371000;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*Math.sin(dLon/2)**2;
return 2 * R * Math.asin(Math.sqrt(a));
}
function rotToDegPerMin(rot) {
// We expect backend to provide ROT already in deg/min (AIS uses signed rate of turn).
// If its absent / not finite, treat as no turn.
if (!isFiniteNumber(rot)) return null;
return rot;
}
function computePredictPath(lat, lon, courseDeg, speedKn, rotDegPerMin) {
if (!isFiniteNumber(lat) || !isFiniteNumber(lon)) return null;
if (!isFiniteNumber(courseDeg) || !isFiniteNumber(speedKn) || speedKn <= 0.05) return null;
const stepS = Math.max(1, VESSEL_PREDICT_STEP_S);
const steps = Math.max(1, Math.floor(VESSEL_PREDICT_SECONDS / stepS));
const metersPerSec = speedKn * 1852 / 3600;
const dStep = metersPerSec * stepS;
let curLat = lat, curLon = lon;
const pts = [[curLat, curLon]];
const rot = rotToDegPerMin(rotDegPerMin);
const hasTurn = rot != null && Math.abs(rot) >= 0.2; // ignore tiny/noisy ROT
for (let i = 1; i <= steps; i++) {
const tS = i * stepS;
const brg = hasTurn ? normalizeDeg(courseDeg + rot * (tS / 60)) : normalizeDeg(courseDeg);
const next = destPointMeters(curLat, curLon, brg, dStep);
curLat = next.lat;
curLon = next.lon;
pts.push([curLat, curLon]);
}
return pts;
}
function getOrCreatePolyline(store, mmsi, opts) {
let pl = store.get(mmsi);
if (pl) return pl;
pl = L.polyline([], Object.assign({ renderer: _overlayCanvasRenderer }, opts)).addTo(map);
store.set(mmsi, pl);
return pl;
}
function removeMotionOverlays(mmsi) {
const v = vesselVectors.get(mmsi);
if (v) { map.removeLayer(v); vesselVectors.delete(mmsi); }
const t = vesselTrails.get(mmsi);
if (t) { map.removeLayer(t); vesselTrails.delete(mmsi); }
vesselHistory.delete(mmsi);
vesselTrailRefMode.delete(mmsi);
}
function updateVesselTrail(mmsi, lat, lon, ts) {
if (!isFiniteNumber(lat) || !isFiniteNumber(lon)) return;
const nowTs = isFiniteNumber(ts) ? ts : Math.floor(Date.now() / 1000);
let hist = vesselHistory.get(mmsi);
if (!hist) { hist = []; vesselHistory.set(mmsi, hist); }
const last = hist.length ? hist[hist.length - 1] : null;
if (!last) {
hist.push({ lat, lon, ts: nowTs });
} else {
const movedM = haversineMetersLatLon(last.lat, last.lon, lat, lon);
if (movedM >= VESSEL_TRAIL_MIN_MOVE_M) {
hist.push({ lat, lon, ts: nowTs });
} else {
// If we didnt move enough, still refresh timestamp to prevent “stale” tail decisions later.
last.ts = nowTs;
}
}
if (hist.length > VESSEL_TRAIL_KEEP_POINTS) hist.splice(0, hist.length - VESSEL_TRAIL_KEEP_POINTS);
const pl = getOrCreatePolyline(vesselTrails, mmsi, {
color: '#4fc3f7',
weight: 2,
opacity: 0.65,
dashArray: '4 8',
lineCap: 'round',
interactive: false,
});
pl.setLatLngs(hist.map(p => [p.lat, p.lon]));
}
function updateVesselVector(mmsi, lat, lon, courseDeg, speedKn, rotDegPerMin) {
const path = computePredictPath(lat, lon, courseDeg, speedKn, rotDegPerMin);
if (!path) {
const pl = vesselVectors.get(mmsi);
if (pl) pl.setLatLngs([]);
return;
}
const pl = getOrCreatePolyline(vesselVectors, mmsi, {
color: '#d2ff1a',
weight: 2,
opacity: 0.9,
dashArray: null,
lineCap: 'round',
interactive: false,
});
pl.setLatLngs(path);
}
// ===================== Vessel detail overlays (true size hull + antenna point) =====================
const VESSEL_DETAIL_ZOOM_MIN = 15;
const vesselLastData = new Map(); // mmsi -> last vessel object
const vesselHulls = new Map(); // mmsi -> L.Polygon
const vesselAntennas = new Map(); // mmsi -> L.CircleMarker
function _numOrNull(v) {
if (v == null) return null;
const n = (typeof v === 'number') ? v : parseFloat(v);
return Number.isFinite(n) ? n : null;
}
function _dimsFromVessel(v) {
const toBow = _numOrNull(v.to_bow);
const toStern = _numOrNull(v.to_stern);
const toPort = _numOrNull(v.to_port);
const toStar = _numOrNull(v.to_starboard);
if (![toBow, toStern, toPort, toStar].every(x => x != null && x > 0)) return null;
const lengthM = toBow + toStern;
const beamM = toPort + toStar;
if (!(lengthM > 0 && beamM > 0)) return null;
return { toBow, toStern, toPort, toStar, lengthM, beamM };
}
function _bearingForHull(v) {
const h = _numOrNull(v.heading);
const c = _numOrNull(v.course);
const s = _numOrNull(v.speed);
// Smart: when moving, COG is what user expects for rotation; when slow/stationary, heading is better.
const moving = s != null && !isNaN(s) && s >= 1.5;
const brg = moving ? (c != null ? c : h) : (h != null ? h : c);
return normalizeDeg(brg != null ? brg : null);
}
function _centerlineFromAntenna(lat, lon, bearingDeg, dims) {
// Antenna can be offset from centerline. Positive is starboard.
const offStarM = (dims.toStar - dims.toPort) / 2;
return destPointMeters(lat, lon, normalizeDeg(bearingDeg + 90), offStarM);
}
function _centerFromAntenna(lat, lon, bearingDeg, dims) {
// Center of hull relative to antenna: account for both longitudinal and lateral offset.
const cl = _centerlineFromAntenna(lat, lon, bearingDeg, dims);
const offFwdM = (dims.toBow - dims.toStern) / 2;
return destPointMeters(cl.lat, cl.lon, bearingDeg, offFwdM);
}
function _hullPolygonFromAntenna(lat, lon, bearingDeg, dims, vesselClass) {
// AIS position is the GNSS antenna reference point.
// Build a simple "ship" polygon (with bow) in meters around that point.
const cl = _centerlineFromAntenna(lat, lon, bearingDeg, dims);
const halfBeam = (dims.toPort + dims.toStar) / 2;
const bowTip = destPointMeters(cl.lat, cl.lon, bearingDeg, dims.toBow);
const sternC = destPointMeters(cl.lat, cl.lon, normalizeDeg(bearingDeg + 180), dims.toStern);
const portBear = normalizeDeg(bearingDeg - 90);
const starBear = normalizeDeg(bearingDeg + 90);
// For Class B at high zoom: simple triangle (tip + stern corners).
if (String(vesselClass || '').toUpperCase() === 'B') {
const sternStar = destPointMeters(sternC.lat, sternC.lon, starBear, halfBeam);
const sternPort = destPointMeters(sternC.lat, sternC.lon, portBear, halfBeam);
return [
[bowTip.lat, bowTip.lon],
[sternStar.lat, sternStar.lon],
[sternPort.lat, sternPort.lon],
];
}
// Bow shoulders: slightly behind the tip to form a pentagon (nose).
const minNose = 3.0;
const maxNose = Math.min(dims.toBow * 0.9, Math.max(6.0, dims.lengthM * 0.22));
const noseLen = Math.max(minNose, Math.min(maxNose, dims.toBow * 0.65));
const shoulderDist = Math.max(0, dims.toBow - noseLen);
const shoulderC = destPointMeters(cl.lat, cl.lon, bearingDeg, shoulderDist);
const bowPort = destPointMeters(shoulderC.lat, shoulderC.lon, portBear, halfBeam);
const bowStar = destPointMeters(shoulderC.lat, shoulderC.lon, starBear, halfBeam);
const sternStar = destPointMeters(sternC.lat, sternC.lon, starBear, halfBeam);
const sternPort = destPointMeters(sternC.lat, sternC.lon, portBear, halfBeam);
return [
[bowTip.lat, bowTip.lon],
[bowStar.lat, bowStar.lon],
[sternStar.lat, sternStar.lon],
[sternPort.lat, sternPort.lon],
[bowPort.lat, bowPort.lon],
];
}
function _ensureHull(mmsi, shiptype) {
let p = vesselHulls.get(mmsi);
if (p) return p;
p = L.polygon([], {
pane: 'vesselDetailPane',
renderer: _overlayCanvasRenderer,
color: '#000',
weight: 2,
opacity: 0.9,
fill: true,
fillColor: shiptypeToFillRgba(shiptype, null),
fillOpacity: 0.38,
interactive: false,
}).addTo(map);
vesselHulls.set(mmsi, p);
return p;
}
function _ensureAntenna(mmsi) {
let c = vesselAntennas.get(mmsi);
if (c) return c;
c = L.circleMarker([0, 0], {
pane: 'vesselDetailPane',
renderer: _overlayCanvasRenderer,
radius: 3.5,
color: '#d2ff1a',
weight: 2,
opacity: 1,
fillColor: '#d2ff1a',
fillOpacity: 0.9,
interactive: false,
}).addTo(map);
vesselAntennas.set(mmsi, c);
return c;
}
function removeVesselDetailOverlays(mmsi) {
const h = vesselHulls.get(mmsi);
if (h) { map.removeLayer(h); vesselHulls.delete(mmsi); }
const a = vesselAntennas.get(mmsi);
if (a) { map.removeLayer(a); vesselAntennas.delete(mmsi); }
}
function updateVesselDetailOverlays(v) {
if (!v) return;
const mmsi = v.mmsi;
const lat = _numOrNull(v.lat);
const lon = _numOrNull(v.lon);
const z = map.getZoom();
const mk = vesselMarkers.get(mmsi);
if (lat == null || lon == null || !mk) { removeVesselDetailOverlays(mmsi); return; }
if (z < VESSEL_DETAIL_ZOOM_MIN) {
removeVesselDetailOverlays(mmsi);
mk.setOpacity(1);
return;
}
const dims = _dimsFromVessel(v);
const brg = _bearingForHull(v);
if (!dims || brg == null) {
removeVesselDetailOverlays(mmsi);
mk.setOpacity(1);
return;
}
const poly = _hullPolygonFromAntenna(lat, lon, brg, dims, v.vessel_class);
const hull = _ensureHull(mmsi, v.shiptype);
hull.setLatLngs(poly);
try { hull.setStyle({ fillColor: shiptypeToFillRgba(v.shiptype, v.nav_status) }); } catch (_) {}
const ant = _ensureAntenna(mmsi);
ant.setLatLng([lat, lon]);
// Hide the simple marker when detailed overlay is visible.
mk.setOpacity(0);
}
function _rgbaFromHex(hex, alpha) {
const s = String(hex || '').replace('#', '');
const r = parseInt(s.slice(0, 2), 16);
const g = parseInt(s.slice(2, 4), 16);
const b = parseInt(s.slice(4, 6), 16);
return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')';
}
function vesselDisplayStyle(shiptype, navStatus) {
const c = parseInt(shiptype, 10);
const ns = parseInt(navStatus, 10);
const anchored = ns === 1 || ns === 5;
const restricted = ns === 3;
let color = '#9aa0a6';
let stroke = '#111111';
let label = 'Не указан';
if (restricted) {
color = '#ff3045';
stroke = '#111111';
label = 'Ограниченная маневренность';
} else if (!isNaN(c)) {
if (c === 30) { color = '#ff3045'; label = 'Рыболовное'; }
else if (c === 37) { color = '#d000ff'; label = 'Прогулочное'; }
else if (c >= 40 && c <= 49) { color = '#ffd31a'; stroke = '#7a5a00'; label = 'Высокоскоростное'; }
else if (c >= 50 && c <= 59) { color = '#00d7df'; stroke = '#005a60'; label = 'Буксир / спецсудно'; }
else if (c >= 60 && c <= 69) { color = '#3026df'; label = 'Пассажирское'; }
else if (c >= 70 && c <= 79) { color = '#19a64a'; label = 'Грузовое'; }
else if (c >= 80 && c <= 89) { color = '#e60018'; label = 'Танкер'; }
else if (c >= 90 && c <= 99) { color = '#f7f7f7'; stroke = '#111111'; label = 'Прочее'; }
else if (c >= 20 && c <= 29) { color = '#f7f7f7'; stroke = '#111111'; label = 'WIG / экраноплан'; }
else if (c >= 1 && c <= 19) { color = '#f7f7f7'; stroke = '#111111'; label = 'Зарезервировано'; }
else if (c >= 31 && c <= 36) { color = '#f7f7f7'; stroke = '#111111'; label = 'Тип 31-36'; }
else if (c >= 38 && c <= 39) { color = '#f7f7f7'; stroke = '#111111'; label = 'Зарезервировано'; }
else if (c >= 100) { color = '#f7f7f7'; stroke = '#111111'; label = 'Тип 100+'; }
}
return { color, stroke, anchored, restricted, label };
}
/** Полупрозрачная заливка по OpenCPN-подобным цветам ship type. */
function shiptypeToFillRgba(shiptype, navStatus) {
return _rgbaFromHex(vesselDisplayStyle(shiptype, navStatus).color, 0.38);
}
const _vesselSvgVB = { A: '0 0 88.5 152.73', B: '0 0 91.38 162.6' };
const _vesselSvgPoly = {
A: '44.25 6.77 4.5 51.46 4.5 148.23 84 148.23 84 51.46 44.25 6.77',
B: '45.69 16.63 5.94 158.1 85.44 158.1 45.69 16.63',
};
const vesselDivIconCache = new Map();
function vesselIconTintKey(vesselClass, shiptype, navStatus) {
const st = shiptype != null && shiptype !== '' ? String(shiptype) : '0';
const ns = navStatus != null && navStatus !== '' ? String(navStatus) : '';
return (vesselClass === 'A' ? 'A' : 'B') + ':' + st + ':' + ns;
}
function getVesselDivIcon(vesselClass, shiptype, navStatus) {
const key = vesselIconTintKey(vesselClass, shiptype, navStatus);
let icon = vesselDivIconCache.get(key);
if (icon) return icon;
const isA = vesselClass === 'A';
const vb = isA ? _vesselSvgVB.A : _vesselSvgVB.B;
const poly = isA ? _vesselSvgPoly.A : _vesselSvgPoly.B;
const style = vesselDisplayStyle(shiptype, navStatus);
if (style.restricted) {
const html = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18" width="18" height="18" style="display:block" aria-hidden="true">' +
'<circle cx="9" cy="9" r="7" fill="' + style.color + '" stroke="' + style.stroke + '" stroke-width="1.4"/>' +
'</svg>';
icon = L.divIcon({ html, iconSize: [18, 18], iconAnchor: [9, 9], popupAnchor: [0, -9], className: 'vessel-icon vessel-icon--svg vessel-icon--status' });
} else if (style.anchored) {
const html = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18" width="18" height="18" style="display:block" aria-hidden="true">' +
'<path d="M9 1.7 16.3 9 9 16.3 1.7 9Z" fill="' + style.color + '" stroke="' + style.stroke + '" stroke-width="1.4"/>' +
'<circle cx="9" cy="9" r="2" fill="rgba(255,255,255,.65)" stroke="' + style.stroke + '" stroke-width=".6"/>' +
'</svg>';
icon = L.divIcon({ html, iconSize: [18, 18], iconAnchor: [9, 9], popupAnchor: [0, -9], className: 'vessel-icon vessel-icon--svg vessel-icon--anchored' });
} else {
const html = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="' + vb + '" width="' + iconWidth + '" height="' + iconHeight + '" style="display:block" aria-hidden="true">' +
'<polygon points="' + poly + '" fill="' + style.color + '" stroke="' + style.stroke + '" stroke-width="9" stroke-linejoin="round"/>' +
'</svg>';
icon = L.divIcon({
html,
iconSize: [iconWidth, iconHeight],
iconAnchor: [iconAnchorX, iconAnchorY],
popupAnchor: [0, -iconHeight],
className: 'vessel-icon vessel-icon--svg',
});
}
vesselDivIconCache.set(key, icon);
return icon;
}
function setIconRotation(marker, heading) {
if (!marker) return;
try { _ensureAisMarkerPane(marker); } catch (e) {}
// Store "true" heading for redraws.
marker._headingDeg = (heading != null && !isNaN(heading)) ? normalizeDeg(heading) : null;
// Prefer leaflet-rotate's native per-marker rotation (radians).
// This avoids CSS-transform stacking issues during zoom/rotate.
try {
if (map && typeof map.getBearing === 'function' && marker.options) {
marker.options.rotateWithView = false;
const deg = (marker._headingDeg == null ? 0 : marker._headingDeg);
const headingRad = deg * Math.PI / 180;
// Markers are in `norotatePane` (do not rotate with the map), so keeping absolute heading
// relative to true north is simply "rotation = heading".
if (typeof marker.setRotation === 'function') marker.setRotation(headingRad);
else {
marker.options.rotation = headingRad;
if (typeof marker.update === 'function') marker.update();
}
// If this marker was previously patched by our CSS-rotate fallback, undo it.
const el = marker._icon;
if (marker._origSetPos) {
try { marker._setPos = marker._origSetPos; } catch (e) {}
try { delete marker._origSetPos; } catch (e) {}
}
if (el && el.dataset && el.dataset.trSet) {
try { delete el.dataset.trSet; } catch (e) {}
}
marker._rotationHeading = null;
return;
}
} catch (e) {}
// Fallback (no rotate plugin): manual CSS rotation for the icon.
const el = marker._icon;
if (!el) return;
marker._rotationHeading = marker._headingDeg;
if (!el.dataset.trSet) {
el.style.transformOrigin = `${iconAnchorX}px ${iconAnchorY}px`;
el.dataset.trSet = '1';
if (!marker._origSetPos) {
marker._origSetPos = marker._setPos;
marker._setPos = function(pos) {
this._origSetPos.call(this, pos);
const ic = this._icon;
if (ic && this._rotationHeading != null) {
const m = (ic.style.transform||'').match(/translate3d\([^)]+\)/);
if (m) ic.style.transform = m[0]+' rotate('+this._rotationHeading+'deg)';
}
};
}
}
const t = (el.style.transform||'').match(/translate3d\([^)]+\)/);
if (t) el.style.transform = marker._rotationHeading != null ? t[0]+' rotate('+marker._rotationHeading+'deg)' : t[0];
else if (marker._rotationHeading != null) el.style.transform = 'rotate('+marker._rotationHeading+'deg)';
}
function _clampInt(v, minV, maxV, defV) {
const n = parseInt(v, 10);
if (!isFinite(n) || isNaN(n)) return defV;
return Math.max(minV, Math.min(maxV, n));
}
function _formatMinutesPreview(mins) {
const m = _clampInt(mins, 1, 24 * 60, 1);
if (m < 60) return m + ' мин';
const h = Math.floor(m / 60);
const mm = m % 60;
return h + ' ч' + (mm ? (' ' + mm + ' мин') : '');
}
function getLosingTargetMinutes() {
return _clampInt(sGet('losingTargetMin', '7'), 1, 24 * 60, 7);
}
function getRemoveTargetMinutes() {
return _clampInt(sGet('removeTargetMin', '10'), 1, 24 * 60, 10);
}
let LOSING_TARGET_TIME = getLosingTargetMinutes() * 60;
let REMOVE_TARGET_TIME = getRemoveTargetMinutes() * 60;
function _isTargetExpiredByTimestamp(item, now) {
return !!(item && item.timestamp && (now - item.timestamp) >= REMOVE_TARGET_TIME);
}
function applyTargetTimingFromSettings() {
let losingMin = getLosingTargetMinutes();
let removeMin = getRemoveTargetMinutes();
// Ensure ordering: "losing" should be earlier than "remove"
if (removeMin <= losingMin) removeMin = losingMin + 1;
LOSING_TARGET_TIME = losingMin * 60;
REMOVE_TARGET_TIME = removeMin * 60;
const losingInput = document.getElementById('set-losing-target-min');
const removeInput = document.getElementById('set-remove-target-min');
const losingPrev = document.getElementById('set-losing-target-preview');
const removePrev = document.getElementById('set-remove-target-preview');
if (losingInput) losingInput.value = String(losingMin);
if (removeInput) removeInput.value = String(removeMin);
if (losingPrev) losingPrev.textContent = _formatMinutesPreview(losingMin);
if (removePrev) removePrev.textContent = _formatMinutesPreview(removeMin);
}
function initTargetTimingSettingsUi() {
const losingInput = document.getElementById('set-losing-target-min');
const removeInput = document.getElementById('set-remove-target-min');
if (!losingInput || !removeInput) return;
const save = () => {
const losingMin = _clampInt(losingInput.value, 1, 24 * 60, 7);
const removeMinRaw = _clampInt(removeInput.value, 1, 24 * 60, 10);
const removeMin = Math.max(removeMinRaw, losingMin + 1);
sSet('losingTargetMin', String(losingMin));
sSet('removeTargetMin', String(removeMin));
applyTargetTimingFromSettings();
};
losingInput.addEventListener('change', save);
losingInput.addEventListener('input', () => {
const prev = document.getElementById('set-losing-target-preview');
if (prev) prev.textContent = _formatMinutesPreview(losingInput.value);
});
removeInput.addEventListener('change', save);
removeInput.addEventListener('input', () => {
const prev = document.getElementById('set-remove-target-preview');
if (prev) prev.textContent = _formatMinutesPreview(removeInput.value);
});
applyTargetTimingFromSettings();
}
try {
initTargetTimingSettingsUi();
if (document && document.addEventListener) {
document.addEventListener('DOMContentLoaded', () => {
try { initTargetTimingSettingsUi(); } catch (e) {}
});
}
} catch (e) {}
// ===================== Distance & range filter =====================
const RANGE_STEPS_NM = [0.1,0.2,0.3,0.5,0.7,1,1.5,2,3,5,7,10,15,20,30,50,70,100,150,200,300,500];
const NM_TO_KM = 1.852;
const NM_TO_AU = 1.852 / 1.496e8;
const KN_TO_KMH = 1.852;
let distUnit = sGet('distUnit', 'nm');
let speedUnit = sGet('speedUnit', 'kn');
let rangeIdx = parseInt(sGet('rangeIdx', String(RANGE_STEPS_NM.length)), 10);
let rangeCircle = null;
let warnRadiusNm = parseFloat(sGet('warnRadiusNm', '0')) || 0;
let nearRadiusNm = parseFloat(sGet('nearRadiusNm', '0')) || 0;
let warnCircle = null;
let nearCircle = null;
let _dangerState = { any: false, count: 0, minNm: null };
function haversineNM(lat1, lon1, lat2, lon2) {
const R = 3440.065;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*Math.sin(dLon/2)**2;
return 2 * R * Math.asin(Math.sqrt(a));
}
function getOwnShipPos() {
const data = ownShipSource === 'phone' ? phoneGps : nmeaGps;
if (data && data.lat != null && data.lon != null) return data;
return null;
}
function getRangeNM() {
return rangeIdx >= RANGE_STEPS_NM.length ? Infinity : RANGE_STEPS_NM[rangeIdx];
}
function fmtDist(nm) {
if (distUnit === 'au') {
const au = nm * NM_TO_AU;
return au.toExponential(2) + ' AU';
}
if (distUnit === 'km') {
const km = nm * NM_TO_KM;
return km < 10 ? km.toFixed(2) + ' км' : km.toFixed(1) + ' км';
}
return nm < 10 ? nm.toFixed(2) + ' NM' : nm.toFixed(1) + ' NM';
}
function bearingDeg(lat1, lon1, lat2, lon2) {
// Initial bearing from point 1 to point 2 (degrees from North, 0..360)
const φ1 = lat1 * Math.PI / 180, φ2 = lat2 * Math.PI / 180;
const Δλ = (lon2 - lon1) * Math.PI / 180;
const y = Math.sin(Δλ) * Math.cos(φ2);
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
const θ = Math.atan2(y, x) * 180 / Math.PI;
return normalizeDeg(θ);
}
function relBearingSignedDeg(ownHeadingDeg, absBearingDeg) {
const h = normalizeDeg(ownHeadingDeg);
const b = normalizeDeg(absBearingDeg);
if (h == null || b == null) return null;
let d = b - h;
if (d > 180) d -= 360;
if (d < -180) d += 360;
return d; // negative = left, positive = right
}
function uiUnitName() {
return distUnit === 'km' ? 'км' : 'NM';
}
function nmToUi(nm) {
return distUnit === 'km' ? (nm * NM_TO_KM) : nm;
}
function uiToNm(v) {
return distUnit === 'km' ? (v / NM_TO_KM) : v;
}
function fmtRange(nm) {
if (!isFinite(nm)) return '\u221e';
if (distUnit === 'au') {
const au = nm * NM_TO_AU;
return au.toExponential(2) + ' AU';
}
if (distUnit === 'km') {
const km = nm * NM_TO_KM;
return (km < 1 ? km.toFixed(2) : km < 10 ? km.toFixed(1) : Math.round(km)) + ' км';
}
return (nm < 1 ? nm.toFixed(1) : nm < 10 ? nm.toFixed(1) : Math.round(nm)) + ' NM';
}
function fmtSpeed(knots) {
if (speedUnit === 'kmh') return (knots * KN_TO_KMH).toFixed(1) + ' км/ч';
return knots.toFixed(1) + ' уз.';
}
function updateRangeCircle() {
const os = getOwnShipPos();
const r = getRangeNM();
if (!os || !isFinite(r)) {
if (rangeCircle) { map.removeLayer(rangeCircle); rangeCircle = null; }
return;
}
const meters = r * 1852;
if (!rangeCircle) {
rangeCircle = L.circle([os.lat, os.lon], {
radius: meters, color: '#d2ff1a', weight: 1.5,
fillColor: '#d2ff1a', fillOpacity: 0.04, dashArray: '6,4', interactive: false
}).addTo(map);
} else {
rangeCircle.setLatLng([os.lat, os.lon]);
rangeCircle.setRadius(meters);
}
}
function updateDangerCircles() {
const os = getOwnShipPos();
if (!os || os.lat == null || os.lon == null) {
if (warnCircle) { map.removeLayer(warnCircle); warnCircle = null; }
if (nearCircle) { map.removeLayer(nearCircle); nearCircle = null; }
return;
}
if (warnRadiusNm > 0) {
const meters = warnRadiusNm * 1852;
if (!warnCircle) {
warnCircle = L.circle([os.lat, os.lon], {
radius: meters,
color: '#f85149',
weight: 1.8,
opacity: 0.9,
fillColor: '#f85149',
fillOpacity: 0.03,
dashArray: '8,6',
interactive: false
}).addTo(map);
} else {
warnCircle.setLatLng([os.lat, os.lon]);
warnCircle.setRadius(meters);
}
} else if (warnCircle) { map.removeLayer(warnCircle); warnCircle = null; }
if (nearRadiusNm > 0) {
const meters = nearRadiusNm * 1852;
if (!nearCircle) {
nearCircle = L.circle([os.lat, os.lon], {
radius: meters,
color: '#f0883e',
weight: 1.5,
opacity: 0.8,
fillColor: '#f0883e',
fillOpacity: 0.02,
dashArray: '4,8',
interactive: false
}).addTo(map);
} else {
nearCircle.setLatLng([os.lat, os.lon]);
nearCircle.setRadius(meters);
}
} else if (nearCircle) { map.removeLayer(nearCircle); nearCircle = null; }
}
function updateDangerBanner() {
const el = document.getElementById('danger-banner');
if (!el) return;
if (warnRadiusNm > 0 && _dangerState.any) {
const rel = _dangerState.relDegSigned;
const relTxt = (rel == null) ? '' : (rel < 0 ? (' ← ' + Math.round(Math.abs(rel)) + '°') : (' ' + Math.round(Math.abs(rel)) + '° →'));
const distTxt = _dangerState.minNm != null ? fmtDist(_dangerState.minNm) : '';
el.textContent = 'ВНИМАНИЕ' + (_dangerState.count > 0 ? ' (' + _dangerState.count + (relTxt ? ',' + relTxt : '') + (distTxt ? ', ' + distTxt : '') + ')' : '');
el.style.display = '';
} else {
el.style.display = 'none';
}
}
(function initRangeSlider() {
const slider = document.getElementById('range-slider');
const label = document.getElementById('range-value');
slider.max = RANGE_STEPS_NM.length;
slider.value = rangeIdx;
label.textContent = fmtRange(getRangeNM());
slider.addEventListener('input', () => {
rangeIdx = parseInt(slider.value, 10);
sSet('rangeIdx', rangeIdx);
label.textContent = fmtRange(getRangeNM());
updateRangeCircle();
});
})();
const olSize = Math.max(iconWidth, iconHeight)*1.5, olAnchor = olSize/2;
const iconChosen = L.icon({ iconUrl:'/svg/ChosenTarget.svg', iconSize:[olSize,olSize], iconAnchor:[olAnchor,olAnchor], className:'vessel-overlay-icon' });
const iconLosing = L.divIcon({
html: '<span class="vessel-lost-dot" aria-hidden="true"></span>',
iconSize: [18, 18],
iconAnchor: [9, 9],
className: 'vessel-overlay-icon vessel-overlay-lost',
});
const vesselOverlays = new Map();
let selectedMmsi = null;
// ===================== Vessel InfoWindow (MarineTraffic-style) =====================
// Replaces the plain Leaflet popup used previously.
// - Desktop: floating card, draggable by its header, anchored inside #map page.
// - Mobile : full-width bottom-sheet above the mobile panel bar, swipe-down closes it.
// - Stays on screen when the map pans/zooms (no snapping to marker after the user
// dragged it), but the content auto-updates when fresh AIS data arrives.
const AIS_NAV_STATUS_LABEL = {
0: 'На ходу (двигатель)', 1: 'На якоре', 2: 'Не под командованием',
3: 'Огранич. манёвренность', 4: 'Огранич. осадкой', 5: 'Пришвартован',
6: 'На мели', 7: 'Рыболовство', 8: 'Под парусом',
9: 'Рез. HSC', 10: 'Рез. WIG', 11: 'Буксир за кормой',
12: 'Буксир спереди', 13: 'Резерв', 14: 'AIS-SART / MOB', 15: 'Не определён',
};
function _navStatusLabel(c) {
if (c == null || c === '') return null;
const n = parseInt(c, 10);
if (isNaN(n)) return null;
return AIS_NAV_STATUS_LABEL[n] || ('Код ' + n);
}
function _signalDbToBars(db) {
if (db == null) return 0;
if (db >= -50) return 4;
if (db >= -60) return 3;
if (db >= -70) return 2;
if (db >= -80) return 1;
return 0;
}
function _vesselHeaderIconSvg(vesselClass, shiptype) {
const isA = vesselClass === 'A';
const vb = isA ? _vesselSvgVB.A : _vesselSvgVB.B;
const poly = isA ? _vesselSvgPoly.A : _vesselSvgPoly.B;
const fill = shiptypeToFillRgba(shiptype, null);
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="' + vb + '" width="26" height="26" aria-hidden="true">' +
'<polygon points="' + poly + '" fill="' + fill + '" stroke="#e0e0e0" stroke-width="8" stroke-linejoin="round"/>' +
'</svg>';
}
const VesselInfoWindow = (function () {
let rootEl = null;
let openMmsi = null;
let userPositioned = false;
let _drag = null;
let isCollapsed = false;
function el() {
if (rootEl) return rootEl;
rootEl = document.getElementById('vessel-infowindow');
return rootEl;
}
function isMobile() {
try {
return window.innerWidth <= 600 ||
(window.matchMedia && window.matchMedia('(max-height:520px) and (pointer:coarse)').matches);
} catch (e) { return false; }
}
function _bars(bars) {
const heights = [5, 8, 11, 14];
const cls = bars >= 3 ? 'on' : (bars === 2 ? 'warn' : (bars >= 1 ? 'bad' : ''));
let h = '<span class="vinf-signal-bars">';
for (let i = 0; i < 4; i++) {
const active = i < bars;
h += '<span class="vinf-signal-bar ' + (active ? cls : '') + '" style="height:' + heights[i] + 'px"></span>';
}
h += '</span>';
return h;
}
function _targetHeaderIconSvg(v) {
if (v && (v.vessel_class === 'BS' || v.kind === 'base_station')) {
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="26" height="26" aria-hidden="true">' +
'<path d="M16 6v20" stroke="#c9d1d9" stroke-width="2.2" stroke-linecap="round"/>' +
'<path d="M10 26h12M12 14h8M13 26l3-12 3 12" stroke="#c9d1d9" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>' +
'<path d="M9 10a10 10 0 0 1 14 0M5 6a16 16 0 0 1 22 0" fill="none" stroke="#4fc3f7" stroke-width="1.8" stroke-linecap="round"/>' +
'</svg>';
}
if (v && (v.vessel_class === 'N' || v.kind === 'buoy')) {
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="26" height="26" aria-hidden="true">' +
'<path d="M16 4l4 7h-8l4-7Z" fill="#ffd166" stroke="#c9d1d9" stroke-width="1.4" stroke-linejoin="round"/>' +
'<path d="M11 12h10l3 12H8l3-12Z" fill="#f85149" stroke="#c9d1d9" stroke-width="1.6" stroke-linejoin="round"/>' +
'<path d="M9 19h14M5 27c2.4-1.2 4.6-1.2 7 0 2.4 1.2 4.6 1.2 7 0 2.4-1.2 4.6-1.2 7 0" fill="none" stroke="#4fc3f7" stroke-width="1.6" stroke-linecap="round"/>' +
'</svg>';
}
return _vesselHeaderIconSvg(v ? v.vessel_class : null, v ? v.shiptype : null);
}
function _dimsLabel(v) {
const toBow = (typeof v.to_bow === 'number' && isFinite(v.to_bow)) ? v.to_bow : parseInt(v.to_bow, 10);
const toStern = (typeof v.to_stern === 'number' && isFinite(v.to_stern)) ? v.to_stern : parseInt(v.to_stern, 10);
const toPort = (typeof v.to_port === 'number' && isFinite(v.to_port)) ? v.to_port : parseInt(v.to_port, 10);
const toStar = (typeof v.to_starboard === 'number' && isFinite(v.to_starboard)) ? v.to_starboard : parseInt(v.to_starboard, 10);
const lenM = (isFinite(toBow) ? Math.max(0, toBow) : 0) + (isFinite(toStern) ? Math.max(0, toStern) : 0);
const beamM = (isFinite(toPort) ? Math.max(0, toPort) : 0) + (isFinite(toStar) ? Math.max(0, toStar) : 0);
return (lenM > 0 && beamM > 0) ? (lenM + '×' + beamM + ' м') : null;
}
function _buildStaticHtml(v) {
const isBase = v.vessel_class === 'BS' || v.kind === 'base_station';
const isAton = v.vessel_class === 'N' || v.kind === 'buoy';
const iconHtml = _targetHeaderIconSvg(v);
const iso2 = mmsiToIso2FromMid(v.mmsi);
const flag = iso2ToFlagEmoji(iso2);
const typeLabel = isAton ? (v.aton_type_label || atonTypeLabel(v.aton_type) || 'Тип СНО не указан') : 'Базовая станция AIS';
const modeLabel = _aidModeLabel(v);
const clsText = isBase ? 'Базовая станция' : 'СНО / буй';
const title = isBase ? 'Базовая станция' : (v.shipname || v.name || 'Буёк / СНО');
const os = getOwnShipPos();
let bearingCell = '';
if (os && os.lat != null && os.lon != null && v.lat != null && v.lon != null) {
const brg = bearingDeg(os.lat, os.lon, v.lat, v.lon);
const dist = v._distNM != null ? v._distNM : haversineNM(os.lat, os.lon, v.lat, v.lon);
bearingCell = '<div class="vinf-cell">' +
'<div class="vinf-cell-lbl">Пеленг / Дальность</div>' +
'<div class="vinf-cell-val">' + brg.toFixed(0) + '° / ' + escHtml(fmtDist(dist)) + '</div></div>';
}
const coordsTxt = (v.lat != null && v.lon != null)
? Number(v.lat).toFixed(5) + ', ' + Number(v.lon).toFixed(5)
: '—';
const dims = _dimsLabel(v);
const ageTxt = v.timestamp ? escHtml(fmtAgo(v.timestamp)) + ' назад' : '—';
const miniDst = v._distNM != null ? escHtml(fmtDist(v._distNM)) : null;
const idsParts = ['<span><b>MMSI:</b> ' + escHtml(v.mmsi) + '</span>'];
if (v.lat != null && v.lon != null) idsParts.push('<span><b>Координаты:</b> ' + escHtml(coordsTxt) + '</span>');
if (isAton) idsParts.push('<span><b>СНО:</b> ' + escHtml(modeLabel) + '</span>');
else idsParts.push('<span><b>Станция:</b> ' + escHtml(modeLabel) + '</span>');
if (isAton && v.off_position) idsParts.push('<span><b>Положение:</b> вне штатной позиции</span>');
if (dims) idsParts.push('<span><b>Размер:</b> ' + escHtml(dims) + '</span>');
return '' +
'<div class="vinf-grip" data-drag-handle aria-hidden="true"></div>' +
'<div class="vinf-header" data-drag-handle>' +
'<div class="vinf-icon">' + iconHtml + '</div>' +
'<div class="vinf-title">' +
'<div class="vinf-name" title="' + escHtml(title) + '">' +
(flag ? '<span class="vinf-flag" title="' + escHtml(iso2 || '') + '">' + flag + '</span> ' : '') +
escHtml(title) +
'</div>' +
'<div class="vinf-sub">' + escHtml(typeLabel) + ' · ' + escHtml(clsText) + '</div>' +
'<div class="vinf-mini">' +
(miniDst ? '<span title="Дальность до цели от своего судна">ДАЛЬН <b>' + miniDst + '</b></span>' : '') +
'<span title="Возраст последнего AIS-сообщения">ВОЗР <b>' + ageTxt + '</b></span>' +
'</div>' +
'</div>' +
'<button type="button" class="vinf-btn vinf-btn-collapse" data-act="collapse" aria-label="Свернуть" title="Свернуть / развернуть">' +
'<svg viewBox="0 0 24 24"><path fill="currentColor" class="vinf-ic-expand" d="M7.41 8.59 12 13.17l4.59-4.58L18 10l-6 6-6-6z"/><path fill="currentColor" class="vinf-ic-collapse" d="M7.41 15.41 12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>' +
'</button>' +
'<button type="button" class="vinf-btn" data-act="close" aria-label="Закрыть" title="Закрыть">' +
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>' +
'</button>' +
'</div>' +
'<div class="vinf-body">' +
'<div class="vinf-voyage">' +
'<div class="vinf-port"><span class="vinf-port-country">Объект</span> ' + escHtml(clsText) + '</div>' +
'<div class="vinf-voyage-arrow"></div>' +
'<div class="vinf-port vinf-port--right">' + escHtml(typeLabel) + '</div>' +
'<div class="vinf-eta">' +
'<span><b>Обновлено:</b> ' + ageTxt + '</span>' +
'<span><b>Источник:</b> AIS</span>' +
'</div>' +
'</div>' +
'<div class="vinf-grid">' +
'<div class="vinf-cell"><div class="vinf-cell-lbl">' + (isAton ? 'Тип СНО' : 'Тип объекта') + '</div><div class="vinf-cell-val vinf-cell-val--wrap">' + escHtml(typeLabel) + '</div></div>' +
'<div class="vinf-cell"><div class="vinf-cell-lbl">Координаты</div><div class="vinf-cell-val vinf-cell-val--wrap">' + escHtml(coordsTxt) + '</div></div>' +
'<div class="vinf-cell"><div class="vinf-cell-lbl">' + (isAton ? 'Флаг СНО' : 'Флаг станции') + '</div><div class="vinf-cell-val vinf-cell-val--wrap">' + escHtml(modeLabel) + '</div></div>' +
(isAton && v.off_position ? '<div class="vinf-cell"><div class="vinf-cell-lbl">Положение</div><div class="vinf-cell-val">Вне позиции</div></div>' : '') +
(dims ? '<div class="vinf-cell"><div class="vinf-cell-lbl">Размер</div><div class="vinf-cell-val">' + escHtml(dims) + '</div></div>' : '') +
bearingCell +
'</div>' +
'<div class="vinf-ids">' + idsParts.join('') + '</div>' +
'<div class="vinf-actions">' +
'<button type="button" class="vinf-act primary" data-act="center" title="Центрировать карту на цели">' +
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm9 3h-2.07A7 7 0 0 0 13 5.07V3h-2v2.07A7 7 0 0 0 5.07 11H3v2h2.07A7 7 0 0 0 11 18.93V21h2v-2.07A7 7 0 0 0 18.93 13H21v-2Z"/></svg>' +
'Центрировать' +
'</button>' +
'<button type="button" class="vinf-act" data-act="ruler" title="Рулетка: ваше судно → эта цель">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path stroke-linejoin="round" d="M2 15 L15 2 L22 9 L9 22 Z"/><path d="M6 16l2-2 M9 18l2-2 M12 20l2-2 M11 11l2-2 M14 13l2-2"/></svg>' +
'Расстояние' +
'</button>' +
'<button type="button" class="vinf-act" data-act="copy" title="Скопировать координаты">' +
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1Zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2Zm0 16H8V7h11v14Z"/></svg>' +
'Копировать' +
'</button>' +
'</div>' +
'</div>' +
'<div class="vinf-footer">' +
(v.timestamp ? '<span>Получено: <b>' + escHtml(fmtTime(v.timestamp)) + '</b> (' + escHtml(fmtAgo(v.timestamp)) + ' назад)</span>' : '<span>Получено: <b>—</b></span>') +
'<span>Источник: <b>' + (isAton ? 'AIS СНО' : 'AIS базовая станция') + '</b></span>' +
'</div>';
}
function _buildHtml(v) {
if (v && (v.vessel_class === 'BS' || v.vessel_class === 'N' || v.kind === 'base_station' || v.kind === 'buoy')) {
return _buildStaticHtml(v);
}
const iconHtml = _targetHeaderIconSvg(v);
const iso2 = mmsiToIso2FromMid(v.mmsi);
const flag = iso2ToFlagEmoji(iso2);
const typeLabel = (v.shiptype != null && v.shiptype !== '')
? ((typeof AIS_TABLE51_SHIP_TYPE_LABEL === 'function' && AIS_TABLE51_SHIP_TYPE_LABEL(v.shiptype)) || ('Тип ' + v.shiptype))
: 'Тип не указан';
const clsText = v.vessel_class === 'A' ? 'Class A'
: v.vessel_class === 'B' ? 'Class B'
: v.vessel_class === 'BS' ? 'Базовая ст.'
: v.vessel_class === 'N' ? 'СНО / буй'
: (v.vessel_class || '?');
// Bearing / distance from ownship
const os = getOwnShipPos();
let bearingCell = '';
if (os && os.lat != null && os.lon != null && v.lat != null && v.lon != null) {
const brg = bearingDeg(os.lat, os.lon, v.lat, v.lon);
const dist = v._distNM;
bearingCell = '<div class="vinf-cell">' +
'<div class="vinf-cell-lbl">Пеленг / Дальность</div>' +
'<div class="vinf-cell-val">' + brg.toFixed(0) + '° / ' +
(dist != null ? escHtml(fmtDist(dist)) : '') + '</div></div>';
}
const navLbl = _navStatusLabel(v.nav_status);
const navVal = navLbl || '<span class="dim">нет</span>';
const speedCourse = (v.speed != null ? escHtml(fmtSpeed(v.speed)) : '') +
' / ' + (v.course != null ? v.course.toFixed(0) + '°' : '');
const heading = v.heading != null ? v.heading.toFixed(0) + '°'
: '<span class="dim">нет</span>';
const draughtRaw = v.draught != null ? Number(v.draught) : null;
const draught = (draughtRaw != null && isFinite(draughtRaw) && draughtRaw > 0)
? (draughtRaw.toFixed(1) + ' м')
: '<span class="dim">—</span>';
const dest = v.destination && String(v.destination).trim()
? escHtml(String(v.destination).trim())
: '<span class="dim">не указано</span>';
const eta = v.eta ? escHtml(String(v.eta)) : '—';
// Signal
let sigBlock = '';
if (v.signal_db != null) {
const bars = _signalDbToBars(v.signal_db);
const ageTxt = v.signal_ts != null ? fmtAgo(Math.floor(v.signal_ts)) : null;
sigBlock = '<div class="vinf-signal">' + _bars(bars) +
'<span>Сигнал: <span class="sig-db">' + Number(v.signal_db).toFixed(1) + ' дБ</span></span>' +
(ageTxt ? '<span>(' + ageTxt + ' назад)</span>' : '') +
'</div>';
}
// Identifiers
const callsign = v.callsign ? String(v.callsign).trim() : null;
const imo = v.imo && Number(v.imo) > 0 ? v.imo : null;
const idsParts = ['<span><b>MMSI:</b> ' + v.mmsi + '</span>'];
if (imo) idsParts.push('<span><b>IMO:</b> ' + imo + '</span>');
if (callsign) idsParts.push('<span><b>Позывной:</b> ' + escHtml(callsign) + '</span>');
if (v.lat != null && v.lon != null) {
idsParts.push('<span><b>Lat/Lon:</b> ' + Number(v.lat).toFixed(5) + ', ' + Number(v.lon).toFixed(5) + '</span>');
}
if (v.rot != null && !isNaN(Number(v.rot))) {
idsParts.push('<span><b>ROT:</b> ' + Number(v.rot).toFixed(1) + ' °/мин</span>');
}
const lenM = (Number(v.to_bow) || 0) + (Number(v.to_stern) || 0);
const beamM = (Number(v.to_port) || 0) + (Number(v.to_starboard) || 0);
if (lenM > 0 && beamM > 0) {
idsParts.push('<span><b>Размер:</b> ' + lenM + '×' + beamM + ' м</span>');
}
const receivedLine = v.timestamp
? '<span>Получено: <b>' + escHtml(fmtTime(v.timestamp)) + '</b> (' + escHtml(fmtAgo(v.timestamp)) + ' назад)</span>'
: '<span>Получено: <b>—</b></span>';
// Mini stats for the collapsed state (shown instead of vinf-sub when collapsed)
const miniSog = v.speed != null ? escHtml(fmtSpeed(v.speed)) : '—';
const miniCog = v.course != null ? v.course.toFixed(0) + '°' : '—';
const miniDst = v._distNM != null ? escHtml(fmtDist(v._distNM)) : null;
return '' +
'<div class="vinf-grip" data-drag-handle aria-hidden="true"></div>' +
'<div class="vinf-header" data-drag-handle>' +
'<div class="vinf-icon">' + iconHtml + '</div>' +
'<div class="vinf-title">' +
'<div class="vinf-name" title="' + escHtml(v.shipname || ('MMSI ' + v.mmsi)) + '">' +
(flag ? '<span class="vinf-flag" title="' + escHtml(iso2 || '') + '">' + flag + '</span> ' : '') +
escHtml(v.shipname || ('MMSI ' + v.mmsi)) +
'</div>' +
'<div class="vinf-sub">' + escHtml(typeLabel) + ' · ' + clsText + '</div>' +
'<div class="vinf-mini">' +
'<span title="Speed Over Ground — скорость относительно земли">SOG <b>' + miniSog + '</b></span>' +
'<span title="Course Over Ground — курс относительно земли">COG <b>' + miniCog + '</b></span>' +
(miniDst ? '<span title="Дальность до цели от своего судна">DST <b>' + miniDst + '</b></span>' : '') +
'</div>' +
'</div>' +
'<button type="button" class="vinf-btn vinf-btn-collapse" data-act="collapse" aria-label="Свернуть" title="Свернуть / развернуть">' +
'<svg viewBox="0 0 24 24"><path fill="currentColor" class="vinf-ic-expand" d="M7.41 8.59 12 13.17l4.59-4.58L18 10l-6 6-6-6z"/><path fill="currentColor" class="vinf-ic-collapse" d="M7.41 15.41 12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>' +
'</button>' +
'<button type="button" class="vinf-btn" data-act="close" aria-label="Закрыть" title="Закрыть">' +
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>' +
'</button>' +
'</div>' +
'<div class="vinf-body">' +
'<div class="vinf-voyage">' +
'<div class="vinf-port"><span class="vinf-port-country">Назначение</span> ' + dest + '</div>' +
'<div class="vinf-voyage-arrow"></div>' +
'<div class="vinf-port vinf-port--right">' + (eta !== '—' ? eta : '<span class="dim">—</span>') + '</div>' +
'<div class="vinf-eta">' +
'<span><b>Обновлено:</b> ' + (v.timestamp ? escHtml(fmtAgo(v.timestamp)) + ' назад' : '—') + '</span>' +
'<span><b>ETA:</b> ' + eta + '</span>' +
'</div>' +
'</div>' +
'<div class="vinf-grid">' +
'<div class="vinf-cell"><div class="vinf-cell-lbl">Навигационный статус</div><div class="vinf-cell-val">' + navVal + '</div></div>' +
'<div class="vinf-cell"><div class="vinf-cell-lbl">Скорость / Курс (COG)</div><div class="vinf-cell-val">' + speedCourse + '</div></div>' +
'<div class="vinf-cell"><div class="vinf-cell-lbl">Направление (HDG)</div><div class="vinf-cell-val">' + heading + '</div></div>' +
'<div class="vinf-cell"><div class="vinf-cell-lbl">Осадка</div><div class="vinf-cell-val">' + draught + '</div></div>' +
bearingCell +
'</div>' +
sigBlock +
'<div class="vinf-ids">' + idsParts.join('') + '</div>' +
'<div class="vinf-actions">' +
'<button type="button" class="vinf-act primary" data-act="center" title="Центрировать карту на цели">' +
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm9 3h-2.07A7 7 0 0 0 13 5.07V3h-2v2.07A7 7 0 0 0 5.07 11H3v2h2.07A7 7 0 0 0 11 18.93V21h2v-2.07A7 7 0 0 0 18.93 13H21v-2Z"/></svg>' +
'Центрировать' +
'</button>' +
'<button type="button" class="vinf-act" data-act="ruler" title="Рулетка: ваше судно → эта цель">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path stroke-linejoin="round" d="M2 15 L15 2 L22 9 L9 22 Z"/><path d="M6 16l2-2 M9 18l2-2 M12 20l2-2 M11 11l2-2 M14 13l2-2"/></svg>' +
'Расстояние' +
'</button>' +
'<button type="button" class="vinf-act" data-act="copy" title="Скопировать координаты">' +
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1Zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2Zm0 16H8V7h11v14Z"/></svg>' +
'Копировать' +
'</button>' +
'</div>' +
'</div>' +
'<div class="vinf-footer">' +
receivedLine +
'<span>Источник: <b>AIS</b></span>' +
'</div>';
}
function _positionNearMarker() {
const root = el();
if (!root || openMmsi == null) return;
if (isMobile()) {
root.style.left = '';
root.style.top = '';
root.style.right = '';
root.style.bottom = '';
return;
}
try {
const mk = _markerForTargetMmsi(openMmsi);
if (!mk) return;
const pt = map.latLngToContainerPoint(mk.getLatLng());
const mapRect = map.getContainer().getBoundingClientRect();
const w = root.offsetWidth || 360;
const h = root.offsetHeight || 280;
const margin = 12;
let x = pt.x + 28;
if (x + w + margin > mapRect.width) x = pt.x - w - 28;
if (x < margin) x = margin;
let y = pt.y - h / 2;
if (y < 56) y = 56;
if (y + h > mapRect.height - margin) y = Math.max(margin, mapRect.height - margin - h);
root.style.left = Math.round(x) + 'px';
root.style.top = Math.round(y) + 'px';
root.style.right = '';
root.style.bottom = '';
} catch (e) {}
}
// Compute the map's "visible" sub-rectangle (excluding the infowindow occlusion)
// and pan so that the marker ends up in the centre of that visible area.
// Only shifts the map if the marker is (or would be) occluded.
function _panMarkerIntoView(latlng, opts) {
opts = opts || {};
const root = el();
if (!root || root.hidden || !latlng) return;
try {
const mapEl = map.getContainer();
const mapRect = mapEl.getBoundingClientRect();
const r = root.getBoundingClientRect();
let vx0 = 0, vy0 = 0, vx1 = mapRect.width, vy1 = mapRect.height;
const ix0 = Math.max(0, r.left - mapRect.left);
const ix1 = Math.min(mapRect.width, r.right - mapRect.left);
const iy0 = Math.max(0, r.top - mapRect.top);
const iy1 = Math.min(mapRect.height, r.bottom - mapRect.top);
if (ix1 > ix0 && iy1 > iy0) {
const isFullWidth = (ix1 - ix0) >= mapRect.width * 0.8;
const isFullHeight = (iy1 - iy0) >= mapRect.height * 0.5;
if (isFullWidth) {
if (iy0 <= 6) vy0 = iy1;
else if (iy1 >= mapRect.height - 6) vy1 = iy0;
} else if (isFullHeight) {
if (ix0 <= 6) vx0 = ix1;
else if (ix1 >= mapRect.width - 6) vx1 = ix0;
} else {
if (iy1 >= mapRect.height - 6) vy1 = iy0;
}
}
const pt = map.latLngToContainerPoint(latlng);
const padding = 24;
const occluded =
pt.x < vx0 + padding || pt.x > vx1 - padding ||
pt.y < vy0 + padding || pt.y > vy1 - padding;
if (!occluded && opts.force !== true) return;
const cx = (vx0 + vx1) / 2;
const cy = (vy0 + vy1) / 2;
const dx = pt.x - cx;
const dy = pt.y - cy;
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
map.panBy([dx, dy], { animate: opts.animate !== false });
}
} catch (_) {}
}
function setCollapsed(v) {
isCollapsed = !!v;
const root = el();
if (!root) return;
root.classList.toggle('vinf--collapsed', isCollapsed);
root.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');
// When collapsing/expanding the desktop panel, we may shift from being clipped to visible;
// re-run positioning on desktop so it stays anchored correctly.
if (!isMobile() && !userPositioned) {
requestAnimationFrame(() => _positionNearMarker());
}
}
function _attachOnce() {
const root = el();
if (!root || root._bound) return;
root._bound = true;
root.addEventListener('click', (e) => {
const btn = e.target.closest('[data-act]');
if (!btn) return;
const act = btn.dataset.act;
const mmsi = openMmsi;
const v = mmsi != null ? getAisTargetByMmsi(mmsi) : null;
if (act === 'close') { close(); return; }
if (act === 'collapse') { setCollapsed(!isCollapsed); return; }
if (act === 'center' && v && v.lat != null && v.lon != null) {
map.setView([v.lat, v.lon], Math.max(map.getZoom(), 14));
requestAnimationFrame(() => _panMarkerIntoView(L.latLng(v.lat, v.lon)));
return;
}
if (act === 'ruler' && v && v.lat != null && v.lon != null) {
try { RulerTool.startFromOwnshipTo(v.lat, v.lon); } catch (_) {}
return;
}
if (act === 'copy' && v && v.lat != null && v.lon != null) {
const txt = Number(v.lat).toFixed(6) + ', ' + Number(v.lon).toFixed(6);
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(txt);
}
} catch (_) {}
btn.textContent = 'Скопировано';
setTimeout(() => { try { refreshIfOpen(mmsi); } catch (_) {} }, 1200);
return;
}
});
// Desktop drag
root.addEventListener('pointerdown', (e) => {
if (isMobile()) return;
if (e.target.closest('[data-act]')) return;
const handle = e.target.closest('[data-drag-handle]');
if (!handle) return;
e.preventDefault();
const rect = root.getBoundingClientRect();
_drag = { id: e.pointerId, dx: e.clientX - rect.left, dy: e.clientY - rect.top };
try { root.setPointerCapture(e.pointerId); } catch (_) {}
userPositioned = true;
});
root.addEventListener('pointermove', (e) => {
if (!_drag || e.pointerId !== _drag.id) return;
const mapRect = map.getContainer().getBoundingClientRect();
let x = e.clientX - mapRect.left - _drag.dx;
let y = e.clientY - mapRect.top - _drag.dy;
const w = root.offsetWidth, h = root.offsetHeight, m = 4;
if (x < m) x = m;
if (y < m) y = m;
if (x + w > mapRect.width - m) x = mapRect.width - m - w;
if (y + h > mapRect.height - m) y = mapRect.height - m - h;
root.style.left = Math.round(x) + 'px';
root.style.top = Math.round(y) + 'px';
root.style.right = '';
root.style.bottom = '';
});
const endDrag = (e) => {
if (!_drag) return;
if (e && e.pointerId !== _drag.id) return;
_drag = null;
};
root.addEventListener('pointerup', endDrag);
root.addEventListener('pointercancel', endDrag);
// Mobile swipe on the header grip:
// - swipe DOWN: collapse (if expanded) or close (if already collapsed)
// - swipe UP : expand (if collapsed)
let sw = null;
root.addEventListener('touchstart', (e) => {
if (!isMobile()) return;
if (!e.target.closest('[data-drag-handle]')) return;
const t = e.touches[0];
if (!t) return;
sw = { y0: t.clientY, id: t.identifier, fired: false };
}, { passive: true });
root.addEventListener('touchmove', (e) => {
if (!sw || sw.fired) return;
const t = Array.from(e.touches).find(tt => tt.identifier === sw.id);
if (!t) return;
const dy = t.clientY - sw.y0;
// Swipe only collapses/expands; the close (X) button is the only way to close.
if (dy > 48 && !isCollapsed) { sw.fired = true; setCollapsed(true); }
else if (dy < -48 && isCollapsed) { sw.fired = true; setCollapsed(false); }
}, { passive: true });
root.addEventListener('touchend', () => { sw = null; }, { passive: true });
// Close on Esc
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && openMmsi != null) close();
});
// Follow marker when map pans/zooms (unless user dragged it already)
try {
map.on('zoomend moveend rotate', () => {
if (openMmsi != null && !userPositioned) _positionNearMarker();
});
} catch (_) {}
}
function open(mmsi) {
const root = el();
if (!root) return;
_attachOnce();
const prev = openMmsi;
openMmsi = mmsi;
selectedMmsi = String(mmsi);
if (prev !== mmsi) {
userPositioned = false;
isCollapsed = false;
}
const v = getAisTargetByMmsi(mmsi);
if (!v) return;
root.innerHTML = _buildHtml(v);
root.hidden = false;
root.setAttribute('aria-hidden', 'false');
root.classList.toggle('vinf--collapsed', isCollapsed);
root.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');
if (!userPositioned) {
requestAnimationFrame(() => _positionNearMarker());
}
// After layout, pan the map so the marker is not hidden behind the sheet/card.
if (v.lat != null && v.lon != null) {
requestAnimationFrame(() => _panMarkerIntoView(L.latLng(v.lat, v.lon)));
}
try {
const ov = vesselOverlays.get(mmsi);
if (v.lat != null && v.lon != null) {
updateVesselOverlay(mmsi, v.lat, v.lon, true,
!!(ov && ov.losing),
(_markerForTargetMmsi(mmsi) && _markerForTargetMmsi(mmsi)._bearingForOverlay));
}
} catch (_) {}
try { renderVesselSidebar(); } catch (_) {}
try { renderTargetsTab(); } catch (_) {}
try { if (typeof NearbyHud !== 'undefined') NearbyHud.render(); } catch (_) {}
}
function close() {
const root = el();
if (!root) return;
const was = openMmsi;
openMmsi = null;
if (String(selectedMmsi) === String(was)) selectedMmsi = null;
root.hidden = true;
root.setAttribute('aria-hidden', 'true');
if (was != null) {
try {
const v = getAisTargetByMmsi(was);
if (v && v.lat != null && v.lon != null) {
const ov = vesselOverlays.get(was);
updateVesselOverlay(was, v.lat, v.lon, false,
!!(ov && ov.losing),
(_markerForTargetMmsi(was) && _markerForTargetMmsi(was)._bearingForOverlay));
}
} catch (_) {}
try { renderVesselSidebar(); } catch (_) {}
try { renderTargetsTab(); } catch (_) {}
try { if (typeof NearbyHud !== 'undefined') NearbyHud.render(); } catch (_) {}
}
}
function refreshIfOpen(mmsi) {
if (openMmsi == null || String(openMmsi) !== String(mmsi)) return;
const root = el();
if (!root || root.hidden) return;
const v = getAisTargetByMmsi(mmsi);
if (!v) return;
root.innerHTML = _buildHtml(v);
root.classList.toggle('vinf--collapsed', isCollapsed);
root.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');
}
function currentMmsi() { return openMmsi; }
function panIntoView(lat, lon, opts) { _panMarkerIntoView(L.latLng(lat, lon), opts || {}); }
return { open, close, refreshIfOpen, currentMmsi, panIntoView, setCollapsed };
})();
// Back-compat shim: existing code checked `openPopups.has(mmsi)` to decide whether
// a vessel is "chosen". Keep the same read-interface but delegate to VesselInfoWindow.
const openPopups = {
has(mmsi) { return String(VesselInfoWindow.currentMmsi()) === String(mmsi); },
add() {}, delete() {}, clear() {},
};
// ===================== Ruler tool (on-map distance measurement) =====================
// - Click to set first point, click again to set end point (or move cursor before second click).
// - Supports "from ownship to target" shortcut via startFromOwnshipTo().
// - HUD in lower-right shows NM / km / bearing and a close button.
const RulerTool = (function () {
let on = false;
let line = null, endMarker = null, startMarker = null;
let startLL = null, endLL = null;
let hud = null;
let onClick = null, onMove = null;
function _ensureHud() {
if (hud) return hud;
hud = document.createElement('div');
hud.className = 'ruler-hud';
hud.hidden = true;
hud.innerHTML =
'<span class="hint">Клик — начальная точка, ещё клик — конечная. Esc — выход.</span>' +
'<span class="dist">—</span>' +
'<button type="button" aria-label="Закрыть рулетку" title="Закрыть">✕</button>';
document.body.appendChild(hud);
hud.querySelector('button').addEventListener('click', disable);
return hud;
}
function _updateHud() {
_ensureHud();
hud.hidden = false;
const d = hud.querySelector('.dist');
if (!startLL) { d.textContent = 'Кликните на карте'; return; }
if (!endLL) { d.textContent = 'Выберите вторую точку…'; return; }
const nm = haversineNM(startLL.lat, startLL.lng, endLL.lat, endLL.lng);
const km = nm * 1.852;
const brg = bearingDeg(startLL.lat, startLL.lng, endLL.lat, endLL.lng);
d.textContent = nm.toFixed(2) + ' NM · ' + km.toFixed(2) + ' км · ' + brg.toFixed(0) + '°';
}
function _draw() {
if (!startLL) return;
const pts = [startLL, endLL || startLL];
if (!line) line = L.polyline(pts, { color: '#d2ff1a', weight: 3, dashArray: '6,4', interactive: false }).addTo(map);
else line.setLatLngs(pts);
if (!startMarker) startMarker = L.circleMarker(startLL, { radius: 5, color: '#d2ff1a', fillColor: '#d2ff1a', fillOpacity: 0.9, interactive: false }).addTo(map);
else startMarker.setLatLng(startLL);
if (endLL) {
if (!endMarker) endMarker = L.circleMarker(endLL, { radius: 6, color: '#d2ff1a', fillColor: '#d2ff1a', fillOpacity: 0.6, interactive: false }).addTo(map);
else endMarker.setLatLng(endLL);
}
}
function enable() {
if (on) return;
on = true;
document.body.classList.add('ruler-active');
try { map.getContainer().style.cursor = 'crosshair'; } catch (_) {}
const btn = document.getElementById('mc-ruler');
if (btn) btn.classList.add('active');
startLL = null; endLL = null;
_updateHud();
onClick = (e) => {
if (!startLL) { startLL = e.latlng; endLL = null; _draw(); _updateHud(); return; }
if (!endLL) { endLL = e.latlng; _draw(); _updateHud(); return; }
// restart measurement
startLL = e.latlng; endLL = null; _draw(); _updateHud();
};
onMove = (e) => {
if (startLL && !endLL && line) line.setLatLngs([startLL, e.latlng]);
};
map.on('click', onClick);
map.on('mousemove', onMove);
}
function disable() {
if (!on) return;
on = false;
document.body.classList.remove('ruler-active');
try { map.getContainer().style.cursor = ''; } catch (_) {}
if (onClick) map.off('click', onClick);
if (onMove) map.off('mousemove', onMove);
onClick = null; onMove = null;
if (line) { try { map.removeLayer(line); } catch (_) {} line = null; }
if (startMarker) { try { map.removeLayer(startMarker); } catch (_) {} startMarker = null; }
if (endMarker) { try { map.removeLayer(endMarker); } catch (_) {} endMarker = null; }
startLL = null; endLL = null;
if (hud) hud.hidden = true;
const btn = document.getElementById('mc-ruler');
if (btn) btn.classList.remove('active');
}
function toggle() { on ? disable() : enable(); }
function startFromOwnshipTo(lat, lon) {
const os = getOwnShipPos();
enable();
if (os && os.lat != null && os.lon != null) {
startLL = L.latLng(os.lat, os.lon);
endLL = L.latLng(lat, lon);
_draw(); _updateHud();
try { map.fitBounds(L.latLngBounds([startLL, endLL]).pad(0.25)); } catch (_) {}
}
}
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && on) disable(); });
return { enable, disable, toggle, startFromOwnshipTo, isOn: () => on };
})();
// ===================== Map controls (zoom, centre, north-up, ruler, one-hand) =====================
(function initMapControls() {
function bind(id, fn) {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('click', (e) => { e.preventDefault(); fn(e); });
}
const centerOnOwnship = () => {
const os = getOwnShipPos();
if (os && os.lat != null && os.lon != null) {
map.setView([os.lat, os.lon], Math.max(map.getZoom(), 13));
}
};
bind('mc-zoom-in', () => map.zoomIn());
bind('mc-zoom-out', () => map.zoomOut());
bind('mc-center', centerOnOwnship);
bind('mc-north-up', () => {
try { if (typeof map.setBearing === 'function') map.setBearing(0); } catch (_) {}
try {
if (typeof rotateMapByCompass !== 'undefined' && rotateMapByCompass) {
rotateMapByCompass = false;
try { sSet('rotateMapByCompass', false); } catch (_) {}
try { _setOwnshipCompassUi(); } catch (_) {}
try { _reflectCompassToggleUi(); } catch (_) {}
}
} catch (_) {}
});
bind('mc-compass', () => {
try { _toggleRotateMapByCompass(); } catch (_) {}
});
try { _reflectCompassToggleUi(); } catch (_) {}
bind('mc-ruler', () => RulerTool.toggle());
// One-hand pad buttons
bind('oh-zoom-in', () => map.zoomIn());
bind('oh-zoom-out', () => map.zoomOut());
bind('oh-center', centerOnOwnship);
// One-hand mode toggle (big buttons + single-finger double-tap-drag zoom)
const onehandBtn = document.getElementById('mc-onehand');
const onehandPad = document.getElementById('onehand-pad');
function setOnehand(v) {
document.body.classList.toggle('onehand', !!v);
if (onehandPad) {
onehandPad.hidden = !v;
onehandPad.setAttribute('aria-hidden', v ? 'false' : 'true');
}
if (onehandBtn) onehandBtn.classList.toggle('active', !!v);
try { sSet('onehand', v ? '1' : '0'); } catch (_) {}
}
try { if (sGet('onehand', '0') === '1') setOnehand(true); } catch (_) {}
if (onehandBtn) onehandBtn.addEventListener('click', () => {
setOnehand(!document.body.classList.contains('onehand'));
});
// Double-tap-drag zoom on a single finger (Google-Maps-style) — active only in one-hand mode.
try {
const mapEl = map.getContainer();
let lastTap = null, dragState = null;
mapEl.addEventListener('touchstart', (e) => {
if (!document.body.classList.contains('onehand')) return;
if (e.touches.length !== 1) { lastTap = null; return; }
const t = e.touches[0];
const now = Date.now();
if (lastTap && (now - lastTap.ts) < 320 &&
Math.hypot(t.clientX - lastTap.x, t.clientY - lastTap.y) < 40) {
const rect = mapEl.getBoundingClientRect();
const pt = L.point(t.clientX - rect.left, t.clientY - rect.top);
dragState = { y0: t.clientY, zoom0: map.getZoom(), centerLL: map.containerPointToLatLng(pt) };
try { map.dragging.disable(); } catch (_) {}
e.preventDefault();
}
lastTap = { x: t.clientX, y: t.clientY, ts: now };
}, { passive: false });
mapEl.addEventListener('touchmove', (e) => {
if (!dragState || e.touches.length !== 1) return;
const t = e.touches[0];
const dy = dragState.y0 - t.clientY;
const z = dragState.zoom0 + dy / 80;
const zMin = map.getMinZoom(), zMax = map.getMaxZoom();
map.setZoomAround(dragState.centerLL, Math.max(zMin, Math.min(zMax, z)));
e.preventDefault();
}, { passive: false });
const endOneFingerZoom = () => {
if (dragState) { dragState = null; try { map.dragging.enable(); } catch (_) {} }
};
mapEl.addEventListener('touchend', endOneFingerZoom);
mapEl.addEventListener('touchcancel', endOneFingerZoom);
} catch (_) {}
})();
// ===================== New "Targets" tab (primary full list on mobile) =====================
function renderTargetsTab() {
const list = document.getElementById('targets-list');
const cntEl = document.getElementById('targets-count');
const suffEl = document.getElementById('targets-count-suffix');
if (!list) return;
const combined = []
.concat(lastAnyVessels || [])
.concat(lastBaseStations || [])
.concat(lastBuoys || []);
const os = getOwnShipPos();
if (os) {
for (const v of combined) {
if (v && v.lat != null && v.lon != null) v._distNM = haversineNM(os.lat, os.lon, v.lat, v.lon);
else v._distNM = null;
}
}
const clsSel = document.getElementById('targets-filter-class');
const stSel = document.getElementById('targets-filter-shiptype');
const searchEl = document.getElementById('targets-search');
const sortEl = document.getElementById('targets-sort');
const cls = clsSel ? clsSel.value : 'all';
const stG = stSel ? stSel.value : 'all';
const q = (searchEl && searchEl.value ? searchEl.value : '').trim().toLowerCase();
const sortMode = sortEl ? sortEl.value : 'time-desc';
const filtered = combined.filter(v => {
if (cls !== 'all' && String(v.vessel_class || '') !== cls) return false;
if (typeof matchesShiptypeFilter === 'function' && !matchesShiptypeFilter(v, stG)) return false;
if (q) {
const hay = (typeof vesselSearchHaystack === 'function') ? vesselSearchHaystack(v) : String(v.mmsi || '').toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
try { if (typeof sortVesselsInPlace === 'function') sortVesselsInPlace(filtered, sortMode); } catch (_) {}
if (cntEl) cntEl.textContent = String(filtered.length);
if (suffEl) suffEl.textContent = filtered.length !== combined.length ? ' из ' + combined.length : '';
list.innerHTML = '';
for (const v of filtered) {
const { mmsi, lat, lon, vessel_class, shipname, speed, course, heading, timestamp } = v;
const it = document.createElement('div');
it.className = 'vessel-item';
it.dataset.mmsi = String(mmsi);
const hasCoord = lat != null && lon != null;
const iso2 = mmsiToIso2FromMid(mmsi);
const flag = iso2ToFlagEmoji(iso2);
const brg = (os && hasCoord) ? bearingDeg(os.lat, os.lon, lat, lon) : null;
let h = '<div class="vessel-mmsi-row"><span class="mmsi">' + escHtml(mmsi) + '</span>';
if (flag) h += '<span class="vessel-flag" title="' + escHtml(iso2 || '') + '">' + flag + '</span>';
h += '</div>';
if (shipname) h += '<div class="name">' + escHtml(shipname) + '</div>';
const clsLabel = (vessel_class === 'BS') ? 'База' : (vessel_class === 'N') ? 'Буёк' : (vessel_class || '?');
h += '<div>Класс: ' + escHtml(clsLabel) + '</div>';
if (String(vessel_class) === 'N' && v.aton_type != null && v.aton_type !== '') {
h += '<div class="coords">Тип: ' + escHtml(v.aton_type_label || atonTypeLabel(v.aton_type) || 'Тип СНО не указан') + '</div>';
}
if (v._distNM != null || brg != null) {
let distLine = '<div class="dist" title="Дальность · пеленг от своего судна">';
if (v._distNM != null) distLine += '<span class="dist-val">' + fmtDist(v._distNM) + '</span>';
if (brg != null) distLine += '<span class="brg-val">' + brg.toFixed(0) + '°</span>';
distLine += '</div>';
h += distLine;
}
h += '<div class="compact-stats">' +
'<span title="SOG — Speed Over Ground, скорость относительно земли">' +
'<span class="k">SOG</span><b>' + (speed != null ? escHtml(fmtSpeed(speed)) : '—') + '</b></span>' +
'<span title="COG — Course Over Ground, истинный курс движения">' +
'<span class="k">COG</span><b>' + (course != null ? course.toFixed(0) + '°' : '—') + '</b></span>' +
'<span title="HDG — Heading, направление носа судна (с компаса/гирокомпаса)">' +
'<span class="k">HDG</span><b>' + (heading != null ? heading.toFixed(0) + '°' : '—') + '</b></span>' +
(v.signal_db != null ? '<span title="Уровень радиосигнала последнего AIS-сообщения">' +
'<span class="k">SIG</span><b>' + Number(v.signal_db).toFixed(1) + ' дБ</b></span>' : '') +
'</div>';
if (timestamp != null) h += '<div class="coords">' + fmtTime(timestamp) + ' (' + fmtAgo(timestamp) + ')</div>';
it.innerHTML = h;
if (hasCoord) {
if (String(mmsi) === (selectedMmsi != null ? String(selectedMmsi) : null)) it.classList.add('selected');
it.addEventListener('click', () => {
selectedMmsi = String(mmsi);
switchTab('map');
setTimeout(() => {
map.setView([lat, lon], Math.max(map.getZoom(), 15), { animate: false });
try { VesselInfoWindow.open(mmsi); } catch (_) {}
try { NearbyHud.render(); } catch (_) {}
}, 80);
});
}
list.appendChild(it);
}
}
(function initTargetsTabControls() {
['targets-search', 'targets-sort', 'targets-filter-class', 'targets-filter-shiptype'].forEach(id => {
const el = document.getElementById(id);
if (!el) return;
const ev = (el.tagName === 'INPUT') ? 'input' : 'change';
el.addEventListener(ev, () => { try { renderTargetsTab(); } catch (_) {} });
});
})();
// ===================== Mobile compact sidebar toggle =====================
// On narrow viewports switch the "Ближайшие" sidebar to a compact row layout
// (MMSI / NAME / DIST + SOG/COG/HDG/BRG/DIST line). Desktop keeps verbose layout.
(function initMobileCompactSidebar() {
const sb = document.getElementById('sidebar');
if (!sb) return;
let mql;
try { mql = window.matchMedia('(max-width:600px)'); } catch (_) { return; }
const apply = () => sb.classList.toggle('sidebar--compact', !!mql.matches);
apply();
try { mql.addEventListener('change', apply); } catch (_) {
if (mql.addListener) mql.addListener(apply);
}
})();
// ===================== Nearby HUD =====================
// Semi-transparent corner overlay: own-ship readout (speed / coords / compass)
// + the 5 nearest vessels OR the single currently selected vessel (with a clear-X).
// Clicking an item selects the vessel and opens the infowindow.
const NearbyHud = (function(){
const root = document.getElementById('nearby-hud');
if (!root) return { render: ()=>{} };
const listEl = document.getElementById('nhud-list');
const ownRow = document.getElementById('nhud-own');
const ownSog = document.getElementById('nhud-own-sog');
const ownCog = document.getElementById('nhud-own-cog');
const ownCoords = document.getElementById('nhud-own-coords');
const ownSrc = document.getElementById('nhud-own-src');
const compassEl = document.getElementById('nhud-compass');
const needle = document.getElementById('nhud-needle');
const compassVal = document.getElementById('nhud-compass-val');
const toggleBtn = document.getElementById('nhud-toggle');
let collapsed = false;
try { collapsed = sGet('nhudCollapsed', '0') === '1'; } catch(_) {}
root.classList.toggle('is-collapsed', collapsed);
if (toggleBtn) {
toggleBtn.addEventListener('click', () => {
collapsed = !collapsed;
root.classList.toggle('is-collapsed', collapsed);
try { sSet('nhudCollapsed', collapsed ? '1' : '0'); } catch(_) {}
});
}
function fmtCoords(lat, lon) {
if (lat == null || lon == null) return '—';
return lat.toFixed(4) + ', ' + lon.toFixed(4);
}
function fmtBrg(b) { return (b != null && !isNaN(b)) ? b.toFixed(0) + '°' : '—'; }
function buildItemHtml(v, isSelected) {
const { mmsi, shipname, callsign, lat, lon, speed, course } = v;
const name = (shipname && String(shipname).trim()) || '';
const cs = (callsign && String(callsign).trim()) || '';
const brg = (v._brg != null) ? v._brg : null;
const distTxt = (v._distNM != null) ? fmtDist(v._distNM) : '—';
let idHtml = '';
if (name) idHtml += '<span class="nhud-item__name">' + escHtml(name) + '</span>';
if (cs) idHtml += '<span class="nhud-item__callsign">' + escHtml(cs) + '</span>';
idHtml += '<span class="nhud-item__mmsi">' + escHtml(mmsi) + '</span>';
let botHtml = '<span class="nhud-item__sog"><span class="k">SOG</span><b>' +
(speed != null ? escHtml(fmtSpeed(speed)) : '—') + '</b></span>';
botHtml += '<span class="nhud-item__cog"><span class="k">COG</span><b>' +
(course != null ? course.toFixed(0) + '°' : '—') + '</b></span>';
botHtml += '<span class="nhud-item__brg">' + fmtBrg(brg) + '</span>';
return '<div class="nhud-item__top">' +
'<span class="nhud-item__id">' + idHtml + '</span>' +
'<span class="nhud-item__dist">' + escHtml(distTxt) + '</span>' +
'</div>' +
'<div class="nhud-item__bot">' + botHtml + '</div>' +
'<div class="nhud-item__coords">' + escHtml(fmtCoords(lat, lon)) + '</div>' +
(isSelected ? '<button type="button" class="nhud-item__clear" title="Снять выделение" aria-label="Снять выделение">×</button>' : '');
}
function renderOwn() {
const data = (typeof ownShipSource !== 'undefined' && ownShipSource === 'phone') ? phoneGps : nmeaGps;
const hasFix = data && data.lat != null && data.lon != null;
if (hasFix) {
ownCoords.textContent = fmtCoords(data.lat, data.lon);
ownSog.textContent = data.speed != null ? fmtSpeed(data.speed) : '—';
ownCog.textContent = data.course != null ? data.course.toFixed(0) + '°' : '—';
} else {
ownCoords.textContent = '—';
ownSog.textContent = '—';
ownCog.textContent = '—';
}
// source label
let srcTxt = '';
let srcErr = false;
if (ownShipSource === 'phone') {
if (phoneGpsError) { srcTxt = phoneGpsError; srcErr = true; }
else if (hasFix) srcTxt = 'PHONE GPS';
else srcTxt = 'PHONE GPS (ожидание…)';
} else {
srcTxt = hasFix ? 'NMEA' : 'NMEA (нет данных)';
}
ownSrc.textContent = srcTxt;
ownSrc.classList.toggle('nhud-own__src--err', srcErr);
// Compass: prefer phone compass when available; else GPS heading/course; else nothing
const compassH = (typeof phoneCompassOk !== 'undefined' && phoneCompassOk && phoneCompassHeading != null)
? phoneCompassHeading : null;
const h = (compassH != null)
? compassH
: (hasFix ? (data.heading != null ? data.heading : data.course) : null);
const hasHeading = h != null && !isNaN(h);
compassEl.classList.toggle('no-data', !hasHeading);
compassEl.classList.toggle('from-phone', compassH != null);
compassEl.classList.toggle('has-val', hasHeading);
if (hasHeading) {
needle.style.transform = 'rotate(' + (-h) + 'deg)';
compassVal.textContent = h.toFixed(0) + '°';
} else {
compassVal.textContent = '';
}
}
function renderList() {
const os = getOwnShipPos();
const combined = []
.concat(lastAnyVessels || [])
.concat(lastBaseStations || [])
.concat(lastBuoys || []);
// Compute distance+bearing from own ship
if (os) {
for (const v of combined) {
if (v && v.lat != null && v.lon != null) {
v._distNM = haversineNM(os.lat, os.lon, v.lat, v.lon);
v._brg = bearingDeg(os.lat, os.lon, v.lat, v.lon);
} else {
v._distNM = null; v._brg = null;
}
}
}
const hasSel = selectedMmsi != null;
let items = [];
if (hasSel) {
const sel = combined.find(v => String(v.mmsi) === String(selectedMmsi));
if (sel) items.push({ v: sel, isSel: true });
}
if (!items.length || !hasSel) {
// Top-5 by distance (if own position known); else by time desc
let pool = combined.filter(v => v && v.lat != null && v.lon != null);
if (hasSel) pool = pool.filter(v => String(v.mmsi) !== String(selectedMmsi));
if (os) {
pool.sort((a,b) => (a._distNM != null ? a._distNM : Infinity) - (b._distNM != null ? b._distNM : Infinity));
} else {
pool.sort((a,b) => (b.timestamp||0) - (a.timestamp||0));
}
const need = hasSel ? 4 : 5;
for (const v of pool.slice(0, need)) items.push({ v, isSel: false });
}
if (!items.length) {
listEl.innerHTML = '<div class="nhud-empty">Нет целей в эфире</div>';
root.classList.add('is-empty');
return;
}
root.classList.remove('is-empty');
const html = items.map(({ v, isSel }) => {
return '<div class="nhud-item' + (isSel ? ' is-selected' : '') + '" data-mmsi="' + escHtml(String(v.mmsi)) + '">' +
buildItemHtml(v, isSel) + '</div>';
}).join('');
listEl.innerHTML = html;
// Wire up click handlers
listEl.querySelectorAll('.nhud-item').forEach(el => {
const mmsi = el.dataset.mmsi;
el.addEventListener('click', (ev) => {
if (ev.target.closest('.nhud-item__clear')) {
selectedMmsi = null;
try { VesselInfoWindow.close(); } catch(_) {}
try { renderVesselSidebar(); } catch(_) {}
render();
return;
}
selectedMmsi = String(mmsi);
const v = combined.find(x => String(x.mmsi) === String(mmsi));
if (v && v.lat != null && v.lon != null) {
try { map.setView([v.lat, v.lon], Math.max(map.getZoom(), 14), { animate: true }); } catch(_) {}
try { VesselInfoWindow.open(mmsi); } catch(_) {}
}
render();
});
});
}
let _renderPending = false;
function render() {
if (_renderPending) return;
_renderPending = true;
requestAnimationFrame(() => {
_renderPending = false;
try { renderOwn(); } catch(_) {}
try { renderList(); } catch(_) {}
});
}
// Short ticker for compass/coords (updates even between vessel feeds).
setInterval(() => { try { renderOwn(); } catch(_) {} }, 500);
// Full re-render pace
setInterval(() => { try { render(); } catch(_) {} }, 2000);
return { render };
})();
/** Stern (icon anchor) → midpoint of hull along keel; bearing ° from north like AIS heading. */
function overlayLatLngFromStern(lat, lon, bearingDeg) {
if (bearingDeg == null || isNaN(bearingDeg)) return [lat, lon];
const ll = L.latLng(lat, lon);
const p = map.latLngToContainerPoint(ll);
if (!p) return [lat, lon];
const mpp = map.distance(ll, map.containerPointToLatLng(L.point(p.x, p.y + 1)));
const distM = (iconHeight / 2) * mpp;
const R = 6371000;
const brng = bearingDeg * Math.PI / 180;
const φ1 = lat * Math.PI / 180, λ1 = lon * Math.PI / 180;
const δ = distM / R;
const sinφ1 = Math.sin(φ1), cosφ1 = Math.cos(φ1);
const sinδ = Math.sin(δ), cosδ = Math.cos(δ);
const sinφ2 = sinφ1 * cosδ + cosφ1 * sinδ * Math.cos(brng);
const φ2 = Math.asin(sinφ2);
const λ2 = λ1 + Math.atan2(Math.sin(brng) * sinδ * cosφ1, cosδ - sinφ1 * sinφ2);
return [φ2 * 180 / Math.PI, λ2 * 180 / Math.PI];
}
function updateVesselOverlay(mmsi, lat, lon, isChosen, isLosing, bearingDeg) {
const [olat, olng] = overlayLatLngFromStern(lat, lon, bearingDeg);
if (!vesselOverlays.has(mmsi)) vesselOverlays.set(mmsi, {chosen:null, losing:null});
const o = vesselOverlays.get(mmsi);
if (isChosen && !o.chosen) o.chosen = L.marker([olat,olng],{icon:iconChosen,zIndexOffset:1000}).addTo(map);
else if (!isChosen && o.chosen) { map.removeLayer(o.chosen); o.chosen=null; }
else if (isChosen && o.chosen) o.chosen.setLatLng([olat,olng]);
if (isLosing && !o.losing) o.losing = L.marker([olat,olng],{icon:iconLosing,zIndexOffset:1000}).addTo(map);
else if (!isLosing && o.losing) { map.removeLayer(o.losing); o.losing=null; }
else if (isLosing && o.losing) o.losing.setLatLng([olat,olng]);
}
function removeVesselOverlay(mmsi) {
if (!vesselOverlays.has(mmsi)) return;
const o = vesselOverlays.get(mmsi);
if (o.chosen) map.removeLayer(o.chosen);
if (o.losing) map.removeLayer(o.losing);
vesselOverlays.delete(mmsi);
}
function fmtAgo(ts) {
if (!ts) return '?';
const d = Math.floor(Date.now()/1000) - ts;
if (d<0) return '?';
if (d<60) return d+'с';
const m=Math.floor(d/60), s=d%60;
if (m<60) return m+'м '+s+'с';
return Math.floor(m/60)+'ч '+m%60+'м';
}
function fmtTime(ts) {
if (!ts) return '?';
const d=new Date(ts*1000);
return [d.getHours(),d.getMinutes(),d.getSeconds()].map(v=>String(v).padStart(2,'0')).join(':');
}
function _clientLog(level, msg, ctx) {
try {
try { console.log('[AISMap] clientLog', level, msg, ctx || null); } catch (e) {}
// Best-effort: do not block UI; keepalive helps on page unload.
const payload = {
level: level || 'info',
msg: msg || '',
ctx: ctx || null,
ts: Math.floor(Date.now() / 1000),
url: (typeof location !== 'undefined' ? location.href : null),
ua: (typeof navigator !== 'undefined' ? navigator.userAgent : null),
build: (typeof APP_BUILD !== 'undefined' ? APP_BUILD : null),
};
// Prefer Beacon (more reliable on mobile/WebView); fall back to fetch.
try {
if (navigator && typeof navigator.sendBeacon === 'function') {
const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
navigator.sendBeacon('/api/client_log', blob);
return;
}
} catch (e) {}
fetch('/api/client_log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true,
}).catch(() => {});
} catch (e) {}
}
function _numOrNull(x) {
if (x == null) return null;
if (typeof x === 'number') return Number.isFinite(x) ? x : null;
const n = parseFloat(x);
return Number.isFinite(n) ? n : null;
}
function _isMockApiEnabled() {
try {
const q = new URLSearchParams(location.search || '');
if (q.get('mock') === '1') return true;
} catch (e) {}
try { return sGet('mockApi', '0') === '1'; } catch (e) { return false; }
}
function _apiUrl(path) {
if (!_isMockApiEnabled()) return path;
try {
const u = new URL(path, location.origin);
u.searchParams.set('mock', '1');
return u.pathname + '?' + u.searchParams.toString();
} catch (e) {
return path + (path.includes('?') ? '&' : '?') + 'mock=1';
}
}
function _extractServerNowAndList(payload, key) {
// Backward-compatible: old APIs returned plain arrays.
if (Array.isArray(payload)) {
return { serverNow: Math.floor(Date.now() / 1000), list: payload };
}
if (payload && typeof payload === 'object') {
const sn = payload.server_now != null ? parseInt(payload.server_now, 10) : null;
const list = payload[key] != null ? payload[key] : (payload.data != null ? payload.data : []);
return {
serverNow: (sn != null && !isNaN(sn)) ? sn : Math.floor(Date.now() / 1000),
list: Array.isArray(list) ? list : []
};
}
return { serverNow: Math.floor(Date.now() / 1000), list: [] };
}
// Debug HUD (left-bottom corner) — disabled because it collides with the
// #nearby-hud overlay. The helpers are kept as no-ops so existing call sites
// keep working and can easily be re-enabled by flipping `_DEBUG_HUD_ENABLED`
// or by removing this guard.
const _DEBUG_HUD_ENABLED = false;
function _ensureDebugHudOnce() {
if (!_DEBUG_HUD_ENABLED) return;
try {
if (document.getElementById('aismap-debug-hud')) return;
const d = document.createElement('div');
d.id = 'aismap-debug-hud';
d.style.cssText = 'position:fixed;left:8px;top:8px;z-index:9999;max-width:92vw;' +
'background:rgba(0,0,0,.75);color:#e6edf3;font:12px/1.35 system-ui,Segoe UI,Roboto,Arial;' +
'padding:6px 8px;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.25);' +
'white-space:pre-wrap;pointer-events:none';
d.textContent = 'AISMap debug HUD\nbuild: ' + APP_BUILD;
document.body.appendChild(d);
} catch (e) {}
}
function _setDebugHud(text) {
if (!_DEBUG_HUD_ENABLED) return;
try {
_ensureDebugHudOnce();
const d = document.getElementById('aismap-debug-hud');
if (d) d.textContent = text;
} catch (e) {}
}
function updateVessels() {
try {
// Источник — in-memory AisHub.vessels, наполняется WebSocket-ом /ws.
const vessels = Array.from(AisHub.vessels.values()).map(v => Object.assign({}, v));
const now = Math.floor(Date.now() / 1000);
_setDebugHud('AISMap debug HUD\nbuild: ' + APP_BUILD + '\nvessels: ' + vessels.length);
const os = getOwnShipPos();
const maxNM = getRangeNM();
// mergedTargetToVessel уже нормализует числа, но на случай кривых событий — страхуем.
for (const v of vessels) {
if (!v) continue;
v.lat = _numOrNull(v.lat);
v.lon = _numOrNull(v.lon);
v.course = _numOrNull(v.course);
v.speed = _numOrNull(v.speed);
v.heading = _numOrNull(v.heading);
v.timestamp = v.timestamp != null ? parseInt(v.timestamp, 10) : v.timestamp;
v.mmsi = v.mmsi != null ? parseInt(v.mmsi, 10) : v.mmsi;
}
for (const v of vessels) {
if (os && v.lat != null && v.lon != null)
v._distNM = haversineNM(os.lat, os.lon, v.lat, v.lon);
else
v._distNM = null;
}
if (os) vessels.sort((a, b) => (a._distNM ?? Infinity) - (b._distNM ?? Infinity));
const visibleSet = new Set();
for (const v of vessels) {
const validCoord = v.lat != null && v.lon != null && !isNaN(v.lat) && !isNaN(v.lon) && v.lat >= -90 && v.lat <= 90 && v.lon >= -180 && v.lon <= 180;
const inRange = !isFinite(maxNM) || v._distNM == null || v._distNM <= maxNM;
const notExpired = !_isTargetExpiredByTimestamp(v, now);
if (validCoord && inRange && notExpired) visibleSet.add(v.mmsi);
}
// Total targets that sent anything (not expired), including those without a "legal" position.
const totalAnySet = new Set();
for (const v of vessels) {
const notExpired = !_isTargetExpiredByTimestamp(v, now);
if (notExpired) totalAnySet.add(v.mmsi);
}
// Update sidebar data early (even if marker drawing fails later).
lastAnyVessels = vessels
.filter(v => !_isTargetExpiredByTimestamp(v, now))
.map(v => Object.assign({}, v, { kind: 'vessel' }));
try {
console.log('[AISMap] vessels sets', {
visible: visibleSet.size,
totalAny: totalAnySet.size,
sampleVisible: (() => {
for (const v of vessels) {
if (visibleSet.has(v && v.mmsi)) return { mmsi: v.mmsi, lat: v.lat, lon: v.lon, ts: v.timestamp };
}
return null;
})()
});
} catch (e) {}
for (const [mmsi, mk] of vesselMarkers.entries()) {
if (!visibleSet.has(mmsi)) {
if (String(mmsi) === String(selectedMmsi)) selectedMmsi = null;
map.removeLayer(mk);
vesselMarkers.delete(mmsi);
removeVesselOverlay(mmsi);
removeMotionOverlays(mmsi);
removeVesselDetailOverlays(mmsi);
vesselLastData.delete(mmsi);
openPopups.delete(mmsi);
try { if (VesselInfoWindow.currentMmsi() && String(VesselInfoWindow.currentMmsi()) === String(mmsi)) VesselInfoWindow.close(); } catch (_) {}
}
}
document.getElementById('status').textContent = 'Целей: ' + visibleSet.size + ' (' + totalAnySet.size + ')';
_setDebugHud('AISMap debug HUD\nbuild: ' + APP_BUILD +
'\n/api/vessels: ' + (Array.isArray(vessels) ? vessels.length : '?') +
'\nvisibleSet: ' + visibleSet.size + ' totalAny: ' + totalAnySet.size);
_dangerState = { any: false, count: 0, minNm: null, relDegSigned: null };
for (const v of vessels) {
try {
if (!v || !visibleSet.has(v.mmsi)) continue;
const {mmsi, lat, lon, vessel_class, shipname, callsign, course, speed, heading, timestamp, shiptype, nav_status} = v;
const moving = speed != null && !isNaN(speed) && speed >= 1.5;
const bearingForOverlay = moving ? (course != null ? course : heading) : (heading != null ? heading : course);
const iconKey = vesselIconTintKey(vessel_class, shiptype, nav_status);
const icon = getVesselDivIcon(vessel_class, shiptype, nav_status);
if (vesselMarkers.has(mmsi)) {
const mk = vesselMarkers.get(mmsi);
_ensureAisMarkerPane(mk);
mk._bearingForOverlay = bearingForOverlay;
const cl = mk.getLatLng();
if (Math.abs(cl.lat - lat) > 0.0001 || Math.abs(cl.lng - lon) > 0.0001) mk.setLatLng([lat, lon]);
const ci = mk.options.icon;
if (!ci || mk._vesselIconKey !== iconKey) {
mk.setIcon(icon);
mk._vesselIconKey = iconKey;
requestAnimationFrame(() => setIconRotation(mk, bearingForOverlay));
} else setIconRotation(mk, bearingForOverlay);
} else {
const mk = L.marker([lat, lon], { icon, pane: AIS_MARKER_PANE || undefined }).addTo(map);
mk.on('click', () => { try { VesselInfoWindow.open(mmsi); } catch (_) {} });
mk._bearingForOverlay = bearingForOverlay;
mk._vesselIconKey = iconKey;
requestAnimationFrame(() => setIconRotation(mk, bearingForOverlay));
vesselMarkers.set(mmsi, mk);
}
const age = timestamp ? (now - timestamp) : Infinity;
const isChosen = openPopups.has(mmsi) || String(mmsi) === String(selectedMmsi);
updateVesselOverlay(mmsi, lat, lon, isChosen, age >= LOSING_TARGET_TIME && age < REMOVE_TARGET_TIME, bearingForOverlay);
// Motion overlays:
// - Vector: ahead by SOG/COG for 1 minute; if ROT is available, bend the vector.
// - Trail: dashed path behind using recent positions.
let refLat = lat, refLon = lon;
const dims = _dimsFromVessel(v);
const brg = _bearingForHull(v);
const refMode = (dims && brg != null) ? 'center' : 'antenna';
if (vesselTrailRefMode.get(mmsi) !== refMode) {
vesselTrailRefMode.set(mmsi, refMode);
vesselHistory.delete(mmsi); // reset when switching reference point
}
if (refMode === 'center') {
const c = _centerFromAntenna(lat, lon, brg, dims);
refLat = c.lat;
refLon = c.lon;
}
const speedForVector = (speed != null && !isNaN(speed) && speed >= 0 && speed <= 60) ? speed : null;
const courseForVector = (course != null && !isNaN(course)) ? course : null;
updateVesselVector(mmsi, refLat, refLon, courseForVector, speedForVector, v.rot);
updateVesselTrail(mmsi, refLat, refLon, timestamp);
// Cache for zoom-driven redraw, and update detailed overlays (true-size hull + antenna).
vesselLastData.set(mmsi, v);
updateVesselDetailOverlays(v);
try { VesselInfoWindow.refreshIfOpen(mmsi); } catch (_) {}
// Proximity logic: warning banner + highlight nearby targets
if (v._distNM != null && isFinite(v._distNM)) {
if (warnRadiusNm > 0 && v._distNM <= warnRadiusNm) {
_dangerState.any = true;
_dangerState.count += 1;
if (_dangerState.minNm == null || v._distNM < _dangerState.minNm) {
_dangerState.minNm = v._distNM;
// Relative bearing to the closest target (for the banner)
const os = getOwnShipPos();
const osHd = os ? (os.heading != null ? os.heading : os.course) : null;
if (os && os.lat != null && os.lon != null && osHd != null) {
const abs = bearingDeg(os.lat, os.lon, v.lat, v.lon);
_dangerState.relDegSigned = relBearingSignedDeg(osHd, abs);
} else {
_dangerState.relDegSigned = null;
}
}
}
const mk = vesselMarkers.get(mmsi);
if (mk && mk._icon) {
const near = nearRadiusNm > 0 && v._distNM <= nearRadiusNm;
mk._icon.classList.toggle('vessel-nearby', !!near);
}
}
} catch (e) {
console.error('updateVessels vessel:', e, v);
_clientLog('error', 'updateVessels per-vessel failed', {
err: String(e && (e.stack || e.message || e)),
vessel: v || null,
});
}
}
lastVisibleVessels = vessels.filter(v => visibleSet.has(v.mmsi)).map(v => Object.assign({}, v, { kind: 'vessel' }));
adjustSidebarHeight();
updateRangeCircle();
updateDangerCircles();
updateDangerBanner();
if (vessels.length > 0 && vesselMarkers.size > 0) {
const f = vessels[0];
if (f.lat && f.lon && map.getZoom() === 10 && Math.abs(map.getCenter().lat - 55.751244) < 0.001)
map.setView([f.lat, f.lon], 12);
}
} catch (e) {
console.error('updateVessels:', e);
_clientLog('error', 'updateVessels failed', { err: String(e && (e.stack || e.message || e)) });
_setDebugHud('AISMap debug HUD\nbuild: ' + APP_BUILD + '\nupdateVessels ERROR:\n' + String(e && (e.message || e)));
}
}
map.on('zoomend', () => {
// Re-evaluate which vessels should show true-size hull at this zoom.
for (const [mmsi, v] of vesselLastData.entries()) {
if (!vesselMarkers.has(mmsi)) continue;
updateVesselDetailOverlays(v);
}
});
// ===================== Список целей: MID→флаг, поиск, фильтры, сортировка =====================
const mmsiMidToIso2 = {};
let lastSidebarVessels = [];
function escHtml(s) {
if (s == null || s === '') return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function mmsiToIso2FromMid(mmsi) {
const d = String(mmsi).replace(/\D/g, '');
if (d.length < 3) return null;
const mid = d.slice(0, 3);
return mmsiMidToIso2[mid] || null;
}
function iso2ToFlagEmoji(iso2) {
if (!iso2 || iso2.length !== 2) return '';
const a = iso2.toUpperCase();
if (a.length !== 2 || a < 'AA' || a > 'ZZ') return '';
const A = 0x1F1E6;
return String.fromCodePoint(A + a.charCodeAt(0) - 65, A + a.charCodeAt(1) - 65);
}
function vesselSearchHaystack(v) {
const parts = [
String(v.mmsi != null ? v.mmsi : ''),
(v.shipname && String(v.shipname).trim()) || '',
(v.callsign && String(v.callsign).trim()) || '',
(v.aton_type_label && String(v.aton_type_label).trim()) || '',
(v.aton_type != null ? String(atonTypeLabel(v.aton_type) || '') : ''),
];
return parts.join('\u0000').toLowerCase();
}
function matchesShiptypeFilter(v, group) {
if (!group || group === 'all') return true;
if (v && v.kind && v.kind !== 'vessel') return false;
const st = v.shiptype;
const c = parseInt(st, 10);
if (group === 'unknown') return st == null || st === '' || c === 0 || isNaN(c);
if (isNaN(c)) return group === 'unknown';
if (group === 'fishing') return c === 30;
if (group === 'tug') return c === 31 || c === 32;
if (group === 'passenger') return c >= 60 && c <= 69;
if (group === 'cargo') return c >= 70 && c <= 79;
if (group === 'tanker') return c >= 80 && c <= 89;
if (group === 'other') {
if (c === 0) return false;
if (c >= 60 && c <= 89) return false;
if (c === 30 || c === 31 || c === 32) return false;
return true;
}
return true;
}
function sortVesselsInPlace(list, mode) {
const nameKey = v => ((v.shipname && String(v.shipname).trim()) || '\uFFFF').toLowerCase();
switch (mode) {
case 'dist-desc':
list.sort((a, b) => {
const da = a._distNM != null && isFinite(a._distNM) ? a._distNM : -1;
const db = b._distNM != null && isFinite(b._distNM) ? b._distNM : -1;
return db - da;
});
break;
case 'dist-asc':
list.sort((a, b) => {
const da = a._distNM != null && isFinite(a._distNM) ? a._distNM : 1e12;
const db = b._distNM != null && isFinite(b._distNM) ? b._distNM : 1e12;
return da - db;
});
break;
case 'time-asc':
list.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
break;
case 'name-asc':
list.sort((a, b) => nameKey(a).localeCompare(nameKey(b), 'ru', { sensitivity: 'base' }));
break;
case 'mmsi-asc':
list.sort((a, b) => String(a.mmsi).localeCompare(String(b.mmsi), undefined, { numeric: true }));
break;
case 'class-asc':
list.sort((a, b) => String(a.vessel_class || '').localeCompare(String(b.vessel_class || '')) ||
String(a.mmsi).localeCompare(String(b.mmsi), undefined, { numeric: true }));
break;
case 'speed-desc':
list.sort((a, b) => {
const sa = a.speed != null && !isNaN(a.speed) ? a.speed : -1;
const sb = b.speed != null && !isNaN(b.speed) ? b.speed : -1;
return sb - sa;
});
break;
case 'time-desc':
default:
list.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
break;
}
}
function renderVesselSidebar() {
const vl = document.getElementById('vessel-list');
const cntEl = document.getElementById('vessel-count');
const suffEl = document.getElementById('vessel-count-suffix');
if (!vl || !cntEl) return;
const total = lastSidebarVessels.length;
const maxNM = getRangeNM();
const clsSel = document.getElementById('vessel-filter-class');
const stSel = document.getElementById('vessel-filter-shiptype');
const searchEl = document.getElementById('vessel-search');
const sortEl = document.getElementById('vessel-sort');
const cls = clsSel ? clsSel.value : 'all';
const stG = stSel ? stSel.value : 'all';
const q = (searchEl && searchEl.value ? searchEl.value : '').trim().toLowerCase();
const sortMode = sortEl ? sortEl.value : 'time-desc';
const list = lastSidebarVessels.filter(v => {
// Range filter: if we have a computed distance, enforce it; if we don't (no ownship fix),
// keep the item visible to avoid "empty list" confusion.
if (isFinite(maxNM) && v && v._distNM != null && isFinite(v._distNM) && v._distNM > maxNM) return false;
if (cls !== 'all' && String(v.vessel_class || '') !== cls) return false;
if (!matchesShiptypeFilter(v, stG)) return false;
if (q) {
const hay = vesselSearchHaystack(v);
if (!hay.includes(q)) return false;
}
return true;
});
sortVesselsInPlace(list, sortMode);
cntEl.textContent = String(list.length);
if (suffEl) suffEl.textContent = list.length !== total ? ' из ' + total : '';
vl.innerHTML = '';
for (const v of list) {
const { mmsi, lat, lon, vessel_class, shipname, callsign, speed, course, heading, timestamp } = v;
const mmsiKey = String(mmsi);
const it = document.createElement('div');
it.className = 'vessel-item';
it.dataset.mmsi = mmsiKey;
const hasCoord = lat != null && lon != null && !isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180;
if (!hasCoord) it.classList.add('vessel-item--no-pos');
const iso2 = mmsiToIso2FromMid(mmsi);
const flag = iso2ToFlagEmoji(iso2);
const flagTitle = iso2 || '';
let h = '<div class="vessel-mmsi-row"><span class="mmsi">' + escHtml(mmsi) + '</span>';
if (flag) h += '<span class="vessel-flag" title="' + escHtml(flagTitle) + '">' + flag + '</span>';
h += '</div>';
if (shipname) h += '<div class="name">' + escHtml(shipname) + '</div>';
if (callsign && String(callsign).trim()) h += '<div class="callsign-row">Позывной: ' + escHtml(String(callsign).trim()) + '</div>';
const clsLabel = (vessel_class === 'BS') ? 'База' : (vessel_class === 'N') ? 'Буёк' : (vessel_class || '?');
h += '<div>Класс: ' + escHtml(clsLabel) + '</div>';
if (String(vessel_class) === 'N' && v.aton_type != null && v.aton_type !== '') {
h += '<div class="coords">Тип: ' + escHtml(v.aton_type_label || atonTypeLabel(v.aton_type) || 'Тип СНО не указан') + '</div>';
}
// Ship dimensions (AIS static): show only when we have both length and beam.
const toBow = (typeof v.to_bow === 'number' && isFinite(v.to_bow)) ? v.to_bow : parseInt(v.to_bow, 10);
const toStern = (typeof v.to_stern === 'number' && isFinite(v.to_stern)) ? v.to_stern : parseInt(v.to_stern, 10);
const toPort = (typeof v.to_port === 'number' && isFinite(v.to_port)) ? v.to_port : parseInt(v.to_port, 10);
const toStar = (typeof v.to_starboard === 'number' && isFinite(v.to_starboard)) ? v.to_starboard : parseInt(v.to_starboard, 10);
const lenM = (isFinite(toBow) ? Math.max(0, toBow) : 0) + (isFinite(toStern) ? Math.max(0, toStern) : 0);
const beamM = (isFinite(toPort) ? Math.max(0, toPort) : 0) + (isFinite(toStar) ? Math.max(0, toStar) : 0);
if (lenM > 0 && beamM > 0) h += '<div class="coords">Размер: ' + lenM + '×' + beamM + ' м</div>';
const _osP = getOwnShipPos();
const _brg = (_osP && hasCoord) ? bearingDeg(_osP.lat, _osP.lon, lat, lon) : null;
if (v._distNM != null || _brg != null) {
let distLine = '<div class="dist" title="Дальность · пеленг от своего судна">';
if (v._distNM != null) distLine += '<span class="dist-val">' + fmtDist(v._distNM) + '</span>';
if (_brg != null) distLine += '<span class="brg-val">' + _brg.toFixed(0) + '°</span>';
distLine += '</div>';
h += distLine;
}
if (hasCoord) h += '<div class="coords">' + lat.toFixed(6) + ', ' + lon.toFixed(6) + '</div>';
else h += '<div class="coords coords--no-pos">Нет координат</div>';
if (timestamp != null) h += '<div class="coords">' + fmtTime(timestamp) + ' (' + fmtAgo(timestamp) + ')</div>';
// Unified SOG/COG/HDG stats row (styled as inline row on desktop, compact grid on mobile).
// Replaces the separate "Скорость:"/"Курс:" lines that used to appear below (duplicated the stats row).
h += '<div class="compact-stats">' +
'<span title="SOG — Speed Over Ground, скорость относительно земли">' +
'<span class="k">SOG</span><b>' + (speed != null ? escHtml(fmtSpeed(speed)) : '—') + '</b></span>' +
'<span title="COG — Course Over Ground, истинный курс">' +
'<span class="k">COG</span><b>' + (course != null ? course.toFixed(0) + '°' : '—') + '</b></span>' +
'<span title="HDG — Heading, направление носа судна">' +
'<span class="k">HDG</span><b>' + (heading != null ? heading.toFixed(0) + '°' : '—') + '</b></span>' +
'</div>';
it.innerHTML = h;
if (hasCoord) {
if (mmsiKey === (selectedMmsi != null ? String(selectedMmsi) : null)) it.classList.add('selected');
it.addEventListener('click', () => {
selectedMmsi = mmsiKey;
document.querySelectorAll('.vessel-item').forEach(e => e.classList.remove('selected'));
it.classList.add('selected');
map.setView([lat, lon], Math.max(map.getZoom(), 15), { animate: false });
try { VesselInfoWindow.open(mmsi); } catch (_) {}
});
}
vl.appendChild(it);
}
}
function updateSidebar(vessels) {
lastSidebarVessels = vessels;
renderVesselSidebar();
try { NearbyHud.render(); } catch (_) {}
}
function initVesselListControls() {
const search = document.getElementById('vessel-search');
const sortEl = document.getElementById('vessel-sort');
const cls = document.getElementById('vessel-filter-class');
const st = document.getElementById('vessel-filter-shiptype');
if (!search || !sortEl || !cls || !st) return;
const SK = 'vesselList';
const load = (k, def) => sGet(SK + '_' + k, def);
sortEl.value = load('sort', 'time-desc');
cls.value = load('fclass', 'all');
st.value = load('ftype', 'all');
search.value = load('search', '');
const persist = () => {
sSet(SK + '_sort', sortEl.value);
sSet(SK + '_fclass', cls.value);
sSet(SK + '_ftype', st.value);
sSet(SK + '_search', search.value);
renderVesselSidebar();
};
search.addEventListener('input', () => {
sSet(SK + '_search', search.value);
renderVesselSidebar();
});
sortEl.addEventListener('change', persist);
cls.addEventListener('change', persist);
st.addEventListener('change', persist);
}
initVesselListControls();
fetch('/static/js/mmsi_mid_iso2.json')
.then(r => (r.ok ? r.json() : {}))
.then(o => { Object.assign(mmsiMidToIso2, o); renderVesselSidebar(); })
.catch(() => {});
const cursorCoords=document.getElementById('cursor-coords');
function fmtZoomZ(){
try { return map && typeof map.getZoom === 'function' ? String(map.getZoom()) : '?'; } catch(e){ return '?'; }
}
// Throttle cursor coordinate updates to 1/frame (mousemove can be very high frequency).
let _ccPendingLatLng = null;
let _ccRaf = 0;
let _ccLastText = '';
function _setCursorCoordsText(latlngOrNull){
if (!cursorCoords) return;
const z = fmtZoomZ();
const txt = latlngOrNull
? ('Координаты: ' + latlngOrNull.lat.toFixed(6) + ', ' + latlngOrNull.lng.toFixed(6) + ' | Z: ' + z)
: ('Координаты: - | Z: ' + z);
if (txt !== _ccLastText) {
cursorCoords.textContent = txt;
_ccLastText = txt;
}
}
function _scheduleCursorCoordsFlush(){
if (_ccRaf) return;
_ccRaf = requestAnimationFrame(() => {
_ccRaf = 0;
_setCursorCoordsText(_ccPendingLatLng);
});
}
map.on('mousemove', (e) => {
_ccPendingLatLng = e && e.latlng ? e.latlng : null;
_scheduleCursorCoordsFlush();
});
map.on('mouseout', () => {
_ccPendingLatLng = null;
_scheduleCursorCoordsFlush();
});
map.on('zoomend', () => {
// Keep current latlng text (if any) and refresh Z; do it in rAF too.
_scheduleCursorCoordsFlush();
});
// ===================== OwnShip =====================
const ownShipIconSize=[22,48], ownShipAnchor=[11,48];
const iconOwnShip = L.icon({ iconUrl:'/svg/SVG/ownShip.svg', iconSize:ownShipIconSize, iconAnchor:ownShipAnchor, popupAnchor:[0,-48], className:'ownship-icon' });
let ownShipMarker=null, ownShipSource='nmea', followMode=false;
let nmeaGps=null, phoneGps=null, phoneWatchId=null, phoneGpsError=null;
let phoneCompassHeading=null, phoneCompassOk=false, phoneCompassListenerAdded=false;
let phoneGpsSmoothed=null;
let _lastFollowTs=0;
let _lastFollowLatLng=null;
function setOwnShipSource(src) {
ownShipSource = (src === 'phone') ? 'phone' : 'nmea';
if (ownShipSource === 'phone') startPhoneGps();
else stopPhoneGps();
sSet('ownShipSource', ownShipSource);
try { if (typeof window._reflectGpsSourceUi === 'function') window._reflectGpsSourceUi(); } catch(_) {}
updateOwnShipDisplay();
}
try {
const savedSrc = sGet('ownShipSource', '');
if (savedSrc === 'phone' || savedSrc === 'nmea') ownShipSource = savedSrc;
} catch (e) {}
function normalizeDeg(d){
if(d==null || isNaN(d)) return null;
d = d % 360;
if(d < 0) d += 360;
return d;
}
function lowPassAngleDeg(prev, next, alpha){
if (prev == null) return normalizeDeg(next);
const p = normalizeDeg(prev), n = normalizeDeg(next);
if (p == null || n == null) return n;
// shortest direction around 0..360
let diff = n - p;
if (diff > 180) diff -= 360;
if (diff < -180) diff += 360;
return normalizeDeg(p + diff * alpha);
}
function haversineMeters(a, b){
const R = 6371000;
const φ1 = a.lat * Math.PI/180, φ2 = b.lat * Math.PI/180;
const = (b.lat - a.lat) * Math.PI/180;
const = (b.lng - a.lng) * Math.PI/180;
const s = Math.sin(/2)**2 + Math.cos(φ1)*Math.cos(φ2)*Math.sin(/2)**2;
return 2 * R * Math.asin(Math.sqrt(s));
}
function pollOwnShip() {
// Источник — ownship.update через WebSocket (заполняется в AisHub.ownship).
try {
const d = AisHub.ownship;
if (d && d.lat != null && d.lon != null) nmeaGps = d;
} catch (e) {}
}
function startPhoneGps() {
phoneGpsError=null;
if (!window.isSecureContext) { phoneGpsError='Требуется HTTPS. Откройте https://'; return; }
if (!navigator.geolocation) { phoneGpsError='Geolocation API недоступен'; return; }
if (phoneWatchId!=null) return;
startPhoneCompass();
phoneWatchId=navigator.geolocation.watchPosition(
pos=>{
phoneGpsError=null;
const raw = {
lat: pos.coords.latitude,
lon: pos.coords.longitude,
accuracy_m: pos.coords.accuracy,
speed: pos.coords.speed!=null ? pos.coords.speed*1.94384 : null, // m/s -> kn
course: normalizeDeg(pos.coords.heading),
heading: normalizeDeg(pos.coords.heading),
compass: normalizeDeg(phoneCompassHeading),
satellites: null,
timestamp: Math.floor(pos.timestamp/1000),
source:'phone'
};
// Smooth position slightly to reduce jitter/“рывки”
const acc = raw.accuracy_m != null ? raw.accuracy_m : Infinity;
const alpha = acc <= 10 ? 0.35 : acc <= 25 ? 0.25 : acc <= 60 ? 0.15 : 0.0;
if (!phoneGpsSmoothed || alpha === 0.0) {
phoneGpsSmoothed = {lat: raw.lat, lon: raw.lon};
} else {
phoneGpsSmoothed.lat = phoneGpsSmoothed.lat + (raw.lat - phoneGpsSmoothed.lat) * alpha;
phoneGpsSmoothed.lon = phoneGpsSmoothed.lon + (raw.lon - phoneGpsSmoothed.lon) * alpha;
}
raw.lat = phoneGpsSmoothed.lat;
raw.lon = phoneGpsSmoothed.lon;
// Heading: prefer GPS course when moving, else use compass if available
const sp = raw.speed;
const moving = sp != null && !isNaN(sp) && sp >= 1.5; // ~1.5 kn threshold
const h = moving ? raw.course : (raw.compass != null ? raw.compass : raw.course);
raw.heading = h;
phoneGps = raw;
},
err=>{ phoneGpsError={1:'Доступ к GPS запрещён',2:'Не удалось определить',3:'Таймаут GPS'}[err.code]||err.message; },
{enableHighAccuracy:true,maximumAge:1000,timeout:15000}
);
}
function stopPhoneGps() {
if(phoneWatchId!=null){navigator.geolocation.clearWatch(phoneWatchId);phoneWatchId=null;}
phoneGpsError=null;
phoneGpsSmoothed=null;
stopPhoneCompass();
}
const OWN_SHIP_SPEED_VECTOR_SECONDS = 360;
const OWN_SHIP_SPEED_VECTOR_TICK_SECONDS = 60;
const OWN_SHIP_HEADING_LINE_M = 130;
const OWN_SHIP_BEAM_LINE_M = 70;
let ownShipHeadingLine = null;
let ownShipBeamLine = null;
let ownShipSpeedVectorShadow = null;
let ownShipSpeedVector = null;
let ownShipSpeedTicks = [];
function _removeLayerSafe(layer) {
if (layer) {
try { map.removeLayer(layer); } catch (_) {}
}
}
function clearOwnShipMotionOverlays() {
_removeLayerSafe(ownShipHeadingLine); ownShipHeadingLine = null;
_removeLayerSafe(ownShipBeamLine); ownShipBeamLine = null;
_removeLayerSafe(ownShipSpeedVectorShadow); ownShipSpeedVectorShadow = null;
_removeLayerSafe(ownShipSpeedVector); ownShipSpeedVector = null;
for (const tick of ownShipSpeedTicks) _removeLayerSafe(tick);
ownShipSpeedTicks = [];
}
function _ensureOwnShipPolyline(current, opts) {
if (current) return current;
return L.polyline([], Object.assign({ renderer: _overlayCanvasRenderer, interactive: false }, opts)).addTo(map);
}
function _setOwnShipTickCount(count) {
while (ownShipSpeedTicks.length < count) {
ownShipSpeedTicks.push(L.polyline([], {
renderer: _overlayCanvasRenderer,
color: '#f0f6fc',
weight: 2,
opacity: 0.9,
interactive: false,
}).addTo(map));
}
while (ownShipSpeedTicks.length > count) {
_removeLayerSafe(ownShipSpeedTicks.pop());
}
}
function updateOwnShipMotionOverlays(data, headingDeg) {
if (!data || data.lat == null || data.lon == null) {
clearOwnShipMotionOverlays();
return;
}
const lat = _numOrNull(data.lat);
const lon = _numOrNull(data.lon);
if (lat == null || lon == null) {
clearOwnShipMotionOverlays();
return;
}
const origin = [lat, lon];
const hd = normalizeDeg(headingDeg);
if (hd != null) {
const headEnd = destPointMeters(lat, lon, hd, OWN_SHIP_HEADING_LINE_M);
const beamA = destPointMeters(lat, lon, normalizeDeg(hd - 90), OWN_SHIP_BEAM_LINE_M / 2);
const beamB = destPointMeters(lat, lon, normalizeDeg(hd + 90), OWN_SHIP_BEAM_LINE_M / 2);
ownShipHeadingLine = _ensureOwnShipPolyline(ownShipHeadingLine, {
color: '#f0f6fc',
weight: 2,
opacity: 0.95,
lineCap: 'round',
});
ownShipHeadingLine.setLatLngs([origin, [headEnd.lat, headEnd.lon]]);
ownShipBeamLine = _ensureOwnShipPolyline(ownShipBeamLine, {
color: '#c9d1d9',
weight: 2,
opacity: 0.8,
lineCap: 'round',
});
ownShipBeamLine.setLatLngs([[beamA.lat, beamA.lon], [beamB.lat, beamB.lon]]);
} else {
_removeLayerSafe(ownShipHeadingLine); ownShipHeadingLine = null;
_removeLayerSafe(ownShipBeamLine); ownShipBeamLine = null;
}
const cog = normalizeDeg(data.course);
const sog = _numOrNull(data.speed);
if (cog == null || sog == null || sog < 0.2) {
_removeLayerSafe(ownShipSpeedVectorShadow); ownShipSpeedVectorShadow = null;
_removeLayerSafe(ownShipSpeedVector); ownShipSpeedVector = null;
_setOwnShipTickCount(0);
return;
}
const meters = sog * 1852 / 3600 * OWN_SHIP_SPEED_VECTOR_SECONDS;
const end = destPointMeters(lat, lon, cog, meters);
const pts = [origin, [end.lat, end.lon]];
ownShipSpeedVectorShadow = _ensureOwnShipPolyline(ownShipSpeedVectorShadow, {
color: '#0d1117',
weight: 6,
opacity: 0.75,
dashArray: '12 10',
lineCap: 'butt',
});
ownShipSpeedVectorShadow.setLatLngs(pts);
ownShipSpeedVector = _ensureOwnShipPolyline(ownShipSpeedVector, {
color: '#d2ff1a',
weight: 3,
opacity: 0.95,
dashArray: '12 10',
lineCap: 'butt',
});
ownShipSpeedVector.setLatLngs(pts);
const tickCount = Math.floor(OWN_SHIP_SPEED_VECTOR_SECONDS / OWN_SHIP_SPEED_VECTOR_TICK_SECONDS);
_setOwnShipTickCount(tickCount);
const tickHalfM = 12;
const mps = sog * 1852 / 3600;
for (let i = 0; i < ownShipSpeedTicks.length; i++) {
const distM = mps * OWN_SHIP_SPEED_VECTOR_TICK_SECONDS * (i + 1);
const p = destPointMeters(lat, lon, cog, distM);
const a = destPointMeters(p.lat, p.lon, normalizeDeg(cog - 90), tickHalfM);
const b = destPointMeters(p.lat, p.lon, normalizeDeg(cog + 90), tickHalfM);
ownShipSpeedTicks[i].setLatLngs([[a.lat, a.lon], [b.lat, b.lon]]);
}
}
// Keep a handle to the Generic Sensor API fallback so we can stop it.
let _absOrientSensor = null;
function startPhoneCompass() {
if (phoneCompassListenerAdded) return;
phoneCompassListenerAdded = true;
const addLegacy = () => {
window.addEventListener('deviceorientationabsolute', onDeviceOrientation, true);
window.addEventListener('deviceorientation', onDeviceOrientation, true);
};
// 1) Prefer Android-friendly Generic Sensor API when available.
// AbsoluteOrientationSensor gives true-north-referenced quaternion on Android
// (chrome://flags / modern Android Chrome have this enabled by default).
async function tryAbsoluteOrientationSensor() {
if (!('AbsoluteOrientationSensor' in window)) return false;
try {
// Permissions API (best-effort): some platforms require explicit grants.
if (navigator.permissions && navigator.permissions.query) {
try {
const res = await Promise.all([
navigator.permissions.query({ name: 'accelerometer' }),
navigator.permissions.query({ name: 'magnetometer' }),
navigator.permissions.query({ name: 'gyroscope' }),
]);
if (res.some(r => r && r.state === 'denied')) return false;
} catch (_) { /* some UAs don't know these names — just try */ }
}
const s = new window.AbsoluteOrientationSensor({ frequency: 30, referenceFrame: 'screen' });
s.addEventListener('reading', () => {
try {
const q = s.quaternion;
if (!q || q.length !== 4) return;
// Convert quaternion → heading (rotation about Z, compass = 360 - yaw)
const [x, y, z, w] = q;
// yaw (Z-axis rotation) in radians
const siny_cosp = 2 * (w * z + x * y);
const cosy_cosp = 1 - 2 * (y * y + z * z);
let yaw = Math.atan2(siny_cosp, cosy_cosp);
let deg = yaw * 180 / Math.PI;
// Normalize to compass heading (CW from north)
let heading = (360 - deg) % 360;
if (heading < 0) heading += 360;
phoneCompassHeading = lowPassAngleDeg(phoneCompassHeading, heading, 0.25);
} catch (_) {}
});
s.addEventListener('error', () => { /* fall through to legacy */ addLegacy(); });
s.start();
_absOrientSensor = s;
phoneCompassOk = true;
return true;
} catch (_) {
return false;
}
}
// 2) iOS 13+ requires explicit permission for DeviceOrientationEvent.
const DOE = window.DeviceOrientationEvent;
if (DOE && typeof DOE.requestPermission === 'function') {
phoneCompassOk = false;
phoneCompassHeading = null;
const once = async () => {
document.removeEventListener('click', once, true);
try {
const r = await DOE.requestPermission();
if (r === 'granted') {
const ok = await tryAbsoluteOrientationSensor();
if (!ok) addLegacy();
phoneCompassOk = true;
}
} catch(e) {}
};
document.addEventListener('click', once, true);
return;
}
// 3) Non-iOS path: try Generic Sensor API first, fall back to deviceorientation.
tryAbsoluteOrientationSensor().then(ok => {
if (!ok) {
phoneCompassOk = true;
addLegacy();
}
});
}
function stopPhoneCompass() {
if (_absOrientSensor) {
try { _absOrientSensor.stop(); } catch (_) {}
_absOrientSensor = null;
}
if (!phoneCompassOk) { phoneCompassHeading = null; return; }
window.removeEventListener('deviceorientationabsolute', onDeviceOrientation, true);
window.removeEventListener('deviceorientation', onDeviceOrientation, true);
phoneCompassHeading = null;
phoneCompassOk = false;
phoneCompassListenerAdded = false;
}
function onDeviceOrientation(e) {
// alpha is 0..360 deg; in many browsers it's “compass heading” when absolute
let a = null;
if (typeof e.webkitCompassHeading === 'number') {
a = e.webkitCompassHeading; // iOS
} else if (typeof e.alpha === 'number') {
// On a number of browsers/devices `alpha` grows counter-clockwise, which makes map rotation feel inverted.
// Convert to a compass-like clockwise heading.
a = 360 - e.alpha;
}
if (a == null) return;
phoneCompassHeading = lowPassAngleDeg(phoneCompassHeading, a, 0.22);
}
function setOwnShipRotation(mk,hd) {
const h = (hd != null && !isNaN(hd)) ? normalizeDeg(hd) : null;
// Desired behavior:
// - When map is rotated by compass (heading-up), keep the ownship marker fixed "up" on screen.
// - Otherwise, rotate the marker to show heading.
const desiredDeg = (rotateMapByCompass ? 0 : (h != null ? h : 0));
// If leaflet-rotate is present, use its supported per-marker rotation option.
// Plugin expects radians.
try {
if (map && typeof map.getBearing === 'function' && mk && mk.options) {
mk.options.rotation = (desiredDeg * Math.PI / 180);
if (typeof mk.update === 'function') mk.update();
return;
}
} catch (e) {}
// Fallback (no rotate plugin): manual CSS rotation for the icon.
const el = mk._icon; if(!el) return;
mk._rotationHeading = desiredDeg;
if(!el.dataset.osO){ el.style.transformOrigin=ownShipAnchor[0]+'px '+ownShipAnchor[1]+'px'; el.dataset.osO='1';
if(!mk._origSetPos){mk._origSetPos=mk._setPos; mk._setPos=function(p){this._origSetPos.call(this,p);const ic=this._icon;if(ic&&this._rotationHeading!=null){const m=(ic.style.transform||'').match(/translate3d\([^)]+\)/);if(m)ic.style.transform=m[0]+' rotate('+this._rotationHeading+'deg)';}};}
}
const t=(el.style.transform||'').match(/translate3d\([^)]+\)/);
if(t) el.style.transform=desiredDeg!=null?t[0]+' rotate('+desiredDeg+'deg)':t[0];
else if(desiredDeg!=null) el.style.transform='rotate('+desiredDeg+'deg)';
}
function updateOwnShipDisplay() {
try { _initOwnshipCompassUiOnce(); } catch (e) {}
const data=ownShipSource==='phone'?phoneGps:nmeaGps;
const src=document.getElementById('os-source'),co=document.getElementById('os-coords'),cr=document.getElementById('os-course'),sp=document.getElementById('os-speed'),sa=document.getElementById('os-sats');
if(!data||data.lat==null||data.lon==null){
if(ownShipSource==='phone'&&phoneGpsError){src.textContent=phoneGpsError;src.style.color='#f85149';}
else{src.textContent=ownShipSource==='phone'?'GPS телефона (ожидание...)':'Внутр. GPS (нет данных)';src.style.color='';}
co.textContent='-';cr.textContent='-';sp.textContent='-';sa.textContent='-';
_lastOwnshipHeadingForRotate = null;
_setOwnshipCompassUi();
if (rotateMapByCompass) _applyMapRotation();
if(ownShipMarker){map.removeLayer(ownShipMarker);ownShipMarker=null;}
clearOwnShipMotionOverlays();
return;
}
src.style.color=''; src.textContent=ownShipSource==='phone'?'GPS телефона':'Внутр. GPS (NMEA)';
co.textContent=data.lat.toFixed(6)+', '+data.lon.toFixed(6);
cr.textContent=data.course!=null?data.course.toFixed(1)+'°':'-';
sp.textContent=data.speed!=null?fmtSpeed(data.speed):'-';
sa.textContent=data.satellites!=null?data.satellites:'-';
// Prefer true heading when available; course can be misleading when drifting / low speed.
const hd = (data.heading != null && !isNaN(data.heading)) ? data.heading
: (data.course != null && !isNaN(data.course)) ? data.course
: null;
// Compass + map rotation (heading-up mode)
_lastOwnshipHeadingForRotate = (hd != null && !isNaN(hd)) ? normalizeDeg(hd) : null;
_setOwnshipCompassUi();
if (rotateMapByCompass) _applyMapRotation();
if(!ownShipMarker){ ownShipMarker=L.marker([data.lat,data.lon],{icon:iconOwnShip,zIndexOffset:2000}).addTo(map).bindPopup('Своё судно'); requestAnimationFrame(()=>setOwnShipRotation(ownShipMarker,hd)); }
else{ const c=ownShipMarker.getLatLng(); if(Math.abs(c.lat-data.lat)>1e-6||Math.abs(c.lng-data.lon)>1e-6) ownShipMarker.setLatLng([data.lat,data.lon]); setOwnShipRotation(ownShipMarker,hd); }
updateOwnShipMotionOverlays(data, hd);
if(followMode){
const now = Date.now();
const graceUntil = (typeof window._followUserPanGraceUntil === 'function') ? window._followUserPanGraceUntil() : 0;
if (now >= graceUntil) {
const ll = L.latLng(data.lat, data.lon);
const minDt = 350; // ms
const minMoveM = 2.5;
const canTime = (now - _lastFollowTs) >= minDt;
const canMove = !_lastFollowLatLng || haversineMeters(_lastFollowLatLng, ll) >= minMoveM;
if (canTime && canMove) {
_lastFollowTs = now;
_lastFollowLatLng = ll;
map.panTo(ll, {animate: true, duration: 0.25});
}
}
}
}
// ===== GPS source + Follow (navigator) — integrated into #map-controls =====
(function initMapControlsGpsFollow(){
const gpsBtn = document.getElementById('mc-gps-src');
const followBtn = document.getElementById('mc-follow');
const iconNmea = gpsBtn ? gpsBtn.querySelector('.mc-gps-icon-nmea') : null;
const iconPhone = gpsBtn ? gpsBtn.querySelector('.mc-gps-icon-phone') : null;
function reflectGpsSourceUi() {
if (!gpsBtn) return;
gpsBtn.dataset.src = ownShipSource;
gpsBtn.classList.toggle('active', ownShipSource === 'phone');
if (iconNmea) iconNmea.style.display = (ownShipSource === 'nmea') ? '' : 'none';
if (iconPhone) iconPhone.style.display = (ownShipSource === 'phone') ? '' : 'none';
gpsBtn.title = 'Источник GPS: ' + (ownShipSource === 'phone' ? 'телефон (переключить на внутренний NMEA)' : 'внутренний NMEA (переключить на телефон)');
}
window._reflectGpsSourceUi = reflectGpsSourceUi;
try { setOwnShipSource(ownShipSource); } catch (e) {}
reflectGpsSourceUi();
if (gpsBtn) {
gpsBtn.addEventListener('click', function(){
const next = (ownShipSource === 'phone') ? 'nmea' : 'phone';
setOwnShipSource(next);
reflectGpsSourceUi();
});
}
// ---- Follow / Navigator mode ----
// State: auto-return to ship after user pan; auto-zoom depending on speed.
let _followUserPanUntil = 0; // "thaw" timestamp — do not recentre for this many ms after user drag
let _followAutoZoomLast = null; // last zoom applied by speed logic
let _followProgrammaticMove = false; // set during our own panTo/setView so dragstart/zoomend don't turn off
function speedToZoom(sogKn) {
if (sogKn == null || isNaN(sogKn) || sogKn < 0) return 15;
if (sogKn < 1) return 16;
if (sogKn < 4) return 15;
if (sogKn < 10) return 14;
if (sogKn < 18) return 13;
if (sogKn < 26) return 12;
return 11;
}
function setFollow(on) {
followMode = !!on;
if (followBtn) followBtn.classList.toggle('active', followMode);
if (followMode) {
_followUserPanUntil = 0;
updateOwnShipDisplay();
try { maybeApplyFollowZoom(true); } catch(_) {}
}
}
window._setFollowMode = setFollow;
function maybeApplyFollowZoom(force) {
if (!followMode) return;
const d = ownShipSource === 'phone' ? phoneGps : nmeaGps;
if (!d || d.lat == null || d.lon == null) return;
const desired = speedToZoom(d.speed);
const cur = map.getZoom();
// Only act when difference is meaningful, or when forced.
if (force || _followAutoZoomLast == null || Math.abs(cur - desired) >= 1) {
if (cur !== desired) {
_followProgrammaticMove = true;
try { map.setZoom(desired, { animate: true }); } catch(_) {}
setTimeout(() => { _followProgrammaticMove = false; }, 300);
}
_followAutoZoomLast = desired;
}
}
// Re-evaluate zoom periodically while follow is on
setInterval(() => { try { maybeApplyFollowZoom(false); } catch(_) {} }, 4000);
if (followBtn) {
followBtn.addEventListener('click', function(){ setFollow(!followMode); });
}
// When user drags the map, pause auto-recentre for ~4s (navigator-like behaviour:
// you can glance around and it snaps back). A deliberate long pan still keeps follow
// on, it just cools down a bit.
map.on('dragstart', () => {
if (_followProgrammaticMove) return;
if (followMode) _followUserPanUntil = Date.now() + 4000;
});
map.on('zoomstart', () => {
if (_followProgrammaticMove) return;
// User-initiated zoom pauses auto-zoom for ~20s so manual override sticks.
if (followMode) _followAutoZoomLast = map.getZoom();
});
// Hook pan behaviour into updateOwnShipDisplay by patching the check via a flag.
window._followUserPanGraceUntil = () => _followUserPanUntil;
})();
// ===================== Sidebar / OwnShip panel sizing =====================
function adjustSidebarHeight() {
const sidebar = document.getElementById('sidebar');
const ownship = document.getElementById('ownship-panel');
const container = document.getElementById('page-map');
if (!sidebar || !ownship || !container || window.innerWidth <= 600) {
sidebar.style.maxHeight = '';
return;
}
const containerH = container.clientHeight;
const ownshipH = ownship.offsetHeight;
sidebar.style.maxHeight = Math.max(120, containerH - ownshipH - 24) + 'px';
}
window.addEventListener('resize', adjustSidebarHeight);
// ===================== Mobile panel tabs =====================
function updateMobCursorCoords() {
// Same breakpoint as CSS: mobile width OR small height on touch devices
const isMobileLayout = (window.innerWidth <= 600) || (window.matchMedia && window.matchMedia('(max-height:520px) and (pointer:coarse)').matches);
if (!isMobileLayout) { document.getElementById('cursor-coords').style.bottom = ''; return; }
const cc = document.getElementById('cursor-coords');
const sb = document.getElementById('sidebar');
const os = document.getElementById('ownship-panel');
const isLandscapeCompact = window.matchMedia && window.matchMedia('(max-height:520px) and (pointer:coarse)').matches;
if (isLandscapeCompact) {
// In landscape mode panels are on the right, keep coords near bottom-left.
cc.style.bottom = '8px';
return;
}
const bar = document.getElementById('mob-panel-bar');
const barH = bar ? Math.round(bar.getBoundingClientRect().height || 0) : 0;
let panelH = 0;
if (sb.classList.contains('mob-open')) panelH = sb.offsetHeight;
else if (os.classList.contains('mob-open')) panelH = os.offsetHeight;
cc.style.bottom = (barH + panelH + 8) + 'px';
}
(function() {
const tabs = document.querySelectorAll('.mob-panel-tab');
const sidebarEl = document.getElementById('sidebar');
const ownshipEl = document.getElementById('ownship-panel');
const barEl = document.getElementById('mob-panel-bar');
function closeMobPanels() {
tabs.forEach(t => t.classList.remove('active'));
sidebarEl.classList.remove('mob-open');
ownshipEl.classList.remove('mob-open');
setTimeout(() => { try { map.invalidateSize(); } catch (_) {} updateMobCursorCoords(); }, 50);
}
// Allow drag-to-close on the mobile bar (prevents instinctive "pull to refresh").
try {
if (barEl) barEl.style.touchAction = 'none';
} catch (e) {}
(function installBarDragToClose() {
if (!barEl) return;
let dragging = false;
let startX = 0, startY = 0;
let lastX = 0, lastY = 0;
let pointerId = null;
const isLandscapeCompact = () => (window.matchMedia && window.matchMedia('(max-height:520px) and (pointer:coarse)').matches);
const thresholdPx = 26;
function anyPanelOpen() {
return (sidebarEl && sidebarEl.classList.contains('mob-open')) || (ownshipEl && ownshipEl.classList.contains('mob-open'));
}
barEl.addEventListener('pointerdown', (e) => {
try { e.preventDefault(); } catch (_) {}
if (!anyPanelOpen()) return; // nothing to close
dragging = true;
pointerId = e.pointerId;
startX = lastX = e.clientX;
startY = lastY = e.clientY;
try { barEl.setPointerCapture(pointerId); } catch (_) {}
}, { passive: false });
barEl.addEventListener('pointermove', (e) => {
if (!dragging || (pointerId != null && e.pointerId !== pointerId)) return;
try { e.preventDefault(); } catch (_) {}
lastX = e.clientX;
lastY = e.clientY;
const dx = lastX - startX;
const dy = lastY - startY;
if (isLandscapeCompact()) {
// Bar is on the right; drag further right to close.
if (dx > thresholdPx) {
dragging = false;
closeMobPanels();
}
} else {
// Bar is at the bottom; drag down to close.
if (dy > thresholdPx) {
dragging = false;
closeMobPanels();
}
}
}, { passive: false });
function endDrag(e) {
if (!dragging) return;
if (pointerId != null && e && e.pointerId !== pointerId) return;
dragging = false;
pointerId = null;
}
barEl.addEventListener('pointerup', endDrag, { passive: true });
barEl.addEventListener('pointercancel', endDrag, { passive: true });
window.addEventListener('blur', () => { dragging = false; pointerId = null; });
})();
// Swipe-down-to-close on the panels themselves (the "sheet" UX).
// Only the top grip zone initiates a swipe — otherwise the user can scroll the list inside.
(function installSheetSwipeToClose() {
const GRIP_ZONE_PX = 26;
const CLOSE_THRESHOLD_PX = 64;
const isLandscapeCompact = () => (window.matchMedia && window.matchMedia('(max-height:520px) and (pointer:coarse)').matches);
function install(el) {
if (!el) return;
let dragging = false, startX = 0, startY = 0, pointerId = null, curDelta = 0;
function reset(animated) {
el.classList.remove('mob-swiping');
if (animated) {
el.style.transition = 'transform .22s ease';
} else {
el.style.transition = '';
}
el.style.transform = '';
setTimeout(() => { try { el.style.transition = ''; } catch(_) {} }, 260);
}
el.addEventListener('pointerdown', (e) => {
if (!el.classList.contains('mob-open')) return;
const rect = el.getBoundingClientRect();
const localY = e.clientY - rect.top;
const localX = e.clientX - rect.left;
// Restrict starting zone to the top grip (portrait) or right grip (landscape).
if (isLandscapeCompact()) {
if (localX > GRIP_ZONE_PX) return; // right-side bar means the grip is on the LEFT edge of the panel itself
} else {
if (localY > GRIP_ZONE_PX) return;
}
dragging = true;
pointerId = e.pointerId;
startX = e.clientX; startY = e.clientY;
curDelta = 0;
el.classList.add('mob-swiping');
try { el.setPointerCapture(pointerId); } catch(_) {}
}, { passive: true });
el.addEventListener('pointermove', (e) => {
if (!dragging || (pointerId != null && e.pointerId !== pointerId)) return;
const dy = e.clientY - startY;
const dx = e.clientX - startX;
if (isLandscapeCompact()) {
if (dx > 0) {
curDelta = dx;
el.style.transform = 'translateX(' + dx + 'px)';
}
} else {
if (dy > 0) {
curDelta = dy;
el.style.transform = 'translateY(' + dy + 'px)';
}
}
}, { passive: true });
function endSwipe(e) {
if (!dragging) return;
if (pointerId != null && e && e.pointerId !== pointerId) return;
dragging = false;
pointerId = null;
if (curDelta > CLOSE_THRESHOLD_PX) {
// Animate off-screen then close
el.style.transition = 'transform .18s ease-in';
el.style.transform = isLandscapeCompact() ? 'translateX(120%)' : 'translateY(120%)';
setTimeout(() => {
closeMobPanels();
reset(false);
}, 170);
} else {
reset(true);
}
curDelta = 0;
}
el.addEventListener('pointerup', endSwipe, { passive: true });
el.addEventListener('pointercancel', endSwipe, { passive: true });
}
install(sidebarEl);
install(ownshipEl);
})();
tabs.forEach(tab => {
tab.addEventListener('click', () => {
const panel = tab.dataset.panel;
const wasActive = tab.classList.contains('active');
tabs.forEach(t => t.classList.remove('active'));
sidebarEl.classList.remove('mob-open');
ownshipEl.classList.remove('mob-open');
if (!wasActive) {
tab.classList.add('active');
if (panel === 'sidebar') sidebarEl.classList.add('mob-open');
else ownshipEl.classList.add('mob-open');
}
setTimeout(() => { map.invalidateSize(); updateMobCursorCoords(); }, 50);
});
});
})();
window.addEventListener('resize', updateMobCursorCoords);
// ===================== Stats =====================
const AIS_TYPE_NAMES = {1:'Позиция класс A',2:'Позиция класс A',3:'Позиция класс A',5:'Статика класс A',18:'Позиция класс B',19:'Расш. позиция B',24:'Статика класс B'};
function fmtUptime(s){const h=Math.floor(s/3600),m=Math.floor(s%3600/60),sec=s%60;return (h?h+'ч ':'')+(m?m+'м ':'')+sec+'с';}
function _setStatText(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
}
function _counterSumByPrefix(counters, prefixes) {
let out = 0;
if (!counters) return 0;
for (const [k, v] of Object.entries(counters)) {
for (const p of prefixes) {
if (k.startsWith(p)) { out += Number(v) || 0; break; }
}
}
return out;
}
function _renderStatsTableBody(tbodyId, rows) {
const tb = document.getElementById(tbodyId);
if (!tb) return;
tb.innerHTML = '';
for (const r of rows) {
const tr = document.createElement('tr');
const v = r.value;
const isErr = !!r.isErr;
tr.innerHTML =
'<td class="stat-k">' + escHtml(r.label) + '</td>' +
'<td class="stat-v' + (isErr ? ' err' : '') + '">' + escHtml(v == null ? '-' : String(v)) + '</td>';
tb.appendChild(tr);
}
}
function updateStats(){
try{
const s = AisHub.stats || {};
const sys = AisHub.sysinfo || {};
const counters = s.counters || {};
// Vessels: считаем по in-memory витрине (ais_hub отдаёт total в state.warmup, но
// "активных" удобнее взять из клиентского Map).
const nowF = Date.now() / 1000;
let active = 0, total = AisHub.vessels.size, classA = 0, classB = 0;
for (const v of AisHub.vessels.values()) {
if (v && v.lat != null && v.lon != null && (nowF - (v.timestamp || 0)) < 600) active++;
if (v && v.vessel_class === 'A') classA++;
else if (v && v.vessel_class === 'B') classB++;
}
_setStatText('st-active', active);
_setStatText('st-total', total);
_setStatText('st-class-a', classA);
_setStatText('st-class-b', classB);
const aisMessages = _counterSumByPrefix(counters, ['ais_msg_']);
_setStatText('st-ais-msg', aisMessages);
_setStatText('st-gps-msg', counters.state_ownship_updates != null ? counters.state_ownship_updates : '-');
_setStatText('st-gps-fix', AisHub.ownship && AisHub.ownship.lat != null ? 'Есть' : 'Нет');
_setStatText('st-uptime', s.uptime_sec != null ? fmtUptime(Math.floor(s.uptime_sec)) : '-');
_setStatText('st-sys-uptime', sys.sys_uptime != null ? fmtUptime(sys.sys_uptime) : '-');
try{
const sn = sys.server_now != null ? parseInt(sys.server_now, 10) : null;
const el = document.getElementById('st-rx-time');
if(el){
if(sn != null && !isNaN(sn)){
const clientNow = Math.floor(Date.now()/1000);
const d = clientNow - sn;
const sign = d > 0 ? '+' : '';
el.textContent = fmtTime(sn) + ' (' + sign + d + 'с)';
} else el.textContent = '-';
}
}catch(e){}
_setStatText('st-cpu-temp', sys.cpu_temp != null ? sys.cpu_temp + '°C' : '-');
_setStatText('st-cpu-load', sys.cpu_percent != null ? sys.cpu_percent + '%' : '-');
_setStatText('st-mem', sys.mem_total_mb != null ? sys.mem_used_mb + ' / ' + sys.mem_total_mb + ' МБ (' + sys.mem_pct + '%)' : '-');
// AIS/NMEA rate: ais_hub шлёт stats.update раз в 5 секунд → считаем rate между снимками.
_setStatText('st-ais-rate', '-');
_setStatText('st-nmea-rate', '-');
_setStatText('st-test-mmsi', '-');
_setStatText('st-test-mmsi-a', '-');
_setStatText('st-test-mmsi-b', '-');
const tb = document.getElementById('st-ais-types');
if (tb) {
tb.innerHTML = '';
const byType = {};
for (const [k, v] of Object.entries(counters)) {
if (k.startsWith('ais_msg_')) {
const n = k.slice('ais_msg_'.length);
byType[n] = v;
}
}
for (const [t, c] of Object.entries(byType).sort((a, b) => b[1] - a[1])) {
const tr = document.createElement('tr');
tr.innerHTML = '<td>' + t + '</td><td>' + (AIS_TYPE_NAMES[t] || 'Тип ' + t) + '</td><td>' + c + '</td>';
tb.appendChild(tr);
}
}
// Expanded parsing/error statistics (toggle section).
const parseRows = [
{ key: 'parser_errors', label: 'parser_errors', isErr: true },
{ key: 'parser_checksum_errors', label: 'parser_checksum_errors', isErr: true },
{ key: 'ais_fragment_errors', label: 'ais_fragment_errors', isErr: true },
{ key: 'ais_fragment_timeouts', label: 'ais_fragment_timeouts', isErr: true },
].map(x => ({ label: x.label, value: counters[x.key], isErr: x.isErr && (Number(counters[x.key]) || 0) > 0 }));
_renderStatsTableBody('st-parse-errors', parseRows);
const udpRows = [
{ key: 'ais_udp_datagrams', label: 'ais_udp_datagrams' },
{ key: 'ais_udp_malformed', label: 'ais_udp_malformed', isErr: true },
{ key: 'udp_events_oversize', label: 'udp_events_oversize', isErr: true },
].map(x => ({ label: x.label, value: counters[x.key], isErr: x.isErr && (Number(counters[x.key]) || 0) > 0 }));
_renderStatsTableBody('st-udp-errors', udpRows);
const storageRows = [
{ key: 'storage_batches', label: 'storage_batches' },
{ key: 'storage_rows', label: 'storage_rows' },
{ key: 'storage_errors', label: 'storage_errors', isErr: true },
{ key: 'storage_last_batch', label: 'storage_last_batch (gauge)' },
].map(x => {
const v = x.key === 'storage_last_batch' ? (s.gauges || {}).storage_last_batch : counters[x.key];
return { label: x.label, value: v, isErr: x.isErr && (Number(v) || 0) > 0 };
});
_renderStatsTableBody('st-storage-errors', storageRows);
const miscRows = [
{ key: 'ws_clients', label: 'ws_clients (gauge)' },
{ key: 'ws_clients_total', label: 'ws_clients_total' },
{ key: 'supervisor_restarts_ingest_ais_udp', label: 'supervisor_restarts_ingest_ais_udp', isErr: true },
{ key: 'udp_events_sent', label: 'udp_events_sent' },
].map(x => {
const v = x.key === 'ws_clients' ? (s.gauges || {}).ws_clients : counters[x.key];
return { label: x.label, value: v, isErr: x.isErr && (Number(v) || 0) > 0 };
});
_renderStatsTableBody('st-misc-counters', miscRows);
}catch(e){}
}
// Локальные системные метрики (CPU/temp/mem/uptime) ais_hub не отдаёт —
// тянем отдельно с локального /api/sysinfo, только пока открыта вкладка «Статистика».
async function refreshSysinfo(){
try {
const r = await fetch('/api/sysinfo');
const d = await r.json();
if (d && typeof d === 'object') AisHub.sysinfo = d;
} catch (e) {}
}
// ===================== TDMA Slots =====================
const SLOT_MIN_COLS = 30, SLOT_MAX_COLS = 75, SLOT_CELL = 14, SLOT_LABEL_W = 42;
const RSSI_WINDOW_SEC = 60;
let slotsOpen = false;
const slotState = {a: null, b: null};
const slotsTooltip = document.getElementById('slots-tooltip');
document.getElementById('slots-toggle').addEventListener('click', function() {
slotsOpen = !slotsOpen;
document.getElementById('slots-content').classList.toggle('open', slotsOpen);
document.getElementById('slots-arrow').classList.toggle('open', slotsOpen);
if (slotsOpen) updateSlots();
});
function _hexToBase64(hex) {
if (!hex) return '';
const bin = new Uint8Array(Math.floor(hex.length / 2));
for (let i = 0; i < bin.length; i++) bin[i] = parseInt(hex.substr(i * 2, 2), 16);
let s = '';
for (let i = 0; i < bin.length; i++) s += String.fromCharCode(bin[i]);
try { return btoa(s); } catch (e) { return ''; }
}
function _buildSlotChannelData(ch) {
const UPPER = ch.toUpperCase();
const occ = AisHub.slots[UPPER];
const power_history = AisHub.livePower[UPPER] || [];
const rssi_history = AisHub.rssiHistory[UPPER] || [];
const evCutoff = Date.now() / 1000 - 90;
const event_history = (AisHub.signalEvents[UPPER] || []).filter(e => (e.ts || 0) >= evCutoff);
// Если нет данных по слотам, но есть радио/RSSI — всё равно рисуем график.
if (!occ) {
const hasAny = (power_history && power_history.length) || (rssi_history && rssi_history.length) || (event_history && event_history.length);
if (!hasAny) return null;
return {
no_slots: true,
rssi_history,
power_history,
event_history,
};
}
const detail = AisHub.slotDetail[UPPER];
const data = Object.assign({}, occ);
// В ais_hub bitmap приходит как hex-строка, а рендер использует base64 (atob).
if (typeof occ.bitmap === 'string' && occ.bitmap.length > 0) {
// heuristic: hex если только [0-9a-f]
if (/^[0-9a-fA-F]+$/.test(occ.bitmap)) data.bitmap = _hexToBase64(occ.bitmap);
}
data.rssi_history = rssi_history;
data.power_history = power_history;
data.event_history = event_history;
if (detail && detail.utc_minute === occ.utc_minute) {
data.detail_signals = detail.signals || {};
data.detail_timestamp = detail.timestamp;
}
return data;
}
function updateSlots() {
try {
renderSlotChannel('a', _buildSlotChannelData('A'));
renderSlotChannel('b', _buildSlotChannelData('B'));
} catch(e) { console.error('updateSlots:', e); }
}
function renderSlotChannel(ch, data) {
const info = document.getElementById('slots-info-' + ch);
const wrap = document.getElementById('slots-wrap-' + ch);
const canvas = document.getElementById('slots-canvas-' + ch);
const rssiCanvas = document.getElementById('slots-rssi-' + ch);
const bar = document.getElementById('slots-bar-' + ch);
const barFill = document.getElementById('slots-bar-fill-' + ch);
if (!data) {
info.innerHTML = '<span class="slots-no-data">Нет данных</span>';
wrap.style.display = 'none';
rssiCanvas.style.display = 'none';
bar.style.display = 'none';
slotState[ch] = null;
return;
}
if (data.no_slots) {
info.innerHTML = '<span class="slots-no-data">Нет данных по слотам</span>';
bar.style.display = 'none';
wrap.style.display = 'none';
slotState[ch] = null;
renderRssiChart(ch, data.rssi_history, data.power_history, data.event_history);
return;
}
const free = data.total - data.occupied;
const pct = (data.occupied / data.total * 100).toFixed(1);
const age = Math.floor(Date.now()/1000) - data.timestamp;
const ageStr = age < 60 ? age + 'с назад' : Math.floor(age/60) + 'м назад';
const utcDate = new Date(data.utc_minute * 60000);
const utcStr = [utcDate.getUTCHours(), utcDate.getUTCMinutes()].map(v=>String(v).padStart(2,'0')).join(':') + ' UTC';
const nfStr = data.noise_floor_dbm != null ? data.noise_floor_dbm.toFixed(1) + ' dBm' : '-';
const thStr = data.threshold_dbm != null ? data.threshold_dbm.toFixed(1) + ' dBm' : '-';
let anchorExtra = '';
const t0n = data.slot0_unix_ms != null ? Number(data.slot0_unix_ms) : 0;
if (t0n > 0 && t0n <= Number.MAX_SAFE_INTEGER) {
anchorExtra += ' | слот0: ' + new Date(t0n).toISOString().slice(11, 19) + 'Z';
}
const fon = data.first_occupied_unix_ms != null ? Number(data.first_occupied_unix_ms) : 0;
if (fon > 0 && fon <= Number.MAX_SAFE_INTEGER) {
anchorExtra += ' | 1-й декод: ' + new Date(fon).toISOString().slice(11, 19) + 'Z';
}
info.textContent = utcStr + ' | Занято: ' + data.occupied + ' (' + pct + '%) | Свободно: ' + free + ' | NF: ' + nfStr + ' | TH: ' + thStr + anchorExtra + ' | ' + ageStr;
bar.style.display = '';
const occPct = data.occupied / data.total * 100;
barFill.style.width = occPct + '%';
barFill.style.background = occPct > 80 ? '#f85149' : occPct > 50 ? '#d29922' : '#238636';
renderRssiChart(ch, data.rssi_history, data.power_history, data.event_history);
wrap.style.display = '';
const wrapWidth = Math.max(wrap.clientWidth || 0, 320);
const slotCols = Math.max(SLOT_MIN_COLS, Math.min(SLOT_MAX_COLS, Math.floor((wrapWidth - SLOT_LABEL_W) / SLOT_CELL)));
const slotRows = Math.ceil(data.total / slotCols);
canvas.width = SLOT_LABEL_W + slotCols * SLOT_CELL;
canvas.height = slotRows * SLOT_CELL;
const ctx = canvas.getContext('2d');
const raw = atob(data.bitmap);
const bitmap = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) bitmap[i] = raw.charCodeAt(i);
const detailSignals = data.detail_signals || {};
slotState[ch] = {
total: data.total,
bitmap: bitmap,
detailSignals: detailSignals,
cols: slotCols,
rows: slotRows,
cell: SLOT_CELL,
labelW: SLOT_LABEL_W,
slot0UnixMs: data.slot0_unix_ms != null ? Number(data.slot0_unix_ms) : 0,
firstOccupiedUnixMs: data.first_occupied_unix_ms != null ? Number(data.first_occupied_unix_ms) : 0,
};
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.font = '10px monospace';
ctx.fillStyle = '#484f58';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (let r = 0; r < slotRows; r += 5) {
const sec = Math.round(r * slotCols * 60 / data.total);
ctx.fillText(sec + 's', SLOT_LABEL_W - 4, r * SLOT_CELL + SLOT_CELL / 2);
}
for (let slot = 0; slot < data.total; slot++) {
const occupied = (bitmap[slot >> 3] >> (slot & 7)) & 1;
const x = SLOT_LABEL_W + (slot % slotCols) * SLOT_CELL;
const y = Math.floor(slot / slotCols) * SLOT_CELL;
ctx.fillStyle = occupied ? '#f85149' : '#238636';
ctx.fillRect(x, y, SLOT_CELL - 1, SLOT_CELL - 1);
}
}
function renderRssiChart(ch, history, powerHistory, eventHistory) {
const canvas = document.getElementById('slots-rssi-' + ch);
const hasHistory = history && history.length >= 2;
const hasPower = powerHistory && powerHistory.length >= 2;
const hasEvents = eventHistory && eventHistory.length >= 1;
if (!hasHistory && !hasPower && !hasEvents) { canvas.style.display = 'none'; return; }
canvas.style.display = '';
const cssWidth = Math.max(320, Math.floor(canvas.getBoundingClientRect().width || canvas.parentElement?.clientWidth || 482));
const cssHeight = 90;
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.floor(cssWidth * dpr);
canvas.height = Math.floor(cssHeight * dpr);
const c = canvas.getContext('2d');
c.setTransform(1, 0, 0, 1, 0, 0);
c.scale(dpr, dpr);
const W = cssWidth, H = cssHeight;
const pad = {t: 10, b: 16, l: 44, r: 10};
const pW = W - pad.l - pad.r, pH = H - pad.t - pad.b;
const latestTs = Math.max(
hasHistory ? history[history.length - 1].ts : -Infinity,
hasPower ? powerHistory[powerHistory.length - 1].ts : -Infinity,
hasEvents ? eventHistory[eventHistory.length - 1].ts : -Infinity,
Date.now() / 1000
);
const xMax = latestTs;
const xMin = xMax - RSSI_WINDOW_SEC;
const visibleHistory = hasHistory ? history.filter(p => p.ts >= xMin && p.ts <= xMax) : [];
const visiblePower = hasPower ? powerHistory.filter(p => p.ts >= xMin && p.ts <= xMax) : [];
const visibleEvents = hasEvents ? eventHistory.filter(ev => ev.ts >= xMin && ev.ts <= xMax) : [];
let yMin = Infinity, yMax = -Infinity;
if (visibleHistory.length) {
for (const p of visibleHistory) {
if (p.nf != null) { yMin = Math.min(yMin, p.nf); yMax = Math.max(yMax, p.nf); }
if (p.th != null) { yMin = Math.min(yMin, p.th); yMax = Math.max(yMax, p.th); }
}
}
if (visiblePower.length) {
for (const p of visiblePower) {
if (p.power != null) { yMin = Math.min(yMin, p.power); yMax = Math.max(yMax, p.power); }
}
}
if (!isFinite(yMin)) { canvas.style.display = 'none'; return; }
const yPad = Math.max((yMax - yMin) * 0.15, 2);
yMin -= yPad; yMax += yPad;
const xRange = Math.max(xMax - xMin, 1);
c.clearRect(0, 0, W, H);
c.fillStyle = '#0d1117';
c.fillRect(pad.l, pad.t, pW, pH);
c.strokeStyle = '#161b22';
c.lineWidth = 1;
const gridSteps = 4;
for (let i = 0; i <= gridSteps; i++) {
const gy = pad.t + pH * i / gridSteps;
c.beginPath(); c.moveTo(pad.l, gy); c.lineTo(pad.l + pW, gy); c.stroke();
}
function toX(ts) { return pad.l + (ts - xMin) / xRange * pW; }
function toY(v) { return pad.t + (1 - (v - yMin) / (yMax - yMin)) * pH; }
/* Начало кадра AIS TDMA — граница UTC-минуты */
c.save();
c.beginPath();
c.rect(pad.l, pad.t, pW, pH);
c.clip();
c.strokeStyle = '#3fb950';
c.lineWidth = 1;
c.setLineDash([3, 3]);
let frameTs = Math.floor(xMin / 60) * 60;
if (frameTs < xMin) frameTs += 60;
for (; frameTs <= xMax; frameTs += 60) {
const fx = toX(frameTs);
c.beginPath();
c.moveTo(fx, pad.t);
c.lineTo(fx, pad.t + pH);
c.stroke();
}
c.setLineDash([]);
c.restore();
function drawLine(src, key, color) {
c.save();
c.beginPath();
c.rect(pad.l, pad.t, pW, pH);
c.clip();
c.beginPath(); c.strokeStyle = color; c.lineWidth = 1.5;
let started = false;
for (const p of src) {
if (p[key] == null) continue;
const x = toX(p.ts), y = toY(p[key]);
if (!started) { c.moveTo(x, y); started = true; } else c.lineTo(x, y);
}
c.stroke();
c.restore();
}
if (visiblePower.length) drawLine(visiblePower, 'power', '#c678dd');
if (visibleHistory.length) drawLine(visibleHistory, 'nf', '#4fc3f7');
if (visibleHistory.length) drawLine(visibleHistory, 'th', '#f0883e');
if (visibleEvents.length) {
c.save();
c.beginPath();
c.rect(pad.l, pad.t, pW, pH);
c.clip();
c.strokeStyle = '#ffd166';
c.fillStyle = '#ffd166';
c.lineWidth = 1;
c.font = '8px monospace';
c.textAlign = 'center';
c.textBaseline = 'top';
for (const ev of visibleEvents) {
const x = toX(ev.ts);
c.beginPath();
c.moveTo(x, pad.t);
c.lineTo(x, pad.t + pH);
c.stroke();
c.fillText(String(ev.slot), x, pad.t + 2);
}
c.restore();
}
c.font = '9px monospace';
c.fillStyle = '#484f58';
c.textAlign = 'right';
c.textBaseline = 'top';
c.fillText(yMax.toFixed(0) + ' dBm', pad.l - 3, pad.t);
c.textBaseline = 'bottom';
c.fillText(yMin.toFixed(0) + ' dBm', pad.l - 3, pad.t + pH);
const yMid = (yMin + yMax) / 2;
c.textBaseline = 'middle';
c.fillText(yMid.toFixed(0), pad.l - 3, pad.t + pH / 2);
c.textAlign = 'left'; c.textBaseline = 'top';
const t0 = new Date(xMin * 1000), t1 = new Date(xMax * 1000);
const fmt = d => [d.getHours(), d.getMinutes()].map(v=>String(v).padStart(2,'0')).join(':');
c.fillText(fmt(t0), pad.l, pad.t + pH + 3);
c.textAlign = 'right';
c.fillText(fmt(t1), pad.l + pW, pad.t + pH + 3);
const lastHistory = visibleHistory.length ? visibleHistory[visibleHistory.length - 1] : null;
const lastPower = visiblePower.length ? visiblePower[visiblePower.length - 1] : null;
if (lastPower && lastPower.power != null) {
c.fillStyle = '#c678dd'; c.textAlign = 'left'; c.textBaseline = 'bottom';
c.fillText(lastPower.power.toFixed(1), toX(lastPower.ts) + 3, toY(lastPower.power));
}
if (lastHistory && lastHistory.nf != null) {
c.fillStyle = '#4fc3f7'; c.textAlign = 'left'; c.textBaseline = 'bottom';
c.fillText(lastHistory.nf.toFixed(1), toX(lastHistory.ts) + 3, toY(lastHistory.nf));
}
if (lastHistory && lastHistory.th != null) {
c.fillStyle = '#f0883e'; c.textAlign = 'left'; c.textBaseline = 'top';
c.fillText(lastHistory.th.toFixed(1), toX(lastHistory.ts) + 3, toY(lastHistory.th));
}
}
function buildSlotInfoText(ch, st, slot) {
const occ = (st.bitmap[slot >> 3] >> (slot & 7)) & 1;
const timeSec = (slot * 60 / st.total).toFixed(2);
let text = 'Канал ' + ch.toUpperCase() + ' | Слот ' + slot + ' | +' + timeSec + 'с от начала кадра | ' + (occ ? 'Занят' : 'Свободен');
const t0 = st.slot0UnixMs;
if (t0 != null && t0 > 0) {
const n = Number(t0);
if (Number.isFinite(n) && n <= Number.MAX_SAFE_INTEGER) {
const slotMs = (60000 * slot) / st.total;
const abs = new Date(n + slotMs);
text += ' | UTC ' + abs.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, 'Z');
}
}
if (occ && st.detailSignals && Object.prototype.hasOwnProperty.call(st.detailSignals, slot)) {
text += ' | Signal ' + st.detailSignals[slot].toFixed(1) + ' dB';
}
return text;
}
function slotCanvasHover(e, ch) {
const st = slotState[ch];
if (!st) { slotsTooltip.style.display = 'none'; return; }
const canvas = e.target;
const rect = canvas.getBoundingClientRect();
const sx = canvas.width / rect.width, sy = canvas.height / rect.height;
const mx = (e.clientX - rect.left) * sx, my = (e.clientY - rect.top) * sy;
const col = Math.floor((mx - st.labelW) / st.cell);
const row = Math.floor(my / st.cell);
if (col < 0 || col >= st.cols || row < 0 || row >= st.rows) { slotsTooltip.style.display = 'none'; return; }
const slot = row * st.cols + col;
if (slot >= st.total) { slotsTooltip.style.display = 'none'; return; }
slotsTooltip.textContent = buildSlotInfoText(ch, st, slot);
slotsTooltip.style.display = '';
slotsTooltip.style.left = (e.clientX + 14) + 'px';
slotsTooltip.style.top = (e.clientY - 10) + 'px';
}
document.getElementById('slots-canvas-a').addEventListener('mousemove', function(e){ slotCanvasHover(e, 'a'); });
document.getElementById('slots-canvas-b').addEventListener('mousemove', function(e){ slotCanvasHover(e, 'b'); });
document.getElementById('slots-canvas-a').addEventListener('mouseleave', function(){ slotsTooltip.style.display='none'; });
document.getElementById('slots-canvas-b').addEventListener('mouseleave', function(){ slotsTooltip.style.display='none'; });
// ===================== Test Slot Send =====================
document.getElementById('test-slot-send').addEventListener('click', async function() {
const btn = this;
const status = document.getElementById('test-slot-status');
const channel = document.getElementById('test-slot-channel').value;
const slot = parseInt(document.getElementById('test-slot-number').value, 10);
if (isNaN(slot) || slot < 0 || slot > 2249) {
status.textContent = 'Слот 0\u20132249';
status.className = 'slots-test-status err';
return;
}
btn.disabled = true;
status.textContent = 'Отправка...';
status.className = 'slots-test-status wait';
try {
const r = await fetch('/api/send_test_slot', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({channel, slot})
});
const data = await r.json();
if (data.ok) {
status.textContent = 'OK \u2192 ' + data.dest + ' (' + data.size + 'B)';
status.className = 'slots-test-status ok';
} else {
status.textContent = data.error || 'Ошибка';
status.className = 'slots-test-status err';
}
} catch(e) {
status.textContent = 'Ошибка: ' + e.message;
status.className = 'slots-test-status err';
}
btn.disabled = false;
});
// Click on slot canvas to fill slot number
function slotCanvasClick(e, ch) {
const st = slotState[ch];
if (!st) return;
const canvas = e.target;
const rect = canvas.getBoundingClientRect();
const sx = canvas.width / rect.width, sy = canvas.height / rect.height;
const mx = (e.clientX - rect.left) * sx, my = (e.clientY - rect.top) * sy;
const col = Math.floor((mx - st.labelW) / st.cell);
const row = Math.floor(my / st.cell);
if (col < 0 || col >= st.cols || row < 0 || row >= st.rows) return;
const slot = row * st.cols + col;
if (slot >= st.total) return;
document.getElementById('test-slot-number').value = slot;
document.getElementById('test-slot-channel').value = ch === 'a' ? 'A' : 'B';
document.getElementById('slot-selected-info').textContent = buildSlotInfoText(ch, st, slot);
}
document.getElementById('slots-canvas-a').addEventListener('click', function(e){ slotCanvasClick(e, 'a'); });
document.getElementById('slots-canvas-b').addEventListener('click', function(e){ slotCanvasClick(e, 'b'); });
window.addEventListener('resize', () => { if (slotsOpen) updateSlots(); });
// ===================== Settings =====================
document.getElementById('set-server').textContent=location.host;
document.getElementById('set-https').textContent=location.protocol==='https:'?'Да':'Нет';
document.getElementById('set-secure').textContent=window.isSecureContext?'Да':'Нет';
(function initUnitSettings() {
const distSel = document.getElementById('set-dist-unit');
distSel.value = distUnit;
distSel.addEventListener('change', () => {
distUnit = distSel.value;
sSet('distUnit', distUnit);
document.getElementById('range-value').textContent = fmtRange(getRangeNM());
});
const speedSel = document.getElementById('set-speed-unit');
speedSel.value = speedUnit;
speedSel.addEventListener('change', () => {
speedUnit = speedSel.value;
sSet('speedUnit', speedUnit);
});
})();
(function initDangerRadiusSettings() {
const warnEl = document.getElementById('set-warn-radius');
const nearEl = document.getElementById('set-near-radius');
const warnSl = document.getElementById('set-warn-radius-slider');
const nearSl = document.getElementById('set-near-radius-slider');
const warnUnit = document.getElementById('set-warn-radius-unit');
const nearUnit = document.getElementById('set-near-radius-unit');
if (!warnEl || !nearEl || !warnSl || !nearSl) return;
const BASE_MAX_NM = 50;
function refreshUiForUnit() {
if (warnUnit) warnUnit.textContent = uiUnitName();
if (nearUnit) nearUnit.textContent = uiUnitName();
const maxUi = nmToUi(BASE_MAX_NM);
warnSl.max = String(maxUi);
nearSl.max = String(maxUi);
// keep current NM values, just re-render in UI units
warnEl.value = String(nmToUi(warnRadiusNm || 0));
nearEl.value = String(nmToUi(nearRadiusNm || 0));
warnSl.value = warnEl.value;
nearSl.value = nearEl.value;
// update placeholders
warnEl.placeholder = '0 = выкл';
nearEl.placeholder = '0 = выкл';
}
refreshUiForUnit();
const apply = () => {
const wUi = parseFloat(warnEl.value);
const nUi = parseFloat(nearEl.value);
const wNm = Number.isFinite(wUi) && wUi > 0 ? uiToNm(wUi) : 0;
const nNm = Number.isFinite(nUi) && nUi > 0 ? uiToNm(nUi) : 0;
warnRadiusNm = wNm;
nearRadiusNm = nNm;
sSet('warnRadiusNm', String(warnRadiusNm));
sSet('nearRadiusNm', String(nearRadiusNm));
updateDangerCircles();
updateDangerBanner();
};
const clampStr = (v) => {
const x = parseFloat(v);
if (!Number.isFinite(x) || x < 0) return '0';
return String(Math.min(parseFloat(warnSl.max || '50'), x));
};
const syncWarnFromText = () => { warnEl.value = clampStr(warnEl.value); warnSl.value = warnEl.value; apply(); };
const syncNearFromText = () => { nearEl.value = clampStr(nearEl.value); nearSl.value = nearEl.value; apply(); };
const syncWarnFromSlider = () => { warnEl.value = warnSl.value; apply(); };
const syncNearFromSlider = () => { nearEl.value = nearSl.value; apply(); };
warnEl.addEventListener('input', syncWarnFromText);
nearEl.addEventListener('input', syncNearFromText);
warnSl.addEventListener('input', syncWarnFromSlider);
nearSl.addEventListener('input', syncNearFromSlider);
apply();
// When distance unit changes, keep NM values but redraw UI in new units
try {
const distSel = document.getElementById('set-dist-unit');
if (distSel) distSel.addEventListener('change', () => setTimeout(() => { refreshUiForUnit(); }, 0));
} catch (e) {}
})();
// ===================== Network Settings =====================
let netSelectedMode = null;
const netMsg = document.getElementById('net-msg');
function showNetMsg(text, cls) {
netMsg.className = 'net-msg ' + cls;
netMsg.textContent = text;
if (cls === 'ok') setTimeout(() => { netMsg.className = 'net-msg'; }, 5000);
}
function setNetMode(mode) {
netSelectedMode = mode;
document.getElementById('net-btn-ap').classList.toggle('active', mode === 'ap');
document.getElementById('net-btn-wifi').classList.toggle('active', mode === 'wifi');
document.getElementById('net-ap-fields').style.display = mode === 'ap' ? '' : 'none';
document.getElementById('net-wifi-fields').style.display = mode === 'wifi' ? '' : 'none';
}
document.getElementById('net-btn-ap').addEventListener('click', () => setNetMode('ap'));
document.getElementById('net-btn-wifi').addEventListener('click', () => setNetMode('wifi'));
document.getElementById('net-adv-toggle').addEventListener('click', function() {
const adv = document.getElementById('net-advanced');
const open = adv.classList.toggle('open');
this.innerHTML = open ? 'Дополнительно &#9652;' : 'Дополнительно &#9662;';
});
async function loadNetworkConfig() {
try {
const r = await fetch('/api/network');
const data = await r.json();
const cfg = data.config || {};
const live = data.live || {};
const liveMode = document.getElementById('net-live-mode');
const modeLabel = {ap: 'Точка доступа', wifi: 'WiFi-клиент'};
liveMode.textContent = modeLabel[live.mode] || live.mode || '?';
liveMode.className = 'net-status ' + (live.mode || 'unknown');
document.getElementById('net-live-ip').textContent = live.ip || '-';
document.getElementById('net-live-ssid').textContent = live.ssid || '-';
setNetMode(cfg.mode || 'ap');
document.getElementById('net-ap-ssid').value = cfg.ap_ssid || '';
document.getElementById('net-ap-psk').value = cfg.ap_psk || '';
document.getElementById('net-ap-ip').value = cfg.ap_ip || '';
document.getElementById('net-wifi-ssid').value = cfg.wifi_ssid || '';
document.getElementById('net-wifi-psk').value = cfg.wifi_psk || '';
document.getElementById('net-wifi-ip').value = cfg.wifi_ip || '';
document.getElementById('net-wifi-gw').value = cfg.wifi_gw || '';
document.getElementById('net-wifi-dns').value = cfg.wifi_dns || '';
document.getElementById('net-iface').value = cfg.iface || 'wlan0';
} catch(e) { console.error('loadNetworkConfig:', e); }
}
function collectNetConfig() {
return {
mode: netSelectedMode,
ap_ssid: document.getElementById('net-ap-ssid').value,
ap_psk: document.getElementById('net-ap-psk').value,
ap_ip: document.getElementById('net-ap-ip').value,
wifi_ssid: document.getElementById('net-wifi-ssid').value,
wifi_psk: document.getElementById('net-wifi-psk').value,
wifi_ip: document.getElementById('net-wifi-ip').value,
wifi_gw: document.getElementById('net-wifi-gw').value,
wifi_dns: document.getElementById('net-wifi-dns').value,
iface: document.getElementById('net-iface').value || 'wlan0',
};
}
document.getElementById('net-save-btn').addEventListener('click', async function() {
this.disabled = true;
try {
const cfg = collectNetConfig();
const r = await fetch('/api/network', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(cfg)});
const data = await r.json();
if (data.ok) showNetMsg('Настройки сохранены', 'ok');
else showNetMsg(data.error || 'Ошибка сохранения', 'err');
} catch(e) { showNetMsg('Ошибка: ' + e.message, 'err'); }
this.disabled = false;
});
document.getElementById('net-switch-btn').addEventListener('click', async function() {
const mode = netSelectedMode;
const modeLabel = mode === 'ap' ? 'точку доступа' : 'WiFi-клиент';
if (!confirm('Переключить на ' + modeLabel + '?\nСоединение может быть потеряно на несколько секунд.')) return;
this.disabled = true;
showNetMsg('Переключение на ' + modeLabel + '...', 'info');
try {
const cfg = collectNetConfig();
cfg.mode = mode;
const r = await fetch('/api/network/switch', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(cfg)});
const data = await r.json();
if (data.ok) {
showNetMsg('Переключено на ' + modeLabel + '. Обновите страницу по новому адресу.', 'ok');
setTimeout(loadNetworkConfig, 3000);
} else {
showNetMsg(data.error || 'Ошибка переключения', 'err');
}
} catch(e) { showNetMsg('Связь потеряна — устройство переключается. Подключитесь к новой сети.', 'info'); }
this.disabled = false;
});
document.getElementById('net-scan-btn').addEventListener('click', async function() {
this.disabled = true;
this.textContent = 'Сканирование...';
const list = document.getElementById('net-scan-list');
try {
const r = await fetch('/api/network/scan');
const data = await r.json();
if (data.ok && data.networks && data.networks.length) {
list.style.display = '';
list.innerHTML = '';
for (const n of data.networks) {
const it = document.createElement('div');
it.className = 'net-scan-item';
it.innerHTML = '<span>' + n.ssid + '</span><span class="net-signal">' + (n.signal != null ? n.signal + ' dBm' : '') + '</span>';
it.addEventListener('click', () => {
document.getElementById('net-wifi-ssid').value = n.ssid;
list.querySelectorAll('.net-scan-item').forEach(e => e.classList.remove('selected'));
it.classList.add('selected');
});
list.appendChild(it);
}
} else {
list.style.display = '';
list.innerHTML = '<div style="padding:8px;color:#667;font-size:12px">' + (data.error || 'Сети не найдены') + '</div>';
}
} catch(e) { list.style.display=''; list.innerHTML='<div style="padding:8px;color:#f85149;font-size:12px">Ошибка сканирования</div>'; }
this.disabled = false;
this.textContent = 'Сканировать';
});
loadNetworkConfig();
// ===================== Logs =====================
let logLastSeq=0, logAutoscroll=true, logLines=[];
const logOutput=document.getElementById('log-output');
const logFilter=document.getElementById('log-filter');
const logSearch=document.getElementById('log-search');
function classifyLine(line){
const h=(line.split(',')[0]||'');
if((line.startsWith('!')||line.startsWith('$'))&&h.endsWith('VDM')) return 'ais';
if(/^\$G[PNLA](RMC|GGA)/i.test(line)) return 'gps';
return 'unknown';
}
function renderLogs(){
const filter=logFilter.value;
const search=logSearch.value.toLowerCase();
const frag=document.createDocumentFragment();
let count=0;
const visible=logLines.filter(l=>{
if(filter!=='all'&&l.cls!==filter) return false;
if(search&&!l.line.toLowerCase().includes(search)) return false;
return true;
}).slice(-500);
logOutput.innerHTML='';
for(const l of visible){
const div=document.createElement('div');
div.className='log-line '+l.cls;
div.innerHTML='<span class="ts">'+l.time+'</span> '+escHtml(l.line);
frag.appendChild(div);
count++;
}
logOutput.appendChild(frag);
document.getElementById('log-count').textContent=count+' строк';
if(logAutoscroll) logOutput.scrollTop=logOutput.scrollHeight;
}
let _logSeenKeys = new Set();
async function pollLogs(){
// ais_hub отдаёт последние N строк (ring buffer / SQLite), seq там нет — дедупим по ts+line.
try{
const r = await fetch('/api/v1/nmea/tail?limit=500');
const data = await r.json();
if (!Array.isArray(data) || !data.length) return;
let added = 0;
for (const e of data) {
if (!e || !e.line) continue;
const key = (e.ts || 0).toFixed(3) + '|' + e.line;
if (_logSeenKeys.has(key)) continue;
_logSeenKeys.add(key);
const d = new Date((e.ts || 0) * 1000);
const time = [d.getHours(), d.getMinutes(), d.getSeconds()].map(v => String(v).padStart(2, '0')).join(':');
logLines.push({ seq: ++logLastSeq, time, line: e.line, cls: classifyLine(e.line) });
added++;
}
if (added === 0) return;
if (logLines.length > 2000) logLines = logLines.slice(-2000);
if (_logSeenKeys.size > 4000) {
// Подрезаем кеш дедупликации чтоб не рос.
_logSeenKeys = new Set();
for (const l of logLines) _logSeenKeys.add(((l.line || '') + '|' + (l.time || '')));
}
if (currentTab === 'logs') renderLogs();
}catch(e){}
}
document.getElementById('log-autoscroll').addEventListener('click',function(){logAutoscroll=!logAutoscroll;this.classList.toggle('active',logAutoscroll);});
document.getElementById('log-clear').addEventListener('click',()=>{logLines=[];renderLogs();});
logFilter.addEventListener('change',renderLogs);
logSearch.addEventListener('input',renderLogs);
// ===================== Config Page =====================
let cfgLoaded = false;
const cfgEditor = document.getElementById('cfg-editor');
const cfgMsg = document.getElementById('cfg-msg');
const cfgSvcBadge = document.getElementById('cfg-svc-state');
const cfgTabMini = document.getElementById('cfg-tab-mini');
const cfgTabAisHub = document.getElementById('cfg-tab-aishub');
const cfgFileHint = document.getElementById('cfg-file-hint');
const cfgBottomHint = document.getElementById('cfg-bottom-hint');
let cfgTarget = 'mini'; // 'mini' | 'aishub'
const CFG_TARGETS = {
mini: { name: 'AIS-catcher Mini', file: '/ais-mini.conf', service: 'aisMini.service', url: '/api/config', svcStatus: '/api/service/status', svcRestart: '/api/service/restart' },
aishub: { name: 'AisHub', file: '/opt/aishub/config/config.yaml', service: 'ais_hub.service', url: '/api/config/aishub', svcStatus: '/api/service/aishub/status', svcRestart: '/api/service/aishub/restart' },
};
function showCfgMsg(text, cls) {
cfgMsg.className = 'config-msg ' + cls;
cfgMsg.textContent = text;
if (cls === 'ok') setTimeout(() => { cfgMsg.className = 'config-msg'; cfgMsg.textContent = ''; }, 5000);
}
function _cfgMeta() { return CFG_TARGETS[cfgTarget] || CFG_TARGETS.mini; }
function _renderCfgHeader() {
const m = _cfgMeta();
try {
if (cfgTabMini) cfgTabMini.classList.toggle('active', cfgTarget === 'mini');
if (cfgTabAisHub) cfgTabAisHub.classList.toggle('active', cfgTarget === 'aishub');
if (cfgFileHint) cfgFileHint.textContent = 'Файл: ' + m.file;
if (cfgBottomHint) cfgBottomHint.textContent = 'Файл: ' + m.file + ' | Сервис: ' + m.service;
} catch (e) {}
}
async function loadConfig() {
try {
const r = await fetch(_cfgMeta().url);
const data = await r.json();
if (data.ok) {
cfgEditor.value = data.text;
cfgLoaded = true;
showCfgMsg('', '');
} else {
cfgEditor.value = '';
showCfgMsg(data.error || 'Ошибка загрузки', 'err');
}
} catch(e) {
cfgEditor.value = '';
showCfgMsg('Ошибка: ' + e.message, 'err');
}
}
async function loadServiceStatus() {
const badge = cfgSvcBadge || document.getElementById('cfg-svc-state');
try {
const r = await fetch(_cfgMeta().svcStatus);
const data = await r.json();
const state = data.state || 'unknown';
badge.textContent = state;
badge.className = 'config-svc-badge ' + (state === 'active' ? 'active' : state === 'inactive' ? 'inactive' : 'unknown');
} catch(e) {
badge.textContent = '?';
badge.className = 'config-svc-badge unknown';
}
}
document.getElementById('cfg-save-btn').addEventListener('click', async function() {
this.disabled = true;
showCfgMsg('Сохранение...', 'info');
try {
const r = await fetch(_cfgMeta().url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text: cfgEditor.value})
});
const data = await r.json();
if (data.ok) showCfgMsg('Конфиг сохранён', 'ok');
else showCfgMsg(data.error || 'Ошибка сохранения', 'err');
} catch(e) {
showCfgMsg('Ошибка: ' + e.message, 'err');
}
this.disabled = false;
});
document.getElementById('cfg-restart-btn').addEventListener('click', async function() {
const m = _cfgMeta();
if (!confirm('Перезапустить ' + m.service + '?\nПриём AIS может быть прерван на несколько секунд.')) return;
this.disabled = true;
showCfgMsg('Перезапуск сервиса...', 'info');
try {
const r = await fetch(m.svcRestart, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
});
const data = await r.json();
if (data.ok) {
showCfgMsg('Сервис перезапущен', 'ok');
setTimeout(loadServiceStatus, 2000);
} else {
showCfgMsg(data.error || 'Ошибка перезапуска', 'err');
}
} catch(e) {
showCfgMsg('Ошибка: ' + e.message, 'err');
}
this.disabled = false;
});
document.getElementById('cfg-reload-btn').addEventListener('click', () => {
loadConfig();
loadServiceStatus();
});
function _setCfgTarget(t) {
cfgTarget = (t === 'aishub') ? 'aishub' : 'mini';
_renderCfgHeader();
loadConfig();
loadServiceStatus();
}
try {
if (cfgTabMini) cfgTabMini.addEventListener('click', () => _setCfgTarget('mini'));
if (cfgTabAisHub) cfgTabAisHub.addEventListener('click', () => _setCfgTarget('aishub'));
_renderCfgHeader();
} catch (e) {}
// ===================== Main loop =====================
// ===================== Connection status (server reachability) =====================
let _connState = { offline: false, lastOkTs: 0, lastErrTs: 0, lastMsg: '' };
function connBannerEl(){ return document.getElementById('conn-banner'); }
function setConnBanner(offline, msg){
const el = connBannerEl();
if (!el) return;
const text = msg || (offline ? 'Нет связи с сервером' : 'Связь восстановлена');
el.textContent = text;
el.classList.toggle('conn-banner--offline', !!offline);
el.classList.toggle('conn-banner--online', !offline);
el.style.display = text ? '' : 'none';
if (!offline) {
// hide "online" after a short while
setTimeout(() => {
if (!_connState.offline && el.textContent === text) el.style.display = 'none';
}, 1500);
}
}
function markConnOk(){
const now = Date.now();
_connState.lastOkTs = now;
if (_connState.offline) {
_connState.offline = false;
setConnBanner(false, 'Связь восстановлена');
}
}
function markConnErr(err){
const now = Date.now();
_connState.lastErrTs = now;
_connState.lastMsg = (err && err.message) ? err.message : '';
if (!_connState.offline) {
_connState.offline = true;
setConnBanner(true, 'Нет связи с сервером');
}
}
window.addEventListener('offline', () => { _connState.offline = true; setConnBanner(true, 'Нет сети (offline)'); });
window.addEventListener('online', () => { /* actual reachability checked by fetches */ setConnBanner(false, 'Сеть появилась…'); });
// ===================== UI tick =====================
// Данные (vessels/ownship/stats/…) приходят по WebSocket и сразу запускают redraw.
// Этот таймер делает только лёгкие UI-штуки: «возраст» в сайдбаре, danger-banner,
// /api/sysinfo и /api/v1/nmea/tail — если открыты соответствующие вкладки.
function uiTick() {
try {
pollOwnShip(); // скопировать AisHub.ownship → nmeaGps
updateOwnShipDisplay(); // время «ago», followMode и пр.
updateBaseStations(); // TTL/removal for static AIS targets between WS events
updateBuoys(); // TTL/removal for AtoN targets between WS events
// Sidebar живёт от in-memory витрин.
if (!window._mapInteracting) {
const os = getOwnShipPos();
const combined = []
.concat(lastAnyVessels || [])
.concat(lastBaseStations || [])
.concat(lastBuoys || []);
if (os) {
for (const v of combined) {
if (v && v.lat != null && v.lon != null) v._distNM = haversineNM(os.lat, os.lon, v.lat, v.lon);
else v._distNM = null;
}
} else {
for (const v of combined) { if (v) v._distNM = null; }
}
updateSidebar(combined);
adjustSidebarHeight();
updateDangerCircles();
updateDangerBanner();
if (currentTab === 'targets') {
try { renderTargetsTab(); } catch (_) {}
}
}
if (currentTab === 'stats') {
refreshSysinfo().then(() => updateStats()).catch(() => {});
}
if (currentTab === 'logs') {
pollLogs();
}
} catch (e) {
try { console.error('[AISMap] uiTick failed', e); } catch (_) {}
}
}
setInterval(uiTick, 1000);
uiTick();
adjustSidebarHeight();
// Индикатор связи: считается здоровым, если WS-соединение живо и недавно были события.
setInterval(() => {
try {
const wsOk = AisHub.wsOpen;
const fresh = (Date.now() - (AisHub.lastEventTs || 0)) < 20000;
if (wsOk) {
// Периодически подтверждаем, даже если событий сейчас нет.
if (AisHub.snapshotLoaded) markConnOk();
} else if (!fresh) {
markConnErr(new Error('ais_hub WS disconnected'));
}
} catch (e) {}
}, 3000);
// Стартуем WebSocket после полной загрузки страницы, чтобы не рвать рукопожатие
// во время загрузки (Firefox: «соединение … было прервано» у new WebSocket).
function _startAisHubWebSocket() {
try { AisHubWS.open(); } catch (e) { try { console.error('[AISMap] WS open failed', e); } catch (_) {} }
}
if (document.readyState === 'complete') {
_startAisHubWebSocket();
} else {
window.addEventListener('load', _startAisHubWebSocket, { once: true });
}
// Map interaction gating for performance (z14+ pan/zoom).
try {
window._mapInteracting = false;
let _resumeTimer = null;
const setInteracting = (v) => {
window._mapInteracting = !!v;
if (!v) {
if (_resumeTimer) clearTimeout(_resumeTimer);
// Let Leaflet finish settling tiles, then do one catch-up pass.
_resumeTimer = setTimeout(() => { uiTick(); AisHubRedraw.flushNow(); }, 120);
}
};
map.on('movestart', () => setInteracting(true));
map.on('moveend', () => setInteracting(false));
map.on('zoomstart', () => setInteracting(true));
map.on('zoomend', () => setInteracting(false));
} catch (e) {}