// ===================== 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 (не HTML). */ function _vtSvgEscapeText(s) { return String(s) .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 = '' + '' + esc + ''; 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: '
' + '
', }); 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: '
' + '' + escHtml(meta.label) + '
', }); 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 = ''; 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 = ''; 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 = ''; 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: '', 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 ''; } 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 = ''; for (let i = 0; i < 4; i++) { const active = i < bars; h += ''; } h += ''; return h; } function _targetHeaderIconSvg(v) { if (v && (v.vessel_class === 'BS' || v.kind === 'base_station')) { return ''; } if (v && (v.vessel_class === 'N' || v.kind === 'buoy')) { return ''; } 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 = '
' + '
Пеленг / Дальность
' + '
' + brg.toFixed(0) + '° / ' + escHtml(fmtDist(dist)) + '
'; } 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 = ['MMSI: ' + escHtml(v.mmsi) + '']; if (v.lat != null && v.lon != null) idsParts.push('Координаты: ' + escHtml(coordsTxt) + ''); if (isAton) idsParts.push('СНО: ' + escHtml(modeLabel) + ''); else idsParts.push('Станция: ' + escHtml(modeLabel) + ''); if (isAton && v.off_position) idsParts.push('Положение: вне штатной позиции'); if (dims) idsParts.push('Размер: ' + escHtml(dims) + ''); return '' + '' + '
' + '
' + iconHtml + '
' + '
' + '
' + (flag ? '' + flag + ' ' : '') + escHtml(title) + '
' + '
' + escHtml(typeLabel) + ' · ' + escHtml(clsText) + '
' + '
' + (miniDst ? 'ДАЛЬН ' + miniDst + '' : '') + 'ВОЗР ' + ageTxt + '' + '
' + '
' + '' + '' + '
' + '
' + '
' + '
Объект ' + escHtml(clsText) + '
' + '
' + '
' + escHtml(typeLabel) + '
' + '
' + 'Обновлено: ' + ageTxt + '' + 'Источник: AIS' + '
' + '
' + '
' + '
' + (isAton ? 'Тип СНО' : 'Тип объекта') + '
' + escHtml(typeLabel) + '
' + '
Координаты
' + escHtml(coordsTxt) + '
' + '
' + (isAton ? 'Флаг СНО' : 'Флаг станции') + '
' + escHtml(modeLabel) + '
' + (isAton && v.off_position ? '
Положение
Вне позиции
' : '') + (dims ? '
Размер
' + escHtml(dims) + '
' : '') + bearingCell + '
' + '
' + idsParts.join('') + '
' + '
' + '' + '' + '' + '
' + '
' + ''; } 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 = '
' + '
Пеленг / Дальность
' + '
' + brg.toFixed(0) + '° / ' + (dist != null ? escHtml(fmtDist(dist)) : '–') + '
'; } const navLbl = _navStatusLabel(v.nav_status); const navVal = navLbl || 'нет'; 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) + '°' : 'нет'; const draughtRaw = v.draught != null ? Number(v.draught) : null; const draught = (draughtRaw != null && isFinite(draughtRaw) && draughtRaw > 0) ? (draughtRaw.toFixed(1) + ' м') : ''; const dest = v.destination && String(v.destination).trim() ? escHtml(String(v.destination).trim()) : 'не указано'; 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 = '
' + _bars(bars) + 'Сигнал: ' + Number(v.signal_db).toFixed(1) + ' дБ' + (ageTxt ? '(' + ageTxt + ' назад)' : '') + '
'; } // Identifiers const callsign = v.callsign ? String(v.callsign).trim() : null; const imo = v.imo && Number(v.imo) > 0 ? v.imo : null; const idsParts = ['MMSI: ' + v.mmsi + '']; if (imo) idsParts.push('IMO: ' + imo + ''); if (callsign) idsParts.push('Позывной: ' + escHtml(callsign) + ''); if (v.lat != null && v.lon != null) { idsParts.push('Lat/Lon: ' + Number(v.lat).toFixed(5) + ', ' + Number(v.lon).toFixed(5) + ''); } if (v.rot != null && !isNaN(Number(v.rot))) { idsParts.push('ROT: ' + Number(v.rot).toFixed(1) + ' °/мин'); } 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('Размер: ' + lenM + '×' + beamM + ' м'); } const receivedLine = v.timestamp ? 'Получено: ' + escHtml(fmtTime(v.timestamp)) + ' (' + escHtml(fmtAgo(v.timestamp)) + ' назад)' : 'Получено: '; // 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 '' + '' + '
' + '
' + iconHtml + '
' + '
' + '
' + (flag ? '' + flag + ' ' : '') + escHtml(v.shipname || ('MMSI ' + v.mmsi)) + '
' + '
' + escHtml(typeLabel) + ' · ' + clsText + '
' + '
' + 'SOG ' + miniSog + '' + 'COG ' + miniCog + '' + (miniDst ? 'DST ' + miniDst + '' : '') + '
' + '
' + '' + '' + '
' + '
' + '
' + '
Назначение ' + dest + '
' + '
' + '
' + (eta !== '—' ? eta : '') + '
' + '
' + 'Обновлено: ' + (v.timestamp ? escHtml(fmtAgo(v.timestamp)) + ' назад' : '—') + '' + 'ETA: ' + eta + '' + '
' + '
' + '
' + '
Навигационный статус
' + navVal + '
' + '
Скорость / Курс (COG)
' + speedCourse + '
' + '
Направление (HDG)
' + heading + '
' + '
Осадка
' + draught + '
' + bearingCell + '
' + sigBlock + '
' + idsParts.join('') + '
' + '
' + '' + '' + '' + '
' + '
' + ''; } 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 = 'Клик — начальная точка, ещё клик — конечная. Esc — выход.' + '' + ''; 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 = '
' + escHtml(mmsi) + ''; if (flag) h += '' + flag + ''; h += '
'; if (shipname) h += '
' + escHtml(shipname) + '
'; const clsLabel = (vessel_class === 'BS') ? 'База' : (vessel_class === 'N') ? 'Буёк' : (vessel_class || '?'); h += '
Класс: ' + escHtml(clsLabel) + '
'; if (String(vessel_class) === 'N' && v.aton_type != null && v.aton_type !== '') { h += '
Тип: ' + escHtml(v.aton_type_label || atonTypeLabel(v.aton_type) || 'Тип СНО не указан') + '
'; } if (v._distNM != null || brg != null) { let distLine = '
'; if (v._distNM != null) distLine += '' + fmtDist(v._distNM) + ''; if (brg != null) distLine += '' + brg.toFixed(0) + '°'; distLine += '
'; h += distLine; } h += '
' + '' + 'SOG' + (speed != null ? escHtml(fmtSpeed(speed)) : '—') + '' + '' + 'COG' + (course != null ? course.toFixed(0) + '°' : '—') + '' + '' + 'HDG' + (heading != null ? heading.toFixed(0) + '°' : '—') + '' + (v.signal_db != null ? '' + 'SIG' + Number(v.signal_db).toFixed(1) + ' дБ' : '') + '
'; if (timestamp != null) h += '
' + fmtTime(timestamp) + ' (' + fmtAgo(timestamp) + ')
'; 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 += '' + escHtml(name) + ''; if (cs) idHtml += '' + escHtml(cs) + ''; idHtml += '' + escHtml(mmsi) + ''; let botHtml = 'SOG' + (speed != null ? escHtml(fmtSpeed(speed)) : '—') + ''; botHtml += 'COG' + (course != null ? course.toFixed(0) + '°' : '—') + ''; botHtml += '' + fmtBrg(brg) + ''; return '
' + '' + idHtml + '' + '' + escHtml(distTxt) + '' + '
' + '
' + botHtml + '
' + '
' + escHtml(fmtCoords(lat, lon)) + '
' + (isSelected ? '' : ''); } 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 = '
Нет целей в эфире
'; root.classList.add('is-empty'); return; } root.classList.remove('is-empty'); const html = items.map(({ v, isSel }) => { return '
' + buildItemHtml(v, isSel) + '
'; }).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, '''); } 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 = '
' + escHtml(mmsi) + ''; if (flag) h += '' + flag + ''; h += '
'; if (shipname) h += '
' + escHtml(shipname) + '
'; if (callsign && String(callsign).trim()) h += '
Позывной: ' + escHtml(String(callsign).trim()) + '
'; const clsLabel = (vessel_class === 'BS') ? 'База' : (vessel_class === 'N') ? 'Буёк' : (vessel_class || '?'); h += '
Класс: ' + escHtml(clsLabel) + '
'; if (String(vessel_class) === 'N' && v.aton_type != null && v.aton_type !== '') { h += '
Тип: ' + escHtml(v.aton_type_label || atonTypeLabel(v.aton_type) || 'Тип СНО не указан') + '
'; } // 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 += '
Размер: ' + lenM + '×' + beamM + ' м
'; const _osP = getOwnShipPos(); const _brg = (_osP && hasCoord) ? bearingDeg(_osP.lat, _osP.lon, lat, lon) : null; if (v._distNM != null || _brg != null) { let distLine = '
'; if (v._distNM != null) distLine += '' + fmtDist(v._distNM) + ''; if (_brg != null) distLine += '' + _brg.toFixed(0) + '°'; distLine += '
'; h += distLine; } if (hasCoord) h += '
' + lat.toFixed(6) + ', ' + lon.toFixed(6) + '
'; else h += '
Нет координат
'; if (timestamp != null) h += '
' + fmtTime(timestamp) + ' (' + fmtAgo(timestamp) + ')
'; // 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 += '
' + '' + 'SOG' + (speed != null ? escHtml(fmtSpeed(speed)) : '—') + '' + '' + 'COG' + (course != null ? course.toFixed(0) + '°' : '—') + '' + '' + 'HDG' + (heading != null ? heading.toFixed(0) + '°' : '—') + '' + '
'; 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 = '' + escHtml(r.label) + '' + '' + escHtml(v == null ? '-' : String(v)) + ''; 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 = '' + t + '' + (AIS_TYPE_NAMES[t] || 'Тип ' + t) + '' + c + ''; 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 = 'Нет данных'; wrap.style.display = 'none'; rssiCanvas.style.display = 'none'; bar.style.display = 'none'; slotState[ch] = null; return; } if (data.no_slots) { info.innerHTML = 'Нет данных по слотам'; 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 = '' + n.ssid + '' + (n.signal != null ? n.signal + ' dBm' : '') + ''; 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 = '
' + (data.error || 'Сети не найдены') + '
'; } } catch(e) { list.style.display=''; list.innerHTML='
Ошибка сканирования
'; } 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=''+l.time+' '+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) {}