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