js/configProcessor.mjs

/**
 * configProcessor.mjs
 * 負責將傳入的 opt prop 正規化為各面板配置,全部為純函式(無副作用)
 */
import each from 'lodash-es/each.js'
import get from 'lodash-es/get.js'
import cloneDeep from 'lodash-es/cloneDeep.js'
import size from 'lodash-es/size.js'
import isNumber from 'lodash-es/isNumber.js'
import isEqual from 'lodash-es/isEqual.js'
import isbol from 'wsemi/src/isbol.mjs'
import isstr from 'wsemi/src/isstr.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import isobj from 'wsemi/src/isobj.mjs'
import isnum from 'wsemi/src/isnum.mjs'
import isarr from 'wsemi/src/isarr.mjs'
import cdbl from 'wsemi/src/cdbl.mjs'


/** 面板尺寸樣式輔助(純函式) */
export function buildPanelStyle(p) {
    let s = {}
    if (isNumber(p.width)) s.width = `${p.width}px`
    if (isNumber(p.maxWidth)) s.maxWidth = `${p.maxWidth}px`
    if (isNumber(p.height)) s.height = `${p.height}px`
    if (isNumber(p.maxHeight)) s.maxHeight = `${p.maxHeight}px`
    return s
}


/**
 * 計算底圖面板配置
 * @param {Object} opt - Vue component 的 opt prop
 * @param {Array} currentBaseMaps - 目前 panelBaseMaps.baseMaps(用於保留已選底圖)
 * @param {Object} currentTerrainMapTemp - 上次 terrainMap 快照(用於 diff)
 * @param {Array} currentBaseMapsDataTemp - 上次底圖資料快照(用於 diff)
 * @param {Array} defBaseMapsData - 預設底圖配置
 * @param {Object} defTerrainMapData - 預設地形配置
 * @returns {{ panelBaseMaps, terrainMap, baseMapsChanged, terrainChanged }}
 */
export function computePanelBaseMaps(opt, currentBaseMaps, currentTerrainMapTemp, currentBaseMapsDataTemp, defBaseMapsData, defTerrainMapData) {
    let def = { show: true, position: 'topleft', baseMaps: [], stopWheel: false }
    let p = get(opt, 'panelBaseMaps', null); if (!isobj(p)) p = {}
    p = { ...def, ...p }
    if (size(get(p, 'baseMaps', [])) === 0) {
        p.baseMaps = size(currentBaseMaps) === 0 ? cloneDeep(defBaseMapsData) : cloneDeep(currentBaseMaps)
    }
    p = cloneDeep(p); p.style = buildPanelStyle(p)

    //colorShade 未給時補文件預設 ''(疊加層): 使面板分類(radio/checkbox)、switchBaseMap 與渲染端 isestr 判斷一致
    each(p.baseMaps, (bm) => {
        if (isobj(bm) && !isstr(bm.colorShade)) bm.colorShade = ''
    })

    let tm = get(p, 'terrainMap', null)
    if (!isobj(tm)) tm = cloneDeep(defTerrainMapData)
    let terrainChanged = !isEqual(currentTerrainMapTemp, tm)

    let baseMapsChanged = !isEqual(currentBaseMapsDataTemp, p.baseMaps)

    return { panelBaseMaps: p, terrainMap: tm, baseMapsChanged, terrainChanged }
}


/**
 * 計算羅盤圖標面板配置
 * @param {Object} opt
 * @param {{ iconCompassRoseLight, iconCompassRoseDark }} uiResData
 * @returns {Object} panelCompassRose
 */
export function computePanelCompassRose(opt, uiResData) {
    let def = {
        show: false,
        position: 'topright',
        size: 120,
        withPanel: false,
        iconSrc: null,
        iconSrcLight: uiResData.iconCompassRoseLight,
        iconSrcDark: uiResData.iconCompassRoseDark,
    }
    let p = get(opt, 'panelCompassRose', null); if (!isobj(p)) p = {}
    return cloneDeep({ ...def, ...p })
}


/** 計算 3D 指北針面板配置 */
export function computePanelCompass3d(opt) {
    let def = { show: true, position: 'topright', size: 44 }
    let p = get(opt, 'panelCompass3d', null); if (!isobj(p)) p = {}
    return cloneDeep({ ...def, ...p })
}


/** 計算地圖資訊面板配置 */
export function computePanelLabels(opt) {
    let def = { show: true, title: '', useItems: ['lng', 'lat', 'zoom'], lng: 'Longitude', lat: 'Latitude', zoom: 'Zoom', position: 'topright' }
    let p = get(opt, 'panelLabels', null); if (!isobj(p)) p = {}
    p = cloneDeep({ ...def, ...p }); p.style = buildPanelStyle(p)
    return p
}


/** 計算圖層顯隱面板配置 */
export function computePanelItems(opt) {
    let def = { show: true, position: 'topleft', stopWheel: false }
    let p = get(opt, 'panelItems', null); if (!isobj(p)) p = {}
    p = cloneDeep({ ...def, ...p }); p.style = buildPanelStyle(p)
    return p
}


/** 計算縮放按鈕面板配置 */
export function computePanelZoom(opt) {
    let def = { show: true, position: 'bottomleft' }
    let p = get(opt, 'panelZoom', null); if (!isobj(p)) p = {}
    return cloneDeep({ ...def, ...p })
}


/** 計算比例尺面板配置 */
export function computePanelScale(opt) {
    let def = { show: true, position: 'bottomright' }
    let p = get(opt, 'panelScale', null); if (!isobj(p)) p = {}
    return cloneDeep({ ...def, ...p })
}


/** 計算圖例面板配置 */
export function computePanelLegends(opt) {
    let def = { show: true, position: 'bottomright', maxWidth: 300, stopWheel: false }
    let p = get(opt, 'panelLegends', null); if (!isobj(p)) p = {}
    p = cloneDeep({ ...def, ...p }); p.style = buildPanelStyle(p)
    return p
}


/**
 * 計算叢集化設定
 * @param {Object} opt
 * @returns {{ enabled, radius, maxZoom, levelNum, levelValues, levelRadius,
 *             levelFillColors, levelLineColors, levelLineWidths, levelTextSizes, levelTextColors, key }}
 */
export function computeClusterOpts(opt) {
    let cpEnabled = get(opt, 'clusterPoints', null)
    if (!isbol(cpEnabled)) cpEnabled = false
    let cpRadius = get(opt, 'clusterPointsRadius', null)
    if (!isnum(cpRadius)) cpRadius = 50; else cpRadius = cdbl(cpRadius)
    let cpMaxZoom = get(opt, 'clusterPointsMaxZoom', null)
    if (!isnum(cpMaxZoom)) cpMaxZoom = 14; else cpMaxZoom = cdbl(cpMaxZoom)
    let cpLevelNum = get(opt, 'clusterPointsLevelNum', null)
    if (!isnum(cpLevelNum)) cpLevelNum = 3; else cpLevelNum = Math.max(1, Math.round(cdbl(cpLevelNum)))
    //預設陣列補齊到指定長度: levelNum > 預設長度時依規則外插, 避免靜默降級成較少級數
    let padArr = (arr, n, fnNext) => {
        let r = arr.slice(0, n)
        while (r.length < n) r.push(fnNext(r[r.length - 1]))
        return r
    }
    let cpLevelValues = get(opt, 'clusterPointsLevelValues', null)
    if (!isarr(cpLevelValues) || cpLevelValues.length !== cpLevelNum - 1) cpLevelValues = padArr([10, 100], cpLevelNum - 1, (last) => last * 10)
    let cpLevelRadius = get(opt, 'clusterPointsLevelRadius', null)
    if (!isarr(cpLevelRadius) || cpLevelRadius.length !== cpLevelNum) cpLevelRadius = padArr([10, 15, 20], cpLevelNum, (last) => last + 5)
    let cpLevelFillColors = get(opt, 'clusterPointsLevelFillColors', null)
    if (!isarr(cpLevelFillColors) || cpLevelFillColors.length !== cpLevelNum) cpLevelFillColors = padArr(['rgba(140, 40, 25, 0.75)', 'rgba(255, 125, 50, 0.75)', 'rgba(255, 50, 100, 0.75)'], cpLevelNum, (last) => last)
    let cpLevelLineColors = get(opt, 'clusterPointsLevelLineColors', null)
    if (!isarr(cpLevelLineColors) || cpLevelLineColors.length !== cpLevelNum) cpLevelLineColors = padArr(['#fff', '#fff', '#fff'], cpLevelNum, (last) => last)
    let cpLevelLineWidths = get(opt, 'clusterPointsLevelLineWidths', null)
    if (!isarr(cpLevelLineWidths) || cpLevelLineWidths.length !== cpLevelNum) cpLevelLineWidths = padArr([2, 2, 2], cpLevelNum, (last) => last)
    let cpLevelTextSizes = get(opt, 'clusterPointsLevelTextSizes', null)
    if (!isarr(cpLevelTextSizes) || cpLevelTextSizes.length !== cpLevelNum) cpLevelTextSizes = padArr([12, 12, 12], cpLevelNum, (last) => last)
    let cpLevelTextColors = get(opt, 'clusterPointsLevelTextColors', null)
    if (!isarr(cpLevelTextColors) || cpLevelTextColors.length !== cpLevelNum) cpLevelTextColors = padArr(['#fff', '#fff', '#fff'], cpLevelNum, (last) => last)

    let key = JSON.stringify({
        cpEnabled,
        cpRadius,
        cpMaxZoom,
        cpLevelNum,
        cpLevelValues,
        cpLevelRadius,
        cpLevelFillColors,
        cpLevelLineColors,
        cpLevelLineWidths,
        cpLevelTextSizes,
        cpLevelTextColors,
    })

    return {
        enabled: cpEnabled,
        radius: cpRadius,
        maxZoom: cpMaxZoom,
        levelNum: cpLevelNum,
        levelValues: cpLevelValues,
        levelRadius: cpLevelRadius,
        levelFillColors: cpLevelFillColors,
        levelLineColors: cpLevelLineColors,
        levelLineWidths: cpLevelLineWidths,
        levelTextSizes: cpLevelTextSizes,
        levelTextColors: cpLevelTextColors,
        key,
    }
}


//圖台縮放範圍預設: 0~22(maxZoom 同 maplibre 預設; minZoom 0=整個世界於 512px 圖磚恰為一幅; maplibre 6.14.0 實作之 minZoom 預設為 -2〔文件寫 0〕, 故由本套件明給)
export const DEF_MIN_ZOOM = 0
export const DEF_MAX_ZOOM = 22
const LIM_ZOOM = 24 //maplibre MapOptions 之合法上限

function toZoomLimit(v) {
    if (!isnum(v)) return null
    v = Number(v)
    if (!Number.isFinite(v)) return null
    return Math.min(Math.max(v, 0), LIM_ZOOM)
}

/**
 * 正規化圖台縮放範圍(opt.minZoom、opt.maxZoom): 數字或數字字串依數值; 非數值/非有限視為未給用預設(0、22); 夾在 0~24;
 * minZoom 大於 maxZoom 時忽略 minZoom(ignoredMin=true, 由呼叫端警告一次; 同圖磚 minzoom/maxzoom 之作法, maxZoom 為保護性欄位)。
 * 範圍只交給地圖把關(R11), 元件側不另夾 opt.zoom
 * @param {Object} opt
 * @returns {{ minZoom: Number, maxZoom: Number, ignoredMin: Boolean }}
 */
export function computeZoomRange(opt) {
    let mn = toZoomLimit(get(opt, 'minZoom', null))
    let mx = toZoomLimit(get(opt, 'maxZoom', null))
    let minZoom = mn === null ? DEF_MIN_ZOOM : mn
    let maxZoom = mx === null ? DEF_MAX_ZOOM : mx
    let ignoredMin = false
    if (minZoom > maxZoom) {
        ignoredMin = true
        minZoom = Math.min(DEF_MIN_ZOOM, maxZoom)
    }
    return { minZoom, maxZoom, ignoredMin }
}

//center 合法: 兩值皆為有限數且緯度在 ±90 內(經度不限, maplibre 會 wrap); isNumber 對 NaN 為 true, 故須另驗有限
function isValidCenter(c) {
    return isarr(c) && size(c) === 2 && isNumber(c[0]) && isNumber(c[1]) && Number.isFinite(c[0]) && Number.isFinite(c[1]) && Math.abs(c[0]) <= 90
}

/**
 * 計算基本 opt 設定(zoom, minZoom, maxZoom, center, panelBackgroundColor 等)。對任何輸入皆回合法值且不拋錯(R13)
 * @param {Object} opt
 * @param {Number} currentZoom
 * @param {Array} currentCenter
 * @returns {{ zoom, minZoom, maxZoom, ignoredMin, center, panelBackgroundColor, displayPopupOnlyone, displayOrderByType, popupPosition, tooltipPosition, projection }}
 */
export function computeBasicOpt(opt, currentZoom, currentCenter) {
    let pbc = get(opt, 'panelBackgroundColor', null)
    if (!isstr(pbc)) pbc = 'rgba(255,255,255,0.95)'

    let zr = computeZoomRange(opt)

    //zoom 不在此夾值: 層級範圍只由地圖之 minZoom/maxZoom 把關(地圖實際可達層級另受投影影響, 如 globe 之下限隨緯度而略低於 minZoom,
    //元件側另夾一次必與地圖實際值不一致而拉回); 超出範圍之值交 map.setZoom/jumpTo, 由 changeOpt 以地圖實際值回寫
    let zoom = get(opt, 'zoom', null)
    zoom = isnum(zoom) ? Number(zoom) : 6

    let center = get(opt, 'center', null)
    if (!isValidCenter(center)) center = [23.5, 121.1]
    center = cloneDeep(center)

    let displayPopupOnlyone = get(opt, 'displayPopupOnlyone', null)
    if (!isbol(displayPopupOnlyone)) displayPopupOnlyone = true

    let displayOrderByType = get(opt, 'displayOrderByType', null)
    if (!isbol(displayOrderByType)) displayOrderByType = true

    let pp = get(opt, 'popupPosition', null)
    let popupPosition = (isestr(pp) && ['top', 'bottom', 'left', 'right'].includes(pp)) ? pp : null

    let tp = get(opt, 'tooltipPosition', null)
    let tooltipPosition = (isestr(tp) && ['top', 'bottom', 'left', 'right'].includes(tp)) ? tp : null

    let projection = get(opt, 'projection', null)
    if (!isstr(projection) || !['globe', 'mercator'].includes(projection)) projection = ''

    return { zoom, minZoom: zr.minZoom, maxZoom: zr.maxZoom, ignoredMin: zr.ignoredMin, center, panelBackgroundColor: pbc, displayPopupOnlyone, displayOrderByType, popupPosition, tooltipPosition, projection }
}