components/WMaplibreglVue.vue

<template>
    <div
        style="position:relative; display:inline-block;"
        v-domresize
        @domresize="resize"
        :loading="loading"
    >

        <!-- 圖台區 -->
        <div
            ref="panel"
            style="width:100%; height:100%;"
        ></div>

        <!-- 輔助區 -->
        <div v-if="!loading">

            <!-- 四角面板容器,面板依順序由上往下堆疊,不重疊 -->
            <div
                class="clsCorner"
                :class="'clsCorner-'+corner"
                :key="corner"
                v-for="corner in corners"
            >

                <!-- 羅盤 -->
                <div
                    :style="{ order: getPanelOrder('panelCompassRose', corner), padding: panelCompassRose.withPanel ? '3px' : '0', background: panelCompassRose.withPanel ? panelBackgroundColor : 'transparent', borderRadius: '5px' }"
                    v-if="panelCompassRose.show && panelCompassRose.position===corner"
                >
                    <img
                        :style="{ width: panelCompassRose.size+'px', height: panelCompassRose.size+'px', display:'block' }"
                        :src="useIconCompassRose"
                    />
                </div>

                <!-- 3D 指北針(含視角傾斜效果,點擊恢復 2D 正北) -->
                <div
                    :style="{ order: getPanelOrder('panelCompass3d', corner), cursor:'pointer', display:'inline-block', lineHeight:'0' }"
                    :title="currentPitch > 0 ? 'Click to reset 2D north-up view' : 'Click to face north'"
                    @click="resetTo2d"
                    v-if="panelCompass3d.show && panelCompass3d.position===corner"
                >

                    <div :style="compass3dDiscStyle">
                        <svg :width="panelCompass3d.size" :height="panelCompass3d.size" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg">
                            <!-- 外圓背景 -->
                            <circle cx="22" cy="22" r="21" fill="rgba(20,20,20,0.78)" stroke="rgba(255,255,255,1)" stroke-width="1"/>
                            <!-- 旋轉群組:指針依 bearing 旋轉 -->
                            <g :transform="`rotate(${-currentBearing}, 22, 22)`">
                                <!-- 北方指針(紅色) -->
                                <polygon points="22,4 18.5,22 22,19.5 25.5,22" fill="#e84040"/>
                                <!-- 南方指針(灰色) -->
                                <polygon points="22,40 18.5,22 22,24.5 25.5,22" fill="#999999"/>
                                <!-- 東西刻度短線 -->
                                <line x1="4" y1="22" x2="7" y2="22" stroke="rgba(255,255,255,0.5)" stroke-width="1.2"/>
                                <line x1="37" y1="22" x2="40" y2="22" stroke="rgba(255,255,255,0.5)" stroke-width="1.2"/>
                            </g>
                            <!-- 中心圓點 -->
                            <circle cx="22" cy="22" r="2.2" fill="rgba(255,255,255,0.85)"/>
                        </svg>
                    </div>

                </div>

                <!-- 底圖選擇 -->
                <div
                    class="clsPanel"
                    :style="{ order: getPanelOrder('panelBaseMaps', corner), background: panelBackgroundColor }"
                    v-if="panelBaseMaps.show && panelBaseMaps.position===corner"
                >

                    <div
                        :style="{ overflow:'auto', ...panelBaseMaps.style }"
                        @wheel="handleWheel($event, panelBaseMaps)"
                    >
                        <div
                            :key="'bm:'+kbm"
                            style="padding:2px 0;"
                            v-for="(bm, kbm) in panelBaseMaps.baseMaps"
                        >

                            <!-- 底圖(colorShade 非空):radio 單選 -->
                            <template v-if="bm.colorShade !== ''">
                                <label style="cursor:pointer; display:flex; align-items:center; gap:4px; font-size:0.8rem;">
                                    <input type="radio" :name="'bmr_'+_uid" :checked="bm.visible" @change="switchBaseMap(kbm)" />
                                    <span>{{ bm.name }}</span>
                                </label>
                            </template>

                            <!-- 疊加圖層(colorShade 空字串):checkbox 多選 + opacity 滑桿 -->
                            <template v-else>
                                <label style="cursor:pointer; display:flex; align-items:center; gap:4px; font-size:0.8rem;">
                                    <input type="checkbox" :checked="bm.visible" @change="toggleOverlayVisible(kbm)" />
                                    <span>{{ bm.name }}</span>
                                </label>
                                <!-- type 為 style 不支援整組透明度(各層自帶 opacity), 不渲染滑桿; 預設值與值域統一由 getBaseMapOpacity 決定(R14) -->
                                <div v-if="bm.type !== 'style'" style="display:flex; align-items:center; gap:4px; padding-left:20px; padding-top:2px; font-size:0.75rem;">
                                    <input type="range" min="0" max="1" step="0.01" :value="bmOpacity(bm)" :disabled="!bm.visible" @input="setOverlayOpacity(kbm, $event.target.value)" style="width:70px;" />
                                    <span>{{ Math.round(bmOpacity(bm) * 100) }}%</span>
                                </div>
                            </template>

                        </div>
                    </div>

                </div>

                <!-- 圖層顯隱 -->
                <div
                    class="clsPanel"
                    :style="{ order: getPanelOrder('panelItems', corner), background: panelBackgroundColor }"
                    v-if="panelItems.show && items.length>0 && panelItems.position===corner"
                >

                    <div
                        :style="{ overflow:'auto', ...panelItems.style }"
                        @wheel="handleWheel($event, panelItems)"
                    >

                        <div
                            :key="'item:'+ki"
                            style="padding:2px 0;"
                            v-for="(item, ki) in items"
                        >

                            <label style="cursor:pointer; display:flex; align-items:center; gap:4px; font-size:0.8rem;">
                                <input type="checkbox" :checked="item.visible" @change="toggleItemVisible(ki)" />
                                <span style="display:inline-flex; flex-direction:column; gap:1px;">
                                    <span>{{ item.name }}</span>
                                    <span v-if="item.msg" style="font-size:0.7rem; color:#888; font-weight:normal; white-space:pre-wrap;">{{ item.msg }}</span>
                                </span>
                            </label>

                        </div>

                    </div>

                </div>

                <!-- 地圖資訊(經緯度/縮放) -->
                <div
                    class="clsPanel"
                    :style="{ order: getPanelOrder('panelLabels', corner), background: panelBackgroundColor }"
                    v-if="panelLabels.show && panelLabels.position===corner"
                >

                    <div :style="{ overflow:'auto', ...panelLabels.style }">
                        <template v-if="panelLabels.title !== ''">
                            <div style="font-size:1.1rem; font-weight:bold; text-align:center;">{{ panelLabels.title }}</div>
                            <div style="border-top:1px solid #b9b9b9; margin:4px 0;"></div>
                        </template>
                        <table><tbody>
                            <tr v-for="(u, ku) in panelLabels.useItems" :key="ku">
                                <td style="text-align:right;">{{ panelLabels[u] }}</td><td> : </td>
                                <td>{{ u==='lng'||u==='lat' ? showLoc[u] : u==='zoom' ? Math.max(0, Math.round(currentZoom*1e5)/1e5) : '' }}</td>
                            </tr>
                        </tbody></table>
                    </div>

                </div>

                <!-- 縮放按鈕 -->
                <div
                    class="clsPanel"
                    :style="{ order: getPanelOrder('panelZoom', corner), padding: '0' }"
                    v-if="panelZoom.show && panelZoom.position===corner"
                >
                    <button class="clsZoomBtn" @click="zoomIn" title="放大">+</button>
                    <div style="border-top:1px solid #ccc;"></div>
                    <button class="clsZoomBtn" @click="zoomOut" title="縮小">−</button>
                </div>

                <!-- 比例尺 -->
                <div
                    class="clsPanel clsScale"
                    :style="{ order: getPanelOrder('panelScale', corner), background: 'rgba(0,0,0,0.6)', color:'#fff' }"
                    v-if="panelScale.show && panelScale.position===corner"
                >
                    <div style="display:flex; align-items:center; gap:4px;">
                        <div style="width:80px; height:3px; background:#fff; border:1px solid #fff;"></div>
                        <span>{{ scaleText }}</span>
                    </div>
                </div>

                <!-- 圖例區(等值線用) -->
                <div
                    class="clsPanel"
                    :style="{ order: getPanelOrder('panelLegends', corner), background: panelBackgroundColor }"
                    v-if="panelLegends.show && countVisible(contourSets)>0 && panelLegends.position===corner"
                >

                    <div
                        :style="{ display:'flex', alignItems:'flex-start', overflow:'auto', ...panelLegends.style }"
                        @wheel="handleWheel($event, panelLegends)"
                    >

                        <div
                            :key="'contourSet:'+kcontourSet"
                            v-for="(contourSet, kcontourSet) in contourSets"
                        >

                            <div
                                style="padding:4px 6px; white-space:nowrap;"
                                v-if="contourSet.visible"
                            >
                                <div style="margin-bottom:5px;">
                                    <div style="text-align:center;" v-html="contourSet.title"></div>
                                    <div style="font-size:0.85rem; text-align:center;" v-html="contourSet.legendMsg"></div>
                                </div>
                                <table style="border-collapse:collapse;">
                                    <tbody>
                                        <tr :key="'klegend:'+klegend" v-for="(legend, klegend) in contourSet.legend">
                                            <td style="height:18px; line-height:18px;" v-if="false">
                                                <span :style="`opacity:${legend.arrow?1:0};`">▶</span>
                                            </td>
                                            <td :style="`background:${legend.color}; width:18px; height:18px;`"></td>
                                            <td style="padding-left:5px;"></td>
                                            <td style="text-align:right; font-size:0.7rem; height:18px; line-height:18px;">
                                                <span v-html="legend.low"></span>
                                            </td>
                                            <td style="padding:0px 2px; text-align:center; font-size:0.7rem; height:18px; line-height:18px;">
                                                <span v-html="legend.delimiter"></span>
                                            </td>
                                            <td style="text-align:left; font-size:0.7rem; height:18px; line-height:18px;">
                                                <span v-html="legend.up"></span>
                                            </td>
                                            <td style="padding-left:3px;" v-if="legend.textExt"></td>
                                            <td style="text-align:left; font-size:0.7rem; height:18px; line-height:18px;" v-if="legend.textExt">
                                                <span v-html="legend.textExt"></span>
                                            </td>
                                        </tr>
                                    </tbody>
                                </table>
                            </div>

                        </div>

                    </div>

                </div>

            </div>

        </div>

        <!-- 隱藏的 slot 容器:用來承載外部傳入的 popup/tooltip slot 內容 -->
        <div style="display:none;">
            <!-- point popup -->
            <div ref="refPointPopup">
                <div ref="refPointPopupInner">
                    <CmpPopupResizable
                        :resizable="popupResizeCfg.resizable"
                        :resizeModes="popupResizeCfg.resizeModes"
                        :minWidth="popupResizeCfg.minWidth"
                        :minHeight="popupResizeCfg.minHeight"
                        :width="popupResizeCfg.width"
                        :height="popupResizeCfg.height"
                        @resizing-change="onPopupResizingChange"
                    >
                        <slot name="point-popup"
                            :point="activePoint"
                            :pointSet="activePointSet"
                        ></slot>
                    </CmpPopupResizable>
                </div>
            </div>
            <!-- point tooltip -->
            <div ref="refPointTooltip">
                <slot name="point-tooltip"
                    :point="activePointTooltip"
                    :pointSet="activePointSetTooltip"
                ></slot>
            </div>
            <!-- polyline popup -->
            <div ref="refPolylinePopup">
                <div ref="refPolylinePopupInner">
                    <CmpPopupResizable
                        :resizable="popupResizeCfg.resizable"
                        :resizeModes="popupResizeCfg.resizeModes"
                        :minWidth="popupResizeCfg.minWidth"
                        :minHeight="popupResizeCfg.minHeight"
                        :width="popupResizeCfg.width"
                        :height="popupResizeCfg.height"
                        @resizing-change="onPopupResizingChange"
                    >
                        <slot name="polyline-popup"
                            :polylineSet="activePolylineSet"
                            :polylineSets="polylineSets"
                        ></slot>
                    </CmpPopupResizable>
                </div>
            </div>
            <!-- polyline tooltip -->
            <div ref="refPolylineTooltip">
                <slot name="polyline-tooltip"
                    :polylineSet="activePolylineSetTooltip"
                    :polylineSets="polylineSets"
                ></slot>
            </div>
            <!-- polygon popup -->
            <div ref="refPolygonPopup">
                <div ref="refPolygonPopupInner">
                    <CmpPopupResizable
                        :resizable="popupResizeCfg.resizable"
                        :resizeModes="popupResizeCfg.resizeModes"
                        :minWidth="popupResizeCfg.minWidth"
                        :minHeight="popupResizeCfg.minHeight"
                        :width="popupResizeCfg.width"
                        :height="popupResizeCfg.height"
                        @resizing-change="onPopupResizingChange"
                    >
                        <slot name="polygon-popup"
                            :polygonSet="activePolygonSet"
                            :polygonSets="polygonSets"
                        ></slot>
                    </CmpPopupResizable>
                </div>
            </div>
            <!-- polygon tooltip -->
            <div ref="refPolygonTooltip">
                <slot name="polygon-tooltip"
                    :polygonSet="activePolygonSetTooltip"
                    :polygonSets="polygonSets"
                ></slot>
            </div>
            <!-- geojson popup -->
            <div ref="refGeojsonPopup">
                <div ref="refGeojsonPopupInner">
                    <CmpPopupResizable
                        :resizable="popupResizeCfg.resizable"
                        :resizeModes="popupResizeCfg.resizeModes"
                        :minWidth="popupResizeCfg.minWidth"
                        :minHeight="popupResizeCfg.minHeight"
                        :width="popupResizeCfg.width"
                        :height="popupResizeCfg.height"
                        @resizing-change="onPopupResizingChange"
                    >
                        <slot name="geojson-popup"
                            :geojsonSet="activeGeojsonSet"
                            :geojsonSets="geojsonSets"
                        ></slot>
                    </CmpPopupResizable>
                </div>
            </div>
            <!-- geojson tooltip -->
            <div ref="refGeojsonTooltip">
                <slot name="geojson-tooltip"
                    :geojsonSet="activeGeojsonSetTooltip"
                    :geojsonSets="geojsonSets"
                ></slot>
            </div>
            <!-- contour popup -->
            <div ref="refContourPopup">
                <div ref="refContourPopupInner">
                    <CmpPopupResizable
                        :resizable="popupResizeCfg.resizable"
                        :resizeModes="popupResizeCfg.resizeModes"
                        :minWidth="popupResizeCfg.minWidth"
                        :minHeight="popupResizeCfg.minHeight"
                        :width="popupResizeCfg.width"
                        :height="popupResizeCfg.height"
                        @resizing-change="onPopupResizingChange"
                    >
                        <slot name="contour-popup"
                            :contourSet="activeContourSet"
                            :contourSets="contourSets"
                        ></slot>
                    </CmpPopupResizable>
                </div>
            </div>
            <!-- contour tooltip -->
            <div ref="refContourTooltip">
                <slot name="contour-tooltip"
                    :contourSet="activeContourSetTooltip"
                    :contourSets="contourSets"
                ></slot>
            </div>
        </div>

    </div>
</template>

<script>
import map from 'lodash-es/map.js'
import each from 'lodash-es/each.js'
import omit from 'lodash-es/omit.js'
import get from 'lodash-es/get.js'
import set from 'lodash-es/set.js'
import isNumber from 'lodash-es/isNumber.js'
import isEqual from 'lodash-es/isEqual.js'
import cloneDeep from 'lodash-es/cloneDeep.js'
import isfun from 'wsemi/src/isfun.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import isarr from 'wsemi/src/isarr.mjs'
import isobj from 'wsemi/src/isobj.mjs'
import isbol from 'wsemi/src/isbol.mjs'
import isnum from 'wsemi/src/isnum.mjs'
import isearr from 'wsemi/src/isearr.mjs'
import dig from 'wsemi/src/dig.mjs'
import debounce from 'wsemi/src/debounce.mjs'
import domResize from 'w-component-vue/src/js/domResize.mjs'
import 'maplibre-gl/dist/maplibre-gl.css'
import defBaseMaps from '../js/defBaseMaps.mjs'
import defTerrainMap from '../js/defTerrainMap.mjs'
import uiRes from '../uiRes.mjs'
import { createMap, applyProjection as _applyProjection, applyZoomRange } from '../js/mapCore.mjs'
import { applyBaseMaps, preloadBaseMapStyles, applyTerrain, switchBaseMap as _switchBaseMap, toggleOverlayVisible as _toggleOverlayVisible, setOverlayOpacity as _setOverlayOpacity, getBaseMapOpacity, updateBaseMapPaint, isBaseMapsPaintOnlyDiff, isBaseMapsStructuralDiff, updateBaseMapsIncremental } from '../js/basemapManager.mjs'
import { computeBasicOpt, computePanelBaseMaps, computePanelCompassRose, computePanelCompass3d, computePanelLabels, computePanelItems, computePanelZoom, computePanelScale, computePanelLegends, computeClusterOpts } from '../js/configProcessor.mjs'
import { clearTrackedByPrefix, buildItemsList, countVisible } from '../js/layerVisibility.mjs'
import { createDirectionalPopup, recheckSinglePopupDir, registerIconImage } from '../js/popupManager.mjs'
import { renderPointSets as renderPointSetsImpl, renderPolylineSets as renderPolylineSetsImpl, renderPolygonSets as renderPolygonSetsImpl, renderGeojsonSets as renderGeojsonSetsImpl, renderImageSets as renderImageSetsImpl, renderContourSets as renderContourSetsImpl } from '../js/layerRenderers.mjs'
import { getMapObject as _getMapObject, calcPanToCenter, findPointById, resolveFeatureById } from '../js/publicApi.mjs'
import CmpPopupResizable from './CmpPopupResizable.vue'


const defPanelsOrder = ['panelBaseMaps', 'panelLabels', 'panelScale', 'panelCompassRose', 'panelCompass3d', 'panelZoom', 'panelItems', 'panelLegends']


/**
 * 深度凍結「大量座標」欄位(points / latLngs),使 Vue 2 observe 直接跳過該子樹
 * Vue 2 之 observe() 對 !Object.isExtensible 者不遞迴、不掛 getter/setter,
 * 萬點級資料存入 reactive data 之深走訪成本(數百 ms)因此免除。
 *
 * 僅可用於「套件內外皆唯讀」之座標欄位:
 *   - 組物件本身不可凍結: syncSetsVisible / toggleItemVisible / modifyItemsVisible 會回寫 visible
 *   - contourSets 不可凍結: renderContourSets 會回寫 cs.legend
 * 座標欄位來源為 processSetsChange 內 cloneDeep 之私有副本, 凍結不影響呼叫端原資料。
 */
function freezeCoordField(v) {
    if (isarr(v)) {
        for (let i = 0; i < v.length; i++) freezeCoordField(v[i])
        return Object.freeze(v)
    }
    if (isobj(v)) {
        let ks = Object.keys(v)
        for (let i = 0; i < ks.length; i++) freezeCoordField(v[ks[i]])
        return Object.freeze(v)
    }
    return v //數值/字串/函數等原生值不處理
}


/**
 * WMaplibreglVue2 - 使用 MapLibre GL JS 重構的地圖組件(Vue 2)
 * 所有面板透過四角容器 (corner container) 自動堆疊,不重疊。
 *
 * 核心業務邏輯已提取至 src/js/ 模組,此元件僅保留:
 *   - 響應式狀態(data / computed / watch)
 *   - 薄委派方法(調用模組函式 + 同步 Vue 狀態)
 *   - Vue 特定操作($nextTick / $refs / $forceUpdate)
 *
 * @vue-prop {Object} opt 輸入資料設定物件
 * @vue-prop {Array} [opt.center=[23.5, 121.1]] 輸入地圖顯示中點陣列,陣列為WGS84[緯度,經度],兩值皆須為有限數(數字字串視為非法)且緯度在±90內,否則視為未給(用預設),預設[23.5, 121.1]
 * @vue-prop {Number} [opt.zoom=6] 輸入地圖顯示層級數字,可給數字或數字字串,非數值視為未給(用預設6),範圍由opt.minZoom與opt.maxZoom決定(元件不另夾值),超出範圍者由地圖拉入範圍,使用者縮放、縮放按鈕或API縮放後會把地圖實際層級寫回本欄位(地球投影下實際下限隨緯度略低於minZoom,墨卡托投影下受容器高度限制而略高於minZoom,皆以實際層級為準;地球投影下僅改center時地圖亦會依緯度差調整層級並寫回本欄位),欲於執行期變更或接收寫回須於opt預先宣告此鍵,預設6
 * @vue-prop {Number} [opt.minZoom=0] 輸入地圖可縮小至之最小顯示層級數字,使用者縮放、縮放按鈕、opt.zoom與API皆受此限制,可給數字或數字字串,夾在0~24,非數值或非有限數視為未給,大於maxZoom時忽略minZoom並console.warn,可於執行期變更(須於opt預先宣告此鍵),預設0
 * @vue-prop {Number} [opt.maxZoom=22] 輸入地圖可放大至之最大顯示層級數字,給法同minZoom(夾在0~24),可於執行期變更(須於opt預先宣告此鍵),預設22;此為地圖顯示層級上限,與圖磚層級不同(256px圖磚於地圖層級z約請求z+1級),各底圖服務之層級上限請以baseMaps[i].maxzoom設定(超過時改以該級圖磚放大顯示),勿以本欄位遷就單一圖資;欲還原舊版上限18可給18
 * @vue-prop {String} [opt.panelBackgroundColor='rgba(255,255,255,0.95)'] 輸入各顯示資訊區背景顏色字串,預設'rgba(255,255,255,0.95)'
 * @vue-prop {String} [opt.projection=''] 輸入地圖投影方式字串,''代表自動(顯示層級≤8用球面globe、以上用平面mercator),可選'globe'、'mercator',預設''
 * @vue-prop {Boolean} [opt.displayPopupOnlyone=true] 輸入是否同時只顯示一個popup布林值,開啟時點擊新圖徵會關閉其他已開的popup,預設true
 * @vue-prop {Boolean} [opt.displayOrderByType=true] 輸入是否依圖徵型別面積序堆疊布林值,開啟時圖層由上而下為點>線>面>等值線>影像,且點擊命中最上層型別(小面積優先),預設true
 * @vue-prop {String} [opt.popupPosition='top'] 輸入popup預設彈出方向字串,可選'top'、'bottom'、'left'、'right',靠近地圖邊緣時會自動翻轉,預設'top'
 * @vue-prop {String} [opt.tooltipPosition='left'] 輸入tooltip預設彈出方向字串,可選'top'、'bottom'、'left'、'right',預設'left'
 * @vue-prop {Array} [opt.panelsOrder=詳見程式碼] 輸入各角落內面板的堆疊順序陣列,預設值詳見程式碼的defPanelsOrder
 * @vue-prop {Object} [opt.popupResize={}] 輸入popup可拖曳調整大小的設定物件,預設{}
 * @vue-prop {Boolean} [opt.popupResize.resizable=false] 輸入是否開啟popup四邊/四角拖曳調整大小布林值,false時為純穿透、行為與未啟用一致,預設false
 * @vue-prop {Array} [opt.popupResize.resizeModes=詳見程式碼] 輸入可拖曳的把手方向陣列,元素可為'top'、'bottom'、'left'、'right'、'top-left'、'top-right'、'bottom-left'、'bottom-right',預設為八向全開
 * @vue-prop {Number} [opt.popupResize.minWidth=200] 輸入popup可調整的最小寬度數字,單位px,預設200
 * @vue-prop {Number} [opt.popupResize.minHeight=200] 輸入popup可調整的最小高度數字,單位px,預設200
 * @vue-prop {Number} [opt.popupResize.width=400] 輸入popup初始寬度數字,單位px,預設400
 * @vue-prop {Number} [opt.popupResize.height=300] 輸入popup初始高度數字,單位px,預設300
 * @vue-prop {Boolean} [opt.clusterPoints=false] 輸入是否啟用點叢集布林值,預設false
 * @vue-prop {Number} [opt.clusterPointsRadius=50] 輸入點叢集半徑數字,單位px,預設50
 * @vue-prop {Number} [opt.clusterPointsMaxZoom=14] 輸入點叢集套用的最大顯示層級數字,預設14
 * @vue-prop {Number} [opt.clusterPointsLevelNum=3] 輸入點叢集分級數量數字(需≥1),預設3
 * @vue-prop {Array} [opt.clusterPointsLevelValues=[10,100]] 輸入點叢集各級的點數門檻陣列,長度需為分級數量-1,預設[10,100]
 * @vue-prop {Array} [opt.clusterPointsLevelRadius=[10,15,20]] 輸入點叢集各級的圓半徑陣列,單位px,長度需為分級數量,預設[10,15,20]
 * @vue-prop {Array} [opt.clusterPointsLevelFillColors=詳見程式碼] 輸入點叢集各級的填充顏色陣列,長度需為分級數量,預設值詳見程式碼
 * @vue-prop {Array} [opt.clusterPointsLevelLineColors=['#fff','#fff','#fff']] 輸入點叢集各級的框線顏色陣列,預設['#fff','#fff','#fff']
 * @vue-prop {Array} [opt.clusterPointsLevelLineWidths=[2,2,2]] 輸入點叢集各級的框線寬度陣列,單位px,預設[2,2,2]
 * @vue-prop {Array} [opt.clusterPointsLevelTextSizes=[12,12,12]] 輸入點叢集各級的計數文字大小陣列,單位px,預設[12,12,12]
 * @vue-prop {Array} [opt.clusterPointsLevelTextColors=['#fff','#fff','#fff']] 輸入點叢集各級的計數文字顏色陣列,預設['#fff','#fff','#fff']
 * @vue-prop {Boolean} [opt.panelBaseMaps.show=true] 輸入是否顯示底圖選擇區布林值,預設true
 * @vue-prop {Array} [opt.panelBaseMaps.baseMaps=詳見程式碼] 輸入底圖選擇清單陣列,各元素為底圖設定物件(欄位見下),預設值詳見程式碼的defBaseMaps
 * @vue-prop {String} [opt.panelBaseMaps.baseMaps[i].name=詳見程式碼] 輸入第i個底圖的名稱字串,供面板清單顯示
 * @vue-prop {String} [opt.panelBaseMaps.baseMaps[i].url=詳見程式碼] 輸入第i個底圖的連結字串,依type而異:raster為圖磚網址樣板,支援{s}(子網域a/b/c)、{z}/{x}/{y}、//(自動補https),maplibre原生之{quadkey}、{bbox-epsg-3857}等佔位符原樣支援;wms為服務端點(自動附加GetMap參數);vector為TileJSON網址(支援//自動補https,不支援圖磚樣板與{s});style為MapLibre style JSON網址。url非有效字串之條目會被略過(console.warn)且不影響其他條目
 * @vue-prop {String} [opt.panelBaseMaps.baseMaps[i].type=raster] 輸入第i個底圖的來源類型字串,省略代表raster圖磚,可為'wms'、'vector'、'style'(url指向完整MapLibre style JSON,整組sources/layers注入現有地圖,glyphs/sprite為全域單例由最後套用者決定),預設raster
 * @vue-prop {String} [opt.panelBaseMaps.baseMaps[i].colorShade=''] 輸入第i個底圖的色調字串,非空(如'light'/'dark')代表底圖(radio單選),空字串''代表疊加層(checkbox多選並有透明度滑桿),預設''
 * @vue-prop {Boolean} [opt.panelBaseMaps.baseMaps[i].visible=false] 輸入是否顯示第i個底圖布林值,預設false
 * @vue-prop {Number} [opt.panelBaseMaps.baseMaps[i].opacity=1] 輸入第i個底圖的透明度數字(0~1),可給數字或數字字串,超出範圍夾限、非數值視為未給,raster與wms預設1、vector預設0.8(疊加層面板滑桿與實繪一致),type為'style'時不支援(各層自帶opacity,面板亦不顯示滑桿)
 * @vue-prop {String} [opt.panelBaseMaps.baseMaps[i].layerType='fill'] 輸入第i個底圖(type為vector時)的圖層類型字串,可選'fill'、'line'、'fill-extrusion',預設'fill'
 * @vue-prop {String} [opt.panelBaseMaps.baseMaps[i].layer=''] 輸入第i個底圖(type為vector時)的source-layer名稱字串,預設''
 * @vue-prop {String} [opt.panelBaseMaps.baseMaps[i].layers=''] 輸入第i個底圖(type為wms時)的LAYERS參數字串,預設''
 * @vue-prop {Number} [opt.panelBaseMaps.baseMaps[i].maxzoom=null] 輸入第i個底圖(type為raster、wms、vector時)的圖磚來源最大層級整數,即服務實際提供圖磚之最深「圖磚層級」(非地圖顯示層級:256px圖磚於地圖層級z時約請求z+1級),超過時改以該級圖磚放大顯示,避免請求不存在之層級致圖層空白並報錯,圖台可放大至opt.maxZoom(預設22,此時請求之圖磚層級可達23),故服務層級未達該值之圖磚服務須給本欄位,可給數字或數字字串,小數無條件捨去,負數、非有限數或非數值視為未給,不給則沿用maplibre預設(raster與wms為22,vector依其TileJSON),vector僅宜往內限縮TileJSON之範圍(大於TileJSON者會請求不存在之層級),type為'style'時不適用(由style JSON自帶),預設null
 * @vue-prop {Number} [opt.panelBaseMaps.baseMaps[i].minzoom=null] 輸入第i個底圖(type為raster、wms、vector時)的圖磚來源最小層級整數,需求之圖磚層級低於此值時不請求圖磚且該處不顯示(地圖俯視或開啟地形時,遠景低於此層級之區域亦不顯示),給法同maxzoom但小數無條件進位,須不大於服務最大層級,與maxzoom同時給且大於maxzoom時忽略minzoom,不給則沿用maplibre預設(raster與wms為0,vector依其TileJSON),type為'style'時不適用,預設null
 * @vue-prop {String} [opt.panelBaseMaps.baseMaps[i].colorFillExtrusion=null] 輸入第i個底圖(layerType為fill-extrusion時)的統一建物顏色字串,不傳則回退用資料colour屬性,預設null
 * @vue-prop {String} [opt.panelBaseMaps.position='topleft'] 輸入底圖選擇區位置字串,可選'topleft'、'topright'、'bottomleft'、'bottomright',預設'topleft'
 * @vue-prop {Number} [opt.panelBaseMaps.width=null] 輸入底圖選擇區寬度數字,單位px,預設null
 * @vue-prop {Number} [opt.panelBaseMaps.maxWidth=null] 輸入底圖選擇區最大寬度數字,單位px,預設null
 * @vue-prop {Number} [opt.panelBaseMaps.height=null] 輸入底圖選擇區高度數字,單位px,預設null
 * @vue-prop {Number} [opt.panelBaseMaps.maxHeight=null] 輸入底圖選擇區最大高度數字,單位px,預設null
 * @vue-prop {Boolean} [opt.panelBaseMaps.stopWheel=false] 輸入底圖選擇區當過高出現垂直捲軸時,是否可接收捲軸布林值,預設false
 * @vue-prop {Object} [opt.panelBaseMaps.terrainMap=詳見程式碼] 輸入3D地形設定物件,不給則使用程式碼的defTerrainMap(遠端DEM),給物件即整組取代預設(未給之欄位不繼承預設,例如只給exaggeration則無地形),給{}則不套用地形,預設值詳見程式碼的defTerrainMap
 * @vue-prop {String} [opt.panelBaseMaps.terrainMap.terrainSource.url] 輸入地形DEM圖磚網址樣板字串,支援{z}/{x}/{y}、//(自動補https),未給則不套用地形(陰影亦不套用)
 * @vue-prop {String} [opt.panelBaseMaps.terrainMap.terrainSource.encoding='terrarium'] 輸入地形DEM編碼字串,可選'terrarium'、'mapbox',預設'terrarium'
 * @vue-prop {Number} [opt.panelBaseMaps.terrainMap.terrainSource.tileSize=256] 輸入地形DEM圖磚尺寸數字,單位px,預設256
 * @vue-prop {Number} [opt.panelBaseMaps.terrainMap.terrainSource.maxzoom=null] 輸入地形DEM圖磚來源最大層級整數,給法同baseMaps[i].maxzoom,預設null(defTerrainMap給13)
 * @vue-prop {Number} [opt.panelBaseMaps.terrainMap.exaggeration=1.5] 輸入地形高度誇張倍率數字,預設1.5
 * @vue-prop {Object} [opt.panelBaseMaps.terrainMap.hillshadeSource=null] 輸入陰影DEM來源設定物件,欄位同terrainSource(url、encoding、tileSize、maxzoom),須同時給有效之terrainSource才生效,url與terrainSource.url相同時共用地形來源(encoding、tileSize、maxzoom以terrainSource為準),不給則不顯示陰影,預設null
 * @vue-prop {Number} [opt.panelBaseMaps.terrainMap.hillshadeExaggeration=0.5] 輸入陰影強度數字,預設0.5
 * @vue-prop {Boolean} [opt.panelLabels.show=true] 輸入是否顯示地圖資訊區布林值,預設true
 * @vue-prop {String} [opt.panelLabels.position='topright'] 輸入地圖資訊區位置字串,可選'topleft'、'topright'、'bottomleft'、'bottomright',預設'topright'
 * @vue-prop {String} [opt.panelLabels.title=''] 輸入地圖資訊區內標題字串,預設''
 * @vue-prop {String} [opt.panelLabels.lng='Longitude'] 輸入地圖資訊區內標注經度字串,預設'Longitude'
 * @vue-prop {String} [opt.panelLabels.lat='Latitude'] 輸入地圖資訊區內標注緯度字串,預設'Latitude'
 * @vue-prop {String} [opt.panelLabels.zoom='Zoom'] 輸入地圖資訊區內標注顯示層級字串,預設'Zoom'
 * @vue-prop {Array} [opt.panelLabels.useItems=['lng','lat','zoom']] 輸入地圖資訊區內呈現項目陣列,各元素給字串,'lng'代表經度,'lat'代表緯度,'zoom'代表顯示層級,預設['lng','lat','zoom']
 * @vue-prop {Number} [opt.panelLabels.width=null] 輸入地圖資訊區寬度數字,單位px,預設null
 * @vue-prop {Number} [opt.panelLabels.maxWidth=null] 輸入地圖資訊區最大寬度數字,單位px,預設null
 * @vue-prop {Number} [opt.panelLabels.height=null] 輸入地圖資訊區高度數字,單位px,預設null
 * @vue-prop {Number} [opt.panelLabels.maxHeight=null] 輸入地圖資訊區最大高度數字,單位px,預設null
 * @vue-prop {Boolean} [opt.panelCompassRose.show=false] 輸入是否顯示玫瑰羅盤區布林值,預設false
 * @vue-prop {String} [opt.panelCompassRose.position='topright'] 輸入玫瑰羅盤區位置字串,可選'topleft'、'topright'、'bottomleft'、'bottomright',預設'topright'
 * @vue-prop {Number} [opt.panelCompassRose.size=120] 輸入玫瑰羅盤區尺寸(長寬)數字,單位px,預設120
 * @vue-prop {Boolean} [opt.panelCompassRose.withPanel=false] 輸入是否顯示玫瑰羅盤區底部面板布林值,預設false
 * @vue-prop {String} [opt.panelCompassRose.iconSrcLight=詳見程式碼] 輸入淺色系玫瑰羅盤圖標來源字串,可使用base64格式或網址,預設值詳見程式碼
 * @vue-prop {String} [opt.panelCompassRose.iconSrcDark=詳見程式碼] 輸入深色系玫瑰羅盤圖標來源字串,可使用base64格式或網址,預設值詳見程式碼
 * @vue-prop {String} [opt.panelCompassRose.iconSrc=null] 輸入玫瑰羅盤圖標來源字串,若有則直接使用;若為null則withPanel為true時用iconSrcDark,否則自動依底圖colorShade決定(空或'light'用iconSrcDark,'dark'用iconSrcLight),預設null
 * @vue-prop {Boolean} [opt.panelCompass3d.show=true] 輸入是否顯示3D指北針區布林值(可點擊重設正北/俯仰),預設true
 * @vue-prop {String} [opt.panelCompass3d.position='topright'] 輸入3D指北針區位置字串,可選'topleft'、'topright'、'bottomleft'、'bottomright',預設'topright'
 * @vue-prop {Number} [opt.panelCompass3d.size=44] 輸入3D指北針尺寸數字,單位px,預設44
 * @vue-prop {Boolean} [opt.panelZoom.show=true] 輸入是否顯示縮放按鈕區布林值,預設true
 * @vue-prop {String} [opt.panelZoom.position='bottomleft'] 輸入縮放按鈕區位置字串,可選'topleft'、'topright'、'bottomleft'、'bottomright',預設'bottomleft'
 * @vue-prop {Boolean} [opt.panelScale.show=true] 輸入是否顯示比例尺區布林值,預設true
 * @vue-prop {String} [opt.panelScale.position='bottomright'] 輸入比例尺區位置字串,可選'topleft'、'topright'、'bottomleft'、'bottomright',預設'bottomright'
 * @vue-prop {Boolean} [opt.panelItems.show=true] 輸入圖層顯隱切換區是否顯示布林值,預設true
 * @vue-prop {String} [opt.panelItems.position='topleft'] 輸入圖層顯隱切換區位置字串,可選'topleft'、'topright'、'bottomleft'、'bottomright',預設'topleft'
 * @vue-prop {Number} [opt.panelItems.width=null] 輸入圖層顯隱切換區寬度數字,單位px,預設null
 * @vue-prop {Number} [opt.panelItems.maxWidth=null] 輸入圖層顯隱切換區最大寬度數字,單位px,預設null
 * @vue-prop {Number} [opt.panelItems.height=null] 輸入圖層顯隱切換區高度數字,單位px,預設null
 * @vue-prop {Number} [opt.panelItems.maxHeight=null] 輸入圖層顯隱切換區最大高度數字,單位px,預設null
 * @vue-prop {Boolean} [opt.panelItems.stopWheel=false] 輸入圖層顯隱切換區當過高出現垂直捲軸時,是否可接收捲軸布林值,預設false
 * @vue-prop {Boolean} [opt.panelLegends.show=true] 輸入是否顯示圖例區布林值(僅有可見等值線時呈現),預設true
 * @vue-prop {String} [opt.panelLegends.position='bottomright'] 輸入圖例區位置字串,可選'topleft'、'topright'、'bottomleft'、'bottomright',預設'bottomright'
 * @vue-prop {Number} [opt.panelLegends.width=null] 輸入圖例區寬度數字,單位px,預設null
 * @vue-prop {Number} [opt.panelLegends.maxWidth=300] 輸入圖例區最大寬度數字,單位px,預設300
 * @vue-prop {Number} [opt.panelLegends.height=null] 輸入圖例區高度數字,單位px,預設null
 * @vue-prop {Number} [opt.panelLegends.maxHeight=null] 輸入圖例區最大高度數字,單位px,預設null
 * @vue-prop {Array} [opt.pointSets=[]] 輸入點集合陣列,各元素為物件,預設[]
 * @vue-prop {String} [opt.pointSets[i].title=''] 輸入第i個點集合的標題字串,預設''
 * @vue-prop {String} [opt.pointSets[i].msg=''] 輸入第i個點集合的說明字串,預設''
 * @vue-prop {String} [opt.pointSets[i].key=null] 輸入第i個點集合的穩定識別鍵字串,需於陣列內唯一;提供後陣列插入或重排時該組身分不變(未變更組可增量跳過不重建),未給則以陣列索引識別,預設null
 * @vue-prop {Number} [opt.pointSets[i].order=null] 輸入第i個點集合的排序用數字,預設null
 * @vue-prop {Boolean} [opt.pointSets[i].visible=true] 輸入是否顯示第i個點集合布林值,預設true
 * @vue-prop {String} [opt.pointSets[i].type='circle'] 輸入第i個點集合的預設呈現類型字串,可選'circle'(圓)、'icon'(圖標),預設'circle'
 * @vue-prop {String} [opt.pointSets[i].lineColor='rgba(255,255,255,1)'] 輸入第i個點集合(circle)的框線顏色字串,預設'rgba(255,255,255,1)'
 * @vue-prop {Number} [opt.pointSets[i].lineWidth=1] 輸入第i個點集合(circle)的框線寬度數字,預設1
 * @vue-prop {String} [opt.pointSets[i].fillColor='rgba(0,150,255,0.65)'] 輸入第i個點集合(circle)的填充顏色字串,預設'rgba(0,150,255,0.65)'
 * @vue-prop {Number} [opt.pointSets[i].size=10] 輸入第i個點集合(circle)的圓半徑數字,預設10
 * @vue-prop {String} [opt.pointSets[i].iconSrc=詳見程式碼] 輸入第i個點集合(icon)的圖標來源字串,可使用base64格式或網址,預設為內建點圖標,詳見程式碼
 * @vue-prop {Array} [opt.pointSets[i].iconSize=[24,40]] 輸入第i個點集合(icon)的圖標尺寸陣列[寬,高],單位px,預設[24,40]
 * @vue-prop {Array} [opt.pointSets[i].iconAnchor=[iconSize[0]/2,iconSize[1]]] 輸入第i個點集合(icon)的圖標定位位置陣列,由圖標左上角起算,往左為+x、往上為+y,單位px,預設[iconSize[0]/2,iconSize[1]]
 * @vue-prop {Array} [opt.pointSets[i].popupAnchor=[0,-iconSize[1]/1.0]] 輸入第i個點集合顯示popup時的指向偏移陣列[x,y],由實際定位點起算,往右為+x、往下為+y,單位px,未給時自動依半徑或iconSize計算
 * @vue-prop {Array} [opt.pointSets[i].tooltipAnchor=[0,-iconSize[1]/1.0]] 輸入第i個點集合顯示tooltip時的指向偏移陣列[x,y],未給時自動依半徑或iconSize計算
 * @vue-prop {Array} [opt.pointSets[i].points=[]] 輸入第i個點集合的各點數據陣列,各元素為物件或緯經度陣列(即[{p1},...]或[[lat,lng],...]),預設[]
 * @vue-prop {Array} [opt.pointSets[i].points[j].latLng=[]] 輸入第i個點集合第j個點的緯經度座標陣列[lat,lng],預設[]
 * @vue-prop {String} [opt.pointSets[i].points[j].title=''] 輸入第i個點集合第j個點的標題字串,預設''
 * @vue-prop {String} [opt.pointSets[i].points[j].msg=''] 輸入第i個點集合第j個點的說明字串,預設''
 * @vue-prop {String} [opt.pointSets[i].points[j].type=繼承pointSet] 輸入第i個點集合第j個點的呈現類型字串,可選'circle'、'icon',未給則繼承所屬點集合
 * @vue-prop {String} [opt.pointSets[i].points[j].lineColor=繼承pointSet] 輸入第i個點集合第j個點的框線顏色字串,未給則繼承所屬點集合
 * @vue-prop {Number} [opt.pointSets[i].points[j].lineWidth=繼承pointSet] 輸入第i個點集合第j個點的框線寬度數字,未給則繼承所屬點集合
 * @vue-prop {String} [opt.pointSets[i].points[j].fillColor=繼承pointSet] 輸入第i個點集合第j個點的填充顏色字串,未給則繼承所屬點集合
 * @vue-prop {Number} [opt.pointSets[i].points[j].size=繼承pointSet] 輸入第i個點集合第j個點的圓半徑數字,未給則繼承所屬點集合
 * @vue-prop {String} [opt.pointSets[i].points[j].iconSrc=繼承pointSet] 輸入第i個點集合第j個點的圖標來源字串,未給則繼承所屬點集合
 * @vue-prop {Array} [opt.pointSets[i].points[j].iconSize=繼承pointSet] 輸入第i個點集合第j個點的圖標尺寸陣列[寬,高],未給則繼承所屬點集合
 * @vue-prop {Array} [opt.pointSets[i].points[j].iconAnchor=繼承pointSet] 輸入第i個點集合第j個點的圖標定位位置陣列,未給則繼承所屬點集合
 * @vue-prop {Array} [opt.pointSets[i].points[j].popupAnchor=null] 輸入第i個點集合第j個點顯示popup時的指向偏移陣列[x,y],預設null
 * @vue-prop {Array} [opt.pointSets[i].points[j].tooltipAnchor=null] 輸入第i個點集合第j個點顯示tooltip時的指向偏移陣列[x,y],預設null
 * @vue-prop {Function} [opt.pointSetsClick=function(){}] 輸入全域點集合的click呼叫函數,引數為{ point, pointSet, pointSets },預設function(){}
 * @vue-prop {Array} [opt.polylineSets=[]] 輸入折線集合陣列,各元素為物件,預設[]
 * @vue-prop {String} [opt.polylineSets[i].title=''] 輸入第i個折線集合的標題字串,預設''
 * @vue-prop {String} [opt.polylineSets[i].msg=''] 輸入第i個折線集合的說明字串,預設''
 * @vue-prop {Number} [opt.polylineSets[i].order=null] 輸入第i個折線集合的排序用數字,預設null
 * @vue-prop {Boolean} [opt.polylineSets[i].visible=true] 輸入是否顯示第i個折線集合布林值,預設true
 * @vue-prop {String} [opt.polylineSets[i].lineColor='rgba(0,150,255,1)'] 輸入第i個折線集合的框線顏色字串,預設'rgba(0,150,255,1)'
 * @vue-prop {Number} [opt.polylineSets[i].lineWidth=3] 輸入第i個折線集合的框線寬度數字,預設3
 * @vue-prop {Array} [opt.polylineSets[i].latLngs=[]] 輸入第i個折線集合的數據陣列,可為polyline([[lat,lng],...])或multiPolyline,各點座標為緯經度,預設[]
 * @vue-prop {Function} [opt.polylineSetsClick=function(){}] 輸入全域折線集合的click呼叫函數,引數為{ ev, polylineSet, kpolylineSet, polylineSets },預設function(){}
 * @vue-prop {Array} [opt.polygonSets=[]] 輸入多邊形集合陣列,各元素為物件,預設[]
 * @vue-prop {String} [opt.polygonSets[i].title=''] 輸入第i個多邊形集合的標題字串,預設''
 * @vue-prop {String} [opt.polygonSets[i].msg=''] 輸入第i個多邊形集合的說明字串,預設''
 * @vue-prop {Number} [opt.polygonSets[i].order=null] 輸入第i個多邊形集合的排序用數字,預設null
 * @vue-prop {Boolean} [opt.polygonSets[i].visible=true] 輸入是否顯示第i個多邊形集合布林值,預設true
 * @vue-prop {String} [opt.polygonSets[i].lineColor='rgba(0,150,255,1)'] 輸入第i個多邊形集合的框線顏色字串,預設'rgba(0,150,255,1)'
 * @vue-prop {Number} [opt.polygonSets[i].lineWidth=3] 輸入第i個多邊形集合的框線寬度數字,預設3
 * @vue-prop {String} [opt.polygonSets[i].fillColor='rgba(0,150,255,0.25)'] 輸入第i個多邊形集合的填充顏色字串,預設'rgba(0,150,255,0.25)'
 * @vue-prop {Array} [opt.polygonSets[i].latLngs=[]] 輸入第i個多邊形集合的數據陣列,各點座標為緯經度,預設[]。可為單一環([[lat,lng],...])、環陣列([[[lat,lng],...],...],代表1個polygon,環間為XOR套疊可表示挖洞)、或polygon陣列([[[[lat,lng],...],...],...],代表1個multiPolygon,各polygon之環各自XOR後合併)
 * @vue-prop {Function} [opt.polygonSetsClick=function(){}] 輸入全域多邊形集合的click呼叫函數,引數為{ ev, polygonSet, kpolygonSet, polygonSets },預設function(){}
 * @vue-prop {Array} [opt.geojsonSets=[]] 輸入geojson集合陣列,各元素為物件,預設[]
 * @vue-prop {String} [opt.geojsonSets[i].title=''] 輸入第i個geojson集合的標題字串,預設''
 * @vue-prop {String} [opt.geojsonSets[i].msg=''] 輸入第i個geojson集合的說明字串,預設''
 * @vue-prop {Number} [opt.geojsonSets[i].order=null] 輸入第i個geojson集合的排序用數字,預設null
 * @vue-prop {Boolean} [opt.geojsonSets[i].visible=true] 輸入是否顯示第i個geojson集合布林值,預設true
 * @vue-prop {String} [opt.geojsonSets[i].lineColor='rgba(0,150,255,1)'] 輸入第i個geojson集合的框線顏色字串,預設'rgba(0,150,255,1)'
 * @vue-prop {Number} [opt.geojsonSets[i].lineWidth=3] 輸入第i個geojson集合的框線寬度數字,預設3
 * @vue-prop {String} [opt.geojsonSets[i].fillColor='rgba(0,150,255,0.25)'] 輸入第i個geojson集合的填充顏色字串,預設'rgba(0,150,255,0.25)'
 * @vue-prop {Object} [opt.geojsonSets[i].geojson={}] 輸入第i個geojson集合的GeoJSON物件(自動拆成點/線/面子圖層),各點座標為緯經度,預設{}
 * @vue-prop {Function} [opt.geojsonSetsClick=function(){}] 輸入全域geojson集合的click呼叫函數,引數為{ ev, lat, lng, geojsonSet, kgeojsonSet, geojsonSets },預設function(){}
 * @vue-prop {Array} [opt.contourSets=[]] 輸入等值線集合陣列,各元素為物件,預設[]
 * @vue-prop {String} [opt.contourSets[i].title=''] 輸入第i個等值線集合的標題字串(圖例與圖層清單顯示用),預設''
 * @vue-prop {String} [opt.contourSets[i].msg=''] 輸入第i個等值線集合的說明字串,預設''
 * @vue-prop {String} [opt.contourSets[i].legendMsg=''] 輸入第i個等值線集合的圖例副標題字串,預設''
 * @vue-prop {Number} [opt.contourSets[i].order=null] 輸入第i個等值線集合的排序用數字,預設null
 * @vue-prop {Boolean} [opt.contourSets[i].visible=true] 輸入是否顯示第i個等值線集合布林值,預設true
 * @vue-prop {Array} [opt.contourSets[i].points=[]] 輸入第i個等值線集合的數據陣列,各點座標為緯經度,自動以三角網格技術計算等值線,預設[]
 * @vue-prop {Array} [opt.contourSets[i].thresholds=[]] 輸入第i個等值線集合的門檻值陣列,給予非有效陣列則自動計算各線門檻值,預設[]
 * @vue-prop {Object} [opt.contourSets[i].gradient=詳見程式碼] 輸入第i個等值線集合的色階(color map)設定物件,鍵範圍0至1、值為對應顏色,各鍵之間採內插取色,預設值詳見程式碼
 * @vue-prop {Function} [opt.contourSets[i].getColor=null] 輸入第i個等值線集合的顏色函數,引數為{ defaultColor, k, n, polygonSet },預設null
 * @vue-prop {String} [opt.contourSets[i].lineColor=''] 輸入第i個等值線集合的框線顏色字串,''則用填充顏色,預設''
 * @vue-prop {Number} [opt.contourSets[i].lineWidth=1] 輸入第i個等值線集合的框線寬度數字,預設1
 * @vue-prop {Number} [opt.contourSets[i].fillOpacity=0.2] 輸入第i個等值線集合的填充透明度數字,預設0.2
 * @vue-prop {String} [opt.contourSets[i].lineColorHover=同lineColor] 輸入滑鼠移入時第i個等值線集合的框線顏色字串,預設同lineColor
 * @vue-prop {Number} [opt.contourSets[i].lineWidthHover=3] 輸入滑鼠移入時第i個等值線集合的框線寬度數字,預設3
 * @vue-prop {Number} [opt.contourSets[i].fillOpacityHover=0.5] 輸入滑鼠移入時第i個等值線集合的填充透明度數字,預設0.5
 * @vue-prop {Boolean} [opt.contourSets[i].changeStyleWhenHover=true] 輸入第i個等值線集合是否啟用滑鼠移入切換style效果布林值,預設true
 * @vue-prop {Number} [opt.contourSets[i].legendNumDig=null] 輸入第i個等值線集合對圖例內數字取的小數位數,null代表不取,預設null
 * @vue-prop {Function} [opt.contourSets[i].legendTextFormater=null] 輸入第i個等值線集合對圖例各色階產生顯示文字的函數,引數為{ low, up, legends, index },預設null
 * @vue-prop {Function} [opt.contourSets[i].legendTextExtra=null] 輸入第i個等值線集合對圖例各色階附加額外文字的函數,引數為{ k, n, polygonSet },預設null
 * @vue-prop {Array} [opt.contourSets[i].polygonClipOuter=null] 輸入第i個等值線集合剔除以外之多邊形(polygon)陣列,預設null
 * @vue-prop {Array} [opt.contourSets[i].polygonsClipInner=null] 輸入第i個等值線集合剔除以內之複數多邊形(multiPolygon)陣列,預設null
 * @vue-prop {Array} [opt.contourSets[i].polygonsContainInner=null] 輸入第i個等值線集合保留以內之複數多邊形(multiPolygon)陣列,預設null
 * @vue-prop {Function} [opt.contourSetsClick=function(){}] 輸入全域等值線集合的click呼叫函數,引數為{ ev, lat, lng, latLngs, polygonSet, kpolygonSet, contourSet, kcontourSet, contourSets },預設function(){}
 * @vue-prop {Array} [opt.imageSets=[]] 輸入影像集合陣列,各元素為物件,預設[]
 * @vue-prop {String} [opt.imageSets[i].title=''] 輸入第i個影像集合的標題字串,預設''
 * @vue-prop {String} [opt.imageSets[i].msg=''] 輸入第i個影像集合的說明字串,預設''
 * @vue-prop {String} [opt.imageSets[i].key=null] 輸入第i個影像集合的穩定識別鍵字串,需於陣列內唯一;提供後陣列插入或重排時該組身分不變(未變更組不重載影像),未給則以陣列索引識別,預設null
 * @vue-prop {Number} [opt.imageSets[i].order=null] 輸入第i個影像集合的排序用數字,預設null
 * @vue-prop {Boolean} [opt.imageSets[i].visible=false] 輸入是否顯示第i個影像集合布林值,預設false
 * @vue-prop {Object} [opt.imageSets[i].image={}] 輸入第i個影像集合的影像來源物件,含url(影像連結字串)與lngMin/lngMax/latMin/latMax(四至經緯度數字),預設{}。四至以實際數值判定東西南北(較大緯度即為北邊),不依欄位命名方向,故latMin填北緯之像素列慣例亦可正確貼合不致上下顛倒
 * @vue-prop {Number} [opt.imageSets[i].opacity=1] 輸入第i個影像集合的透明度數字,範圍0~1,超出範圍將夾限,預設1
 */
export default {
    directives: {
        domresize: domResize(),
    },
    components: {
        CmpPopupResizable,
    },
    props: {
        opt: {
            type: Object,
            default: () => ({}),
        },
    },
    data() {
        return {

            loading: true,

            map: null,
            corners: ['topleft', 'topright', 'bottomleft', 'bottomright'],

            // debounce
            dbcChangePointSets: debounce(),
            dbcChangePolylineSets: debounce(),
            dbcChangePolygonSets: debounce(),
            dbcChangeGeojsonSets: debounce(),
            dbcChangeContourSets: debounce(),
            dbcChangeImageSets: debounce(),
            dbcChangeItems: debounce(),

            wats: [],
            currentZoom: 7,
            currentCenter: [23.5, 121.1],
            currentPitch: 0,
            currentBearing: 0,
            zoomRange: null, //地圖現行縮放範圍 { minZoom, maxZoom }(建圖後由地圖讀取, 與 opt 比對以判斷是否需執行期變更)
            zoomWarnKey: '', //minZoom 被忽略(大於 maxZoom)之警告去重鍵(輸入組合), ''=無需警告
            holdWriteBack: false, //範圍變更期間護欄: zoomend/moveend 不回寫 opt(R12)
            panTarget: null, //元件發起、尚未結束之平移動畫目標([lat,lng]); 動畫中又要改 zoom 時連同落定, 否則 jumpTo/setZoom 之 stop() 會把動畫中途點寫回 opt.center(R12)
            mapLoaded: false,
            projection: '',
            panelsOrder: [...defPanelsOrder],
            terrainMap: null,
            terrainMapTemp: null,

            // 面板設定
            panelBackgroundColor: 'rgba(255,255,255,0.95)',
            panelBaseMaps: { show: true, position: 'topleft', baseMaps: [], style: {}, stopWheel: false },
            panelBaseMapsTemp: {},
            panelCompassRose: { show: false, position: 'topright', size: 120, withPanel: false, iconSrc: null, iconSrcLight: uiRes.iconCompassRoseLight, iconSrcDark: uiRes.iconCompassRoseDark },
            panelCompassRoseTemp: {},
            panelCompass3d: { show: true, position: 'topright', size: 44 },
            panelCompass3dTemp: {},
            panelLabels: { show: true, position: 'topright', useItems: ['lng', 'lat', 'zoom'], lng: 'Longitude', lat: 'Latitude', zoom: 'Zoom', title: '', style: {} },
            panelLabelsTemp: {},
            panelItems: { show: true, position: 'topleft', stopWheel: false, style: {} },
            panelItemsTemp: {},
            panelZoom: { show: true, position: 'bottomleft' },
            panelZoomTemp: {},
            panelScale: { show: true, position: 'bottomright' },
            panelScaleTemp: {},
            panelLegends: { show: true, position: 'bottomright', maxWidth: 300, stopWheel: false, style: {} },
            panelLegendsTemp: {},

            displayPopupOnlyone: true,
            popupPosition: 'top',
            tooltipPosition: 'left',
            showLoc: { lat: '', lng: '' },

            // 數據集
            imageSets: [],
            effImageSetsTemp: [],
            pointSets: [],
            effPointSetsTemp: [],
            polylineSets: [],
            effPolylineSetsTemp: [],
            polygonSets: [],
            effPolygonSetsTemp: [],
            geojsonSets: [],
            effGeojsonSetsTemp: [],
            contourSets: [],
            effContourSetsTemp: [],

            items: [],
            trackedSourceIds: [],
            trackedLayerIds: [],

            displayOrderByType: true, //圖徵依型別面積序堆疊(點>線>面類>影像), 點擊命中小面積圖徵; false=維持插入序
            raiseOrderPending: false, //圖層型別重排的 idle 補重排去抖旗標

            // 叢集化設定(統一物件,由 computeClusterOpts 產生)
            clusterOpts: {
                enabled: false,
                radius: 50,
                maxZoom: 14,
                levelNum: 3,
                levelValues: [10, 100],
                levelRadius: [10, 15, 20],
                levelFillColors: ['rgba(140, 40, 25, 0.75)', 'rgba(255, 125, 50, 0.75)', 'rgba(255, 50, 100, 0.75)'],
                levelLineColors: ['#fff', '#fff', '#fff'],
                levelLineWidths: [2, 2, 2],
                levelTextSizes: [12, 12, 12],
                levelTextColors: ['#fff', '#fff', '#fff'],
                key: '',
            },

            featurePopup: null,
            featurePopupOwner: '',
            featureTooltip: null,
            featureTooltipOwner: '',

            // 當前活躍的 feature(供 slot 使用的響應式資料)
            activePolylineSet: {},
            activePolygonSet: {},
            activeGeojsonSet: {},
            activeContourSet: {},
            activePoint: {},
            activePointSet: {},
            //tooltip 專屬 active 變數: 與 popup 分離, 避免 hover 圖徵時 tooltip setter 連帶重繪已開 popup 的活元素
            activePolylineSetTooltip: {},
            activePolygonSetTooltip: {},
            activeGeojsonSetTooltip: {},
            activeContourSetTooltip: {},
            activePointTooltip: {},
            activePointSetTooltip: {},

            baseMapsDataTemp: [],
            prevClusterKey: '',
        }
    },
    mounted() {
        let vo = this
        vo.initMap()
        let wo = { immediate: true, deep: true }
        each(['imageSets'], (v) => {
            vo.wats.push(vo.$watch(`opt.${v}`, vo.changeImageSetsDebounce, wo))
        })
        each(['pointSets', 'pointSetsClick'], (v) => {
            vo.wats.push(vo.$watch(`opt.${v}`, vo.changePointSetsDebounce, wo))
        })
        each(['polylineSets', 'polylineSetsClick'], (v) => {
            vo.wats.push(vo.$watch(`opt.${v}`, vo.changePolylineSetsDebounce, wo))
        })
        each(['polygonSets', 'polygonSetsClick'], (v) => {
            vo.wats.push(vo.$watch(`opt.${v}`, vo.changePolygonSetsDebounce, wo))
        })
        each(['geojsonSets', 'geojsonSetsClick'], (v) => {
            vo.wats.push(vo.$watch(`opt.${v}`, vo.changeGeojsonSetsDebounce, wo))
        })
        each(['contourSets', 'contourSetsClick'], (v) => {
            vo.wats.push(vo.$watch(`opt.${v}`, vo.changeContourSetsDebounce, wo))
        })
    },
    beforeDestroy() {
        each(this.wats, (w) => {
            w()
        })
        if (this.map) {
            this.map.remove(); this.map = null
        }
    },
    watch: {
        opt: {
            handler() {
                this.changeOpt()
            },
            immediate: true,
            deep: true
        },
    },
    computed: {

        /** 依底圖色調選擇羅盤圖標 */
        useIconCompassRose() {
            let p = this.panelCompassRose
            if (isestr(p.iconSrc)) return p.iconSrc
            if (p.withPanel) return p.iconSrcDark
            return this.baseMapColorShade === 'dark' ? p.iconSrcLight : p.iconSrcDark
        },

        /** 當前可見底圖的色調 */
        baseMapColorShade() {
            let shade = ''
            each(get(this, 'panelBaseMaps.baseMaps', []), (v) => {
                if (v.visible && isestr(v.colorShade)) {
                    shade = v.colorShade; return false
                }
            })
            return shade
        },

        /** 比例尺文字 */
        scaleText() {
            let lat = isNumber(this.currentCenter[0]) ? this.currentCenter[0] : 23.5 //緯度 0 為有效值, 不可用 || 回退
            //maplibre 世界為 512px(zoom 0 = 512px), 赤道每像素公尺 = 40075016.686 / 512 = 78271.51696(R15; 256px 世界之 156543.03392 會使文字為實際距離之 2 倍)
            let mpp = 78271.51696 * Math.cos(lat * Math.PI / 180) / Math.pow(2, this.currentZoom)
            let d = mpp * 80
            return d >= 1000 ? Math.round(d / 1000) + ' km' : Math.round(d) + ' m'
        },

        /** 3D指北針:模擬俯視角傾斜效果的 CSS transform */
        compass3dDiscStyle() {
            let tilt = Math.min(this.currentPitch * 0.72, 52)
            return {
                display: 'inline-block',
                transform: `perspective(160px) rotateX(${tilt}deg)`,
                transition: 'transform 0.08s linear',
                transformOrigin: 'center center',
            }
        },

        /** popup 可調大小設定(讀 opt.popupResize, 給預設) */
        popupResizeCfg() {
            let c = get(this.opt, 'popupResize', {})
            return {
                resizable: c.resizable === true,
                resizeModes: isearr(c.resizeModes) ? c.resizeModes : ['top', 'bottom', 'left', 'right', 'top-left', 'top-right', 'bottom-left', 'bottom-right'],
                minWidth: isNumber(c.minWidth) ? c.minWidth : 200,
                minHeight: isNumber(c.minHeight) ? c.minHeight : 200,
                width: isNumber(c.width) ? c.width : 400,
                height: isNumber(c.height) ? c.height : 300,
            }
        },

    },
    methods: {

        // ===== 初始化 =====

        initMap() {
            let vo = this
            vo.loading = true
            vo.$emit('loading', true)
            vo.map = createMap(vo.$refs.panel, vo.opt)
            vo.currentZoom = vo.map.getZoom()
            vo.zoomRange = { minZoom: vo.map.getMinZoom(), maxZoom: vo.map.getMaxZoom() }
            //建圖後 opt.zoom(未給/非數值者為預設 6)與地圖實際層級不同(被地圖夾入範圍)時回寫實際層級, 使 opt.zoom 與地圖一致(R12-7);
            //與地圖一致者(含未給 opt.zoom 而預設 6 落在範圍內)不回寫, 不替使用端新增鍵; 不一致卻不回寫, 日後第一次無關之 opt 變更會以過期值 setZoom 而中斷使用者手勢
            let b0 = computeBasicOpt(vo.opt)
            if (Math.abs(b0.zoom - vo.currentZoom) > 1e-9) vo.opt.zoom = vo.currentZoom
            vo.zoomWarnKey = vo.getZoomWarnKey(b0) //minZoom 被忽略之警告已由 createMap 於建圖時發出一次

            vo.map.on('load', () => {
                vo.mapLoaded = true
                vo.applyProjection()
                vo.applyBaseMaps()
                vo.applyTerrain()
                vo.ensureDataProcessed()
                vo.applyAllDataLayers()
                vo.map.once('idle', () => {
                    vo.loading = false
                    vo.$emit('loading', false)
                })
            })
            vo.map.on('mousemove', (e) => {
                vo.showLoc = { lat: dig(e.lngLat.lat, 7), lng: dig(e.lngLat.lng, 7) }
            })
            vo.map.on('zoomend', () => {
                let z = vo.map.getZoom(); vo.currentZoom = z; if (!vo.holdWriteBack) vo.opt.zoom = z
                if (vo.projection === '') vo.applyProjection()
                vo.syncZoomFromMap() //切換投影時 maplibre 帶約束遷移 transform 且不發事件, 層級可能被改
            })
            vo.map.on('moveend', () => {
                let c = vo.map.getCenter(); let nc = [c.lat, c.lng]
                if (vo.holdWriteBack) return //範圍變更連帶之移動不回寫(尚未處理之 opt.center 不可被舊值蓋掉)
                vo.panTarget = null //平移動畫(或被中斷之動畫)已結束
                if (!isEqual(vo.opt.center, nc)) vo.opt.center = nc
                vo.currentCenter = nc
                vo.syncZoomFromMap() //resize 等不發 zoomend 之層級變更
            })
            //切換投影(含使用端經 getMapObject().setProjection、load 後自動投影、changeOpt 之 opt.projection): maplibre 帶約束遷移 transform 且不發 zoomend/moveend,
            //層級與中心可能被約束改動 → 事件於遷移後同步發出, 在此重讀(R12-6)
            vo.map.on('projectiontransition', () => {
                vo.syncZoomFromMap()
                vo.syncCenterFromMap()
            })
            vo.map.on('move', () => {
                vo.recheckPopupDirections()
            })
            vo.map.on('pitch', () => {
                vo.currentPitch = vo.map.getPitch()
            })
            vo.map.on('rotate', () => {
                vo.currentBearing = vo.map.getBearing()
            })
        },

        resize() {
            if (this.map) this.map.resize()
        },

        // 以地圖實際層級為準同步 currentZoom 與 opt.zoom(層級被不發 zoomend 之路徑改動時, 如切換投影、resize 後之世界高度適配; R12-6)
        syncZoomFromMap() {
            let vo = this
            if (!vo.map || vo.holdWriteBack) return
            let z = vo.map.getZoom()
            if (Math.abs(z - vo.currentZoom) > 1e-9) {
                vo.currentZoom = z; vo.opt.zoom = z
            }
        },

        // 以地圖實際中心為準同步 currentCenter 與 opt.center(切換投影帶約束遷移 transform 且不發事件, 中心可能被約束改動; R12-6)。
        // 動畫進行中不同步(中途點非最終值, 動畫結束之 moveend 會寫回)
        syncCenterFromMap() {
            let vo = this
            if (!vo.map || vo.holdWriteBack || vo.map.isMoving()) return
            let c = vo.map.getCenter(); let nc = [c.lat, c.lng]
            let cc = vo.currentCenter
            if (!isarr(cc) || Math.abs(cc[0] - nc[0]) > 1e-9 || Math.abs(cc[1] - nc[1]) > 1e-9) {
                vo.currentCenter = nc; vo.opt.center = nc
            }
        },

        // minZoom 被忽略(大於 maxZoom)時之警告去重鍵; 無需警告回 ''
        getZoomWarnKey(basic) {
            return basic.ignoredMin ? `${String(this.opt.minZoom)}|${String(this.opt.maxZoom)}` : ''
        },

        // ===== 面板排序 =====

        getPanelOrder(panelName, corner) {
            let order = this.panelsOrder
            let idx = order.indexOf(panelName)
            if (corner.startsWith('bottom')) return idx < 0 ? -1 : (order.length - idx)
            return idx < 0 ? order.length : idx
        },

        // ===== 投影 =====

        applyProjection() {
            _applyProjection(this.map, this.projection)
        },

        // ===== 底圖 =====

        applyBaseMaps() {
            let vo = this
            applyBaseMaps(vo.map, get(vo, 'panelBaseMaps.baseMaps', []))
            //style 型別底圖須先 fetch style JSON: 首次(或新增 url)載入完成後補套一次;
            //全部已快取或無 style 型別時回傳 0, 不補套(applyBaseMaps 為完整重建, 會重抓圖磚, 不可無謂觸發)
            preloadBaseMapStyles(get(vo, 'panelBaseMaps.baseMaps', []))
                .then((n) => {
                    if (n <= 0 || !vo.map || !vo.mapLoaded) return
                    applyBaseMaps(vo.map, get(vo, 'panelBaseMaps.baseMaps', []))
                    //applyBaseMaps 重加底圖(無 beforeId)會排在資料圖層之上: 將 hillshade 與資料圖層壓回頂端(同 processPanelBaseMaps 重建路徑)
                    if (vo.map.getLayer('terrain-hillshade')) vo.map.moveLayer('terrain-hillshade')
                    each(vo.trackedLayerIds, (lid) => {
                        if (vo.map.getLayer(lid)) vo.map.moveLayer(lid)
                    })
                    vo.raiseFeatureLayersByType()
                })
                .catch((err) => {
                    console.error('[WMaplibreglVue] applyBaseMaps preload error:', err)
                })
        },
        applyTerrain() {
            applyTerrain(this.map, this.terrainMap, this.trackedLayerIds)
        },

        // 將執行期底圖的顯隱/透明度寫回 opt。
        // opt 有給 baseMaps 時,computePanelBaseMaps 一律以原始 opt 的 baseMaps 為準;
        // 而元件自身於 moveend/zoomend 會改動 opt.center/opt.zoom,經 deep watcher 觸發重推,
        // 若未寫回,重推就會以原始 opt 的 visible/opacity 覆寫,使使用者的底圖選取被還原。
        syncBaseMapsToOpt() {
            let vo = this
            let optBaseMaps = get(vo, 'opt.panelBaseMaps.baseMaps', null)
            if (!isearr(optBaseMaps)) return //opt 未給 baseMaps 時 computePanelBaseMaps 本就沿用執行期狀態,無須寫回
            each(get(vo, 'panelBaseMaps.baseMaps', []), (bm, k) => {
                let obm = get(optBaseMaps, k, null); if (!isobj(obm)) return
                vo.$set(obm, 'visible', bm.visible)
                if (isNumber(bm.opacity)) vo.$set(obm, 'opacity', bm.opacity)
            })
        },

        switchBaseMap(idx) {
            _switchBaseMap(this.map, this.panelBaseMaps.baseMaps, idx)
            // 同步 baseMapsDataTemp,避免 processPanelBaseMaps 將此 visible 變動誤判為 baseMapsChanged
            // 而觸發 applyBaseMaps(),導致底圖圖層被重新堆疊在資料圖層上方
            this.baseMapsDataTemp = cloneDeep(this.panelBaseMaps.baseMaps)
            this.syncBaseMapsToOpt()
            this.$forceUpdate()
        },
        toggleOverlayVisible(idx) {
            _toggleOverlayVisible(this.map, this.panelBaseMaps.baseMaps, idx)
            // 同步 baseMapsDataTemp,避免 processPanelBaseMaps 將此 visible 變動誤判為 baseMapsChanged
            // 而觸發 applyBaseMaps(),導致底圖圖層被重新堆疊在資料圖層上方
            this.baseMapsDataTemp = cloneDeep(this.panelBaseMaps.baseMaps)
            this.syncBaseMapsToOpt()
            this.$forceUpdate()
        },
        // 面板顯示用之疊加層透明度(與實繪同一擁有者 getBaseMapOpacity; style 型不渲染滑桿故不會取到 null, 保底回 1)
        bmOpacity(bm) {
            let v = getBaseMapOpacity(bm)
            return v === null ? 1 : v
        },
        setOverlayOpacity(idx, val) {
            _setOverlayOpacity(this.map, this.panelBaseMaps.baseMaps, idx, val)
            this.baseMapsDataTemp = cloneDeep(this.panelBaseMaps.baseMaps)
            this.syncBaseMapsToOpt()
            this.$forceUpdate()
        },

        // ===== 自訂縮放 =====

        zoomIn() {
            if (this.map) this.map.zoomIn()
        },
        zoomOut() {
            if (this.map) this.map.zoomOut()
        },

        // ===== 滾輪攔截 =====

        handleWheel(e, panel) {
            if (panel && panel.stopWheel) e.stopPropagation()
        },

        // ===== opt 變更 =====

        changeOpt() {
            let vo = this

            let basic = computeBasicOpt(vo.opt, vo.currentZoom, vo.currentCenter)
            vo.panelBackgroundColor = basic.panelBackgroundColor
            //minZoom 被忽略(大於 maxZoom)之警告: 與是否改變有效範圍無關(例如只給 minZoom:23 而有效範圍仍為預設 0~22), 以輸入組合去重(同一組只警告一次)
            if (vo.map) {
                let wk = vo.getZoomWarnKey(basic)
                if (wk !== vo.zoomWarnKey) {
                    vo.zoomWarnKey = wk
                    if (wk !== '') console.warn(`[WMaplibreglVue] opt.minZoom 大於 opt.maxZoom(有效 maxZoom=${basic.maxZoom}), 忽略 minZoom`)
                }
            }
            let zoom0 = vo.currentZoom //範圍變更前之層級: 父層本輪明給之 zoom 是否與現行不同, 須以此判斷(範圍變更可能已把現行層級拉到恰等於目標)
            //範圍變更先套用(maplibre 會把範圍外之現行層級拉入); 期間 zoomend/moveend 不回寫 opt, 由下方以地圖實際值統一回寫(R11、R12-2)
            if (vo.map && vo.zoomRange && (vo.zoomRange.minZoom !== basic.minZoom || vo.zoomRange.maxZoom !== basic.maxZoom)) {
                vo.holdWriteBack = true
                try {
                    applyZoomRange(vo.map, basic)
                }
                finally {
                    vo.holdWriteBack = false
                }
                vo.zoomRange = { minZoom: vo.map.getMinZoom(), maxZoom: vo.map.getMaxZoom() } //以地圖實際值為準(套用失敗時不誤記為已套用)
                vo.currentZoom = vo.map.getZoom()
            }
            //浮點雜訊不視為變更(R12-4); 範圍變更前後任一與目標不同即視為父層要變更 zoom: 否則範圍把現行層級拉到恰等於目標時 needZoom 為假,
            //僅剩 center 時走 panTo, globe 下 maplibre 會依緯度差調整層級而丟掉父層明給之 zoom(R12-1)
            let needZoom = Math.abs(zoom0 - basic.zoom) > 1e-9 || Math.abs(vo.currentZoom - basic.zoom) > 1e-9
            let needCenter = !isEqual(vo.currentCenter, basic.center)
            if (!vo.map) {
                vo.currentZoom = basic.zoom; vo.currentCenter = basic.center
            }
            else if (needZoom && needCenter) {
                //同時變更: 單一 jumpTo, 避免 setZoom 同步觸發之 moveend 把尚未平移之舊 center 寫回 opt.center 而吞掉新 center(R12-1)
                vo.currentCenter = basic.center
                vo.panTarget = null
                vo.map.jumpTo({ center: [basic.center[1], basic.center[0]], zoom: basic.zoom })
            }
            else if (needZoom) {
                if (vo.panTarget && vo.map.isMoving()) {
                    //元件發起之平移動畫尚未結束又要改 zoom: setZoom 之 stop() 會中斷動畫並把中途點寫回 opt.center → 連同平移目標一併落定(R12-5)
                    vo.map.jumpTo({ center: [vo.panTarget[1], vo.panTarget[0]], zoom: basic.zoom })
                }
                else {
                    vo.map.setZoom(basic.zoom)
                }
            }
            else if (needCenter) {
                vo.currentCenter = basic.center
                vo.map.panTo([basic.center[1], basic.center[0]])
                //panTarget 須於 panTo 之後設: panTo 之 stop() 會同步補發被中斷動畫(如前一次 opt.center 變更之平移)之 moveend, 其 handler 會清除 panTarget;
                //平移即刻完成(無動畫)者不留 panTarget
                vo.panTarget = vo.map.isMoving() ? basic.center : null
            }
            if (vo.map && needZoom) {
                //以地圖實際層級為準: 超出範圍者被 maplibre 拉入; 若拉入後與原層級相同則不觸發 zoomend, 須在此補回寫, opt.zoom 方不與地圖脫節(R12-3);
                //opt.zoom 非數值者(含 NaN、Symbol 等, 不可對其強制轉型)一律回寫實際層級
                let z = vo.map.getZoom(); vo.currentZoom = z
                let oz = vo.opt.zoom
                if (!(isnum(oz) && Math.abs(Number(oz) - z) <= 1e-9)) vo.opt.zoom = z
            }
            vo.showLoc = { lat: dig(basic.center[0], 7), lng: dig(basic.center[1], 7) }
            vo.displayPopupOnlyone = basic.displayPopupOnlyone
            vo.displayOrderByType = basic.displayOrderByType
            if (basic.popupPosition) vo.popupPosition = basic.popupPosition
            if (basic.tooltipPosition) vo.tooltipPosition = basic.tooltipPosition

            vo.processPanelBaseMaps()
            vo.processPanelCompassRose()
            vo.processPanelCompass3d()
            vo.processPanelLabels()
            vo.processPanelItems()
            vo.processPanelZoom()
            vo.processPanelScale()
            vo.processPanelLegends()

            // 叢集化
            let newOpts = computeClusterOpts(vo.opt)
            let clusterChanged = vo.prevClusterKey !== '' && vo.prevClusterKey !== newOpts.key
            vo.clusterOpts = newOpts
            vo.prevClusterKey = newOpts.key
            if (clusterChanged && vo.map && vo.mapLoaded) {
                let t = clearTrackedByPrefix(vo.map, 'point-', vo.trackedLayerIds, vo.trackedSourceIds)
                vo.trackedLayerIds = t.layerIds; vo.trackedSourceIds = t.sourceIds
                vo.renderPointSets()
            }

            // panelsOrder
            let panelsOrder = get(vo, 'opt.panelsOrder', null)
            if (!isarr(panelsOrder) || panelsOrder.length === 0) panelsOrder = defPanelsOrder
            if (!isEqual(vo.panelsOrder, panelsOrder)) vo.panelsOrder = cloneDeep(panelsOrder)

            // projection
            let projection = basic.projection
            if (vo.projection !== projection) {
                vo.projection = projection
                if (vo.map && vo.mapLoaded) vo.applyProjection() //層級/中心被約束改動之同步由 projectiontransition 事件處理(R12-6)
            }

            vo.changeItemsDebounce()
        },

        processPanelBaseMaps() {
            let vo = this
            let result = computePanelBaseMaps(vo.opt, get(vo, 'panelBaseMaps.baseMaps', []), vo.terrainMapTemp, vo.baseMapsDataTemp, defBaseMaps, defTerrainMap)
            vo.terrainMapTemp = cloneDeep(result.terrainMap)
            vo.terrainMap = result.terrainMap
            vo.panelBaseMaps = result.panelBaseMaps
            vo.panelBaseMapsTemp = cloneDeep(result.panelBaseMaps)
            if (result.baseMapsChanged) {
                let prevBaseMaps = vo.baseMapsDataTemp //更新前的上次套用值, 供 paint-only 判斷
                let nextBaseMaps = result.panelBaseMaps.baseMaps
                vo.baseMapsDataTemp = cloneDeep(nextBaseMaps)
                if (vo.map && vo.mapLoaded) {
                    if (isBaseMapsPaintOnlyDiff(prevBaseMaps, nextBaseMaps)) {
                        //僅 paint(如 colorFillExtrusion/opacity)變更: 就地 setPaintProperty,
                        //不重建底圖 layer/source → 不重抓圖磚、不影響其他未變更圖層(無閃爍)
                        each(nextBaseMaps, (bm, k) => updateBaseMapPaint(vo.map, nextBaseMaps, k))
                    }
                    else if (!isBaseMapsStructuralDiff(prevBaseMaps, nextBaseMaps)) {
                        //僅順序/可見性(可含 paint)變更: 增量套用(setPaintProperty + setLayoutProperty + moveLayer),
                        //不重建 layer/source → 不重抓圖磚、不擾動資料圖層 z-order(無閃爍)
                        updateBaseMapsIncremental(vo.map, nextBaseMaps)
                    }
                    else {
                        vo.applyBaseMaps()
                        // applyBaseMaps 會移除後重新加入底圖圖層(無 beforeId),使底圖排在資料圖層之上;
                        // 因此需將所有已追蹤的資料圖層移回最頂層
                        //hillshade 不在 tracked 清單, 先單獨壓回(維持「底圖上、資料下」之疊序)
                        if (vo.map.getLayer('terrain-hillshade')) vo.map.moveLayer('terrain-hillshade')
                        each(vo.trackedLayerIds, (lid) => {
                            if (vo.map.getLayer(lid)) vo.map.moveLayer(lid)
                        })
                        //再依圖徵型別面積序重排(點>線>面), 使點擊命中小面積圖徵(displayOrderByType=false 則不重排)
                        vo.raiseFeatureLayersByType()
                    }
                }
            }
            if (result.terrainChanged && vo.map && vo.mapLoaded) vo.applyTerrain()
        },

        processPanelCompassRose() {
            let vo = this
            let p = computePanelCompassRose(vo.opt, uiRes)
            if (!isEqual(vo.panelCompassRoseTemp, p)) {
                vo.panelCompassRose = p; vo.panelCompassRoseTemp = cloneDeep(p)
            }
        },

        processPanelCompass3d() {
            let vo = this
            let p = computePanelCompass3d(vo.opt)
            if (!isEqual(vo.panelCompass3dTemp, p)) {
                vo.panelCompass3d = p; vo.panelCompass3dTemp = cloneDeep(p)
            }
        },

        resetTo2d() {
            if (this.map) this.map.easeTo({ pitch: 0, bearing: 0, duration: 400 })
        },

        processPanelLabels() {
            let vo = this
            let p = computePanelLabels(vo.opt)
            if (!isEqual(vo.panelLabelsTemp, p)) {
                vo.panelLabels = p; vo.panelLabelsTemp = cloneDeep(p)
            }
        },

        processPanelItems() {
            let vo = this
            let p = computePanelItems(vo.opt)
            if (!isEqual(vo.panelItemsTemp, p)) {
                vo.panelItems = p; vo.panelItemsTemp = cloneDeep(p)
            }
        },

        processPanelZoom() {
            let vo = this
            let p = computePanelZoom(vo.opt)
            if (!isEqual(vo.panelZoomTemp, p)) {
                vo.panelZoom = p; vo.panelZoomTemp = cloneDeep(p)
            }
        },

        processPanelScale() {
            let vo = this
            let p = computePanelScale(vo.opt)
            if (!isEqual(vo.panelScaleTemp, p)) {
                vo.panelScale = p; vo.panelScaleTemp = cloneDeep(p)
            }
        },

        processPanelLegends() {
            let vo = this
            let p = computePanelLegends(vo.opt)
            if (!isEqual(vo.panelLegendsTemp, p)) {
                vo.panelLegends = p; vo.panelLegendsTemp = cloneDeep(p)
            }
        },

        // ===== 資料圖層 =====

        ensureDataProcessed() {
            let vo = this
            if (!isearr(vo.pointSets) && isearr(get(vo, 'opt.pointSets', null))) vo.changePointSets()
            if (!isearr(vo.polylineSets) && isearr(get(vo, 'opt.polylineSets', null))) vo.changePolylineSets()
            if (!isearr(vo.polygonSets) && isearr(get(vo, 'opt.polygonSets', null))) vo.changePolygonSets()
            if (!isearr(vo.geojsonSets) && isearr(get(vo, 'opt.geojsonSets', null))) vo.changeGeojsonSets()
            if (!isearr(vo.contourSets) && isearr(get(vo, 'opt.contourSets', null))) vo.changeContourSets()
            if (!isearr(vo.imageSets) && isearr(get(vo, 'opt.imageSets', null))) vo.changeImageSets()
        },

        applyAllDataLayers() {
            this.renderPointSets(); this.renderPolylineSets(); this.renderPolygonSets()
            this.renderGeojsonSets(); this.renderContourSets(); this.renderImageSets()
        },

        //依「圖徵型別面積序」重新堆疊圖層: image(最底)→contour→polygon→geojson→polyline→point(最頂),
        //使面積小者在上(點>線>面), 點擊才命中小面積圖徵而非被大面積圖徵攔截; 同時讓資料圖層位於底圖之上.
        //直接掃描地圖實際圖層(不依賴 tracked: 點圖層非同步加入, tracked 常不同步). displayOrderByType=false 時不重排.
        raiseFeatureLayersByType() {
            let vo = this
            if (!vo.map || !vo.displayOrderByType) return
            let style = vo.map.getStyle()
            if (!style || !isarr(style.layers)) return
            let ids = style.layers.map((l) => l.id).filter((id) => isestr(id))
            //由下到上, 後 moveLayer 者疊在上層(moveLayer 無 beforeId = 移到最頂)
            let order = ['image-', 'contour-', 'polygon-', 'geojson-', 'polyline-', 'point-']
            each(order, (prefix) => {
                each(ids, (lid) => {
                    if (lid.indexOf(prefix) === 0 && vo.map.getLayer(lid)) vo.map.moveLayer(lid)
                })
            })
        },
        //圖層(尤其點)為非同步載入, 故先同步重排一次, 再掛一次性 idle 待延後圖層就位後重排;
        //raiseOrderPending 將連續 render 的多次重排合併為一次 idle 重排
        scheduleRaiseFeatureLayers() {
            let vo = this
            if (!vo.map || !vo.mapLoaded || !vo.displayOrderByType) return
            vo.raiseFeatureLayersByType()
            if (vo.raiseOrderPending) return
            vo.raiseOrderPending = true
            vo.map.once('idle', () => {
                vo.raiseOrderPending = false
                vo.raiseFeatureLayersByType()
            })
        },
        //點擊優先權: displayOrderByType 開啟時, 同一點若有「更高優先型別」(point>polyline>geojson>polygon>contour)的圖徵,
        //則目前型別讓位(該 click handler 直接 return, 不開 popup 也不觸發 funSetsClick), 使點擊確定命中視覺最上層型別,
        //與 z-order 一致. 因 maplibre 各圖層 click listener 皆會觸發、displayPopupOnlyone 為「最後者勝」, 故需此閘門.
        shouldDeferFeatureClick(screenPoint, myType) {
            let vo = this
            if (!vo.displayOrderByType || !vo.map || !screenPoint) return false
            let rank = { point: 5, polyline: 4, geojson: 3, polygon: 2, contour: 1 }
            let myRank = rank[myType] || 0
            let fs = []
            try {
                fs = vo.map.queryRenderedFeatures(screenPoint) || []
            }
            catch (e) {
                return false
            }
            let topRank = 0
            each(fs, (f) => {
                let lid = (f && f.layer && f.layer.id) || ''
                each(rank, (r, t) => {
                    if (r > topRank && lid.indexOf(t + '-') === 0) topRank = r
                })
            })
            return topRank > myRank //同點有更高優先型別 → 讓位
        },

        clearTrackedByPrefix(prefix) {
            let t = clearTrackedByPrefix(this.map, prefix, this.trackedLayerIds, this.trackedSourceIds)
            this.trackedLayerIds = t.layerIds; this.trackedSourceIds = t.sourceIds
        },

        // -- 點 --
        changePointSetsDebounce() {
            this.dbcChangePointSets(() => {
                this.changePointSets()
            })
        },
        //六型別 changeXxxSets 共用流程: cloneDeep → 結構 diff(omit visible) → 早退時僅同步 visible → normalize → 面板清單 + render。
        //o: { optKey, tempKey, setsKey, defVisible, clickKey, normalize(sets), render(), renderOnIdleIfNotLoaded }
        //注意: o.defVisible 須與該型別 normalize 內的 visible 預設值一致(point/polyline/polygon/geojson=true, image/contour=false),
        //兩處不一致會使「visible 同步早退」與「完整 normalize」對缺省 visible 的解讀不同
        processSetsChange(o) {
            let vo = this
            let sets = get(vo, `opt.${o.optKey}`, null); if (!isarr(sets)) sets = []; sets = cloneDeep(sets)
            let eff = map(sets, (v) => omit(v, 'visible'))
            //全域 click 函數(opt.xxxSetsClick)不在 eff 內(位於 sets 之外), 須另以參考比對偵測——
            //否則 runtime 只更換 click 函數時會被結構早退擋下, 新函數永不進正規化資料
            if (!vo._effClickFns) vo._effClickFns = {} //刻意不進 reactive data(僅存函式參考快照)
            let fcNow = o.clickKey ? get(vo, `opt.${o.clickKey}`, null) : null
            let clickChanged = o.clickKey ? vo._effClickFns[o.clickKey] !== fcNow : false
            if (!clickChanged && isEqual(vo[o.tempKey], eff)) {
                //結構未變: 僅同步 visible(涵蓋外部直接改 opt.xxxSets[k].visible 的路徑), 有差異才重新套用顯隱
                if (vo.syncSetsVisible(vo[o.setsKey], sets, o.defVisible)) {
                    vo.changeItemsDebounce(); if (vo.map && vo.mapLoaded) o.render()
                }
                return
            }
            vo[o.tempKey] = eff
            if (o.clickKey) vo._effClickFns[o.clickKey] = fcNow
            vo[o.setsKey] = o.normalize(sets)
            vo.changeItemsDebounce()
            if (vo.map) {
                if (vo.mapLoaded) o.render()
                else if (o.renderOnIdleIfNotLoaded) vo.map.once('idle', () => o.render())
            }
        },

        changePointSets() {
            let vo = this
            vo.processSetsChange({
                optKey: 'pointSets',
                tempKey: 'effPointSetsTemp',
                setsKey: 'pointSets',
                defVisible: true,
                clickKey: 'pointSetsClick',
                renderOnIdleIfNotLoaded: true, //點圖層允許 map 尚未 load 完就排程渲染(once idle)
                render: () => vo.renderPointSets(),
                normalize: (sets) => {
                    let fc = get(vo, 'opt.pointSetsClick', null)
                    let norm = map(sets, (ps, kps) => {
                        //穩定識別碼 kid: 使用者給 key(字串, 需於陣列內唯一)則陣列位移時組身分不變(可走逐組差異跳過);
                        //未給則退回索引字串(id/圖層命名與現行完全一致)
                        let kid = isestr(get(ps, 'key', null)) ? `k-${ps.key}` : String(kps)
                        if (!isestr(get(ps, 'title', null))) ps.title = ''; if (!isestr(get(ps, 'msg', null))) ps.msg = ''
                        if (!isNumber(get(ps, 'order', null))) ps.order = null; if (!isbol(get(ps, 'visible', null))) ps.visible = true
                        let sz = get(ps, 'size', 10); let fl = get(ps, 'fillColor', 'rgba(0,150,255,0.65)'); let lc = get(ps, 'lineColor', 'rgba(255,255,255,1)'); let lw = get(ps, 'lineWidth', 1)
                        ps.points = map(ps.points, (pt, kpt) => {
                            if (isearr(pt)) pt = { latLng: [get(pt, 0), get(pt, 1)] }
                            if (!isestr(get(pt, 'title', null))) pt.title = ''; if (!isestr(get(pt, 'msg', null))) pt.msg = ''
                            return { id: `pointSet-${kid}-point-${kpt}`, ...pt, radius: get(pt, 'size', null) || sz, fillColor: get(pt, 'fillColor', null) || fl, lineColor: get(pt, 'lineColor', null) || lc, lineWidth: get(pt, 'lineWidth', null) || lw, funSetsClick: fc }
                        })
                        freezeCoordField(ps.points) //萬點級: 免 Vue 深度 observe(渲染/事件反查/popup 皆唯讀取用)
                        return { id: `pointSet-${kid}`, ...ps, kid, funSetsClick: fc }
                    })
                    //kid 查找表(刻意不進 reactive data): 事件 handler 以 _kid 取「最新」資料。
                    //key 重複時後者覆蓋前者(同 Vue :key 約定)
                    let byKid = {}
                    each(norm, (ps) => {
                        if (byKid[ps.kid] !== undefined) console.warn(`[WMaplibreglVue] pointSets 有重複 key「${ps.kid}」: 各組將共用同一組圖層 id 互相覆蓋, 請保持 key 於陣列內唯一`)
                        byKid[ps.kid] = ps
                    })
                    vo.pointSetsByKid = byKid
                    return norm
                },
            })
        },

        renderPointSets() {
            let vo = this
            let tracked = { sourceIds: vo.trackedSourceIds, layerIds: vo.trackedLayerIds }
            //過期清理改於 impl 內以 kid 判斷(索引式 removeStaleSetLayers 會誤刪位移後的組)
            renderPointSetsImpl(vo.map, vo.pointSets, vo.clusterOpts, tracked, {
                registerIcon: (key, src, w, h) => registerIconImage(vo.map, key, src, w, h),
                //kid 可能含 . 與 -(來自使用者 key), 須用陣列路徑查找, 不可用字串路徑
                getPointData: (kid, kpt) => get(vo, ['pointSetsByKid', kid, 'points', kpt], null),
                getPointSetData: (kid) => get(vo, ['pointSetsByKid', kid], null),
                getDisplayOrderByType: () => vo.displayOrderByType,
                onPointClick: (ptData, psData, p, coords) => {
                    if (isfun(ptData.funSetsClick)) ptData.funSetsClick({ point: ptData, pointSet: psData, pointSets: vo.pointSets })
                    let popAnchor = get(ptData, 'popupAnchor', null) || get(psData, 'popupAnchor', null)
                    if (!isarr(popAnchor) || popAnchor.length < 2) {
                        if (p._ptype === 'circle') {
                            let r = p._radius || get(psData, 'size', 10); popAnchor = [0, -(r / 1.0)]
                        }
                        else {
                            popAnchor = isNumber(get(p, '_defPopupAnchorY', null)) ? [0, p._defPopupAnchorY] : null //預設 icon 錨點記於 feature props
                            if (!isarr(popAnchor)) {
                                let iSz = get(ptData, 'iconSize', null) || get(psData, 'iconSize', null) || [25, 41]
                                popAnchor = [0, -(iSz[1] / 1.0)]
                            }
                        }
                    }
                    vo.activePoint = ptData; vo.activePointSet = psData
                    vo.$nextTick(() => {
                        let refInner = vo.$refs.refPointPopupInner; if (!refInner || !refInner.innerHTML.trim()) return
                        vo.closeAllPopupsIfOnlyone()
                        vo.featurePopup = createDirectionalPopup(vo.map, coords, refInner, vo.popupPosition, 0, { maxWidth: 'none' }, popAnchor)
                        //owner 以組身分(kid)記錄: 索引會因 keyed 重排位移, 面板隱藏之關閉路徑由 closePopupByOwner 轉譯對應
                        vo.featurePopupOwner = `pointSets.k:${p._kid}`
                    })
                },
                onPointEnter: (ptData, psData, p, coords) => {
                    let tipAnchor = get(ptData, 'tooltipAnchor', null) || get(psData, 'tooltipAnchor', null)
                    if (!isarr(tipAnchor) || tipAnchor.length < 2) {
                        if (p._ptype === 'circle') {
                            tipAnchor = [0, 0]
                        }
                        else {
                            tipAnchor = isNumber(get(p, '_defTooltipAnchorY', null)) ? [0, p._defTooltipAnchorY] : null //預設 icon 錨點記於 feature props
                            if (!isarr(tipAnchor)) {
                                let iSz = get(ptData, 'iconSize', null) || get(psData, 'iconSize', null) || [25, 41]
                                tipAnchor = [0, -(iSz[1] / 1.0)]
                            }
                        }
                    }
                    vo.activePointTooltip = ptData; vo.activePointSetTooltip = psData
                    vo.$nextTick(() => {
                        let refEl = vo.$refs.refPointTooltip; if (!refEl) return
                        let html = refEl.innerHTML.trim(); if (!html) return
                        vo.hideFeatureTooltip()
                        let ct = document.createElement('div'); ct.innerHTML = html
                        vo.featureTooltip = createDirectionalPopup(vo.map, coords, ct, vo.tooltipPosition, 0, { closeButton: false, closeOnClick: false, maxWidth: 'none', className: 'wlv2-tooltip' }, tipAnchor)
                        vo.featureTooltipOwner = `pointSets.k:${p._kid}` //owner 以組身分(kid)記錄, 同 popup
                    })
                },
                onPointLeave: () => {
                    vo.hideFeatureTooltip()
                },
            }, {
                src: uiRes.iconPoint,
                size: [24, 40],
                key: 'point-icon-default',
            })
            vo.trackedSourceIds = tracked.sourceIds
            vo.trackedLayerIds = tracked.layerIds
            vo.scheduleRaiseFeatureLayers()
        },

        // -- 折線 --
        changePolylineSetsDebounce() {
            this.dbcChangePolylineSets(() => {
                this.changePolylineSets()
            })
        },
        changePolylineSets() {
            let vo = this
            vo.processSetsChange({
                optKey: 'polylineSets',
                tempKey: 'effPolylineSetsTemp',
                setsKey: 'polylineSets',
                defVisible: true,
                clickKey: 'polylineSetsClick',
                render: () => vo.renderPolylineSets(),
                normalize: (sets) => {
                    let fc = get(vo, 'opt.polylineSetsClick', null)
                    return map(sets, (pls, k) => {
                        if (!isestr(get(pls, 'title', null))) pls.title = ''; if (!isestr(get(pls, 'msg', null))) pls.msg = ''
                        if (!isNumber(get(pls, 'order', null))) pls.order = null; if (!isbol(get(pls, 'visible', null))) pls.visible = true
                        let lc = get(pls, 'lineColor', null); if (!isestr(lc)) lc = 'rgba(0,150,255,1)'
                        let lw = get(pls, 'lineWidth', null); if (!isNumber(lw)) lw = 3
                        freezeCoordField(pls.latLngs) //大量座標: 免 Vue 深度 observe(渲染端唯讀取用)
                        return { id: `polylineSet-${k}`, ...pls, lineColor: lc, lineWidth: lw, funSetsClick: fc }
                    })
                },
            })
        },
        renderPolylineSets() {
            let vo = this
            let tracked = { sourceIds: vo.trackedSourceIds, layerIds: vo.trackedLayerIds }
            //聚合渲染(單 source + 1 層, id 無數字尾綴): 過期/無可見組之清理由 impl 內自行處理
            renderPolylineSetsImpl(vo.map, vo.polylineSets, tracked, {
                shouldDeferClick: (pt, type) => vo.shouldDeferFeatureClick(pt, type),
                onPopupClick: (lngLat, fd, type, idx) => vo.showFeaturePopup(lngLat, fd, type, idx),
                onTooltipEnter: (lngLat, fd, type, idx) => vo.showFeatureTooltip(lngLat, fd, type, idx),
                onTooltipLeave: () => vo.hideFeatureTooltip(),
            })
            vo.trackedSourceIds = tracked.sourceIds; vo.trackedLayerIds = tracked.layerIds
            vo.scheduleRaiseFeatureLayers()
        },

        // -- 多邊形 --
        changePolygonSetsDebounce() {
            this.dbcChangePolygonSets(() => {
                this.changePolygonSets()
            })
        },
        changePolygonSets() {
            let vo = this
            vo.processSetsChange({
                optKey: 'polygonSets',
                tempKey: 'effPolygonSetsTemp',
                setsKey: 'polygonSets',
                defVisible: true,
                clickKey: 'polygonSetsClick',
                render: () => vo.renderPolygonSets(),
                normalize: (sets) => {
                    let fc = get(vo, 'opt.polygonSetsClick', null)
                    return map(sets, (pg, k) => {
                        if (!isestr(get(pg, 'title', null))) pg.title = ''; if (!isestr(get(pg, 'msg', null))) pg.msg = ''
                        if (!isNumber(get(pg, 'order', null))) pg.order = null; if (!isbol(get(pg, 'visible', null))) pg.visible = true
                        let lc = get(pg, 'lineColor', 'rgba(0,150,255,1)'); if (!isestr(lc)) lc = 'rgba(0,150,255,1)'
                        let lw = get(pg, 'lineWidth', 3); if (!isNumber(lw)) lw = 3
                        let fl = get(pg, 'fillColor', 'rgba(0,150,255,0.25)'); if (!isestr(fl)) fl = 'rgba(0,150,255,0.25)'
                        //註: polygonSets.latLngs 刻意不凍結——buildPolygonGeometry 回退之 w-gis 慢管線
                        //(fixCloseMultiPolygon)會就地 push 閉合點, 凍結會在該路徑丟 TypeError
                        return { id: `polygonSet-${k}`, ...pg, lineColor: lc, lineWidth: lw, fillColor: fl, funSetsClick: fc }
                    })
                },
            })
        },
        renderPolygonSets() {
            let vo = this
            let tracked = { sourceIds: vo.trackedSourceIds, layerIds: vo.trackedLayerIds }
            //聚合渲染(單 source + 2 層, id 無數字尾綴): 過期/無可見組之清理由 impl 內自行處理
            renderPolygonSetsImpl(vo.map, vo.polygonSets, tracked, {
                shouldDeferClick: (pt, type) => vo.shouldDeferFeatureClick(pt, type),
                onPopupClick: (lngLat, fd, type, idx) => vo.showFeaturePopup(lngLat, fd, type, idx),
                onTooltipEnter: (lngLat, fd, type, idx) => vo.showFeatureTooltip(lngLat, fd, type, idx),
                onTooltipLeave: () => vo.hideFeatureTooltip(),
            })
            vo.trackedSourceIds = tracked.sourceIds; vo.trackedLayerIds = tracked.layerIds
            vo.scheduleRaiseFeatureLayers()
        },

        // -- GeoJSON --
        changeGeojsonSetsDebounce() {
            this.dbcChangeGeojsonSets(() => {
                this.changeGeojsonSets()
            })
        },
        changeGeojsonSets() {
            let vo = this
            vo.processSetsChange({
                optKey: 'geojsonSets',
                tempKey: 'effGeojsonSetsTemp',
                setsKey: 'geojsonSets',
                defVisible: true,
                clickKey: 'geojsonSetsClick',
                render: () => vo.renderGeojsonSets(),
                normalize: (sets) => {
                    let fc = get(vo, 'opt.geojsonSetsClick', null)
                    return map(sets, (gj, k) => {
                        if (!isestr(get(gj, 'title', null))) gj.title = ''; if (!isestr(get(gj, 'msg', null))) gj.msg = ''
                        if (!isNumber(get(gj, 'order', null))) gj.order = null; if (!isbol(get(gj, 'visible', null))) gj.visible = true
                        let lc = get(gj, 'lineColor', 'rgba(0,150,255,1)'); if (!isestr(lc)) lc = 'rgba(0,150,255,1)'
                        let lw = get(gj, 'lineWidth', 3); if (!isNumber(lw)) lw = 3
                        let fl = get(gj, 'fillColor', 'rgba(0,150,255,0.25)'); if (!isestr(fl)) fl = 'rgba(0,150,255,0.25)'
                        return { id: `geojsonSet-${k}`, ...gj, lineColor: lc, lineWidth: lw, fillColor: fl, funSetsClick: fc }
                    })
                },
            })
        },
        renderGeojsonSets() {
            let vo = this
            let tracked = { sourceIds: vo.trackedSourceIds, layerIds: vo.trackedLayerIds }
            //聚合渲染(3 source + 4 層, id 無數字尾綴): 過期/無可見組之清理由 impl 內自行處理
            renderGeojsonSetsImpl(vo.map, vo.geojsonSets, tracked, {
                shouldDeferClick: (pt, type) => vo.shouldDeferFeatureClick(pt, type),
                onPopupClick: (lngLat, fd, type, idx) => vo.showFeaturePopup(lngLat, fd, type, idx),
                onTooltipEnter: (lngLat, fd, type, idx) => vo.showFeatureTooltip(lngLat, fd, type, idx),
                onTooltipLeave: () => vo.hideFeatureTooltip(),
            })
            vo.trackedSourceIds = tracked.sourceIds; vo.trackedLayerIds = tracked.layerIds
            vo.scheduleRaiseFeatureLayers()
        },

        // -- 影像 --
        changeImageSetsDebounce() {
            this.dbcChangeImageSets(() => {
                this.changeImageSets()
            })
        },
        changeImageSets() {
            let vo = this
            vo.processSetsChange({
                optKey: 'imageSets',
                tempKey: 'effImageSetsTemp',
                setsKey: 'imageSets',
                defVisible: false,
                render: () => vo.renderImageSets(),
                normalize: (sets) => {
                    let seenKid = {}
                    return map(sets, (im, k) => {
                        //穩定識別碼 kid: 使用者給 key 則位移時身分不變(未變更組零觸碰, 不重載圖片); 未給退回索引
                        let kid = isestr(get(im, 'key', null)) ? `k-${im.key}` : String(k)
                        if (seenKid[kid]) console.warn(`[WMaplibreglVue] imageSets 有重複 key「${kid}」: 各組將共用同一組圖層 id 互相覆蓋, 請保持 key 於陣列內唯一`)
                        seenKid[kid] = true
                        if (!isestr(get(im, 'title', null))) im.title = ''; if (!isestr(get(im, 'msg', null))) im.msg = ''
                        if (!isNumber(get(im, 'order', null))) im.order = null; if (!isbol(get(im, 'visible', null))) im.visible = false
                        let op = get(im, 'opacity', null); if (!isNumber(op)) op = 1; if (op < 0) op = 0; if (op > 1) op = 1 //夾限至 0~1, 免無效值直入 paint
                        return { id: `imageSet-${kid}`, ...im, kid, opacity: op }
                    })
                },
            })
        },
        renderImageSets() {
            let vo = this
            let tracked = { sourceIds: vo.trackedSourceIds, layerIds: vo.trackedLayerIds }
            //kid 識別 + 逐組差異跳過: 過期/隱藏組之清理由 impl 內處理
            renderImageSetsImpl(vo.map, vo.imageSets, tracked, {
                getDisplayOrderByType: () => vo.displayOrderByType,
            })
            vo.trackedSourceIds = tracked.sourceIds; vo.trackedLayerIds = tracked.layerIds
            vo.scheduleRaiseFeatureLayers()
        },

        // -- 等值線 --
        changeContourSetsDebounce() {
            this.dbcChangeContourSets(() => {
                this.changeContourSets()
            })
        },
        changeContourSets() {
            let vo = this
            vo.processSetsChange({
                optKey: 'contourSets',
                tempKey: 'effContourSetsTemp',
                setsKey: 'contourSets',
                defVisible: false,
                clickKey: 'contourSetsClick',
                render: () => vo.renderContourSets(),
                normalize: (sets) => vo.normalizeContourSets(sets),
            })
        },
        normalizeContourSets(contourSets) {
            let vo = this
            let funSetsClick = get(vo, 'opt.contourSetsClick', null)
            return map(contourSets, (contourSet, kcontourSet) => {
                let lineColor = get(contourSet, 'lineColor', null); if (!isestr(lineColor)) lineColor = ''
                let lineWidth = get(contourSet, 'lineWidth', null); if (!isNumber(lineWidth)) lineWidth = 1
                let fillOpacity = get(contourSet, 'fillOpacity', null); if (!isNumber(fillOpacity)) fillOpacity = 0.2
                let lineColorHover = get(contourSet, 'lineColorHover', null); if (!isestr(lineColorHover)) lineColorHover = lineColor
                let lineWidthHover = get(contourSet, 'lineWidthHover', null); if (!isNumber(lineWidthHover)) lineWidthHover = 3
                let fillOpacityHover = get(contourSet, 'fillOpacityHover', null); if (!isNumber(fillOpacityHover)) fillOpacityHover = 0.5
                let changeStyleWhenHover = get(contourSet, 'changeStyleWhenHover', null); if (!isbol(changeStyleWhenHover)) changeStyleWhenHover = true
                let legendNumDig = get(contourSet, 'legendNumDig', null); if (!isNumber(legendNumDig)) legendNumDig = null
                let legendTextFormater = get(contourSet, 'legendTextFormater', null); if (!isfun(legendTextFormater)) legendTextFormater = null
                let legendTextExtra = get(contourSet, 'legendTextExtra', null); if (!isfun(legendTextExtra)) legendTextExtra = null
                if (!isestr(get(contourSet, 'title', null))) contourSet.title = ''
                if (!isestr(get(contourSet, 'msg', null))) contourSet.msg = ''
                if (!isNumber(get(contourSet, 'order', null))) contourSet.order = null
                if (!isbol(get(contourSet, 'visible', null))) contourSet.visible = false
                return {
                    id: `contourSet-${kcontourSet}`,
                    ...contourSet,
                    lineColor,
                    lineWidth,
                    fillOpacity,
                    lineColorHover,
                    lineWidthHover,
                    fillOpacityHover,
                    changeStyleWhenHover,
                    legendNumDig,
                    legendTextFormater,
                    legendTextExtra,
                    legend: [],
                    funSetsClick,
                }
            })
        },
        renderContourSets() {
            let vo = this
            let tracked = { sourceIds: vo.trackedSourceIds, layerIds: vo.trackedLayerIds }
            //聚合渲染(單 source + 2 層, id 無數字尾綴): 過期/無可見組之清理由 impl 內自行處理
            renderContourSetsImpl(vo.map, vo.contourSets, tracked, {
                shouldDeferClick: (pt, type) => vo.shouldDeferFeatureClick(pt, type),
                onContourClick: (e, ps, kps, cs, kcs, contourSets) => {
                    let msg = {
                        ev: e,
                        lat: e.lngLat.lat,
                        lng: e.lngLat.lng,
                        latLngs: ps.latLngs,
                        polygonSet: ps,
                        kpolygonSet: kps,
                        contourSet: cs,
                        kcontourSet: kcs,
                        contourSets,
                    }
                    if (isfun(cs.funSetsClick)) cs.funSetsClick(msg)
                    vo.activeContourSet = cs
                    vo.$nextTick(() => {
                        let el = vo.$refs.refContourPopupInner; if (!el || !el.innerHTML.trim()) return
                        vo.closeAllPopupsIfOnlyone()
                        vo.featurePopup = createDirectionalPopup(vo.map, e.lngLat, el, vo.popupPosition, 5, { maxWidth: 'none' })
                        vo.featurePopupOwner = `contourSets.${kcs}`
                    })
                },
                onContourEnter: (e, cs, kcs) => {
                    vo.activeContourSetTooltip = cs
                    vo.$nextTick(() => {
                        let el = vo.$refs.refContourTooltip; if (!el) return
                        let html = el.innerHTML.trim(); if (!html) return
                        vo.hideFeatureTooltip()
                        let container = document.createElement('div'); container.innerHTML = html
                        vo.featureTooltip = createDirectionalPopup(vo.map, e.lngLat, container, vo.tooltipPosition, 10, { closeButton: false, closeOnClick: false, maxWidth: 'none', className: 'wlv2-tooltip' })
                        vo.featureTooltipOwner = `contourSets.${kcs}`
                    })
                },
                onContourLeave: () => {
                    vo.hideFeatureTooltip()
                },
            })
            vo.trackedSourceIds = tracked.sourceIds; vo.trackedLayerIds = tracked.layerIds
            vo.scheduleRaiseFeatureLayers()
        },

        // ===== 圖層顯隱 =====

        countVisible(arr) {
            return countVisible(arr)
        },

        changeItemsDebounce() {
            this.dbcChangeItems(() => {
                this.changeItems()
            })
        },
        changeItems() {
            this.items = buildItemsList(this.imageSets, this.pointSets, this.polylineSets, this.polygonSets, this.geojsonSets, this.contourSets)
        },

        //同步 opt 端 visible 到已正規化資料並回傳是否有變更。
        //各 changeXxxSets 的結構 diff 刻意 omit visible(避免面板寫回時雙重渲染),
        //外部「直接改 opt.xxxSets[k].visible」會走到 eff 相等的早退路徑, 故以此另行同步使其生效
        syncSetsVisible(normSets, rawSets, defVisible) {
            let changed = false
            each(normSets, (v, k) => {
                let b = get(rawSets, `${k}.visible`, null); if (!isbol(b)) b = defVisible
                if (v.visible !== b) {
                    v.visible = b; changed = true
                }
            })
            return changed
        },

        //將顯隱寫回 opt: 目標欄位可能原不存在(使用者未宣告 visible), 直接賦值不具響應性,
        //父層對 opt.xxxSets[i].visible 的 watcher/template 會收不到通知, 故用 $set(與 syncBaseMapsToOpt 同慣例)
        setOptVisibleReactive(updatePath, b) {
            let vo = this
            let owner = get(vo.opt, updatePath.replace(/\.visible$/, ''), null)
            if (isobj(owner)) vo.$set(owner, 'visible', b)
        },
        //僅重繪指定型別: 顯隱切換不需整批重跑六型別 renderer(重型別如 contour 之 calcContours
        //不應被無關切換觸發); 與「外部直改 opt.xxxSets[k].visible」路徑僅 render 自身型別的行為一致
        renderSetsByType(t) {
            let vo = this
            let m = {
                imageSets: () => vo.renderImageSets(),
                pointSets: () => vo.renderPointSets(),
                polylineSets: () => vo.renderPolylineSets(),
                polygonSets: () => vo.renderPolygonSets(),
                geojsonSets: () => vo.renderGeojsonSets(),
                contourSets: () => vo.renderContourSets(),
            }
            if (isfun(m[t])) m[t]()
        },

        toggleItemVisible(i) {
            let vo = this; let item = vo.items[i]; item.visible = !item.visible
            set(vo, item.updatePath, item.visible); vo.setOptVisibleReactive(item.updatePath, item.visible)
            if (!item.visible) {
                let setKey = item.updatePath.replace('.visible', '')
                vo.closePopupByOwner(setKey)
            }
            if (vo.map && vo.mapLoaded) vo.renderSetsByType(item.updatePath.split('.')[0])
        },

        // ===== Popup / Tooltip =====

        recheckPopupDirections() {
            let vo = this; if (!vo.map) return
            if (vo._popupResizing) return //拖曳 resize 中暫停翻轉, 避免重建摧毀拖曳 handler
            let fp = recheckSinglePopupDir(vo.map, vo.featurePopup); if (fp !== vo.featurePopup) vo.featurePopup = fp
            let ft = recheckSinglePopupDir(vo.map, vo.featureTooltip); if (ft !== vo.featureTooltip) vo.featureTooltip = ft
        },

        //resize 拖曳開始/結束: 暫停/恢復翻轉; 結束後補正一次方向
        onPopupResizingChange(v) {
            let vo = this
            vo._popupResizing = v
            if (!v && vo.map) vo.recheckPopupDirections()
        },

        showFeaturePopup(lngLat, featureData, type, setIndex) {
            let vo = this
            if (type === 'polyline') vo.activePolylineSet = featureData
            else if (type === 'polygon') vo.activePolygonSet = featureData
            else if (type === 'geojson') vo.activeGeojsonSet = featureData
            vo.$nextTick(() => {
                let refName = type === 'polyline' ? 'refPolylinePopupInner' : type === 'polygon' ? 'refPolygonPopupInner' : 'refGeojsonPopupInner'
                let el = vo.$refs[refName]; if (!el || !el.innerHTML.trim()) return
                vo.closeAllPopupsIfOnlyone()
                vo.featurePopup = createDirectionalPopup(vo.map, lngLat, el, vo.popupPosition, 5, { maxWidth: 'none' })
                vo.featurePopupOwner = `${type}Sets.${setIndex}`
            })
        },

        showFeatureTooltip(lngLat, featureData, type, setIndex) {
            let vo = this
            if (type === 'polyline') vo.activePolylineSetTooltip = featureData
            else if (type === 'polygon') vo.activePolygonSetTooltip = featureData
            else if (type === 'geojson') vo.activeGeojsonSetTooltip = featureData
            vo.$nextTick(() => {
                let refName = type === 'polyline' ? 'refPolylineTooltip' : type === 'polygon' ? 'refPolygonTooltip' : 'refGeojsonTooltip'
                let el = vo.$refs[refName]; if (!el) return
                let html = el.innerHTML.trim(); if (!html) return
                vo.hideFeatureTooltip()
                let container = document.createElement('div'); container.innerHTML = html
                vo.featureTooltip = createDirectionalPopup(vo.map, lngLat, container, vo.tooltipPosition, 10, { closeButton: false, closeOnClick: false, maxWidth: 'none', className: 'wlv2-tooltip' })
                vo.featureTooltipOwner = `${type}Sets.${setIndex}`
            })
        },

        hideFeatureTooltip() {
            if (this.featureTooltip) {
                this.featureTooltip.remove(); this.featureTooltip = null
            }
            this.featureTooltipOwner = ''
        },

        closePopup() {
            let vo = this
            if (vo.featurePopup) {
                vo.featurePopup.remove(); vo.featurePopup = null
            }
            vo.featurePopupOwner = ''
            vo.hideFeatureTooltip()
        },

        closePopupByOwner(setKey) {
            let vo = this
            //pointSets 之 owner 以 kid 記錄(索引因 keyed 重排會位移): 面板路徑傳入的「pointSets.<idx>」先轉譯為 kid 形式
            if (String(setKey).indexOf('pointSets.') === 0) {
                let idx = parseInt(String(setKey).slice('pointSets.'.length), 10)
                let kid = get(vo, ['pointSets', idx, 'kid'], null)
                if (isestr(kid)) setKey = `pointSets.k:${kid}`
            }
            if (vo.featurePopup && vo.featurePopupOwner === setKey) {
                vo.featurePopup.remove(); vo.featurePopup = null; vo.featurePopupOwner = ''
            }
            if (vo.featureTooltip && vo.featureTooltipOwner === setKey) {
                vo.featureTooltip.remove(); vo.featureTooltip = null; vo.featureTooltipOwner = ''
            }
        },

        closeAllPopupsIfOnlyone() {
            let vo = this
            // 任一時刻僅一個 featurePopup, 開新的前一律先關舊的(不受 displayPopupOnlyone 影響):
            // featurePopup 為單例變數, 若不關舊的就覆寫參考會造成舊 popup 殘留;
            // 且承載活 slot 元素時, 開新 popup 會把活元素自舊 popup 搬走 → 舊 popup 變空
            if (vo.featurePopup) {
                vo.featurePopup.remove(); vo.featurePopup = null
            }
            vo.featurePopupOwner = ''
        },

        // ===== 公開方法 =====

        getMapObject() {
            return _getMapObject(this.map)
        },

        panTo(latLng, opt = {}) {
            let vo = this
            let latLngNew = calcPanToCenter(vo.map, latLng, opt); if (!latLngNew) return
            vo.map.panTo([latLngNew[1], latLngNew[0]])
        },
        centerTo(ll) {
            this.panTo(ll)
        },

        popupPoint(obj) {
            let vo = this
            let point = get(obj, 'point', null); if (!isobj(point)) return null
            let ll = get(point, 'latLng', null); if (!isarr(ll)) return null
            let targetId = get(point, 'id', '')
            let { foundPt, foundPs, foundPsIndex } = findPointById(vo.pointSets, targetId)
            let ptData = foundPt || point; let psData = foundPs || {}
            let ptype = get(ptData, 'type', null) || get(psData, 'type', 'circle')
            vo.activePoint = ptData; vo.activePointSet = psData
            vo.$nextTick(() => {
                let refInner = vo.$refs.refPointPopupInner
                let hasLive = !!(refInner && refInner.innerHTML.trim())
                vo.closeAllPopupsIfOnlyone()
                let content
                if (hasLive) {
                    content = refInner // 有 slot: 用活元素(可互動)
                }
                else {
                    content = document.createElement('div') // 無 slot: 用靜態 fallback
                    content.innerHTML = `<div style="padding:8px"><b>${ptData.title || ''}</b><br/>${ptData.msg || ''}</div>`
                }
                let popGap = ptype === 'circle' ? (ptData.radius || get(psData, 'size', 10)) : 5
                vo.featurePopup = createDirectionalPopup(vo.map, [ll[1], ll[0]], content, vo.popupPosition, popGap, { maxWidth: 'none' })
                //owner 以組身分(kid)記錄(同 onPointClick): 索引因 keyed 重排會位移
                vo.featurePopupOwner = foundPsIndex >= 0 ? `pointSets.k:${get(vo, ['pointSets', foundPsIndex, 'kid'], foundPsIndex)}` : ''
            })
            return ll
        },

        popupFeatureById(id) {
            let vo = this
            let resolved = resolveFeatureById(id, vo.polylineSets, vo.polygonSets); if (!resolved) return null
            let { center, featureData, ownerPath, type } = resolved
            if (type === 'polyline') vo.activePolylineSet = featureData
            else if (type === 'polygon') vo.activePolygonSet = featureData
            vo.$nextTick(() => {
                let refName = type === 'polyline' ? 'refPolylinePopupInner' : 'refPolygonPopupInner'
                let el = vo.$refs[refName]
                let hasLive = !!(el && el.innerHTML.trim())
                vo.closeAllPopupsIfOnlyone()
                let content
                if (hasLive) {
                    content = el // 有 slot: 用活元素(可互動)
                }
                else {
                    content = document.createElement('div') // 無 slot: 用靜態 fallback
                    content.innerHTML = `<div style="padding:8px"><b>${featureData.title || ''}</b><br/>${featureData.msg || ''}</div>`
                }
                vo.featurePopup = createDirectionalPopup(vo.map, [center[1], center[0]], content, vo.popupPosition, 5, { maxWidth: 'none' })
                vo.featurePopupOwner = ownerPath
            })
            return center
        },

        modifyItemsVisible(fun) {
            let vo = this; if (!isfun(fun)) return
            let hiddenKeys = []
            let changedTypes = {} //僅重繪實際有顯隱變更的型別
            each(vo.items, (v, k) => {
                let b = fun(v, k)
                if (!b && v.visible) hiddenKeys.push(v.updatePath.replace('.visible', ''))
                if (v.visible !== b) changedTypes[v.updatePath.split('.')[0]] = true
                v.visible = b; set(vo, v.updatePath, b); vo.setOptVisibleReactive(v.updatePath, b)
            })
            each(hiddenKeys, (setKey) => {
                vo.closePopupByOwner(setKey)
            })
            if (vo.map && vo.mapLoaded) each(Object.keys(changedTypes), (t) => vo.renderSetsByType(t))
        },
    },
}
</script>

<style scoped>
/* 四角容器:flex column 自動堆疊,不重疊 */
.clsCorner { position:absolute; z-index:10; display:flex; flex-direction:column; gap:8px; pointer-events:none; }
.clsCorner > * { pointer-events:auto; }
.clsCorner-topleft { top:10px; left:10px; align-items:flex-start; }
.clsCorner-topright { top:10px; right:10px; align-items:flex-end; }
.clsCorner-bottomleft { bottom:10px; left:10px; align-items:flex-start; }
.clsCorner-bottomright { bottom:10px; right:10px; align-items:flex-end; }

.clsPanel { display:flex; flex-direction:column; padding:3px 8px; box-shadow:0 0 15px rgba(0,0,0,0.2); border-radius:5px; font-size:0.8rem; font-family:'Microsoft JhengHei','Avenir','Helvetica'; }

/* 自訂縮放按鈕 */
.clsZoomBtn { width:30px; height:30px; border:none; background:rgba(255,255,255,0.95); cursor:pointer; font-size:1.2rem; font-weight:bold; display:flex; align-items:center; justify-content:center; }
.clsZoomBtn:hover { background:rgba(230,230,230,1); }

/* 比例尺 */
.clsScale { padding:2px 6px; font-size:0.7rem; border-radius:3px; }

::v-deep .maplibregl-popup-content { padding:0; border-radius:5px; }
/* 放大 popup 關閉按鈕(maplibre 預設過小不好點) */
::v-deep .maplibregl-popup-close-button {
    width:26px; height:26px; padding:0;
    font-size:22px; line-height:24px;
    border-radius:0 5px 0 5px;
}
::v-deep .maplibregl-popup-close-button:hover { background-color:rgba(0,0,0,0.08); }
/* tooltip 樣式:移除關閉按鈕 padding 並加上陰影,pointer-events:none 避免遮擋 canvas 事件造成閃爍 */
::v-deep .wlv2-tooltip { pointer-events: none; }
::v-deep .wlv2-tooltip .maplibregl-popup-content { padding:0; border-radius:5px; box-shadow:0 2px 8px rgba(0,0,0,0.25); }
/* 隱藏 maplibre 預設 control 容器 */
::v-deep .maplibregl-ctrl-top-left,
::v-deep .maplibregl-ctrl-top-right,
::v-deep .maplibregl-ctrl-bottom-left,
::v-deep .maplibregl-ctrl-bottom-right { display:none; }
</style>