/**
* 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
}