5df38bad2d
Closes TG-4
6078 lines
268 KiB
JavaScript
6078 lines
268 KiB
JavaScript
// ===================== 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, 'Слот 0–2249', 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, 'Слот 0–2249', 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, 'Слот 0–2249', 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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>');
|
||
}
|
||
|
||
// Векторная подложка в тон веб-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: '© 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: '© OpenStreetMap contributors © 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: '© OpenStreetMap contributors © 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 © 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 it’s 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 didn’t 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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
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 dφ = (b.lat - a.lat) * Math.PI/180;
|
||
const dλ = (b.lng - a.lng) * Math.PI/180;
|
||
const s = Math.sin(dφ/2)**2 + Math.cos(φ1)*Math.cos(φ2)*Math.sin(dλ/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 ? 'Дополнительно ▴' : 'Дополнительно ▾';
|
||
});
|
||
|
||
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) {}
|