js/popupManager.mjs

/**
 * popupManager.mjs
 * 方向性 Popup/Tooltip 建立、動態翻轉偵測、Icon 圖片註冊
 */
import isNumber from 'lodash-es/isNumber.js'
import isarr from 'wsemi/src/isarr.mjs'
import * as maplibregl from 'maplibre-gl' //maplibre-gl 6.x 起為 ESM-only 且移除 default export, 須以 namespace 方式匯入


//方向 → maplibre Popup anchor/offset 對照(gap 為與錨點間距, anchorOffset 為額外偏移 [dx,dy], 正x=右移/正y=下移)。
//createDirectionalPopup 與 recheckSinglePopupDir 共用同一份規則, 避免翻轉邏輯雙份漂移
function buildPosMap(gap, anchorOffset) {
    let aoX = 0; let aoY = 0
    if (isarr(anchorOffset) && anchorOffset.length >= 2) {
        aoX = anchorOffset[0] || 0; aoY = anchorOffset[1] || 0
    }
    return {
        'top': { anchor: 'bottom', offset: [aoX, -gap + aoY] },
        'bottom': { anchor: 'top', offset: [aoX, gap + aoY] },
        'left': { anchor: 'right', offset: [-gap + aoX, aoY] },
        'right': { anchor: 'left', offset: [gap + aoX, aoY] },
    }
}
const flipMap = { 'top': 'bottom', 'bottom': 'top', 'left': 'right', 'right': 'left' }
//指定方向放置尺寸 elW×elH 的彈窗是否超出容器範圍。
//anchorOffset 會實際位移彈窗(如 icon 點預設 popupAnchor 之 -iconHeight), 溢出判斷須一併計入,
//否則靠緣時判定未溢出不翻轉, 實際 DOM 已被位移出容器而遭裁切; 各方向公式與 buildPosMap 的實際擺放一致
function checkOverflow(dir, proj, cw, ch, elW, elH, gap, anchorOffset) {
    let aoX = 0; let aoY = 0
    if (isarr(anchorOffset) && anchorOffset.length >= 2) {
        aoX = anchorOffset[0] || 0; aoY = anchorOffset[1] || 0
    }
    if (dir === 'top') return proj.y - gap + aoY - elH < 0
    if (dir === 'bottom') return proj.y + gap + aoY + elH > ch
    if (dir === 'left') return proj.x - gap + aoX - elW < 0
    if (dir === 'right') return proj.x + gap + aoX + elW > cw
    return false
}


/**
 * 建立方向性彈窗(支援 top/bottom/left/right + 二階段自動翻轉)
 * @param {Object} map - MapLibre 地圖實例
 * @param {Array} lngLat - [lng, lat]
 * @param {HTMLElement} content - DOM 內容元素
 * @param {String} position - 'top'|'bottom'|'left'|'right'
 * @param {Number} gap - 與錨點的間距(px)
 * @param {Object} extraOpts - 其他 Popup 選項
 * @param {Array} [anchorOffset=[0,0]] - 額外偏移 [dx, dy](正x=右移,正y=下移)
 * @returns {maplibregl.Popup|null}
 */
export function createDirectionalPopup(map, lngLat, content, position, gap, extraOpts, anchorOffset) {
    if (!map) return null
    gap = isNumber(gap) ? Math.abs(gap) : 5
    let posMap = buildPosMap(gap, anchorOffset)

    // 第一階段:以預估尺寸決定初始方向
    let chosenPos = position
    try {
        let proj = map.project(lngLat)
        let ct = map.getContainer(); let cw = ct.clientWidth; let ch = ct.clientHeight
        let estW = 220; let estH = 160
        if (checkOverflow(position, proj, cw, ch, estW, estH, gap, anchorOffset)) {
            chosenPos = flipMap[position] || position
        }
    }
    catch (e) { /* 投影失敗時使用原始方向 */ }

    let pos = posMap[chosenPos] || posMap['top']
    let popup = new maplibregl.Popup({ ...extraOpts, anchor: pos.anchor, offset: pos.offset })
        .setLngLat(lngLat).setDOMContent(content).addTo(map)

    // 第二階段:渲染後以實際 DOM 尺寸做二次校正
    try {
        let popupEl = popup.getElement()
        if (popupEl) {
            let rect = popupEl.getBoundingClientRect()
            let realW = rect.width; let realH = rect.height
            let proj = map.project(lngLat)
            let ct = map.getContainer(); let cw = ct.clientWidth; let ch = ct.clientHeight
            if (checkOverflow(chosenPos, proj, cw, ch, realW, realH, gap, anchorOffset)) {
                let flipped = flipMap[chosenPos] || chosenPos
                if (!checkOverflow(flipped, proj, cw, ch, realW, realH, gap, anchorOffset)) {
                    chosenPos = flipped
                    let newPos = posMap[flipped]
                    popup.remove()
                    popup = new maplibregl.Popup({ ...extraOpts, anchor: newPos.anchor, offset: newPos.offset })
                        .setLngLat(lngLat).setDOMContent(content).addTo(map)
                }
            }
        }
    }
    catch (e) { /* 二次校正失敗時維持當前方向 */ }

    popup._dirMeta = {
        lngLat,
        position,
        chosenDir: chosenPos,
        gap,
        extraOpts,
        anchorOffset: anchorOffset || null,
        contentEl: content, // 存「活的 DOM 元素參考」, 翻轉/recheck 時沿用同一元素(非靜態 innerHTML 複製)
    }
    return popup
}


/**
 * 偵測單一 popup/tooltip 的方向是否需要翻轉,若需要則重建
 * @param {Object} map - MapLibre 地圖實例
 * @param {maplibregl.Popup} popup - 目前的 popup 物件
 * @returns {maplibregl.Popup} - 原 popup 或翻轉後的新 popup
 */
export function recheckSinglePopupDir(map, popup) {
    if (!popup || !popup._dirMeta) return popup
    let meta = popup._dirMeta
    let { lngLat, position, chosenDir, gap, extraOpts, anchorOffset } = meta
    let posMap = buildPosMap(gap, anchorOffset)

    try {
        let popupEl = popup.getElement(); if (!popupEl) return popup
        let rect = popupEl.getBoundingClientRect()
        let elW = rect.width; let elH = rect.height
        let proj = map.project(lngLat)
        let ct = map.getContainer(); let cw = ct.clientWidth; let ch = ct.clientHeight
        let ovf = (dir) => checkOverflow(dir, proj, cw, ch, elW, elH, gap, anchorOffset)

        let overflow = ovf(chosenDir)
        let newDir = chosenDir
        if (overflow) {
            let flipped = flipMap[chosenDir]
            if (!ovf(flipped)) newDir = flipped
        }
        else if (chosenDir !== position) {
            if (!ovf(position)) newDir = position
        }

        if (newDir === chosenDir) return popup

        let newPos = posMap[newDir]
        let newContent = meta.contentEl // 沿用同一個活的 slot 元素, 不重建靜態複製(維持可互動)
        popup.remove()
        let newPopup = new maplibregl.Popup({ ...extraOpts, anchor: newPos.anchor, offset: newPos.offset })
            .setLngLat(lngLat).setDOMContent(newContent).addTo(map)
        newPopup._dirMeta = { ...meta, chosenDir: newDir }
        return newPopup
    }
    catch (e) {
        return popup
    }
}


/**
 * 將 base64/URL 圖片載入並以指定大小註冊到地圖 sprite atlas
 * @param {Object} map - MapLibre 地圖實例
 * @param {String} key - sprite key
 * @param {String} src - 圖片來源(URL 或 base64)
 * @param {Number} tw - 目標寬度
 * @param {Number} th - 目標高度
 * @returns {Promise}
 */
const KEY_ICON_FP = '__wmlgvIconFp'
const KEY_ICON_PENDING = '__wmlgvIconPending'
const KEY_ICON_LATEST = '__wmlgvIconLatest'
export function registerIconImage(map, key, src, tw, th) {
    if (!map) return Promise.resolve()
    let fps = map[KEY_ICON_FP]
    if (!fps) {
        fps = {}; map[KEY_ICON_FP] = fps
    }
    let pend = map[KEY_ICON_PENDING]
    if (!pend) {
        pend = {}; map[KEY_ICON_PENDING] = pend
    }
    let latest = map[KEY_ICON_LATEST]
    if (!latest) {
        latest = {}; map[KEY_ICON_LATEST] = latest
    }
    //內容指紋 = 長度 + 頭尾片段 + 目標尺寸(避免整串 base64 常駐記憶體):
    //同 key 同內容直接沿用; 內容變更則載入後 removeImage 再 addImage 正確替換(免呼叫端先清光 icon 的 workaround)
    let s = String(src || '')
    let fp = `${s.length}:${s.slice(0, 64)}:${s.slice(-64)}:${tw}x${th}`
    latest[key] = fp //本次呼叫成為該 key 的最新內容意圖(載入完成時據此棄置較舊之並發載入)
    if (map.hasImage(key) && fps[key] === fp) return Promise.resolve()
    //同 key 同內容「載入中」共用同一 Promise: 首次渲染多點同用一張圖(如預設 icon)時
    //免重複建立 Image/解碼/繪 canvas; 鍵含指紋, 同 key 不同內容(更新場景)仍各自載入
    let pk = `${key}|${fp}`
    if (pend[pk]) return pend[pk]
    let pm = new Promise((resolve) => {
        let img = new Image()
        img.crossOrigin = 'anonymous' //跨域圖片須帶 CORS 載入, 否則 canvas 被污染後 getImageData 會 throw
        img.onload = () => {
            if (!map || latest[key] !== fp) { //已有更新的內容意圖: 本輪(較舊)載入完成較晚, 棄置不得覆寫新圖
                resolve(); return
            }
            //所有 map 操作皆入 try: 元件卸載後 map.remove() 會清掉 style, 待載回呼再碰 map 必 throw,
            //不得成為未捕捉例外(且無論如何須 resolve, 使 pending 表可清)
            try {
                if (map.hasImage(key) && fps[key] === fp) { //並發同內容載入: 先完成者已註冊
                    resolve(); return
                }
                let c = document.createElement('canvas'); c.width = tw; c.height = th
                let ctx = c.getContext('2d'); ctx.drawImage(img, 0, 0, tw, th)
                let d = ctx.getImageData(0, 0, tw, th)
                if (map.hasImage(key)) map.removeImage(key) //同 key 內容變更: 移除舊圖再註冊
                map.addImage(key, { width: tw, height: th, data: new Uint8Array(d.data.buffer) })
                fps[key] = fp
            }
            catch (e) {
                console.warn('[popupManager] registerIconImage error:', e) //單顆 icon 失敗不可卡住整組渲染
            }
            resolve()
        }
        img.onerror = () => resolve()
        img.src = src
    })
    pend[pk] = pm
    pm.then(() => {
        delete pend[pk]
    })
    return pm
}