js/layerRenderers.mjs

/**
 * layerRenderers.mjs
 * 六種圖層(點/線/面/GeoJSON/等值線/影像)的增量渲染邏輯
 *
 * 每個 render* 函式接受:
 *   map        - MapLibre 地圖實例
 *   dataSets   - 已正規化的資料陣列
 *   tracked    - { sourceIds: [], layerIds: [] }(函式內部會修改此物件的屬性)
 *   callbacks  - 事件回調物件(由 Vue 元件提供,內含 $nextTick / $refs 等閉包)
 *
 * tracked 物件在函式執行後其屬性可能被重新賦值,呼叫端需同步回 Vue data。
 */
import each from 'lodash-es/each.js'
import get from 'lodash-es/get.js'
import lmap from 'lodash-es/map.js'
import isNumber from 'lodash-es/isNumber.js'
import isEqual from 'lodash-es/isEqual.js'
import size from 'lodash-es/size.js'
import reverse from 'lodash-es/reverse.js'
import isarr from 'wsemi/src/isarr.mjs'
import isearr from 'wsemi/src/isearr.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import isobj from 'wsemi/src/isobj.mjs'
import isfun from 'wsemi/src/isfun.mjs'
import cdbl from 'wsemi/src/cdbl.mjs'
import oc from 'wsemi/src/color.mjs'
import dig from 'wsemi/src/dig.mjs'
import calcContours from 'w-gis/src/calcContours.mjs'
import fixCloseMultiPolygon from 'w-gis/src/fixCloseMultiPolygon.mjs'
import flattenMultiPolygon from 'w-gis/src/flattenMultiPolygon.mjs'
import invCoordMultiPolygonOrMultiPolyline from 'w-gis/src/invCoordMultiPolygonOrMultiPolyline.mjs'
import splitAndProcGeoJSON from 'w-gis/src/splitAndProcGeoJSON.mjs'
import { offDelegatedListenersByLayer } from './layerVisibility.mjs'


// ===== 工具函式 =====

/**
 * 集中式圖層 hover 管理器
 *
 * maplibre 對「帶 layerId 的委派事件」之內部實作, 是每個 listener 於每次 mousemove 各自執行一次
 * queryRenderedFeatures 命中測試(單次約 5ms 基礎開銷); 互動圖層一多(如 22 層 × enter/leave = 44 個
 * 委派 listener)單次滑鼠移動即阻塞主執行緒 200ms+。本管理器全 map 僅掛一個 mousemove listener,
 * 每次滑鼠移動最多執行一次 queryRenderedFeatures(帶全部已註冊圖層), 依命中差集對各圖層分發
 * enter/leave, 語意與原委派事件相同(enter 事件帶 features, 由上而下排序)。並加兩道防護:
 *   - 拖曳中(originalEvent.buttons > 0)與地圖動畫中(map.isMoving())不做命中測試——拖曳體驗優先, hover 狀態暫凍結
 *   - 命中測試節流(leading+trailing, 間隔 100ms)——高頻 mousemove 合併為一次查詢
 *
 * 注意: 每個 layerId 僅支援一組 handler(重複註冊為覆蓋語意, 與原生 map.on 可疊加多 listener 不同)。
 * 選配第五參數 onMove: 該層「持續 hover 中」時分發(payload 同 enter, 含 features),
 * 供聚合層(單層多組 feature)偵測跨組移動; 未傳者行為不變。
 */
const KEY_HOVER_MANAGER = '__wmlgvHoverManager'
export function onLayerHover(map, layerId, onEnter, onLeave, onMove) {
    let mgr = map[KEY_HOVER_MANAGER]
    if (!mgr) {
        mgr = { regs: {}, hovered: {} }
        map[KEY_HOVER_MANAGER] = mgr
        //對「先前 hover 中、本次未命中」之圖層分發 leave; hit 為 null 代表滑鼠離開地圖, 全部 leave
        let dispatchLeaves = (hit) => {
            each(Object.keys(mgr.hovered), (lid) => {
                if (hit && hit[lid]) return
                delete mgr.hovered[lid]
                let r = mgr.regs[lid]
                if (r && isfun(r.onLeave)) r.onLeave()
            })
        }
        //單次命中測試: 一次 queryRenderedFeatures 帶全部已註冊圖層, 依命中差集分發 enter/leave
        let runHitTest = () => {
            if (!map || !map.style) return //map 已被銷毀(節流 trailing timer 殘留之競態)
            let e = mgr.lastEv
            if (!e) return
            let lids = Object.keys(mgr.regs).filter((lid) => map.getLayer(lid))
            let fs = []
            if (lids.length > 0) {
                try {
                    fs = map.queryRenderedFeatures(e.point, { layers: lids }) || []
                }
                catch (err) {
                    fs = []
                }
            }
            let hit = {}
            each(fs, (f) => {
                let lid = f && f.layer && f.layer.id
                if (!lid) return
                if (!hit[lid]) hit[lid] = []
                hit[lid].push(f) //queryRenderedFeatures 由上而下排序, hit[lid][0] 即該層最上層 feature
            })
            dispatchLeaves(hit)
            each(Object.keys(hit), (lid) => {
                let r = mgr.regs[lid]
                if (mgr.hovered[lid]) {
                    //持續 hover 中: 分發選配 onMove(供聚合層以 features 偵測跨組移動)
                    if (r && isfun(r.onMove)) r.onMove({ point: e.point, lngLat: e.lngLat, originalEvent: e.originalEvent, features: hit[lid] })
                    return
                }
                mgr.hovered[lid] = true
                if (r && isfun(r.onEnter)) r.onEnter({ point: e.point, lngLat: e.lngLat, originalEvent: e.originalEvent, features: hit[lid] })
            })
        }
        //節流(leading+trailing): 命中測試最多每 HIT_TEST_INTERVAL 執行一次, 高頻 mousemove 合併為一次查詢
        const HIT_TEST_INTERVAL = 100
        map.on('mousemove', (e) => {
            //拖曳中(按鍵按住)或地圖動畫中不做 hover 命中測試, 拖曳體驗優先(hover 狀態暫凍結)
            if (e.originalEvent && e.originalEvent.buttons > 0) return
            if (isfun(map.isMoving) && map.isMoving()) return
            mgr.lastEv = { point: e.point, lngLat: e.lngLat, originalEvent: e.originalEvent }
            let now = performance.now()
            let since = now - (mgr.lastRun || 0)
            if (since >= HIT_TEST_INTERVAL) {
                mgr.lastRun = now
                runHitTest()
            }
            else if (!mgr.pending) {
                mgr.pending = true
                setTimeout(() => {
                    mgr.pending = false
                    mgr.lastRun = performance.now()
                    runHitTest()
                }, HIT_TEST_INTERVAL - since)
            }
        })
        map.on('mouseout', () => {
            mgr.lastEv = null //使節流中之待執行命中測試失效, 避免滑鼠已離開地圖仍誤發 enter
            dispatchLeaves(null)
        })
    }
    mgr.regs[layerId] = { onEnter, onLeave, onMove }
}


/**
 * 反註冊圖層之 hover handler(圖層移除時呼叫, 使註冊與圖層同壽命)。
 * 若移除當下該圖層為 hover 中, 先發 leave 還原 cursor/tooltip 再移除。
 */
export function offLayerHover(map, layerId) {
    let mgr = map[KEY_HOVER_MANAGER]
    if (!mgr) return
    if (mgr.hovered[layerId]) {
        delete mgr.hovered[layerId]
        let r = mgr.regs[layerId]
        if (r && isfun(r.onLeave)) r.onLeave()
    }
    delete mgr.regs[layerId]
}


/**
 * 各型別「最新一輪 render 的正規化資料」存放處(掛於 map 實例上)。
 * 事件 handler 於事件觸發時來此查最新資料, 而非使用建層當時閉包捕捉的物件——
 * 否則 runtime 變更資料/樣式後(source 已存在僅 setData), 回呼會帶到舊值。
 *
 * store 完整形狀(新增欄位請同步維護此清單):
 *   polylineSets / polygonSets / geojsonSets / contourSets - 各型別最新正規化陣列
 *   contourBands   - { [kcs]: 色帶陣列 } 等值線各組最新色帶(含顏色增補)
 *   pointRenderSeq - { [srcId]: Number } 點圖層每 source 的 render 序號(兼「已知組」名冊, 供過期清理)
 *   pointSetsPrev  - { [kid]: 正規化組 } 點圖層上一輪已套用資料(供逐組差異跳過)
 *   imageSetsPrev  - { [kid]: 正規化組 } 影像圖層上一輪已套用資料(供逐組差異跳過, 免重載圖片)
 *   polygonGeomPrev - { [kpg]: { lls, geometry } } 聚合多邊形各組上一輪 latLngs 與其幾何(供純樣式變更時重用幾何)
 *                     代價: 額外常駐一份 latLngs 參考與幾何結果, 大量座標場景記憶體約增一份幾何量
 *   polygonHoverK  - Number|null 聚合多邊形目前 hover 之組索引(供跨組移動偵測)
 *   polylineHoverK - Number|null 聚合折線目前 hover 之組索引(供跨組移動偵測)
 *   geojsonHoverK  - { [layerId]: Number } 聚合 GeoJSON 各互動層目前 hover 之組索引
 *   contourHoverKcs - Number|null 聚合等值線目前 hover 之組索引(tooltip 用)
 *   contourHoverIds - Array<Number> 聚合等值線目前 hover 之 feature id 集合(feature-state 樣式用)
 */
const KEY_SETS_STORE = '__wmlgvSetsStore'
function getSetsStore(map) {
    if (!map[KEY_SETS_STORE]) map[KEY_SETS_STORE] = {}
    return map[KEY_SETS_STORE]
}


/**
 * 點是否在環(ring)內部(Ray Casting 演算法)
 * @param {Array} pt - [lng, lat]
 * @param {Array} ring - [[lng, lat], ...]
 */
export function pointInRing(pt, ring) {
    let inside = false
    for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
        let xi = ring[i][0]; let yi = ring[i][1]
        let xj = ring[j][0]; let yj = ring[j][1]
        if (((yi > pt[1]) !== (yj > pt[1])) &&
            (pt[0] < (xj - xi) * (pt[1] - yi) / (yj - yi) + xi)) {
            inside = !inside
        }
    }
    return inside
}


/** 點 (rx,ry) 是否落在線段 (px,py)-(qx,qy) 上(含端點,需完全共線) */
function ptOnSeg(px, py, qx, qy, rx, ry) {
    if ((qx - px) * (ry - py) - (qy - py) * (rx - px) !== 0) return false
    return rx >= (px < qx ? px : qx) && rx <= (px > qx ? px : qx) &&
        ry >= (py < qy ? py : qy) && ry <= (py > qy ? py : qy)
}


/**
 * 一組(已閉合)環之間是否存在相交或接觸
 * 「相交」含嚴格穿越(8 字型環、部分重疊環)與端點接觸(重複頂點、頂點壓線之退化環),
 * 兩者 polybool 都會切分/重組,快速路徑不會,故一律視為不可走快速路徑。
 * 以 x 方向掃描線 + y 方向 bbox 預篩把配對數壓到近線性,避免大環的 O(n^2) 爆炸;
 * 段資料存平坦 TypedArray,免去每段一個物件的配置成本。
 * 同環之相鄰段與閉環首尾段本就共端點,不計。
 * @param {Array} rings - [[[x,y],...], ...] 各環首尾點相同
 * @returns {Boolean}
 */
function ringsHaveCrossing(rings) {

    let total = 0
    for (let r = 0; r < rings.length; r++) total += rings[r].length - 1

    let ax = new Float64Array(total); let ay = new Float64Array(total)
    let bx = new Float64Array(total); let by = new Float64Array(total)
    let x0 = new Float64Array(total); let x1 = new Float64Array(total)
    let y0 = new Float64Array(total); let y1 = new Float64Array(total)
    let rid = new Int32Array(total); let sid = new Int32Array(total); let rn = new Int32Array(total)
    let m = 0
    for (let r = 0; r < rings.length; r++) {
        let ring = rings[r]
        let n = ring.length - 1 //首尾重複, 段數為 n
        for (let i = 0; i < n; i++, m++) {
            let a = ring[i]; let b = ring[i + 1]
            ax[m] = a[0]; ay[m] = a[1]; bx[m] = b[0]; by[m] = b[1]
            x0[m] = a[0] < b[0] ? a[0] : b[0]; x1[m] = a[0] > b[0] ? a[0] : b[0]
            y0[m] = a[1] < b[1] ? a[1] : b[1]; y1[m] = a[1] > b[1] ? a[1] : b[1]
            rid[m] = r; sid[m] = i; rn[m] = n
        }
    }

    let ord = new Int32Array(total)
    for (let k = 0; k < total; k++) ord[k] = k
    ord.sort((p, q) => x0[p] - x0[q])

    let act = new Int32Array(total); let na = 0
    for (let k = 0; k < total; k++) {
        let s = ord[k]
        let sx0 = x0[s]; let sy0 = y0[s]; let sy1 = y1[s]
        let w = 0
        for (let t = 0; t < na; t++) { //汰除已掃過之段
            let o = act[t]
            if (x1[o] >= sx0) act[w++] = o
        }
        na = w
        let sax = ax[s]; let say = ay[s]; let sbx = bx[s]; let sby = by[s]
        for (let t = 0; t < na; t++) {
            let o = act[t]
            if (y1[o] < sy0 || y0[o] > sy1) continue
            if (rid[o] === rid[s]) {
                let d = sid[o] - sid[s]; if (d < 0) d = -d
                if (d === 1 || d === rn[s] - 1) continue //相鄰段/首尾段共端點
            }
            let oax = ax[o]; let oay = ay[o]; let obx = bx[o]; let oby = by[o]
            let d1 = (obx - oax) * (say - oay) - (oby - oay) * (sax - oax)
            let d2 = (obx - oax) * (sby - oay) - (oby - oay) * (sbx - oax)
            let d3 = (sbx - sax) * (oay - say) - (sby - say) * (oax - sax)
            let d4 = (sbx - sax) * (oby - say) - (sby - say) * (obx - sax)
            if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) return true
            if (d1 === 0 || d2 === 0 || d3 === 0 || d4 === 0) { //可能為端點接觸/共線, 逐點確認
                if (ptOnSeg(oax, oay, obx, oby, sax, say) || ptOnSeg(oax, oay, obx, oby, sbx, sby) ||
                    ptOnSeg(sax, say, sbx, sby, oax, oay) || ptOnSeg(sax, say, sbx, sby, obx, oby)) return true
            }
        }
        act[na++] = s
    }
    return false
}


/** 環之 bbox:[minX, minY, maxX, maxY] */
function ringBBox(ring) {
    let x0 = Infinity; let y0 = Infinity; let x1 = -Infinity; let y1 = -Infinity
    for (let i = 0; i < ring.length; i++) {
        let p = ring[i]
        if (p[0] < x0) x0 = p[0]; if (p[0] > x1) x1 = p[0]
        if (p[1] < y0) y0 = p[1]; if (p[1] > y1) y1 = p[1]
    }
    return [x0, y0, x1, y1]
}


/**
 * 將「單一 XOR 群」(一組環)轉為 GeoJSON polygon 陣列
 * 僅在該組環之 XOR 語意與 GeoJSON 環語意等價時才成立,不等價回傳 null。
 *
 * 等價條件(全數滿足):
 *   1. 各環為 >= 3 點之點陣列且座標為有限數值
 *   2. 環內無自相交、環間無相交或接觸(8 字型環、部分重疊環、重複頂點之自我接觸環,
 *      polybool 會切分/做對稱差,快速路徑不會)
 *   3. 多環時,二者擇一:
 *      a. 全離散: 各環 bbox 兩兩不重疊 → 互不重疊環之 XOR 對稱差=聯集,各環為獨立 polygon
 *         (行政區飛地/離島常態;bbox 互斥本身即排除包含與相交)
 *      b. 首環 + 互不重疊之洞: 第 2 環起之 bbox 互不重疊(避免島中島三層套疊被誤當成洞)
 *         且各自完全落在首環內
 *      混合情形(部分洞/部分離散)回退慢管線
 * @param {Array} src - 環陣列 [ring][pt[lat, lng]]
 * @returns {Array|null} polygon 陣列(各元素為環陣列,座標已轉為 [lng, lat])或 null
 */
function fastRingGroupToPolygons(src) {

    //閉環 + 座標反轉([lat,lng] -> [lng,lat]), 同時檢查座標數值合法
    let rings = []
    for (let r = 0; r < src.length; r++) {
        let ring = src[r]
        if (!isearr(ring) || ring.length < 3) return null
        let out = new Array(ring.length)
        for (let i = 0; i < ring.length; i++) {
            let p = ring[i]
            if (!isearr(p) || !isNumber(p[0]) || !isNumber(p[1]) || !isFinite(p[0]) || !isFinite(p[1])) return null
            out[i] = [p[1], p[0]]
        }
        let f = out[0]; let l = out[out.length - 1]
        if (f[0] !== l[0] || f[1] !== l[1]) out.push([f[0], f[1]])
        if (out.length < 4) return null //閉合後至少 4 點才構成有效環
        rings.push(out)
    }

    if (ringsHaveCrossing(rings)) return null

    if (rings.length === 1) return [rings]

    //多環: 「全離散」或「首環 + 互不重疊之洞」
    let bbs = rings.map(ringBBox)

    //全離散: 各環(含首環) bbox 兩兩不重疊 → 每環自成一個 polygon
    let allDisjoint = true
    for (let i = 0; i < rings.length && allDisjoint; i++) {
        let bi = bbs[i]
        for (let j = i + 1; j < rings.length; j++) {
            let bj = bbs[j]
            if (bi[0] <= bj[2] && bj[0] <= bi[2] && bi[1] <= bj[3] && bj[1] <= bi[3]) {
                allDisjoint = false
                break
            }
        }
    }
    if (allDisjoint) return rings.map((r) => [r])

    //首環 + 互不重疊之洞
    let b0 = bbs[0]
    for (let i = 1; i < rings.length; i++) {
        let bi = bbs[i]
        if (bi[0] < b0[0] || bi[1] < b0[1] || bi[2] > b0[2] || bi[3] > b0[3]) return null
        if (!pointInRing(rings[i][0], rings[0])) return null
        for (let j = i + 1; j < rings.length; j++) {
            let bj = bbs[j]
            if (bi[0] <= bj[2] && bj[0] <= bi[2] && bi[1] <= bj[3] && bj[1] <= bi[3]) return null //bbox 重疊
        }
    }
    return [rings]
}


/**
 * buildPolygonGeometry 之快速路徑:單趟迴圈完成「閉環 + 座標反轉」,
 * 結構非預期或 XOR 語意不等價一律回傳 null 交回慢管線(polybool)處理。
 *
 * 支援之輸入深度(與慢管線 toMultiPolygon 之判讀一致):
 *   2 - [pt[lat,lng]]          單一 ringString
 *   3 - [ring][pt]             一個 polygon 之環陣列(XOR 套疊)
 *   4 - [polygon][ring][pt]    multiPolygon;各 polygon 之環各自 XOR 後串接,
 *                              故逐 polygon 獨立判定等價性(跨 polygon 之環互不影響)
 * @param {Array} lls - 原始 latLngs(座標為 [lat, lng])
 * @returns {Object|null} MultiPolygon geometry 或 null
 */
function tryBuildPolygonGeometryFast(lls) {

    if (!isearr(lls)) return null
    let h = lls[0]
    let groups
    if (isearr(h) && isNumber(h[0])) groups = [[lls]]
    else if (isearr(h) && isearr(h[0]) && isNumber(h[0][0])) groups = [lls]
    else if (isearr(h) && isearr(h[0]) && isearr(h[0][0]) && isNumber(h[0][0][0])) groups = lls
    else return null

    let coordinates = []
    for (let g = 0; g < groups.length; g++) {
        let grp = groups[g]
        if (!isearr(grp)) return null
        let pgs = fastRingGroupToPolygons(grp)
        if (!pgs) return null
        for (let i = 0; i < pgs.length; i++) coordinates.push(pgs[i])
    }
    if (coordinates.length === 0) return null

    return { type: 'MultiPolygon', coordinates }
}


/**
 * 將多個環座標(XOR 套疊結構)轉為 MapLibre GL 相容的 GeoJSON MultiPolygon
 * 先試等價之快速路徑(單趟閉環+反轉),不成立才走 w-gis 之 XOR 管線。
 */
export function buildPolygonGeometry(lls) {
    let fast = tryBuildPolygonGeometryFast(lls)
    if (fast) return fast
    let pgs = fixCloseMultiPolygon(lls, { supposeType: 'ringStrings' })
    pgs = flattenMultiPolygon(pgs)
    pgs = invCoordMultiPolygonOrMultiPolyline(pgs)
    return { type: 'MultiPolygon', coordinates: pgs }
}


/**
 * 計算等值線色彩(基於 gradient 內插)
 */
export function getContourColor(k, n, gradient, funGetColor, polygonSet) {
    let fun = oc.interp(gradient || {
        0: 'rgb(255, 255, 255)',
        0.2: 'rgb(254, 178, 76)',
        0.4: 'rgb(252, 78, 42)',
        0.6: 'rgb(220, 58, 38)',
        0.8: 'rgb(200, 40, 23)',
        1: 'rgb(180, 30, 60)',
    })
    let _c = n > 0 ? fun(k / n) : fun(1)
    let c = ''
    if (isfun(funGetColor)) c = funGetColor({ defaultColor: _c, k, n, polygonSet })
    if (!isestr(c)) c = _c
    return c
}


/**
 * 等值線圖例生成
 * @param {{ polygonSets: Array }} data
 * @param {Object} contourSet - 含 legendNumDig, legendTextFormater, legendTextExtra
 * @returns {Array} legend(已 reverse,由大到小)
 */
export function buildContourLegend(data, contourSet) {
    let legends = lmap(data.polygonSets, 'range')
    function getText(k, range) {
        let low = range.low; let up = range.up; let delimiter = '-'
        if (isNumber(contourSet.legendNumDig)) {
            low = dig(range.low, contourSet.legendNumDig)
            up = dig(range.up, contourSet.legendNumDig)
        }
        if (isfun(contourSet.legendTextFormater)) {
            let r = contourSet.legendTextFormater({ low, up, legends, index: k })
            low = get(r, 'low'); up = get(r, 'up'); delimiter = get(r, 'delimiter', delimiter)
        }
        return { low, up, delimiter }
    }
    let legend = lmap(data.polygonSets, (v, k) => {
        let textExt = ''
        if (isfun(contourSet.legendTextExtra)) textExt = contourSet.legendTextExtra({ k, n: size(data.polygonSets), polygonSet: v })
        return { ...getText(k, v.range), textExt, color: v.color, arrow: false, index: k }
    })
    return reverse(legend)
}


// ===== 渲染函式 =====

//單一 pointSet 之 4 個 layer id(以 kid 識別)
function pointSetLayerIds(kid) {
    return [`point-${kid}-circle`, `point-${kid}-symbol`, `point-${kid}-cluster-circle`, `point-${kid}-cluster-count`]
}


//移除單一 pointSet(以 kid 識別)之全部 layer/source 與事件註冊, 並同步 tracked 與 store
function removePointSetByKid(map, kid, tracked, store) {
    let srcId = `point-${kid}-src`
    let lids = pointSetLayerIds(kid)
    offDelegatedListenersByLayer(map, (l) => lids.indexOf(l) >= 0) //清委派 click listeners(與圖層同壽命)
    each(lids, (lid) => {
        offLayerHover(map, lid)
        if (map.getLayer(lid)) map.removeLayer(lid)
    })
    if (map.getSource(srcId)) map.removeSource(srcId)
    tracked.layerIds = tracked.layerIds.filter((x) => lids.indexOf(x) < 0)
    tracked.sourceIds = tracked.sourceIds.filter((x) => x !== srcId)
    if (store.pointSetsPrev) delete store.pointSetsPrev[kid]
    if (store.pointRenderSeq) delete store.pointRenderSeq[srcId] //pending doRender 查無序號即作廢
}


/**
 * 渲染點圖層(circle + symbol,含叢集化;kid 識別 + 逐組差異跳過)
 * @param {Object} map
 * @param {Array} pointSets - 已正規化的 pointSets(各組含 kid)
 * @param {Object} clusterOpts - computeClusterOpts() 的回傳值
 * @param {Object} tracked - { sourceIds, layerIds }
 * @param {Object} callbacks - {
 *   registerIcon(key, src, w, h): Promise,
 *   onPointClick(ptData, psData, p, coords): void,
 *   onPointEnter(ptData, psData, p, coords): void,
 *   onPointLeave(): void,
 *   getPointData(kid, kpt): ptData,
 *   getPointSetData(kid): psData,
 *   getDisplayOrderByType(): Boolean,
 * }
 * @param {Object} iconDefault - { src, size, key }
 */
export function renderPointSets(map, pointSets, clusterOpts, tracked, callbacks, iconDefault) {
    if (!map) return
    let store = getSetsStore(map)
    if (!store.pointRenderSeq) store.pointRenderSeq = {}
    if (!store.pointSetsPrev) store.pointSetsPrev = {}

    //kid 式過期清理: 曾渲染(或渲染中)而本輪已不在的組, 連同 layer/source/事件註冊一併移除
    //(pointRenderSeq 的 key 為 point-<kid>-src, 即「已知組」名冊)
    let kidsNow = {}
    each(pointSets, (ps, kps) => {
        kidsNow[get(ps, 'kid', null) || String(kps)] = true
    })
    each(Object.keys(store.pointRenderSeq), (sid) => {
        let kid = sid.slice('point-'.length, -('-src'.length))
        if (!kidsNow[kid]) removePointSetByKid(map, kid, tracked, store)
    })

    let setRenderPromises = []
    each(pointSets, (ps, kps) => {
        let kid = get(ps, 'kid', null) || String(kps) //穩定識別碼: 未給 key 時退回索引(與現行行為一致)
        let srcId = `point-${kid}-src`
        let circleId = `point-${kid}-circle`
        let symbolId = `point-${kid}-symbol`
        let clusterCircleId = `point-${kid}-cluster-circle`
        let clusterCountId = `point-${kid}-cluster-count`

        //每 source 一個 render 序號: icon 非同步載入期間若資料再變更(或該組轉為隱藏/移除),
        //舊輪 doRender 於載入完成後執行會以舊資料覆寫新資料, 故以序號作廢舊輪
        store.pointRenderSeq[srcId] = (store.pointRenderSeq[srcId] || 0) + 1
        let renderSeq = store.pointRenderSeq[srcId]

        if (!ps.visible) {
            removePointSetByKid(map, kid, tracked, store)
            return
        }

        //逐組差異跳過(核心): 內容未變且 source 存在之組零觸碰(不 setData, 不動 icon), 僅參與最後的堆疊回歸
        if (store.pointSetsPrev[kid] && map.getSource(srcId) && isEqual(store.pointSetsPrev[kid], ps)) return

        let psType = get(ps, 'type', 'circle')
        let psIconSrc = get(ps, 'iconSrc', '')
        let psIconSize = get(ps, 'iconSize', [25, 41])
        let psIconAnchor = get(ps, 'iconAnchor', null)
        let psIconKey = `point-icon-ps-${kid}`

        let features = []
        let iconLoadTasks = []
        let psIconRegistered = false
        let iconOffsets = {}

        each(ps.points, (pt, kpt) => {
            let ll = get(pt, 'latLng', null); if (!isarr(ll) || size(ll) < 2) return
            let ptype = get(pt, 'type', null) || psType
            //_kid 為主要識別(事件時查最新資料); _kps 僅作 kidToIndex 查無時的 fallback, 位移後即過期, 勿當主要索引使用
            let props = { _kid: kid, _kps: kps, _kpt: kpt, _ptype: ptype, _id: pt.id || '' }

            if (ptype === 'circle') {
                props._radius = pt.radius || get(ps, 'size', 10)
                props._fillColor = pt.fillColor || get(ps, 'fillColor', 'rgba(0,150,255,0.65)')
                props._lineColor = pt.lineColor || get(ps, 'lineColor', 'rgba(255,255,255,1)')
                props._lineWidth = pt.lineWidth || get(ps, 'lineWidth', 1)
            }
            else if (ptype === 'icon') {
                let ptIconSrc = get(pt, 'iconSrc', null) || psIconSrc
                let ptIconSize = get(pt, 'iconSize', null) || psIconSize
                let ptIconAnchor = get(pt, 'iconAnchor', null) || psIconAnchor

                let eff = isestr(ptIconSrc) && isarr(ptIconSize) && size(ptIconSize) === 2 && isarr(ptIconAnchor) && size(ptIconAnchor) === 2
                if (!eff) {
                    ptIconSrc = iconDefault.src; ptIconSize = iconDefault.size
                    ptIconAnchor = [ptIconSize[0] / 2, ptIconSize[1]]
                    //預設 icon 的 popup/tooltip 錨點記在 feature props(存數值 Y 偏移: properties 內陣列會被 maplibre 序列化成字串)。
                    //不可回寫 pt 物件——渲染期 mutate 輸入資料會使逐輪 isEqual 恆不相等, 破壞逐組差異跳過
                    if (!isarr(get(pt, 'popupAnchor', null)) && !isarr(get(ps, 'popupAnchor', null))) props._defPopupAnchorY = -ptIconSize[1]
                    if (!isarr(get(pt, 'tooltipAnchor', null)) && !isarr(get(ps, 'tooltipAnchor', null))) props._defTooltipAnchorY = -ptIconSize[1]
                }

                let ptIw = ptIconSize[0]; let ptIh = ptIconSize[1]
                let iconKey
                //icon 載入一律交 registerIcon(由其以內容指紋去重/更新), 不在此以 hasImage 判斷——
                //否則索引/內容變動時同 key 沿用舊圖造成 icon 錯置
                if (eff && isestr(get(pt, 'iconSrc', null))) {
                    iconKey = `point-icon-pt-${kid}-${kpt}`
                    iconLoadTasks.push(callbacks.registerIcon(iconKey, ptIconSrc, ptIw, ptIh))
                }
                else if (eff && isestr(psIconSrc)) {
                    if (!psIconRegistered) {
                        let w = isarr(psIconSize) ? psIconSize[0] : 25; let h = isarr(psIconSize) ? psIconSize[1] : 41
                        iconLoadTasks.push(callbacks.registerIcon(psIconKey, psIconSrc, w, h))
                        psIconRegistered = true
                    }
                    iconKey = psIconKey
                }
                else {
                    let defKey = iconDefault.key
                    iconLoadTasks.push(callbacks.registerIcon(defKey, ptIconSrc, ptIw, ptIh))
                    iconKey = defKey
                }
                props._iconKey = iconKey

                let centerX = ptIw / 2; let centerY = ptIh / 2
                iconOffsets[kpt] = [centerX - ptIconAnchor[0], centerY - ptIconAnchor[1]]
            }
            //feature id 交由 source 的 generateId 產生(generateId 開啟時顯式 id 會被忽略)
            features.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [ll[1], ll[0]] }, properties: props })
        })

        let geojsonData = { type: 'FeatureCollection', features }

        // 建構 icon-offset expression
        let iconOffsetExpr
        let offKeys = Object.keys(iconOffsets)
        if (offKeys.length === 0) {
            iconOffsetExpr = ['literal', [0, 0]]
        }
        else {
            let firstOff = iconOffsets[offKeys[0]]
            let allSame = offKeys.every((k) => iconOffsets[k][0] === firstOff[0] && iconOffsets[k][1] === firstOff[1])
            if (allSame) {
                iconOffsetExpr = ['literal', firstOff]
            }
            else {
                let matchArgs = ['match', ['get', '_kpt']]
                each(offKeys, (k) => {
                    matchArgs.push(parseInt(k, 10)); matchArgs.push(['literal', iconOffsets[k]])
                })
                matchArgs.push(['literal', firstOff])
                iconOffsetExpr = matchArgs
            }
        }

        let doRender = () => {
            if (!map) return
            if (store.pointRenderSeq[srcId] !== renderSeq) return //已有較新一輪 render(或該組已移除), 放棄本輪避免舊資料覆寫
            //實際套用才記錄(供下一輪逐組差異跳過)。存「參考」而非快照, 其正確性依賴兩條契約:
            //1) vo.pointSets 僅在結構 diff 變更時整批換新物件(舊物件即成快照); 2) visible 走獨立路徑不進 isEqual。
            //若未來有人就地 mutate 正規化資料, 跳過判斷會誤判——請維持整批替換的更新模式
            store.pointSetsPrev[kid] = ps
            if (map.getSource(srcId)) {
                map.getSource(srcId).setData(geojsonData)
                if (map.getLayer(symbolId)) {
                    map.setLayoutProperty(symbolId, 'icon-image', ['get', '_iconKey'])
                    map.setLayoutProperty(symbolId, 'icon-offset', iconOffsetExpr)
                }
            }
            else {
                let srcOpts = { type: 'geojson', data: geojsonData, generateId: true }
                if (clusterOpts.enabled) {
                    srcOpts.cluster = true
                    srcOpts.clusterRadius = clusterOpts.radius
                    srcOpts.clusterMaxZoom = clusterOpts.maxZoom
                }
                map.addSource(srcId, srcOpts)
                map.addLayer({
                    id: circleId,
                    type: 'circle',
                    source: srcId,
                    filter: ['==', ['get', '_ptype'], 'circle'],
                    paint: {
                        'circle-radius': ['get', '_radius'],
                        'circle-color': ['get', '_fillColor'],
                        'circle-stroke-color': ['get', '_lineColor'],
                        'circle-stroke-width': ['get', '_lineWidth'],
                    }
                })
                map.addLayer({
                    id: symbolId,
                    type: 'symbol',
                    source: srcId,
                    filter: ['==', ['get', '_ptype'], 'icon'],
                    layout: {
                        'icon-image': ['get', '_iconKey'],
                        'icon-size': 1,
                        'icon-anchor': 'center',
                        'icon-offset': iconOffsetExpr,
                        'icon-allow-overlap': true,
                        'icon-ignore-placement': true,
                    }
                })
                tracked.sourceIds.push(srcId)
                tracked.layerIds.push(circleId, symbolId)

                if (clusterOpts.enabled) {
                    let buildStepExpr = (vals) => {
                        if (clusterOpts.levelNum <= 1) return vals[0]
                        let expr = ['step', ['get', 'point_count'], vals[0]]
                        for (let li = 0; li < clusterOpts.levelValues.length; li++) {
                            expr.push(clusterOpts.levelValues[li], vals[li + 1])
                        }
                        return expr
                    }
                    map.addLayer({
                        id: clusterCircleId,
                        type: 'circle',
                        source: srcId,
                        filter: ['has', 'point_count'],
                        paint: {
                            'circle-color': buildStepExpr(clusterOpts.levelFillColors),
                            'circle-radius': buildStepExpr(clusterOpts.levelRadius),
                            'circle-stroke-width': buildStepExpr(clusterOpts.levelLineWidths),
                            'circle-stroke-color': buildStepExpr(clusterOpts.levelLineColors),
                        }
                    })
                    map.addLayer({
                        id: clusterCountId,
                        type: 'symbol',
                        source: srcId,
                        filter: ['has', 'point_count'],
                        layout: {
                            'text-field': '{point_count_abbreviated}',
                            'text-size': buildStepExpr(clusterOpts.levelTextSizes),
                        },
                        paint: { 'text-color': buildStepExpr(clusterOpts.levelTextColors) }
                    })
                    tracked.layerIds.push(clusterCircleId, clusterCountId)

                    map.on('click', clusterCircleId, (e) => {
                        let fs = map.queryRenderedFeatures(e.point, { layers: [clusterCircleId] })
                        if (!fs.length) return
                        let clusterId = fs[0].properties.cluster_id
                        let src = map.getSource(srcId); if (!src) return
                        let result = src.getClusterExpansionZoom(clusterId)
                        if (result && typeof result.then === 'function') {
                            result.then((zoom) => {
                                map.easeTo({ center: fs[0].geometry.coordinates, zoom })
                            }).catch(() => {})
                        }
                    })
                    onLayerHover(map, clusterCircleId, () => {
                        map.getCanvas().style.cursor = 'pointer'
                    }, () => {
                        map.getCanvas().style.cursor = ''
                    })
                }

                let handlePointEvent = (e, action) => {
                    if (!e.features || !e.features.length) return
                    let f = e.features[0]; let p = f.properties
                    let ptData = callbacks.getPointData(p._kid, p._kpt) //以 kid 查最新資料(索引位移後 _kps 會過期)
                    let psData = callbacks.getPointSetData(p._kid)
                    if (!ptData || !psData) return
                    let coords = f.geometry.coordinates
                    action(e, ptData, psData, p, coords)
                }

                map.on('click', circleId, (e) => handlePointEvent(e, (ev, ptData, psData, p, coords) => callbacks.onPointClick(ptData, psData, p, coords)))
                map.on('click', symbolId, (e) => handlePointEvent(e, (ev, ptData, psData, p, coords) => callbacks.onPointClick(ptData, psData, p, coords)))
                onLayerHover(map, circleId, (e) => {
                    map.getCanvas().style.cursor = 'pointer'
                    handlePointEvent(e, (ev, ptData, psData, p, coords) => callbacks.onPointEnter(ptData, psData, p, coords))
                }, () => {
                    map.getCanvas().style.cursor = ''; callbacks.onPointLeave()
                })
                onLayerHover(map, symbolId, (e) => {
                    map.getCanvas().style.cursor = 'pointer'
                    handlePointEvent(e, (ev, ptData, psData, p, coords) => callbacks.onPointEnter(ptData, psData, p, coords))
                }, () => {
                    map.getCanvas().style.cursor = ''; callbacks.onPointLeave()
                })
            }
        }

        if (iconLoadTasks.length > 0) {
            setRenderPromises.push(Promise.all(iconLoadTasks).then(doRender).catch(() => doRender()))
        }
        else {
            doRender()
        }
    })

    //堆疊順序回歸: 逐組差異跳過下, 重建組的圖層會落在最上層, 與「陣列後者在上」語意不符;
    //全部渲染完成後依資料順序逐組 moveLayer(僅調整繪製順序, 無資料重建, 不閃爍)。
    //僅 displayOrderByType 開啟時執行(關閉時維持「插入順序即堆疊」之既有語意, 不主動重排)
    let needReorder = isfun(callbacks.getDisplayOrderByType) ? callbacks.getDisplayOrderByType() : true
    if (needReorder) {
        Promise.all(setRenderPromises).then(() => {
            if (!map || !map.style) return
            each(pointSets, (ps, kps) => {
                let kid = get(ps, 'kid', null) || String(kps)
                each(pointSetLayerIds(kid), (lid) => {
                    if (map.getLayer(lid)) map.moveLayer(lid)
                })
            })
        }).catch(() => {})
    }
}


/**
 * 渲染折線圖層(增量更新)
 * @param {Object} map
 * @param {Array} polylineSets
 * @param {Object} tracked - { sourceIds, layerIds }
 * @param {Object} callbacks - {
 *   onPopupClick(lngLat, featureData, type, idx): void,
 *   onTooltipEnter(lngLat, featureData, type, idx): void,
 *   onTooltipLeave(): void,
 * }
 */
//(聚合渲染: 全部 polylineSets 併入單一 source + 1 個 line 圖層, 樣式 data-driven;
// feature 帶組索引 _kpl, click/hover 以其反查 store 最新資料, 跨組移動由 onMove 驅動 tooltip 切換)
const POLYLINE_AGG_SRC = 'polyline-agg-src'
const POLYLINE_AGG_LID = 'polyline-agg-line'
export function renderPolylineSets(map, polylineSets, tracked, callbacks) {
    if (!map) return
    let store = getSetsStore(map)
    store.polylineSets = polylineSets //供事件 handler 於事件時取最新資料

    //掃除舊版逐組命名殘留(升級中場景: 不留重複繪製)
    let isLegacyLayer = (id) => /^polyline-layer-\d+$/.test(id)
    let style = map.getStyle ? (map.getStyle() || {}) : {}
    let legacyLids = (style.layers || []).map((l) => l.id).filter(isLegacyLayer)
    if (legacyLids.length > 0) {
        offDelegatedListenersByLayer(map, isLegacyLayer)
        each(legacyLids, (id) => {
            offLayerHover(map, id)
            if (map.getLayer(id)) map.removeLayer(id)
        })
        each(Object.keys(style.sources || {}), (id) => {
            if (/^polyline-src-\d+$/.test(id) && map.getSource(id)) map.removeSource(id)
        })
        tracked.layerIds = tracked.layerIds.filter((x) => !isLegacyLayer(x))
        tracked.sourceIds = tracked.sourceIds.filter((x) => !/^polyline-src-\d+$/.test(x))
    }

    //聚合 features: 每組(visible)一個 Feature, 樣式放 properties(data-driven)
    let features = []
    each(polylineSets, (pls, k) => {
        if (!pls.visible) return
        let lls = get(pls, 'latLngs', []); if (!isearr(lls)) return
        let geomType, coords
        if (isarr(lls[0]) && isNumber(lls[0][0])) {
            geomType = 'LineString'; coords = lmap(lls, (ll) => [ll[1], ll[0]])
        }
        else {
            //lls[0][0][0]為數字才是線陣列層, 否則多包一層(depth-4)須鑽 lls[0]
            let ml = (isarr(lls[0]) && isarr(lls[0][0]) && isNumber(lls[0][0][0])) ? lls : lls[0]
            geomType = 'MultiLineString'; coords = lmap(ml, (seg) => lmap(seg, (ll) => [ll[1], ll[0]]))
        }
        features.push({ type: 'Feature', geometry: { type: geomType, coordinates: coords }, properties: { _kpl: k, __lineColor: pls.lineColor, __lineWidth: pls.lineWidth } })
    })

    if (features.length === 0) {
        //無可見組: 移除聚合層/來源與事件註冊(聚合 id 無數字尾綴, 索引式 removeStaleSetLayers 不會處理, 由此自清)
        offDelegatedListenersByLayer(map, (l) => l === POLYLINE_AGG_LID)
        offLayerHover(map, POLYLINE_AGG_LID)
        if (map.getLayer(POLYLINE_AGG_LID)) map.removeLayer(POLYLINE_AGG_LID)
        if (map.getSource(POLYLINE_AGG_SRC)) map.removeSource(POLYLINE_AGG_SRC)
        tracked.layerIds = tracked.layerIds.filter((x) => x !== POLYLINE_AGG_LID)
        tracked.sourceIds = tracked.sourceIds.filter((x) => x !== POLYLINE_AGG_SRC)
        store.polylineHoverK = null
        return
    }

    let fc = { type: 'FeatureCollection', features }
    if (map.getSource(POLYLINE_AGG_SRC)) {
        map.getSource(POLYLINE_AGG_SRC).setData(fc) //資料與樣式(內含於 properties)一次同步
        return
    }
    map.addSource(POLYLINE_AGG_SRC, { type: 'geojson', data: fc, generateId: true })
    //sort-key 依 _kpl: 同層內 feature 疊序與陣列序一致(後者在上, 交叉線段的上下關係對齊逐組時代圖層序)
    map.addLayer({
        id: POLYLINE_AGG_LID,
        type: 'line',
        source: POLYLINE_AGG_SRC,
        layout: { 'line-sort-key': ['get', '_kpl'] },
        paint: { 'line-color': ['get', '__lineColor'], 'line-width': ['get', '__lineWidth'] },
    })
    tracked.sourceIds.push(POLYLINE_AGG_SRC)
    tracked.layerIds.push(POLYLINE_AGG_LID)

    map.on('click', POLYLINE_AGG_LID, (e) => {
        if (callbacks.shouldDeferClick && callbacks.shouldDeferClick(e.point, 'polyline')) return //讓位給更高優先型別(如點)
        let k = get(e, 'features.0.properties._kpl', null); if (!isNumber(k)) return //features 由上而下排序, [0] 即最上組
        let plsNow = get(store, `polylineSets.${k}`, null); if (!plsNow) return //事件時取最新資料
        if (isfun(plsNow.funSetsClick)) plsNow.funSetsClick({ ev: e, polylineSet: plsNow, kpolylineSet: k, polylineSets: store.polylineSets })
        callbacks.onPopupClick(e.lngLat, plsNow, 'polyline', k)
    })
    //hover: 聚合層為單一圖層, 以 _kpl 追蹤目前 hover 組; 跨組移動由 onMove 分發,
    //組變更才重發 tooltip(同組內移動不重發, 與逐組時代行為一致)
    let hoverByK = (e) => {
        let k = get(e, 'features.0.properties._kpl', null); if (!isNumber(k)) return
        if (store.polylineHoverK === k) return
        store.polylineHoverK = k
        let plsNow = get(store, `polylineSets.${k}`, null); if (!plsNow) return
        map.getCanvas().style.cursor = 'pointer'
        callbacks.onTooltipEnter(e.lngLat, plsNow, 'polyline', k)
    }
    onLayerHover(map, POLYLINE_AGG_LID, hoverByK, () => {
        store.polylineHoverK = null
        map.getCanvas().style.cursor = ''; callbacks.onTooltipLeave()
    }, hoverByK)
}


/**
 * 渲染多邊形圖層(聚合渲染: 全部 polygonSets 併入單一 source + fill/line 各一層, 樣式 data-driven)。
 * maplibre 每圖層每幀有固定開銷, 逐組建層在大量組數時(如數百鄉鎮)層數爆量致拖曳卡頓;
 * 聚合後恆為 2 層, 樣式放 feature properties, 資料/樣式變更皆走單次 setData。
 * click/hover 以 feature 的 _kpg 反查 store 最新資料; 跨組移動由 onLayerHover 的 onMove 驅動 tooltip 切換。
 */
const POLYGON_AGG_SRC = 'polygon-agg-src'
const POLYGON_AGG_FILL = 'polygon-agg-fill'
const POLYGON_AGG_LINE = 'polygon-agg-line'
export function renderPolygonSets(map, polygonSets, tracked, callbacks) {
    if (!map) return
    let store = getSetsStore(map)
    store.polygonSets = polygonSets //供事件 handler 於事件時取最新資料

    //掃除舊版逐組命名殘留(升級中場景: 不留重複繪製)
    let isLegacyLayer = (id) => /^polygon-(fill|line)-\d+$/.test(id)
    let style = map.getStyle ? (map.getStyle() || {}) : {}
    let legacyLids = (style.layers || []).map((l) => l.id).filter(isLegacyLayer)
    if (legacyLids.length > 0) {
        offDelegatedListenersByLayer(map, isLegacyLayer)
        each(legacyLids, (id) => {
            offLayerHover(map, id)
            if (map.getLayer(id)) map.removeLayer(id)
        })
        each(Object.keys(style.sources || {}), (id) => {
            if (/^polygon-src-\d+$/.test(id) && map.getSource(id)) map.removeSource(id)
        })
        tracked.layerIds = tracked.layerIds.filter((x) => !isLegacyLayer(x))
        tracked.sourceIds = tracked.sourceIds.filter((x) => !/^polygon-src-\d+$/.test(x))
    }

    //聚合 features: 每組(visible)一個 Feature, 樣式放 properties(data-driven)
    //幾何快取: latLngs 內容未變之組重用前次幾何, 免純樣式變更(如調透明度/色階)重算全部幾何。
    //polygonSets 無穩定 key(身分即陣列索引), 故以索引為快取鍵; 命中一律經 isEqual 內容比對把關,
    //位移僅致未命中重算, 不致錯配他組幾何。因 opt 輸入經 cloneDeep, 參考必變, 無法以 === 判定。
    if (!store.polygonGeomPrev) store.polygonGeomPrev = {}
    let geomPrev = store.polygonGeomPrev
    let geomNow = {}
    let features = []
    each(polygonSets, (pg, k) => {
        if (!pg.visible) return
        let lls = get(pg, 'latLngs', []); if (!isearr(lls)) return
        let prev = geomPrev[k]
        let geometry = (prev && isEqual(prev.lls, lls)) ? prev.geometry : buildPolygonGeometry(lls)
        if (!geometry) return
        geomNow[k] = { lls, geometry }
        features.push({ type: 'Feature', geometry, properties: { _kpg: k, __fillColor: pg.fillColor, __lineColor: pg.lineColor, __lineWidth: pg.lineWidth } })
    })
    store.polygonGeomPrev = geomNow //以本輪出現之組重建快取表, 組消失或轉隱藏即釋放

    if (features.length === 0) {
        //無可見組: 移除聚合層/來源與事件註冊(聚合 id 無數字尾綴, 索引式 removeStaleSetLayers 不會處理, 由此自清)
        offDelegatedListenersByLayer(map, (l) => l === POLYGON_AGG_FILL)
        offLayerHover(map, POLYGON_AGG_FILL)
        if (map.getLayer(POLYGON_AGG_FILL)) map.removeLayer(POLYGON_AGG_FILL)
        if (map.getLayer(POLYGON_AGG_LINE)) map.removeLayer(POLYGON_AGG_LINE)
        if (map.getSource(POLYGON_AGG_SRC)) map.removeSource(POLYGON_AGG_SRC)
        tracked.layerIds = tracked.layerIds.filter((x) => x !== POLYGON_AGG_FILL && x !== POLYGON_AGG_LINE)
        tracked.sourceIds = tracked.sourceIds.filter((x) => x !== POLYGON_AGG_SRC)
        store.polygonHoverK = null
        return
    }

    let fc = { type: 'FeatureCollection', features }
    if (map.getSource(POLYGON_AGG_SRC)) {
        map.getSource(POLYGON_AGG_SRC).setData(fc) //資料與樣式(內含於 properties)一次同步
        return
    }
    map.addSource(POLYGON_AGG_SRC, { type: 'geojson', data: fc, generateId: true })
    //sort-key 依 _kpg: 同層內 feature 疊序與陣列序一致(後者在上), 對齊逐組時代的圖層序語意
    map.addLayer({
        id: POLYGON_AGG_FILL,
        type: 'fill',
        source: POLYGON_AGG_SRC,
        layout: { 'fill-sort-key': ['get', '_kpg'] },
        paint: { 'fill-color': ['get', '__fillColor'], 'fill-opacity': 1 },
    })
    map.addLayer({
        id: POLYGON_AGG_LINE,
        type: 'line',
        source: POLYGON_AGG_SRC,
        layout: { 'line-sort-key': ['get', '_kpg'] },
        paint: { 'line-color': ['get', '__lineColor'], 'line-width': ['get', '__lineWidth'] },
    })
    tracked.sourceIds.push(POLYGON_AGG_SRC)
    tracked.layerIds.push(POLYGON_AGG_FILL, POLYGON_AGG_LINE)

    map.on('click', POLYGON_AGG_FILL, (e) => {
        if (callbacks.shouldDeferClick && callbacks.shouldDeferClick(e.point, 'polygon')) return //讓位給更高優先型別(點/線/geojson)
        let k = get(e, 'features.0.properties._kpg', null); if (!isNumber(k)) return //features 由上而下排序, [0] 即最上組
        let pgNow = get(store, `polygonSets.${k}`, null); if (!pgNow) return //事件時取最新資料
        if (isfun(pgNow.funSetsClick)) pgNow.funSetsClick({ ev: e, polygonSet: pgNow, kpolygonSet: k, polygonSets: store.polygonSets })
        callbacks.onPopupClick(e.lngLat, pgNow, 'polygon', k)
    })
    //hover: 聚合層為單一圖層, 以 _kpg 追蹤目前 hover 組; 跨組移動由 onMove 分發,
    //組變更才重發 tooltip(同組內移動不重發, 與逐組時代行為一致)
    let hoverByK = (e) => {
        let k = get(e, 'features.0.properties._kpg', null); if (!isNumber(k)) return
        if (store.polygonHoverK === k) return
        store.polygonHoverK = k
        let pgNow = get(store, `polygonSets.${k}`, null); if (!pgNow) return
        map.getCanvas().style.cursor = 'pointer'
        callbacks.onTooltipEnter(e.lngLat, pgNow, 'polygon', k)
    }
    onLayerHover(map, POLYGON_AGG_FILL, hoverByK, () => {
        store.polygonHoverK = null
        map.getCanvas().style.cursor = ''; callbacks.onTooltipLeave()
    }, hoverByK)
}


/**
 * 渲染 GeoJSON 圖層(聚合渲染: 全部 geojsonSets 拆分後併入 3 個 source + 4 層, 樣式 data-driven)。
 * 每組 geojson 經 splitAndProcGeoJSON 拆為 points/lines/polygons, 各自併入全域聚合 source;
 * feature 帶組索引 _kgj, click/hover 以其反查 store 最新資料; 跨組移動由 onMove 驅動 tooltip 切換。
 */
const GEOJSON_AGG_PTS_SRC = 'geojson-agg-pts-src'
const GEOJSON_AGG_PTS_LID = 'geojson-agg-pts-circle'
const GEOJSON_AGG_LNS_SRC = 'geojson-agg-lns-src'
const GEOJSON_AGG_LNS_LID = 'geojson-agg-lns-line'
const GEOJSON_AGG_PGS_SRC = 'geojson-agg-pgs-src'
const GEOJSON_AGG_PGS_FID = 'geojson-agg-pgs-fill'
const GEOJSON_AGG_PGS_LID = 'geojson-agg-pgs-line'
export function renderGeojsonSets(map, geojsonSets, tracked, callbacks) {
    if (!map) return
    let store = getSetsStore(map)
    store.geojsonSets = geojsonSets //供事件 handler 於事件時取最新資料
    let aggSids = [GEOJSON_AGG_PTS_SRC, GEOJSON_AGG_LNS_SRC, GEOJSON_AGG_PGS_SRC]
    let aggLids = [GEOJSON_AGG_PTS_LID, GEOJSON_AGG_LNS_LID, GEOJSON_AGG_PGS_FID, GEOJSON_AGG_PGS_LID]
    let interactiveLids = [GEOJSON_AGG_PTS_LID, GEOJSON_AGG_LNS_LID, GEOJSON_AGG_PGS_FID]

    //掃除舊版逐組命名殘留(升級中場景: 不留重複繪製)
    let isLegacyLayer = (id) => /^geojson-(pts-circle|lns-line|pgs-fill|pgs-line)-\d+$/.test(id)
    let style = map.getStyle ? (map.getStyle() || {}) : {}
    let legacyLids = (style.layers || []).map((l) => l.id).filter(isLegacyLayer)
    if (legacyLids.length > 0) {
        offDelegatedListenersByLayer(map, isLegacyLayer)
        each(legacyLids, (id) => {
            offLayerHover(map, id)
            if (map.getLayer(id)) map.removeLayer(id)
        })
        each(Object.keys(style.sources || {}), (id) => {
            if (/^geojson-(pts|lns|pgs)-src-\d+$/.test(id) && map.getSource(id)) map.removeSource(id)
        })
        tracked.layerIds = tracked.layerIds.filter((x) => !isLegacyLayer(x))
        tracked.sourceIds = tracked.sourceIds.filter((x) => !/^geojson-(pts|lns|pgs)-src-\d+$/.test(x))
    }

    //聚合 features: 每組(visible)拆分後注入 _kgj 與 data-driven 樣式(保留原 feature properties)
    let ptsFeats = []; let lnsFeats = []; let pgsFeats = []
    each(geojsonSets, (gj, k) => {
        if (!gj.visible || !isobj(get(gj, 'geojson', null))) return
        let splitted = splitAndProcGeoJSON(gj.geojson)
        let addProps = (f) => ({ ...f, properties: { ...(f.properties || {}), _kgj: k, __fillColor: gj.fillColor, __lineColor: gj.lineColor, __lineWidth: gj.lineWidth } })
        each(get(splitted, 'points.features', []), (f) => ptsFeats.push(addProps(f)))
        each(get(splitted, 'lines.features', []), (f) => lnsFeats.push(addProps(f)))
        each(get(splitted, 'polygons.features', []), (f) => pgsFeats.push(addProps(f)))
    })

    if (ptsFeats.length + lnsFeats.length + pgsFeats.length === 0) {
        //無可見 feature: 移除聚合層/來源與事件註冊(聚合 id 無數字尾綴, 索引式 removeStaleSetLayers 不會處理, 由此自清)
        offDelegatedListenersByLayer(map, (l) => interactiveLids.indexOf(l) >= 0)
        each(interactiveLids, (lid) => offLayerHover(map, lid))
        each(aggLids, (lid) => {
            if (map.getLayer(lid)) map.removeLayer(lid)
        })
        each(aggSids, (sid) => {
            if (map.getSource(sid)) map.removeSource(sid)
        })
        tracked.layerIds = tracked.layerIds.filter((x) => aggLids.indexOf(x) < 0)
        tracked.sourceIds = tracked.sourceIds.filter((x) => aggSids.indexOf(x) < 0)
        store.geojsonHoverK = {}
        return
    }

    let ptsFc = { type: 'FeatureCollection', features: ptsFeats }
    let lnsFc = { type: 'FeatureCollection', features: lnsFeats }
    let pgsFc = { type: 'FeatureCollection', features: pgsFeats }
    if (map.getSource(GEOJSON_AGG_PTS_SRC)) {
        map.getSource(GEOJSON_AGG_PTS_SRC).setData(ptsFc) //資料與樣式(內含於 properties)一次同步(下同)
        map.getSource(GEOJSON_AGG_LNS_SRC).setData(lnsFc)
        map.getSource(GEOJSON_AGG_PGS_SRC).setData(pgsFc)
        return
    }
    map.addSource(GEOJSON_AGG_PTS_SRC, { type: 'geojson', data: ptsFc, generateId: true })
    map.addSource(GEOJSON_AGG_LNS_SRC, { type: 'geojson', data: lnsFc, generateId: true })
    map.addSource(GEOJSON_AGG_PGS_SRC, { type: 'geojson', data: pgsFc, generateId: true })
    //sort-key 依 _kgj: 同層內 feature 疊序與陣列序一致(後者在上)
    map.addLayer({
        id: GEOJSON_AGG_PGS_FID,
        type: 'fill',
        source: GEOJSON_AGG_PGS_SRC,
        layout: { 'fill-sort-key': ['get', '_kgj'] },
        paint: { 'fill-color': ['get', '__fillColor'], 'fill-opacity': 1 },
    })
    map.addLayer({
        id: GEOJSON_AGG_PGS_LID,
        type: 'line',
        source: GEOJSON_AGG_PGS_SRC,
        layout: { 'line-sort-key': ['get', '_kgj'] },
        paint: { 'line-color': ['get', '__lineColor'], 'line-width': ['get', '__lineWidth'] },
    })
    map.addLayer({
        id: GEOJSON_AGG_LNS_LID,
        type: 'line',
        source: GEOJSON_AGG_LNS_SRC,
        layout: { 'line-sort-key': ['get', '_kgj'] },
        paint: { 'line-color': ['get', '__lineColor'], 'line-width': ['get', '__lineWidth'] },
    })
    map.addLayer({
        id: GEOJSON_AGG_PTS_LID,
        type: 'circle',
        source: GEOJSON_AGG_PTS_SRC,
        layout: { 'circle-sort-key': ['get', '_kgj'] },
        paint: { 'circle-color': ['get', '__fillColor'], 'circle-radius': 5, 'circle-stroke-color': ['get', '__lineColor'], 'circle-stroke-width': 1 },
    })
    tracked.sourceIds.push(GEOJSON_AGG_PTS_SRC, GEOJSON_AGG_LNS_SRC, GEOJSON_AGG_PGS_SRC)
    tracked.layerIds.push(GEOJSON_AGG_PGS_FID, GEOJSON_AGG_PGS_LID, GEOJSON_AGG_LNS_LID, GEOJSON_AGG_PTS_LID)

    //click/hover: 以 _kgj 反查最新資料; hover 追蹤按圖層各自記錄(pts/lns/pgs 互不干擾, 對齊逐組時代逐層註冊語意)
    let bindAggEvents = (layerId) => {
        map.on('click', layerId, (e) => {
            if (callbacks.shouldDeferClick && callbacks.shouldDeferClick(e.point, 'geojson')) return //讓位給更高優先型別(點/線)
            let k = get(e, 'features.0.properties._kgj', null); if (!isNumber(k)) return
            let gjNow = get(store, `geojsonSets.${k}`, null); if (!gjNow) return
            if (isfun(gjNow.funSetsClick)) gjNow.funSetsClick({ ev: e, lat: e.lngLat.lat, lng: e.lngLat.lng, geojsonSet: gjNow, kgeojsonSet: k, geojsonSets: store.geojsonSets })
            callbacks.onPopupClick(e.lngLat, gjNow, 'geojson', k)
        })
        let hoverByK = (e) => {
            let k = get(e, 'features.0.properties._kgj', null); if (!isNumber(k)) return
            if (!store.geojsonHoverK) store.geojsonHoverK = {}
            if (store.geojsonHoverK[layerId] === k) return
            store.geojsonHoverK[layerId] = k
            let gjNow = get(store, `geojsonSets.${k}`, null); if (!gjNow) return
            map.getCanvas().style.cursor = 'pointer'
            callbacks.onTooltipEnter(e.lngLat, gjNow, 'geojson', k)
        }
        onLayerHover(map, layerId, hoverByK, () => {
            if (store.geojsonHoverK) delete store.geojsonHoverK[layerId]
            map.getCanvas().style.cursor = ''; callbacks.onTooltipLeave()
        }, hoverByK)
    }
    each(interactiveLids, (lid) => bindAggEvents(lid))
}


//移除單一 imageSet(以 kid 識別)之 layer/source, 並同步 tracked 與 store
function removeImageSetByKid(map, kid, tracked, store) {
    let sid = `image-src-${kid}`; let lid = `image-layer-${kid}`
    if (map.getLayer(lid)) map.removeLayer(lid)
    if (map.getSource(sid)) map.removeSource(sid)
    tracked.layerIds = tracked.layerIds.filter((x) => x !== lid)
    tracked.sourceIds = tracked.sourceIds.filter((x) => x !== sid)
    if (store.imageSetsPrev) delete store.imageSetsPrev[kid]
}


/**
 * 渲染影像圖層(kid 識別 + 逐組差異跳過)。
 * maplibre 的 image source 一源一張圖, 無法聚合; 但 updateImage 會觸發圖片重載,
 * 故以逐組差異跳過使未變更組零觸碰(不 updateImage → 不重載/不閃爍),
 * 並以 kid(選配 key, 未給退回索引)維持組身分, 免陣列位移造成全組重載。
 * @param {Object} map
 * @param {Array} imageSets - 已正規化的 imageSets(各組含 kid)
 * @param {Object} tracked - { sourceIds, layerIds }
 * @param {Object} callbacks - { getDisplayOrderByType(): Boolean }
 */
export function renderImageSets(map, imageSets, tracked, callbacks) {
    if (!map) return
    let store = getSetsStore(map)
    if (!store.imageSetsPrev) store.imageSetsPrev = {}

    //kid 式過期清理(imageSetsPrev 之 key 即「已知組」名冊; 影像渲染為同步, 無 renderSeq 需求)
    let kidsNow = {}
    each(imageSets, (im, k) => {
        kidsNow[get(im, 'kid', null) || String(k)] = true
    })
    each(Object.keys(store.imageSetsPrev), (kid) => {
        if (!kidsNow[kid]) removeImageSetByKid(map, kid, tracked, store)
    })

    each(imageSets, (im, k) => {
        let kid = get(im, 'kid', null) || String(k)
        let sid = `image-src-${kid}`; let lid = `image-layer-${kid}`
        if (!im.visible) {
            removeImageSetByKid(map, kid, tracked, store)
            return
        }
        //逐組差異跳過: 內容未變且 source 存在之組零觸碰(不 updateImage → 不重載圖片)
        if (store.imageSetsPrev[kid] && map.getSource(sid) && isEqual(store.imageSetsPrev[kid], im)) return
        //image/url 無效(如 runtime 清空)視同不可顯示: 移除既有圖層, 不得殘留舊影像
        let img = get(im, 'image', {})
        if (!isobj(img)) {
            removeImageSetByKid(map, kid, tracked, store); return
        }
        let url = get(img, 'url', '')
        if (!isestr(url)) {
            removeImageSetByKid(map, kid, tracked, store); return
        }
        //四至以實際數值定 west/east/north/south, 不信任 lngMin/lngMax/latMin/latMax 之欄位命名方向:
        //呼叫端可能以「像素列慣例」填 latMin=頂列緯度(北, 較大值), 直接取 latMax 當頂邊會使影像上下顛倒。
        //maplibre image source 之 coordinates 須為 [左上, 右上, 右下, 左下](北在上), 故 north 取較大緯度。
        //對 latMin<latMax 之合規呼叫端結果不變。
        let _lnA = cdbl(get(img, 'lngMin', 0)); let _lnB = cdbl(get(img, 'lngMax', 0))
        let _ltA = cdbl(get(img, 'latMin', 0)); let _ltB = cdbl(get(img, 'latMax', 0))
        let lnW = _lnA < _lnB ? _lnA : _lnB; let lnE = _lnA > _lnB ? _lnA : _lnB
        let ltS = _ltA < _ltB ? _ltA : _ltB; let ltN = _ltA > _ltB ? _ltA : _ltB
        let coordinates = [[lnW, ltN], [lnE, ltN], [lnE, ltS], [lnW, ltS]]
        let op = get(im, 'opacity', null); if (!isNumber(op)) op = 1
        let sameImage = store.imageSetsPrev[kid] && isEqual(get(store.imageSetsPrev[kid], 'image', null), img) //須於覆寫 prev 前取值
        store.imageSetsPrev[kid] = im //實際套用才記錄(aliasing 契約同 pointSetsPrev: 整批替換 + visible 走獨立路徑)
        if (map.getSource(sid)) {
            //僅 opacity 變更(影像內容未變)不重載圖片: updateImage 會觸發 base64 重解碼與閃爍, 調滑桿不應付此成本
            if (!sameImage) map.getSource(sid).updateImage({ url, coordinates })
            if (map.getLayer(lid)) map.setPaintProperty(lid, 'raster-opacity', op)
        }
        else {
            map.addSource(sid, { type: 'image', url, coordinates })
            map.addLayer({ id: lid, type: 'raster', source: sid, paint: { 'raster-opacity': op } })
            tracked.sourceIds.push(sid); tracked.layerIds.push(lid)
        }
    })

    //堆疊順序回歸(僅 displayOrderByType 開啟時): 逐組差異跳過下, 重建組圖層落在最上,
    //依資料順序 moveLayer 回歸「陣列後者在上」; 型別間順序隨後由 raiseFeatureLayersByType 壓回。
    //不可以「本輪有無變更」閘住: 純重排(帶 key, 各組內容未變)時逐組全跳過, 仍須回歸堆疊序(對齊 renderPointSets)
    let needReorder = (callbacks && isfun(callbacks.getDisplayOrderByType) ? callbacks.getDisplayOrderByType() : true)
    if (needReorder) {
        each(imageSets, (im, k) => {
            let kid = get(im, 'kid', null) || String(k)
            let lid = `image-layer-${kid}`
            if (map.getLayer(lid)) map.moveLayer(lid)
        })
    }
}


/**
 * 渲染等值線圖層(聚合渲染: 全部 contourSets 之色帶併入單一 source + fill/line 各一層, 含圖例生成)。
 * 樣式 data-driven 放 feature properties; hover 樣式切換(changeStyleWhenHover)改以 feature-state 驅動:
 * paint 以 ['feature-state','hover'] case 切換基礎/hover 值, 游標下所有色帶(巢狀命中)同時高亮(對齊逐組時代行為);
 * 不啟用 hover 樣式之組, hover 值即基礎值(case 恆等, 行為不變)。
 * feature 帶 _kcs/_kps, click/hover 反查 store 最新資料; 跨組移動由 onMove 驅動 tooltip 切換。
 * @param {Object} map
 * @param {Array} contourSets - 已正規化的 contourSets(函式會直接更新各 cs.legend)
 * @param {Object} tracked - { sourceIds, layerIds }
 * @param {Object} callbacks - {
 *   onContourClick(e, ps, kps, cs, kcs, contourSets): void,
 *   onContourEnter(e, cs, kcs): void,
 *   onContourLeave(): void,
 * }
 */
const CONTOUR_AGG_SRC = 'contour-agg-src'
const CONTOUR_AGG_FILL = 'contour-agg-fill'
const CONTOUR_AGG_LINE = 'contour-agg-line'
export function renderContourSets(map, contourSets, tracked, callbacks) {
    if (!map) return
    let store = getSetsStore(map)
    store.contourSets = contourSets //供事件 handler 於事件時取最新資料
    if (!store.contourBands) store.contourBands = {}

    //掃除舊版逐組命名殘留(升級中場景: 不留重複繪製)
    let isLegacyLayer = (id) => /^contour-\d+-(fill|line)-\d+$/.test(id)
    let style = map.getStyle ? (map.getStyle() || {}) : {}
    let legacyLids = (style.layers || []).map((l) => l.id).filter(isLegacyLayer)
    if (legacyLids.length > 0) {
        offDelegatedListenersByLayer(map, isLegacyLayer)
        each(legacyLids, (id) => {
            offLayerHover(map, id)
            if (map.getLayer(id)) map.removeLayer(id)
        })
        each(Object.keys(style.sources || {}), (id) => {
            if (/^contour-\d+-src-\d+$/.test(id) && map.getSource(id)) map.removeSource(id)
        })
        tracked.layerIds = tracked.layerIds.filter((x) => !isLegacyLayer(x))
        tracked.sourceIds = tracked.sourceIds.filter((x) => !/^contour-\d+-src-\d+$/.test(x))
    }

    //聚合 features: 每組(visible)之每個色帶一個 Feature, 樣式(含 hover 變體)放 properties
    let features = []
    let ord = 0
    each(contourSets, (cs, kcs) => {
        if (!cs.visible) {
            delete store.contourBands[kcs]
            return
        }
        let points = get(cs, 'points', [])
        if (!isearr(points)) return

        let calcOpt = {
            containInner: get(cs, 'polygonsContainInner', null),
            clipInner: get(cs, 'polygonsClipInner', null),
            clipOuter: get(cs, 'polygonClipOuter', null),
            thresholds: get(cs, 'thresholds', null),
        }
        let polygonSets = calcContours(points, calcOpt)
        if (!isarr(polygonSets) || polygonSets.err) return

        let n = polygonSets.length - 1
        polygonSets = lmap(polygonSets, (ps, k) => {
            let color = getContourColor(k, n, cs.gradient, cs.getColor, ps)
            let lineColor = isestr(cs.lineColor) ? cs.lineColor : color
            return { ...ps, color, lineColor, fillColor: color }
        })

        // 更新圖例(渲染期寫回正規化資料: legend 需進 reactive 供圖例面板讀取, 屬刻意設計。
        // 注意: 若未來將 kid 逐組差異跳過推廣到等值線, 此 mutate 會使 isEqual 恆不相等, 屆時 legend 須改走獨立 store)
        cs.legend = buildContourLegend({ polygonSets }, cs)

        store.contourBands[kcs] = polygonSets //最新色帶資料, 供 handler 於事件時查詢

        let hoverable = cs.changeStyleWhenHover
        each(polygonSets, (ps, kps) => {
            let latLngs = get(ps, 'latLngs', []); if (!isearr(latLngs)) return
            let coordinates = lmap(latLngs, (polygon) => {
                return lmap(polygon, (ring) => {
                    if (isarr(ring) && isarr(ring[0])) return lmap(ring, (pt) => [pt[1], pt[0]])
                    return [ring[1], ring[0]]
                })
            })
            features.push({
                type: 'Feature',
                geometry: { type: 'MultiPolygon', coordinates },
                properties: {
                    _kcs: kcs,
                    _kps: kps,
                    _ord: ord++, //全域保序: 各組依序、組內色帶依閾值升冪(內圈在上), 對齊逐組時代圖層序
                    __fillColor: ps.fillColor,
                    __fillOpacity: cs.fillOpacity,
                    __fillOpacityHover: hoverable ? cs.fillOpacityHover : cs.fillOpacity,
                    __lineColor: ps.lineColor,
                    __lineWidth: cs.lineWidth,
                    __lineColorHover: hoverable ? (isestr(cs.lineColorHover) ? cs.lineColorHover : ps.color) : ps.lineColor,
                    __lineWidthHover: hoverable ? cs.lineWidthHover : cs.lineWidth,
                },
            })
        })
    })

    if (features.length === 0) {
        //無可見色帶: 移除聚合層/來源與事件註冊(聚合 id 無符合索引式清理之格式, 由此自清)
        offDelegatedListenersByLayer(map, (l) => l === CONTOUR_AGG_FILL)
        offLayerHover(map, CONTOUR_AGG_FILL)
        if (map.getLayer(CONTOUR_AGG_FILL)) map.removeLayer(CONTOUR_AGG_FILL)
        if (map.getLayer(CONTOUR_AGG_LINE)) map.removeLayer(CONTOUR_AGG_LINE)
        if (map.getSource(CONTOUR_AGG_SRC)) map.removeSource(CONTOUR_AGG_SRC)
        tracked.layerIds = tracked.layerIds.filter((x) => x !== CONTOUR_AGG_FILL && x !== CONTOUR_AGG_LINE)
        tracked.sourceIds = tracked.sourceIds.filter((x) => x !== CONTOUR_AGG_SRC)
        store.contourHoverIds = []
        store.contourHoverKcs = null
        return
    }

    let fc = { type: 'FeatureCollection', features }
    if (map.getSource(CONTOUR_AGG_SRC)) {
        //setData 會依索引重配 feature id, 舊 hover feature-state 會殘留到新 id 上 → 先全清
        map.removeFeatureState({ source: CONTOUR_AGG_SRC })
        store.contourHoverIds = []
        map.getSource(CONTOUR_AGG_SRC).setData(fc) //資料與樣式(內含於 properties)一次同步
        return
    }
    map.addSource(CONTOUR_AGG_SRC, { type: 'geojson', data: fc, generateId: true })
    //hover 樣式以 feature-state 切換: hover=true 取 __xxxHover, 否則取基礎值
    let hoverCase = (hoverProp, baseProp) => ['case', ['boolean', ['feature-state', 'hover'], false], ['get', hoverProp], ['get', baseProp]]
    map.addLayer({
        id: CONTOUR_AGG_FILL,
        type: 'fill',
        source: CONTOUR_AGG_SRC,
        layout: { 'fill-sort-key': ['get', '_ord'] },
        paint: { 'fill-color': ['get', '__fillColor'], 'fill-opacity': hoverCase('__fillOpacityHover', '__fillOpacity') },
    })
    map.addLayer({
        id: CONTOUR_AGG_LINE,
        type: 'line',
        source: CONTOUR_AGG_SRC,
        layout: { 'line-sort-key': ['get', '_ord'] },
        paint: { 'line-color': hoverCase('__lineColorHover', '__lineColor'), 'line-width': hoverCase('__lineWidthHover', '__lineWidth') },
    })
    tracked.sourceIds.push(CONTOUR_AGG_SRC)
    tracked.layerIds.push(CONTOUR_AGG_FILL, CONTOUR_AGG_LINE)

    map.on('click', CONTOUR_AGG_FILL, (e) => {
        if (callbacks.shouldDeferClick && callbacks.shouldDeferClick(e.point, 'contour')) return //讓位給更高優先型別
        let p = get(e, 'features.0.properties', null); if (!p || !isNumber(p._kcs)) return //features 由上而下排序, [0] 即最上色帶
        let csNow = get(store, `contourSets.${p._kcs}`, null)
        let psNow = get(store, `contourBands.${p._kcs}.${p._kps}`, null)
        if (!csNow || !psNow) return
        callbacks.onContourClick(e, psNow, p._kps, csNow, p._kcs, store.contourSets)
    })
    //hover: 游標下所有色帶(巢狀命中)設 hover feature-state(樣式由 paint case 切換);
    //tooltip 以最上 feature 之 _kcs 驅動, 組變更才重發(同組內移動不重發)
    let applyHover = (e) => {
        let feats = get(e, 'features', null) || []
        let ids = []
        each(feats, (f) => {
            if (isNumber(f.id)) ids.push(f.id)
        })
        let prev = store.contourHoverIds || []
        if (map.getSource(CONTOUR_AGG_SRC)) {
            each(prev, (id) => {
                if (ids.indexOf(id) < 0) map.setFeatureState({ source: CONTOUR_AGG_SRC, id }, { hover: false })
            })
            each(ids, (id) => {
                if (prev.indexOf(id) < 0) map.setFeatureState({ source: CONTOUR_AGG_SRC, id }, { hover: true })
            })
        }
        store.contourHoverIds = ids
        let kcs = get(feats, '0.properties._kcs', null); if (!isNumber(kcs)) return
        map.getCanvas().style.cursor = 'pointer'
        if (store.contourHoverKcs === kcs) return
        store.contourHoverKcs = kcs
        let csNow = get(store, `contourSets.${kcs}`, null); if (!csNow) return
        callbacks.onContourEnter(e, csNow, kcs)
    }
    onLayerHover(map, CONTOUR_AGG_FILL, applyHover, () => {
        let prev = store.contourHoverIds || []
        if (map.getSource(CONTOUR_AGG_SRC)) {
            each(prev, (id) => map.setFeatureState({ source: CONTOUR_AGG_SRC, id }, { hover: false }))
        }
        store.contourHoverIds = []
        store.contourHoverKcs = null
        map.getCanvas().style.cursor = ''
        callbacks.onContourLeave()
    }, applyHover)
}