// ===================== Tab switching =====================
const APP_BUILD = '2026-04-30h';
try { console.log('[AISMap] app.js build', APP_BUILD, 'loaded at', new Date().toISOString()); } catch (e) {}
let currentTab = 'map';

// ===================== ais_hub state (fed by WebSocket /ws) =====================
// Всё AIS/GPS состояние хранится здесь и наполняется live-событиями ais_hub.
// Рендер-функции (updateVessels/…/updateSlots) читают отсюда вместо /api/*.
const AisHub = {
    vessels: new Map(),        // mmsi -> vessel (наш внутренний shape)
    baseStations: new Map(),   // mmsi -> base station
    atons: new Map(),          // mmsi -> buoy/AtoN
    ownship: null,             // последний GPS-fix из ais_hub (наш shape)
    stats: null,               // сырой /api/v1/stats
    sysinfo: null,             // локальный /api/sysinfo
    slots: {A: null, B: null}, // per-channel slot bitmap/occupancy + detail
    slotDetail: {A: null, B: null},
    rssiHistory: {A: [], B: []},      // история noise_floor/threshold per minute
    livePower: {A: [], B: []},        // мгновенная мощность ~10 Гц
    signalEvents: {A: [], B: []},     // события декодов (sparkline сигнала)
    snapshotLoaded: false,
    wsOpen: false,
    lastEventTs: 0,
};
window.AisHub = AisHub;

const AIS_LIVE_POWER_MAX = 600;
const AIS_SIGNAL_EVENTS_MAX = 300;
const AIS_RSSI_HISTORY_MAX = 120;

// ITU-R M.1371-5 / Message 21 Table 74: тип средства навигационного оборудования (СНО / AtoN).
const AIS_ATON_TYPE_LABELS = {
    0: 'Тип СНО не указан',
    1: 'Опорная точка',
    2: 'RACON (радиолокационный ответчик)',
    3: 'Стационарное морское сооружение',
    4: 'Аварийный буй обозначения затонувшего объекта',
    5: 'Огонь без секторов',
    6: 'Огонь с секторами',
    7: 'Передний створный огонь',
    8: 'Задний створный огонь',
    9: 'Знак: кардинальный северный',
    10: 'Знак: кардинальный восточный',
    11: 'Знак: кардинальный южный',
    12: 'Знак: кардинальный западный',
    13: 'Знак левой стороны фарватера',
    14: 'Знак правой стороны фарватера',
    15: 'Знак предпочтительного канала, левая сторона',
    16: 'Знак предпочтительного канала, правая сторона',
    17: 'Знак изолированной опасности',
    18: 'Знак чистой воды',
    19: 'Специальный знак',
    20: 'Плавучий кардинальный знак северный',
    21: 'Плавучий кардинальный знак восточный',
    22: 'Плавучий кардинальный знак южный',
    23: 'Плавучий кардинальный знак западный',
    24: 'Плавучий знак левой стороны',
    25: 'Плавучий знак правой стороны',
    26: 'Плавучий знак предпочтительного канала, левая сторона',
    27: 'Плавучий знак предпочтительного канала, правая сторона',
    28: 'Плавучий знак изолированной опасности',
    29: 'Плавучий знак чистой воды',
    30: 'Специальный плавучий знак',
    31: 'Плавучий маяк / LANBY / буровая установка',
};

function atonTypeLabel(code) {
    if (code == null || code === '') return null;
    const n = parseInt(code, 10);
    if (!isNaN(n) && Object.prototype.hasOwnProperty.call(AIS_ATON_TYPE_LABELS, n)) {
        return AIS_ATON_TYPE_LABELS[n];
    }
    const s = String(code).trim();
    return s || null;
}

// Infer vessel class from observed AIS message types.
function _vesselClassFromMsgTypes(msgTypes) {
    if (!Array.isArray(msgTypes)) return null;
    let sawA = false, sawB = false, sawBS = false, sawATON = false;
    for (const t of msgTypes) {
        const n = parseInt(t, 10);
        if (n === 1 || n === 2 || n === 3 || n === 5) sawA = true;
        else if (n === 18 || n === 19 || n === 24) sawB = true;
        else if (n === 4 || n === 11) sawBS = true;
        else if (n === 21) sawATON = true;
    }
    if (sawA) return 'A';
    if (sawB) return 'B';
    if (sawBS) return 'BS';
    if (sawATON) return 'N';
    return null;
}

// MergedTarget (ais_hub) → наш вессел shape (shipname/course/speed/timestamp/…).
function mergedTargetToVessel(t) {
    if (!t || typeof t !== 'object') return null;
    const dyn = t.dynamic || {};
    const dims = t.dims || {};
    const voy = t.voyage || {};
    const sig = t.signal || {};
    const name = (t.name != null) ? String(t.name).trim() : null;
    const callsign = (t.callsign != null) ? String(t.callsign).trim() : null;
    const ts = t.last_seen != null ? t.last_seen
        : (t.last_dynamic_ts || t.last_static_ts || 0);
    return {
        mmsi: t.mmsi,
        shipname: name || null,
        callsign: callsign || null,
        imo: t.imo != null ? t.imo : null,
        shiptype: t.ship_type != null ? t.ship_type : null,
        vessel_class: _vesselClassFromMsgTypes(t.msg_types),
        lat: dyn.lat != null ? dyn.lat : null,
        lon: dyn.lon != null ? dyn.lon : null,
        course: dyn.cog != null ? dyn.cog : null,
        speed: dyn.sog != null ? dyn.sog : null,
        heading: dyn.heading != null ? dyn.heading : null,
        nav_status: dyn.nav_status != null ? dyn.nav_status : null,
        rot: dyn.rot != null ? dyn.rot : null,
        to_bow: dims.a != null ? dims.a : null,
        to_stern: dims.b != null ? dims.b : null,
        to_port: dims.c != null ? dims.c : null,
        to_starboard: dims.d != null ? dims.d : null,
        eta: voy.eta != null ? voy.eta : null,
        draught: voy.draught != null ? voy.draught : null,
        destination: voy.destination != null ? voy.destination : null,
        signal_db: sig.last_db != null ? sig.last_db : null,
        signal_ts: sig.last_ts != null ? sig.last_ts : null,
        timestamp: ts ? Math.floor(ts) : 0,
        last_seen: ts,
    };
}

// ais_hub ownship (GET /api/v1/ownship / ownship.update) → наш shape.
function ownshipToLocal(o) {
    if (!o || typeof o !== 'object') return null;
    return {
        lat: o.lat != null ? o.lat : null,
        lon: o.lon != null ? o.lon : null,
        course: o.cog != null ? o.cog : null,
        speed: o.sog != null ? o.sog : null,
        heading: null,
        satellites: o.sats != null ? o.sats : null,
        fix_quality: o.fix_quality != null ? o.fix_quality : null,
        timestamp: o.ts != null ? Math.floor(o.ts) : null,
        source: 'nmea',
    };
}

// base_station.update → наш shape для маркера.
function baseStationToLocal(b) {
    if (!b || typeof b !== 'object') return null;
    return {
        mmsi: b.mmsi,
        lat: b.lat != null ? b.lat : null,
        lon: b.lon != null ? b.lon : null,
        virtual: !!(b.virtual || b.virtual_station),
        synthetic: !!b.synthetic,
        timestamp: b.ts != null ? Math.floor(b.ts) : (b.timestamp != null ? Math.floor(b.timestamp) : 0),
    };
}

// aton.update → наш buoy shape.
function atonToLocal(a) {
    if (!a || typeof a !== 'object') return null;
    const dims = a.dims || {};
    const atonType = a.type != null ? a.type : (a.aton_type != null ? a.aton_type : (a.aid_type != null ? a.aid_type : null));
    return {
        mmsi: a.mmsi,
        lat: a.lat != null ? a.lat : null,
        lon: a.lon != null ? a.lon : null,
        name: a.name || null,
        aton_type: atonType,
        aton_type_label: atonTypeLabel(atonType),
        virtual: !!(a.virtual || a.virtual_aid || a.virtual_aton),
        synthetic: !!a.synthetic,
        off_position: !!(a.off_position || a.off_pos),
        to_bow: a.to_bow != null ? a.to_bow : (dims.a != null ? dims.a : (a.a != null ? a.a : null)),
        to_stern: a.to_stern != null ? a.to_stern : (dims.b != null ? dims.b : (a.b != null ? a.b : null)),
        to_port: a.to_port != null ? a.to_port : (dims.c != null ? dims.c : (a.c != null ? a.c : null)),
        to_starboard: a.to_starboard != null ? a.to_starboard : (dims.d != null ? dims.d : (a.d != null ? a.d : null)),
        timestamp: a.ts != null ? Math.floor(a.ts) : (a.timestamp != null ? Math.floor(a.timestamp) : 0),
    };
}

// occupancy object (GET /api/v1/slots → data.occupancy[A|B]) or slots.update event.
function slotsOccupancyToLocal(s) {
    if (!s || typeof s !== 'object') return null;
    return {
        utc_minute: s.utc_minute != null ? s.utc_minute : null,
        total: s.slots_total != null ? s.slots_total : 2250,
        occupied: s.occupied_count != null ? s.occupied_count : 0,
        noise_floor_dbm: null,
        threshold_dbm: null,
        slot0_unix_ms: s.slot0_unix_ms != null ? s.slot0_unix_ms : null,
        first_occupied_unix_ms: s.first_occupied_unix_ms != null ? s.first_occupied_unix_ms : null,
        bitmap: s.bitmap_hex || null,
        timestamp: s.ts != null ? Math.floor(s.ts) : Math.floor(Date.now() / 1000),
    };
}

// slots.detail event or REST detail[A|B] — список занятых слотов с уровнем сигнала.
function slotsDetailToLocal(d) {
    if (!d || typeof d !== 'object') return null;
    const signals = {};
    const entries = Array.isArray(d.entries) ? d.entries : [];
    for (const e of entries) {
        if (!e || e.slot == null) continue;
        signals[e.slot] = (e.level_db != null) ? Math.round(e.level_db * 10) / 10 : null;
    }
    return {
        utc_minute: d.utc_minute != null ? d.utc_minute : null,
        signals,
        timestamp: d.ts != null ? Math.floor(d.ts) : Math.floor(Date.now() / 1000),
    };
}

// Throttled redraw coordinator: несколько target.update'ов подряд → один redraw.
const AisHubRedraw = (function () {
    let pending = { vessels: false, baseStations: false, atons: false, ownship: false, stats: false, slots: false };
    let timer = null;
    function flush() {
        timer = null;
        const p = pending;
        pending = { vessels: false, baseStations: false, atons: false, ownship: false, stats: false, slots: false };
        try {
            if (p.ownship && typeof updateOwnShipDisplay === 'function') updateOwnShipDisplay();
            if (p.vessels && typeof updateVessels === 'function') updateVessels();
            if (p.baseStations && typeof updateBaseStations === 'function') updateBaseStations();
            if (p.atons && typeof updateBuoys === 'function') updateBuoys();
            if (p.stats && currentTab === 'stats' && typeof updateStats === 'function') updateStats();
            if (p.slots && typeof slotsOpen !== 'undefined' && slotsOpen && typeof updateSlots === 'function') updateSlots();
            if (typeof adjustSidebarHeight === 'function') adjustSidebarHeight();
        } catch (e) { try { console.error('[AISMap] redraw failed', e); } catch (_) {} }
    }
    return {
        request(kind) {
            if (kind && pending.hasOwnProperty(kind)) pending[kind] = true;
            if (timer == null) timer = setTimeout(flush, 200);
        },
        flushNow: flush,
    };
})();

// Обработка одного события от ais_hub (target.update / ownship.update / …).
function handleAisHubEvent(ev) {
    if (!ev || typeof ev !== 'object') return;
    const t = ev.type;
    const d = ev.data;
    AisHub.lastEventTs = Date.now();
    if (t === 'state.snapshot' && d) {
        // Полная замена витрин.
        AisHub.vessels.clear();
        if (Array.isArray(d.vessels)) {
            for (const mt of d.vessels) {
                const v = mergedTargetToVessel(mt);
                if (v && v.mmsi != null) AisHub.vessels.set(v.mmsi, v);
            }
        }
        AisHub.baseStations.clear();
        if (Array.isArray(d.base_stations)) {
            for (const b of d.base_stations) {
                const bs = baseStationToLocal(b);
                if (bs && bs.mmsi != null) AisHub.baseStations.set(bs.mmsi, bs);
            }
        }
        AisHub.atons.clear();
        if (Array.isArray(d.atons)) {
            for (const a of d.atons) {
                const an = atonToLocal(a);
                if (an && an.mmsi != null) AisHub.atons.set(an.mmsi, an);
            }
        }
        AisHub.ownship = d.ownship ? ownshipToLocal(d.ownship) : null;
        AisHub.stats = d.stats || null;
        if (d.slots) {
            const occ = d.slots.occupancy || {};
            const det = d.slots.detail || {};
            AisHub.slots.A = occ.A ? slotsOccupancyToLocal(occ.A) : null;
            AisHub.slots.B = occ.B ? slotsOccupancyToLocal(occ.B) : null;
            AisHub.slotDetail.A = det.A ? slotsDetailToLocal(det.A) : null;
            AisHub.slotDetail.B = det.B ? slotsDetailToLocal(det.B) : null;
        }
        AisHub.snapshotLoaded = true;
        AisHubRedraw.request('vessels');
        AisHubRedraw.request('baseStations');
        AisHubRedraw.request('atons');
        AisHubRedraw.request('ownship');
        AisHubRedraw.request('stats');
        AisHubRedraw.request('slots');
        return;
    }
    if (t === 'target.update' && d) {
        const v = mergedTargetToVessel(d);
        if (v && v.mmsi != null) {
            AisHub.vessels.set(v.mmsi, v);
            AisHubRedraw.request('vessels');
        }
        return;
    }
    if (t === 'ownship.update' && d) {
        AisHub.ownship = ownshipToLocal(d);
        AisHubRedraw.request('ownship');
        return;
    }
    if (t === 'base_station.update' && d) {
        const bs = baseStationToLocal(d);
        if (bs && bs.mmsi != null) {
            AisHub.baseStations.set(bs.mmsi, bs);
            AisHubRedraw.request('baseStations');
        }
        return;
    }
    if (t === 'aton.update' && d) {
        const an = atonToLocal(d);
        if (an && an.mmsi != null) {
            AisHub.atons.set(an.mmsi, an);
            AisHubRedraw.request('atons');
        }
        return;
    }
    if (t === 'stats.update' && d) {
        AisHub.stats = d;
        AisHubRedraw.request('stats');
        return;
    }
    if (t === 'slots.update' && d && (d.channel === 'A' || d.channel === 'B')) {
        const ch = d.channel;
        AisHub.slots[ch] = slotsOccupancyToLocal(d);
        // Поддерживаем «per-minute» историю RSSI (для графиков), noise/threshold
        // ais_hub нам не присылает → используем пустые плейсхолдеры, оставляем «ts».
        const hist = AisHub.rssiHistory[ch];
        hist.push({ ts: (d.ts || Math.floor(Date.now() / 1000)), nf: null, th: null });
        if (hist.length > AIS_RSSI_HISTORY_MAX) hist.splice(0, hist.length - AIS_RSSI_HISTORY_MAX);
        AisHubRedraw.request('slots');
        return;
    }
    if (t === 'slots.detail' && d && (d.channel === 'A' || d.channel === 'B')) {
        const ch = d.channel;
        AisHub.slotDetail[ch] = slotsDetailToLocal(d);
        AisHubRedraw.request('slots');
        return;
    }
    if (t === 'signal.update' && d && (d.channel === 'A' || d.channel === 'B')) {
        const ch = d.channel;
        const events = Array.isArray(d.events) ? d.events : [];
        const store = AisHub.signalEvents[ch];
        for (const e of events) {
            if (!e) continue;
            store.push({
                ts: e.unix_ms != null ? e.unix_ms / 1000 : Math.floor(Date.now() / 1000),
                slot: e.slot != null ? e.slot : null,
                mmsi: e.mmsi != null ? e.mmsi : null,
                signal: e.level_db != null ? Math.round(e.level_db * 10) / 10 : null,
            });
        }
        if (store.length > AIS_SIGNAL_EVENTS_MAX) store.splice(0, store.length - AIS_SIGNAL_EVENTS_MAX);
        AisHubRedraw.request('slots');
        return;
    }
    if (t === 'radio.update' && d) {
        if (d.source === 'aiscatcher_rssi' || d.power_a_db != null || d.power_b_db != null) {
            const ts = ev.ts || (Date.now() / 1000);
            if (d.power_a_db != null) {
                AisHub.livePower.A.push({ ts, power: Math.round(d.power_a_db * 10) / 10 });
                if (AisHub.livePower.A.length > AIS_LIVE_POWER_MAX) AisHub.livePower.A.shift();
            }
            if (d.power_b_db != null) {
                AisHub.livePower.B.push({ ts, power: Math.round(d.power_b_db * 10) / 10 });
                if (AisHub.livePower.B.length > AIS_LIVE_POWER_MAX) AisHub.livePower.B.shift();
            }
            AisHubRedraw.request('slots');
        }
        return;
    }
}

// WebSocket-клиент с переподключением (1→2→5→10 с).
const AisHubWS = (function () {
    let ws = null;
    let backoff = 1000;
    let reconnectTimer = null;
    function open() {
        if (reconnectTimer != null) { clearTimeout(reconnectTimer); reconnectTimer = null; }
        if (ws) {
            try {
                ws.onopen = ws.onclose = ws.onerror = ws.onmessage = null;
                if (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN) {
                    ws.close(1000, 'reconnect');
                }
            } catch (_) {}
            ws = null;
        }
        try {
            const proto = (location.protocol === 'https:') ? 'wss:' : 'ws:';
            const url = proto + '//' + location.host + '/ws';
            ws = new WebSocket(url);
        } catch (e) {
            scheduleReconnect();
            return;
        }
        ws.onopen = () => {
            AisHub.wsOpen = true;
            backoff = 1000;
            try { console.log('[AISMap] ws open'); } catch (_) {}
        };
        ws.onmessage = (m) => {
            let ev;
            try { ev = JSON.parse(m.data); } catch (e) { return; }
            handleAisHubEvent(ev);
        };
        ws.onerror = () => {};
        ws.onclose = () => {
            AisHub.wsOpen = false;
            try { console.warn('[AISMap] ws close, reconnect in', backoff, 'ms'); } catch (_) {}
            scheduleReconnect();
        };
    }
    function scheduleReconnect() {
        if (reconnectTimer != null) return;
        reconnectTimer = setTimeout(() => { reconnectTimer = null; open(); }, backoff);
        backoff = Math.min(backoff * 2, 10000);
    }
    return { open };
})();
const tabs = document.querySelectorAll('.nav-tab');
const pages = document.querySelectorAll('.tab-page');
const hamburger = document.getElementById('hamburger');
const navTabs = document.getElementById('nav-tabs');

// ===================== VisualViewport safe insets (WebView/browser UI) =====================
function updateVisualViewportInsets(){
    const vv = window.visualViewport;
    if (!vv) return;
    // Approximate occluded areas in CSS px
    const top = Math.max(0, vv.offsetTop || 0);
    const bottom = Math.max(0, (window.innerHeight - (vv.height + (vv.offsetTop || 0))) || 0);
    const left = Math.max(0, vv.offsetLeft || 0);
    const right = Math.max(0, (window.innerWidth - (vv.width + (vv.offsetLeft || 0))) || 0);
    const root = document.documentElement;
    root.style.setProperty('--vv-top', top.toFixed(0) + 'px');
    root.style.setProperty('--vv-bottom', bottom.toFixed(0) + 'px');
    root.style.setProperty('--vv-left', left.toFixed(0) + 'px');
    root.style.setProperty('--vv-right', right.toFixed(0) + 'px');
}
try{
    if (window.visualViewport) {
        window.visualViewport.addEventListener('resize', updateVisualViewportInsets);
        window.visualViewport.addEventListener('scroll', updateVisualViewportInsets);
        window.addEventListener('resize', updateVisualViewportInsets);
        updateVisualViewportInsets();
    }
}catch(e){}

// ===================== Session settings (cookie) =====================
function _cookieOpts() {
    const secure = (location && location.protocol === 'https:') ? '; Secure' : '';
    return '; Path=/; SameSite=Lax' + secure;
}
function cookieGet(name) {
    try {
        const key = encodeURIComponent(name) + '=';
        const parts = String(document.cookie || '').split(/;\s*/);
        for (const p of parts) {
            if (p.startsWith(key)) return decodeURIComponent(p.slice(key.length));
        }
    } catch (e) {}
    return null;
}
function cookieSet(name, value) {
    try {
        document.cookie = encodeURIComponent(name) + '=' + encodeURIComponent(String(value)) + _cookieOpts();
        return true;
    } catch (e) {}
    return false;
}
const SSK = 'aismap_';
function sGet(key, def) {
    const v = cookieGet(SSK + key);
    return (v == null || v === '') ? def : v;
}
function sSet(key, value) {
    return cookieSet(SSK + key, value);
}

function switchTab(tab) {
    currentTab = tab;
    tabs.forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
    pages.forEach(p => p.classList.toggle('active', p.id === 'page-' + tab));
    navTabs.classList.remove('open');
    if (tab === 'map') {
        setTimeout(() => map.invalidateSize(), 50);
    }
    if (tab === 'config') {
        if (!cfgLoaded) loadConfig();
        loadServiceStatus();
    }
    if (tab === 'console') {
        initConsole().then(() => {
            if (window._fitTerminal) setTimeout(() => window._fitTerminal(), 80);
        });
    }
    if (tab === 'transponder') {
        loadTransponderPage();
    }
    if (tab === 'targets') {
        try { renderTargetsTab(); } catch (_) {}
    }
}
tabs.forEach(t => t.addEventListener('click', () => switchTab(t.dataset.tab)));
hamburger.addEventListener('click', () => navTabs.classList.toggle('open'));

// ===================== Transponder (Class B) =====================
async function tpParseJsonResponse(r) {
    const text = await r.text();
    if (!text || !text.trim()) {
        return { _empty: true, _status: r.status };
    }
    try {
        return JSON.parse(text);
    } catch (e) {
        const snippet = text.replace(/\s+/g, ' ').slice(0, 180);
        throw new Error('Ответ не JSON (HTTP ' + r.status + '): ' + snippet);
    }
}

function tpCollectConfig() {
    return {
        mmsi: parseInt(document.getElementById('tp-mmsi').value, 10) || 0,
        shipname: document.getElementById('tp-shipname').value,
        callsign: document.getElementById('tp-callsign').value,
        ship_type: parseInt(document.getElementById('tp-ship-type').value, 10) || 0,
        to_bow: parseInt(document.getElementById('tp-to-bow').value, 10) || 0,
        to_stern: parseInt(document.getElementById('tp-to-stern').value, 10) || 0,
        to_port: parseInt(document.getElementById('tp-to-port').value, 10) || 0,
        to_starboard: parseInt(document.getElementById('tp-to-starboard').value, 10) || 0,
        vendorid: document.getElementById('tp-vendorid').value,
        model: parseInt(document.getElementById('tp-model').value, 10) || 0,
        serial: parseInt(document.getElementById('tp-serial').value, 10) || 0,
        use_gps_motion: document.getElementById('tp-use-gps').checked,
        nrzi_slot_channel: document.getElementById('tp-slot-channel').value,
        nrzi_slot: parseInt(document.getElementById('tp-slot-number').value, 10) || 0,
        nrzi_encoder: document.getElementById('tp-nrzi-encoder').value,
        nrzi_mode: document.getElementById('tp-nrzi-mode').value,
        include_nrzi_preamble: document.getElementById('tp-preamble').checked,
        nrzi_preamble_bits: Math.max(0, Math.min(128, parseInt(document.getElementById('tp-preamble-bits').value, 10) || 0)),
        nrzi_pad_payload_to_octet: document.getElementById('tp-pad-payload').checked,
        nrzi_pad_nrz_bits: Math.max(0, Math.min(4096, parseInt(document.getElementById('tp-pad-nrz-bits').value, 10) || 0)),
        tx_gpio_pulse_auto: document.getElementById('tp-gpio-auto').checked,
        tx_gpio_pulse_delay_ms: Math.max(50, Math.min(100, parseInt(document.getElementById('tp-gpio-delay-ms').value, 10) || 75)),
        tx_gpio_pulse_script: document.getElementById('tp-gpio-script').value,
    };
}

function tpApplyConfig(c) {
    if (!c) return;
    document.getElementById('tp-mmsi').value = c.mmsi != null ? c.mmsi : '';
    document.getElementById('tp-shipname').value = c.shipname || '';
    document.getElementById('tp-callsign').value = c.callsign || '';
    const stSel = document.getElementById('tp-ship-type');
    if (stSel) {
        const stv = c.ship_type != null ? parseInt(c.ship_type, 10) : 0;
        if (typeof window.ensureShipTypeOption === 'function') {
            window.ensureShipTypeOption(Number.isFinite(stv) ? stv : 0);
        }
        stSel.value = String(Number.isFinite(stv) ? Math.max(0, Math.min(255, stv)) : 0);
    }
    document.getElementById('tp-to-bow').value = c.to_bow != null ? c.to_bow : '';
    document.getElementById('tp-to-stern').value = c.to_stern != null ? c.to_stern : '';
    document.getElementById('tp-to-port').value = c.to_port != null ? c.to_port : '';
    document.getElementById('tp-to-starboard').value = c.to_starboard != null ? c.to_starboard : '';
    document.getElementById('tp-vendorid').value = c.vendorid || '';
    document.getElementById('tp-model').value = c.model != null ? c.model : '';
    document.getElementById('tp-serial').value = c.serial != null ? c.serial : '';
    document.getElementById('tp-use-gps').checked = !!c.use_gps_motion;
    const tch = document.getElementById('tp-slot-channel');
    if (tch) tch.value = (c.nrzi_slot_channel === 'B') ? 'B' : 'A';
    const tsn = document.getElementById('tp-slot-number');
    if (tsn) {
        const sn = c.nrzi_slot != null ? Math.max(0, Math.min(2249, parseInt(c.nrzi_slot, 10) || 0)) : 0;
        tsn.value = sn;
    }
    const enc = document.getElementById('tp-nrzi-encoder');
    if (enc) enc.value = (c.nrzi_encoder === 'ais_phy') ? 'ais_phy' : 'aistx';
    document.getElementById('tp-nrzi-mode').value = (c.nrzi_mode === 'expanded') ? 'expanded' : 'packed';
    document.getElementById('tp-preamble').checked = c.include_nrzi_preamble !== false;
    document.getElementById('tp-pad-payload').checked = c.nrzi_pad_payload_to_octet !== false;
    const tpn = document.getElementById('tp-pad-nrz-bits');
    if (tpn) {
        const pn = c.nrzi_pad_nrz_bits != null ? parseInt(c.nrzi_pad_nrz_bits, 10) : 256;
        tpn.value = isNaN(pn) ? 256 : Math.max(0, Math.min(4096, pn));
    }
    const tpb = document.getElementById('tp-preamble-bits');
    if (tpb) {
        const pb = c.nrzi_preamble_bits != null ? parseInt(c.nrzi_preamble_bits, 10) : 24;
        tpb.value = isNaN(pb) ? 24 : Math.max(0, Math.min(128, pb));
    }
    const gAuto = document.getElementById('tp-gpio-auto');
    if (gAuto) gAuto.checked = !!c.tx_gpio_pulse_auto;
    const gDel = document.getElementById('tp-gpio-delay-ms');
    if (gDel) {
        const gd = c.tx_gpio_pulse_delay_ms != null ? parseInt(c.tx_gpio_pulse_delay_ms, 10) : 75;
        gDel.value = isNaN(gd) ? 75 : Math.max(50, Math.min(100, gd));
    }
    const gScr = document.getElementById('tp-gpio-script');
    if (gScr) gScr.value = c.tx_gpio_pulse_script != null ? c.tx_gpio_pulse_script : '';
    if (typeof window.shipDimsEditorRefresh === 'function') window.shipDimsEditorRefresh();
}

function tpFormatSendResult(res) {
    if (!res) return '';
    const parts = [];
    if (res.dest) parts.push(res.dest);
    if (res.slot_channel != null && res.slot != null) parts.push('ch ' + res.slot_channel + ' slot ' + res.slot);
    if (res.nrzi_bytes) parts.push('UDP: ' + res.nrzi_bytes + ' B');
    if (res.payload_bytes != null) parts.push('NRZI payload: ' + res.payload_bytes + ' B');
    if (res.udp_bytes != null) parts.push('UDP кадр: ' + res.udp_bytes + ' B');
    if (res.gpio_pulse && res.gpio_pulse.length) {
        const gp = res.gpio_pulse.map(function (p) {
            if (p.ok) return 'ok';
            return 'fail: ' + (p.error || p.stderr || p.returncode);
        }).join('; ');
        parts.push('GPIO: ' + gp);
    }
    if (res.errors && res.errors.length) parts.push(res.errors.join('; '));
    return parts.join(' · ');
}

function tpFormatPreview(prev) {
    if (!prev || !prev.nrzi_hex) return '';
    const lines = [];
    if (prev.dest) lines.push('Назначение UDP: ' + prev.dest);
    if (prev.slot_channel != null && prev.slot != null) {
        lines.push('Заголовок кадра: канал ' + prev.slot_channel + ', слот ' + prev.slot + ' (как тест слота)');
    }
    const phy = prev.phy;
    if (phy) {
        lines.push('=== PHY (отладка) ===');
        lines.push('Преамбула: ' + (phy.include_preamble ? phy.preamble_bits + ' бит 1010… (как gr-aistx)' : 'выкл (только HDLC+NRZI)'));
        lines.push('NRZI режим: ' + (phy.nrzi_mode || ''));
        if (phy.fcs) lines.push('FCS: ' + phy.fcs);
        if (phy.hdlc) lines.push('HDLC: ' + phy.hdlc);
        if (phy.nrzi) lines.push('NRZI: ' + phy.nrzi);
        if (phy.packed) lines.push('Packed: ' + phy.packed);
        if (phy.nrzi_encoder) lines.push('Кодер: ' + phy.nrzi_encoder);
        if (phy.aistx_phy_note) lines.push(phy.aistx_phy_note);
        if (phy.pad_payload_to_octet != null) {
            lines.push('Добор payload до октета: ' + (phy.pad_payload_to_octet ? 'да' : 'нет'));
        }
        if (phy.pad_nrz_total_bits != null && phy.pad_nrz_total_bits > 0) {
            lines.push('Добор NRZ перед NRZI до: ' + phy.pad_nrz_total_bits + ' бит');
        } else if (phy.pad_nrz_total_bits === null || phy.pad_nrz_total_bits === 0) {
            lines.push('Добор NRZ перед NRZI: выкл');
        }
        const pm = phy.per_message || {};
        ['18', '19', '24A', '24B'].forEach(k => {
            const s = pm[k];
            if (!s) return;
            const p0 = s.payload_bits_input != null ? s.payload_bits_input : s.payload_bits;
            const p1 = s.payload_bits_after_pad != null ? s.payload_bits_after_pad : p0;
            const ptxt = p0 === p1 ? (p0 + ' бит') : (p0 + '→' + p1 + ' бит');
            const stuff = s.bits_between_flags_stuffed != null ? s.bits_between_flags_stuffed : '—';
            const hdlc = s.hdlc_frame_bits != null ? s.hdlc_frame_bits + ' бит' : (s.hdlc_note || '—');
            lines.push(k + ': payload ' + ptxt + ', stuff ' + stuff + ', HDLC ' + hdlc +
                ', NRZI ' + s.nrzi_packed_bytes + ' B, UDP ' + s.udp_total_bytes + ' B');
        });
        if (phy.test_slot_note) lines.push(phy.test_slot_note);
    }
    lines.push('=== Только NRZI (hex) ===');
    const hx = prev.nrzi_hex || {};
    ['18', '19', '24A', '24B'].forEach(k => {
        const h = hx[k];
        lines.push(k + ': ' + (h ? h.length + ' hex — ' + h.slice(0, 120) + (h.length > 120 ? '…' : '') : '-'));
    });
    const fr = prev.udp_frame_hex || {};
    lines.push('=== Полный UDP payload: канал+слот+NRZI (hex) ===');
    ['18', '19', '24A', '24B'].forEach(k => {
        const h = fr[k];
        lines.push(k + ': ' + (h ? h.length + ' hex — ' + h.slice(0, 140) + (h.length > 140 ? '…' : '') : '-'));
    });
    return lines.join('\n');
}

function tpSetMsg(el, text, ok) {
    if (!el) return;
    el.textContent = text || '';
    el.classList.remove('ok', 'err');
    if (text) el.classList.add(ok ? 'ok' : 'err');
}

async function loadTransponderPage() {
    const msg = document.getElementById('tp-msg');
    try {
        const r = await fetch('/api/transponder');
        const d = await tpParseJsonResponse(r);
        if (d._empty || d.ok === false) {
            tpSetMsg(msg, d.error || 'Пустой ответ API', false);
            return;
        }
        tpApplyConfig(d.config);
        const encSel = document.getElementById('tp-nrzi-encoder');
        if (encSel) {
            const ok = !!d.aistx_phy_available;
            Array.from(encSel.options).forEach(function(o) {
                if (o.value === 'aistx') o.disabled = !ok;
            });
            if (!ok && encSel.value === 'aistx') encSel.value = 'ais_phy';
        }
        const own = d.ownship || {};
        const hint = document.getElementById('tp-ownship-hint');
        if (hint) {
            if (own.lat != null && own.lon != null) {
                hint.textContent = own.lat.toFixed(5) + ', ' + own.lon.toFixed(5) +
                    ' | COG ' + (own.course != null ? own.course : '—') +
                    ' | SOG ' + (own.speed != null ? own.speed : '—');
            } else {
                hint.textContent = 'Нет фикса';
            }
        }
        tpSetMsg(msg, '', true);
    } catch (e) {
        tpSetMsg(msg, 'Загрузка: ' + e.message, false);
    }
}

async function tpDoPreview() {
    const out = document.getElementById('tp-preview-out');
    const msg = document.getElementById('tp-msg');
    const sn = parseInt(document.getElementById('tp-slot-number').value, 10);
    if (isNaN(sn) || sn < 0 || sn > 2249) {
        tpSetMsg(msg, 'Слот 0–2249', false);
        return;
    }
    tpSetMsg(msg, 'Превью…', true);
    try {
        const r = await fetch('/api/transponder/preview', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(tpCollectConfig()),
        });
        const d = await tpParseJsonResponse(r);
        if (!d.ok) {
            tpSetMsg(msg, d.error || 'Ошибка', false);
            return;
        }
        out.textContent = tpFormatPreview(d.preview);
        tpSetMsg(msg, 'Превью обновлено', true);
    } catch (e) {
        tpSetMsg(msg, e.message, false);
    }
}

async function tpDoSave() {
    const msg = document.getElementById('tp-msg');
    try {
        const r = await fetch('/api/transponder', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(tpCollectConfig()),
        });
        const d = await tpParseJsonResponse(r);
        if (!d.ok && d.error) {
            tpSetMsg(msg, d.error, false);
            return;
        }
        if (d.config) tpApplyConfig(d.config);
        tpSetMsg(msg, 'Сохранено', true);
    } catch (e) {
        tpSetMsg(msg, e.message, false);
    }
}

async function tpDoGpioPulseOnce() {
    const msg = document.getElementById('tp-msg');
    tpSetMsg(msg, 'Импульс GPIO…', true);
    try {
        const r = await fetch('/api/transponder/gpio_pulse', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(tpCollectConfig()),
        });
        const d = await tpParseJsonResponse(r);
        if (!d.ok) {
            tpSetMsg(msg, d.error || 'Ошибка GPIO', false);
            return;
        }
        const p = d.pulse || {};
        tpSetMsg(msg, 'GPIO: ok (code ' + (p.returncode != null ? p.returncode : 0) + ')', true);
    } catch (e) {
        tpSetMsg(msg, e.message, false);
    }
}

async function tpDoSend(which) {
    const msg = document.getElementById('tp-msg');
    const sn = parseInt(document.getElementById('tp-slot-number').value, 10);
    if (isNaN(sn) || sn < 0 || sn > 2249) {
        tpSetMsg(msg, 'Слот 0–2249', false);
        return;
    }
    const body = Object.assign({}, tpCollectConfig(), { which });
    tpSetMsg(msg, 'Отправка…', true);
    try {
        const r = await fetch('/api/transponder/send', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(body),
        });
        const d = await tpParseJsonResponse(r);
        if (!d.ok) {
            tpSetMsg(msg, d.error || 'Ошибка', false);
            return;
        }
        const res = d.result || {};
        const line = tpFormatSendResult(res);
        tpSetMsg(msg, line || 'Готово', !(res.errors && res.errors.length));
    } catch (e) {
        tpSetMsg(msg, e.message, false);
    }
}

(function initTransponderUi() {
    const save = document.getElementById('tp-save');
    const preview = document.getElementById('tp-preview');
    if (!save || !preview) return;
    save.addEventListener('click', () => tpDoSave());
    preview.addEventListener('click', () => tpDoPreview());
    document.getElementById('tp-send-18').addEventListener('click', () => tpDoSend('18'));
    document.getElementById('tp-send-19').addEventListener('click', () => tpDoSend('19'));
    document.getElementById('tp-send-24a').addEventListener('click', () => tpDoSend('24A'));
    document.getElementById('tp-send-24b').addEventListener('click', () => tpDoSend('24B'));
    document.getElementById('tp-send-broadcast').addEventListener('click', () => tpDoSend('broadcast'));
    const gpioOnce = document.getElementById('tp-gpio-pulse-once');
    if (gpioOnce) gpioOnce.addEventListener('click', () => tpDoGpioPulseOnce());
    const rawBtn = document.getElementById('tp-send-raw');
    if (rawBtn) rawBtn.addEventListener('click', () => tpDoSendRaw());
})();

async function tpDoSendRaw() {
    const msg = document.getElementById('tp-msg');
    const hex = document.getElementById('tp-raw-hex').value;
    const channel = document.getElementById('tp-slot-channel').value;
    const slot = parseInt(document.getElementById('tp-slot-number').value, 10);
    if (isNaN(slot) || slot < 0 || slot > 2249) {
        tpSetMsg(msg, 'Слот 0–2249', false);
        return;
    }
    if (!hex.trim()) {
        tpSetMsg(msg, 'Введите hex', false);
        return;
    }
    tpSetMsg(msg, 'Отправка…', true);
    try {
        const r = await fetch('/api/transponder/send_raw', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(Object.assign({}, tpCollectConfig(), { nrzi_hex: hex, channel, slot })),
        });
        const d = await tpParseJsonResponse(r);
        if (!d.ok) {
            tpSetMsg(msg, d.error || 'Ошибка', false);
            return;
        }
        const res = d.result || {};
        const line = tpFormatSendResult(res);
        tpSetMsg(msg, line || 'Готово', !(res.errors && res.errors.length));
    } catch (e) {
        tpSetMsg(msg, e.message, false);
    }
}

function loadStylesheet(href) {
    return new Promise((resolve, reject) => {
        const l = document.createElement('link');
        l.rel = 'stylesheet';
        l.href = href;
        l.onload = () => resolve();
        l.onerror = () => reject(new Error('css'));
        document.head.appendChild(l);
    });
}
function loadScript(src) {
    return new Promise((resolve, reject) => {
        const s = document.createElement('script');
        s.src = src;
        s.onload = resolve;
        s.onerror = () => reject(new Error('script'));
        document.head.appendChild(s);
    });
}

let _consoleInitPromise = null;
function initConsole() {
    if (window._consoleInited) {
        if (window._fitTerminal) setTimeout(() => window._fitTerminal(), 80);
        return Promise.resolve();
    }
    if (_consoleInitPromise) return _consoleInitPromise;
    _consoleInitPromise = doInitConsole();
    return _consoleInitPromise;
}

async function doInitConsole() {
    try {
    const elUnavailable = document.getElementById('terminal-unavailable');
    const elWrap = document.getElementById('terminal-wrap');
    const elStatus = document.getElementById('console-status');
    let cfg;
    try {
        const r = await fetch('/api/terminal');
        cfg = await r.json();
    } catch (e) {
        elUnavailable.style.display = 'block';
        elUnavailable.textContent = 'Не удалось получить настройки терминала.';
        elStatus.textContent = 'ошибка';
        return;
    }
    if (!cfg.pty) {
        elUnavailable.style.display = 'block';
        elUnavailable.innerHTML = 'Интерактивная консоль доступна только на устройстве под Linux (PTY). На Windows shell в браузере не подключается.';
        elStatus.textContent = 'недоступно';
        window._consoleInited = true;
        return;
    }
    if (!cfg.ws) {
        elUnavailable.style.display = 'block';
        elUnavailable.textContent = 'Установите зависимость: pip install flask-sock';
        elStatus.textContent = 'нет WS';
        window._consoleInited = true;
        return;
    }

    try {
        await loadStylesheet('/static/xterm/xterm.css');
        await loadScript('/static/xterm/xterm.min.js');
        await loadScript('/static/xterm/xterm-addon-fit.min.js');
    } catch (e) {
        elUnavailable.style.display = 'block';
        elUnavailable.textContent = 'Не удалось загрузить xterm.js (проверьте сеть / CDN).';
        elStatus.textContent = 'ошибка';
        return;
    }

    const Term = window.Terminal;
    /* UMD xterm-addon-fit: window.FitAddon — это модуль { FitAddon: class }, не конструктор */
    const FitAddonMod = window.FitAddon;
    const FitAddonCtor = (FitAddonMod && typeof FitAddonMod.FitAddon === 'function')
        ? FitAddonMod.FitAddon
        : (typeof FitAddonMod === 'function' ? FitAddonMod : null);
    if (!Term || !FitAddonCtor) {
        elUnavailable.style.display = 'block';
        elUnavailable.textContent = 'xterm: неверные глобалы после загрузки скриптов.';
        elStatus.textContent = 'ошибка';
        return;
    }

    elUnavailable.style.display = 'none';
    elWrap.style.display = 'block';

    const term = new Term({
        cursorBlink: true,
        fontSize: 14,
        fontFamily: "'JetBrains Mono', 'Cascadia Mono', 'Consolas', monospace",
        theme: {
            background: '#0d1117',
            foreground: '#e0e0e0',
            cursor: '#d2ff1a',
            cursorAccent: '#1a1a2e',
            selectionBackground: '#264f78'
        }
    });
    const fitAddon = new FitAddonCtor();
    term.loadAddon(fitAddon);
    term.open(elWrap);
    fitAddon.fit();

    const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
    const ws = new WebSocket(proto + '//' + location.host + '/ws/terminal');
    ws.binaryType = 'arraybuffer';

    ws.onopen = () => {
        elStatus.textContent = 'подключено';
        ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
    };
    ws.onclose = () => { elStatus.textContent = 'отключено'; };
    ws.onerror = () => { elStatus.textContent = 'ошибка соединения'; };

    ws.onmessage = (ev) => {
        const data = ev.data;
        if (data instanceof ArrayBuffer) {
            term.write(new Uint8Array(data));
        } else if (typeof Blob !== 'undefined' && data instanceof Blob) {
            data.arrayBuffer().then(buf => term.write(new Uint8Array(buf)));
        } else {
            term.write(data);
        }
    };

    term.onData(data => {
        if (ws.readyState === WebSocket.OPEN) ws.send(data);
    });
    term.onResize(({ cols, rows }) => {
        if (ws.readyState === WebSocket.OPEN) {
            ws.send(JSON.stringify({ type: 'resize', cols, rows }));
        }
    });

    window._fitTerminal = () => { try { fitAddon.fit(); } catch(e) {} };
    window.addEventListener('resize', () => {
        if (currentTab === 'console' && window._fitTerminal) window._fitTerminal();
    });

    window._consoleInited = true;
    } finally {
        _consoleInitPromise = null;
    }
}

// ===================== Map init =====================
L.Icon.Default.imagePath = '/static/leaflet/images/';
const EMPTY_TILE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';

/** Текст внутри SVG <text> (не HTML). */
function _vtSvgEscapeText(s) {
    return String(s)
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;');
}

// Векторная подложка в тон веб-UI: суша светлее, вода темнее — заметнее граница с фоном #0e1a2b
var _vtLand = '#323f58';
var _vtWater = '#0c1424';
var _vtWaterStroke = '#1a3d5c';
var _vtCoastStroke = '#5c7fa3';
var _vtWaterway = '#3d5d78';
var _vtRoad = '#5a6578';
var _vtBuilding = '#2c3548';
var _vtGreen = '#2a3d34';
var _vtPark = '#263d30';
var _vtLabelFill = '#c9d1d9';
var _vtLabelHalo = '#0e1a2b';

/**
 * Leaflet.VectorGrid рисует подписи точек только через L.icon + iconUrl.
 * Дубли по границам тайлов — ограничение MVT; уменьшаем шум: высокий minZoom, только нужные классы.
 */
function _vtTextLabelIcon(name, zoom, minZoom) {
    if (zoom < minZoom || !name || typeof name !== 'string') return null;
    const fs = zoom <= 7 ? 10 : zoom <= 10 ? 11 : 12;
    const padX = 6;
    // 0.52 занижало ширину для кириллицы / жирного шрифта → текст обрезался по viewBox SVG.
    let nonAscii = 0;
    for (let i = 0; i < name.length; i++) {
        if (name.charCodeAt(i) > 127) nonAscii++;
    }
    const perChar = fs * (nonAscii > 0 ? 0.72 : 0.62);
    const strokePad = 8;
    const w = Math.min(640, Math.max(20, Math.ceil(name.length * perChar + padX * 2 + strokePad)));
    const h = fs + 10;
    const esc = _vtSvgEscapeText(name);
    const svg = '<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + h + '" overflow="visible">' +
        '<text x="' + padX + '" y="' + (fs + 4) + '" text-anchor="start" font-size="' + fs + 'px" font-family="system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-weight="600" fill="' + _vtLabelFill + '" stroke="' + _vtLabelHalo + '" stroke-width="2.5" stroke-linejoin="round" paint-order="stroke fill">' + esc + '</text></svg>';
    const url = 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(svg);
    return L.icon({
        iconUrl: url,
        iconSize: [w, h],
        iconAnchor: [0, h],
    });
}

function _vtPlaceName(properties) {
    if (!properties || typeof properties !== 'object') return '';
    return (
        properties.name ||
        properties.name_en ||
        properties['name:latin'] ||
        properties['name:en'] ||
        properties['name:ru'] ||
        properties['name:de'] ||
        ''
    );
}

/** Только крупные населённые пункты (без деревень, кварталов, POI). */
function _vtCityPlaceLabelIcon(properties, zoom) {
    const cls = String(properties.class || '').toLowerCase();
    if (cls) {
        if (cls !== 'city' && cls !== 'town') return null;
        if (properties.rank != null && Number(properties.rank) > 10) return null;
        return _vtTextLabelIcon(_vtPlaceName(properties), zoom, 7);
    }
    // Простые тайлы без class: только с z≥8, отсекаем типичные мелкие типы
    if (zoom < 8) return null;
    const sub = String(properties.subclass || properties.type || '').toLowerCase();
    if (['village', 'hamlet', 'suburb', 'quarter', 'neighbourhood', 'locality', 'isolated_dwelling'].indexOf(sub) >= 0) {
        return null;
    }
    return _vtTextLabelIcon(_vtPlaceName(properties), zoom, 8);
}

/** Подписи только рек / ручьёв / каналов (не озёра/моря). */
function _vtRiverNameLabelIcon(properties, zoom) {
    const cls = String(properties.class || '').toLowerCase();
    if (['river', 'stream', 'canal'].indexOf(cls) < 0) return null;
    if (properties.rank != null && Number(properties.rank) > 4) return null;
    return _vtTextLabelIcon(_vtPlaceName(properties), zoom, 10);
}

function _vtWaterNameLayerStyle(p, z) {
    const icon = _vtRiverNameLabelIcon(p, z);
    if (!icon) return [];
    return {icon: icon};
}

function _vtWaterwayLineStyle(p, z) {
    var w = z < 6 ? 0.4 : z < 9 ? 0.65 : 0.95;
    return {weight: w, color: _vtWaterway, opacity: 0.82, fill: false, fillOpacity: 0};
}

function _vtRoadLineStyle(p, z) {
    var w = z < 6 ? 0.5 : z < 8 ? 0.85 : 1.15;
    return {weight: w, color: _vtRoad, opacity: 0.7, fill: false, fillOpacity: 0};
}

/** Береговая линия (LineString). Несколько имён слоёв — у разных сборок planet/OSM по-разному. */
function _vtCoastlineLineStyle(p, z) {
    var w = z < 8 ? 0.85 : 1.15;
    return {
        fill: false,
        fillOpacity: 0,
        weight: w,
        color: _vtCoastStroke,
        opacity: 0.88,
        lineCap: 'round',
        lineJoin: 'round',
    };
}

/**
 * Слой water в planet-часто смешанный: Polygon + LineString + Point.
 * Точки — в основном seamark (мосты, ограничения по высоте), паромы, slipway: дают «кружки цвета воды».
 * В VectorGrid PointSymbolizer: _radius = style.radius || defaultRadius — при radius: 0 срабатывает дефолт ~10px.
 */
function _vtWaterLayerPointLikeProps(p) {
    if (!p || typeof p !== 'object') return false;
    if (p.amenity === 'ferry_terminal' || p.leisure === 'slipway' || p.railway === 'ferry_terminal' || p.ferry === 'yes') {
        return true;
    }
    for (var k in p) {
        if (Object.prototype.hasOwnProperty.call(p, k) && k.indexOf('seamark:') === 0) {
            return true;
        }
    }
    return false;
}

function _vtWaterLayerStyle(properties) {
    if (_vtWaterLayerPointLikeProps(properties)) {
        return [];
    }
    return {
        fill: true,
        weight: 0.45,
        color: _vtWaterStroke,
        opacity: 0.85,
        fillColor: _vtWater,
        fillOpacity: 1,
        radius: 0.01,
    };
}

const map = L.map('map', {
    background: '#0e1a2b',
    maxZoom: 19,
    // Critical for performance with many polylines/polygons at high zoom.
    preferCanvas: true,
    // leaflet-rotate plugin
    rotate: true,
    // leaflet-rotate.js enables this by default; we use our own compass UI.
    rotateControl: false,
    bearing: 0,
}).setView([55.751244, 37.618423], 10);

// Put AIS/aux markers into a pane that does NOT rotate with the map.
// leaflet-rotate creates `norotatePane`; if not present we fall back to default markerPane.
let AIS_MARKER_PANE = null;
try {
    const nr = map.getPane && map.getPane('norotatePane');
    if (nr) {
        AIS_MARKER_PANE = 'aisMarkerPane';
        if (!map.getPane(AIS_MARKER_PANE)) {
            map.createPane(AIS_MARKER_PANE, nr);
            map.getPane(AIS_MARKER_PANE).style.zIndex = 610; // above tiles/overlays, under popups
        }
    }
} catch (e) { AIS_MARKER_PANE = null; }

function _ensureAisMarkerPane(mk) {
    try {
        if (!AIS_MARKER_PANE || !mk || !mk.options || mk.options.pane === AIS_MARKER_PANE) return;
        // Pane is effectively applied on add; re-add to move existing markers.
        const wasOnMap = !!(map && map.hasLayer && map.hasLayer(mk));
        if (wasOnMap) mk.remove();
        mk.options.pane = AIS_MARKER_PANE;
        if (wasOnMap) mk.addTo(map);
    } catch (e) {}
}

// ===================== Compass + map rotation =====================
// Default is ON: the map rotates so the heading points up, which is the most
// useful mode on a vessel. Users can toggle it off via the compass button in
// #map-controls, the dial in #ownship-panel, or the HUD compass click.
let rotateMapByCompass = true;
try {
    const _rmc = sGet('rotateMapByCompass', null);
    if (_rmc != null) rotateMapByCompass = !!_rmc;
} catch (e) {}
let _lastOwnshipHeadingForRotate = null;
let _ownshipCompassUi = { wrap: null, btn: null, dial: null, arrow: null };

function _mapBearingDegSafe() {
    try { return (map && typeof map.getBearing === 'function') ? (map.getBearing() || 0) : 0; } catch (e) { return 0; }
}

function _applyMapRotation() {
    try {
        if (!map || typeof map.setBearing !== 'function') return;
        const hd = (rotateMapByCompass && _lastOwnshipHeadingForRotate != null && !isNaN(_lastOwnshipHeadingForRotate))
            ? _lastOwnshipHeadingForRotate
            : null;
        // heading-up: rotate map counter to heading
        map.setBearing(hd == null ? 0 : -hd);
    } catch (e) {}
}

function _setOwnshipCompassUi() {
    const wrap = _ownshipCompassUi.wrap;
    if (!wrap) return;
    wrap.classList.toggle('compass--rotate-on', !!rotateMapByCompass);
    const hd = (_lastOwnshipHeadingForRotate != null && !isNaN(_lastOwnshipHeadingForRotate)) ? _lastOwnshipHeadingForRotate : null;
    // Compass indicates north: when map rotated by -hd, north appears at +hd on screen.
    const a = (rotateMapByCompass && hd != null) ? hd : 0;
    if (_ownshipCompassUi.arrow) _ownshipCompassUi.arrow.style.transform = 'rotate(' + (Math.round(a * 10) / 10) + 'deg)';
    if (_ownshipCompassUi.dial) _ownshipCompassUi.dial.classList.toggle('compass--no-heading', hd == null);
}

// Shared toggle for "heading-up" (map rotates by compass). Used by:
//   - #ownship-panel compass button + dial (desktop)
//   - #map-controls #mc-compass (everywhere)
//   - Clicking the HUD compass (#nhud-compass) as a quick shortcut
function _toggleRotateMapByCompass() {
    rotateMapByCompass = !rotateMapByCompass;
    try { sSet('rotateMapByCompass', rotateMapByCompass); } catch (e) {}
    try { _setOwnshipCompassUi(); } catch (e) {}
    try { _reflectCompassToggleUi(); } catch (e) {}
    try { _applyMapRotation(); } catch (e) {}
}

// Reflect the current state on the #map-controls compass button (active ring)
// and the HUD compass element (clickable cursor).
function _reflectCompassToggleUi() {
    const mc = document.getElementById('mc-compass');
    if (mc) {
        mc.classList.toggle('active', !!rotateMapByCompass);
        mc.title = rotateMapByCompass
            ? 'Heading-up: ВКЛ (нажмите, чтобы North-up)'
            : 'North-up (нажмите, чтобы вращать карту по компасу)';
    }
    const hc = document.getElementById('nhud-compass');
    if (hc) {
        hc.classList.toggle('is-rotating-map', !!rotateMapByCompass);
    }
}

function _initOwnshipCompassUiOnce() {
    try {
        if (_ownshipCompassUi.wrap) return;
        const btn = document.getElementById('btn-rotate-map');
        const dial = document.getElementById('os-compass-dial');
        const arrow = document.getElementById('os-compass-arrow');
        if (!btn || !dial || !arrow) return;
        _ownshipCompassUi = { wrap: dial.closest('.os-compass') || dial, btn, dial, arrow };

        btn.addEventListener('click', (e) => { e.preventDefault(); _toggleRotateMapByCompass(); });
        dial.addEventListener('dblclick', (e) => { e.preventDefault(); _toggleRotateMapByCompass(); });
        _setOwnshipCompassUi();
    } catch (e) {}
}
try { _initOwnshipCompassUiOnce(); } catch (e) {}

// Initial UI reflection for controls that exist at page load.
document.addEventListener('DOMContentLoaded', () => {
    try { _reflectCompassToggleUi(); } catch (e) {}
});
// Also attach a click handler on the HUD compass once it's in the DOM.
document.addEventListener('DOMContentLoaded', () => {
    const hc = document.getElementById('nhud-compass');
    if (hc && !hc.dataset.hcBound) {
        hc.dataset.hcBound = '1';
        hc.style.cursor = 'pointer';
        hc.addEventListener('click', (e) => { e.preventDefault(); _toggleRotateMapByCompass(); });
    }
});
// Apply initial rotation state right after map init (in case GPS already has a fix
// from a saved state or Android compass starts quickly).
try { setTimeout(() => { try { _applyMapRotation(); } catch (_) {} }, 100); } catch (e) {}

// When the map bearing changes (programmatically or via gestures), keep ownship marker
// in sync even if no new GPS data arrived (e.g. during follow/compass updates).
try {
    map.on('rotate', () => {
        try { if (ownShipMarker) setOwnShipRotation(ownShipMarker, _lastOwnshipHeadingForRotate); } catch (e) {}
    });
} catch (e) {}

const _vtBaseLayerStyles = {
        // Локальные / planet_small слои (land, water, coastline, place)
        land:           {fill: true, weight: 0, fillColor: _vtLand, fillOpacity: 1},
        water:          _vtWaterLayerStyle,
        coastline:      _vtCoastlineLineStyle,
        coastlines:     _vtCoastlineLineStyle,
        coast_line:     _vtCoastlineLineStyle,
        ocean_coastline:_vtCoastlineLineStyle,
        osm_coastline:  _vtCoastlineLineStyle,
        water_point:    [],
        ocean_point:    [],
        marine_point:   [],
        // place:          function (properties, zoom) {
        //     const icon = _vtCityPlaceLabelIcon(properties, zoom);
        //     if (!icon) return [];
        //     return {icon: icon};
        // },
        place: [],
        // Линии воды / дорог (явный stroke, без синего Leaflet по умолчанию)
        waterway:       _vtWaterwayLineStyle,
        stream:         _vtWaterwayLineStyle,
        hydrology:      _vtWaterwayLineStyle,
        transportation: _vtRoadLineStyle,
        road:           _vtRoadLineStyle,
        roads:          _vtRoadLineStyle,
        street:         _vtRoadLineStyle,
        streets:        _vtRoadLineStyle,
        highway:        _vtRoadLineStyle,
        bridge:         _vtRoadLineStyle,
        tunnel:         _vtRoadLineStyle,
        railway:        _vtRoadLineStyle,
        rail:           _vtRoadLineStyle,
        landcover:      {fill: true, weight: 0, fillColor: _vtGreen, fillOpacity: 0.45},
        landuse:        {fill: true, weight: 0, fillColor: _vtLand, fillOpacity: 0.5},
        park:           {fill: true, weight: 0, fillColor: _vtPark, fillOpacity: 0.5},
        transportation_name: [],
        // water_name:     _vtWaterNameLayerStyle,
        // waterway_name:  _vtWaterNameLayerStyle,
        water_name: [],
        waterway_name: [],
        building:       {fill: true, weight: 0, fillColor: _vtBuilding, fillOpacity: 0.65},
        // boundary:       function(p, z) {
        //     var admin = p.admin_level || 2;
        //     if (admin > 4) return [];
        //     return {weight: admin <= 2 ? 1.2 : 0.65, color: '#5c6b80', opacity: 0.45, dashArray: '6,3', fill: false, fillOpacity: 0};
        // },
        boundary: {weight: 1, color: '#5c6b80', opacity: 0.35, fill: false},
        housenumber:    [],
        poi:            [],
        mountain_peak:  [],
        aerodrome_label:[],
        aeroway:        {weight: 0.75, color: _vtRoad, opacity: 0.5, fill: false, fillOpacity: 0},
        globallandcover:{fill: true, weight: 0, fillColor: _vtGreen, fillOpacity: 0.35},
};

const _vtLayerStyles = new Proxy(_vtBaseLayerStyles, {
    get(target, prop) {
        if (Object.prototype.hasOwnProperty.call(target, prop)) {
            return target[prop];
        }
        if (typeof prop !== 'string') {
            return target[prop];
        }
        // Слой есть в тайле, но нет в _vtBaseLayerStyles: не рисуем.
        // Иначе точки превращаются в светлые CircleMarker («белые кружки» по всей карте).
        return function () {
            return [];
        };
    },
});

// Общие tweaks для всех TileLayer-ов: keepBuffer=4 — меньше просадок при pan'е,
// updateWhenZooming=false + updateWhenIdle=true — не шлём сотню запросов во время pinch-zoom.
const _tileTweaks = {
    keepBuffer: 4,
    updateWhenZooming: false,
    updateWhenIdle: true,
};

const vectorLayer = L.vectorGrid.protobuf('/vtiles/{z}/{x}/{y}.pbf', Object.assign({}, _tileTweaks, {
    maxZoom: 19,
    // Локальные MVT сгенерированы до z13: при z>13 тот же растр тайла масштабируется (overzoom).
    maxNativeZoom: 13,
    rendererFactory: L.canvas.tile,
    vectorTileLayerStyles: _vtLayerStyles,
    interactive: false
}));

const rasterLayer = L.tileLayer('/tiles/{z}/{x}/{y}.png', Object.assign({}, _tileTweaks, {
    maxZoom: 19,
    errorTileUrl: EMPTY_TILE
}));
vectorLayer.addTo(map);

// Panes for z-index control (Leaflet default panes: overlayPane ~400, markerPane ~600)
try {
    if (!map.getPane('vesselDetailPane')) {
        map.createPane('vesselDetailPane');
        map.getPane('vesselDetailPane').style.zIndex = 650; // above overlays, below popups
    }
} catch (e) {}

// Shared Canvas renderer for vector overlays (trails/vectors/hulls/antenna).
// This avoids SVG DOM bloat and reduces input jank at high zoom.
const _overlayCanvasRenderer = L.canvas({ padding: 0.5 });

const osmLayer = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', Object.assign({}, _tileTweaks, {
    maxZoom: 19,
    attribution: '&copy; OpenStreetMap contributors',
    crossOrigin: true
}));
const cartoPositron = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', Object.assign({}, _tileTweaks, {
    maxZoom: 20,
    subdomains: 'abcd',
    attribution: '&copy; OpenStreetMap contributors &copy; CARTO',
    crossOrigin: true
}));
const cartoDark = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', Object.assign({}, _tileTweaks, {
    maxZoom: 20,
    subdomains: 'abcd',
    attribution: '&copy; OpenStreetMap contributors &copy; CARTO',
    crossOrigin: true
}));
const esriWorldImagery = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', Object.assign({}, _tileTweaks, {
    maxZoom: 19,
    attribution: 'Tiles &copy; Esri',
    crossOrigin: true
}));

const baseLayers = {
    'Вектор': vectorLayer,
    'Локальные': rasterLayer,
    'OSM': osmLayer,
    'CARTO Light': cartoPositron,
    'CARTO Dark': cartoDark,
    'Esri Спутник': esriWorldImagery
};
L.control.layers(baseLayers, null, {position: 'topright'}).addTo(map);

function baseLayerNameByObj(layer) {
    for (const [name, ly] of Object.entries(baseLayers)) {
        if (ly === layer) return name;
    }
    return null;
}
function applyBaseLayerByName(name) {
    const target = baseLayers[name];
    if (!target) return false;
    for (const ly of Object.values(baseLayers)) {
        if (map.hasLayer(ly)) map.removeLayer(ly);
    }
    target.addTo(map);
    return true;
}
try {
    const savedBase = sGet('baseLayer', '');
    if (savedBase && savedBase !== 'Вектор') applyBaseLayerByName(savedBase);
} catch (e) {}
map.on('baselayerchange', (e) => {
    const nm = baseLayerNameByObj(e && e.layer);
    if (nm) sSet('baseLayer', nm);
});

const vesselMarkers = new Map();
const iconWidth = 17, iconHeight = 30;
const iconAnchorX = iconWidth / 2, iconAnchorY = iconHeight;

// ===================== Base stations & buoys (separate layers) =====================
const baseStationMarkers = new Map(); // mmsi -> marker
const buoyMarkers = new Map();        // mmsi -> marker
let lastBaseStations = [];
let lastBuoys = [];
let lastVisibleVessels = [];
let lastAnyVessels = [];

function _mapGetByMmsi(src, mmsi) {
    if (!src || mmsi == null) return null;
    if (src.has(mmsi)) return src.get(mmsi);
    const key = String(mmsi);
    for (const [k, v] of src.entries()) {
        if (String(k) === key) return v;
    }
    return null;
}

function _markerForTargetMmsi(mmsi) {
    return _mapGetByMmsi(vesselMarkers, mmsi) ||
        _mapGetByMmsi(baseStationMarkers, mmsi) ||
        _mapGetByMmsi(buoyMarkers, mmsi);
}

function _decorateAisTarget(v) {
    if (!v) return null;
    const out = Object.assign({}, v);
    if (out.vessel_class === 'BS' || out.kind === 'base_station') {
        out.vessel_class = 'BS';
        out.kind = 'base_station';
        out.shipname = out.shipname || 'Базовая станция';
        out.virtual = !!out.virtual;
        out.synthetic = !!out.synthetic;
    } else if (out.vessel_class === 'N' || out.kind === 'buoy') {
        out.vessel_class = 'N';
        out.kind = 'buoy';
        out.shipname = out.shipname || out.name || 'Буёк / СНО';
        out.aton_type_label = out.aton_type_label || atonTypeLabel(out.aton_type);
        out.virtual = !!out.virtual;
        out.synthetic = !!out.synthetic;
        out.off_position = !!out.off_position;
    } else {
        out.kind = out.kind || 'vessel';
    }
    const os = getOwnShipPos();
    if (os && out.lat != null && out.lon != null) {
        out._distNM = haversineNM(os.lat, os.lon, out.lat, out.lon);
    }
    return out;
}

function getAisTargetByMmsi(mmsi) {
    const v = _mapGetByMmsi(vesselLastData, mmsi) || _mapGetByMmsi(AisHub.vessels, mmsi);
    if (v) return _decorateAisTarget(Object.assign({ kind: 'vessel' }, v));
    const bs = _mapGetByMmsi(AisHub.baseStations, mmsi) ||
        (lastBaseStations || []).find(x => String(x && x.mmsi) === String(mmsi));
    if (bs) return _decorateAisTarget(Object.assign({ vessel_class: 'BS', kind: 'base_station' }, bs));
    const aton = _mapGetByMmsi(AisHub.atons, mmsi) ||
        (lastBuoys || []).find(x => String(x && x.mmsi) === String(mmsi));
    if (aton) return _decorateAisTarget(Object.assign({ vessel_class: 'N', kind: 'buoy' }, aton));
    return null;
}

function _aidModeClass(item) {
    if (item && item.virtual) return 'ais-aid--virtual';
    if (item && item.synthetic) return 'ais-aid--synthetic';
    return 'ais-aid--real';
}

function _aidModeLabel(item) {
    if (item && item.virtual) return 'виртуальное';
    if (item && item.synthetic) return 'синтетическое';
    return 'реальное';
}

function _atonIconMeta(type) {
    const c = parseInt(type, 10);
    const fixed = c >= 5 && c <= 19;
    const floating = c === 4 || (c >= 20 && c <= 31);
    const nature = fixed ? 'fixed' : (floating ? 'floating' : 'generic');
    switch (c) {
        case 2: return { style: 'racon', label: 'R', nature };
        case 3: return { style: 'structure', label: 'ПЛ', nature };
        case 4: return { style: 'wreck', label: 'АВ', nature };
        case 5:
        case 6: return { style: 'light', label: 'ОГ', nature };
        case 7: return { style: 'leading', label: 'ПС', nature };
        case 8: return { style: 'leading', label: 'ЗС', nature };
        case 9:
        case 20: return { style: 'cardinal-n', label: 'С', nature };
        case 10:
        case 21: return { style: 'cardinal-e', label: 'В', nature };
        case 11:
        case 22: return { style: 'cardinal-s', label: 'Ю', nature };
        case 12:
        case 23: return { style: 'cardinal-w', label: 'З', nature };
        case 13:
        case 24: return { style: 'lateral-port', label: 'Л', nature };
        case 14:
        case 25: return { style: 'lateral-starboard', label: 'П', nature };
        case 15:
        case 26: return { style: 'preferred-port', label: 'ЛК', nature };
        case 16:
        case 27: return { style: 'preferred-starboard', label: 'ПК', nature };
        case 17:
        case 28: return { style: 'isolated-danger', label: '!', nature };
        case 18:
        case 29: return { style: 'safe-water', label: 'ЧВ', nature };
        case 19:
        case 30: return { style: 'special', label: '*', nature };
        case 31: return { style: 'light-vessel', label: 'ПМ', nature };
        case 1: return { style: 'reference', label: 'ОТ', nature };
        default: return { style: 'generic', label: 'СНО', nature };
    }
}

function getBaseStationDivIcon(b) {
    const mode = _aidModeClass(b);
    const label = _aidModeLabel(b);
    const key = 'bs|' + mode;
    const icon = L.divIcon({
        className: 'ais-bs-divicon ' + mode,
        iconSize: [34, 34],
        iconAnchor: [17, 17],
        html: '<div class="ais-bs-symbol" title="Базовая станция AIS, ' + escHtml(label) + '">' +
            '<span class="ais-bs-mast"></span><span class="ais-bs-waves"></span></div>',
    });
    icon._aisIconKey = key;
    return icon;
}

function getBuoyDivIcon(b) {
    const meta = _atonIconMeta(b && b.aton_type);
    const mode = _aidModeClass(b);
    const off = b && b.off_position ? ' ais-aid--offposition' : '';
    const key = 'aton|' + meta.style + '|' + meta.nature + '|' + mode + off;
    const title = (b && (b.aton_type_label || atonTypeLabel(b.aton_type))) || 'Тип СНО не указан';
    const icon = L.divIcon({
        className: 'ais-aton-divicon ' + mode + off,
        iconSize: [32, 32],
        iconAnchor: [16, 16],
        html: '<div class="ais-aton-symbol ais-aton-symbol--' + meta.style + ' ais-aton-symbol--' + meta.nature +
            '" title="' + escHtml(title + ', ' + _aidModeLabel(b)) + '">' +
            '<span class="ais-aton-label">' + escHtml(meta.label) + '</span></div>',
    });
    icon._aisIconKey = key;
    return icon;
}

function updateBaseStations() {
    try {
        const now = Math.floor(Date.now() / 1000);
        const list = Array.from(AisHub.baseStations.values())
            .filter(b => !_isTargetExpiredByTimestamp(b, now));
        lastBaseStations = list.map(b => Object.assign({}, b, {
            vessel_class: 'BS',
            kind: 'base_station',
            shipname: 'Базовая станция',
            callsign: null,
            shiptype: null,
            virtual: !!b.virtual,
            synthetic: !!b.synthetic,
        }));
        const visible = new Set();
        for (const b of list) {
            if (!b || b.lat == null || b.lon == null) continue;
            const mmsi = b.mmsi;
            visible.add(mmsi);
            if (baseStationMarkers.has(mmsi)) {
                const mk = baseStationMarkers.get(mmsi);
                _ensureAisMarkerPane(mk);
                mk.setLatLng([b.lat, b.lon]);
                const icon = getBaseStationDivIcon(b);
                if (mk._aisIconKey !== icon._aisIconKey) {
                    mk.setIcon(icon);
                    mk._aisIconKey = icon._aisIconKey;
                }
            } else {
                const icon = getBaseStationDivIcon(b);
                const mk = L.marker([b.lat, b.lon], { icon, zIndexOffset: 500, pane: AIS_MARKER_PANE || undefined }).addTo(map);
                mk._aisIconKey = icon._aisIconKey;
                mk.on('click', () => { try { VesselInfoWindow.open(mmsi); } catch (_) {} });
                baseStationMarkers.set(mmsi, mk);
            }
        }
        for (const [mmsi, mk] of baseStationMarkers.entries()) {
            if (!visible.has(mmsi)) {
                if (String(mmsi) === String(selectedMmsi)) selectedMmsi = null;
                try { if (String(VesselInfoWindow.currentMmsi()) === String(mmsi)) VesselInfoWindow.close(); } catch (_) {}
                map.removeLayer(mk);
                baseStationMarkers.delete(mmsi);
            }
        }
        for (const [mmsi, b] of AisHub.baseStations.entries()) {
            if (_isTargetExpiredByTimestamp(b, now)) AisHub.baseStations.delete(mmsi);
        }
    } catch (e) { /* ignore */ }
}

function updateBuoys() {
    try {
        const now = Math.floor(Date.now() / 1000);
        const list = Array.from(AisHub.atons.values())
            .filter(b => !_isTargetExpiredByTimestamp(b, now));
        lastBuoys = list.map(b => Object.assign({}, b, {
            vessel_class: 'N',
            kind: 'buoy',
            shipname: b && b.name ? b.name : 'Буёк / СНО',
            aton_type_label: atonTypeLabel(b && b.aton_type),
            callsign: null,
            shiptype: null,
            virtual: !!(b && b.virtual),
            synthetic: !!(b && b.synthetic),
            off_position: !!(b && b.off_position),
        }));
        const visible = new Set();
        for (const b of list) {
            if (!b || b.lat == null || b.lon == null) continue;
            const mmsi = b.mmsi;
            visible.add(mmsi);
            const typeLabel = atonTypeLabel(b.aton_type);
            b.aton_type_label = typeLabel;
            if (buoyMarkers.has(mmsi)) {
                const mk = buoyMarkers.get(mmsi);
                _ensureAisMarkerPane(mk);
                mk.setLatLng([b.lat, b.lon]);
                const icon = getBuoyDivIcon(b);
                if (mk._aisIconKey !== icon._aisIconKey) {
                    mk.setIcon(icon);
                    mk._aisIconKey = icon._aisIconKey;
                }
            } else {
                const icon = getBuoyDivIcon(b);
                const mk = L.marker([b.lat, b.lon], { icon, zIndexOffset: 450, pane: AIS_MARKER_PANE || undefined }).addTo(map);
                mk._aisIconKey = icon._aisIconKey;
                mk.on('click', () => { try { VesselInfoWindow.open(mmsi); } catch (_) {} });
                buoyMarkers.set(mmsi, mk);
            }
        }
        for (const [mmsi, mk] of buoyMarkers.entries()) {
            if (!visible.has(mmsi)) {
                if (String(mmsi) === String(selectedMmsi)) selectedMmsi = null;
                try { if (String(VesselInfoWindow.currentMmsi()) === String(mmsi)) VesselInfoWindow.close(); } catch (_) {}
                map.removeLayer(mk);
                buoyMarkers.delete(mmsi);
            }
        }
        for (const [mmsi, b] of AisHub.atons.entries()) {
            if (_isTargetExpiredByTimestamp(b, now)) AisHub.atons.delete(mmsi);
        }
    } catch (e) { /* ignore */ }
}

// ===================== Vessel motion overlays (vector + trail) =====================
const VESSEL_PREDICT_SECONDS = 60;
const VESSEL_PREDICT_STEP_S = 5;
const VESSEL_TRAIL_KEEP_POINTS = 40;
const VESSEL_TRAIL_MIN_MOVE_M = 8; // reduce noise/jitter
const vesselVectors = new Map(); // mmsi -> L.Polyline
const vesselTrails = new Map();  // mmsi -> L.Polyline
const vesselHistory = new Map(); // mmsi -> Array<{lat:number, lon:number, ts:number}>
const vesselTrailRefMode = new Map(); // mmsi -> 'antenna' | 'center'

function isFiniteNumber(x) {
    return typeof x === 'number' && Number.isFinite(x);
}

function destPointMeters(lat, lon, bearingDeg, distM) {
    const R = 6371000;
    const brng = bearingDeg * Math.PI / 180;
    const φ1 = lat * Math.PI / 180, λ1 = lon * Math.PI / 180;
    const δ = distM / R;
    const sinφ1 = Math.sin(φ1), cosφ1 = Math.cos(φ1);
    const sinδ = Math.sin(δ), cosδ = Math.cos(δ);
    const sinφ2 = sinφ1 * cosδ + cosφ1 * sinδ * Math.cos(brng);
    const φ2 = Math.asin(sinφ2);
    const λ2 = λ1 + Math.atan2(Math.sin(brng) * sinδ * cosφ1, cosδ - sinφ1 * sinφ2);
    return { lat: φ2 * 180 / Math.PI, lon: λ2 * 180 / Math.PI };
}

function haversineMetersLatLon(lat1, lon1, lat2, lon2) {
    const R = 6371000;
    const dLat = (lat2 - lat1) * Math.PI / 180;
    const dLon = (lon2 - lon1) * Math.PI / 180;
    const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*Math.sin(dLon/2)**2;
    return 2 * R * Math.asin(Math.sqrt(a));
}

function rotToDegPerMin(rot) {
    // We expect backend to provide ROT already in deg/min (AIS uses signed rate of turn).
    // If it’s absent / not finite, treat as no turn.
    if (!isFiniteNumber(rot)) return null;
    return rot;
}

function computePredictPath(lat, lon, courseDeg, speedKn, rotDegPerMin) {
    if (!isFiniteNumber(lat) || !isFiniteNumber(lon)) return null;
    if (!isFiniteNumber(courseDeg) || !isFiniteNumber(speedKn) || speedKn <= 0.05) return null;

    const stepS = Math.max(1, VESSEL_PREDICT_STEP_S);
    const steps = Math.max(1, Math.floor(VESSEL_PREDICT_SECONDS / stepS));
    const metersPerSec = speedKn * 1852 / 3600;
    const dStep = metersPerSec * stepS;

    let curLat = lat, curLon = lon;
    const pts = [[curLat, curLon]];

    const rot = rotToDegPerMin(rotDegPerMin);
    const hasTurn = rot != null && Math.abs(rot) >= 0.2; // ignore tiny/noisy ROT

    for (let i = 1; i <= steps; i++) {
        const tS = i * stepS;
        const brg = hasTurn ? normalizeDeg(courseDeg + rot * (tS / 60)) : normalizeDeg(courseDeg);
        const next = destPointMeters(curLat, curLon, brg, dStep);
        curLat = next.lat;
        curLon = next.lon;
        pts.push([curLat, curLon]);
    }
    return pts;
}

function getOrCreatePolyline(store, mmsi, opts) {
    let pl = store.get(mmsi);
    if (pl) return pl;
    pl = L.polyline([], Object.assign({ renderer: _overlayCanvasRenderer }, opts)).addTo(map);
    store.set(mmsi, pl);
    return pl;
}

function removeMotionOverlays(mmsi) {
    const v = vesselVectors.get(mmsi);
    if (v) { map.removeLayer(v); vesselVectors.delete(mmsi); }
    const t = vesselTrails.get(mmsi);
    if (t) { map.removeLayer(t); vesselTrails.delete(mmsi); }
    vesselHistory.delete(mmsi);
    vesselTrailRefMode.delete(mmsi);
}

function updateVesselTrail(mmsi, lat, lon, ts) {
    if (!isFiniteNumber(lat) || !isFiniteNumber(lon)) return;
    const nowTs = isFiniteNumber(ts) ? ts : Math.floor(Date.now() / 1000);
    let hist = vesselHistory.get(mmsi);
    if (!hist) { hist = []; vesselHistory.set(mmsi, hist); }

    const last = hist.length ? hist[hist.length - 1] : null;
    if (!last) {
        hist.push({ lat, lon, ts: nowTs });
    } else {
        const movedM = haversineMetersLatLon(last.lat, last.lon, lat, lon);
        if (movedM >= VESSEL_TRAIL_MIN_MOVE_M) {
            hist.push({ lat, lon, ts: nowTs });
        } else {
            // If we didn’t move enough, still refresh timestamp to prevent “stale” tail decisions later.
            last.ts = nowTs;
        }
    }
    if (hist.length > VESSEL_TRAIL_KEEP_POINTS) hist.splice(0, hist.length - VESSEL_TRAIL_KEEP_POINTS);

    const pl = getOrCreatePolyline(vesselTrails, mmsi, {
        color: '#4fc3f7',
        weight: 2,
        opacity: 0.65,
        dashArray: '4 8',
        lineCap: 'round',
        interactive: false,
    });
    pl.setLatLngs(hist.map(p => [p.lat, p.lon]));
}

function updateVesselVector(mmsi, lat, lon, courseDeg, speedKn, rotDegPerMin) {
    const path = computePredictPath(lat, lon, courseDeg, speedKn, rotDegPerMin);
    if (!path) {
        const pl = vesselVectors.get(mmsi);
        if (pl) pl.setLatLngs([]);
        return;
    }
    const pl = getOrCreatePolyline(vesselVectors, mmsi, {
        color: '#d2ff1a',
        weight: 2,
        opacity: 0.9,
        dashArray: null,
        lineCap: 'round',
        interactive: false,
    });
    pl.setLatLngs(path);
}

// ===================== Vessel detail overlays (true size hull + antenna point) =====================
const VESSEL_DETAIL_ZOOM_MIN = 15;
const vesselLastData = new Map(); // mmsi -> last vessel object
const vesselHulls = new Map(); // mmsi -> L.Polygon
const vesselAntennas = new Map(); // mmsi -> L.CircleMarker

function _numOrNull(v) {
    if (v == null) return null;
    const n = (typeof v === 'number') ? v : parseFloat(v);
    return Number.isFinite(n) ? n : null;
}

function _dimsFromVessel(v) {
    const toBow = _numOrNull(v.to_bow);
    const toStern = _numOrNull(v.to_stern);
    const toPort = _numOrNull(v.to_port);
    const toStar = _numOrNull(v.to_starboard);
    if (![toBow, toStern, toPort, toStar].every(x => x != null && x > 0)) return null;
    const lengthM = toBow + toStern;
    const beamM = toPort + toStar;
    if (!(lengthM > 0 && beamM > 0)) return null;
    return { toBow, toStern, toPort, toStar, lengthM, beamM };
}

function _bearingForHull(v) {
    const h = _numOrNull(v.heading);
    const c = _numOrNull(v.course);
    const s = _numOrNull(v.speed);
    // Smart: when moving, COG is what user expects for rotation; when slow/stationary, heading is better.
    const moving = s != null && !isNaN(s) && s >= 1.5;
    const brg = moving ? (c != null ? c : h) : (h != null ? h : c);
    return normalizeDeg(brg != null ? brg : null);
}

function _centerlineFromAntenna(lat, lon, bearingDeg, dims) {
    // Antenna can be offset from centerline. Positive is starboard.
    const offStarM = (dims.toStar - dims.toPort) / 2;
    return destPointMeters(lat, lon, normalizeDeg(bearingDeg + 90), offStarM);
}

function _centerFromAntenna(lat, lon, bearingDeg, dims) {
    // Center of hull relative to antenna: account for both longitudinal and lateral offset.
    const cl = _centerlineFromAntenna(lat, lon, bearingDeg, dims);
    const offFwdM = (dims.toBow - dims.toStern) / 2;
    return destPointMeters(cl.lat, cl.lon, bearingDeg, offFwdM);
}

function _hullPolygonFromAntenna(lat, lon, bearingDeg, dims, vesselClass) {
    // AIS position is the GNSS antenna reference point.
    // Build a simple "ship" polygon (with bow) in meters around that point.
    const cl = _centerlineFromAntenna(lat, lon, bearingDeg, dims);
    const halfBeam = (dims.toPort + dims.toStar) / 2;
    const bowTip = destPointMeters(cl.lat, cl.lon, bearingDeg, dims.toBow);
    const sternC = destPointMeters(cl.lat, cl.lon, normalizeDeg(bearingDeg + 180), dims.toStern);
    const portBear = normalizeDeg(bearingDeg - 90);
    const starBear = normalizeDeg(bearingDeg + 90);

    // For Class B at high zoom: simple triangle (tip + stern corners).
    if (String(vesselClass || '').toUpperCase() === 'B') {
        const sternStar = destPointMeters(sternC.lat, sternC.lon, starBear, halfBeam);
        const sternPort = destPointMeters(sternC.lat, sternC.lon, portBear, halfBeam);
        return [
            [bowTip.lat, bowTip.lon],
            [sternStar.lat, sternStar.lon],
            [sternPort.lat, sternPort.lon],
        ];
    }

    // Bow shoulders: slightly behind the tip to form a pentagon (nose).
    const minNose = 3.0;
    const maxNose = Math.min(dims.toBow * 0.9, Math.max(6.0, dims.lengthM * 0.22));
    const noseLen = Math.max(minNose, Math.min(maxNose, dims.toBow * 0.65));
    const shoulderDist = Math.max(0, dims.toBow - noseLen);
    const shoulderC = destPointMeters(cl.lat, cl.lon, bearingDeg, shoulderDist);
    const bowPort = destPointMeters(shoulderC.lat, shoulderC.lon, portBear, halfBeam);
    const bowStar = destPointMeters(shoulderC.lat, shoulderC.lon, starBear, halfBeam);
    const sternStar = destPointMeters(sternC.lat, sternC.lon, starBear, halfBeam);
    const sternPort = destPointMeters(sternC.lat, sternC.lon, portBear, halfBeam);
    return [
        [bowTip.lat, bowTip.lon],
        [bowStar.lat, bowStar.lon],
        [sternStar.lat, sternStar.lon],
        [sternPort.lat, sternPort.lon],
        [bowPort.lat, bowPort.lon],
    ];
}

function _ensureHull(mmsi, shiptype) {
    let p = vesselHulls.get(mmsi);
    if (p) return p;
    p = L.polygon([], {
        pane: 'vesselDetailPane',
        renderer: _overlayCanvasRenderer,
        color: '#000',
        weight: 2,
        opacity: 0.9,
        fill: true,
        fillColor: shiptypeToFillRgba(shiptype, null),
        fillOpacity: 0.38,
        interactive: false,
    }).addTo(map);
    vesselHulls.set(mmsi, p);
    return p;
}

function _ensureAntenna(mmsi) {
    let c = vesselAntennas.get(mmsi);
    if (c) return c;
    c = L.circleMarker([0, 0], {
        pane: 'vesselDetailPane',
        renderer: _overlayCanvasRenderer,
        radius: 3.5,
        color: '#d2ff1a',
        weight: 2,
        opacity: 1,
        fillColor: '#d2ff1a',
        fillOpacity: 0.9,
        interactive: false,
    }).addTo(map);
    vesselAntennas.set(mmsi, c);
    return c;
}

function removeVesselDetailOverlays(mmsi) {
    const h = vesselHulls.get(mmsi);
    if (h) { map.removeLayer(h); vesselHulls.delete(mmsi); }
    const a = vesselAntennas.get(mmsi);
    if (a) { map.removeLayer(a); vesselAntennas.delete(mmsi); }
}

function updateVesselDetailOverlays(v) {
    if (!v) return;
    const mmsi = v.mmsi;
    const lat = _numOrNull(v.lat);
    const lon = _numOrNull(v.lon);
    const z = map.getZoom();
    const mk = vesselMarkers.get(mmsi);
    if (lat == null || lon == null || !mk) { removeVesselDetailOverlays(mmsi); return; }

    if (z < VESSEL_DETAIL_ZOOM_MIN) {
        removeVesselDetailOverlays(mmsi);
        mk.setOpacity(1);
        return;
    }

    const dims = _dimsFromVessel(v);
    const brg = _bearingForHull(v);
    if (!dims || brg == null) {
        removeVesselDetailOverlays(mmsi);
        mk.setOpacity(1);
        return;
    }

    const poly = _hullPolygonFromAntenna(lat, lon, brg, dims, v.vessel_class);
    const hull = _ensureHull(mmsi, v.shiptype);
    hull.setLatLngs(poly);
    try { hull.setStyle({ fillColor: shiptypeToFillRgba(v.shiptype, v.nav_status) }); } catch (_) {}
    const ant = _ensureAntenna(mmsi);
    ant.setLatLng([lat, lon]);

    // Hide the simple marker when detailed overlay is visible.
    mk.setOpacity(0);
}

function _rgbaFromHex(hex, alpha) {
    const s = String(hex || '').replace('#', '');
    const r = parseInt(s.slice(0, 2), 16);
    const g = parseInt(s.slice(2, 4), 16);
    const b = parseInt(s.slice(4, 6), 16);
    return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')';
}

function vesselDisplayStyle(shiptype, navStatus) {
    const c = parseInt(shiptype, 10);
    const ns = parseInt(navStatus, 10);
    const anchored = ns === 1 || ns === 5;
    const restricted = ns === 3;
    let color = '#9aa0a6';
    let stroke = '#111111';
    let label = 'Не указан';
    if (restricted) {
        color = '#ff3045';
        stroke = '#111111';
        label = 'Ограниченная маневренность';
    } else if (!isNaN(c)) {
        if (c === 30) { color = '#ff3045'; label = 'Рыболовное'; }
        else if (c === 37) { color = '#d000ff'; label = 'Прогулочное'; }
        else if (c >= 40 && c <= 49) { color = '#ffd31a'; stroke = '#7a5a00'; label = 'Высокоскоростное'; }
        else if (c >= 50 && c <= 59) { color = '#00d7df'; stroke = '#005a60'; label = 'Буксир / спецсудно'; }
        else if (c >= 60 && c <= 69) { color = '#3026df'; label = 'Пассажирское'; }
        else if (c >= 70 && c <= 79) { color = '#19a64a'; label = 'Грузовое'; }
        else if (c >= 80 && c <= 89) { color = '#e60018'; label = 'Танкер'; }
        else if (c >= 90 && c <= 99) { color = '#f7f7f7'; stroke = '#111111'; label = 'Прочее'; }
        else if (c >= 20 && c <= 29) { color = '#f7f7f7'; stroke = '#111111'; label = 'WIG / экраноплан'; }
        else if (c >= 1 && c <= 19) { color = '#f7f7f7'; stroke = '#111111'; label = 'Зарезервировано'; }
        else if (c >= 31 && c <= 36) { color = '#f7f7f7'; stroke = '#111111'; label = 'Тип 31-36'; }
        else if (c >= 38 && c <= 39) { color = '#f7f7f7'; stroke = '#111111'; label = 'Зарезервировано'; }
        else if (c >= 100) { color = '#f7f7f7'; stroke = '#111111'; label = 'Тип 100+'; }
    }
    return { color, stroke, anchored, restricted, label };
}

/** Полупрозрачная заливка по OpenCPN-подобным цветам ship type. */
function shiptypeToFillRgba(shiptype, navStatus) {
    return _rgbaFromHex(vesselDisplayStyle(shiptype, navStatus).color, 0.38);
}

const _vesselSvgVB = { A: '0 0 88.5 152.73', B: '0 0 91.38 162.6' };
const _vesselSvgPoly = {
    A: '44.25 6.77 4.5 51.46 4.5 148.23 84 148.23 84 51.46 44.25 6.77',
    B: '45.69 16.63 5.94 158.1 85.44 158.1 45.69 16.63',
};
const vesselDivIconCache = new Map();

function vesselIconTintKey(vesselClass, shiptype, navStatus) {
    const st = shiptype != null && shiptype !== '' ? String(shiptype) : '0';
    const ns = navStatus != null && navStatus !== '' ? String(navStatus) : '';
    return (vesselClass === 'A' ? 'A' : 'B') + ':' + st + ':' + ns;
}

function getVesselDivIcon(vesselClass, shiptype, navStatus) {
    const key = vesselIconTintKey(vesselClass, shiptype, navStatus);
    let icon = vesselDivIconCache.get(key);
    if (icon) return icon;
    const isA = vesselClass === 'A';
    const vb = isA ? _vesselSvgVB.A : _vesselSvgVB.B;
    const poly = isA ? _vesselSvgPoly.A : _vesselSvgPoly.B;
    const style = vesselDisplayStyle(shiptype, navStatus);
    if (style.restricted) {
        const html = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18" width="18" height="18" style="display:block" aria-hidden="true">' +
            '<circle cx="9" cy="9" r="7" fill="' + style.color + '" stroke="' + style.stroke + '" stroke-width="1.4"/>' +
            '</svg>';
        icon = L.divIcon({ html, iconSize: [18, 18], iconAnchor: [9, 9], popupAnchor: [0, -9], className: 'vessel-icon vessel-icon--svg vessel-icon--status' });
    } else if (style.anchored) {
        const html = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18" width="18" height="18" style="display:block" aria-hidden="true">' +
            '<path d="M9 1.7 16.3 9 9 16.3 1.7 9Z" fill="' + style.color + '" stroke="' + style.stroke + '" stroke-width="1.4"/>' +
            '<circle cx="9" cy="9" r="2" fill="rgba(255,255,255,.65)" stroke="' + style.stroke + '" stroke-width=".6"/>' +
            '</svg>';
        icon = L.divIcon({ html, iconSize: [18, 18], iconAnchor: [9, 9], popupAnchor: [0, -9], className: 'vessel-icon vessel-icon--svg vessel-icon--anchored' });
    } else {
        const html = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="' + vb + '" width="' + iconWidth + '" height="' + iconHeight + '" style="display:block" aria-hidden="true">' +
            '<polygon points="' + poly + '" fill="' + style.color + '" stroke="' + style.stroke + '" stroke-width="9" stroke-linejoin="round"/>' +
            '</svg>';
        icon = L.divIcon({
            html,
            iconSize: [iconWidth, iconHeight],
            iconAnchor: [iconAnchorX, iconAnchorY],
            popupAnchor: [0, -iconHeight],
            className: 'vessel-icon vessel-icon--svg',
        });
    }
    vesselDivIconCache.set(key, icon);
    return icon;
}

function setIconRotation(marker, heading) {
    if (!marker) return;
    try { _ensureAisMarkerPane(marker); } catch (e) {}
    // Store "true" heading for redraws.
    marker._headingDeg = (heading != null && !isNaN(heading)) ? normalizeDeg(heading) : null;

    // Prefer leaflet-rotate's native per-marker rotation (radians).
    // This avoids CSS-transform stacking issues during zoom/rotate.
    try {
        if (map && typeof map.getBearing === 'function' && marker.options) {
            marker.options.rotateWithView = false;
            const deg = (marker._headingDeg == null ? 0 : marker._headingDeg);
            const headingRad = deg * Math.PI / 180;
            // Markers are in `norotatePane` (do not rotate with the map), so keeping absolute heading
            // relative to true north is simply "rotation = heading".
            if (typeof marker.setRotation === 'function') marker.setRotation(headingRad);
            else {
                marker.options.rotation = headingRad;
                if (typeof marker.update === 'function') marker.update();
            }

            // If this marker was previously patched by our CSS-rotate fallback, undo it.
            const el = marker._icon;
            if (marker._origSetPos) {
                try { marker._setPos = marker._origSetPos; } catch (e) {}
                try { delete marker._origSetPos; } catch (e) {}
            }
            if (el && el.dataset && el.dataset.trSet) {
                try { delete el.dataset.trSet; } catch (e) {}
            }
            marker._rotationHeading = null;
            return;
        }
    } catch (e) {}

    // Fallback (no rotate plugin): manual CSS rotation for the icon.
    const el = marker._icon;
    if (!el) return;
    marker._rotationHeading = marker._headingDeg;
    if (!el.dataset.trSet) {
        el.style.transformOrigin = `${iconAnchorX}px ${iconAnchorY}px`;
        el.dataset.trSet = '1';
        if (!marker._origSetPos) {
            marker._origSetPos = marker._setPos;
            marker._setPos = function(pos) {
                this._origSetPos.call(this, pos);
                const ic = this._icon;
                if (ic && this._rotationHeading != null) {
                    const m = (ic.style.transform||'').match(/translate3d\([^)]+\)/);
                    if (m) ic.style.transform = m[0]+' rotate('+this._rotationHeading+'deg)';
                }
            };
        }
    }
    const t = (el.style.transform||'').match(/translate3d\([^)]+\)/);
    if (t) el.style.transform = marker._rotationHeading != null ? t[0]+' rotate('+marker._rotationHeading+'deg)' : t[0];
    else if (marker._rotationHeading != null) el.style.transform = 'rotate('+marker._rotationHeading+'deg)';
}

function _clampInt(v, minV, maxV, defV) {
    const n = parseInt(v, 10);
    if (!isFinite(n) || isNaN(n)) return defV;
    return Math.max(minV, Math.min(maxV, n));
}

function _formatMinutesPreview(mins) {
    const m = _clampInt(mins, 1, 24 * 60, 1);
    if (m < 60) return m + ' мин';
    const h = Math.floor(m / 60);
    const mm = m % 60;
    return h + ' ч' + (mm ? (' ' + mm + ' мин') : '');
}

function getLosingTargetMinutes() {
    return _clampInt(sGet('losingTargetMin', '7'), 1, 24 * 60, 7);
}
function getRemoveTargetMinutes() {
    return _clampInt(sGet('removeTargetMin', '10'), 1, 24 * 60, 10);
}

let LOSING_TARGET_TIME = getLosingTargetMinutes() * 60;
let REMOVE_TARGET_TIME = getRemoveTargetMinutes() * 60;

function _isTargetExpiredByTimestamp(item, now) {
    return !!(item && item.timestamp && (now - item.timestamp) >= REMOVE_TARGET_TIME);
}

function applyTargetTimingFromSettings() {
    let losingMin = getLosingTargetMinutes();
    let removeMin = getRemoveTargetMinutes();

    // Ensure ordering: "losing" should be earlier than "remove"
    if (removeMin <= losingMin) removeMin = losingMin + 1;

    LOSING_TARGET_TIME = losingMin * 60;
    REMOVE_TARGET_TIME = removeMin * 60;

    const losingInput = document.getElementById('set-losing-target-min');
    const removeInput = document.getElementById('set-remove-target-min');
    const losingPrev = document.getElementById('set-losing-target-preview');
    const removePrev = document.getElementById('set-remove-target-preview');

    if (losingInput) losingInput.value = String(losingMin);
    if (removeInput) removeInput.value = String(removeMin);
    if (losingPrev) losingPrev.textContent = _formatMinutesPreview(losingMin);
    if (removePrev) removePrev.textContent = _formatMinutesPreview(removeMin);
}

function initTargetTimingSettingsUi() {
    const losingInput = document.getElementById('set-losing-target-min');
    const removeInput = document.getElementById('set-remove-target-min');
    if (!losingInput || !removeInput) return;

    const save = () => {
        const losingMin = _clampInt(losingInput.value, 1, 24 * 60, 7);
        const removeMinRaw = _clampInt(removeInput.value, 1, 24 * 60, 10);
        const removeMin = Math.max(removeMinRaw, losingMin + 1);
        sSet('losingTargetMin', String(losingMin));
        sSet('removeTargetMin', String(removeMin));
        applyTargetTimingFromSettings();
    };

    losingInput.addEventListener('change', save);
    losingInput.addEventListener('input', () => {
        const prev = document.getElementById('set-losing-target-preview');
        if (prev) prev.textContent = _formatMinutesPreview(losingInput.value);
    });
    removeInput.addEventListener('change', save);
    removeInput.addEventListener('input', () => {
        const prev = document.getElementById('set-remove-target-preview');
        if (prev) prev.textContent = _formatMinutesPreview(removeInput.value);
    });

    applyTargetTimingFromSettings();
}

try {
    initTargetTimingSettingsUi();
    if (document && document.addEventListener) {
        document.addEventListener('DOMContentLoaded', () => {
            try { initTargetTimingSettingsUi(); } catch (e) {}
        });
    }
} catch (e) {}

// ===================== Distance & range filter =====================
const RANGE_STEPS_NM = [0.1,0.2,0.3,0.5,0.7,1,1.5,2,3,5,7,10,15,20,30,50,70,100,150,200,300,500];
const NM_TO_KM = 1.852;
const NM_TO_AU = 1.852 / 1.496e8;
const KN_TO_KMH = 1.852;
let distUnit = sGet('distUnit', 'nm');
let speedUnit = sGet('speedUnit', 'kn');
let rangeIdx = parseInt(sGet('rangeIdx', String(RANGE_STEPS_NM.length)), 10);
let rangeCircle = null;
let warnRadiusNm = parseFloat(sGet('warnRadiusNm', '0')) || 0;
let nearRadiusNm = parseFloat(sGet('nearRadiusNm', '0')) || 0;
let warnCircle = null;
let nearCircle = null;
let _dangerState = { any: false, count: 0, minNm: null };

function haversineNM(lat1, lon1, lat2, lon2) {
    const R = 3440.065;
    const dLat = (lat2 - lat1) * Math.PI / 180;
    const dLon = (lon2 - lon1) * Math.PI / 180;
    const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*Math.sin(dLon/2)**2;
    return 2 * R * Math.asin(Math.sqrt(a));
}

function getOwnShipPos() {
    const data = ownShipSource === 'phone' ? phoneGps : nmeaGps;
    if (data && data.lat != null && data.lon != null) return data;
    return null;
}

function getRangeNM() {
    return rangeIdx >= RANGE_STEPS_NM.length ? Infinity : RANGE_STEPS_NM[rangeIdx];
}

function fmtDist(nm) {
    if (distUnit === 'au') {
        const au = nm * NM_TO_AU;
        return au.toExponential(2) + ' AU';
    }
    if (distUnit === 'km') {
        const km = nm * NM_TO_KM;
        return km < 10 ? km.toFixed(2) + ' км' : km.toFixed(1) + ' км';
    }
    return nm < 10 ? nm.toFixed(2) + ' NM' : nm.toFixed(1) + ' NM';
}

function bearingDeg(lat1, lon1, lat2, lon2) {
    // Initial bearing from point 1 to point 2 (degrees from North, 0..360)
    const φ1 = lat1 * Math.PI / 180, φ2 = lat2 * Math.PI / 180;
    const Δλ = (lon2 - lon1) * Math.PI / 180;
    const y = Math.sin(Δλ) * Math.cos(φ2);
    const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
    const θ = Math.atan2(y, x) * 180 / Math.PI;
    return normalizeDeg(θ);
}

function relBearingSignedDeg(ownHeadingDeg, absBearingDeg) {
    const h = normalizeDeg(ownHeadingDeg);
    const b = normalizeDeg(absBearingDeg);
    if (h == null || b == null) return null;
    let d = b - h;
    if (d > 180) d -= 360;
    if (d < -180) d += 360;
    return d; // negative = left, positive = right
}

function uiUnitName() {
    return distUnit === 'km' ? 'км' : 'NM';
}
function nmToUi(nm) {
    return distUnit === 'km' ? (nm * NM_TO_KM) : nm;
}
function uiToNm(v) {
    return distUnit === 'km' ? (v / NM_TO_KM) : v;
}

function fmtRange(nm) {
    if (!isFinite(nm)) return '\u221e';
    if (distUnit === 'au') {
        const au = nm * NM_TO_AU;
        return au.toExponential(2) + ' AU';
    }
    if (distUnit === 'km') {
        const km = nm * NM_TO_KM;
        return (km < 1 ? km.toFixed(2) : km < 10 ? km.toFixed(1) : Math.round(km)) + ' км';
    }
    return (nm < 1 ? nm.toFixed(1) : nm < 10 ? nm.toFixed(1) : Math.round(nm)) + ' NM';
}

function fmtSpeed(knots) {
    if (speedUnit === 'kmh') return (knots * KN_TO_KMH).toFixed(1) + ' км/ч';
    return knots.toFixed(1) + ' уз.';
}

function updateRangeCircle() {
    const os = getOwnShipPos();
    const r = getRangeNM();
    if (!os || !isFinite(r)) {
        if (rangeCircle) { map.removeLayer(rangeCircle); rangeCircle = null; }
        return;
    }
    const meters = r * 1852;
    if (!rangeCircle) {
        rangeCircle = L.circle([os.lat, os.lon], {
            radius: meters, color: '#d2ff1a', weight: 1.5,
            fillColor: '#d2ff1a', fillOpacity: 0.04, dashArray: '6,4', interactive: false
        }).addTo(map);
    } else {
        rangeCircle.setLatLng([os.lat, os.lon]);
        rangeCircle.setRadius(meters);
    }
}

function updateDangerCircles() {
    const os = getOwnShipPos();
    if (!os || os.lat == null || os.lon == null) {
        if (warnCircle) { map.removeLayer(warnCircle); warnCircle = null; }
        if (nearCircle) { map.removeLayer(nearCircle); nearCircle = null; }
        return;
    }

    if (warnRadiusNm > 0) {
        const meters = warnRadiusNm * 1852;
        if (!warnCircle) {
            warnCircle = L.circle([os.lat, os.lon], {
                radius: meters,
                color: '#f85149',
                weight: 1.8,
                opacity: 0.9,
                fillColor: '#f85149',
                fillOpacity: 0.03,
                dashArray: '8,6',
                interactive: false
            }).addTo(map);
        } else {
            warnCircle.setLatLng([os.lat, os.lon]);
            warnCircle.setRadius(meters);
        }
    } else if (warnCircle) { map.removeLayer(warnCircle); warnCircle = null; }

    if (nearRadiusNm > 0) {
        const meters = nearRadiusNm * 1852;
        if (!nearCircle) {
            nearCircle = L.circle([os.lat, os.lon], {
                radius: meters,
                color: '#f0883e',
                weight: 1.5,
                opacity: 0.8,
                fillColor: '#f0883e',
                fillOpacity: 0.02,
                dashArray: '4,8',
                interactive: false
            }).addTo(map);
        } else {
            nearCircle.setLatLng([os.lat, os.lon]);
            nearCircle.setRadius(meters);
        }
    } else if (nearCircle) { map.removeLayer(nearCircle); nearCircle = null; }
}

function updateDangerBanner() {
    const el = document.getElementById('danger-banner');
    if (!el) return;
    if (warnRadiusNm > 0 && _dangerState.any) {
        const rel = _dangerState.relDegSigned;
        const relTxt = (rel == null) ? '' : (rel < 0 ? (' ← ' + Math.round(Math.abs(rel)) + '°') : (' ' + Math.round(Math.abs(rel)) + '° →'));
        const distTxt = _dangerState.minNm != null ? fmtDist(_dangerState.minNm) : '';
        el.textContent = 'ВНИМАНИЕ' + (_dangerState.count > 0 ? ' (' + _dangerState.count + (relTxt ? ',' + relTxt : '') + (distTxt ? ', ' + distTxt : '') + ')' : '');
        el.style.display = '';
    } else {
        el.style.display = 'none';
    }
}

(function initRangeSlider() {
    const slider = document.getElementById('range-slider');
    const label = document.getElementById('range-value');
    slider.max = RANGE_STEPS_NM.length;
    slider.value = rangeIdx;
    label.textContent = fmtRange(getRangeNM());
    slider.addEventListener('input', () => {
        rangeIdx = parseInt(slider.value, 10);
        sSet('rangeIdx', rangeIdx);
        label.textContent = fmtRange(getRangeNM());
        updateRangeCircle();
    });
})();
const olSize = Math.max(iconWidth, iconHeight)*1.5, olAnchor = olSize/2;
const iconChosen = L.icon({ iconUrl:'/svg/ChosenTarget.svg', iconSize:[olSize,olSize], iconAnchor:[olAnchor,olAnchor], className:'vessel-overlay-icon' });
const iconLosing = L.divIcon({
    html: '<span class="vessel-lost-dot" aria-hidden="true"></span>',
    iconSize: [18, 18],
    iconAnchor: [9, 9],
    className: 'vessel-overlay-icon vessel-overlay-lost',
});
const vesselOverlays = new Map();
let selectedMmsi = null;

// ===================== Vessel InfoWindow (MarineTraffic-style) =====================
// Replaces the plain Leaflet popup used previously.
// - Desktop: floating card, draggable by its header, anchored inside #map page.
// - Mobile : full-width bottom-sheet above the mobile panel bar, swipe-down closes it.
// - Stays on screen when the map pans/zooms (no snapping to marker after the user
//   dragged it), but the content auto-updates when fresh AIS data arrives.
const AIS_NAV_STATUS_LABEL = {
    0: 'На ходу (двигатель)', 1: 'На якоре', 2: 'Не под командованием',
    3: 'Огранич. манёвренность', 4: 'Огранич. осадкой', 5: 'Пришвартован',
    6: 'На мели', 7: 'Рыболовство', 8: 'Под парусом',
    9: 'Рез. HSC', 10: 'Рез. WIG', 11: 'Буксир за кормой',
    12: 'Буксир спереди', 13: 'Резерв', 14: 'AIS-SART / MOB', 15: 'Не определён',
};
function _navStatusLabel(c) {
    if (c == null || c === '') return null;
    const n = parseInt(c, 10);
    if (isNaN(n)) return null;
    return AIS_NAV_STATUS_LABEL[n] || ('Код ' + n);
}
function _signalDbToBars(db) {
    if (db == null) return 0;
    if (db >= -50) return 4;
    if (db >= -60) return 3;
    if (db >= -70) return 2;
    if (db >= -80) return 1;
    return 0;
}
function _vesselHeaderIconSvg(vesselClass, shiptype) {
    const isA = vesselClass === 'A';
    const vb = isA ? _vesselSvgVB.A : _vesselSvgVB.B;
    const poly = isA ? _vesselSvgPoly.A : _vesselSvgPoly.B;
    const fill = shiptypeToFillRgba(shiptype, null);
    return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="' + vb + '" width="26" height="26" aria-hidden="true">' +
        '<polygon points="' + poly + '" fill="' + fill + '" stroke="#e0e0e0" stroke-width="8" stroke-linejoin="round"/>' +
        '</svg>';
}

const VesselInfoWindow = (function () {
    let rootEl = null;
    let openMmsi = null;
    let userPositioned = false;
    let _drag = null;
    let isCollapsed = false;

    function el() {
        if (rootEl) return rootEl;
        rootEl = document.getElementById('vessel-infowindow');
        return rootEl;
    }
    function isMobile() {
        try {
            return window.innerWidth <= 600 ||
                (window.matchMedia && window.matchMedia('(max-height:520px) and (pointer:coarse)').matches);
        } catch (e) { return false; }
    }

    function _bars(bars) {
        const heights = [5, 8, 11, 14];
        const cls = bars >= 3 ? 'on' : (bars === 2 ? 'warn' : (bars >= 1 ? 'bad' : ''));
        let h = '<span class="vinf-signal-bars">';
        for (let i = 0; i < 4; i++) {
            const active = i < bars;
            h += '<span class="vinf-signal-bar ' + (active ? cls : '') + '" style="height:' + heights[i] + 'px"></span>';
        }
        h += '</span>';
        return h;
    }

    function _targetHeaderIconSvg(v) {
        if (v && (v.vessel_class === 'BS' || v.kind === 'base_station')) {
            return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="26" height="26" aria-hidden="true">' +
                '<path d="M16 6v20" stroke="#c9d1d9" stroke-width="2.2" stroke-linecap="round"/>' +
                '<path d="M10 26h12M12 14h8M13 26l3-12 3 12" stroke="#c9d1d9" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>' +
                '<path d="M9 10a10 10 0 0 1 14 0M5 6a16 16 0 0 1 22 0" fill="none" stroke="#4fc3f7" stroke-width="1.8" stroke-linecap="round"/>' +
                '</svg>';
        }
        if (v && (v.vessel_class === 'N' || v.kind === 'buoy')) {
            return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="26" height="26" aria-hidden="true">' +
                '<path d="M16 4l4 7h-8l4-7Z" fill="#ffd166" stroke="#c9d1d9" stroke-width="1.4" stroke-linejoin="round"/>' +
                '<path d="M11 12h10l3 12H8l3-12Z" fill="#f85149" stroke="#c9d1d9" stroke-width="1.6" stroke-linejoin="round"/>' +
                '<path d="M9 19h14M5 27c2.4-1.2 4.6-1.2 7 0 2.4 1.2 4.6 1.2 7 0 2.4-1.2 4.6-1.2 7 0" fill="none" stroke="#4fc3f7" stroke-width="1.6" stroke-linecap="round"/>' +
                '</svg>';
        }
        return _vesselHeaderIconSvg(v ? v.vessel_class : null, v ? v.shiptype : null);
    }

    function _dimsLabel(v) {
        const toBow = (typeof v.to_bow === 'number' && isFinite(v.to_bow)) ? v.to_bow : parseInt(v.to_bow, 10);
        const toStern = (typeof v.to_stern === 'number' && isFinite(v.to_stern)) ? v.to_stern : parseInt(v.to_stern, 10);
        const toPort = (typeof v.to_port === 'number' && isFinite(v.to_port)) ? v.to_port : parseInt(v.to_port, 10);
        const toStar = (typeof v.to_starboard === 'number' && isFinite(v.to_starboard)) ? v.to_starboard : parseInt(v.to_starboard, 10);
        const lenM = (isFinite(toBow) ? Math.max(0, toBow) : 0) + (isFinite(toStern) ? Math.max(0, toStern) : 0);
        const beamM = (isFinite(toPort) ? Math.max(0, toPort) : 0) + (isFinite(toStar) ? Math.max(0, toStar) : 0);
        return (lenM > 0 && beamM > 0) ? (lenM + '×' + beamM + ' м') : null;
    }

    function _buildStaticHtml(v) {
        const isBase = v.vessel_class === 'BS' || v.kind === 'base_station';
        const isAton = v.vessel_class === 'N' || v.kind === 'buoy';
        const iconHtml = _targetHeaderIconSvg(v);
        const iso2 = mmsiToIso2FromMid(v.mmsi);
        const flag = iso2ToFlagEmoji(iso2);
        const typeLabel = isAton ? (v.aton_type_label || atonTypeLabel(v.aton_type) || 'Тип СНО не указан') : 'Базовая станция AIS';
        const modeLabel = _aidModeLabel(v);
        const clsText = isBase ? 'Базовая станция' : 'СНО / буй';
        const title = isBase ? 'Базовая станция' : (v.shipname || v.name || 'Буёк / СНО');

        const os = getOwnShipPos();
        let bearingCell = '';
        if (os && os.lat != null && os.lon != null && v.lat != null && v.lon != null) {
            const brg = bearingDeg(os.lat, os.lon, v.lat, v.lon);
            const dist = v._distNM != null ? v._distNM : haversineNM(os.lat, os.lon, v.lat, v.lon);
            bearingCell = '<div class="vinf-cell">' +
                '<div class="vinf-cell-lbl">Пеленг / Дальность</div>' +
                '<div class="vinf-cell-val">' + brg.toFixed(0) + '° / ' + escHtml(fmtDist(dist)) + '</div></div>';
        }

        const coordsTxt = (v.lat != null && v.lon != null)
            ? Number(v.lat).toFixed(5) + ', ' + Number(v.lon).toFixed(5)
            : '—';
        const dims = _dimsLabel(v);
        const ageTxt = v.timestamp ? escHtml(fmtAgo(v.timestamp)) + ' назад' : '—';
        const miniDst = v._distNM != null ? escHtml(fmtDist(v._distNM)) : null;
        const idsParts = ['<span><b>MMSI:</b> ' + escHtml(v.mmsi) + '</span>'];
        if (v.lat != null && v.lon != null) idsParts.push('<span><b>Координаты:</b> ' + escHtml(coordsTxt) + '</span>');
        if (isAton) idsParts.push('<span><b>СНО:</b> ' + escHtml(modeLabel) + '</span>');
        else idsParts.push('<span><b>Станция:</b> ' + escHtml(modeLabel) + '</span>');
        if (isAton && v.off_position) idsParts.push('<span><b>Положение:</b> вне штатной позиции</span>');
        if (dims) idsParts.push('<span><b>Размер:</b> ' + escHtml(dims) + '</span>');

        return '' +
        '<div class="vinf-grip" data-drag-handle aria-hidden="true"></div>' +
        '<div class="vinf-header" data-drag-handle>' +
          '<div class="vinf-icon">' + iconHtml + '</div>' +
          '<div class="vinf-title">' +
            '<div class="vinf-name" title="' + escHtml(title) + '">' +
              (flag ? '<span class="vinf-flag" title="' + escHtml(iso2 || '') + '">' + flag + '</span> ' : '') +
              escHtml(title) +
            '</div>' +
            '<div class="vinf-sub">' + escHtml(typeLabel) + ' · ' + escHtml(clsText) + '</div>' +
            '<div class="vinf-mini">' +
              (miniDst ? '<span title="Дальность до цели от своего судна">ДАЛЬН <b>' + miniDst + '</b></span>' : '') +
              '<span title="Возраст последнего AIS-сообщения">ВОЗР <b>' + ageTxt + '</b></span>' +
            '</div>' +
          '</div>' +
          '<button type="button" class="vinf-btn vinf-btn-collapse" data-act="collapse" aria-label="Свернуть" title="Свернуть / развернуть">' +
            '<svg viewBox="0 0 24 24"><path fill="currentColor" class="vinf-ic-expand" d="M7.41 8.59 12 13.17l4.59-4.58L18 10l-6 6-6-6z"/><path fill="currentColor" class="vinf-ic-collapse" d="M7.41 15.41 12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>' +
          '</button>' +
          '<button type="button" class="vinf-btn" data-act="close" aria-label="Закрыть" title="Закрыть">' +
            '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>' +
          '</button>' +
        '</div>' +
        '<div class="vinf-body">' +
          '<div class="vinf-voyage">' +
            '<div class="vinf-port"><span class="vinf-port-country">Объект</span> ' + escHtml(clsText) + '</div>' +
            '<div class="vinf-voyage-arrow">›</div>' +
            '<div class="vinf-port vinf-port--right">' + escHtml(typeLabel) + '</div>' +
            '<div class="vinf-eta">' +
              '<span><b>Обновлено:</b> ' + ageTxt + '</span>' +
              '<span><b>Источник:</b> AIS</span>' +
            '</div>' +
          '</div>' +
          '<div class="vinf-grid">' +
            '<div class="vinf-cell"><div class="vinf-cell-lbl">' + (isAton ? 'Тип СНО' : 'Тип объекта') + '</div><div class="vinf-cell-val vinf-cell-val--wrap">' + escHtml(typeLabel) + '</div></div>' +
            '<div class="vinf-cell"><div class="vinf-cell-lbl">Координаты</div><div class="vinf-cell-val vinf-cell-val--wrap">' + escHtml(coordsTxt) + '</div></div>' +
            '<div class="vinf-cell"><div class="vinf-cell-lbl">' + (isAton ? 'Флаг СНО' : 'Флаг станции') + '</div><div class="vinf-cell-val vinf-cell-val--wrap">' + escHtml(modeLabel) + '</div></div>' +
            (isAton && v.off_position ? '<div class="vinf-cell"><div class="vinf-cell-lbl">Положение</div><div class="vinf-cell-val">Вне позиции</div></div>' : '') +
            (dims ? '<div class="vinf-cell"><div class="vinf-cell-lbl">Размер</div><div class="vinf-cell-val">' + escHtml(dims) + '</div></div>' : '') +
            bearingCell +
          '</div>' +
          '<div class="vinf-ids">' + idsParts.join('') + '</div>' +
          '<div class="vinf-actions">' +
            '<button type="button" class="vinf-act primary" data-act="center" title="Центрировать карту на цели">' +
              '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm9 3h-2.07A7 7 0 0 0 13 5.07V3h-2v2.07A7 7 0 0 0 5.07 11H3v2h2.07A7 7 0 0 0 11 18.93V21h2v-2.07A7 7 0 0 0 18.93 13H21v-2Z"/></svg>' +
              'Центрировать' +
            '</button>' +
            '<button type="button" class="vinf-act" data-act="ruler" title="Рулетка: ваше судно → эта цель">' +
              '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path stroke-linejoin="round" d="M2 15 L15 2 L22 9 L9 22 Z"/><path d="M6 16l2-2 M9 18l2-2 M12 20l2-2 M11 11l2-2 M14 13l2-2"/></svg>' +
              'Расстояние' +
            '</button>' +
            '<button type="button" class="vinf-act" data-act="copy" title="Скопировать координаты">' +
              '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1Zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2Zm0 16H8V7h11v14Z"/></svg>' +
              'Копировать' +
            '</button>' +
          '</div>' +
        '</div>' +
        '<div class="vinf-footer">' +
          (v.timestamp ? '<span>Получено: <b>' + escHtml(fmtTime(v.timestamp)) + '</b> (' + escHtml(fmtAgo(v.timestamp)) + ' назад)</span>' : '<span>Получено: <b>—</b></span>') +
          '<span>Источник: <b>' + (isAton ? 'AIS СНО' : 'AIS базовая станция') + '</b></span>' +
        '</div>';
    }

    function _buildHtml(v) {
        if (v && (v.vessel_class === 'BS' || v.vessel_class === 'N' || v.kind === 'base_station' || v.kind === 'buoy')) {
            return _buildStaticHtml(v);
        }
        const iconHtml = _targetHeaderIconSvg(v);
        const iso2 = mmsiToIso2FromMid(v.mmsi);
        const flag = iso2ToFlagEmoji(iso2);
        const typeLabel = (v.shiptype != null && v.shiptype !== '')
            ? ((typeof AIS_TABLE51_SHIP_TYPE_LABEL === 'function' && AIS_TABLE51_SHIP_TYPE_LABEL(v.shiptype)) || ('Тип ' + v.shiptype))
            : 'Тип не указан';
        const clsText = v.vessel_class === 'A' ? 'Class A'
            : v.vessel_class === 'B' ? 'Class B'
            : v.vessel_class === 'BS' ? 'Базовая ст.'
            : v.vessel_class === 'N' ? 'СНО / буй'
            : (v.vessel_class || '?');

        // Bearing / distance from ownship
        const os = getOwnShipPos();
        let bearingCell = '';
        if (os && os.lat != null && os.lon != null && v.lat != null && v.lon != null) {
            const brg = bearingDeg(os.lat, os.lon, v.lat, v.lon);
            const dist = v._distNM;
            bearingCell = '<div class="vinf-cell">' +
                '<div class="vinf-cell-lbl">Пеленг / Дальность</div>' +
                '<div class="vinf-cell-val">' + brg.toFixed(0) + '° / ' +
                (dist != null ? escHtml(fmtDist(dist)) : '–') + '</div></div>';
        }

        const navLbl = _navStatusLabel(v.nav_status);
        const navVal = navLbl || '<span class="dim">нет</span>';
        const speedCourse = (v.speed != null ? escHtml(fmtSpeed(v.speed)) : '–') +
            ' / ' + (v.course != null ? v.course.toFixed(0) + '°' : '–');
        const heading = v.heading != null ? v.heading.toFixed(0) + '°'
            : '<span class="dim">нет</span>';
        const draughtRaw = v.draught != null ? Number(v.draught) : null;
        const draught = (draughtRaw != null && isFinite(draughtRaw) && draughtRaw > 0)
            ? (draughtRaw.toFixed(1) + ' м')
            : '<span class="dim">—</span>';
        const dest = v.destination && String(v.destination).trim()
            ? escHtml(String(v.destination).trim())
            : '<span class="dim">не указано</span>';
        const eta = v.eta ? escHtml(String(v.eta)) : '—';

        // Signal
        let sigBlock = '';
        if (v.signal_db != null) {
            const bars = _signalDbToBars(v.signal_db);
            const ageTxt = v.signal_ts != null ? fmtAgo(Math.floor(v.signal_ts)) : null;
            sigBlock = '<div class="vinf-signal">' + _bars(bars) +
                '<span>Сигнал: <span class="sig-db">' + Number(v.signal_db).toFixed(1) + ' дБ</span></span>' +
                (ageTxt ? '<span>(' + ageTxt + ' назад)</span>' : '') +
                '</div>';
        }

        // Identifiers
        const callsign = v.callsign ? String(v.callsign).trim() : null;
        const imo = v.imo && Number(v.imo) > 0 ? v.imo : null;
        const idsParts = ['<span><b>MMSI:</b> ' + v.mmsi + '</span>'];
        if (imo) idsParts.push('<span><b>IMO:</b> ' + imo + '</span>');
        if (callsign) idsParts.push('<span><b>Позывной:</b> ' + escHtml(callsign) + '</span>');
        if (v.lat != null && v.lon != null) {
            idsParts.push('<span><b>Lat/Lon:</b> ' + Number(v.lat).toFixed(5) + ', ' + Number(v.lon).toFixed(5) + '</span>');
        }
        if (v.rot != null && !isNaN(Number(v.rot))) {
            idsParts.push('<span><b>ROT:</b> ' + Number(v.rot).toFixed(1) + ' °/мин</span>');
        }
        const lenM = (Number(v.to_bow) || 0) + (Number(v.to_stern) || 0);
        const beamM = (Number(v.to_port) || 0) + (Number(v.to_starboard) || 0);
        if (lenM > 0 && beamM > 0) {
            idsParts.push('<span><b>Размер:</b> ' + lenM + '×' + beamM + ' м</span>');
        }

        const receivedLine = v.timestamp
            ? '<span>Получено: <b>' + escHtml(fmtTime(v.timestamp)) + '</b> (' + escHtml(fmtAgo(v.timestamp)) + ' назад)</span>'
            : '<span>Получено: <b>—</b></span>';

        // Mini stats for the collapsed state (shown instead of vinf-sub when collapsed)
        const miniSog = v.speed != null ? escHtml(fmtSpeed(v.speed)) : '—';
        const miniCog = v.course != null ? v.course.toFixed(0) + '°' : '—';
        const miniDst = v._distNM != null ? escHtml(fmtDist(v._distNM)) : null;

        return '' +
        '<div class="vinf-grip" data-drag-handle aria-hidden="true"></div>' +
        '<div class="vinf-header" data-drag-handle>' +
          '<div class="vinf-icon">' + iconHtml + '</div>' +
          '<div class="vinf-title">' +
            '<div class="vinf-name" title="' + escHtml(v.shipname || ('MMSI ' + v.mmsi)) + '">' +
              (flag ? '<span class="vinf-flag" title="' + escHtml(iso2 || '') + '">' + flag + '</span> ' : '') +
              escHtml(v.shipname || ('MMSI ' + v.mmsi)) +
            '</div>' +
            '<div class="vinf-sub">' + escHtml(typeLabel) + ' · ' + clsText + '</div>' +
            '<div class="vinf-mini">' +
              '<span title="Speed Over Ground — скорость относительно земли">SOG <b>' + miniSog + '</b></span>' +
              '<span title="Course Over Ground — курс относительно земли">COG <b>' + miniCog + '</b></span>' +
              (miniDst ? '<span title="Дальность до цели от своего судна">DST <b>' + miniDst + '</b></span>' : '') +
            '</div>' +
          '</div>' +
          '<button type="button" class="vinf-btn vinf-btn-collapse" data-act="collapse" aria-label="Свернуть" title="Свернуть / развернуть">' +
            '<svg viewBox="0 0 24 24"><path fill="currentColor" class="vinf-ic-expand" d="M7.41 8.59 12 13.17l4.59-4.58L18 10l-6 6-6-6z"/><path fill="currentColor" class="vinf-ic-collapse" d="M7.41 15.41 12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>' +
          '</button>' +
          '<button type="button" class="vinf-btn" data-act="close" aria-label="Закрыть" title="Закрыть">' +
            '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>' +
          '</button>' +
        '</div>' +
        '<div class="vinf-body">' +
          '<div class="vinf-voyage">' +
            '<div class="vinf-port"><span class="vinf-port-country">Назначение</span> ' + dest + '</div>' +
            '<div class="vinf-voyage-arrow">›</div>' +
            '<div class="vinf-port vinf-port--right">' + (eta !== '—' ? eta : '<span class="dim">—</span>') + '</div>' +
            '<div class="vinf-eta">' +
              '<span><b>Обновлено:</b> ' + (v.timestamp ? escHtml(fmtAgo(v.timestamp)) + ' назад' : '—') + '</span>' +
              '<span><b>ETA:</b> ' + eta + '</span>' +
            '</div>' +
          '</div>' +
          '<div class="vinf-grid">' +
            '<div class="vinf-cell"><div class="vinf-cell-lbl">Навигационный статус</div><div class="vinf-cell-val">' + navVal + '</div></div>' +
            '<div class="vinf-cell"><div class="vinf-cell-lbl">Скорость / Курс (COG)</div><div class="vinf-cell-val">' + speedCourse + '</div></div>' +
            '<div class="vinf-cell"><div class="vinf-cell-lbl">Направление (HDG)</div><div class="vinf-cell-val">' + heading + '</div></div>' +
            '<div class="vinf-cell"><div class="vinf-cell-lbl">Осадка</div><div class="vinf-cell-val">' + draught + '</div></div>' +
            bearingCell +
          '</div>' +
          sigBlock +
          '<div class="vinf-ids">' + idsParts.join('') + '</div>' +
          '<div class="vinf-actions">' +
            '<button type="button" class="vinf-act primary" data-act="center" title="Центрировать карту на цели">' +
              '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm9 3h-2.07A7 7 0 0 0 13 5.07V3h-2v2.07A7 7 0 0 0 5.07 11H3v2h2.07A7 7 0 0 0 11 18.93V21h2v-2.07A7 7 0 0 0 18.93 13H21v-2Z"/></svg>' +
              'Центрировать' +
            '</button>' +
            '<button type="button" class="vinf-act" data-act="ruler" title="Рулетка: ваше судно → эта цель">' +
              '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path stroke-linejoin="round" d="M2 15 L15 2 L22 9 L9 22 Z"/><path d="M6 16l2-2 M9 18l2-2 M12 20l2-2 M11 11l2-2 M14 13l2-2"/></svg>' +
              'Расстояние' +
            '</button>' +
            '<button type="button" class="vinf-act" data-act="copy" title="Скопировать координаты">' +
              '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1Zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2Zm0 16H8V7h11v14Z"/></svg>' +
              'Копировать' +
            '</button>' +
          '</div>' +
        '</div>' +
        '<div class="vinf-footer">' +
          receivedLine +
          '<span>Источник: <b>AIS</b></span>' +
        '</div>';
    }

    function _positionNearMarker() {
        const root = el();
        if (!root || openMmsi == null) return;
        if (isMobile()) {
            root.style.left = '';
            root.style.top = '';
            root.style.right = '';
            root.style.bottom = '';
            return;
        }
        try {
            const mk = _markerForTargetMmsi(openMmsi);
            if (!mk) return;
            const pt = map.latLngToContainerPoint(mk.getLatLng());
            const mapRect = map.getContainer().getBoundingClientRect();
            const w = root.offsetWidth || 360;
            const h = root.offsetHeight || 280;
            const margin = 12;
            let x = pt.x + 28;
            if (x + w + margin > mapRect.width) x = pt.x - w - 28;
            if (x < margin) x = margin;
            let y = pt.y - h / 2;
            if (y < 56) y = 56;
            if (y + h > mapRect.height - margin) y = Math.max(margin, mapRect.height - margin - h);
            root.style.left = Math.round(x) + 'px';
            root.style.top = Math.round(y) + 'px';
            root.style.right = '';
            root.style.bottom = '';
        } catch (e) {}
    }

    // Compute the map's "visible" sub-rectangle (excluding the infowindow occlusion)
    // and pan so that the marker ends up in the centre of that visible area.
    // Only shifts the map if the marker is (or would be) occluded.
    function _panMarkerIntoView(latlng, opts) {
        opts = opts || {};
        const root = el();
        if (!root || root.hidden || !latlng) return;
        try {
            const mapEl = map.getContainer();
            const mapRect = mapEl.getBoundingClientRect();
            const r = root.getBoundingClientRect();
            let vx0 = 0, vy0 = 0, vx1 = mapRect.width, vy1 = mapRect.height;
            const ix0 = Math.max(0, r.left - mapRect.left);
            const ix1 = Math.min(mapRect.width, r.right - mapRect.left);
            const iy0 = Math.max(0, r.top - mapRect.top);
            const iy1 = Math.min(mapRect.height, r.bottom - mapRect.top);
            if (ix1 > ix0 && iy1 > iy0) {
                const isFullWidth = (ix1 - ix0) >= mapRect.width * 0.8;
                const isFullHeight = (iy1 - iy0) >= mapRect.height * 0.5;
                if (isFullWidth) {
                    if (iy0 <= 6) vy0 = iy1;
                    else if (iy1 >= mapRect.height - 6) vy1 = iy0;
                } else if (isFullHeight) {
                    if (ix0 <= 6) vx0 = ix1;
                    else if (ix1 >= mapRect.width - 6) vx1 = ix0;
                } else {
                    if (iy1 >= mapRect.height - 6) vy1 = iy0;
                }
            }
            const pt = map.latLngToContainerPoint(latlng);
            const padding = 24;
            const occluded =
                pt.x < vx0 + padding || pt.x > vx1 - padding ||
                pt.y < vy0 + padding || pt.y > vy1 - padding;
            if (!occluded && opts.force !== true) return;
            const cx = (vx0 + vx1) / 2;
            const cy = (vy0 + vy1) / 2;
            const dx = pt.x - cx;
            const dy = pt.y - cy;
            if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
                map.panBy([dx, dy], { animate: opts.animate !== false });
            }
        } catch (_) {}
    }

    function setCollapsed(v) {
        isCollapsed = !!v;
        const root = el();
        if (!root) return;
        root.classList.toggle('vinf--collapsed', isCollapsed);
        root.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');
        // When collapsing/expanding the desktop panel, we may shift from being clipped to visible;
        // re-run positioning on desktop so it stays anchored correctly.
        if (!isMobile() && !userPositioned) {
            requestAnimationFrame(() => _positionNearMarker());
        }
    }

    function _attachOnce() {
        const root = el();
        if (!root || root._bound) return;
        root._bound = true;

        root.addEventListener('click', (e) => {
            const btn = e.target.closest('[data-act]');
            if (!btn) return;
            const act = btn.dataset.act;
            const mmsi = openMmsi;
            const v = mmsi != null ? getAisTargetByMmsi(mmsi) : null;
            if (act === 'close') { close(); return; }
            if (act === 'collapse') { setCollapsed(!isCollapsed); return; }
            if (act === 'center' && v && v.lat != null && v.lon != null) {
                map.setView([v.lat, v.lon], Math.max(map.getZoom(), 14));
                requestAnimationFrame(() => _panMarkerIntoView(L.latLng(v.lat, v.lon)));
                return;
            }
            if (act === 'ruler' && v && v.lat != null && v.lon != null) {
                try { RulerTool.startFromOwnshipTo(v.lat, v.lon); } catch (_) {}
                return;
            }
            if (act === 'copy' && v && v.lat != null && v.lon != null) {
                const txt = Number(v.lat).toFixed(6) + ', ' + Number(v.lon).toFixed(6);
                try {
                    if (navigator.clipboard && navigator.clipboard.writeText) {
                        navigator.clipboard.writeText(txt);
                    }
                } catch (_) {}
                btn.textContent = 'Скопировано';
                setTimeout(() => { try { refreshIfOpen(mmsi); } catch (_) {} }, 1200);
                return;
            }
        });

        // Desktop drag
        root.addEventListener('pointerdown', (e) => {
            if (isMobile()) return;
            if (e.target.closest('[data-act]')) return;
            const handle = e.target.closest('[data-drag-handle]');
            if (!handle) return;
            e.preventDefault();
            const rect = root.getBoundingClientRect();
            _drag = { id: e.pointerId, dx: e.clientX - rect.left, dy: e.clientY - rect.top };
            try { root.setPointerCapture(e.pointerId); } catch (_) {}
            userPositioned = true;
        });
        root.addEventListener('pointermove', (e) => {
            if (!_drag || e.pointerId !== _drag.id) return;
            const mapRect = map.getContainer().getBoundingClientRect();
            let x = e.clientX - mapRect.left - _drag.dx;
            let y = e.clientY - mapRect.top - _drag.dy;
            const w = root.offsetWidth, h = root.offsetHeight, m = 4;
            if (x < m) x = m;
            if (y < m) y = m;
            if (x + w > mapRect.width - m) x = mapRect.width - m - w;
            if (y + h > mapRect.height - m) y = mapRect.height - m - h;
            root.style.left = Math.round(x) + 'px';
            root.style.top = Math.round(y) + 'px';
            root.style.right = '';
            root.style.bottom = '';
        });
        const endDrag = (e) => {
            if (!_drag) return;
            if (e && e.pointerId !== _drag.id) return;
            _drag = null;
        };
        root.addEventListener('pointerup', endDrag);
        root.addEventListener('pointercancel', endDrag);

        // Mobile swipe on the header grip:
        //   - swipe DOWN: collapse (if expanded) or close (if already collapsed)
        //   - swipe UP  : expand (if collapsed)
        let sw = null;
        root.addEventListener('touchstart', (e) => {
            if (!isMobile()) return;
            if (!e.target.closest('[data-drag-handle]')) return;
            const t = e.touches[0];
            if (!t) return;
            sw = { y0: t.clientY, id: t.identifier, fired: false };
        }, { passive: true });
        root.addEventListener('touchmove', (e) => {
            if (!sw || sw.fired) return;
            const t = Array.from(e.touches).find(tt => tt.identifier === sw.id);
            if (!t) return;
            const dy = t.clientY - sw.y0;
            // Swipe only collapses/expands; the close (X) button is the only way to close.
            if (dy > 48 && !isCollapsed) { sw.fired = true; setCollapsed(true); }
            else if (dy < -48 && isCollapsed) { sw.fired = true; setCollapsed(false); }
        }, { passive: true });
        root.addEventListener('touchend', () => { sw = null; }, { passive: true });

        // Close on Esc
        document.addEventListener('keydown', (e) => {
            if (e.key === 'Escape' && openMmsi != null) close();
        });

        // Follow marker when map pans/zooms (unless user dragged it already)
        try {
            map.on('zoomend moveend rotate', () => {
                if (openMmsi != null && !userPositioned) _positionNearMarker();
            });
        } catch (_) {}
    }

    function open(mmsi) {
        const root = el();
        if (!root) return;
        _attachOnce();
        const prev = openMmsi;
        openMmsi = mmsi;
        selectedMmsi = String(mmsi);
        if (prev !== mmsi) {
            userPositioned = false;
            isCollapsed = false;
        }

        const v = getAisTargetByMmsi(mmsi);
        if (!v) return;
        root.innerHTML = _buildHtml(v);
        root.hidden = false;
        root.setAttribute('aria-hidden', 'false');
        root.classList.toggle('vinf--collapsed', isCollapsed);
        root.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');

        if (!userPositioned) {
            requestAnimationFrame(() => _positionNearMarker());
        }
        // After layout, pan the map so the marker is not hidden behind the sheet/card.
        if (v.lat != null && v.lon != null) {
            requestAnimationFrame(() => _panMarkerIntoView(L.latLng(v.lat, v.lon)));
        }
        try {
            const ov = vesselOverlays.get(mmsi);
            if (v.lat != null && v.lon != null) {
                updateVesselOverlay(mmsi, v.lat, v.lon, true,
                    !!(ov && ov.losing),
                    (_markerForTargetMmsi(mmsi) && _markerForTargetMmsi(mmsi)._bearingForOverlay));
            }
        } catch (_) {}
        try { renderVesselSidebar(); } catch (_) {}
        try { renderTargetsTab(); } catch (_) {}
        try { if (typeof NearbyHud !== 'undefined') NearbyHud.render(); } catch (_) {}
    }
    function close() {
        const root = el();
        if (!root) return;
        const was = openMmsi;
        openMmsi = null;
        if (String(selectedMmsi) === String(was)) selectedMmsi = null;
        root.hidden = true;
        root.setAttribute('aria-hidden', 'true');
        if (was != null) {
            try {
                const v = getAisTargetByMmsi(was);
                if (v && v.lat != null && v.lon != null) {
                    const ov = vesselOverlays.get(was);
                    updateVesselOverlay(was, v.lat, v.lon, false,
                        !!(ov && ov.losing),
                        (_markerForTargetMmsi(was) && _markerForTargetMmsi(was)._bearingForOverlay));
                }
            } catch (_) {}
            try { renderVesselSidebar(); } catch (_) {}
            try { renderTargetsTab(); } catch (_) {}
            try { if (typeof NearbyHud !== 'undefined') NearbyHud.render(); } catch (_) {}
        }
    }
    function refreshIfOpen(mmsi) {
        if (openMmsi == null || String(openMmsi) !== String(mmsi)) return;
        const root = el();
        if (!root || root.hidden) return;
        const v = getAisTargetByMmsi(mmsi);
        if (!v) return;
        root.innerHTML = _buildHtml(v);
        root.classList.toggle('vinf--collapsed', isCollapsed);
        root.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');
    }
    function currentMmsi() { return openMmsi; }
    function panIntoView(lat, lon, opts) { _panMarkerIntoView(L.latLng(lat, lon), opts || {}); }

    return { open, close, refreshIfOpen, currentMmsi, panIntoView, setCollapsed };
})();

// Back-compat shim: existing code checked `openPopups.has(mmsi)` to decide whether
// a vessel is "chosen". Keep the same read-interface but delegate to VesselInfoWindow.
const openPopups = {
    has(mmsi) { return String(VesselInfoWindow.currentMmsi()) === String(mmsi); },
    add() {}, delete() {}, clear() {},
};

// ===================== Ruler tool (on-map distance measurement) =====================
// - Click to set first point, click again to set end point (or move cursor before second click).
// - Supports "from ownship to target" shortcut via startFromOwnshipTo().
// - HUD in lower-right shows NM / km / bearing and a close button.
const RulerTool = (function () {
    let on = false;
    let line = null, endMarker = null, startMarker = null;
    let startLL = null, endLL = null;
    let hud = null;
    let onClick = null, onMove = null;

    function _ensureHud() {
        if (hud) return hud;
        hud = document.createElement('div');
        hud.className = 'ruler-hud';
        hud.hidden = true;
        hud.innerHTML =
            '<span class="hint">Клик — начальная точка, ещё клик — конечная. Esc — выход.</span>' +
            '<span class="dist">—</span>' +
            '<button type="button" aria-label="Закрыть рулетку" title="Закрыть">✕</button>';
        document.body.appendChild(hud);
        hud.querySelector('button').addEventListener('click', disable);
        return hud;
    }
    function _updateHud() {
        _ensureHud();
        hud.hidden = false;
        const d = hud.querySelector('.dist');
        if (!startLL) { d.textContent = 'Кликните на карте'; return; }
        if (!endLL) { d.textContent = 'Выберите вторую точку…'; return; }
        const nm = haversineNM(startLL.lat, startLL.lng, endLL.lat, endLL.lng);
        const km = nm * 1.852;
        const brg = bearingDeg(startLL.lat, startLL.lng, endLL.lat, endLL.lng);
        d.textContent = nm.toFixed(2) + ' NM · ' + km.toFixed(2) + ' км · ' + brg.toFixed(0) + '°';
    }
    function _draw() {
        if (!startLL) return;
        const pts = [startLL, endLL || startLL];
        if (!line) line = L.polyline(pts, { color: '#d2ff1a', weight: 3, dashArray: '6,4', interactive: false }).addTo(map);
        else line.setLatLngs(pts);
        if (!startMarker) startMarker = L.circleMarker(startLL, { radius: 5, color: '#d2ff1a', fillColor: '#d2ff1a', fillOpacity: 0.9, interactive: false }).addTo(map);
        else startMarker.setLatLng(startLL);
        if (endLL) {
            if (!endMarker) endMarker = L.circleMarker(endLL, { radius: 6, color: '#d2ff1a', fillColor: '#d2ff1a', fillOpacity: 0.6, interactive: false }).addTo(map);
            else endMarker.setLatLng(endLL);
        }
    }
    function enable() {
        if (on) return;
        on = true;
        document.body.classList.add('ruler-active');
        try { map.getContainer().style.cursor = 'crosshair'; } catch (_) {}
        const btn = document.getElementById('mc-ruler');
        if (btn) btn.classList.add('active');
        startLL = null; endLL = null;
        _updateHud();
        onClick = (e) => {
            if (!startLL) { startLL = e.latlng; endLL = null; _draw(); _updateHud(); return; }
            if (!endLL) { endLL = e.latlng; _draw(); _updateHud(); return; }
            // restart measurement
            startLL = e.latlng; endLL = null; _draw(); _updateHud();
        };
        onMove = (e) => {
            if (startLL && !endLL && line) line.setLatLngs([startLL, e.latlng]);
        };
        map.on('click', onClick);
        map.on('mousemove', onMove);
    }
    function disable() {
        if (!on) return;
        on = false;
        document.body.classList.remove('ruler-active');
        try { map.getContainer().style.cursor = ''; } catch (_) {}
        if (onClick) map.off('click', onClick);
        if (onMove) map.off('mousemove', onMove);
        onClick = null; onMove = null;
        if (line) { try { map.removeLayer(line); } catch (_) {} line = null; }
        if (startMarker) { try { map.removeLayer(startMarker); } catch (_) {} startMarker = null; }
        if (endMarker) { try { map.removeLayer(endMarker); } catch (_) {} endMarker = null; }
        startLL = null; endLL = null;
        if (hud) hud.hidden = true;
        const btn = document.getElementById('mc-ruler');
        if (btn) btn.classList.remove('active');
    }
    function toggle() { on ? disable() : enable(); }
    function startFromOwnshipTo(lat, lon) {
        const os = getOwnShipPos();
        enable();
        if (os && os.lat != null && os.lon != null) {
            startLL = L.latLng(os.lat, os.lon);
            endLL = L.latLng(lat, lon);
            _draw(); _updateHud();
            try { map.fitBounds(L.latLngBounds([startLL, endLL]).pad(0.25)); } catch (_) {}
        }
    }
    document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && on) disable(); });
    return { enable, disable, toggle, startFromOwnshipTo, isOn: () => on };
})();

// ===================== Map controls (zoom, centre, north-up, ruler, one-hand) =====================
(function initMapControls() {
    function bind(id, fn) {
        const el = document.getElementById(id);
        if (!el) return;
        el.addEventListener('click', (e) => { e.preventDefault(); fn(e); });
    }
    const centerOnOwnship = () => {
        const os = getOwnShipPos();
        if (os && os.lat != null && os.lon != null) {
            map.setView([os.lat, os.lon], Math.max(map.getZoom(), 13));
        }
    };
    bind('mc-zoom-in', () => map.zoomIn());
    bind('mc-zoom-out', () => map.zoomOut());
    bind('mc-center', centerOnOwnship);
    bind('mc-north-up', () => {
        try { if (typeof map.setBearing === 'function') map.setBearing(0); } catch (_) {}
        try {
            if (typeof rotateMapByCompass !== 'undefined' && rotateMapByCompass) {
                rotateMapByCompass = false;
                try { sSet('rotateMapByCompass', false); } catch (_) {}
                try { _setOwnshipCompassUi(); } catch (_) {}
                try { _reflectCompassToggleUi(); } catch (_) {}
            }
        } catch (_) {}
    });
    bind('mc-compass', () => {
        try { _toggleRotateMapByCompass(); } catch (_) {}
    });
    try { _reflectCompassToggleUi(); } catch (_) {}
    bind('mc-ruler', () => RulerTool.toggle());

    // One-hand pad buttons
    bind('oh-zoom-in', () => map.zoomIn());
    bind('oh-zoom-out', () => map.zoomOut());
    bind('oh-center', centerOnOwnship);

    // One-hand mode toggle (big buttons + single-finger double-tap-drag zoom)
    const onehandBtn = document.getElementById('mc-onehand');
    const onehandPad = document.getElementById('onehand-pad');
    function setOnehand(v) {
        document.body.classList.toggle('onehand', !!v);
        if (onehandPad) {
            onehandPad.hidden = !v;
            onehandPad.setAttribute('aria-hidden', v ? 'false' : 'true');
        }
        if (onehandBtn) onehandBtn.classList.toggle('active', !!v);
        try { sSet('onehand', v ? '1' : '0'); } catch (_) {}
    }
    try { if (sGet('onehand', '0') === '1') setOnehand(true); } catch (_) {}
    if (onehandBtn) onehandBtn.addEventListener('click', () => {
        setOnehand(!document.body.classList.contains('onehand'));
    });

    // Double-tap-drag zoom on a single finger (Google-Maps-style) — active only in one-hand mode.
    try {
        const mapEl = map.getContainer();
        let lastTap = null, dragState = null;
        mapEl.addEventListener('touchstart', (e) => {
            if (!document.body.classList.contains('onehand')) return;
            if (e.touches.length !== 1) { lastTap = null; return; }
            const t = e.touches[0];
            const now = Date.now();
            if (lastTap && (now - lastTap.ts) < 320 &&
                Math.hypot(t.clientX - lastTap.x, t.clientY - lastTap.y) < 40) {
                const rect = mapEl.getBoundingClientRect();
                const pt = L.point(t.clientX - rect.left, t.clientY - rect.top);
                dragState = { y0: t.clientY, zoom0: map.getZoom(), centerLL: map.containerPointToLatLng(pt) };
                try { map.dragging.disable(); } catch (_) {}
                e.preventDefault();
            }
            lastTap = { x: t.clientX, y: t.clientY, ts: now };
        }, { passive: false });
        mapEl.addEventListener('touchmove', (e) => {
            if (!dragState || e.touches.length !== 1) return;
            const t = e.touches[0];
            const dy = dragState.y0 - t.clientY;
            const z = dragState.zoom0 + dy / 80;
            const zMin = map.getMinZoom(), zMax = map.getMaxZoom();
            map.setZoomAround(dragState.centerLL, Math.max(zMin, Math.min(zMax, z)));
            e.preventDefault();
        }, { passive: false });
        const endOneFingerZoom = () => {
            if (dragState) { dragState = null; try { map.dragging.enable(); } catch (_) {} }
        };
        mapEl.addEventListener('touchend', endOneFingerZoom);
        mapEl.addEventListener('touchcancel', endOneFingerZoom);
    } catch (_) {}
})();

// ===================== New "Targets" tab (primary full list on mobile) =====================
function renderTargetsTab() {
    const list = document.getElementById('targets-list');
    const cntEl = document.getElementById('targets-count');
    const suffEl = document.getElementById('targets-count-suffix');
    if (!list) return;

    const combined = []
        .concat(lastAnyVessels || [])
        .concat(lastBaseStations || [])
        .concat(lastBuoys || []);
    const os = getOwnShipPos();
    if (os) {
        for (const v of combined) {
            if (v && v.lat != null && v.lon != null) v._distNM = haversineNM(os.lat, os.lon, v.lat, v.lon);
            else v._distNM = null;
        }
    }

    const clsSel = document.getElementById('targets-filter-class');
    const stSel = document.getElementById('targets-filter-shiptype');
    const searchEl = document.getElementById('targets-search');
    const sortEl = document.getElementById('targets-sort');
    const cls = clsSel ? clsSel.value : 'all';
    const stG = stSel ? stSel.value : 'all';
    const q = (searchEl && searchEl.value ? searchEl.value : '').trim().toLowerCase();
    const sortMode = sortEl ? sortEl.value : 'time-desc';

    const filtered = combined.filter(v => {
        if (cls !== 'all' && String(v.vessel_class || '') !== cls) return false;
        if (typeof matchesShiptypeFilter === 'function' && !matchesShiptypeFilter(v, stG)) return false;
        if (q) {
            const hay = (typeof vesselSearchHaystack === 'function') ? vesselSearchHaystack(v) : String(v.mmsi || '').toLowerCase();
            if (!hay.includes(q)) return false;
        }
        return true;
    });
    try { if (typeof sortVesselsInPlace === 'function') sortVesselsInPlace(filtered, sortMode); } catch (_) {}

    if (cntEl) cntEl.textContent = String(filtered.length);
    if (suffEl) suffEl.textContent = filtered.length !== combined.length ? ' из ' + combined.length : '';

    list.innerHTML = '';
    for (const v of filtered) {
        const { mmsi, lat, lon, vessel_class, shipname, speed, course, heading, timestamp } = v;
        const it = document.createElement('div');
        it.className = 'vessel-item';
        it.dataset.mmsi = String(mmsi);
        const hasCoord = lat != null && lon != null;
        const iso2 = mmsiToIso2FromMid(mmsi);
        const flag = iso2ToFlagEmoji(iso2);
        const brg = (os && hasCoord) ? bearingDeg(os.lat, os.lon, lat, lon) : null;

        let h = '<div class="vessel-mmsi-row"><span class="mmsi">' + escHtml(mmsi) + '</span>';
        if (flag) h += '<span class="vessel-flag" title="' + escHtml(iso2 || '') + '">' + flag + '</span>';
        h += '</div>';
        if (shipname) h += '<div class="name">' + escHtml(shipname) + '</div>';
        const clsLabel = (vessel_class === 'BS') ? 'База' : (vessel_class === 'N') ? 'Буёк' : (vessel_class || '?');
        h += '<div>Класс: ' + escHtml(clsLabel) + '</div>';
        if (String(vessel_class) === 'N' && v.aton_type != null && v.aton_type !== '') {
            h += '<div class="coords">Тип: ' + escHtml(v.aton_type_label || atonTypeLabel(v.aton_type) || 'Тип СНО не указан') + '</div>';
        }
        if (v._distNM != null || brg != null) {
            let distLine = '<div class="dist" title="Дальность · пеленг от своего судна">';
            if (v._distNM != null) distLine += '<span class="dist-val">' + fmtDist(v._distNM) + '</span>';
            if (brg != null) distLine += '<span class="brg-val">' + brg.toFixed(0) + '°</span>';
            distLine += '</div>';
            h += distLine;
        }
        h += '<div class="compact-stats">' +
            '<span title="SOG — Speed Over Ground, скорость относительно земли">' +
                '<span class="k">SOG</span><b>' + (speed != null ? escHtml(fmtSpeed(speed)) : '—') + '</b></span>' +
            '<span title="COG — Course Over Ground, истинный курс движения">' +
                '<span class="k">COG</span><b>' + (course != null ? course.toFixed(0) + '°' : '—') + '</b></span>' +
            '<span title="HDG — Heading, направление носа судна (с компаса/гирокомпаса)">' +
                '<span class="k">HDG</span><b>' + (heading != null ? heading.toFixed(0) + '°' : '—') + '</b></span>' +
            (v.signal_db != null ? '<span title="Уровень радиосигнала последнего AIS-сообщения">' +
                '<span class="k">SIG</span><b>' + Number(v.signal_db).toFixed(1) + ' дБ</b></span>' : '') +
        '</div>';
        if (timestamp != null) h += '<div class="coords">' + fmtTime(timestamp) + ' (' + fmtAgo(timestamp) + ')</div>';
        it.innerHTML = h;

        if (hasCoord) {
            if (String(mmsi) === (selectedMmsi != null ? String(selectedMmsi) : null)) it.classList.add('selected');
            it.addEventListener('click', () => {
                selectedMmsi = String(mmsi);
                switchTab('map');
                setTimeout(() => {
                    map.setView([lat, lon], Math.max(map.getZoom(), 15), { animate: false });
                    try { VesselInfoWindow.open(mmsi); } catch (_) {}
                    try { NearbyHud.render(); } catch (_) {}
                }, 80);
            });
        }
        list.appendChild(it);
    }
}

(function initTargetsTabControls() {
    ['targets-search', 'targets-sort', 'targets-filter-class', 'targets-filter-shiptype'].forEach(id => {
        const el = document.getElementById(id);
        if (!el) return;
        const ev = (el.tagName === 'INPUT') ? 'input' : 'change';
        el.addEventListener(ev, () => { try { renderTargetsTab(); } catch (_) {} });
    });
})();

// ===================== Mobile compact sidebar toggle =====================
// On narrow viewports switch the "Ближайшие" sidebar to a compact row layout
// (MMSI / NAME / DIST + SOG/COG/HDG/BRG/DIST line). Desktop keeps verbose layout.
(function initMobileCompactSidebar() {
    const sb = document.getElementById('sidebar');
    if (!sb) return;
    let mql;
    try { mql = window.matchMedia('(max-width:600px)'); } catch (_) { return; }
    const apply = () => sb.classList.toggle('sidebar--compact', !!mql.matches);
    apply();
    try { mql.addEventListener('change', apply); } catch (_) {
        if (mql.addListener) mql.addListener(apply);
    }
})();

// ===================== Nearby HUD =====================
// Semi-transparent corner overlay: own-ship readout (speed / coords / compass)
// + the 5 nearest vessels OR the single currently selected vessel (with a clear-X).
// Clicking an item selects the vessel and opens the infowindow.
const NearbyHud = (function(){
    const root = document.getElementById('nearby-hud');
    if (!root) return { render: ()=>{} };
    const listEl = document.getElementById('nhud-list');
    const ownRow = document.getElementById('nhud-own');
    const ownSog = document.getElementById('nhud-own-sog');
    const ownCog = document.getElementById('nhud-own-cog');
    const ownCoords = document.getElementById('nhud-own-coords');
    const ownSrc = document.getElementById('nhud-own-src');
    const compassEl = document.getElementById('nhud-compass');
    const needle = document.getElementById('nhud-needle');
    const compassVal = document.getElementById('nhud-compass-val');
    const toggleBtn = document.getElementById('nhud-toggle');

    let collapsed = false;
    try { collapsed = sGet('nhudCollapsed', '0') === '1'; } catch(_) {}
    root.classList.toggle('is-collapsed', collapsed);

    if (toggleBtn) {
        toggleBtn.addEventListener('click', () => {
            collapsed = !collapsed;
            root.classList.toggle('is-collapsed', collapsed);
            try { sSet('nhudCollapsed', collapsed ? '1' : '0'); } catch(_) {}
        });
    }

    function fmtCoords(lat, lon) {
        if (lat == null || lon == null) return '—';
        return lat.toFixed(4) + ', ' + lon.toFixed(4);
    }
    function fmtBrg(b) { return (b != null && !isNaN(b)) ? b.toFixed(0) + '°' : '—'; }

    function buildItemHtml(v, isSelected) {
        const { mmsi, shipname, callsign, lat, lon, speed, course } = v;
        const name = (shipname && String(shipname).trim()) || '';
        const cs = (callsign && String(callsign).trim()) || '';
        const brg = (v._brg != null) ? v._brg : null;
        const distTxt = (v._distNM != null) ? fmtDist(v._distNM) : '—';

        let idHtml = '';
        if (name) idHtml += '<span class="nhud-item__name">' + escHtml(name) + '</span>';
        if (cs) idHtml += '<span class="nhud-item__callsign">' + escHtml(cs) + '</span>';
        idHtml += '<span class="nhud-item__mmsi">' + escHtml(mmsi) + '</span>';

        let botHtml = '<span class="nhud-item__sog"><span class="k">SOG</span><b>' +
            (speed != null ? escHtml(fmtSpeed(speed)) : '—') + '</b></span>';
        botHtml += '<span class="nhud-item__cog"><span class="k">COG</span><b>' +
            (course != null ? course.toFixed(0) + '°' : '—') + '</b></span>';
        botHtml += '<span class="nhud-item__brg">' + fmtBrg(brg) + '</span>';

        return '<div class="nhud-item__top">' +
                 '<span class="nhud-item__id">' + idHtml + '</span>' +
                 '<span class="nhud-item__dist">' + escHtml(distTxt) + '</span>' +
               '</div>' +
               '<div class="nhud-item__bot">' + botHtml + '</div>' +
               '<div class="nhud-item__coords">' + escHtml(fmtCoords(lat, lon)) + '</div>' +
               (isSelected ? '<button type="button" class="nhud-item__clear" title="Снять выделение" aria-label="Снять выделение">×</button>' : '');
    }

    function renderOwn() {
        const data = (typeof ownShipSource !== 'undefined' && ownShipSource === 'phone') ? phoneGps : nmeaGps;
        const hasFix = data && data.lat != null && data.lon != null;
        if (hasFix) {
            ownCoords.textContent = fmtCoords(data.lat, data.lon);
            ownSog.textContent = data.speed != null ? fmtSpeed(data.speed) : '—';
            ownCog.textContent = data.course != null ? data.course.toFixed(0) + '°' : '—';
        } else {
            ownCoords.textContent = '—';
            ownSog.textContent = '—';
            ownCog.textContent = '—';
        }
        // source label
        let srcTxt = '';
        let srcErr = false;
        if (ownShipSource === 'phone') {
            if (phoneGpsError) { srcTxt = phoneGpsError; srcErr = true; }
            else if (hasFix) srcTxt = 'PHONE GPS';
            else srcTxt = 'PHONE GPS (ожидание…)';
        } else {
            srcTxt = hasFix ? 'NMEA' : 'NMEA (нет данных)';
        }
        ownSrc.textContent = srcTxt;
        ownSrc.classList.toggle('nhud-own__src--err', srcErr);

        // Compass: prefer phone compass when available; else GPS heading/course; else nothing
        const compassH = (typeof phoneCompassOk !== 'undefined' && phoneCompassOk && phoneCompassHeading != null)
            ? phoneCompassHeading : null;
        const h = (compassH != null)
            ? compassH
            : (hasFix ? (data.heading != null ? data.heading : data.course) : null);
        const hasHeading = h != null && !isNaN(h);
        compassEl.classList.toggle('no-data', !hasHeading);
        compassEl.classList.toggle('from-phone', compassH != null);
        compassEl.classList.toggle('has-val', hasHeading);
        if (hasHeading) {
            needle.style.transform = 'rotate(' + (-h) + 'deg)';
            compassVal.textContent = h.toFixed(0) + '°';
        } else {
            compassVal.textContent = '';
        }
    }

    function renderList() {
        const os = getOwnShipPos();
        const combined = []
            .concat(lastAnyVessels || [])
            .concat(lastBaseStations || [])
            .concat(lastBuoys || []);

        // Compute distance+bearing from own ship
        if (os) {
            for (const v of combined) {
                if (v && v.lat != null && v.lon != null) {
                    v._distNM = haversineNM(os.lat, os.lon, v.lat, v.lon);
                    v._brg = bearingDeg(os.lat, os.lon, v.lat, v.lon);
                } else {
                    v._distNM = null; v._brg = null;
                }
            }
        }

        const hasSel = selectedMmsi != null;
        let items = [];
        if (hasSel) {
            const sel = combined.find(v => String(v.mmsi) === String(selectedMmsi));
            if (sel) items.push({ v: sel, isSel: true });
        }
        if (!items.length || !hasSel) {
            // Top-5 by distance (if own position known); else by time desc
            let pool = combined.filter(v => v && v.lat != null && v.lon != null);
            if (hasSel) pool = pool.filter(v => String(v.mmsi) !== String(selectedMmsi));
            if (os) {
                pool.sort((a,b) => (a._distNM != null ? a._distNM : Infinity) - (b._distNM != null ? b._distNM : Infinity));
            } else {
                pool.sort((a,b) => (b.timestamp||0) - (a.timestamp||0));
            }
            const need = hasSel ? 4 : 5;
            for (const v of pool.slice(0, need)) items.push({ v, isSel: false });
        }

        if (!items.length) {
            listEl.innerHTML = '<div class="nhud-empty">Нет целей в эфире</div>';
            root.classList.add('is-empty');
            return;
        }
        root.classList.remove('is-empty');

        const html = items.map(({ v, isSel }) => {
            return '<div class="nhud-item' + (isSel ? ' is-selected' : '') + '" data-mmsi="' + escHtml(String(v.mmsi)) + '">' +
                buildItemHtml(v, isSel) + '</div>';
        }).join('');
        listEl.innerHTML = html;

        // Wire up click handlers
        listEl.querySelectorAll('.nhud-item').forEach(el => {
            const mmsi = el.dataset.mmsi;
            el.addEventListener('click', (ev) => {
                if (ev.target.closest('.nhud-item__clear')) {
                    selectedMmsi = null;
                    try { VesselInfoWindow.close(); } catch(_) {}
                    try { renderVesselSidebar(); } catch(_) {}
                    render();
                    return;
                }
                selectedMmsi = String(mmsi);
                const v = combined.find(x => String(x.mmsi) === String(mmsi));
                if (v && v.lat != null && v.lon != null) {
                    try { map.setView([v.lat, v.lon], Math.max(map.getZoom(), 14), { animate: true }); } catch(_) {}
                    try { VesselInfoWindow.open(mmsi); } catch(_) {}
                }
                render();
            });
        });
    }

    let _renderPending = false;
    function render() {
        if (_renderPending) return;
        _renderPending = true;
        requestAnimationFrame(() => {
            _renderPending = false;
            try { renderOwn(); } catch(_) {}
            try { renderList(); } catch(_) {}
        });
    }

    // Short ticker for compass/coords (updates even between vessel feeds).
    setInterval(() => { try { renderOwn(); } catch(_) {} }, 500);
    // Full re-render pace
    setInterval(() => { try { render(); } catch(_) {} }, 2000);

    return { render };
})();

/** Stern (icon anchor) → midpoint of hull along keel; bearing ° from north like AIS heading. */
function overlayLatLngFromStern(lat, lon, bearingDeg) {
    if (bearingDeg == null || isNaN(bearingDeg)) return [lat, lon];
    const ll = L.latLng(lat, lon);
    const p = map.latLngToContainerPoint(ll);
    if (!p) return [lat, lon];
    const mpp = map.distance(ll, map.containerPointToLatLng(L.point(p.x, p.y + 1)));
    const distM = (iconHeight / 2) * mpp;
    const R = 6371000;
    const brng = bearingDeg * Math.PI / 180;
    const φ1 = lat * Math.PI / 180, λ1 = lon * Math.PI / 180;
    const δ = distM / R;
    const sinφ1 = Math.sin(φ1), cosφ1 = Math.cos(φ1);
    const sinδ = Math.sin(δ), cosδ = Math.cos(δ);
    const sinφ2 = sinφ1 * cosδ + cosφ1 * sinδ * Math.cos(brng);
    const φ2 = Math.asin(sinφ2);
    const λ2 = λ1 + Math.atan2(Math.sin(brng) * sinδ * cosφ1, cosδ - sinφ1 * sinφ2);
    return [φ2 * 180 / Math.PI, λ2 * 180 / Math.PI];
}

function updateVesselOverlay(mmsi, lat, lon, isChosen, isLosing, bearingDeg) {
    const [olat, olng] = overlayLatLngFromStern(lat, lon, bearingDeg);
    if (!vesselOverlays.has(mmsi)) vesselOverlays.set(mmsi, {chosen:null, losing:null});
    const o = vesselOverlays.get(mmsi);
    if (isChosen && !o.chosen) o.chosen = L.marker([olat,olng],{icon:iconChosen,zIndexOffset:1000}).addTo(map);
    else if (!isChosen && o.chosen) { map.removeLayer(o.chosen); o.chosen=null; }
    else if (isChosen && o.chosen) o.chosen.setLatLng([olat,olng]);
    if (isLosing && !o.losing) o.losing = L.marker([olat,olng],{icon:iconLosing,zIndexOffset:1000}).addTo(map);
    else if (!isLosing && o.losing) { map.removeLayer(o.losing); o.losing=null; }
    else if (isLosing && o.losing) o.losing.setLatLng([olat,olng]);
}
function removeVesselOverlay(mmsi) {
    if (!vesselOverlays.has(mmsi)) return;
    const o = vesselOverlays.get(mmsi);
    if (o.chosen) map.removeLayer(o.chosen);
    if (o.losing) map.removeLayer(o.losing);
    vesselOverlays.delete(mmsi);
}

function fmtAgo(ts) {
    if (!ts) return '?';
    const d = Math.floor(Date.now()/1000) - ts;
    if (d<0) return '?';
    if (d<60) return d+'с';
    const m=Math.floor(d/60), s=d%60;
    if (m<60) return m+'м '+s+'с';
    return Math.floor(m/60)+'ч '+m%60+'м';
}
function fmtTime(ts) {
    if (!ts) return '?';
    const d=new Date(ts*1000);
    return [d.getHours(),d.getMinutes(),d.getSeconds()].map(v=>String(v).padStart(2,'0')).join(':');
}

function _clientLog(level, msg, ctx) {
    try {
        try { console.log('[AISMap] clientLog', level, msg, ctx || null); } catch (e) {}
        // Best-effort: do not block UI; keepalive helps on page unload.
        const payload = {
            level: level || 'info',
            msg: msg || '',
            ctx: ctx || null,
            ts: Math.floor(Date.now() / 1000),
            url: (typeof location !== 'undefined' ? location.href : null),
            ua: (typeof navigator !== 'undefined' ? navigator.userAgent : null),
            build: (typeof APP_BUILD !== 'undefined' ? APP_BUILD : null),
        };
        // Prefer Beacon (more reliable on mobile/WebView); fall back to fetch.
        try {
            if (navigator && typeof navigator.sendBeacon === 'function') {
                const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
                navigator.sendBeacon('/api/client_log', blob);
                return;
            }
        } catch (e) {}
        fetch('/api/client_log', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(payload),
            keepalive: true,
        }).catch(() => {});
    } catch (e) {}
}

function _numOrNull(x) {
    if (x == null) return null;
    if (typeof x === 'number') return Number.isFinite(x) ? x : null;
    const n = parseFloat(x);
    return Number.isFinite(n) ? n : null;
}

function _isMockApiEnabled() {
    try {
        const q = new URLSearchParams(location.search || '');
        if (q.get('mock') === '1') return true;
    } catch (e) {}
    try { return sGet('mockApi', '0') === '1'; } catch (e) { return false; }
}

function _apiUrl(path) {
    if (!_isMockApiEnabled()) return path;
    try {
        const u = new URL(path, location.origin);
        u.searchParams.set('mock', '1');
        return u.pathname + '?' + u.searchParams.toString();
    } catch (e) {
        return path + (path.includes('?') ? '&' : '?') + 'mock=1';
    }
}

function _extractServerNowAndList(payload, key) {
    // Backward-compatible: old APIs returned plain arrays.
    if (Array.isArray(payload)) {
        return { serverNow: Math.floor(Date.now() / 1000), list: payload };
    }
    if (payload && typeof payload === 'object') {
        const sn = payload.server_now != null ? parseInt(payload.server_now, 10) : null;
        const list = payload[key] != null ? payload[key] : (payload.data != null ? payload.data : []);
        return {
            serverNow: (sn != null && !isNaN(sn)) ? sn : Math.floor(Date.now() / 1000),
            list: Array.isArray(list) ? list : []
        };
    }
    return { serverNow: Math.floor(Date.now() / 1000), list: [] };
}

// Debug HUD (left-bottom corner) — disabled because it collides with the
// #nearby-hud overlay. The helpers are kept as no-ops so existing call sites
// keep working and can easily be re-enabled by flipping `_DEBUG_HUD_ENABLED`
// or by removing this guard.
const _DEBUG_HUD_ENABLED = false;

function _ensureDebugHudOnce() {
    if (!_DEBUG_HUD_ENABLED) return;
    try {
        if (document.getElementById('aismap-debug-hud')) return;
        const d = document.createElement('div');
        d.id = 'aismap-debug-hud';
        d.style.cssText = 'position:fixed;left:8px;top:8px;z-index:9999;max-width:92vw;' +
            'background:rgba(0,0,0,.75);color:#e6edf3;font:12px/1.35 system-ui,Segoe UI,Roboto,Arial;' +
            'padding:6px 8px;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.25);' +
            'white-space:pre-wrap;pointer-events:none';
        d.textContent = 'AISMap debug HUD\nbuild: ' + APP_BUILD;
        document.body.appendChild(d);
    } catch (e) {}
}

function _setDebugHud(text) {
    if (!_DEBUG_HUD_ENABLED) return;
    try {
        _ensureDebugHudOnce();
        const d = document.getElementById('aismap-debug-hud');
        if (d) d.textContent = text;
    } catch (e) {}
}

function updateVessels() {
    try {
        // Источник — in-memory AisHub.vessels, наполняется WebSocket-ом /ws.
        const vessels = Array.from(AisHub.vessels.values()).map(v => Object.assign({}, v));
        const now = Math.floor(Date.now() / 1000);
        _setDebugHud('AISMap debug HUD\nbuild: ' + APP_BUILD + '\nvessels: ' + vessels.length);
        const os = getOwnShipPos();
        const maxNM = getRangeNM();

        // mergedTargetToVessel уже нормализует числа, но на случай кривых событий — страхуем.
        for (const v of vessels) {
            if (!v) continue;
            v.lat = _numOrNull(v.lat);
            v.lon = _numOrNull(v.lon);
            v.course = _numOrNull(v.course);
            v.speed = _numOrNull(v.speed);
            v.heading = _numOrNull(v.heading);
            v.timestamp = v.timestamp != null ? parseInt(v.timestamp, 10) : v.timestamp;
            v.mmsi = v.mmsi != null ? parseInt(v.mmsi, 10) : v.mmsi;
        }

        for (const v of vessels) {
            if (os && v.lat != null && v.lon != null)
                v._distNM = haversineNM(os.lat, os.lon, v.lat, v.lon);
            else
                v._distNM = null;
        }

        if (os) vessels.sort((a, b) => (a._distNM ?? Infinity) - (b._distNM ?? Infinity));

        const visibleSet = new Set();
        for (const v of vessels) {
            const validCoord = v.lat != null && v.lon != null && !isNaN(v.lat) && !isNaN(v.lon) && v.lat >= -90 && v.lat <= 90 && v.lon >= -180 && v.lon <= 180;
            const inRange = !isFinite(maxNM) || v._distNM == null || v._distNM <= maxNM;
            const notExpired = !_isTargetExpiredByTimestamp(v, now);
            if (validCoord && inRange && notExpired) visibleSet.add(v.mmsi);
        }

        // Total targets that sent anything (not expired), including those without a "legal" position.
        const totalAnySet = new Set();
        for (const v of vessels) {
            const notExpired = !_isTargetExpiredByTimestamp(v, now);
            if (notExpired) totalAnySet.add(v.mmsi);
        }

        // Update sidebar data early (even if marker drawing fails later).
        lastAnyVessels = vessels
            .filter(v => !_isTargetExpiredByTimestamp(v, now))
            .map(v => Object.assign({}, v, { kind: 'vessel' }));

        try {
            console.log('[AISMap] vessels sets', {
                visible: visibleSet.size,
                totalAny: totalAnySet.size,
                sampleVisible: (() => {
                    for (const v of vessels) {
                        if (visibleSet.has(v && v.mmsi)) return { mmsi: v.mmsi, lat: v.lat, lon: v.lon, ts: v.timestamp };
                    }
                    return null;
                })()
            });
        } catch (e) {}

        for (const [mmsi, mk] of vesselMarkers.entries()) {
            if (!visibleSet.has(mmsi)) {
                if (String(mmsi) === String(selectedMmsi)) selectedMmsi = null;
                map.removeLayer(mk);
                vesselMarkers.delete(mmsi);
                removeVesselOverlay(mmsi);
                removeMotionOverlays(mmsi);
                removeVesselDetailOverlays(mmsi);
                vesselLastData.delete(mmsi);
                openPopups.delete(mmsi);
                try { if (VesselInfoWindow.currentMmsi() && String(VesselInfoWindow.currentMmsi()) === String(mmsi)) VesselInfoWindow.close(); } catch (_) {}
            }
        }

        document.getElementById('status').textContent = 'Целей: ' + visibleSet.size + ' (' + totalAnySet.size + ')';
        _setDebugHud('AISMap debug HUD\nbuild: ' + APP_BUILD +
            '\n/api/vessels: ' + (Array.isArray(vessels) ? vessels.length : '?') +
            '\nvisibleSet: ' + visibleSet.size + ' totalAny: ' + totalAnySet.size);

        _dangerState = { any: false, count: 0, minNm: null, relDegSigned: null };

        for (const v of vessels) {
            try {
                if (!v || !visibleSet.has(v.mmsi)) continue;
                const {mmsi, lat, lon, vessel_class, shipname, callsign, course, speed, heading, timestamp, shiptype, nav_status} = v;
                const moving = speed != null && !isNaN(speed) && speed >= 1.5;
                const bearingForOverlay = moving ? (course != null ? course : heading) : (heading != null ? heading : course);
                const iconKey = vesselIconTintKey(vessel_class, shiptype, nav_status);
                const icon = getVesselDivIcon(vessel_class, shiptype, nav_status);

                if (vesselMarkers.has(mmsi)) {
                    const mk = vesselMarkers.get(mmsi);
                    _ensureAisMarkerPane(mk);
                    mk._bearingForOverlay = bearingForOverlay;
                    const cl = mk.getLatLng();
                    if (Math.abs(cl.lat - lat) > 0.0001 || Math.abs(cl.lng - lon) > 0.0001) mk.setLatLng([lat, lon]);
                    const ci = mk.options.icon;
                    if (!ci || mk._vesselIconKey !== iconKey) {
                        mk.setIcon(icon);
                        mk._vesselIconKey = iconKey;
                        requestAnimationFrame(() => setIconRotation(mk, bearingForOverlay));
                    } else setIconRotation(mk, bearingForOverlay);
                } else {
                    const mk = L.marker([lat, lon], { icon, pane: AIS_MARKER_PANE || undefined }).addTo(map);
                    mk.on('click', () => { try { VesselInfoWindow.open(mmsi); } catch (_) {} });
                    mk._bearingForOverlay = bearingForOverlay;
                    mk._vesselIconKey = iconKey;
                    requestAnimationFrame(() => setIconRotation(mk, bearingForOverlay));
                    vesselMarkers.set(mmsi, mk);
                }
                const age = timestamp ? (now - timestamp) : Infinity;
                const isChosen = openPopups.has(mmsi) || String(mmsi) === String(selectedMmsi);
                updateVesselOverlay(mmsi, lat, lon, isChosen, age >= LOSING_TARGET_TIME && age < REMOVE_TARGET_TIME, bearingForOverlay);

                // Motion overlays:
                // - Vector: ahead by SOG/COG for 1 minute; if ROT is available, bend the vector.
                // - Trail: dashed path behind using recent positions.
                let refLat = lat, refLon = lon;
                const dims = _dimsFromVessel(v);
                const brg = _bearingForHull(v);
                const refMode = (dims && brg != null) ? 'center' : 'antenna';
                if (vesselTrailRefMode.get(mmsi) !== refMode) {
                    vesselTrailRefMode.set(mmsi, refMode);
                    vesselHistory.delete(mmsi); // reset when switching reference point
                }
                if (refMode === 'center') {
                    const c = _centerFromAntenna(lat, lon, brg, dims);
                    refLat = c.lat;
                    refLon = c.lon;
                }
                const speedForVector = (speed != null && !isNaN(speed) && speed >= 0 && speed <= 60) ? speed : null;
                const courseForVector = (course != null && !isNaN(course)) ? course : null;
                updateVesselVector(mmsi, refLat, refLon, courseForVector, speedForVector, v.rot);
                updateVesselTrail(mmsi, refLat, refLon, timestamp);

                // Cache for zoom-driven redraw, and update detailed overlays (true-size hull + antenna).
                vesselLastData.set(mmsi, v);
                updateVesselDetailOverlays(v);
                try { VesselInfoWindow.refreshIfOpen(mmsi); } catch (_) {}

                // Proximity logic: warning banner + highlight nearby targets
                if (v._distNM != null && isFinite(v._distNM)) {
                    if (warnRadiusNm > 0 && v._distNM <= warnRadiusNm) {
                        _dangerState.any = true;
                        _dangerState.count += 1;
                        if (_dangerState.minNm == null || v._distNM < _dangerState.minNm) {
                            _dangerState.minNm = v._distNM;
                            // Relative bearing to the closest target (for the banner)
                            const os = getOwnShipPos();
                            const osHd = os ? (os.heading != null ? os.heading : os.course) : null;
                            if (os && os.lat != null && os.lon != null && osHd != null) {
                                const abs = bearingDeg(os.lat, os.lon, v.lat, v.lon);
                                _dangerState.relDegSigned = relBearingSignedDeg(osHd, abs);
                            } else {
                                _dangerState.relDegSigned = null;
                            }
                        }
                    }
                    const mk = vesselMarkers.get(mmsi);
                    if (mk && mk._icon) {
                        const near = nearRadiusNm > 0 && v._distNM <= nearRadiusNm;
                        mk._icon.classList.toggle('vessel-nearby', !!near);
                    }
                }
            } catch (e) {
                console.error('updateVessels vessel:', e, v);
                _clientLog('error', 'updateVessels per-vessel failed', {
                    err: String(e && (e.stack || e.message || e)),
                    vessel: v || null,
                });
            }
        }
        lastVisibleVessels = vessels.filter(v => visibleSet.has(v.mmsi)).map(v => Object.assign({}, v, { kind: 'vessel' }));
        adjustSidebarHeight();
        updateRangeCircle();
        updateDangerCircles();
        updateDangerBanner();
        if (vessels.length > 0 && vesselMarkers.size > 0) {
            const f = vessels[0];
            if (f.lat && f.lon && map.getZoom() === 10 && Math.abs(map.getCenter().lat - 55.751244) < 0.001)
                map.setView([f.lat, f.lon], 12);
        }
    } catch (e) {
        console.error('updateVessels:', e);
        _clientLog('error', 'updateVessels failed', { err: String(e && (e.stack || e.message || e)) });
        _setDebugHud('AISMap debug HUD\nbuild: ' + APP_BUILD + '\nupdateVessels ERROR:\n' + String(e && (e.message || e)));
    }
}

map.on('zoomend', () => {
    // Re-evaluate which vessels should show true-size hull at this zoom.
    for (const [mmsi, v] of vesselLastData.entries()) {
        if (!vesselMarkers.has(mmsi)) continue;
        updateVesselDetailOverlays(v);
    }
});

// ===================== Список целей: MID→флаг, поиск, фильтры, сортировка =====================
const mmsiMidToIso2 = {};
let lastSidebarVessels = [];

function escHtml(s) {
    if (s == null || s === '') return '';
    return String(s)
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#39;');
}

function mmsiToIso2FromMid(mmsi) {
    const d = String(mmsi).replace(/\D/g, '');
    if (d.length < 3) return null;
    const mid = d.slice(0, 3);
    return mmsiMidToIso2[mid] || null;
}

function iso2ToFlagEmoji(iso2) {
    if (!iso2 || iso2.length !== 2) return '';
    const a = iso2.toUpperCase();
    if (a.length !== 2 || a < 'AA' || a > 'ZZ') return '';
    const A = 0x1F1E6;
    return String.fromCodePoint(A + a.charCodeAt(0) - 65, A + a.charCodeAt(1) - 65);
}

function vesselSearchHaystack(v) {
    const parts = [
        String(v.mmsi != null ? v.mmsi : ''),
        (v.shipname && String(v.shipname).trim()) || '',
        (v.callsign && String(v.callsign).trim()) || '',
        (v.aton_type_label && String(v.aton_type_label).trim()) || '',
        (v.aton_type != null ? String(atonTypeLabel(v.aton_type) || '') : ''),
    ];
    return parts.join('\u0000').toLowerCase();
}

function matchesShiptypeFilter(v, group) {
    if (!group || group === 'all') return true;
    if (v && v.kind && v.kind !== 'vessel') return false;
    const st = v.shiptype;
    const c = parseInt(st, 10);
    if (group === 'unknown') return st == null || st === '' || c === 0 || isNaN(c);
    if (isNaN(c)) return group === 'unknown';
    if (group === 'fishing') return c === 30;
    if (group === 'tug') return c === 31 || c === 32;
    if (group === 'passenger') return c >= 60 && c <= 69;
    if (group === 'cargo') return c >= 70 && c <= 79;
    if (group === 'tanker') return c >= 80 && c <= 89;
    if (group === 'other') {
        if (c === 0) return false;
        if (c >= 60 && c <= 89) return false;
        if (c === 30 || c === 31 || c === 32) return false;
        return true;
    }
    return true;
}

function sortVesselsInPlace(list, mode) {
    const nameKey = v => ((v.shipname && String(v.shipname).trim()) || '\uFFFF').toLowerCase();
    switch (mode) {
        case 'dist-desc':
            list.sort((a, b) => {
                const da = a._distNM != null && isFinite(a._distNM) ? a._distNM : -1;
                const db = b._distNM != null && isFinite(b._distNM) ? b._distNM : -1;
                return db - da;
            });
            break;
        case 'dist-asc':
            list.sort((a, b) => {
                const da = a._distNM != null && isFinite(a._distNM) ? a._distNM : 1e12;
                const db = b._distNM != null && isFinite(b._distNM) ? b._distNM : 1e12;
                return da - db;
            });
            break;
        case 'time-asc':
            list.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
            break;
        case 'name-asc':
            list.sort((a, b) => nameKey(a).localeCompare(nameKey(b), 'ru', { sensitivity: 'base' }));
            break;
        case 'mmsi-asc':
            list.sort((a, b) => String(a.mmsi).localeCompare(String(b.mmsi), undefined, { numeric: true }));
            break;
        case 'class-asc':
            list.sort((a, b) => String(a.vessel_class || '').localeCompare(String(b.vessel_class || '')) ||
                String(a.mmsi).localeCompare(String(b.mmsi), undefined, { numeric: true }));
            break;
        case 'speed-desc':
            list.sort((a, b) => {
                const sa = a.speed != null && !isNaN(a.speed) ? a.speed : -1;
                const sb = b.speed != null && !isNaN(b.speed) ? b.speed : -1;
                return sb - sa;
            });
            break;
        case 'time-desc':
        default:
            list.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
            break;
    }
}

function renderVesselSidebar() {
    const vl = document.getElementById('vessel-list');
    const cntEl = document.getElementById('vessel-count');
    const suffEl = document.getElementById('vessel-count-suffix');
    if (!vl || !cntEl) return;

    const total = lastSidebarVessels.length;
    const maxNM = getRangeNM();
    const clsSel = document.getElementById('vessel-filter-class');
    const stSel = document.getElementById('vessel-filter-shiptype');
    const searchEl = document.getElementById('vessel-search');
    const sortEl = document.getElementById('vessel-sort');

    const cls = clsSel ? clsSel.value : 'all';
    const stG = stSel ? stSel.value : 'all';
    const q = (searchEl && searchEl.value ? searchEl.value : '').trim().toLowerCase();
    const sortMode = sortEl ? sortEl.value : 'time-desc';

    const list = lastSidebarVessels.filter(v => {
        // Range filter: if we have a computed distance, enforce it; if we don't (no ownship fix),
        // keep the item visible to avoid "empty list" confusion.
        if (isFinite(maxNM) && v && v._distNM != null && isFinite(v._distNM) && v._distNM > maxNM) return false;
        if (cls !== 'all' && String(v.vessel_class || '') !== cls) return false;
        if (!matchesShiptypeFilter(v, stG)) return false;
        if (q) {
            const hay = vesselSearchHaystack(v);
            if (!hay.includes(q)) return false;
        }
        return true;
    });

    sortVesselsInPlace(list, sortMode);

    cntEl.textContent = String(list.length);
    if (suffEl) suffEl.textContent = list.length !== total ? ' из ' + total : '';

    vl.innerHTML = '';
    for (const v of list) {
        const { mmsi, lat, lon, vessel_class, shipname, callsign, speed, course, heading, timestamp } = v;
        const mmsiKey = String(mmsi);
        const it = document.createElement('div');
        it.className = 'vessel-item';
        it.dataset.mmsi = mmsiKey;

        const hasCoord = lat != null && lon != null && !isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180;
        if (!hasCoord) it.classList.add('vessel-item--no-pos');

        const iso2 = mmsiToIso2FromMid(mmsi);
        const flag = iso2ToFlagEmoji(iso2);
        const flagTitle = iso2 || '';

        let h = '<div class="vessel-mmsi-row"><span class="mmsi">' + escHtml(mmsi) + '</span>';
        if (flag) h += '<span class="vessel-flag" title="' + escHtml(flagTitle) + '">' + flag + '</span>';
        h += '</div>';
        if (shipname) h += '<div class="name">' + escHtml(shipname) + '</div>';
        if (callsign && String(callsign).trim()) h += '<div class="callsign-row">Позывной: ' + escHtml(String(callsign).trim()) + '</div>';
        const clsLabel = (vessel_class === 'BS') ? 'База' : (vessel_class === 'N') ? 'Буёк' : (vessel_class || '?');
        h += '<div>Класс: ' + escHtml(clsLabel) + '</div>';
        if (String(vessel_class) === 'N' && v.aton_type != null && v.aton_type !== '') {
            h += '<div class="coords">Тип: ' + escHtml(v.aton_type_label || atonTypeLabel(v.aton_type) || 'Тип СНО не указан') + '</div>';
        }
        // Ship dimensions (AIS static): show only when we have both length and beam.
        const toBow = (typeof v.to_bow === 'number' && isFinite(v.to_bow)) ? v.to_bow : parseInt(v.to_bow, 10);
        const toStern = (typeof v.to_stern === 'number' && isFinite(v.to_stern)) ? v.to_stern : parseInt(v.to_stern, 10);
        const toPort = (typeof v.to_port === 'number' && isFinite(v.to_port)) ? v.to_port : parseInt(v.to_port, 10);
        const toStar = (typeof v.to_starboard === 'number' && isFinite(v.to_starboard)) ? v.to_starboard : parseInt(v.to_starboard, 10);
        const lenM = (isFinite(toBow) ? Math.max(0, toBow) : 0) + (isFinite(toStern) ? Math.max(0, toStern) : 0);
        const beamM = (isFinite(toPort) ? Math.max(0, toPort) : 0) + (isFinite(toStar) ? Math.max(0, toStar) : 0);
        if (lenM > 0 && beamM > 0) h += '<div class="coords">Размер: ' + lenM + '×' + beamM + ' м</div>';
        const _osP = getOwnShipPos();
        const _brg = (_osP && hasCoord) ? bearingDeg(_osP.lat, _osP.lon, lat, lon) : null;
        if (v._distNM != null || _brg != null) {
            let distLine = '<div class="dist" title="Дальность · пеленг от своего судна">';
            if (v._distNM != null) distLine += '<span class="dist-val">' + fmtDist(v._distNM) + '</span>';
            if (_brg != null) distLine += '<span class="brg-val">' + _brg.toFixed(0) + '°</span>';
            distLine += '</div>';
            h += distLine;
        }
        if (hasCoord) h += '<div class="coords">' + lat.toFixed(6) + ', ' + lon.toFixed(6) + '</div>';
        else h += '<div class="coords coords--no-pos">Нет координат</div>';
        if (timestamp != null) h += '<div class="coords">' + fmtTime(timestamp) + ' (' + fmtAgo(timestamp) + ')</div>';
        // Unified SOG/COG/HDG stats row (styled as inline row on desktop, compact grid on mobile).
        // Replaces the separate "Скорость:"/"Курс:" lines that used to appear below (duplicated the stats row).
        h += '<div class="compact-stats">' +
            '<span title="SOG — Speed Over Ground, скорость относительно земли">' +
                '<span class="k">SOG</span><b>' + (speed != null ? escHtml(fmtSpeed(speed)) : '—') + '</b></span>' +
            '<span title="COG — Course Over Ground, истинный курс">' +
                '<span class="k">COG</span><b>' + (course != null ? course.toFixed(0) + '°' : '—') + '</b></span>' +
            '<span title="HDG — Heading, направление носа судна">' +
                '<span class="k">HDG</span><b>' + (heading != null ? heading.toFixed(0) + '°' : '—') + '</b></span>' +
        '</div>';
        it.innerHTML = h;
        if (hasCoord) {
            if (mmsiKey === (selectedMmsi != null ? String(selectedMmsi) : null)) it.classList.add('selected');
            it.addEventListener('click', () => {
                selectedMmsi = mmsiKey;
                document.querySelectorAll('.vessel-item').forEach(e => e.classList.remove('selected'));
                it.classList.add('selected');
                map.setView([lat, lon], Math.max(map.getZoom(), 15), { animate: false });
                try { VesselInfoWindow.open(mmsi); } catch (_) {}
            });
        }
        vl.appendChild(it);
    }
}

function updateSidebar(vessels) {
    lastSidebarVessels = vessels;
    renderVesselSidebar();
    try { NearbyHud.render(); } catch (_) {}
}

function initVesselListControls() {
    const search = document.getElementById('vessel-search');
    const sortEl = document.getElementById('vessel-sort');
    const cls = document.getElementById('vessel-filter-class');
    const st = document.getElementById('vessel-filter-shiptype');
    if (!search || !sortEl || !cls || !st) return;

    const SK = 'vesselList';
    const load = (k, def) => sGet(SK + '_' + k, def);
    sortEl.value = load('sort', 'time-desc');
    cls.value = load('fclass', 'all');
    st.value = load('ftype', 'all');
    search.value = load('search', '');
    const persist = () => {
        sSet(SK + '_sort', sortEl.value);
        sSet(SK + '_fclass', cls.value);
        sSet(SK + '_ftype', st.value);
        sSet(SK + '_search', search.value);
        renderVesselSidebar();
    };
    search.addEventListener('input', () => {
        sSet(SK + '_search', search.value);
        renderVesselSidebar();
    });
    sortEl.addEventListener('change', persist);
    cls.addEventListener('change', persist);
    st.addEventListener('change', persist);
}

initVesselListControls();
fetch('/static/js/mmsi_mid_iso2.json')
    .then(r => (r.ok ? r.json() : {}))
    .then(o => { Object.assign(mmsiMidToIso2, o); renderVesselSidebar(); })
    .catch(() => {});

const cursorCoords=document.getElementById('cursor-coords');
function fmtZoomZ(){
    try { return map && typeof map.getZoom === 'function' ? String(map.getZoom()) : '?'; } catch(e){ return '?'; }
}
// Throttle cursor coordinate updates to 1/frame (mousemove can be very high frequency).
let _ccPendingLatLng = null;
let _ccRaf = 0;
let _ccLastText = '';
function _setCursorCoordsText(latlngOrNull){
    if (!cursorCoords) return;
    const z = fmtZoomZ();
    const txt = latlngOrNull
        ? ('Координаты: ' + latlngOrNull.lat.toFixed(6) + ', ' + latlngOrNull.lng.toFixed(6) + ' | Z: ' + z)
        : ('Координаты: - | Z: ' + z);
    if (txt !== _ccLastText) {
        cursorCoords.textContent = txt;
        _ccLastText = txt;
    }
}
function _scheduleCursorCoordsFlush(){
    if (_ccRaf) return;
    _ccRaf = requestAnimationFrame(() => {
        _ccRaf = 0;
        _setCursorCoordsText(_ccPendingLatLng);
    });
}
map.on('mousemove', (e) => {
    _ccPendingLatLng = e && e.latlng ? e.latlng : null;
    _scheduleCursorCoordsFlush();
});
map.on('mouseout', () => {
    _ccPendingLatLng = null;
    _scheduleCursorCoordsFlush();
});
map.on('zoomend', () => {
    // Keep current latlng text (if any) and refresh Z; do it in rAF too.
    _scheduleCursorCoordsFlush();
});

// ===================== OwnShip =====================
const ownShipIconSize=[22,48], ownShipAnchor=[11,48];
const iconOwnShip = L.icon({ iconUrl:'/svg/SVG/ownShip.svg', iconSize:ownShipIconSize, iconAnchor:ownShipAnchor, popupAnchor:[0,-48], className:'ownship-icon' });
let ownShipMarker=null, ownShipSource='nmea', followMode=false;
let nmeaGps=null, phoneGps=null, phoneWatchId=null, phoneGpsError=null;
let phoneCompassHeading=null, phoneCompassOk=false, phoneCompassListenerAdded=false;
let phoneGpsSmoothed=null;
let _lastFollowTs=0;
let _lastFollowLatLng=null;

function setOwnShipSource(src) {
    ownShipSource = (src === 'phone') ? 'phone' : 'nmea';
    if (ownShipSource === 'phone') startPhoneGps();
    else stopPhoneGps();
    sSet('ownShipSource', ownShipSource);
    try { if (typeof window._reflectGpsSourceUi === 'function') window._reflectGpsSourceUi(); } catch(_) {}
    updateOwnShipDisplay();
}
try {
    const savedSrc = sGet('ownShipSource', '');
    if (savedSrc === 'phone' || savedSrc === 'nmea') ownShipSource = savedSrc;
} catch (e) {}

function normalizeDeg(d){
    if(d==null || isNaN(d)) return null;
    d = d % 360;
    if(d < 0) d += 360;
    return d;
}

function lowPassAngleDeg(prev, next, alpha){
    if (prev == null) return normalizeDeg(next);
    const p = normalizeDeg(prev), n = normalizeDeg(next);
    if (p == null || n == null) return n;
    // shortest direction around 0..360
    let diff = n - p;
    if (diff > 180) diff -= 360;
    if (diff < -180) diff += 360;
    return normalizeDeg(p + diff * alpha);
}

function haversineMeters(a, b){
    const R = 6371000;
    const φ1 = a.lat * Math.PI/180, φ2 = b.lat * Math.PI/180;
    const dφ = (b.lat - a.lat) * Math.PI/180;
    const dλ = (b.lng - a.lng) * Math.PI/180;
    const s = Math.sin(dφ/2)**2 + Math.cos(φ1)*Math.cos(φ2)*Math.sin(dλ/2)**2;
    return 2 * R * Math.asin(Math.sqrt(s));
}

function pollOwnShip() {
    // Источник — ownship.update через WebSocket (заполняется в AisHub.ownship).
    try {
        const d = AisHub.ownship;
        if (d && d.lat != null && d.lon != null) nmeaGps = d;
    } catch (e) {}
}

function startPhoneGps() {
    phoneGpsError=null;
    if (!window.isSecureContext) { phoneGpsError='Требуется HTTPS. Откройте https://'; return; }
    if (!navigator.geolocation) { phoneGpsError='Geolocation API недоступен'; return; }
    if (phoneWatchId!=null) return;
    startPhoneCompass();
    phoneWatchId=navigator.geolocation.watchPosition(
        pos=>{
            phoneGpsError=null;
            const raw = {
                lat: pos.coords.latitude,
                lon: pos.coords.longitude,
                accuracy_m: pos.coords.accuracy,
                speed: pos.coords.speed!=null ? pos.coords.speed*1.94384 : null, // m/s -> kn
                course: normalizeDeg(pos.coords.heading),
                heading: normalizeDeg(pos.coords.heading),
                compass: normalizeDeg(phoneCompassHeading),
                satellites: null,
                timestamp: Math.floor(pos.timestamp/1000),
                source:'phone'
            };

            // Smooth position slightly to reduce jitter/“рывки”
            const acc = raw.accuracy_m != null ? raw.accuracy_m : Infinity;
            const alpha = acc <= 10 ? 0.35 : acc <= 25 ? 0.25 : acc <= 60 ? 0.15 : 0.0;
            if (!phoneGpsSmoothed || alpha === 0.0) {
                phoneGpsSmoothed = {lat: raw.lat, lon: raw.lon};
            } else {
                phoneGpsSmoothed.lat = phoneGpsSmoothed.lat + (raw.lat - phoneGpsSmoothed.lat) * alpha;
                phoneGpsSmoothed.lon = phoneGpsSmoothed.lon + (raw.lon - phoneGpsSmoothed.lon) * alpha;
            }
            raw.lat = phoneGpsSmoothed.lat;
            raw.lon = phoneGpsSmoothed.lon;

            // Heading: prefer GPS course when moving, else use compass if available
            const sp = raw.speed;
            const moving = sp != null && !isNaN(sp) && sp >= 1.5; // ~1.5 kn threshold
            const h = moving ? raw.course : (raw.compass != null ? raw.compass : raw.course);
            raw.heading = h;

            phoneGps = raw;
        },
        err=>{ phoneGpsError={1:'Доступ к GPS запрещён',2:'Не удалось определить',3:'Таймаут GPS'}[err.code]||err.message; },
        {enableHighAccuracy:true,maximumAge:1000,timeout:15000}
    );
}
function stopPhoneGps() {
    if(phoneWatchId!=null){navigator.geolocation.clearWatch(phoneWatchId);phoneWatchId=null;}
    phoneGpsError=null;
    phoneGpsSmoothed=null;
    stopPhoneCompass();
}

const OWN_SHIP_SPEED_VECTOR_SECONDS = 360;
const OWN_SHIP_SPEED_VECTOR_TICK_SECONDS = 60;
const OWN_SHIP_HEADING_LINE_M = 130;
const OWN_SHIP_BEAM_LINE_M = 70;
let ownShipHeadingLine = null;
let ownShipBeamLine = null;
let ownShipSpeedVectorShadow = null;
let ownShipSpeedVector = null;
let ownShipSpeedTicks = [];

function _removeLayerSafe(layer) {
    if (layer) {
        try { map.removeLayer(layer); } catch (_) {}
    }
}

function clearOwnShipMotionOverlays() {
    _removeLayerSafe(ownShipHeadingLine); ownShipHeadingLine = null;
    _removeLayerSafe(ownShipBeamLine); ownShipBeamLine = null;
    _removeLayerSafe(ownShipSpeedVectorShadow); ownShipSpeedVectorShadow = null;
    _removeLayerSafe(ownShipSpeedVector); ownShipSpeedVector = null;
    for (const tick of ownShipSpeedTicks) _removeLayerSafe(tick);
    ownShipSpeedTicks = [];
}

function _ensureOwnShipPolyline(current, opts) {
    if (current) return current;
    return L.polyline([], Object.assign({ renderer: _overlayCanvasRenderer, interactive: false }, opts)).addTo(map);
}

function _setOwnShipTickCount(count) {
    while (ownShipSpeedTicks.length < count) {
        ownShipSpeedTicks.push(L.polyline([], {
            renderer: _overlayCanvasRenderer,
            color: '#f0f6fc',
            weight: 2,
            opacity: 0.9,
            interactive: false,
        }).addTo(map));
    }
    while (ownShipSpeedTicks.length > count) {
        _removeLayerSafe(ownShipSpeedTicks.pop());
    }
}

function updateOwnShipMotionOverlays(data, headingDeg) {
    if (!data || data.lat == null || data.lon == null) {
        clearOwnShipMotionOverlays();
        return;
    }
    const lat = _numOrNull(data.lat);
    const lon = _numOrNull(data.lon);
    if (lat == null || lon == null) {
        clearOwnShipMotionOverlays();
        return;
    }
    const origin = [lat, lon];
    const hd = normalizeDeg(headingDeg);
    if (hd != null) {
        const headEnd = destPointMeters(lat, lon, hd, OWN_SHIP_HEADING_LINE_M);
        const beamA = destPointMeters(lat, lon, normalizeDeg(hd - 90), OWN_SHIP_BEAM_LINE_M / 2);
        const beamB = destPointMeters(lat, lon, normalizeDeg(hd + 90), OWN_SHIP_BEAM_LINE_M / 2);
        ownShipHeadingLine = _ensureOwnShipPolyline(ownShipHeadingLine, {
            color: '#f0f6fc',
            weight: 2,
            opacity: 0.95,
            lineCap: 'round',
        });
        ownShipHeadingLine.setLatLngs([origin, [headEnd.lat, headEnd.lon]]);
        ownShipBeamLine = _ensureOwnShipPolyline(ownShipBeamLine, {
            color: '#c9d1d9',
            weight: 2,
            opacity: 0.8,
            lineCap: 'round',
        });
        ownShipBeamLine.setLatLngs([[beamA.lat, beamA.lon], [beamB.lat, beamB.lon]]);
    } else {
        _removeLayerSafe(ownShipHeadingLine); ownShipHeadingLine = null;
        _removeLayerSafe(ownShipBeamLine); ownShipBeamLine = null;
    }

    const cog = normalizeDeg(data.course);
    const sog = _numOrNull(data.speed);
    if (cog == null || sog == null || sog < 0.2) {
        _removeLayerSafe(ownShipSpeedVectorShadow); ownShipSpeedVectorShadow = null;
        _removeLayerSafe(ownShipSpeedVector); ownShipSpeedVector = null;
        _setOwnShipTickCount(0);
        return;
    }
    const meters = sog * 1852 / 3600 * OWN_SHIP_SPEED_VECTOR_SECONDS;
    const end = destPointMeters(lat, lon, cog, meters);
    const pts = [origin, [end.lat, end.lon]];
    ownShipSpeedVectorShadow = _ensureOwnShipPolyline(ownShipSpeedVectorShadow, {
        color: '#0d1117',
        weight: 6,
        opacity: 0.75,
        dashArray: '12 10',
        lineCap: 'butt',
    });
    ownShipSpeedVectorShadow.setLatLngs(pts);
    ownShipSpeedVector = _ensureOwnShipPolyline(ownShipSpeedVector, {
        color: '#d2ff1a',
        weight: 3,
        opacity: 0.95,
        dashArray: '12 10',
        lineCap: 'butt',
    });
    ownShipSpeedVector.setLatLngs(pts);

    const tickCount = Math.floor(OWN_SHIP_SPEED_VECTOR_SECONDS / OWN_SHIP_SPEED_VECTOR_TICK_SECONDS);
    _setOwnShipTickCount(tickCount);
    const tickHalfM = 12;
    const mps = sog * 1852 / 3600;
    for (let i = 0; i < ownShipSpeedTicks.length; i++) {
        const distM = mps * OWN_SHIP_SPEED_VECTOR_TICK_SECONDS * (i + 1);
        const p = destPointMeters(lat, lon, cog, distM);
        const a = destPointMeters(p.lat, p.lon, normalizeDeg(cog - 90), tickHalfM);
        const b = destPointMeters(p.lat, p.lon, normalizeDeg(cog + 90), tickHalfM);
        ownShipSpeedTicks[i].setLatLngs([[a.lat, a.lon], [b.lat, b.lon]]);
    }
}

// Keep a handle to the Generic Sensor API fallback so we can stop it.
let _absOrientSensor = null;

function startPhoneCompass() {
    if (phoneCompassListenerAdded) return;
    phoneCompassListenerAdded = true;

    const addLegacy = () => {
        window.addEventListener('deviceorientationabsolute', onDeviceOrientation, true);
        window.addEventListener('deviceorientation', onDeviceOrientation, true);
    };

    // 1) Prefer Android-friendly Generic Sensor API when available.
    //    AbsoluteOrientationSensor gives true-north-referenced quaternion on Android
    //    (chrome://flags / modern Android Chrome have this enabled by default).
    async function tryAbsoluteOrientationSensor() {
        if (!('AbsoluteOrientationSensor' in window)) return false;
        try {
            // Permissions API (best-effort): some platforms require explicit grants.
            if (navigator.permissions && navigator.permissions.query) {
                try {
                    const res = await Promise.all([
                        navigator.permissions.query({ name: 'accelerometer' }),
                        navigator.permissions.query({ name: 'magnetometer' }),
                        navigator.permissions.query({ name: 'gyroscope' }),
                    ]);
                    if (res.some(r => r && r.state === 'denied')) return false;
                } catch (_) { /* some UAs don't know these names — just try */ }
            }
            const s = new window.AbsoluteOrientationSensor({ frequency: 30, referenceFrame: 'screen' });
            s.addEventListener('reading', () => {
                try {
                    const q = s.quaternion;
                    if (!q || q.length !== 4) return;
                    // Convert quaternion → heading (rotation about Z, compass = 360 - yaw)
                    const [x, y, z, w] = q;
                    // yaw (Z-axis rotation) in radians
                    const siny_cosp = 2 * (w * z + x * y);
                    const cosy_cosp = 1 - 2 * (y * y + z * z);
                    let yaw = Math.atan2(siny_cosp, cosy_cosp);
                    let deg = yaw * 180 / Math.PI;
                    // Normalize to compass heading (CW from north)
                    let heading = (360 - deg) % 360;
                    if (heading < 0) heading += 360;
                    phoneCompassHeading = lowPassAngleDeg(phoneCompassHeading, heading, 0.25);
                } catch (_) {}
            });
            s.addEventListener('error', () => { /* fall through to legacy */ addLegacy(); });
            s.start();
            _absOrientSensor = s;
            phoneCompassOk = true;
            return true;
        } catch (_) {
            return false;
        }
    }

    // 2) iOS 13+ requires explicit permission for DeviceOrientationEvent.
    const DOE = window.DeviceOrientationEvent;
    if (DOE && typeof DOE.requestPermission === 'function') {
        phoneCompassOk = false;
        phoneCompassHeading = null;
        const once = async () => {
            document.removeEventListener('click', once, true);
            try {
                const r = await DOE.requestPermission();
                if (r === 'granted') {
                    const ok = await tryAbsoluteOrientationSensor();
                    if (!ok) addLegacy();
                    phoneCompassOk = true;
                }
            } catch(e) {}
        };
        document.addEventListener('click', once, true);
        return;
    }

    // 3) Non-iOS path: try Generic Sensor API first, fall back to deviceorientation.
    tryAbsoluteOrientationSensor().then(ok => {
        if (!ok) {
            phoneCompassOk = true;
            addLegacy();
        }
    });
}

function stopPhoneCompass() {
    if (_absOrientSensor) {
        try { _absOrientSensor.stop(); } catch (_) {}
        _absOrientSensor = null;
    }
    if (!phoneCompassOk) { phoneCompassHeading = null; return; }
    window.removeEventListener('deviceorientationabsolute', onDeviceOrientation, true);
    window.removeEventListener('deviceorientation', onDeviceOrientation, true);
    phoneCompassHeading = null;
    phoneCompassOk = false;
    phoneCompassListenerAdded = false;
}

function onDeviceOrientation(e) {
    // alpha is 0..360 deg; in many browsers it's “compass heading” when absolute
    let a = null;
    if (typeof e.webkitCompassHeading === 'number') {
        a = e.webkitCompassHeading; // iOS
    } else if (typeof e.alpha === 'number') {
        // On a number of browsers/devices `alpha` grows counter-clockwise, which makes map rotation feel inverted.
        // Convert to a compass-like clockwise heading.
        a = 360 - e.alpha;
    }
    if (a == null) return;
    phoneCompassHeading = lowPassAngleDeg(phoneCompassHeading, a, 0.22);
}

function setOwnShipRotation(mk,hd) {
    const h = (hd != null && !isNaN(hd)) ? normalizeDeg(hd) : null;

    // Desired behavior:
    // - When map is rotated by compass (heading-up), keep the ownship marker fixed "up" on screen.
    // - Otherwise, rotate the marker to show heading.
    const desiredDeg = (rotateMapByCompass ? 0 : (h != null ? h : 0));

    // If leaflet-rotate is present, use its supported per-marker rotation option.
    // Plugin expects radians.
    try {
        if (map && typeof map.getBearing === 'function' && mk && mk.options) {
            mk.options.rotation = (desiredDeg * Math.PI / 180);
            if (typeof mk.update === 'function') mk.update();
            return;
        }
    } catch (e) {}

    // Fallback (no rotate plugin): manual CSS rotation for the icon.
    const el = mk._icon; if(!el) return;
    mk._rotationHeading = desiredDeg;
    if(!el.dataset.osO){ el.style.transformOrigin=ownShipAnchor[0]+'px '+ownShipAnchor[1]+'px'; el.dataset.osO='1';
        if(!mk._origSetPos){mk._origSetPos=mk._setPos; mk._setPos=function(p){this._origSetPos.call(this,p);const ic=this._icon;if(ic&&this._rotationHeading!=null){const m=(ic.style.transform||'').match(/translate3d\([^)]+\)/);if(m)ic.style.transform=m[0]+' rotate('+this._rotationHeading+'deg)';}};}
    }
    const t=(el.style.transform||'').match(/translate3d\([^)]+\)/);
    if(t) el.style.transform=desiredDeg!=null?t[0]+' rotate('+desiredDeg+'deg)':t[0];
    else if(desiredDeg!=null) el.style.transform='rotate('+desiredDeg+'deg)';
}

function updateOwnShipDisplay() {
    try { _initOwnshipCompassUiOnce(); } catch (e) {}
    const data=ownShipSource==='phone'?phoneGps:nmeaGps;
    const src=document.getElementById('os-source'),co=document.getElementById('os-coords'),cr=document.getElementById('os-course'),sp=document.getElementById('os-speed'),sa=document.getElementById('os-sats');
    if(!data||data.lat==null||data.lon==null){
        if(ownShipSource==='phone'&&phoneGpsError){src.textContent=phoneGpsError;src.style.color='#f85149';}
        else{src.textContent=ownShipSource==='phone'?'GPS телефона (ожидание...)':'Внутр. GPS (нет данных)';src.style.color='';}
        co.textContent='-';cr.textContent='-';sp.textContent='-';sa.textContent='-';
        _lastOwnshipHeadingForRotate = null;
        _setOwnshipCompassUi();
        if (rotateMapByCompass) _applyMapRotation();
        if(ownShipMarker){map.removeLayer(ownShipMarker);ownShipMarker=null;}
        clearOwnShipMotionOverlays();
        return;
    }
    src.style.color=''; src.textContent=ownShipSource==='phone'?'GPS телефона':'Внутр. GPS (NMEA)';
    co.textContent=data.lat.toFixed(6)+', '+data.lon.toFixed(6);
    cr.textContent=data.course!=null?data.course.toFixed(1)+'°':'-';
    sp.textContent=data.speed!=null?fmtSpeed(data.speed):'-';
    sa.textContent=data.satellites!=null?data.satellites:'-';
    // Prefer true heading when available; course can be misleading when drifting / low speed.
    const hd = (data.heading != null && !isNaN(data.heading)) ? data.heading
        : (data.course != null && !isNaN(data.course)) ? data.course
        : null;
    // Compass + map rotation (heading-up mode)
    _lastOwnshipHeadingForRotate = (hd != null && !isNaN(hd)) ? normalizeDeg(hd) : null;
    _setOwnshipCompassUi();
    if (rotateMapByCompass) _applyMapRotation();
    if(!ownShipMarker){ ownShipMarker=L.marker([data.lat,data.lon],{icon:iconOwnShip,zIndexOffset:2000}).addTo(map).bindPopup('Своё судно'); requestAnimationFrame(()=>setOwnShipRotation(ownShipMarker,hd)); }
    else{ const c=ownShipMarker.getLatLng(); if(Math.abs(c.lat-data.lat)>1e-6||Math.abs(c.lng-data.lon)>1e-6) ownShipMarker.setLatLng([data.lat,data.lon]); setOwnShipRotation(ownShipMarker,hd); }
    updateOwnShipMotionOverlays(data, hd);
    if(followMode){
        const now = Date.now();
        const graceUntil = (typeof window._followUserPanGraceUntil === 'function') ? window._followUserPanGraceUntil() : 0;
        if (now >= graceUntil) {
            const ll = L.latLng(data.lat, data.lon);
            const minDt = 350; // ms
            const minMoveM = 2.5;
            const canTime = (now - _lastFollowTs) >= minDt;
            const canMove = !_lastFollowLatLng || haversineMeters(_lastFollowLatLng, ll) >= minMoveM;
            if (canTime && canMove) {
                _lastFollowTs = now;
                _lastFollowLatLng = ll;
                map.panTo(ll, {animate: true, duration: 0.25});
            }
        }
    }
}

// ===== GPS source + Follow (navigator) — integrated into #map-controls =====
(function initMapControlsGpsFollow(){
    const gpsBtn = document.getElementById('mc-gps-src');
    const followBtn = document.getElementById('mc-follow');
    const iconNmea = gpsBtn ? gpsBtn.querySelector('.mc-gps-icon-nmea') : null;
    const iconPhone = gpsBtn ? gpsBtn.querySelector('.mc-gps-icon-phone') : null;

    function reflectGpsSourceUi() {
        if (!gpsBtn) return;
        gpsBtn.dataset.src = ownShipSource;
        gpsBtn.classList.toggle('active', ownShipSource === 'phone');
        if (iconNmea) iconNmea.style.display = (ownShipSource === 'nmea') ? '' : 'none';
        if (iconPhone) iconPhone.style.display = (ownShipSource === 'phone') ? '' : 'none';
        gpsBtn.title = 'Источник GPS: ' + (ownShipSource === 'phone' ? 'телефон (переключить на внутренний NMEA)' : 'внутренний NMEA (переключить на телефон)');
    }
    window._reflectGpsSourceUi = reflectGpsSourceUi;

    try { setOwnShipSource(ownShipSource); } catch (e) {}
    reflectGpsSourceUi();

    if (gpsBtn) {
        gpsBtn.addEventListener('click', function(){
            const next = (ownShipSource === 'phone') ? 'nmea' : 'phone';
            setOwnShipSource(next);
            reflectGpsSourceUi();
        });
    }

    // ---- Follow / Navigator mode ----
    // State: auto-return to ship after user pan; auto-zoom depending on speed.
    let _followUserPanUntil = 0;          // "thaw" timestamp — do not recentre for this many ms after user drag
    let _followAutoZoomLast = null;       // last zoom applied by speed logic
    let _followProgrammaticMove = false;  // set during our own panTo/setView so dragstart/zoomend don't turn off

    function speedToZoom(sogKn) {
        if (sogKn == null || isNaN(sogKn) || sogKn < 0) return 15;
        if (sogKn < 1) return 16;
        if (sogKn < 4) return 15;
        if (sogKn < 10) return 14;
        if (sogKn < 18) return 13;
        if (sogKn < 26) return 12;
        return 11;
    }

    function setFollow(on) {
        followMode = !!on;
        if (followBtn) followBtn.classList.toggle('active', followMode);
        if (followMode) {
            _followUserPanUntil = 0;
            updateOwnShipDisplay();
            try { maybeApplyFollowZoom(true); } catch(_) {}
        }
    }
    window._setFollowMode = setFollow;

    function maybeApplyFollowZoom(force) {
        if (!followMode) return;
        const d = ownShipSource === 'phone' ? phoneGps : nmeaGps;
        if (!d || d.lat == null || d.lon == null) return;
        const desired = speedToZoom(d.speed);
        const cur = map.getZoom();
        // Only act when difference is meaningful, or when forced.
        if (force || _followAutoZoomLast == null || Math.abs(cur - desired) >= 1) {
            if (cur !== desired) {
                _followProgrammaticMove = true;
                try { map.setZoom(desired, { animate: true }); } catch(_) {}
                setTimeout(() => { _followProgrammaticMove = false; }, 300);
            }
            _followAutoZoomLast = desired;
        }
    }
    // Re-evaluate zoom periodically while follow is on
    setInterval(() => { try { maybeApplyFollowZoom(false); } catch(_) {} }, 4000);

    if (followBtn) {
        followBtn.addEventListener('click', function(){ setFollow(!followMode); });
    }

    // When user drags the map, pause auto-recentre for ~4s (navigator-like behaviour:
    // you can glance around and it snaps back). A deliberate long pan still keeps follow
    // on, it just cools down a bit.
    map.on('dragstart', () => {
        if (_followProgrammaticMove) return;
        if (followMode) _followUserPanUntil = Date.now() + 4000;
    });
    map.on('zoomstart', () => {
        if (_followProgrammaticMove) return;
        // User-initiated zoom pauses auto-zoom for ~20s so manual override sticks.
        if (followMode) _followAutoZoomLast = map.getZoom();
    });

    // Hook pan behaviour into updateOwnShipDisplay by patching the check via a flag.
    window._followUserPanGraceUntil = () => _followUserPanUntil;
})();

// ===================== Sidebar / OwnShip panel sizing =====================
function adjustSidebarHeight() {
    const sidebar = document.getElementById('sidebar');
    const ownship = document.getElementById('ownship-panel');
    const container = document.getElementById('page-map');
    if (!sidebar || !ownship || !container || window.innerWidth <= 600) {
        sidebar.style.maxHeight = '';
        return;
    }
    const containerH = container.clientHeight;
    const ownshipH = ownship.offsetHeight;
    sidebar.style.maxHeight = Math.max(120, containerH - ownshipH - 24) + 'px';
}
window.addEventListener('resize', adjustSidebarHeight);

// ===================== Mobile panel tabs =====================
function updateMobCursorCoords() {
    // Same breakpoint as CSS: mobile width OR small height on touch devices
    const isMobileLayout = (window.innerWidth <= 600) || (window.matchMedia && window.matchMedia('(max-height:520px) and (pointer:coarse)').matches);
    if (!isMobileLayout) { document.getElementById('cursor-coords').style.bottom = ''; return; }
    const cc = document.getElementById('cursor-coords');
    const sb = document.getElementById('sidebar');
    const os = document.getElementById('ownship-panel');
    const isLandscapeCompact = window.matchMedia && window.matchMedia('(max-height:520px) and (pointer:coarse)').matches;
    if (isLandscapeCompact) {
        // In landscape mode panels are on the right, keep coords near bottom-left.
        cc.style.bottom = '8px';
        return;
    }
    const bar = document.getElementById('mob-panel-bar');
    const barH = bar ? Math.round(bar.getBoundingClientRect().height || 0) : 0;
    let panelH = 0;
    if (sb.classList.contains('mob-open')) panelH = sb.offsetHeight;
    else if (os.classList.contains('mob-open')) panelH = os.offsetHeight;
    cc.style.bottom = (barH + panelH + 8) + 'px';
}
(function() {
    const tabs = document.querySelectorAll('.mob-panel-tab');
    const sidebarEl = document.getElementById('sidebar');
    const ownshipEl = document.getElementById('ownship-panel');
    const barEl = document.getElementById('mob-panel-bar');

    function closeMobPanels() {
        tabs.forEach(t => t.classList.remove('active'));
        sidebarEl.classList.remove('mob-open');
        ownshipEl.classList.remove('mob-open');
        setTimeout(() => { try { map.invalidateSize(); } catch (_) {} updateMobCursorCoords(); }, 50);
    }

    // Allow drag-to-close on the mobile bar (prevents instinctive "pull to refresh").
    try {
        if (barEl) barEl.style.touchAction = 'none';
    } catch (e) {}
    (function installBarDragToClose() {
        if (!barEl) return;
        let dragging = false;
        let startX = 0, startY = 0;
        let lastX = 0, lastY = 0;
        let pointerId = null;

        const isLandscapeCompact = () => (window.matchMedia && window.matchMedia('(max-height:520px) and (pointer:coarse)').matches);
        const thresholdPx = 26;

        function anyPanelOpen() {
            return (sidebarEl && sidebarEl.classList.contains('mob-open')) || (ownshipEl && ownshipEl.classList.contains('mob-open'));
        }

        barEl.addEventListener('pointerdown', (e) => {
            try { e.preventDefault(); } catch (_) {}
            if (!anyPanelOpen()) return; // nothing to close
            dragging = true;
            pointerId = e.pointerId;
            startX = lastX = e.clientX;
            startY = lastY = e.clientY;
            try { barEl.setPointerCapture(pointerId); } catch (_) {}
        }, { passive: false });

        barEl.addEventListener('pointermove', (e) => {
            if (!dragging || (pointerId != null && e.pointerId !== pointerId)) return;
            try { e.preventDefault(); } catch (_) {}
            lastX = e.clientX;
            lastY = e.clientY;
            const dx = lastX - startX;
            const dy = lastY - startY;
            if (isLandscapeCompact()) {
                // Bar is on the right; drag further right to close.
                if (dx > thresholdPx) {
                    dragging = false;
                    closeMobPanels();
                }
            } else {
                // Bar is at the bottom; drag down to close.
                if (dy > thresholdPx) {
                    dragging = false;
                    closeMobPanels();
                }
            }
        }, { passive: false });

        function endDrag(e) {
            if (!dragging) return;
            if (pointerId != null && e && e.pointerId !== pointerId) return;
            dragging = false;
            pointerId = null;
        }

        barEl.addEventListener('pointerup', endDrag, { passive: true });
        barEl.addEventListener('pointercancel', endDrag, { passive: true });
        window.addEventListener('blur', () => { dragging = false; pointerId = null; });
    })();

    // Swipe-down-to-close on the panels themselves (the "sheet" UX).
    // Only the top grip zone initiates a swipe — otherwise the user can scroll the list inside.
    (function installSheetSwipeToClose() {
        const GRIP_ZONE_PX = 26;
        const CLOSE_THRESHOLD_PX = 64;
        const isLandscapeCompact = () => (window.matchMedia && window.matchMedia('(max-height:520px) and (pointer:coarse)').matches);

        function install(el) {
            if (!el) return;
            let dragging = false, startX = 0, startY = 0, pointerId = null, curDelta = 0;

            function reset(animated) {
                el.classList.remove('mob-swiping');
                if (animated) {
                    el.style.transition = 'transform .22s ease';
                } else {
                    el.style.transition = '';
                }
                el.style.transform = '';
                setTimeout(() => { try { el.style.transition = ''; } catch(_) {} }, 260);
            }

            el.addEventListener('pointerdown', (e) => {
                if (!el.classList.contains('mob-open')) return;
                const rect = el.getBoundingClientRect();
                const localY = e.clientY - rect.top;
                const localX = e.clientX - rect.left;
                // Restrict starting zone to the top grip (portrait) or right grip (landscape).
                if (isLandscapeCompact()) {
                    if (localX > GRIP_ZONE_PX) return; // right-side bar means the grip is on the LEFT edge of the panel itself
                } else {
                    if (localY > GRIP_ZONE_PX) return;
                }
                dragging = true;
                pointerId = e.pointerId;
                startX = e.clientX; startY = e.clientY;
                curDelta = 0;
                el.classList.add('mob-swiping');
                try { el.setPointerCapture(pointerId); } catch(_) {}
            }, { passive: true });

            el.addEventListener('pointermove', (e) => {
                if (!dragging || (pointerId != null && e.pointerId !== pointerId)) return;
                const dy = e.clientY - startY;
                const dx = e.clientX - startX;
                if (isLandscapeCompact()) {
                    if (dx > 0) {
                        curDelta = dx;
                        el.style.transform = 'translateX(' + dx + 'px)';
                    }
                } else {
                    if (dy > 0) {
                        curDelta = dy;
                        el.style.transform = 'translateY(' + dy + 'px)';
                    }
                }
            }, { passive: true });

            function endSwipe(e) {
                if (!dragging) return;
                if (pointerId != null && e && e.pointerId !== pointerId) return;
                dragging = false;
                pointerId = null;
                if (curDelta > CLOSE_THRESHOLD_PX) {
                    // Animate off-screen then close
                    el.style.transition = 'transform .18s ease-in';
                    el.style.transform = isLandscapeCompact() ? 'translateX(120%)' : 'translateY(120%)';
                    setTimeout(() => {
                        closeMobPanels();
                        reset(false);
                    }, 170);
                } else {
                    reset(true);
                }
                curDelta = 0;
            }
            el.addEventListener('pointerup', endSwipe, { passive: true });
            el.addEventListener('pointercancel', endSwipe, { passive: true });
        }

        install(sidebarEl);
        install(ownshipEl);
    })();

    tabs.forEach(tab => {
        tab.addEventListener('click', () => {
            const panel = tab.dataset.panel;
            const wasActive = tab.classList.contains('active');
            tabs.forEach(t => t.classList.remove('active'));
            sidebarEl.classList.remove('mob-open');
            ownshipEl.classList.remove('mob-open');
            if (!wasActive) {
                tab.classList.add('active');
                if (panel === 'sidebar') sidebarEl.classList.add('mob-open');
                else ownshipEl.classList.add('mob-open');
            }
            setTimeout(() => { map.invalidateSize(); updateMobCursorCoords(); }, 50);
        });
    });
})();
window.addEventListener('resize', updateMobCursorCoords);

// ===================== Stats =====================
const AIS_TYPE_NAMES = {1:'Позиция класс A',2:'Позиция класс A',3:'Позиция класс A',5:'Статика класс A',18:'Позиция класс B',19:'Расш. позиция B',24:'Статика класс B'};
function fmtUptime(s){const h=Math.floor(s/3600),m=Math.floor(s%3600/60),sec=s%60;return (h?h+'ч ':'')+(m?m+'м ':'')+sec+'с';}
function _setStatText(id, text) {
    const el = document.getElementById(id);
    if (el) el.textContent = text;
}

function _counterSumByPrefix(counters, prefixes) {
    let out = 0;
    if (!counters) return 0;
    for (const [k, v] of Object.entries(counters)) {
        for (const p of prefixes) {
            if (k.startsWith(p)) { out += Number(v) || 0; break; }
        }
    }
    return out;
}

function _renderStatsTableBody(tbodyId, rows) {
    const tb = document.getElementById(tbodyId);
    if (!tb) return;
    tb.innerHTML = '';
    for (const r of rows) {
        const tr = document.createElement('tr');
        const v = r.value;
        const isErr = !!r.isErr;
        tr.innerHTML =
            '<td class="stat-k">' + escHtml(r.label) + '</td>' +
            '<td class="stat-v' + (isErr ? ' err' : '') + '">' + escHtml(v == null ? '-' : String(v)) + '</td>';
        tb.appendChild(tr);
    }
}

function updateStats(){
    try{
        const s = AisHub.stats || {};
        const sys = AisHub.sysinfo || {};
        const counters = s.counters || {};

        // Vessels: считаем по in-memory витрине (ais_hub отдаёт total в state.warmup, но
        // "активных" удобнее взять из клиентского Map).
        const nowF = Date.now() / 1000;
        let active = 0, total = AisHub.vessels.size, classA = 0, classB = 0;
        for (const v of AisHub.vessels.values()) {
            if (v && v.lat != null && v.lon != null && (nowF - (v.timestamp || 0)) < 600) active++;
            if (v && v.vessel_class === 'A') classA++;
            else if (v && v.vessel_class === 'B') classB++;
        }
        _setStatText('st-active', active);
        _setStatText('st-total', total);
        _setStatText('st-class-a', classA);
        _setStatText('st-class-b', classB);

        const aisMessages = _counterSumByPrefix(counters, ['ais_msg_']);
        _setStatText('st-ais-msg', aisMessages);
        _setStatText('st-gps-msg', counters.state_ownship_updates != null ? counters.state_ownship_updates : '-');
        _setStatText('st-gps-fix', AisHub.ownship && AisHub.ownship.lat != null ? 'Есть' : 'Нет');
        _setStatText('st-uptime', s.uptime_sec != null ? fmtUptime(Math.floor(s.uptime_sec)) : '-');
        _setStatText('st-sys-uptime', sys.sys_uptime != null ? fmtUptime(sys.sys_uptime) : '-');

        try{
            const sn = sys.server_now != null ? parseInt(sys.server_now, 10) : null;
            const el = document.getElementById('st-rx-time');
            if(el){
                if(sn != null && !isNaN(sn)){
                    const clientNow = Math.floor(Date.now()/1000);
                    const d = clientNow - sn;
                    const sign = d > 0 ? '+' : '';
                    el.textContent = fmtTime(sn) + ' (' + sign + d + 'с)';
                } else el.textContent = '-';
            }
        }catch(e){}

        _setStatText('st-cpu-temp', sys.cpu_temp != null ? sys.cpu_temp + '°C' : '-');
        _setStatText('st-cpu-load', sys.cpu_percent != null ? sys.cpu_percent + '%' : '-');
        _setStatText('st-mem', sys.mem_total_mb != null ? sys.mem_used_mb + ' / ' + sys.mem_total_mb + ' МБ (' + sys.mem_pct + '%)' : '-');

        // AIS/NMEA rate: ais_hub шлёт stats.update раз в 5 секунд → считаем rate между снимками.
        _setStatText('st-ais-rate', '-');
        _setStatText('st-nmea-rate', '-');
        _setStatText('st-test-mmsi', '-');
        _setStatText('st-test-mmsi-a', '-');
        _setStatText('st-test-mmsi-b', '-');

        const tb = document.getElementById('st-ais-types');
        if (tb) {
            tb.innerHTML = '';
            const byType = {};
            for (const [k, v] of Object.entries(counters)) {
                if (k.startsWith('ais_msg_')) {
                    const n = k.slice('ais_msg_'.length);
                    byType[n] = v;
                }
            }
            for (const [t, c] of Object.entries(byType).sort((a, b) => b[1] - a[1])) {
                const tr = document.createElement('tr');
                tr.innerHTML = '<td>' + t + '</td><td>' + (AIS_TYPE_NAMES[t] || 'Тип ' + t) + '</td><td>' + c + '</td>';
                tb.appendChild(tr);
            }
        }

        // Expanded parsing/error statistics (toggle section).
        const parseRows = [
            { key: 'parser_errors', label: 'parser_errors', isErr: true },
            { key: 'parser_checksum_errors', label: 'parser_checksum_errors', isErr: true },
            { key: 'ais_fragment_errors', label: 'ais_fragment_errors', isErr: true },
            { key: 'ais_fragment_timeouts', label: 'ais_fragment_timeouts', isErr: true },
        ].map(x => ({ label: x.label, value: counters[x.key], isErr: x.isErr && (Number(counters[x.key]) || 0) > 0 }));
        _renderStatsTableBody('st-parse-errors', parseRows);

        const udpRows = [
            { key: 'ais_udp_datagrams', label: 'ais_udp_datagrams' },
            { key: 'ais_udp_malformed', label: 'ais_udp_malformed', isErr: true },
            { key: 'udp_events_oversize', label: 'udp_events_oversize', isErr: true },
        ].map(x => ({ label: x.label, value: counters[x.key], isErr: x.isErr && (Number(counters[x.key]) || 0) > 0 }));
        _renderStatsTableBody('st-udp-errors', udpRows);

        const storageRows = [
            { key: 'storage_batches', label: 'storage_batches' },
            { key: 'storage_rows', label: 'storage_rows' },
            { key: 'storage_errors', label: 'storage_errors', isErr: true },
            { key: 'storage_last_batch', label: 'storage_last_batch (gauge)' },
        ].map(x => {
            const v = x.key === 'storage_last_batch' ? (s.gauges || {}).storage_last_batch : counters[x.key];
            return { label: x.label, value: v, isErr: x.isErr && (Number(v) || 0) > 0 };
        });
        _renderStatsTableBody('st-storage-errors', storageRows);

        const miscRows = [
            { key: 'ws_clients', label: 'ws_clients (gauge)' },
            { key: 'ws_clients_total', label: 'ws_clients_total' },
            { key: 'supervisor_restarts_ingest_ais_udp', label: 'supervisor_restarts_ingest_ais_udp', isErr: true },
            { key: 'udp_events_sent', label: 'udp_events_sent' },
        ].map(x => {
            const v = x.key === 'ws_clients' ? (s.gauges || {}).ws_clients : counters[x.key];
            return { label: x.label, value: v, isErr: x.isErr && (Number(v) || 0) > 0 };
        });
        _renderStatsTableBody('st-misc-counters', miscRows);
    }catch(e){}
}

// Локальные системные метрики (CPU/temp/mem/uptime) ais_hub не отдаёт —
// тянем отдельно с локального /api/sysinfo, только пока открыта вкладка «Статистика».
async function refreshSysinfo(){
    try {
        const r = await fetch('/api/sysinfo');
        const d = await r.json();
        if (d && typeof d === 'object') AisHub.sysinfo = d;
    } catch (e) {}
}

// ===================== TDMA Slots =====================
const SLOT_MIN_COLS = 30, SLOT_MAX_COLS = 75, SLOT_CELL = 14, SLOT_LABEL_W = 42;
const RSSI_WINDOW_SEC = 60;
let slotsOpen = false;
const slotState = {a: null, b: null};
const slotsTooltip = document.getElementById('slots-tooltip');

document.getElementById('slots-toggle').addEventListener('click', function() {
    slotsOpen = !slotsOpen;
    document.getElementById('slots-content').classList.toggle('open', slotsOpen);
    document.getElementById('slots-arrow').classList.toggle('open', slotsOpen);
    if (slotsOpen) updateSlots();
});

function _hexToBase64(hex) {
    if (!hex) return '';
    const bin = new Uint8Array(Math.floor(hex.length / 2));
    for (let i = 0; i < bin.length; i++) bin[i] = parseInt(hex.substr(i * 2, 2), 16);
    let s = '';
    for (let i = 0; i < bin.length; i++) s += String.fromCharCode(bin[i]);
    try { return btoa(s); } catch (e) { return ''; }
}

function _buildSlotChannelData(ch) {
    const UPPER = ch.toUpperCase();
    const occ = AisHub.slots[UPPER];
    const power_history = AisHub.livePower[UPPER] || [];
    const rssi_history = AisHub.rssiHistory[UPPER] || [];
    const evCutoff = Date.now() / 1000 - 90;
    const event_history = (AisHub.signalEvents[UPPER] || []).filter(e => (e.ts || 0) >= evCutoff);
    // Если нет данных по слотам, но есть радио/RSSI — всё равно рисуем график.
    if (!occ) {
        const hasAny = (power_history && power_history.length) || (rssi_history && rssi_history.length) || (event_history && event_history.length);
        if (!hasAny) return null;
        return {
            no_slots: true,
            rssi_history,
            power_history,
            event_history,
        };
    }
    const detail = AisHub.slotDetail[UPPER];
    const data = Object.assign({}, occ);
    // В ais_hub bitmap приходит как hex-строка, а рендер использует base64 (atob).
    if (typeof occ.bitmap === 'string' && occ.bitmap.length > 0) {
        // heuristic: hex если только [0-9a-f]
        if (/^[0-9a-fA-F]+$/.test(occ.bitmap)) data.bitmap = _hexToBase64(occ.bitmap);
    }
    data.rssi_history = rssi_history;
    data.power_history = power_history;
    data.event_history = event_history;
    if (detail && detail.utc_minute === occ.utc_minute) {
        data.detail_signals = detail.signals || {};
        data.detail_timestamp = detail.timestamp;
    }
    return data;
}

function updateSlots() {
    try {
        renderSlotChannel('a', _buildSlotChannelData('A'));
        renderSlotChannel('b', _buildSlotChannelData('B'));
    } catch(e) { console.error('updateSlots:', e); }
}

function renderSlotChannel(ch, data) {
    const info = document.getElementById('slots-info-' + ch);
    const wrap = document.getElementById('slots-wrap-' + ch);
    const canvas = document.getElementById('slots-canvas-' + ch);
    const rssiCanvas = document.getElementById('slots-rssi-' + ch);
    const bar = document.getElementById('slots-bar-' + ch);
    const barFill = document.getElementById('slots-bar-fill-' + ch);
    if (!data) {
        info.innerHTML = '<span class="slots-no-data">Нет данных</span>';
        wrap.style.display = 'none';
        rssiCanvas.style.display = 'none';
        bar.style.display = 'none';
        slotState[ch] = null;
        return;
    }
    if (data.no_slots) {
        info.innerHTML = '<span class="slots-no-data">Нет данных по слотам</span>';
        bar.style.display = 'none';
        wrap.style.display = 'none';
        slotState[ch] = null;
        renderRssiChart(ch, data.rssi_history, data.power_history, data.event_history);
        return;
    }
    const free = data.total - data.occupied;
    const pct = (data.occupied / data.total * 100).toFixed(1);
    const age = Math.floor(Date.now()/1000) - data.timestamp;
    const ageStr = age < 60 ? age + 'с назад' : Math.floor(age/60) + 'м назад';
    const utcDate = new Date(data.utc_minute * 60000);
    const utcStr = [utcDate.getUTCHours(), utcDate.getUTCMinutes()].map(v=>String(v).padStart(2,'0')).join(':') + ' UTC';
    const nfStr = data.noise_floor_dbm != null ? data.noise_floor_dbm.toFixed(1) + ' dBm' : '-';
    const thStr = data.threshold_dbm != null ? data.threshold_dbm.toFixed(1) + ' dBm' : '-';
    let anchorExtra = '';
    const t0n = data.slot0_unix_ms != null ? Number(data.slot0_unix_ms) : 0;
    if (t0n > 0 && t0n <= Number.MAX_SAFE_INTEGER) {
        anchorExtra += ' | слот0: ' + new Date(t0n).toISOString().slice(11, 19) + 'Z';
    }
    const fon = data.first_occupied_unix_ms != null ? Number(data.first_occupied_unix_ms) : 0;
    if (fon > 0 && fon <= Number.MAX_SAFE_INTEGER) {
        anchorExtra += ' | 1-й декод: ' + new Date(fon).toISOString().slice(11, 19) + 'Z';
    }
    info.textContent = utcStr + ' | Занято: ' + data.occupied + ' (' + pct + '%) | Свободно: ' + free + ' | NF: ' + nfStr + ' | TH: ' + thStr + anchorExtra + ' | ' + ageStr;

    bar.style.display = '';
    const occPct = data.occupied / data.total * 100;
    barFill.style.width = occPct + '%';
    barFill.style.background = occPct > 80 ? '#f85149' : occPct > 50 ? '#d29922' : '#238636';

    renderRssiChart(ch, data.rssi_history, data.power_history, data.event_history);

    wrap.style.display = '';
    const wrapWidth = Math.max(wrap.clientWidth || 0, 320);
    const slotCols = Math.max(SLOT_MIN_COLS, Math.min(SLOT_MAX_COLS, Math.floor((wrapWidth - SLOT_LABEL_W) / SLOT_CELL)));
    const slotRows = Math.ceil(data.total / slotCols);
    canvas.width = SLOT_LABEL_W + slotCols * SLOT_CELL;
    canvas.height = slotRows * SLOT_CELL;
    const ctx = canvas.getContext('2d');
    const raw = atob(data.bitmap);
    const bitmap = new Uint8Array(raw.length);
    for (let i = 0; i < raw.length; i++) bitmap[i] = raw.charCodeAt(i);
    const detailSignals = data.detail_signals || {};
    slotState[ch] = {
        total: data.total,
        bitmap: bitmap,
        detailSignals: detailSignals,
        cols: slotCols,
        rows: slotRows,
        cell: SLOT_CELL,
        labelW: SLOT_LABEL_W,
        slot0UnixMs: data.slot0_unix_ms != null ? Number(data.slot0_unix_ms) : 0,
        firstOccupiedUnixMs: data.first_occupied_unix_ms != null ? Number(data.first_occupied_unix_ms) : 0,
    };

    ctx.clearRect(0, 0, canvas.width, canvas.height);

    ctx.font = '10px monospace';
    ctx.fillStyle = '#484f58';
    ctx.textAlign = 'right';
    ctx.textBaseline = 'middle';
    for (let r = 0; r < slotRows; r += 5) {
        const sec = Math.round(r * slotCols * 60 / data.total);
        ctx.fillText(sec + 's', SLOT_LABEL_W - 4, r * SLOT_CELL + SLOT_CELL / 2);
    }

    for (let slot = 0; slot < data.total; slot++) {
        const occupied = (bitmap[slot >> 3] >> (slot & 7)) & 1;
        const x = SLOT_LABEL_W + (slot % slotCols) * SLOT_CELL;
        const y = Math.floor(slot / slotCols) * SLOT_CELL;
        ctx.fillStyle = occupied ? '#f85149' : '#238636';
        ctx.fillRect(x, y, SLOT_CELL - 1, SLOT_CELL - 1);
    }
}

function renderRssiChart(ch, history, powerHistory, eventHistory) {
    const canvas = document.getElementById('slots-rssi-' + ch);
    const hasHistory = history && history.length >= 2;
    const hasPower = powerHistory && powerHistory.length >= 2;
    const hasEvents = eventHistory && eventHistory.length >= 1;
    if (!hasHistory && !hasPower && !hasEvents) { canvas.style.display = 'none'; return; }
    canvas.style.display = '';
    const cssWidth = Math.max(320, Math.floor(canvas.getBoundingClientRect().width || canvas.parentElement?.clientWidth || 482));
    const cssHeight = 90;
    const dpr = window.devicePixelRatio || 1;
    canvas.width = Math.floor(cssWidth * dpr);
    canvas.height = Math.floor(cssHeight * dpr);
    const c = canvas.getContext('2d');
    c.setTransform(1, 0, 0, 1, 0, 0);
    c.scale(dpr, dpr);
    const W = cssWidth, H = cssHeight;
    const pad = {t: 10, b: 16, l: 44, r: 10};
    const pW = W - pad.l - pad.r, pH = H - pad.t - pad.b;

    const latestTs = Math.max(
        hasHistory ? history[history.length - 1].ts : -Infinity,
        hasPower ? powerHistory[powerHistory.length - 1].ts : -Infinity,
        hasEvents ? eventHistory[eventHistory.length - 1].ts : -Infinity,
        Date.now() / 1000
    );
    const xMax = latestTs;
    const xMin = xMax - RSSI_WINDOW_SEC;

    const visibleHistory = hasHistory ? history.filter(p => p.ts >= xMin && p.ts <= xMax) : [];
    const visiblePower = hasPower ? powerHistory.filter(p => p.ts >= xMin && p.ts <= xMax) : [];
    const visibleEvents = hasEvents ? eventHistory.filter(ev => ev.ts >= xMin && ev.ts <= xMax) : [];

    let yMin = Infinity, yMax = -Infinity;
    if (visibleHistory.length) {
        for (const p of visibleHistory) {
            if (p.nf != null) { yMin = Math.min(yMin, p.nf); yMax = Math.max(yMax, p.nf); }
            if (p.th != null) { yMin = Math.min(yMin, p.th); yMax = Math.max(yMax, p.th); }
        }
    }
    if (visiblePower.length) {
        for (const p of visiblePower) {
            if (p.power != null) { yMin = Math.min(yMin, p.power); yMax = Math.max(yMax, p.power); }
        }
    }
    if (!isFinite(yMin)) { canvas.style.display = 'none'; return; }
    const yPad = Math.max((yMax - yMin) * 0.15, 2);
    yMin -= yPad; yMax += yPad;
    const xRange = Math.max(xMax - xMin, 1);

    c.clearRect(0, 0, W, H);
    c.fillStyle = '#0d1117';
    c.fillRect(pad.l, pad.t, pW, pH);

    c.strokeStyle = '#161b22';
    c.lineWidth = 1;
    const gridSteps = 4;
    for (let i = 0; i <= gridSteps; i++) {
        const gy = pad.t + pH * i / gridSteps;
        c.beginPath(); c.moveTo(pad.l, gy); c.lineTo(pad.l + pW, gy); c.stroke();
    }

    function toX(ts) { return pad.l + (ts - xMin) / xRange * pW; }
    function toY(v)  { return pad.t + (1 - (v - yMin) / (yMax - yMin)) * pH; }

    /* Начало кадра AIS TDMA — граница UTC-минуты */
    c.save();
    c.beginPath();
    c.rect(pad.l, pad.t, pW, pH);
    c.clip();
    c.strokeStyle = '#3fb950';
    c.lineWidth = 1;
    c.setLineDash([3, 3]);
    let frameTs = Math.floor(xMin / 60) * 60;
    if (frameTs < xMin) frameTs += 60;
    for (; frameTs <= xMax; frameTs += 60) {
        const fx = toX(frameTs);
        c.beginPath();
        c.moveTo(fx, pad.t);
        c.lineTo(fx, pad.t + pH);
        c.stroke();
    }
    c.setLineDash([]);
    c.restore();

    function drawLine(src, key, color) {
        c.save();
        c.beginPath();
        c.rect(pad.l, pad.t, pW, pH);
        c.clip();
        c.beginPath(); c.strokeStyle = color; c.lineWidth = 1.5;
        let started = false;
        for (const p of src) {
            if (p[key] == null) continue;
            const x = toX(p.ts), y = toY(p[key]);
            if (!started) { c.moveTo(x, y); started = true; } else c.lineTo(x, y);
        }
        c.stroke();
        c.restore();
    }
    if (visiblePower.length) drawLine(visiblePower, 'power', '#c678dd');
    if (visibleHistory.length) drawLine(visibleHistory, 'nf', '#4fc3f7');
    if (visibleHistory.length) drawLine(visibleHistory, 'th', '#f0883e');

    if (visibleEvents.length) {
        c.save();
        c.beginPath();
        c.rect(pad.l, pad.t, pW, pH);
        c.clip();
        c.strokeStyle = '#ffd166';
        c.fillStyle = '#ffd166';
        c.lineWidth = 1;
        c.font = '8px monospace';
        c.textAlign = 'center';
        c.textBaseline = 'top';
        for (const ev of visibleEvents) {
            const x = toX(ev.ts);
            c.beginPath();
            c.moveTo(x, pad.t);
            c.lineTo(x, pad.t + pH);
            c.stroke();
            c.fillText(String(ev.slot), x, pad.t + 2);
        }
        c.restore();
    }

    c.font = '9px monospace';
    c.fillStyle = '#484f58';
    c.textAlign = 'right';
    c.textBaseline = 'top';
    c.fillText(yMax.toFixed(0) + ' dBm', pad.l - 3, pad.t);
    c.textBaseline = 'bottom';
    c.fillText(yMin.toFixed(0) + ' dBm', pad.l - 3, pad.t + pH);
    const yMid = (yMin + yMax) / 2;
    c.textBaseline = 'middle';
    c.fillText(yMid.toFixed(0), pad.l - 3, pad.t + pH / 2);

    c.textAlign = 'left'; c.textBaseline = 'top';
    const t0 = new Date(xMin * 1000), t1 = new Date(xMax * 1000);
    const fmt = d => [d.getHours(), d.getMinutes()].map(v=>String(v).padStart(2,'0')).join(':');
    c.fillText(fmt(t0), pad.l, pad.t + pH + 3);
    c.textAlign = 'right';
    c.fillText(fmt(t1), pad.l + pW, pad.t + pH + 3);

    const lastHistory = visibleHistory.length ? visibleHistory[visibleHistory.length - 1] : null;
    const lastPower = visiblePower.length ? visiblePower[visiblePower.length - 1] : null;
    if (lastPower && lastPower.power != null) {
        c.fillStyle = '#c678dd'; c.textAlign = 'left'; c.textBaseline = 'bottom';
        c.fillText(lastPower.power.toFixed(1), toX(lastPower.ts) + 3, toY(lastPower.power));
    }
    if (lastHistory && lastHistory.nf != null) {
        c.fillStyle = '#4fc3f7'; c.textAlign = 'left'; c.textBaseline = 'bottom';
        c.fillText(lastHistory.nf.toFixed(1), toX(lastHistory.ts) + 3, toY(lastHistory.nf));
    }
    if (lastHistory && lastHistory.th != null) {
        c.fillStyle = '#f0883e'; c.textAlign = 'left'; c.textBaseline = 'top';
        c.fillText(lastHistory.th.toFixed(1), toX(lastHistory.ts) + 3, toY(lastHistory.th));
    }
}

function buildSlotInfoText(ch, st, slot) {
    const occ = (st.bitmap[slot >> 3] >> (slot & 7)) & 1;
    const timeSec = (slot * 60 / st.total).toFixed(2);
    let text = 'Канал ' + ch.toUpperCase() + ' | Слот ' + slot + ' | +' + timeSec + 'с от начала кадра | ' + (occ ? 'Занят' : 'Свободен');
    const t0 = st.slot0UnixMs;
    if (t0 != null && t0 > 0) {
        const n = Number(t0);
        if (Number.isFinite(n) && n <= Number.MAX_SAFE_INTEGER) {
            const slotMs = (60000 * slot) / st.total;
            const abs = new Date(n + slotMs);
            text += ' | UTC ' + abs.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, 'Z');
        }
    }
    if (occ && st.detailSignals && Object.prototype.hasOwnProperty.call(st.detailSignals, slot)) {
        text += ' | Signal ' + st.detailSignals[slot].toFixed(1) + ' dB';
    }
    return text;
}

function slotCanvasHover(e, ch) {
    const st = slotState[ch];
    if (!st) { slotsTooltip.style.display = 'none'; return; }
    const canvas = e.target;
    const rect = canvas.getBoundingClientRect();
    const sx = canvas.width / rect.width, sy = canvas.height / rect.height;
    const mx = (e.clientX - rect.left) * sx, my = (e.clientY - rect.top) * sy;
    const col = Math.floor((mx - st.labelW) / st.cell);
    const row = Math.floor(my / st.cell);
    if (col < 0 || col >= st.cols || row < 0 || row >= st.rows) { slotsTooltip.style.display = 'none'; return; }
    const slot = row * st.cols + col;
    if (slot >= st.total) { slotsTooltip.style.display = 'none'; return; }
    slotsTooltip.textContent = buildSlotInfoText(ch, st, slot);
    slotsTooltip.style.display = '';
    slotsTooltip.style.left = (e.clientX + 14) + 'px';
    slotsTooltip.style.top = (e.clientY - 10) + 'px';
}
document.getElementById('slots-canvas-a').addEventListener('mousemove', function(e){ slotCanvasHover(e, 'a'); });
document.getElementById('slots-canvas-b').addEventListener('mousemove', function(e){ slotCanvasHover(e, 'b'); });
document.getElementById('slots-canvas-a').addEventListener('mouseleave', function(){ slotsTooltip.style.display='none'; });
document.getElementById('slots-canvas-b').addEventListener('mouseleave', function(){ slotsTooltip.style.display='none'; });

// ===================== Test Slot Send =====================
document.getElementById('test-slot-send').addEventListener('click', async function() {
    const btn = this;
    const status = document.getElementById('test-slot-status');
    const channel = document.getElementById('test-slot-channel').value;
    const slot = parseInt(document.getElementById('test-slot-number').value, 10);
    if (isNaN(slot) || slot < 0 || slot > 2249) {
        status.textContent = 'Слот 0\u20132249';
        status.className = 'slots-test-status err';
        return;
    }
    btn.disabled = true;
    status.textContent = 'Отправка...';
    status.className = 'slots-test-status wait';
    try {
        const r = await fetch('/api/send_test_slot', {
            method: 'POST',
            headers: {'Content-Type': 'application/json'},
            body: JSON.stringify({channel, slot})
        });
        const data = await r.json();
        if (data.ok) {
            status.textContent = 'OK \u2192 ' + data.dest + ' (' + data.size + 'B)';
            status.className = 'slots-test-status ok';
        } else {
            status.textContent = data.error || 'Ошибка';
            status.className = 'slots-test-status err';
        }
    } catch(e) {
        status.textContent = 'Ошибка: ' + e.message;
        status.className = 'slots-test-status err';
    }
    btn.disabled = false;
});

// Click on slot canvas to fill slot number
function slotCanvasClick(e, ch) {
    const st = slotState[ch];
    if (!st) return;
    const canvas = e.target;
    const rect = canvas.getBoundingClientRect();
    const sx = canvas.width / rect.width, sy = canvas.height / rect.height;
    const mx = (e.clientX - rect.left) * sx, my = (e.clientY - rect.top) * sy;
    const col = Math.floor((mx - st.labelW) / st.cell);
    const row = Math.floor(my / st.cell);
    if (col < 0 || col >= st.cols || row < 0 || row >= st.rows) return;
    const slot = row * st.cols + col;
    if (slot >= st.total) return;
    document.getElementById('test-slot-number').value = slot;
    document.getElementById('test-slot-channel').value = ch === 'a' ? 'A' : 'B';
    document.getElementById('slot-selected-info').textContent = buildSlotInfoText(ch, st, slot);
}
document.getElementById('slots-canvas-a').addEventListener('click', function(e){ slotCanvasClick(e, 'a'); });
document.getElementById('slots-canvas-b').addEventListener('click', function(e){ slotCanvasClick(e, 'b'); });
window.addEventListener('resize', () => { if (slotsOpen) updateSlots(); });

// ===================== Settings =====================
document.getElementById('set-server').textContent=location.host;
document.getElementById('set-https').textContent=location.protocol==='https:'?'Да':'Нет';
document.getElementById('set-secure').textContent=window.isSecureContext?'Да':'Нет';

(function initUnitSettings() {
    const distSel = document.getElementById('set-dist-unit');
    distSel.value = distUnit;
    distSel.addEventListener('change', () => {
        distUnit = distSel.value;
        sSet('distUnit', distUnit);
        document.getElementById('range-value').textContent = fmtRange(getRangeNM());
    });
    const speedSel = document.getElementById('set-speed-unit');
    speedSel.value = speedUnit;
    speedSel.addEventListener('change', () => {
        speedUnit = speedSel.value;
        sSet('speedUnit', speedUnit);
    });
})();

(function initDangerRadiusSettings() {
    const warnEl = document.getElementById('set-warn-radius');
    const nearEl = document.getElementById('set-near-radius');
    const warnSl = document.getElementById('set-warn-radius-slider');
    const nearSl = document.getElementById('set-near-radius-slider');
    const warnUnit = document.getElementById('set-warn-radius-unit');
    const nearUnit = document.getElementById('set-near-radius-unit');
    if (!warnEl || !nearEl || !warnSl || !nearSl) return;

    const BASE_MAX_NM = 50;
    function refreshUiForUnit() {
        if (warnUnit) warnUnit.textContent = uiUnitName();
        if (nearUnit) nearUnit.textContent = uiUnitName();
        const maxUi = nmToUi(BASE_MAX_NM);
        warnSl.max = String(maxUi);
        nearSl.max = String(maxUi);
        // keep current NM values, just re-render in UI units
        warnEl.value = String(nmToUi(warnRadiusNm || 0));
        nearEl.value = String(nmToUi(nearRadiusNm || 0));
        warnSl.value = warnEl.value;
        nearSl.value = nearEl.value;
        // update placeholders
        warnEl.placeholder = '0 = выкл';
        nearEl.placeholder = '0 = выкл';
    }

    refreshUiForUnit();

    const apply = () => {
        const wUi = parseFloat(warnEl.value);
        const nUi = parseFloat(nearEl.value);
        const wNm = Number.isFinite(wUi) && wUi > 0 ? uiToNm(wUi) : 0;
        const nNm = Number.isFinite(nUi) && nUi > 0 ? uiToNm(nUi) : 0;
        warnRadiusNm = wNm;
        nearRadiusNm = nNm;
        sSet('warnRadiusNm', String(warnRadiusNm));
        sSet('nearRadiusNm', String(nearRadiusNm));
        updateDangerCircles();
        updateDangerBanner();
    };
    const clampStr = (v) => {
        const x = parseFloat(v);
        if (!Number.isFinite(x) || x < 0) return '0';
        return String(Math.min(parseFloat(warnSl.max || '50'), x));
    };
    const syncWarnFromText = () => { warnEl.value = clampStr(warnEl.value); warnSl.value = warnEl.value; apply(); };
    const syncNearFromText = () => { nearEl.value = clampStr(nearEl.value); nearSl.value = nearEl.value; apply(); };
    const syncWarnFromSlider = () => { warnEl.value = warnSl.value; apply(); };
    const syncNearFromSlider = () => { nearEl.value = nearSl.value; apply(); };

    warnEl.addEventListener('input', syncWarnFromText);
    nearEl.addEventListener('input', syncNearFromText);
    warnSl.addEventListener('input', syncWarnFromSlider);
    nearSl.addEventListener('input', syncNearFromSlider);
    apply();

    // When distance unit changes, keep NM values but redraw UI in new units
    try {
        const distSel = document.getElementById('set-dist-unit');
        if (distSel) distSel.addEventListener('change', () => setTimeout(() => { refreshUiForUnit(); }, 0));
    } catch (e) {}
})();

// ===================== Network Settings =====================
let netSelectedMode = null;
const netMsg = document.getElementById('net-msg');

function showNetMsg(text, cls) {
    netMsg.className = 'net-msg ' + cls;
    netMsg.textContent = text;
    if (cls === 'ok') setTimeout(() => { netMsg.className = 'net-msg'; }, 5000);
}

function setNetMode(mode) {
    netSelectedMode = mode;
    document.getElementById('net-btn-ap').classList.toggle('active', mode === 'ap');
    document.getElementById('net-btn-wifi').classList.toggle('active', mode === 'wifi');
    document.getElementById('net-ap-fields').style.display = mode === 'ap' ? '' : 'none';
    document.getElementById('net-wifi-fields').style.display = mode === 'wifi' ? '' : 'none';
}
document.getElementById('net-btn-ap').addEventListener('click', () => setNetMode('ap'));
document.getElementById('net-btn-wifi').addEventListener('click', () => setNetMode('wifi'));

document.getElementById('net-adv-toggle').addEventListener('click', function() {
    const adv = document.getElementById('net-advanced');
    const open = adv.classList.toggle('open');
    this.innerHTML = open ? 'Дополнительно &#9652;' : 'Дополнительно &#9662;';
});

async function loadNetworkConfig() {
    try {
        const r = await fetch('/api/network');
        const data = await r.json();
        const cfg = data.config || {};
        const live = data.live || {};

        const liveMode = document.getElementById('net-live-mode');
        const modeLabel = {ap: 'Точка доступа', wifi: 'WiFi-клиент'};
        liveMode.textContent = modeLabel[live.mode] || live.mode || '?';
        liveMode.className = 'net-status ' + (live.mode || 'unknown');

        document.getElementById('net-live-ip').textContent = live.ip || '-';
        document.getElementById('net-live-ssid').textContent = live.ssid || '-';

        setNetMode(cfg.mode || 'ap');
        document.getElementById('net-ap-ssid').value = cfg.ap_ssid || '';
        document.getElementById('net-ap-psk').value = cfg.ap_psk || '';
        document.getElementById('net-ap-ip').value = cfg.ap_ip || '';
        document.getElementById('net-wifi-ssid').value = cfg.wifi_ssid || '';
        document.getElementById('net-wifi-psk').value = cfg.wifi_psk || '';
        document.getElementById('net-wifi-ip').value = cfg.wifi_ip || '';
        document.getElementById('net-wifi-gw').value = cfg.wifi_gw || '';
        document.getElementById('net-wifi-dns').value = cfg.wifi_dns || '';
        document.getElementById('net-iface').value = cfg.iface || 'wlan0';
    } catch(e) { console.error('loadNetworkConfig:', e); }
}

function collectNetConfig() {
    return {
        mode: netSelectedMode,
        ap_ssid: document.getElementById('net-ap-ssid').value,
        ap_psk: document.getElementById('net-ap-psk').value,
        ap_ip: document.getElementById('net-ap-ip').value,
        wifi_ssid: document.getElementById('net-wifi-ssid').value,
        wifi_psk: document.getElementById('net-wifi-psk').value,
        wifi_ip: document.getElementById('net-wifi-ip').value,
        wifi_gw: document.getElementById('net-wifi-gw').value,
        wifi_dns: document.getElementById('net-wifi-dns').value,
        iface: document.getElementById('net-iface').value || 'wlan0',
    };
}

document.getElementById('net-save-btn').addEventListener('click', async function() {
    this.disabled = true;
    try {
        const cfg = collectNetConfig();
        const r = await fetch('/api/network', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(cfg)});
        const data = await r.json();
        if (data.ok) showNetMsg('Настройки сохранены', 'ok');
        else showNetMsg(data.error || 'Ошибка сохранения', 'err');
    } catch(e) { showNetMsg('Ошибка: ' + e.message, 'err'); }
    this.disabled = false;
});

document.getElementById('net-switch-btn').addEventListener('click', async function() {
    const mode = netSelectedMode;
    const modeLabel = mode === 'ap' ? 'точку доступа' : 'WiFi-клиент';
    if (!confirm('Переключить на ' + modeLabel + '?\nСоединение может быть потеряно на несколько секунд.')) return;
    this.disabled = true;
    showNetMsg('Переключение на ' + modeLabel + '...', 'info');
    try {
        const cfg = collectNetConfig();
        cfg.mode = mode;
        const r = await fetch('/api/network/switch', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(cfg)});
        const data = await r.json();
        if (data.ok) {
            showNetMsg('Переключено на ' + modeLabel + '. Обновите страницу по новому адресу.', 'ok');
            setTimeout(loadNetworkConfig, 3000);
        } else {
            showNetMsg(data.error || 'Ошибка переключения', 'err');
        }
    } catch(e) { showNetMsg('Связь потеряна — устройство переключается. Подключитесь к новой сети.', 'info'); }
    this.disabled = false;
});

document.getElementById('net-scan-btn').addEventListener('click', async function() {
    this.disabled = true;
    this.textContent = 'Сканирование...';
    const list = document.getElementById('net-scan-list');
    try {
        const r = await fetch('/api/network/scan');
        const data = await r.json();
        if (data.ok && data.networks && data.networks.length) {
            list.style.display = '';
            list.innerHTML = '';
            for (const n of data.networks) {
                const it = document.createElement('div');
                it.className = 'net-scan-item';
                it.innerHTML = '<span>' + n.ssid + '</span><span class="net-signal">' + (n.signal != null ? n.signal + ' dBm' : '') + '</span>';
                it.addEventListener('click', () => {
                    document.getElementById('net-wifi-ssid').value = n.ssid;
                    list.querySelectorAll('.net-scan-item').forEach(e => e.classList.remove('selected'));
                    it.classList.add('selected');
                });
                list.appendChild(it);
            }
        } else {
            list.style.display = '';
            list.innerHTML = '<div style="padding:8px;color:#667;font-size:12px">' + (data.error || 'Сети не найдены') + '</div>';
        }
    } catch(e) { list.style.display=''; list.innerHTML='<div style="padding:8px;color:#f85149;font-size:12px">Ошибка сканирования</div>'; }
    this.disabled = false;
    this.textContent = 'Сканировать';
});

loadNetworkConfig();

// ===================== Logs =====================
let logLastSeq=0, logAutoscroll=true, logLines=[];
const logOutput=document.getElementById('log-output');
const logFilter=document.getElementById('log-filter');
const logSearch=document.getElementById('log-search');

function classifyLine(line){
    const h=(line.split(',')[0]||'');
    if((line.startsWith('!')||line.startsWith('$'))&&h.endsWith('VDM')) return 'ais';
    if(/^\$G[PNLA](RMC|GGA)/i.test(line)) return 'gps';
    return 'unknown';
}
function renderLogs(){
    const filter=logFilter.value;
    const search=logSearch.value.toLowerCase();
    const frag=document.createDocumentFragment();
    let count=0;
    const visible=logLines.filter(l=>{
        if(filter!=='all'&&l.cls!==filter) return false;
        if(search&&!l.line.toLowerCase().includes(search)) return false;
        return true;
    }).slice(-500);
    logOutput.innerHTML='';
    for(const l of visible){
        const div=document.createElement('div');
        div.className='log-line '+l.cls;
        div.innerHTML='<span class="ts">'+l.time+'</span> '+escHtml(l.line);
        frag.appendChild(div);
        count++;
    }
    logOutput.appendChild(frag);
    document.getElementById('log-count').textContent=count+' строк';
    if(logAutoscroll) logOutput.scrollTop=logOutput.scrollHeight;
}

let _logSeenKeys = new Set();
async function pollLogs(){
    // ais_hub отдаёт последние N строк (ring buffer / SQLite), seq там нет — дедупим по ts+line.
    try{
        const r = await fetch('/api/v1/nmea/tail?limit=500');
        const data = await r.json();
        if (!Array.isArray(data) || !data.length) return;
        let added = 0;
        for (const e of data) {
            if (!e || !e.line) continue;
            const key = (e.ts || 0).toFixed(3) + '|' + e.line;
            if (_logSeenKeys.has(key)) continue;
            _logSeenKeys.add(key);
            const d = new Date((e.ts || 0) * 1000);
            const time = [d.getHours(), d.getMinutes(), d.getSeconds()].map(v => String(v).padStart(2, '0')).join(':');
            logLines.push({ seq: ++logLastSeq, time, line: e.line, cls: classifyLine(e.line) });
            added++;
        }
        if (added === 0) return;
        if (logLines.length > 2000) logLines = logLines.slice(-2000);
        if (_logSeenKeys.size > 4000) {
            // Подрезаем кеш дедупликации чтоб не рос.
            _logSeenKeys = new Set();
            for (const l of logLines) _logSeenKeys.add(((l.line || '') + '|' + (l.time || '')));
        }
        if (currentTab === 'logs') renderLogs();
    }catch(e){}
}

document.getElementById('log-autoscroll').addEventListener('click',function(){logAutoscroll=!logAutoscroll;this.classList.toggle('active',logAutoscroll);});
document.getElementById('log-clear').addEventListener('click',()=>{logLines=[];renderLogs();});
logFilter.addEventListener('change',renderLogs);
logSearch.addEventListener('input',renderLogs);

// ===================== Config Page =====================
let cfgLoaded = false;
const cfgEditor = document.getElementById('cfg-editor');
const cfgMsg = document.getElementById('cfg-msg');
const cfgSvcBadge = document.getElementById('cfg-svc-state');
const cfgTabMini = document.getElementById('cfg-tab-mini');
const cfgTabAisHub = document.getElementById('cfg-tab-aishub');
const cfgFileHint = document.getElementById('cfg-file-hint');
const cfgBottomHint = document.getElementById('cfg-bottom-hint');

let cfgTarget = 'mini'; // 'mini' | 'aishub'
const CFG_TARGETS = {
    mini:   { name: 'AIS-catcher Mini', file: '/ais-mini.conf', service: 'aisMini.service', url: '/api/config', svcStatus: '/api/service/status', svcRestart: '/api/service/restart' },
    aishub: { name: 'AisHub',           file: '/opt/aishub/config/config.yaml', service: 'ais_hub.service', url: '/api/config/aishub', svcStatus: '/api/service/aishub/status', svcRestart: '/api/service/aishub/restart' },
};

function showCfgMsg(text, cls) {
    cfgMsg.className = 'config-msg ' + cls;
    cfgMsg.textContent = text;
    if (cls === 'ok') setTimeout(() => { cfgMsg.className = 'config-msg'; cfgMsg.textContent = ''; }, 5000);
}

function _cfgMeta() { return CFG_TARGETS[cfgTarget] || CFG_TARGETS.mini; }
function _renderCfgHeader() {
    const m = _cfgMeta();
    try {
        if (cfgTabMini) cfgTabMini.classList.toggle('active', cfgTarget === 'mini');
        if (cfgTabAisHub) cfgTabAisHub.classList.toggle('active', cfgTarget === 'aishub');
        if (cfgFileHint) cfgFileHint.textContent = 'Файл: ' + m.file;
        if (cfgBottomHint) cfgBottomHint.textContent = 'Файл: ' + m.file + ' | Сервис: ' + m.service;
    } catch (e) {}
}

async function loadConfig() {
    try {
        const r = await fetch(_cfgMeta().url);
        const data = await r.json();
        if (data.ok) {
            cfgEditor.value = data.text;
            cfgLoaded = true;
            showCfgMsg('', '');
        } else {
            cfgEditor.value = '';
            showCfgMsg(data.error || 'Ошибка загрузки', 'err');
        }
    } catch(e) {
        cfgEditor.value = '';
        showCfgMsg('Ошибка: ' + e.message, 'err');
    }
}

async function loadServiceStatus() {
    const badge = cfgSvcBadge || document.getElementById('cfg-svc-state');
    try {
        const r = await fetch(_cfgMeta().svcStatus);
        const data = await r.json();
        const state = data.state || 'unknown';
        badge.textContent = state;
        badge.className = 'config-svc-badge ' + (state === 'active' ? 'active' : state === 'inactive' ? 'inactive' : 'unknown');
    } catch(e) {
        badge.textContent = '?';
        badge.className = 'config-svc-badge unknown';
    }
}

document.getElementById('cfg-save-btn').addEventListener('click', async function() {
    this.disabled = true;
    showCfgMsg('Сохранение...', 'info');
    try {
        const r = await fetch(_cfgMeta().url, {
            method: 'POST',
            headers: {'Content-Type': 'application/json'},
            body: JSON.stringify({text: cfgEditor.value})
        });
        const data = await r.json();
        if (data.ok) showCfgMsg('Конфиг сохранён', 'ok');
        else showCfgMsg(data.error || 'Ошибка сохранения', 'err');
    } catch(e) {
        showCfgMsg('Ошибка: ' + e.message, 'err');
    }
    this.disabled = false;
});

document.getElementById('cfg-restart-btn').addEventListener('click', async function() {
    const m = _cfgMeta();
    if (!confirm('Перезапустить ' + m.service + '?\nПриём AIS может быть прерван на несколько секунд.')) return;
    this.disabled = true;
    showCfgMsg('Перезапуск сервиса...', 'info');
    try {
        const r = await fetch(m.svcRestart, {
            method: 'POST',
            headers: {'Content-Type': 'application/json'}
        });
        const data = await r.json();
        if (data.ok) {
            showCfgMsg('Сервис перезапущен', 'ok');
            setTimeout(loadServiceStatus, 2000);
        } else {
            showCfgMsg(data.error || 'Ошибка перезапуска', 'err');
        }
    } catch(e) {
        showCfgMsg('Ошибка: ' + e.message, 'err');
    }
    this.disabled = false;
});

document.getElementById('cfg-reload-btn').addEventListener('click', () => {
    loadConfig();
    loadServiceStatus();
});

function _setCfgTarget(t) {
    cfgTarget = (t === 'aishub') ? 'aishub' : 'mini';
    _renderCfgHeader();
    loadConfig();
    loadServiceStatus();
}
try {
    if (cfgTabMini) cfgTabMini.addEventListener('click', () => _setCfgTarget('mini'));
    if (cfgTabAisHub) cfgTabAisHub.addEventListener('click', () => _setCfgTarget('aishub'));
    _renderCfgHeader();
} catch (e) {}

// ===================== Main loop =====================
// ===================== Connection status (server reachability) =====================
let _connState = { offline: false, lastOkTs: 0, lastErrTs: 0, lastMsg: '' };
function connBannerEl(){ return document.getElementById('conn-banner'); }
function setConnBanner(offline, msg){
    const el = connBannerEl();
    if (!el) return;
    const text = msg || (offline ? 'Нет связи с сервером' : 'Связь восстановлена');
    el.textContent = text;
    el.classList.toggle('conn-banner--offline', !!offline);
    el.classList.toggle('conn-banner--online', !offline);
    el.style.display = text ? '' : 'none';
    if (!offline) {
        // hide "online" after a short while
        setTimeout(() => {
            if (!_connState.offline && el.textContent === text) el.style.display = 'none';
        }, 1500);
    }
}
function markConnOk(){
    const now = Date.now();
    _connState.lastOkTs = now;
    if (_connState.offline) {
        _connState.offline = false;
        setConnBanner(false, 'Связь восстановлена');
    }
}
function markConnErr(err){
    const now = Date.now();
    _connState.lastErrTs = now;
    _connState.lastMsg = (err && err.message) ? err.message : '';
    if (!_connState.offline) {
        _connState.offline = true;
        setConnBanner(true, 'Нет связи с сервером');
    }
}
window.addEventListener('offline', () => { _connState.offline = true; setConnBanner(true, 'Нет сети (offline)'); });
window.addEventListener('online', () => { /* actual reachability checked by fetches */ setConnBanner(false, 'Сеть появилась…'); });

// ===================== UI tick =====================
// Данные (vessels/ownship/stats/…) приходят по WebSocket и сразу запускают redraw.
// Этот таймер делает только лёгкие UI-штуки: «возраст» в сайдбаре, danger-banner,
// /api/sysinfo и /api/v1/nmea/tail — если открыты соответствующие вкладки.
function uiTick() {
    try {
        pollOwnShip();                 // скопировать AisHub.ownship → nmeaGps
        updateOwnShipDisplay();        // время «ago», followMode и пр.
        updateBaseStations();          // TTL/removal for static AIS targets between WS events
        updateBuoys();                 // TTL/removal for AtoN targets between WS events

        // Sidebar живёт от in-memory витрин.
        if (!window._mapInteracting) {
            const os = getOwnShipPos();
            const combined = []
                .concat(lastAnyVessels || [])
                .concat(lastBaseStations || [])
                .concat(lastBuoys || []);
            if (os) {
                for (const v of combined) {
                    if (v && v.lat != null && v.lon != null) v._distNM = haversineNM(os.lat, os.lon, v.lat, v.lon);
                    else v._distNM = null;
                }
            } else {
                for (const v of combined) { if (v) v._distNM = null; }
            }
            updateSidebar(combined);
            adjustSidebarHeight();
            updateDangerCircles();
            updateDangerBanner();
            if (currentTab === 'targets') {
                try { renderTargetsTab(); } catch (_) {}
            }
        }

        if (currentTab === 'stats') {
            refreshSysinfo().then(() => updateStats()).catch(() => {});
        }
        if (currentTab === 'logs') {
            pollLogs();
        }
    } catch (e) {
        try { console.error('[AISMap] uiTick failed', e); } catch (_) {}
    }
}

setInterval(uiTick, 1000);
uiTick();
adjustSidebarHeight();

// Индикатор связи: считается здоровым, если WS-соединение живо и недавно были события.
setInterval(() => {
    try {
        const wsOk = AisHub.wsOpen;
        const fresh = (Date.now() - (AisHub.lastEventTs || 0)) < 20000;
        if (wsOk) {
            // Периодически подтверждаем, даже если событий сейчас нет.
            if (AisHub.snapshotLoaded) markConnOk();
        } else if (!fresh) {
            markConnErr(new Error('ais_hub WS disconnected'));
        }
    } catch (e) {}
}, 3000);

// Стартуем WebSocket после полной загрузки страницы, чтобы не рвать рукопожатие
// во время загрузки (Firefox: «соединение … было прервано» у new WebSocket).
function _startAisHubWebSocket() {
    try { AisHubWS.open(); } catch (e) { try { console.error('[AISMap] WS open failed', e); } catch (_) {} }
}
if (document.readyState === 'complete') {
    _startAisHubWebSocket();
} else {
    window.addEventListener('load', _startAisHubWebSocket, { once: true });
}

// Map interaction gating for performance (z14+ pan/zoom).
try {
    window._mapInteracting = false;
    let _resumeTimer = null;
    const setInteracting = (v) => {
        window._mapInteracting = !!v;
        if (!v) {
            if (_resumeTimer) clearTimeout(_resumeTimer);
            // Let Leaflet finish settling tiles, then do one catch-up pass.
            _resumeTimer = setTimeout(() => { uiTick(); AisHubRedraw.flushNow(); }, 120);
        }
    };
    map.on('movestart', () => setInteracting(true));
    map.on('moveend', () => setInteracting(false));
    map.on('zoomstart', () => setInteracting(true));
    map.on('zoomend', () => setInteracting(false));
} catch (e) {}
