import {
  Fog,
  Group,
  MeshBasicMaterial,
  Shape,
  ShapeGeometry,
  Line as ThreeLine,
  LineLoop,
  CatmullRomCurve3,
  TubeGeometry,
  BufferGeometry,
  Float32BufferAttribute,
  DirectionalLight,
  AmbientLight,
  PointLight,
  Vector3,
  MeshLambertMaterial,
  LineBasicMaterial,
  Color,
  MeshStandardMaterial,
  PlaneGeometry,
  Mesh,
  DoubleSide,
  RepeatWrapping,
  ClampToEdgeWrapping,
  SRGBColorSpace,
  AdditiveBlending,
  VideoTexture,
  NearestFilter,
  LinearFilter,
  CanvasTexture,
  BoxGeometry,
  PointsMaterial,
  Sprite,
  SpriteMaterial,
  CustomBlending,
  AddEquation,
  DstColorFactor,
  OneFactor,
} from "three"
import {
  Mini3d,
  ExtrudeMap,
  BaseMap,
  Line,
  Grid,
  Label3d,
  Plane,
  Particles,
  GradientShader,
  DiffuseShader,
} from "@/mini3d"

import { geoMercator } from "d3-geo"
import labelIcon from "@/assets/texture/label-icon.png"
import chinaData from "./map/chinaData"
import provincesData from "./map/provincesData"
import scatterData from "./map/scatter"
import infoData from "./map/infoData"
import gsap from "gsap"
import { InteractionManager } from "three.interactive"
import { applyMapTextureUv, createHongyuanSatelliteTexture, getHongyuanSatelliteTileRange } from "./satelliteTiles"
import { ecologicalProtectionLayers, forestGrassWetLayers, HONGYUAN_BOUNDS, SATELLITE_TEXTURE_ZOOM, WMS_IMAGE_SIZE } from "@/config/layers"
import { buildWmsGetMapUrl, getFeatureInfo, getWfsFeatureProperties, normalizeFeatureInfo } from "@/services/geoserver"
const DEFAULT_MAP_CAMERA_POSITION = new Vector3(-0.17427287762525134, 13.678992786206543, 20.688611202093714)
const DEFAULT_MAP_TARGET = new Vector3(0, 0, 0)
const INTRO_MAP_CAMERA_POSITION = new Vector3(-13.767695123014105, 12.990152163077308, 39.28228164159694)
const CUSTOM_SATELLITE_MAX_ZOOM = 21
const DYNAMIC_SATELLITE_TILE_WINDOW = 9
const DYNAMIC_SATELLITE_CHECK_INTERVAL = 260
const SELECTED_FEATURE_FILL = "#FF2D2D"
const SELECTED_FEATURE_STROKE = "#FFECEC"
const SELECTED_FEATURE_GLOW = "#FF4A3D"
const SELECTED_FEATURE_FILL_HEX = 0xff2d2d
const SELECTED_FEATURE_STROKE_HEX = 0xffecec
const SELECTED_FEATURE_GLOW_HEX = 0xff4a3d
const YAK_INDUSTRY_MARKER_TYPES = {
  "family-pasture": "pasture",
  "artificial-grassland": "grass",
  "scale-breeding": "breeding",
  "forage-storage": "storage",
  "slaughter-processing": "processing",
  "farm-equipment": "equipment",
  "social-service": "service",
}
function sortByValue(data) {
  data.sort((a, b) => b.value - a.value)
  return data
}
function normalizeTownName(name) {
  return String(name || "")
    .replace(/\s+/g, "")
    .trim()
}
function toFiniteNumber(value, fallback = 0) {
  const number = Number(value)
  return Number.isFinite(number) ? number : fallback
}
function escapeHtml(value) {
  return String(value ?? "").replace(/[&<>"']/g, (char) => ({
    "&": "&amp;",
    "<": "&lt;",
    ">": "&gt;",
    "\"": "&quot;",
    "'": "&#39;",
  }[char]))
}
function isVillageRanking(ranking) {
  return ranking?.scope === "village" || ranking?.labelMode === "village"
}
function isTradeFlowRanking(ranking) {
  return ranking?.scope === "trade-flow" || ranking?.labelMode === "trade-flow"
}
function shouldShowTradeFlowLines(ranking) {
  return ranking?.showFlowLines !== false
}
function shouldHideTownNames(ranking) {
  return Boolean(ranking?.hideTownNames || ranking?.showTownNames === false)
}
function shouldHideMapLabels(ranking) {
  return Boolean(ranking?.hideMapLabels || ranking?.showMapLabels === false)
}
function isCalloutRanking(ranking) {
  return ranking?.displayMode === "callout" || ranking?.labelMode === "callout"
}
function isVectorTopicLayer(layer) {
  return ["wkt-api", "geojson", "wfs"].includes(layer?.sourceType)
}
function isReadOnlyTopicLayer(layer) {
  return layer?.key === "yak-recognition-layer"
}
function buildPublicUrl(path) {
  if (!path) return ""
  if (/^https?:\/\//.test(path) || path.startsWith("/")) return path
  return `${import.meta.env.BASE_URL || "./"}${path}`
}
function parseGeoJsonData(data) {
  if (!data) return null
  if (typeof data === "string") {
    try {
      return JSON.parse(data)
    } catch (error) {
      console.warn("地图 GeoJSON 解析失败", error)
      return null
    }
  }
  return data
}
function normalizeMapGeoJsonData(data) {
  const parsed = parseGeoJsonData(data)
  if (!parsed?.features) return parsed
  parsed.features.forEach((feature) => {
    if (feature.geometry?.type === "Polygon") {
      feature.geometry.coordinates = [feature.geometry.coordinates]
    }
  })
  return parsed
}
function getFeaturePolygons(geometry) {
  if (!geometry?.coordinates) return []
  if (geometry.type === "Polygon") {
    return Array.isArray(geometry.coordinates?.[0]?.[0]?.[0])
      ? geometry.coordinates
      : [geometry.coordinates]
  }
  if (geometry.type === "MultiPolygon") return geometry.coordinates
  return []
}
export class World extends Mini3d {
  constructor(canvas, assets, callbacks = {}) {
    super(canvas)
    // 中心坐标
    this.geoProjectionCenter = [102.6305459723107, 32.72169252326339]
    // 缩放比例
    this.geoProjectionScale = 650
    this.tradeGeoProjectionCenter = [104, 36]
    this.tradeGeoProjectionScale = 11.4
    this.tradeNationalDepth = 0.24
    // 地图拉伸高度
    this.depth = 0.5
    // 是否点击
    this.clicked = false
    // 雾
    this.scene.fog = new Fog(0x071f2f, 1, 50)
    // 背景
    this.scene.background = new Color(0x071f2f)

    // 相机初始位置
    this.camera.instance.position.copy(INTRO_MAP_CAMERA_POSITION)
    this.camera.instance.near = 1
    this.camera.instance.far = 10000
    this.camera.instance.updateProjectionMatrix()
    this.defaultMapCameraPosition = DEFAULT_MAP_CAMERA_POSITION.clone()
    this.defaultMapTarget = DEFAULT_MAP_TARGET.clone()
    this.camera.controls.target.copy(this.defaultMapTarget)
    this.camera.controls.update()
    this.activeMapViewTweens = []
    this.sceneTweens = []
    this.sceneTimers = []
    // 创建交互管理
    this.interactionManager = new InteractionManager(this.renderer.instance, this.camera.instance, this.canvas)

    this.assets = assets
    this.callbacks = callbacks
    this.tileUrlTemplate = callbacks.tileUrlTemplate || ""
    this.usesCustomSatelliteTiles = Boolean(this.tileUrlTemplate)
    this.adminBoundaryLineScale = this.normalizeAdminBoundaryLineScale(callbacks.adminBoundaryLineScale)
    this.mapBoundaryEmphasisMaterial = null
    this.dynamicSatelliteMaxZoom = CUSTOM_SATELLITE_MAX_ZOOM
    this.dynamicSatelliteTileWindow = DYNAMIC_SATELLITE_TILE_WINDOW
    this.dynamicSatelliteOverlay = null
    this.dynamicSatelliteMaterial = null
    this.dynamicSatelliteTexture = null
    this.dynamicSatelliteCurrentKey = ""
    this.dynamicSatelliteLoadingKey = ""
    this.dynamicSatelliteRequestId = 0
    this.dynamicSatelliteLastCheck = 0
    this.topicLayerLoadId = 0
    this.visibleTopicLayers = []
    this.mapAnimationComplete = false
    this.showTownRankingBars = false
    this.showTownRankingPointMarkers = false
    this.currentTownRanking = null
    this.villageRankingGroup = null
    this.villageRankingLabels = []
    this.tradeFlowGroup = null
    this.tradeFlowMeshGroup = null
    this.tradeFlowLabelGroup = null
    this.tradeFlowLabels = []
    this.tradeFlowMaterials = []
    this.tradeFlowPulseSprites = []
    this.isTradeNationalMapActive = false
    this.selectedFeatureGroup = null
    this.selectedVectorFeatures = []
    this.analysisBoundaryFeature = null
    this.analysisMatchedFeatures = []
    this.boundaryDrawingEnabled = false
    this.boundaryDrawPoints = []
    this.lastBoundaryDrawPoint = null
    this.currentVectorFeatures = []
    this.yakIndustryPointLabelGroup = null
    this.yakIndustryPointLabels = []
    this.yakIndustryPointMarkerGroup = null
    this.yakIndustryPointMarkers = []
    this.yakIndustryMarkerMaterialCache = new Map()
    this.hongyuanBoundaryPolygons = []
    this.townMetaByName = new Map(provincesData.map((item) => [normalizeTownName(item.name), item]))
    this.staticMapData = {
      china: normalizeMapGeoJsonData(this.assets.instance.getResource("china")),
      mapJson: normalizeMapGeoJsonData(this.assets.instance.getResource("mapJson")),
      mapStroke: normalizeMapGeoJsonData(this.assets.instance.getResource("mapStroke")),
    }
    this.handleCanvasBoundaryPointerDown = this.handleCanvasBoundaryPointerDown.bind(this)
    this.handleCanvasBoundaryDoubleClick = this.handleCanvasBoundaryDoubleClick.bind(this)
    this.canvas?.addEventListener?.("pointerdown", this.handleCanvasBoundaryPointerDown, true)
    this.canvas?.addEventListener?.("dblclick", this.handleCanvasBoundaryDoubleClick, true)
    // 创建环境光
    this.initEnvironment()
    this.init()
  }
  init() {
    // 标签组
    this.labelGroup = new Group()
    this.label3d = new Label3d(this)
    this.labelGroup.rotation.x = -Math.PI / 2
    this.scene.add(this.labelGroup)
    // 飞线焦点光圈组
    this.flyLineFocusGroup = new Group()
    this.flyLineFocusGroup.visible = false
    this.flyLineFocusGroup.rotation.x = -Math.PI / 2
    this.scene.add(this.flyLineFocusGroup)
    // 区域事件元素
    this.eventElement = []
    // 鼠标移上移除的材质
    this.defaultMaterial = null // 默认材质
    this.defaultLightMaterial = null // 高亮材质
    // 创建底部高亮
    this.createBottomBg()
    // 模糊边线
    this.createChinaBlurLine()

    // 扩散网格
    this.createGrid()
    // 旋转圆环
    this.createRotateBorder()
    // 创建标签
    this.createLabel()
    // 创建地图
    this.createMap()
    // 添加事件
    this.createEvent()
    // 创建飞线
    this.createFlyLine()
    // 创建飞线焦点
    this.createFocus()
    // 创建粒子
    this.createParticles()
    // 创建散点图
    this.createScatter()
    // 创建信息点
    this.createInfoPoint()
    // 创建轮廓
    this.createStorke()
    // this.time.on("tick", () => {
    //   console.log(this.camera.instance.position);
    // });
    // 创建动画时间线
    let tl = gsap.timeline({
      onComplete: () => {
        this.mapAnimationComplete = true
        this.applyLayerMapView(this.currentTopicLayer, { force: true })
        this.setTradeNationalMapVisible(false)
        this.updateYakIndustryPointMarkers(this.currentTopicLayer, this.currentVectorFeatures)
        this.updateYakIndustryPointLabels(this.currentTopicLayer, this.currentVectorFeatures)
        if (shouldHideMapLabels(this.currentTownRanking)) {
          this.hideAllMapStatisticLabels()
          return
        }
        this.updateVillageRankingLabels(this.currentTownRanking)
        this.updateTradeFlowLayer(this.currentTownRanking)
        this.updateInfoPointStats()
      },
    })
    tl.pause()
    this.animateTl = tl
    tl.addLabel("focusMap", 1.5)
    tl.addLabel("focusMapOpacity", 2)
    tl.addLabel("bar", 3)
    tl.to(this.camera.instance.position, {
      duration: 2,
      x: this.defaultMapCameraPosition.x,
      y: this.defaultMapCameraPosition.y,
      z: this.defaultMapCameraPosition.z,
      ease: "circ.out",
      onStart: () => {
        this.flyLineFocusGroup.visible = false
      },
    })
    tl.to(
      this.focusMapGroup.position,
      {
        duration: 1,
        x: 0,
        y: 0,
        z: 0,
      },
      "focusMap"
    )

    tl.to(
      this.focusMapGroup.scale,
      {
        duration: 1,
        x: 1,
        y: 1,
        z: 1,
        ease: "circ.out",
        onComplete: () => {
          this.flyLineGroup.visible = true
          this.scatterGroup.visible = true
          this.InfoPointGroup.visible = !shouldHideMapLabels(this.currentTownRanking) && this.showTownRankingPointMarkers && this.hasTownRankingRows()
          this.setTradeNationalMapVisible(false)
          this.updateYakIndustryPointMarkers(this.currentTopicLayer, this.currentVectorFeatures)
          this.updateYakIndustryPointLabels(this.currentTopicLayer, this.currentVectorFeatures)
          if (shouldHideMapLabels(this.currentTownRanking)) {
            this.hideAllMapStatisticLabels()
            return
          }
          this.updateVillageRankingLabels(this.currentTownRanking)
          this.updateTradeFlowLayer(this.currentTownRanking)
          this.updateInfoPointStats()
        },
      },
      "focusMap"
    )

    tl.to(
      this.focusMapTopMaterial,
      {
        duration: 1,
        opacity: this.getFocusMapTopOpacity(),
        ease: "circ.out",
      },
      "focusMapOpacity"
    )
    tl.to(
      this.focusMapSideMaterial,
      {
        duration: 1,
        opacity: 1,
        ease: "circ.out",
        onComplete: () => {
          this.focusMapSideMaterial.transparent = false
        },
      },
      "focusMapOpacity"
    )
    this.otherLabel.map((item, index) => {
      let element = item.element.querySelector(".other-label")
      tl.to(
        element,
        {
          duration: 1,
          delay: 0.1 * index,
          translateY: 0,
          opacity: 1,
          ease: "circ.out",
        },
        "focusMapOpacity"
      )
    })
    tl.to(
      this.mapLineMaterial,
      {
        duration: 0.5,
        delay: 0.3,
        opacity: 1,
      },
      "focusMapOpacity"
    )
    if (this.mapBoundaryEmphasisMaterial) {
      tl.to(
        this.mapBoundaryEmphasisMaterial,
        {
          duration: 0.5,
          delay: 0.3,
          opacity: this.getAdminBoundaryEmphasisOpacity(),
        },
        "focusMapOpacity"
      )
    }
    tl.to(
      this.rotateBorder1.scale,
      {
        delay: 0.3,
        duration: 1,
        x: 1,
        y: 1,
        z: 1,
        ease: "circ.out",
      },
      "focusMapOpacity"
    )
    tl.to(
      this.rotateBorder2.scale,
      {
        duration: 1,
        delay: 0.5,
        x: 1,
        y: 1,
        z: 1,
        ease: "circ.out",
        onComplete: () => {
          this.flyLineFocusGroup.visible = true
          this.callbacks.onPlayComplete?.()
        },
      },
      "focusMapOpacity"
    )
    if (this.showTownRankingBars) {
      this.allBar.map((item, index) => {
        tl.to(
          item.scale,
          {
            duration: 1,
            delay: 0.1 * index,
            x: 1,
            y: 1,
            z: 1,
            ease: "circ.out",
          },
          "bar"
        )
      })
      this.allBarMaterial.map((item, index) => {
        tl.to(
          item,
          {
            duration: 1,
            delay: 0.1 * index,
            opacity: 1,
            ease: "circ.out",
          },
          "bar"
        )
      })
    }

    const self = this
    this.allProvinceLabel.map((item, index) => {
      let element = item.element.querySelector(".provinces-label-wrap")
      let number = item.element.querySelector(".number .value")
      let numberAnimate = {
        progress: 0,
      }
      tl.to(
        element,
        {
          duration: 1,
          delay: 0.2 * index,
          translateY: 0,
          opacity: 1,
          ease: "circ.out",
        },
        "bar"
      )
      tl.to(
        numberAnimate,
        {
          duration: 1,
          delay: 0.2 * index,
          progress: 1,
          onStart: () => {
            numberAnimate.progress = 0
          },
          onUpdate: showScore,
        },
        "bar"
      )
      function showScore() {
        const targetValue = toFiniteNumber(item.userData?.targetValue ?? number.dataset.targetValue)
        number.innerText = self.formatMapRankingValue(targetValue * numberAnimate.progress)
      }
    })
    if (this.showTownRankingBars) {
      this.allGuangquan.map((item, index) => {
        tl.to(
          item.children[0].scale,
          {
            duration: 1,
            delay: 0.1 * index,
            x: 1,
            y: 1,
            z: 1,
            ease: "circ.out",
          },
          "bar"
        )
        tl.to(
          item.children[1].scale,
          {
            duration: 1,
            delay: 0.1 * index,
            x: 1,
            y: 1,
            z: 1,
            ease: "circ.out",
          },
          "bar"
        )
      })
    }
  }

  initEnvironment() {
    let sun = new AmbientLight(0xc8fbff, 4.2)
    this.scene.add(sun)
    let directionalLight = new DirectionalLight(0xdffcff, 5.8)
    directionalLight.position.set(-30, 6, -8)
    directionalLight.castShadow = true
    directionalLight.shadow.radius = 20
    directionalLight.shadow.mapSize.width = 1024
    directionalLight.shadow.mapSize.height = 1024
    this.scene.add(directionalLight)
    this.createPointLight({
      color: "#1fd6df",
      intensity: 680,
      distance: 10000,
      x: -9,
      y: 3,
      z: -3,
    })
    this.createPointLight({
      color: "#2af0c6",
      intensity: 260,
      distance: 10000,
      x: 0,
      y: 2,
      z: 5,
    })
  }
  createPointLight(pointParams) {
    const pointLight = new PointLight(0x1d5e5e, pointParams.intensity, pointParams.distance)
    pointLight.position.set(pointParams.x, pointParams.y, pointParams.z)
    this.scene.add(pointLight)
  }
  createMap() {
    let mapGroup = new Group()
    let focusMapGroup = new Group()
    this.mapGroup = mapGroup
    this.focusMapGroup = focusMapGroup
    let { china, chinaTopLine } = this.createChina()
    let { map, mapTop, mapLine, mapBoundaryEmphasis } = this.createProvince()
    china.setParent(mapGroup)
    chinaTopLine.setParent(mapGroup)
    // 创建扩散
    this.createDiffuse()
    map.setParent(focusMapGroup)
    mapTop.setParent(focusMapGroup)
    mapLine.setParent(focusMapGroup)
    mapBoundaryEmphasis?.setParent(focusMapGroup)
    focusMapGroup.position.set(0, 0, -0.01)
    focusMapGroup.scale.set(1, 1, 0)
    mapGroup.add(focusMapGroup)
    this.createTopicLayer()
    mapGroup.rotation.x = -Math.PI / 2
    mapGroup.position.set(0, 0.2, 0)
    this.scene.add(mapGroup)
    this.createBar()
  }
  createChina() {
    let params = {
      chinaBgMaterialColor: "#0d3349",
      lineColor: "#39c7ef",
    }
    let chinaData = this.staticMapData.china
    let chinaBgMaterial = new MeshLambertMaterial({
      color: new Color(params.chinaBgMaterialColor),
      transparent: true,
      opacity: 0.72,
    })
    let china = new BaseMap(this, {
      //position: new Vector3(0, 0, -0.03),
      data: chinaData,
      geoProjectionCenter: this.geoProjectionCenter,
      geoProjectionScale: this.geoProjectionScale,
      merge: true,
      material: chinaBgMaterial,
      renderOrder: 2,
    })
    let chinaTopLineMaterial = new LineBasicMaterial({
      color: params.lineColor,
      transparent: true,
      opacity: 0.58,
    })
    let chinaTopLine = new Line(this, {
      // position: new Vector3(0, 0, -0.02),
      data: chinaData,
      geoProjectionCenter: this.geoProjectionCenter,
      geoProjectionScale: this.geoProjectionScale,
      material: chinaTopLineMaterial,
      renderOrder: 3,
    })
    chinaTopLine.lineGroup.position.z += 0.01
    return { china, chinaTopLine }
  }
  createTradeNationalMap() {
    if (this.tradeNationalMapGroup) return true
    let chinaMapData = null
    try {
      chinaMapData = normalizeMapGeoJsonData(this.assets.instance.getResource("chinaMap"))
    } catch (error) {
      console.warn("牦牛交易全国地图资源未加载", error)
      return false
    }
    const chinaMapJson = chinaMapData
    if (!chinaMapData || !chinaMapJson?.features?.length) return false
    const nationalGroup = new Group()
    nationalGroup.name = "trade-national-map"
    nationalGroup.visible = false
    nationalGroup.rotation.x = -Math.PI / 2
    nationalGroup.position.set(0, 0.04, 0)

    const shadowMaterial = new MeshBasicMaterial({
      color: 0x062639,
      transparent: true,
      opacity: 0.14,
      side: DoubleSide,
      depthWrite: false,
      fog: false,
    })
    const nationalShadow = new BaseMap(this, {
      data: chinaMapData,
      geoProjectionCenter: this.tradeGeoProjectionCenter,
      geoProjectionScale: this.tradeGeoProjectionScale,
      position: new Vector3(0.05, -0.05, -0.03),
      material: shadowMaterial,
      renderOrder: 17,
    })
    nationalShadow.setParent(nationalGroup)

    const { topMaterial, sideMaterial } = this.createTradeNationalMaterials()
    const nationalMap = new ExtrudeMap(this, {
      data: chinaMapData,
      geoProjectionCenter: this.tradeGeoProjectionCenter,
      geoProjectionScale: this.tradeGeoProjectionScale,
      position: new Vector3(0, 0, 0),
      depth: this.tradeNationalDepth,
      topFaceMaterial: topMaterial,
      sideMaterial,
      renderOrder: 20,
      bevelThickness: 0.024,
      bevelSegments: 1,
    })
    nationalMap.mapGroup.traverse((object) => {
      if (object.isMesh) object.renderOrder = 20
    })
    nationalMap.setParent(nationalGroup)

    const lineMaterial = new LineBasicMaterial({
      color: 0x48e7ff,
      transparent: true,
      opacity: 0.52,
      fog: false,
      depthTest: false,
    })
    const nationalLine = new Line(this, {
      data: chinaMapData,
      geoProjectionCenter: this.tradeGeoProjectionCenter,
      geoProjectionScale: this.tradeGeoProjectionScale,
      material: lineMaterial,
      renderOrder: 21,
    })
    nationalLine.lineGroup.position.z += this.tradeNationalDepth + 0.055
    nationalLine.setParent(nationalGroup)

    this.scene.add(nationalGroup)
    this.tradeNationalMapGroup = nationalGroup
    this.createTradeNationalLabels(chinaMapJson)
    return true
  }
  createTradeNationalMaterials() {
    const topMaterial = new MeshStandardMaterial({
      color: 0xffffff,
      transparent: true,
      opacity: 0.54,
      roughness: 0.84,
      metalness: 0.08,
      side: DoubleSide,
      fog: false,
    })
    new GradientShader(topMaterial, {
      uColor1: 0x0c9fb5,
      uColor2: 0x06283b,
      size: 6.8,
      dir: "x",
    })

    const sideMap = this.assets.instance.getResource("side").clone()
    sideMap.wrapS = RepeatWrapping
    sideMap.wrapT = RepeatWrapping
    sideMap.repeat.set(1, 1.2)
    sideMap.needsUpdate = true
    const sideMaterial = new MeshStandardMaterial({
      color: 0x16aabd,
      map: sideMap,
      transparent: true,
      opacity: 0.58,
      roughness: 0.78,
      metalness: 0.04,
      side: DoubleSide,
      fog: false,
    })
    new GradientShader(sideMaterial, {
      uColor1: 0x14a9be,
      uColor2: 0x020f1c,
      size: Math.max(this.tradeNationalDepth, 0.1),
      dir: "y",
    })
    this.time.on("tick", () => {
      sideMap.offset.y += 0.004
    })
    return { topMaterial, sideMaterial }
  }
  createTradeNationalLabels(chinaMapData) {
    this.tradeNationalLabelGroup = new Group()
    this.tradeNationalLabelGroup.name = "trade-national-label"
    this.tradeNationalLabelGroup.rotation.x = -Math.PI / 2
    this.tradeNationalLabelGroup.visible = false
    this.scene.add(this.tradeNationalLabelGroup)
    this.tradeNationalLabels = []
    const visibleNames = new Set(["四川", "青海", "甘肃", "陕西", "重庆", "云南", "西藏", "新疆"])
    ;(chinaMapData.features || []).forEach((feature) => {
      const name = feature.properties?.name || ""
      const displayName = this.normalizeTradeProvinceName(name)
      if (!visibleNames.has(displayName)) return
      const center = this.getFeatureCenterLngLat(feature)
      if (!center) return
      const label = this.label3d.create("", "trade-national-label", true)
      label.init(`<div class="trade-national-name">${escapeHtml(displayName)}</div>`, this.getTradeLocalPoint(center.lng, center.lat, 0.82))
      this.label3d.setLabelStyle(label, 0.012, "x")
      label.setParent(this.tradeNationalLabelGroup)
      label.visible = true
      this.tradeNationalLabels.push(label)
    })
  }
  normalizeTradeProvinceName(name = "") {
    return String(name)
      .replace(/省|市|自治区|壮族|回族|维吾尔|特别行政区/g, "")
      .trim()
  }
  getFeatureCenterLngLat(feature) {
    const center = feature?.properties?.cp || feature?.properties?.centroid || feature?.properties?.center
    if (Array.isArray(center) && Number.isFinite(Number(center[0])) && Number.isFinite(Number(center[1]))) {
      return { lng: Number(center[0]), lat: Number(center[1]) }
    }
    const coordinates = this.getGeometryCoordinateList(feature?.geometry)
    if (!coordinates.length) return null
    const bounds = coordinates.reduce(
      (result, coord) => ({
        minLng: Math.min(result.minLng, Number(coord[0])),
        maxLng: Math.max(result.maxLng, Number(coord[0])),
        minLat: Math.min(result.minLat, Number(coord[1])),
        maxLat: Math.max(result.maxLat, Number(coord[1])),
      }),
      { minLng: Infinity, maxLng: -Infinity, minLat: Infinity, maxLat: -Infinity }
    )
    return {
      lng: (bounds.minLng + bounds.maxLng) / 2,
      lat: (bounds.minLat + bounds.maxLat) / 2,
    }
  }
  createProvince() {
    let mapJsonData = this.staticMapData.mapJson
    this.mapJsonData = mapJsonData
    const satellite = createHongyuanSatelliteTexture({
      geoProjectionCenter: this.geoProjectionCenter,
      geoProjectionScale: this.geoProjectionScale,
      zoom: SATELLITE_TEXTURE_ZOOM,
      tileUrlTemplate: this.tileUrlTemplate,
      onTileStatus: (status) => this.callbacks.onTileStatus?.(status),
    })
    this.enhanceTexture(satellite.texture)
    let [topMaterial, sideMaterial] = this.createProvinceMaterial()
    this.focusMapTopMaterial = topMaterial
    this.focusMapSideMaterial = sideMaterial
    let map = new ExtrudeMap(this, {
      geoProjectionCenter: this.geoProjectionCenter,
      geoProjectionScale: this.geoProjectionScale,
      position: new Vector3(0, 0, 0.11),
      data: mapJsonData,
      depth: this.depth,
      topFaceMaterial: topMaterial,
      sideMaterial: sideMaterial,
      renderOrder: 9,
    })
    let faceMaterial = new MeshStandardMaterial({
      color: 0xffffff,
      map: satellite.texture,
      transparent: true,
      opacity: this.usesCustomSatelliteTiles ? 1 : 0.94,
      roughness: this.usesCustomSatelliteTiles ? 0.7 : 0.82,
      // fog: false,
    })
    if (!this.usesCustomSatelliteTiles) {
      new GradientShader(faceMaterial, {
        // uColor1: 0x2a6e92,
        // uColor2: 0x102736,
        uColor1: 0x18dce2,
        uColor2: 0x0b8fa9,
      })
    }
    this.defaultMaterial = faceMaterial
    this.defaultLightMaterial = this.defaultMaterial.clone()
    this.defaultLightMaterial.color = new Color("rgba(128,244,255,1)")
    this.defaultLightMaterial.opacity = 0.9
    // this.defaultLightMaterial.emissive.setHex(new Color("rgba(115,208,255,1)"));
    // this.defaultLightMaterial.emissiveIntensity = 3.5;
    let mapTop = new BaseMap(this, {
      geoProjectionCenter: this.geoProjectionCenter,
      geoProjectionScale: this.geoProjectionScale,
      position: new Vector3(0, 0, this.depth + 0.22),
      data: mapJsonData,
      material: faceMaterial,
      renderOrder: this.usesCustomSatelliteTiles ? 14 : 8,
    })
    this.satelliteMapTop = mapTop
    this.satelliteMaterial = faceMaterial
    applyMapTextureUv(mapTop, satellite.uvBounds)
    mapTop.mapGroup.children.map((group) => {
      group.children.map((mesh) => {
        if (mesh.type === "Mesh") {
          this.eventElement.push(mesh)
        }
      })
    })
    this.mapLineMaterial = new LineBasicMaterial({
      color: 0xbafcff,
      opacity: 0,
      transparent: true,
      linewidth: this.adminBoundaryLineScale,
      fog: false,
    })
    let mapLine = new Line(this, {
      geoProjectionCenter: this.geoProjectionCenter,
      geoProjectionScale: this.geoProjectionScale,
      data: mapJsonData,
      material: this.mapLineMaterial,
      renderOrder: 3,
    })
    mapLine.lineGroup.position.z += this.depth + 0.23
    let mapBoundaryEmphasis = null
    const emphasisRadius = this.getAdminBoundaryEmphasisRadius()
    if (emphasisRadius > 0) {
      this.mapBoundaryEmphasisMaterial = new MeshBasicMaterial({
        color: 0x92fbff,
        transparent: true,
        opacity: 0,
        depthTest: false,
        depthWrite: false,
        fog: false,
        blending: AdditiveBlending,
      })
      mapBoundaryEmphasis = new Line(this, {
        geoProjectionCenter: this.geoProjectionCenter,
        geoProjectionScale: this.geoProjectionScale,
        data: mapJsonData,
        material: this.mapBoundaryEmphasisMaterial,
        renderOrder: this.usesCustomSatelliteTiles ? 24 : 18,
        type: "Line3",
        tubeRadius: emphasisRadius,
      })
      mapBoundaryEmphasis.lineGroup.position.z += this.depth + 0.255
    }
    return {
      map,
      mapTop,
      mapLine,
      mapBoundaryEmphasis,
    }
  }
  normalizeAdminBoundaryLineScale(value) {
    const scale = Number(value)
    if (!Number.isFinite(scale)) return 1
    return Math.min(4, Math.max(1, scale))
  }
  getAdminBoundaryEmphasisRadius() {
    if (this.adminBoundaryLineScale <= 1) return 0
    return Math.min(0.04, 0.011 * this.adminBoundaryLineScale)
  }
  getAdminBoundaryEmphasisOpacity() {
    if (this.adminBoundaryLineScale <= 1) return 0
    return Math.min(0.5, 0.2 + this.adminBoundaryLineScale * 0.08)
  }
  createTopicLayer() {
    if (!this.mapJsonData) return
    this.initHongyuanBoundaryPolygons()
    const canvas = document.createElement("canvas")
    canvas.width = WMS_IMAGE_SIZE.width
    canvas.height = WMS_IMAGE_SIZE.height
    const context = canvas.getContext("2d")
    context.clearRect(0, 0, canvas.width, canvas.height)

    const texture = new CanvasTexture(canvas)
    texture.colorSpace = SRGBColorSpace
    texture.wrapS = ClampToEdgeWrapping
    texture.wrapT = ClampToEdgeWrapping
    texture.minFilter = LinearFilter
    texture.magFilter = LinearFilter
    this.enhanceTexture(texture)
    texture.needsUpdate = true

    const material = new MeshBasicMaterial({
      map: texture,
      transparent: true,
      opacity: 0,
      depthWrite: false,
      side: DoubleSide,
      fog: false,
    })

    const layerUvBounds = this.createLayerUvBounds()
    const layerMap = new BaseMap(this, {
      geoProjectionCenter: this.geoProjectionCenter,
      geoProjectionScale: this.geoProjectionScale,
      position: new Vector3(0, 0, this.depth + 0.255),
      data: this.mapJsonData,
      material,
      renderOrder: 18,
    })
    applyMapTextureUv(layerMap, layerUvBounds)
    layerMap.setParent(this.focusMapGroup)

    this.topicLayerUvBounds = layerUvBounds
    this.topicLayerCanvas = canvas
    this.topicLayerContext = context
    this.topicLayerTexture = texture
    this.topicLayerMaterial = material
    this.topicLayerMap = layerMap
  }
  initHongyuanBoundaryPolygons() {
    if (this.hongyuanBoundaryPolygons?.length) return
    const boundaryData = this.staticMapData.mapStroke
    this.hongyuanBoundaryPolygons = getFeaturePolygons(boundaryData?.features?.[0]?.geometry)
  }
  createSelectedFeatureGroup() {
    if (this.selectedFeatureGroup) return
    this.selectedFeatureGroup = new Group()
    this.selectedFeatureGroup.name = "selected-feature-highlight"
    this.selectedFeatureGroup.position.z = this.depth + 0.31
    this.focusMapGroup?.add(this.selectedFeatureGroup)
  }
  createLayerUvBounds() {
    const projection = geoMercator()
      .center(this.geoProjectionCenter)
      .scale(this.geoProjectionScale)
      .translate([0, 0])
    const [left] = projection([HONGYUAN_BOUNDS.west, HONGYUAN_BOUNDS.south])
    const [right] = projection([HONGYUAN_BOUNDS.east, HONGYUAN_BOUNDS.south])
    const [, projectedNorth] = projection([HONGYUAN_BOUNDS.west, HONGYUAN_BOUNDS.north])
    const [, projectedSouth] = projection([HONGYUAN_BOUNDS.west, HONGYUAN_BOUNDS.south])
    return {
      left,
      right,
      bottom: -projectedSouth,
      top: -projectedNorth,
    }
  }
  setTopicLayer(layer) {
    if (!layer || !this.topicLayerContext || !this.topicLayerTexture) return
    this.currentTopicLayer = layer
    this.visibleTopicLayers = [layer]
    this.clearSelectedFeature()
    this.applyLayerMapView(layer)
    const loadId = ++this.topicLayerLoadId
    this.currentVectorFeatures = []
    this.clearYakIndustryPointMarkers()
    this.clearYakIndustryPointLabels()
    this.topicLayerContext.clearRect(0, 0, this.topicLayerCanvas.width, this.topicLayerCanvas.height)
    this.topicLayerTexture.needsUpdate = true
    this.topicLayerMaterial.opacity = 0
    this.callbacks.onLayerStatus?.({
      type: "loading",
      text: `正在加载 ${layer.name}`,
    })

    if (isVectorTopicLayer(layer)) {
      this.loadVectorTopicLayer(layer, loadId)
      return
    }

    const image = new Image()
    image.crossOrigin = "anonymous"
    image.onload = () => {
      if (loadId !== this.topicLayerLoadId) return
      this.topicLayerContext.clearRect(0, 0, this.topicLayerCanvas.width, this.topicLayerCanvas.height)
      this.topicLayerContext.drawImage(image, 0, 0, this.topicLayerCanvas.width, this.topicLayerCanvas.height)
      this.topicLayerTexture.needsUpdate = true
      this.topicLayerMaterial.opacity = layer.opacity ?? 0.84
      this.callbacks.onLayerStatus?.({
        type: "success",
        text: "GeoServer 图层已加载",
      })
    }
    image.onerror = () => {
      if (loadId !== this.topicLayerLoadId) return
      this.topicLayerContext.clearRect(0, 0, this.topicLayerCanvas.width, this.topicLayerCanvas.height)
      this.topicLayerTexture.needsUpdate = true
      this.topicLayerMaterial.opacity = 0
      this.callbacks.onLayerStatus?.({
        type: "error",
        text: "GeoServer 图层加载失败",
      })
    }
    image.src = buildWmsGetMapUrl(layer)
  }
  setTopicLayers(layers = [], activeLayer = layers[0]) {
    if (!this.topicLayerContext || !this.topicLayerTexture) return
    const validLayers = layers.filter(Boolean)
    if (!validLayers.length) {
      this.topicLayerLoadId += 1
      this.visibleTopicLayers = []
      this.currentTopicLayer = null
      this.currentVectorFeatures = []
      this.clearYakIndustryPointMarkers()
      this.clearYakIndustryPointLabels()
      this.clearSelectedFeature()
      this.applyLayerMapView(null)
      this.topicLayerContext.clearRect(0, 0, this.topicLayerCanvas.width, this.topicLayerCanvas.height)
      this.topicLayerTexture.needsUpdate = true
      this.topicLayerMaterial.opacity = 0
      this.callbacks.onLayerStatus?.({
        type: "success",
        text: "图层已关闭",
      })
      return
    }
    if (validLayers.length === 1) {
      this.setTopicLayer({ ...validLayers[0], ...(activeLayer?.key === validLayers[0].key ? activeLayer : {}) })
      return
    }
    this.currentTopicLayer = activeLayer || validLayers[validLayers.length - 1]
    this.visibleTopicLayers = validLayers
    this.clearSelectedFeature()
    this.clearYakIndustryPointMarkers()
    this.clearYakIndustryPointLabels()
    this.applyLayerMapView(this.currentTopicLayer)
    const loadId = ++this.topicLayerLoadId
    this.currentVectorFeatures = []
    this.topicLayerContext.clearRect(0, 0, this.topicLayerCanvas.width, this.topicLayerCanvas.height)
    this.topicLayerTexture.needsUpdate = true
    this.topicLayerMaterial.opacity = 0
    this.callbacks.onLayerStatus?.({
      type: "loading",
      text: `正在加载 ${validLayers.length} 个林地图层`,
    })
    Promise.all(validLayers.map((layer) => this.loadCompositeTopicLayerItem(layer)))
      .then((items) => {
        if (loadId !== this.topicLayerLoadId) return
        const loadedItems = items.filter(Boolean)
        const activeVectorItem = loadedItems.find((item) => item.type === "vector" && item.layer?.key === this.currentTopicLayer?.key)
        this.currentVectorFeatures = activeVectorItem?.geojson?.features || []
        this.topicLayerContext.clearRect(0, 0, this.topicLayerCanvas.width, this.topicLayerCanvas.height)
        loadedItems.forEach((item, index) => {
          if (item.type === "image") {
            this.topicLayerContext.save()
            const opacity = index === loadedItems.length - 1
              ? Math.max(0.78, item.layer.opacity ?? 0.9)
              : Math.min(0.52, item.layer.opacity ?? 0.52)
            this.topicLayerContext.globalAlpha = Math.min(1, Math.max(0.28, opacity))
            this.topicLayerContext.drawImage(item.image, 0, 0, this.topicLayerCanvas.width, this.topicLayerCanvas.height)
            this.topicLayerContext.restore()
            return
          }
          this.drawVectorTopicLayer(item.layer, item.geojson.features || [], { clear: false })
        })
        this.updateYakIndustryPointMarkers(this.currentTopicLayer, this.currentVectorFeatures)
        this.updateYakIndustryPointLabels(this.currentTopicLayer, this.currentVectorFeatures)
        this.topicLayerTexture.needsUpdate = true
        this.topicLayerMaterial.opacity = loadedItems.length ? 1 : 0
        this.callbacks.onLayerStatus?.({
          type: loadedItems.length ? "success" : "error",
          text: loadedItems.length ? `已加载 ${loadedItems.length} 个林地图层` : "林地图层加载失败",
        })
      })
  }
  async loadCompositeTopicLayerItem(layer) {
    if (isVectorTopicLayer(layer)) {
      try {
        const geojson = await this.fetchVectorLayerGeojson(layer)
        return { type: "vector", layer, geojson }
      } catch (error) {
        return null
      }
    }
    return this.loadTopicLayerImage(layer).then((item) => (item ? { type: "image", ...item } : null))
  }
  loadTopicLayerImage(layer) {
    return new Promise((resolve) => {
      const image = new Image()
      image.crossOrigin = "anonymous"
      image.onload = () => resolve({ image, layer })
      image.onerror = () => resolve(null)
      image.src = buildWmsGetMapUrl(layer)
    })
  }
  applyLayerMapView(layer, options = {}) {
    if (!this.camera?.instance || !this.camera?.controls) return
    if (!this.mapAnimationComplete && !options.force) return
    const ranking = layer ? null : this.currentTownRanking
    const view = layer?.mapView
      || (layer?.cameraPosition ? { cameraPosition: layer.cameraPosition } : null)
      || ranking?.mapView
      || (ranking?.cameraPosition ? { cameraPosition: ranking.cameraPosition } : null)
    const target = view ? this.getLayerMapViewTarget(view) : this.defaultMapTarget.clone()
    const position = view ? this.getLayerMapViewCameraPosition(view, target) : this.defaultMapCameraPosition.clone()
    const duration = view && Number.isFinite(view.duration) ? view.duration : 0.75
    this.animateMapCamera(position, target, duration)
  }
  getFocusMapTopOpacity() {
    return this.usesCustomSatelliteTiles ? 0.16 : 1
  }
  getDynamicSatelliteZoom(distance) {
    if (!this.usesCustomSatelliteTiles) return SATELLITE_TEXTURE_ZOOM
    if (distance <= 4.2) return Math.min(this.dynamicSatelliteMaxZoom, 21)
    if (distance <= 6.4) return Math.min(this.dynamicSatelliteMaxZoom, 19)
    if (distance <= 8.4) return Math.min(this.dynamicSatelliteMaxZoom, 17)
    if (distance <= 11) return Math.min(this.dynamicSatelliteMaxZoom, 15)
    if (distance <= 14.5) return Math.min(this.dynamicSatelliteMaxZoom, 13)
    if (distance <= 18) return Math.min(this.dynamicSatelliteMaxZoom, 11)
    return SATELLITE_TEXTURE_ZOOM
  }
  getCameraTargetLngLat() {
    const target = this.camera?.controls?.target?.clone?.()
    if (!target) return [102.58, 32.72]
    this.focusMapGroup?.updateWorldMatrix?.(true, false)
    const local = this.focusMapGroup ? this.focusMapGroup.worldToLocal(target) : target
    const projection = geoMercator()
      .center(this.geoProjectionCenter)
      .scale(this.geoProjectionScale)
      .translate([0, 0])
    const lngLat = projection.invert?.([local.x, -local.y]) || [102.58, 32.72]
    return [
      Math.max(HONGYUAN_BOUNDS.west, Math.min(HONGYUAN_BOUNDS.east, Number(lngLat[0]) || 102.58)),
      Math.max(HONGYUAN_BOUNDS.south, Math.min(HONGYUAN_BOUNDS.north, Number(lngLat[1]) || 32.72)),
    ]
  }
  getDynamicSatelliteTileWindow(zoom) {
    if (zoom >= 21) return 3
    if (zoom >= 19) return 5
    if (zoom >= 17) return 7
    if (zoom >= 15) return this.dynamicSatelliteTileWindow
    if (zoom >= 13) return Math.min(11, this.dynamicSatelliteTileWindow + 2)
    return Math.min(11, this.dynamicSatelliteTileWindow + 2)
  }
  getDynamicSatelliteKey(zoom, tileRange) {
    if (!tileRange) return ""
    return `${zoom}:${tileRange.minX}-${tileRange.maxX}:${tileRange.minY}-${tileRange.maxY}`
  }
  updateDynamicSatelliteTiles(force = false) {
    if (!this.usesCustomSatelliteTiles || !this.mapAnimationComplete) return
    const now = Date.now()
    if (!force && now - this.dynamicSatelliteLastCheck < DYNAMIC_SATELLITE_CHECK_INTERVAL) return
    this.dynamicSatelliteLastCheck = now
    const distance = this.camera.instance.position.distanceTo(this.camera.controls.target)
    const zoom = this.getDynamicSatelliteZoom(distance)
    if (zoom <= SATELLITE_TEXTURE_ZOOM) {
      this.hideDynamicSatelliteOverlay()
      return
    }
    const centerLngLat = this.getCameraTargetLngLat()
    const tileWindow = this.getDynamicSatelliteTileWindow(zoom)
    const tileRange = getHongyuanSatelliteTileRange({
      zoom,
      centerLngLat,
      maxCols: tileWindow,
      maxRows: tileWindow,
    })
    const key = this.getDynamicSatelliteKey(zoom, tileRange)
    if (!key || key === this.dynamicSatelliteCurrentKey || key === this.dynamicSatelliteLoadingKey) return
    this.loadDynamicSatelliteOverlay({ zoom, centerLngLat, maxTiles: tileWindow, key })
  }
  loadDynamicSatelliteOverlay({ zoom, centerLngLat, maxTiles, key }) {
    const requestId = ++this.dynamicSatelliteRequestId
    this.dynamicSatelliteLoadingKey = key
    let satellite
    satellite = createHongyuanSatelliteTexture({
      geoProjectionCenter: this.geoProjectionCenter,
      geoProjectionScale: this.geoProjectionScale,
      zoom,
      centerLngLat,
      maxCols: maxTiles,
      maxRows: maxTiles,
      tileUrlTemplate: this.tileUrlTemplate,
      transparentBackground: true,
      applyTone: false,
      onTileStatus: (status) => {
        this.callbacks.onTileStatus?.({
          ...status,
          text: status.text?.replace("影像底图", "高清影像底图") || status.text,
        })
        if (status.type === "loading") return
        if (requestId !== this.dynamicSatelliteRequestId) {
          satellite?.texture?.dispose?.()
          return
        }
        this.dynamicSatelliteLoadingKey = ""
        if (Number(status.loaded || 0) <= 0) {
          if (this.dynamicSatelliteCurrentKey === key) this.hideDynamicSatelliteOverlay()
          return
        }
        this.applyDynamicSatelliteOverlay(satellite, key)
      },
    })
    this.applyDynamicSatelliteOverlay(satellite, key)
  }
  ensureDynamicSatelliteOverlay() {
    if (this.dynamicSatelliteOverlay) return
    const material = new MeshBasicMaterial({
      transparent: true,
      opacity: 0,
      depthWrite: false,
      depthTest: true,
      side: DoubleSide,
      fog: false,
    })
    const mesh = new Mesh(new PlaneGeometry(1, 1), material)
    mesh.name = "dynamic-satellite-overlay"
    mesh.renderOrder = 15
    mesh.visible = false
    mesh.position.z = this.depth + 0.248
    this.focusMapGroup?.add(mesh)
    this.dynamicSatelliteOverlay = mesh
    this.dynamicSatelliteMaterial = material
  }
  applyDynamicSatelliteOverlay(satellite, key) {
    if (!satellite?.texture || !satellite?.uvBounds) return
    this.ensureDynamicSatelliteOverlay()
    const bounds = satellite.uvBounds
    const width = Math.max(0.0001, bounds.right - bounds.left)
    const height = Math.max(0.0001, bounds.top - bounds.bottom)
    this.dynamicSatelliteOverlay.position.set(
      (bounds.left + bounds.right) / 2,
      (bounds.bottom + bounds.top) / 2,
      this.depth + 0.248,
    )
    this.dynamicSatelliteOverlay.scale.set(width, height, 1)
    this.dynamicSatelliteOverlay.visible = true
    if (this.dynamicSatelliteTexture && this.dynamicSatelliteTexture !== satellite.texture) {
      this.dynamicSatelliteTexture.dispose?.()
    }
    this.dynamicSatelliteTexture = satellite.texture
    this.dynamicSatelliteMaterial.map = satellite.texture
    this.dynamicSatelliteMaterial.opacity = 1
    this.dynamicSatelliteMaterial.needsUpdate = true
    this.dynamicSatelliteCurrentKey = key
  }
  hideDynamicSatelliteOverlay() {
    if (this.dynamicSatelliteOverlay) this.dynamicSatelliteOverlay.visible = false
    if (this.dynamicSatelliteLoadingKey) {
      this.dynamicSatelliteRequestId += 1
      this.dynamicSatelliteLoadingKey = ""
    }
    this.dynamicSatelliteCurrentKey = ""
  }
  ensureMapRevealed() {
    if (this.mapAnimationComplete) return
    this.animateTl?.progress?.(1, false)
    this.animateTl?.pause?.()
    this.mapAnimationComplete = true
    if (this.focusMapGroup) {
      this.focusMapGroup.position.set(0, 0, 0)
      this.focusMapGroup.scale.set(1, 1, 1)
    }
    if (this.focusMapTopMaterial) this.focusMapTopMaterial.opacity = this.getFocusMapTopOpacity()
    if (this.focusMapSideMaterial) {
      this.focusMapSideMaterial.opacity = 1
      this.focusMapSideMaterial.transparent = false
    }
    if (this.mapGroup) this.mapGroup.visible = true
    if (this.labelGroup) this.labelGroup.visible = true
    if (this.flyLineGroup) this.flyLineGroup.visible = true
    if (this.flyLineFocusGroup) this.flyLineFocusGroup.visible = true
    if (this.scatterGroup) this.scatterGroup.visible = true
    if (this.InfoPointGroup) this.InfoPointGroup.visible = this.showTownRankingPointMarkers && this.hasTownRankingRows()
    this.applyLayerMapView(this.currentTopicLayer, { force: true })
    this.updateDynamicSatelliteTiles(true)
    this.updateYakIndustryPointMarkers(this.currentTopicLayer, this.currentVectorFeatures)
    this.updateYakIndustryPointLabels(this.currentTopicLayer, this.currentVectorFeatures)
    if (shouldHideMapLabels(this.currentTownRanking)) {
      this.hideAllMapStatisticLabels()
      return
    }
    this.updateVillageRankingLabels(this.currentTownRanking)
    this.updateTradeFlowLayer(this.currentTownRanking)
    this.updateInfoPointStats()
  }
  getLayerMapViewTarget(view) {
    if (view?.targetLngLat) {
      return this.lngLatToWorldPoint(view.targetLngLat, view.targetLocalZ ?? 0.72)
    }
    if (view?.target) return this.toVector3(view.target, this.defaultMapTarget)
    return this.defaultMapTarget.clone()
  }
  getLayerMapViewCameraPosition(view, target) {
    if (view?.cameraPosition) return this.toVector3(view.cameraPosition, this.defaultMapCameraPosition)
    const scale = Number.isFinite(view?.distanceScale) ? view.distanceScale : 1
    const offset = this.defaultMapCameraPosition.clone().sub(this.defaultMapTarget).multiplyScalar(scale)
    const position = target.clone().add(offset)
    if (view?.cameraOffset) {
      position.add(this.toVector3(view.cameraOffset, new Vector3(0, 0, 0)))
    }
    return position
  }
  lngLatToWorldPoint(lngLat, localZ = 0) {
    const coordinate = Array.isArray(lngLat) ? lngLat : [lngLat?.lng, lngLat?.lat]
    const lng = Number(coordinate?.[0])
    const lat = Number(coordinate?.[1])
    if (!Number.isFinite(lng) || !Number.isFinite(lat)) return this.defaultMapTarget.clone()
    const [x, y] = this.geoProjection([lng, lat])
    const localPoint = new Vector3(x, -y, Number(localZ) || 0)
    return this.focusMapGroup ? this.focusMapGroup.localToWorld(localPoint) : localPoint
  }
  toVector3(value, fallback) {
    if (Array.isArray(value)) {
      const [x, y, z] = value.map(Number)
      if ([x, y, z].every(Number.isFinite)) return new Vector3(x, y, z)
      return fallback.clone()
    }
    const x = Number(value?.x)
    const y = Number(value?.y)
    const z = Number(value?.z)
    if ([x, y, z].every(Number.isFinite)) return new Vector3(x, y, z)
    return fallback.clone()
  }
  animateMapCamera(position, target, duration = 0.75) {
    this.activeMapViewTweens.forEach((tween) => tween?.kill?.())
    this.activeMapViewTweens = []
    const controls = this.camera.controls
    const applyUpdate = () => controls.update()
    this.activeMapViewTweens.push(
      gsap.to(this.camera.instance.position, {
        duration,
        x: position.x,
        y: position.y,
        z: position.z,
        ease: "power2.out",
        onUpdate: applyUpdate,
      }),
      gsap.to(controls.target, {
        duration,
        x: target.x,
        y: target.y,
        z: target.z,
        ease: "power2.out",
        onUpdate: applyUpdate,
      })
    )
  }
  addSceneTween(tween) {
    if (!tween) return tween
    this.sceneTweens.push(tween)
    if (this.scenePaused) tween.pause?.()
    return tween
  }
  addSceneTimer(timer) {
    if (timer) this.sceneTimers.push(timer)
    return timer
  }
  focusTownByName(townName, options = {}) {
    const townMeta = this.townMetaByName.get(normalizeTownName(townName))
    if (!townMeta?.center) return false
    const target = this.lngLatToWorldPoint(townMeta.center, options.targetLocalZ ?? 0.82)
    const position = this.getLayerMapViewCameraPosition({
      distanceScale: options.distanceScale ?? 0.82,
      cameraOffset: options.cameraOffset || { x: 0, y: 1.2, z: 0 },
    }, target)
    this.animateMapCamera(position, target, options.duration ?? 0.75)
    return true
  }
  focusYakIndustryPoint(payload = {}) {
    const coordinate = this.getYakIndustryPayloadCoordinate(payload)
    if (!coordinate) {
      this.callbacks.onLayerStatus?.({
        type: "warning",
        text: "当前主体暂无可定位坐标",
      })
      return { focused: false, screen: null }
    }
    const feature = this.findYakIndustryPointFeature(payload, coordinate)
    if (feature) {
      this.setSelectedVectorFeatures([feature])
    } else {
      this.clearSelectedFeature()
    }
    const focused = this.flyToLngLat(coordinate, {
      span: 0.035,
      duration: 0.72,
      statusText: "已定位产业主体",
    })
    return {
      focused,
      screen: this.getYakIndustryPointScreen(payload),
      feature,
    }
  }
  getYakIndustryPointScreen(payload = {}) {
    const coordinate = this.getYakIndustryPayloadCoordinate(payload)
    if (!coordinate) return null
    return this.lngLatToLayerScreenPoint(coordinate, this.depth + 0.36)
      || this.lngLatToScreenPoint(coordinate, this.depth + 0.32)
  }
  getYakIndustryPayloadCoordinate(payload = {}) {
    const coordinates = payload.geometry?.coordinates || payload.coordinates
    const lng = Number(payload.longitude ?? payload.lng ?? coordinates?.[0])
    const lat = Number(payload.latitude ?? payload.lat ?? coordinates?.[1])
    if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null
    return [lng, lat]
  }
  findYakIndustryPointFeature(payload = {}, coordinate = this.getYakIndustryPayloadCoordinate(payload)) {
    const sourceFeatures = this.getVisibleVectorFeatures(this.currentTopicLayer, this.currentVectorFeatures || [])
    const id = String(payload.id || payload.key || "")
    const name = String(payload.fullName || payload.name || "").trim()
    const stageKey = String(payload.stageKey || "")
    const matched = sourceFeatures.find((feature) => {
      const properties = feature?.properties || {}
      if (id && String(properties.id || "") === id) return true
      if (name && String(properties.name || properties.fullName || "").trim() === name) return true
      return false
    })
    if (matched) return matched
    if (!coordinate) return null
    return sourceFeatures.find((feature) => {
      if (feature?.geometry?.type !== "Point") return false
      const properties = feature.properties || {}
      if (stageKey && String(properties.stageKey || "") !== stageKey) return false
      return this.getCoordinateDistance(feature.geometry.coordinates, coordinate) <= 0.0008
    }) || null
  }
  async loadVectorTopicLayer(layer, loadId) {
    try {
      const geojson = await this.fetchVectorLayerGeojson(layer)
      if (loadId !== this.topicLayerLoadId) return
      this.currentVectorFeatures = geojson.features || []
      this.drawVectorTopicLayer(layer, this.currentVectorFeatures)
      this.updateYakIndustryPointMarkers(layer, this.currentVectorFeatures)
      this.updateYakIndustryPointLabels(layer, this.currentVectorFeatures)
      this.topicLayerTexture.needsUpdate = true
      this.topicLayerMaterial.opacity = layer.opacity ?? 0.9
      this.callbacks.onLayerStatus?.({
        type: "success",
        text: "真实矢量图层已加载",
      })
    } catch (error) {
      if (loadId !== this.topicLayerLoadId) return
      this.currentVectorFeatures = []
      this.clearYakIndustryPointMarkers()
      this.clearYakIndustryPointLabels()
      this.topicLayerContext.clearRect(0, 0, this.topicLayerCanvas.width, this.topicLayerCanvas.height)
      this.topicLayerTexture.needsUpdate = true
      this.topicLayerMaterial.opacity = 0
      this.callbacks.onLayerStatus?.({
        type: "error",
        text: "真实矢量图层加载失败",
      })
    }
  }
  async fetchVectorLayerGeojson(layer) {
    if (layer.sourceType === "geojson") {
      if (layer.geojsonData?.type === "FeatureCollection") return layer.geojsonData
      const response = await fetch(buildPublicUrl(layer.dataPath), { credentials: "include" })
      if (!response.ok) throw new Error(`HTTP ${response.status}`)
      return await response.json()
    }
    if (layer.sourceType === "wfs") {
      const response = await getWfsFeatureProperties(layer.layerName, {
        cqlFilter: layer.cqlFilter,
        maxFeatures: layer.maxFeatures || 1200,
        timeout: layer.timeout || 18000,
      })
      return response?.type === "FeatureCollection"
        ? response
        : { type: "FeatureCollection", features: response?.features || [] }
    }
    let rows = []
    let apiError = null
    try {
      const response = await fetch(layer.apiUrl, { credentials: "include" })
      if (!response.ok) throw new Error(`HTTP ${response.status}`)
      const json = await response.json()
      rows = Array.isArray(json?.data) ? json.data : Array.isArray(json?.data?.rows) ? json.data.rows : []
    } catch (error) {
      apiError = error
    }
    let features = this.buildWktLayerFeatures(rows)
    if ((!rows.length || !features.length) && layer.fallbackPath) {
      const fallback = await fetch(buildPublicUrl(layer.fallbackPath), { credentials: "include" })
      if (!fallback.ok) throw apiError || new Error(`HTTP ${fallback.status}`)
      const json = await fallback.json()
      rows = Array.isArray(json?.rows) ? json.rows : []
      features = this.buildWktLayerFeatures(rows)
    }
    return {
      type: "FeatureCollection",
      features,
    }
  }
  buildWktLayerFeatures(rows = []) {
    return (rows || [])
      .map((row) => ({
        type: "Feature",
        properties: {
          id: row.id,
          type: row.type,
          totalAcreage: row.totalAcreage,
          cdAcreage: row.cdAcreage,
          sdAcreage: row.sdAcreage,
          cdScl: row.cdScl,
          sdScl: row.sdScl,
        },
        geometry: this.parseWktGeometry(this.pickWktValue(row)),
      }))
      .filter((feature) => feature.geometry)
  }
  pickWktValue(row = {}) {
    return row.wkt || row.WKT || row.geom || row.geometry || row.the_geom || row.theGeom || row.shape || row.Shape
  }
  drawVectorTopicLayer(layer, features, options = {}) {
    const context = this.topicLayerContext
    if (options.clear !== false) context.clearRect(0, 0, this.topicLayerCanvas.width, this.topicLayerCanvas.height)
    context.save()
    context.lineJoin = "round"
    context.lineCap = "round"
    this.clipVectorLayerContext(context, layer)
    const visibleFeatures = this.getVisibleVectorFeatures(layer, features)
    const sortedFeatures = visibleFeatures.sort((a, b) => {
      const aType = this.getGeometryDrawRank(a.geometry?.type)
      const bType = this.getGeometryDrawRank(b.geometry?.type)
      return aType - bType
    })
    sortedFeatures.forEach((feature) => {
      const style = this.getVectorFeatureStyle(layer, feature)
      this.drawVectorFeature(context, feature.geometry, style)
    })
    this.drawNationalParkBoundaryOverlay(context, layer, sortedFeatures)
    this.drawSelectedVectorOverlay(context)
    context.restore()
  }
  drawNationalParkBoundaryOverlay(context, layer, features = []) {
    if (layer?.key !== "national-park-zoning" || !features.length) return
    features.forEach((feature) => {
      const baseStyle = this.getVectorFeatureStyle(layer, feature)
      this.drawVectorFeature(context, feature.geometry, {
        ...baseStyle,
        fill: "#001D2C",
        stroke: "#001D2C",
        polygonAlpha: 0,
        lineAlpha: 0.58,
        lineWidth: 12,
        glow: true,
      })
      this.drawVectorFeature(context, feature.geometry, {
        ...baseStyle,
        fill: "#F8FFFF",
        stroke: "#F8FFFF",
        polygonAlpha: 0,
        lineAlpha: 0.96,
        lineWidth: 3.2,
        glow: false,
      })
    })
  }
  drawSelectedVectorOverlay(context) {
    const features = this.selectedVectorFeatures || []
    const analysisFeatures = this.analysisMatchedFeatures || []
    const boundary = this.analysisBoundaryFeature
    const drawPoints = this.boundaryDrawPoints || []
    if (!features.length && !analysisFeatures.length && !boundary && !drawPoints.length) return
    const quietProjectLayer = this.isEcologicalProjectLayer(this.currentTopicLayer)
    const glowStyle = {
      fill: SELECTED_FEATURE_FILL,
      stroke: SELECTED_FEATURE_GLOW,
      polygonAlpha: quietProjectLayer ? 0.2 : 0.28,
      lineAlpha: quietProjectLayer ? 0.78 : 0.98,
      lineWidth: quietProjectLayer ? 6 : 12,
      pointRadius: 11,
      glow: !quietProjectLayer,
      halo: !quietProjectLayer,
    }
    const focusStyle = {
      fill: SELECTED_FEATURE_FILL,
      stroke: SELECTED_FEATURE_STROKE,
      polygonAlpha: quietProjectLayer ? 0.34 : 0.52,
      lineAlpha: quietProjectLayer ? 0.94 : 1,
      lineWidth: quietProjectLayer ? 3.2 : 5,
      pointRadius: 6.5,
      glow: false,
      halo: false,
    }
    context.save()
    features.forEach((feature) => {
      const geometry = feature?.geometry || feature?.feature?.geometry
      this.drawVectorFeature(context, geometry, glowStyle)
      this.drawVectorFeature(context, geometry, focusStyle)
    })
    analysisFeatures.forEach((feature) => {
      const geometry = feature?.geometry || feature?.feature?.geometry
      this.drawVectorFeature(context, geometry, {
        fill: "#FF8A3D",
        stroke: "#FFE15A",
        polygonAlpha: quietProjectLayer ? 0.22 : 0.28,
        lineAlpha: quietProjectLayer ? 0.88 : 0.98,
        lineWidth: quietProjectLayer ? 2.8 : 5,
        pointRadius: 6,
        glow: !quietProjectLayer,
      })
    })
    if (boundary?.geometry) {
      this.drawVectorFeature(context, boundary.geometry, {
        fill: "#24F6C4",
        stroke: "#FFFFFF",
        polygonAlpha: quietProjectLayer ? 0.12 : 0.18,
        lineAlpha: quietProjectLayer ? 0.92 : 1,
        lineWidth: quietProjectLayer ? 3 : 4,
        pointRadius: 6,
        glow: !quietProjectLayer,
      })
    }
    if (drawPoints.length) {
      const coordinates = drawPoints.map((item) => [item.lng, item.lat])
      const lineGeometry = {
        type: "LineString",
        coordinates: this.boundaryDrawingEnabled ? coordinates : [...coordinates, coordinates[0]].filter(Boolean),
      }
      this.drawVectorFeature(context, lineGeometry, {
        fill: "#24F6C4",
        stroke: "#24F6C4",
        polygonAlpha: quietProjectLayer ? 0.08 : 0.12,
        lineAlpha: 1,
        lineWidth: quietProjectLayer ? 3 : 4,
        pointRadius: 6,
        glow: !quietProjectLayer,
      })
      coordinates.forEach((coordinate) => {
        this.drawVectorFeature(context, { type: "Point", coordinates: coordinate }, {
          fill: "#FFFFFF",
          stroke: "#24F6C4",
          polygonAlpha: 0.2,
          lineAlpha: 1,
          lineWidth: quietProjectLayer ? 2.4 : 3,
          pointRadius: 5.5,
          glow: !quietProjectLayer,
        })
      })
    }
    context.restore()
  }
  redrawVectorTopicLayer() {
    if (!this.topicLayerContext || !this.topicLayerTexture) return
    if (!this.currentTopicLayer || !isVectorTopicLayer(this.currentTopicLayer)) return
    this.drawVectorTopicLayer(this.currentTopicLayer, this.currentVectorFeatures || [])
    this.updateYakIndustryPointMarkers(this.currentTopicLayer, this.currentVectorFeatures)
    this.topicLayerTexture.needsUpdate = true
  }
  getVisibleVectorFeatures(layer, features) {
    const legend = layer?.activeLegend
    const match = legend?.match
    if (match?.field) {
      return (features || []).filter((feature) => String(feature?.properties?.[match.field] ?? "") === String(match.value))
    }
    const cqlFilter = String(layer?.cqlFilter || "").trim()
    const equalMatch = cqlFilter.match(/^([a-zA-Z0-9_]+)\s*=\s*'((?:''|[^'])*)'$/)
    if (equalMatch) {
      const [, field, value] = equalMatch
      const expected = value.replace(/''/g, "'")
      return (features || []).filter((feature) => String(feature?.properties?.[field] ?? "") === expected)
    }
    return [...(features || [])]
  }
  updateYakIndustryPointLabels(layer = this.currentTopicLayer, features = this.currentVectorFeatures) {
    this.clearYakIndustryPointLabels()
    if (layer?.showPointLabels === false) return
    if (layer?.key !== "yak-industry-points" || !this.label3d) return
    const labelFeatures = this.getYakIndustryLabelFeatures(layer, features)
    if (!labelFeatures.length) return
    this.ensureYakIndustryPointLabelGroup()
    labelFeatures.forEach((feature, index) => {
      let label = this.yakIndustryPointLabels[index]
      if (!label) {
        label = this.label3d.create("", "yak-industry-point-label", true)
        this.label3d.setLabelStyle(label, 0.012, "x")
        label.setParent(this.yakIndustryPointLabelGroup)
        this.yakIndustryPointLabels.push(label)
      }
      label.element.className = "yak-industry-point-label"
      label.init(
        this.getYakIndustryPointLabelHtml(feature),
        this.getYakIndustryPointLabelPosition(feature, index)
      )
      label.visible = true
      label.show?.()
      label.userData = { feature }
    })
    this.yakIndustryPointLabels.slice(labelFeatures.length).forEach((label) => {
      label.visible = false
      label.hide?.()
      label.userData = null
    })
    this.yakIndustryPointLabelGroup.visible = Boolean(this.mapAnimationComplete)
  }
  updateYakIndustryPointMarkers(layer = this.currentTopicLayer, features = this.currentVectorFeatures) {
    this.clearYakIndustryPointMarkers()
    if (layer?.key !== "yak-industry-points") return
    const markerFeatures = this.getVisibleVectorFeatures(layer, features)
      .filter((feature) => feature?.geometry?.type === "Point")
    if (!markerFeatures.length) return
    this.ensureYakIndustryPointMarkerGroup()
    markerFeatures.forEach((feature, index) => {
      let marker = this.yakIndustryPointMarkers[index]
      const style = this.getVectorFeatureStyle(layer, feature)
      const material = this.getYakIndustryMarkerMaterial(style, feature)
      const position = this.lngLatToLayerLocalPoint(feature.geometry.coordinates, this.depth + 0.36)
      const scale = Number(layer?.pointSpriteScale) || 0.34
      const markerType = this.getYakIndustryMarkerType(feature)
      if (!marker) {
        marker = new Sprite(material)
        marker.name = "yak-industry-point-marker"
        marker.renderOrder = 42
        this.bindYakIndustryPointMarkerEvents(marker)
        this.interactionManager.add(marker)
        this.yakIndustryPointMarkerGroup.add(marker)
        this.yakIndustryPointMarkers.push(marker)
      }
      marker.material = material
      marker.center.set(0.5, 0.5)
      marker.position.copy(position)
      marker.scale.set(scale * 0.88, scale * 0.88, 1)
      marker.visible = true
      marker.userData = {
        feature,
        layer,
        index,
        markerType,
      }
    })
    this.yakIndustryPointMarkers.slice(markerFeatures.length).forEach((marker) => {
      marker.visible = false
      marker.userData = null
    })
    this.yakIndustryPointMarkerGroup.visible = Boolean(this.mapAnimationComplete)
  }
  ensureYakIndustryPointMarkerGroup() {
    if (this.yakIndustryPointMarkerGroup) return
    this.yakIndustryPointMarkerGroup = new Group()
    this.yakIndustryPointMarkerGroup.name = "yak-industry-point-markers"
    this.yakIndustryPointMarkerGroup.visible = false
    this.focusMapGroup?.add(this.yakIndustryPointMarkerGroup)
  }
  clearYakIndustryPointMarkers() {
    if (this.yakIndustryPointMarkerGroup) this.yakIndustryPointMarkerGroup.visible = false
    this.yakIndustryPointMarkers?.forEach((marker) => {
      marker.visible = false
    })
  }
  bindYakIndustryPointMarkerEvents(marker) {
    marker.addEventListener("mousedown", (event) => {
      if (this.clicked || !this.yakIndustryPointMarkerGroup?.visible) return false
      const feature = event.target?.userData?.feature
      if (!feature) return false
      this.clicked = true
      const coordinates = feature.geometry?.coordinates || []
      this.setSelectedVectorFeatures([feature])
      this.callbacks.onFeatureInfo?.({
        feature: {
          id: feature.properties?.id || feature.properties?.feature_id || feature.properties?.project_name,
          properties: feature.properties || {},
          feature,
          layer: this.currentTopicLayer,
          lngLat: { lng: Number(coordinates[0]), lat: Number(coordinates[1]) },
        },
        screen: this.getPointFeatureScreenPoint(feature),
      })
    })
    marker.addEventListener("mouseup", () => {
      this.clicked = false
    })
    marker.addEventListener("mouseover", () => {
      if (marker.visible) document.body.style.cursor = "pointer"
    })
    marker.addEventListener("mouseout", () => {
      document.body.style.cursor = "default"
    })
  }
  getYakIndustryMarkerMaterial(style, feature) {
    const markerType = style.markerType || this.getYakIndustryMarkerType(feature)
    const key = `${style.fill || "#24F6C4"}-${style.stroke || "#B7FFF2"}-${markerType}`
    const cached = this.yakIndustryMarkerMaterialCache.get(key)
    if (cached) return cached
    const texture = this.createYakIndustryMarkerTexture(style.fill, style.stroke, markerType)
    const material = new SpriteMaterial({
      map: texture,
      transparent: true,
      depthTest: false,
      depthWrite: false,
      fog: false,
    })
    this.yakIndustryMarkerMaterialCache.set(key, material)
    return material
  }
  getYakIndustryMarkerType(feature = {}) {
    const stageKey = String(feature?.properties?.stageKey || "")
    return YAK_INDUSTRY_MARKER_TYPES[stageKey] || "point"
  }
  createYakIndustryMarkerTexture(fill = "#24F6C4", stroke = "#B7FFF2", markerType = "point") {
    const canvas = document.createElement("canvas")
    canvas.width = 112
    canvas.height = 112
    const context = canvas.getContext("2d")
    const x = 56
    const y = 56
    context.clearRect(0, 0, canvas.width, canvas.height)

    context.beginPath()
    context.arc(x, y, 42, 0, Math.PI * 2)
    context.fillStyle = this.hexToRgba(fill, 0.18)
    context.shadowColor = fill
    context.shadowBlur = 20
    context.fill()

    this.drawYakIndustryMarkerFrame(context, markerType, x, y, fill, stroke || fill)

    context.save()
    context.shadowBlur = 0
    context.beginPath()
    context.arc(x, y, 19, 0, Math.PI * 2)
    context.fillStyle = "rgba(3, 23, 34, 0.64)"
    context.fill()
    context.strokeStyle = this.hexToRgba("#FFFFFF", 0.32)
    context.lineWidth = 2
    context.stroke()
    context.restore()

    this.drawYakIndustryMarkerGlyph(context, markerType, x, y, "#FFFFFF")

    const texture = new CanvasTexture(canvas)
    texture.colorSpace = SRGBColorSpace
    texture.needsUpdate = true
    return texture
  }
  drawYakIndustryMarkerFrame(context, markerType, x, y, fill = "#24F6C4", stroke = "#B7FFF2") {
    context.save()
    context.translate(x, y)
    context.fillStyle = this.hexToRgba(fill, 0.92)
    context.strokeStyle = this.hexToRgba(stroke || fill, 0.98)
    context.lineWidth = 4
    context.shadowColor = stroke || fill
    context.shadowBlur = 14

    if (markerType === "pasture") {
      context.beginPath()
      context.moveTo(-34, -9)
      context.lineTo(0, -38)
      context.lineTo(34, -9)
      context.lineTo(28, -9)
      context.lineTo(28, 24)
      context.quadraticCurveTo(28, 31, 21, 31)
      context.lineTo(-21, 31)
      context.quadraticCurveTo(-28, 31, -28, 24)
      context.lineTo(-28, -9)
      context.closePath()
    } else if (markerType === "grass") {
      context.beginPath()
      context.moveTo(0, -38)
      context.bezierCurveTo(31, -22, 34, 18, 0, 38)
      context.bezierCurveTo(-34, 18, -31, -22, 0, -38)
      context.closePath()
    } else if (markerType === "breeding") {
      context.beginPath()
      for (let index = 0; index < 6; index += 1) {
        const angle = -Math.PI / 2 + (Math.PI * 2 * index) / 6
        const px = Math.cos(angle) * 35
        const py = Math.sin(angle) * 35
        if (index === 0) context.moveTo(px, py)
        else context.lineTo(px, py)
      }
      context.closePath()
    } else if (markerType === "storage") {
      context.beginPath()
      context.moveTo(-34, -25)
      context.lineTo(25, -25)
      context.lineTo(35, -13)
      context.lineTo(35, 27)
      context.lineTo(-25, 27)
      context.lineTo(-35, 15)
      context.lineTo(-35, -25)
      context.closePath()
    } else if (markerType === "processing") {
      context.beginPath()
      for (let index = 0; index < 10; index += 1) {
        const angle = -Math.PI / 2 + (Math.PI * 2 * index) / 10
        const radius = index % 2 === 0 ? 38 : 30
        const px = Math.cos(angle) * radius
        const py = Math.sin(angle) * radius
        if (index === 0) context.moveTo(px, py)
        else context.lineTo(px, py)
      }
      context.closePath()
    } else if (markerType === "equipment") {
      context.beginPath()
      for (let index = 0; index < 16; index += 1) {
        const angle = -Math.PI / 2 + (Math.PI * 2 * index) / 16
        const radius = index % 2 === 0 ? 38 : 30
        const px = Math.cos(angle) * radius
        const py = Math.sin(angle) * radius
        if (index === 0) context.moveTo(px, py)
        else context.lineTo(px, py)
      }
      context.closePath()
    } else if (markerType === "service") {
      context.beginPath()
      context.moveTo(0, -39)
      context.lineTo(31, -24)
      context.lineTo(25, 14)
      context.quadraticCurveTo(0, 39, -25, 14)
      context.lineTo(-31, -24)
      context.closePath()
    } else {
      context.beginPath()
      context.arc(0, 0, 34, 0, Math.PI * 2)
    }

    context.fill()
    context.stroke()
    context.restore()
  }
  drawYakIndustryMarkerGlyph(context, markerType, x, y, fill = "#24F6C4") {
    context.save()
    context.translate(x, y)
    context.lineCap = "round"
    context.lineJoin = "round"
    context.shadowBlur = 0
    context.fillStyle = this.hexToRgba(fill, 0.98)
    context.strokeStyle = this.hexToRgba(fill, 0.98)
    context.lineWidth = 3

    if (markerType === "pasture") {
      context.beginPath()
      context.moveTo(-12, -2)
      context.lineTo(0, -11)
      context.lineTo(12, -2)
      context.stroke()
      context.fillRect(-9, -2, 18, 12)
      context.clearRect(-3, 2, 6, 8)
    } else if (markerType === "grass") {
      context.beginPath()
      context.moveTo(0, 10)
      context.quadraticCurveTo(-10, 1, -7, -10)
      context.moveTo(0, 10)
      context.quadraticCurveTo(10, 1, 7, -10)
      context.moveTo(0, 10)
      context.quadraticCurveTo(0, 0, 1, -12)
      context.stroke()
    } else if (markerType === "breeding") {
      context.beginPath()
      context.moveTo(-8, -5)
      context.quadraticCurveTo(-15, -12, -18, -4)
      context.moveTo(8, -5)
      context.quadraticCurveTo(15, -12, 18, -4)
      context.stroke()
      context.beginPath()
      context.ellipse(0, 1, 11, 12, 0, 0, Math.PI * 2)
      context.fill()
      context.fillStyle = this.hexToRgba("#08202D", 0.85)
      context.fillRect(-5, -2, 3, 3)
      context.fillRect(2, -2, 3, 3)
    } else if (markerType === "storage") {
      context.beginPath()
      context.moveTo(-13, -3)
      context.lineTo(0, -11)
      context.lineTo(13, -3)
      context.lineTo(13, 9)
      context.lineTo(-13, 9)
      context.closePath()
      context.fill()
      context.strokeStyle = this.hexToRgba("#08202D", 0.72)
      context.lineWidth = 2
      context.beginPath()
      context.moveTo(-8, 0)
      context.lineTo(8, 0)
      context.moveTo(-8, 5)
      context.lineTo(8, 5)
      context.stroke()
    } else if (markerType === "processing") {
      context.beginPath()
      context.moveTo(-13, 8)
      context.lineTo(-13, -1)
      context.lineTo(-5, -6)
      context.lineTo(-5, -1)
      context.lineTo(3, -6)
      context.lineTo(3, -1)
      context.lineTo(12, -6)
      context.lineTo(12, 8)
      context.closePath()
      context.fill()
      context.fillRect(-13, -11, 5, 7)
      context.fillStyle = this.hexToRgba("#08202D", 0.72)
      context.fillRect(-8, 2, 4, 3)
      context.fillRect(0, 2, 4, 3)
      context.fillRect(7, 2, 4, 3)
    } else if (markerType === "equipment") {
      for (let index = 0; index < 8; index += 1) {
        context.save()
        context.rotate((Math.PI * 2 * index) / 8)
        context.fillRect(8, -2, 5, 4)
        context.restore()
      }
      context.beginPath()
      context.arc(0, 0, 9, 0, Math.PI * 2)
      context.fill()
      context.fillStyle = this.hexToRgba("#FFFFFF", 0.98)
      context.beginPath()
      context.arc(0, 0, 4, 0, Math.PI * 2)
      context.fill()
    } else if (markerType === "service") {
      context.beginPath()
      context.moveTo(0, -13)
      context.lineTo(12, -7)
      context.lineTo(9, 6)
      context.quadraticCurveTo(0, 14, -9, 6)
      context.lineTo(-12, -7)
      context.closePath()
      context.fill()
      context.strokeStyle = this.hexToRgba("#FFFFFF", 0.94)
      context.lineWidth = 2.4
      context.beginPath()
      context.moveTo(0, -7)
      context.lineTo(0, 6)
      context.moveTo(-6, -1)
      context.lineTo(6, -1)
      context.stroke()
    } else {
      context.beginPath()
      context.arc(0, 0, 7, 0, Math.PI * 2)
      context.fill()
    }
    context.restore()
  }
  ensureYakIndustryPointLabelGroup() {
    if (this.yakIndustryPointLabelGroup) return
    this.yakIndustryPointLabelGroup = new Group()
    this.yakIndustryPointLabelGroup.name = "yak-industry-point-labels"
    this.yakIndustryPointLabelGroup.rotation.x = -Math.PI / 2
    this.yakIndustryPointLabelGroup.visible = false
    this.scene.add(this.yakIndustryPointLabelGroup)
  }
  clearYakIndustryPointLabels() {
    if (this.yakIndustryPointLabelGroup) this.yakIndustryPointLabelGroup.visible = false
    this.yakIndustryPointLabels?.forEach((label) => {
      label.visible = false
      label.hide?.()
    })
  }
  getYakIndustryLabelFeatures(layer, features) {
    const visibleFeatures = this.getVisibleVectorFeatures(layer, features)
      .filter((feature) => feature?.geometry?.type === "Point")
    const limit = Number(layer?.pointLabelLimit) || (visibleFeatures.length > 36 ? 12 : Math.min(visibleFeatures.length, 16))
    const sorted = visibleFeatures
      .map((feature) => ({
        feature,
        score: this.getYakIndustryFeatureScore(feature),
        town: normalizeTownName(feature.properties?.town),
      }))
      .sort((a, b) => b.score - a.score || String(a.feature.properties?.name || "").localeCompare(String(b.feature.properties?.name || ""), "zh-CN"))
    const selected = []
    const usedTowns = new Set()
    sorted.forEach((item) => {
      if (selected.length >= limit) return
      if (!item.town || usedTowns.has(item.town)) return
      selected.push(item.feature)
      usedTowns.add(item.town)
    })
    sorted.forEach((item) => {
      if (selected.length >= limit) return
      if (selected.includes(item.feature)) return
      selected.push(item.feature)
    })
    return selected
  }
  getYakIndustryFeatureScore(feature) {
    const properties = feature?.properties || {}
    const values = [
      properties.outputValue,
      properties.capacity,
      properties.value,
      properties.count,
      properties["年产值 (万元)"],
      properties["年总产值 (万元)"],
      properties["设计存栏量 (头)"],
      properties["总库容 (吨)"],
    ].map((value) => this.parseMapMetricNumber(value))
    return Math.max(...values, 0)
  }
  parseMapMetricNumber(value) {
    if (typeof value === "number") return Number.isFinite(value) ? value : 0
    const match = String(value ?? "").replace(/,/g, "").match(/-?\d+(?:\.\d+)?/)
    return match ? Number(match[0]) || 0 : 0
  }
  getYakIndustryPointLabelPosition(feature, index = 0) {
    const point = this.lngLatToLayerLocalPoint(feature?.geometry?.coordinates, this.depth + 0.42 + Math.min(index, 5) * 0.01)
    point.x += ((index % 3) - 1) * 0.08
    point.y += index % 2 === 0 ? 0.07 : -0.06
    return point
  }
  getYakIndustryPointLabelHtml(feature) {
    const properties = feature?.properties || {}
    const name = this.compactYakIndustryLabelName(properties.name || properties.fullName || "产业主体")
    const category = this.formatYakIndustryLabelText(properties.categoryName || properties.stageName || "产业主体")
    const town = properties.town || properties.village || "红原县"
    const metric = this.getYakIndustryPointMetricText(properties)
    const color = this.getYakIndustryLabelColor(properties)
    return `<div class="yak-industry-point-label-wrap" style="--yak-point-color:${escapeHtml(color)}">
      <div class="yak-industry-point-label-head"><i></i><span>${escapeHtml(category)}</span></div>
      <div class="yak-industry-point-label-name">${escapeHtml(name)}</div>
      <div class="yak-industry-point-label-meta">
        <span>${escapeHtml(town)}</span>
        ${metric ? `<strong>${escapeHtml(metric)}</strong>` : ""}
      </div>
    </div>`
  }
  compactYakIndustryLabelName(value = "") {
    const text = String(value)
      .replace(/^四川红原/, "")
      .replace(/^红原县/, "")
      .replace(/有限责任公司$/, "")
      .replace(/农民专业合作社$/, "合作社")
      .replace(/\s+/g, " ")
      .trim()
    return text.length > 16 ? `${text.slice(0, 15)}…` : text
  }
  formatYakIndustryLabelText(value = "") {
    return String(value)
      .replace(/\bcxpt[-_./\s]*/gi, "")
      .replace(/^cxpt/i, "")
      .replace(/\s+/g, " ")
      .trim()
  }
  getYakIndustryPointMetricText(properties = {}) {
    if (properties.capacity !== undefined && properties.capacity !== null && properties.capacity !== "" && this.parseMapMetricNumber(properties.capacity) > 0) {
      return this.formatYakIndustryMetricText(properties.capacity, properties.capacityUnit)
    }
    if (this.parseMapMetricNumber(properties.outputValue) > 0) return `${this.formatMapRankingValue(properties.outputValue)}万元`
    if (this.isMeaningfulMapText(properties.locationType)) return properties.locationType
    if (this.isMeaningfulMapText(properties.status)) return properties.status
    return ""
  }
  isMeaningfulMapText(value) {
    const text = String(value ?? "").trim()
    return Boolean(text && text !== "0" && text !== "无" && text !== "待完善")
  }
  formatYakIndustryMetricText(value, unit = "") {
    const text = String(value ?? "").trim()
    if (!text || text === "0") return ""
    if (typeof value === "string" && /[^\d.,\s-]/.test(text)) return text
    return `${this.formatMapRankingValue(value)}${unit || ""}`
  }
  getYakIndustryLabelColor(properties = {}) {
    const stageKey = String(properties.stageKey || "")
    const legend = (this.currentTopicLayer?.legends || []).find((item) => String(item.match?.value || "") === stageKey)
      || (String(this.currentTopicLayer?.activeLegend?.match?.value || "") === stageKey ? this.currentTopicLayer.activeLegend : null)
    return legend?.fillColor || this.currentTopicLayer?.activeLegend?.fillColor || "#24F6C4"
  }
  clipVectorLayerContext(context, layer) {
    if (this.shouldClipLayerToHongyuanBoundary(layer)) {
      this.clipContextToHongyuanBoundary(context)
    }
    if (!this.shouldClipLayerToLayerBounds(layer)) return
    const bounds = this.getLayerClipBounds(layer)
    if (!bounds) return
    const topLeft = this.lngLatToLayerCanvasPoint([bounds.west, bounds.north])
    const bottomRight = this.lngLatToLayerCanvasPoint([bounds.east, bounds.south])
    const x = Math.min(topLeft.x, bottomRight.x)
    const y = Math.min(topLeft.y, bottomRight.y)
    const width = Math.abs(bottomRight.x - topLeft.x)
    const height = Math.abs(bottomRight.y - topLeft.y)
    if (!width || !height) return
    context.beginPath()
    context.rect(x, y, width, height)
    context.clip()
  }
  clipContextToHongyuanBoundary(context) {
    const polygons = this.hongyuanBoundaryPolygons || []
    if (!polygons.length) return
    context.beginPath()
    polygons.forEach((rings) => {
      rings.forEach((ring) => {
        ring.forEach((coordinate, index) => {
          const point = this.lngLatToLayerCanvasPoint(coordinate)
          if (index === 0) context.moveTo(point.x, point.y)
          else context.lineTo(point.x, point.y)
        })
        context.closePath()
      })
    })
    context.clip("evenodd")
  }
  shouldClipLayerToHongyuanBoundary(layer = this.currentTopicLayer) {
    if (!layer) return false
    if (layer.clipToHongyuanBoundary === false) return false
    if (layer.clipToHongyuanBoundary === true) return true
    const key = String(layer?.key || "")
    return Boolean(key === "national-park-zoning" || key.startsWith("yak-"))
  }
  shouldClipLayerToLayerBounds(layer = this.currentTopicLayer) {
    return Boolean(layer?.clipBounds && layer?.clipToLayerBounds !== false)
  }
  isLngLatInHongyuanBoundary(lngLat) {
    const polygons = this.hongyuanBoundaryPolygons || []
    if (!polygons.length) return true
    return polygons.some((polygon) => this.isLngLatInPolygon(lngLat, polygon))
  }
  isLngLatInLayerClipBounds(lngLat, layer = this.currentTopicLayer) {
    const bounds = this.getLayerClipBounds(layer)
    if (!bounds) return true
    return lngLat.lng >= bounds.west && lngLat.lng <= bounds.east && lngLat.lat >= bounds.south && lngLat.lat <= bounds.north
  }
  getLayerClipBounds(layer) {
    const bounds = layer?.clipBounds
    if (!bounds) return null
    const west = Math.max(Number(bounds.west), HONGYUAN_BOUNDS.west)
    const south = Math.max(Number(bounds.south), HONGYUAN_BOUNDS.south)
    const east = Math.min(Number(bounds.east), HONGYUAN_BOUNDS.east)
    const north = Math.min(Number(bounds.north), HONGYUAN_BOUNDS.north)
    if (![west, south, east, north].every(Number.isFinite) || west >= east || south >= north) return null
    return { west, south, east, north }
  }
  getGeometryDrawRank(type) {
    if (String(type).includes("Polygon")) return 1
    if (String(type).includes("LineString")) return 2
    return 3
  }
  isEcologicalProjectLayer(layer = this.currentTopicLayer) {
    return ["restoration-projects", "engineering-analysis-projects"].includes(layer?.key)
  }
  getVectorFeatureStyle(layer, feature) {
    const properties = feature.properties || {}
    const matchedLegend = (layer.legends || []).find((legend) => {
      const match = legend.match
      if (!match) return false
      return String(properties[match.field] ?? "") === String(match.value)
    })
    const legend = matchedLegend || layer.activeLegend || layer.legends?.[0] || {}
    const isLine = String(feature.geometry?.type || "").includes("LineString")
    const isPoint = feature.geometry?.type === "Point"
    if (this.isEcologicalProjectLayer(layer)) {
      const lineFeature = isLine || String(properties.feature_role || "") === "线性设施"
      return {
        fill: lineFeature || isPoint ? legend.strokeColor || legend.fillColor || "#D2EA78" : legend.fillColor || "#75AD49",
        stroke: legend.strokeColor || legend.fillColor || "#D2EA78",
        polygonAlpha: lineFeature ? 0.08 : 0.22,
        lineAlpha: lineFeature ? 0.9 : 0.82,
        lineWidth: lineFeature ? 3 : 1.85,
        pointRadius: isPoint ? 3.8 : 4,
        glow: false,
      }
    }
    if (layer?.key === "yak-epidemic-village-boundary") {
      return {
        fill: legend.fillColor || "#24F6C4",
        stroke: legend.strokeColor || "#FFF06A",
        polygonAlpha: 0.16,
        lineAlpha: 1,
        lineWidth: 5,
        pointRadius: 4.5,
        glow: true,
      }
    }
    if (layer?.key === "yak-industry-points") {
      return {
        fill: legend.fillColor || "#24F6C4",
        stroke: legend.strokeColor || legend.fillColor || "#B7FFF2",
        polygonAlpha: 0.36,
        lineAlpha: 0.82,
        lineWidth: 1.6,
        pointRadius: this.getVectorPointRadius(layer, feature),
        glow: true,
        halo: true,
        marker: true,
        markerType: YAK_INDUSTRY_MARKER_TYPES[String(properties.stageKey || "")] || "point",
      }
    }
    if (layer?.key === "national-park-zoning") {
      return {
        fill: legend.fillColor || "#30DCFF",
        stroke: legend.strokeColor || "#F2FFFF",
        polygonAlpha: 0.36,
        lineAlpha: 1,
        lineWidth: 5.8,
        pointRadius: 4.5,
        glow: true,
      }
    }
    if (layer?.boundaryOnly) {
      return {
        fill: legend.fillColor || "#24F6C4",
        stroke: legend.strokeColor || legend.fillColor || "#18F8FF",
        polygonAlpha: 0.04,
        lineAlpha: 0.96,
        lineWidth: 10,
        pointRadius: 4.5,
        glow: true,
      }
    }
    return {
      fill: isLine || isPoint ? legend.strokeColor || legend.fillColor || "#30DCFF" : legend.fillColor || "#30DCFF",
      stroke: legend.strokeColor || legend.fillColor || "#30DCFF",
      polygonAlpha: 0.36,
      lineAlpha: isLine ? 0.92 : 0.9,
      lineWidth: isLine ? 5 : 2.4,
      pointRadius: isPoint ? this.getVectorPointRadius(layer, feature) : 4.5,
      glow: true,
    }
  }
  getVectorPointRadius(layer, feature) {
    const value = Number(feature?.properties?.count || feature?.properties?.value || 0)
    if (layer?.key === "yak-recognition-layer") return Math.max(3.8, Math.min(8.6, 3.6 + Math.sqrt(value) / 68))
    if (layer?.key === "yak-industry-points") return 10.4
    return 4.5
  }
  drawVectorFeature(context, geometry, style) {
    if (!geometry?.coordinates) return
    if (geometry.type === "Polygon") {
      this.drawPolygonPath(context, geometry.coordinates, style)
      return
    }
    if (geometry.type === "MultiPolygon") {
      geometry.coordinates.forEach((polygon) => this.drawPolygonPath(context, polygon, style))
      return
    }
    if (geometry.type === "LineString") {
      this.drawLinePath(context, geometry.coordinates, style)
      return
    }
    if (geometry.type === "MultiLineString") {
      geometry.coordinates.forEach((line) => this.drawLinePath(context, line, style))
      return
    }
    if (geometry.type === "Point") {
      this.drawPointPath(context, geometry.coordinates, style)
    }
  }
  drawPolygonPath(context, rings, style) {
    if (!Array.isArray(rings?.[0])) return
    context.save()
    context.beginPath()
    rings.forEach((ring) => {
      ring.forEach((coordinate, index) => {
        const point = this.lngLatToLayerCanvasPoint(coordinate)
        if (index === 0) context.moveTo(point.x, point.y)
        else context.lineTo(point.x, point.y)
      })
      context.closePath()
    })
    context.fillStyle = this.hexToRgba(style.fill, style.polygonAlpha)
    context.strokeStyle = this.hexToRgba(style.stroke, style.lineAlpha)
    context.lineWidth = style.lineWidth
    if (style.glow) {
      context.shadowColor = style.stroke
      context.shadowBlur = 14
    }
    context.fill("evenodd")
    context.stroke()
    context.restore()
  }
  drawLinePath(context, coordinates, style) {
    if (!Array.isArray(coordinates) || coordinates.length < 2) return
    context.save()
    context.beginPath()
    coordinates.forEach((coordinate, index) => {
      const point = this.lngLatToLayerCanvasPoint(coordinate)
      if (index === 0) context.moveTo(point.x, point.y)
      else context.lineTo(point.x, point.y)
    })
    context.strokeStyle = this.hexToRgba(style.stroke, style.lineAlpha)
    context.lineWidth = style.lineWidth
    context.shadowColor = style.stroke
    context.shadowBlur = 12
    context.stroke()
    context.restore()
  }
  drawPointPath(context, coordinate, style) {
    if (style.marker) {
      this.drawMarkerPointPath(context, coordinate, style)
      return
    }
    const point = this.lngLatToLayerCanvasPoint(coordinate)
    context.save()
    if (style.halo) {
      context.beginPath()
      context.arc(point.x, point.y, style.pointRadius * 2.35, 0, Math.PI * 2)
      context.fillStyle = this.hexToRgba(style.fill, 0.18)
      context.shadowColor = style.fill
      context.shadowBlur = 18
      context.fill()
    }
    context.beginPath()
    context.arc(point.x, point.y, style.pointRadius, 0, Math.PI * 2)
    context.fillStyle = this.hexToRgba(style.fill, 0.96)
    context.shadowColor = style.stroke
    context.shadowBlur = style.halo ? 8 : 12
    context.fill()
    if (style.lineWidth) {
      context.lineWidth = style.lineWidth
      context.strokeStyle = this.hexToRgba(style.stroke, style.lineAlpha ?? 0.9)
      context.stroke()
    }
    context.restore()
  }
  drawMarkerPointPath(context, coordinate, style) {
    const point = this.lngLatToLayerCanvasPoint(coordinate)
    const radius = style.pointRadius || 6
    context.save()
    context.beginPath()
    context.arc(point.x, point.y + radius * 0.22, radius * 2.55, 0, Math.PI * 2)
    context.fillStyle = this.hexToRgba(style.fill, 0.16)
    context.shadowColor = style.fill
    context.shadowBlur = 24
    context.fill()

    context.beginPath()
    context.moveTo(point.x, point.y + radius * 2.05)
    context.bezierCurveTo(point.x + radius * 1.46, point.y + radius * 0.62, point.x + radius * 1.34, point.y - radius * 1.34, point.x, point.y - radius * 1.62)
    context.bezierCurveTo(point.x - radius * 1.34, point.y - radius * 1.34, point.x - radius * 1.46, point.y + radius * 0.62, point.x, point.y + radius * 2.05)
    context.closePath()
    context.fillStyle = this.hexToRgba(style.fill, 0.94)
    context.strokeStyle = this.hexToRgba(style.stroke, 0.98)
    context.lineWidth = Math.max(2, style.lineWidth || 2)
    context.shadowColor = style.stroke
    context.shadowBlur = 14
    context.fill()
    context.stroke()

    context.beginPath()
    context.arc(point.x, point.y - radius * 0.34, radius * 0.52, 0, Math.PI * 2)
    context.fillStyle = this.hexToRgba("#FFFFFF", 0.96)
    context.shadowBlur = 0
    context.fill()

    context.beginPath()
    context.arc(point.x, point.y - radius * 0.34, radius * 0.82, 0, Math.PI * 2)
    context.lineWidth = Math.max(1.2, radius * 0.14)
    context.strokeStyle = this.hexToRgba("#FFFFFF", 0.42)
    context.stroke()
    context.restore()
  }
  lngLatToLayerCanvasPoint(coordinate) {
    const lng = Number(coordinate?.[0])
    const lat = Number(coordinate?.[1])
    return {
      x: ((lng - HONGYUAN_BOUNDS.west) / (HONGYUAN_BOUNDS.east - HONGYUAN_BOUNDS.west)) * this.topicLayerCanvas.width,
      y: ((HONGYUAN_BOUNDS.north - lat) / (HONGYUAN_BOUNDS.north - HONGYUAN_BOUNDS.south)) * this.topicLayerCanvas.height,
    }
  }
  hexToRgba(color, alpha = 1) {
    const value = String(color || "#30DCFF").replace("#", "")
    const normalized = value.length === 3 ? value.split("").map((item) => item + item).join("") : value
    const number = Number.parseInt(normalized, 16)
    if (!Number.isFinite(number)) return `rgba(48, 220, 255, ${alpha})`
    const r = (number >> 16) & 255
    const g = (number >> 8) & 255
    const b = number & 255
    return `rgba(${r}, ${g}, ${b}, ${alpha})`
  }
  parseWktGeometry(wkt) {
    const text = String(wkt || "").trim()
    if (!text) return null
    const typeMatch = text.match(/^([A-Z]+)(?:\s+[A-Z]+)?\s*\(/i)
    const type = typeMatch?.[1]?.toUpperCase()
    if (!type) return null
    const body = text.slice(text.indexOf("("))
    if (type === "POLYGON") {
      return { type: "Polygon", coordinates: this.parseWktPolygonBody(body) }
    }
    if (type === "MULTIPOLYGON") {
      return { type: "MultiPolygon", coordinates: this.parseWktMultiPolygonBody(body) }
    }
    if (type === "POINT") {
      return { type: "Point", coordinates: this.parseWktPoint(body) }
    }
    if (type === "MULTIPOINT") {
      return { type: "MultiPoint", coordinates: this.getWktChildGroups(body).map((pointText) => this.parseWktPoint(pointText)).filter(Boolean) }
    }
    if (type === "LINESTRING") {
      return { type: "LineString", coordinates: this.parseWktRing(body) }
    }
    if (type === "MULTILINESTRING") {
      return { type: "MultiLineString", coordinates: this.getWktChildGroups(body).map((lineText) => this.parseWktRing(lineText)).filter((line) => line.length) }
    }
    return null
  }
  parseWktMultiPolygonBody(body) {
    return this.getWktChildGroups(body).map((polygonText) => this.parseWktPolygonBody(polygonText)).filter((polygon) => polygon.length)
  }
  parseWktPolygonBody(body) {
    return this.getWktChildGroups(body).map((ringText) => this.parseWktRing(ringText)).filter((ring) => ring.length >= 3)
  }
  getWktChildGroups(text) {
    const trimmed = String(text || "").trim()
    const inner = trimmed.startsWith("(") && trimmed.endsWith(")") ? trimmed.slice(1, -1) : trimmed
    const groups = []
    let depth = 0
    let start = -1
    for (let index = 0; index < inner.length; index += 1) {
      const char = inner[index]
      if (char === "(") {
        if (depth === 0) start = index
        depth += 1
      } else if (char === ")") {
        depth -= 1
        if (depth === 0 && start >= 0) {
          groups.push(inner.slice(start, index + 1))
          start = -1
        }
      }
    }
    return groups.length ? groups : [inner]
  }
  parseWktRing(text) {
    const inner = String(text || "").replace(/^\(+|\)+$/g, "")
    return inner
      .split(",")
      .map((chunk) => chunk.trim().split(/\s+/).slice(0, 2).map(Number))
      .filter(([lng, lat]) => Number.isFinite(lng) && Number.isFinite(lat))
  }
  parseWktPoint(text) {
    const point = String(text || "").replace(/^\(+|\)+$/g, "").trim().split(/\s+/).slice(0, 2).map(Number)
    return point.length >= 2 && Number.isFinite(point[0]) && Number.isFinite(point[1]) ? [point[0], point[1]] : null
  }
  createProvinceMaterial() {
    let topMaterial = new MeshLambertMaterial({
      color: 0xffffff,
      transparent: true,
      opacity: 0,
      depthWrite: !this.usesCustomSatelliteTiles,
      fog: false,
      side: DoubleSide,
    })
    topMaterial.onBeforeCompile = (shader) => {
      shader.uniforms = {
        ...shader.uniforms,
        uColor1: { value: new Color(0x20d8e4) },
        uColor2: { value: new Color(0x07415a) },
      }
      shader.vertexShader = shader.vertexShader.replace(
        "void main() {",
        `
        attribute float alpha;
        varying vec3 vPosition;
        varying float vAlpha;
        void main() {
          vAlpha = alpha;
          vPosition = position;
      `
      )
      shader.fragmentShader = shader.fragmentShader.replace(
        "void main() {",
        `
        varying vec3 vPosition;
        varying float vAlpha;
        uniform vec3 uColor1;
        uniform vec3 uColor2;
        void main() {
      `
      )
      shader.fragmentShader = shader.fragmentShader.replace(
        "#include <opaque_fragment>",
        /* glsl */ `
      #ifdef OPAQUE
      diffuseColor.a = 1.0;
      #endif
            #ifdef USE_TRANSMISSION
      diffuseColor.a *= transmissionAlpha + 0.1;
      #endif
      vec3 gradient = mix(uColor1, uColor2, vPosition.x/15.78);
      outgoingLight = mix(outgoingLight * vec3(1.18), outgoingLight * gradient, 0.32);
      float topAlpha = 0.92;
      if(vPosition.z>0.3){
        diffuseColor.a *= topAlpha;
      }
      gl_FragColor = vec4( outgoingLight, diffuseColor.a  );
      `
      )
    }
    let sideMap = this.assets.instance.getResource("side")
    sideMap.wrapS = RepeatWrapping
    sideMap.wrapT = RepeatWrapping
    sideMap.repeat.set(1, 1.8)
    sideMap.offset.y += 0.065
    let sideMaterial = new MeshStandardMaterial({
      color: 0x20c9dd,
      map: sideMap,
      fog: false,
      opacity: 0,
      side: DoubleSide,
      roughness: 0.58,
      metalness: 0.08,
    })
    this.time.on("tick", () => {
      sideMap.offset.y += 0.005
    })
    sideMaterial.onBeforeCompile = (shader) => {
      shader.uniforms = {
        ...shader.uniforms,
        uColor1: { value: new Color(0x18dce2) },
        uColor2: { value: new Color(0x032235) },
      }
      shader.vertexShader = shader.vertexShader.replace(
        "void main() {",
        `
        attribute float alpha;
        varying vec3 vPosition;
        varying float vAlpha;
        void main() {
          vAlpha = alpha;
          vPosition = position;
      `
      )
      shader.fragmentShader = shader.fragmentShader.replace(
        "void main() {",
        `
        varying vec3 vPosition;
        varying float vAlpha;
        uniform vec3 uColor1;
        uniform vec3 uColor2;
        void main() {
      `
      )
      shader.fragmentShader = shader.fragmentShader.replace(
        "#include <opaque_fragment>",
        /* glsl */ `
      #ifdef OPAQUE
      diffuseColor.a = 1.0;
      #endif
            #ifdef USE_TRANSMISSION
      diffuseColor.a *= transmissionAlpha + 0.1;
      #endif
      vec3 gradient = mix(uColor1, uColor2, vPosition.z/1.2);
      outgoingLight = outgoingLight*gradient*vec3(1.12);
      gl_FragColor = vec4( outgoingLight, diffuseColor.a  );
      `
      )
    }
    return [topMaterial, sideMaterial]
  }
  getDefaultTownRanking() {
    return {
      title: "乡镇统计加载中",
      unit: "",
      rows: provincesData.map((item) => ({
        name: item.name,
        value: 0,
        unit: "",
      })),
    }
  }
  getTownRankingMeta(ranking = this.currentTownRanking) {
    const fallback = this.getDefaultTownRanking()
    const calloutMode = isCalloutRanking(ranking)
    return {
      title: ranking?.title || fallback.title,
      unit: ranking?.unit || fallback.unit,
      metricLabel: ranking?.metricLabel || ranking?.legendName || "",
      note: ranking?.note || "",
      hideTownNames: shouldHideTownNames(ranking),
      hideMapLabels: shouldHideMapLabels(ranking),
      calloutMode,
      showBars: ranking?.showBars ?? false,
      showPointMarkers: ranking?.showPointMarkers ?? calloutMode,
      onlyRankedTownLabels: ranking?.onlyRankedTownLabels ?? false,
      showMissingTownStats: ranking?.showMissingTownStats === true,
    }
  }
  getTownRankingRows(ranking = this.currentTownRanking, limit = 7, useFallback = true) {
    const fallback = this.getDefaultTownRanking()
    const sourceRows = ranking?.rows?.length ? ranking.rows : useFallback ? fallback.rows : []
    const meta = this.getTownRankingMeta(ranking)
    const rows = sourceRows
      .map((row) => {
        const townMeta = this.townMetaByName.get(normalizeTownName(row.name))
        if (!townMeta) return null
        return {
          ...townMeta,
          ...row,
          name: townMeta.name,
          enName: townMeta.enName,
          center: townMeta.center,
          centroid: townMeta.centroid,
          value: toFiniteNumber(row.value),
          unit: row.unit || meta.unit,
          metricTitle: meta.title,
        }
      })
      .filter(Boolean)
      .sort((a, b) => b.value - a.value)
      .map((row, index) => ({
        ...row,
        rank: index + 1,
      }))
    return Number.isFinite(limit) ? rows.slice(0, limit) : rows
  }
  getVillageRankingRows(ranking = this.currentTownRanking) {
    const meta = this.getTownRankingMeta(ranking)
    return (ranking?.rows || [])
      .map((row, index) => {
        const lng = toFiniteNumber(row.lng ?? row.longitude, NaN)
        const lat = toFiniteNumber(row.lat ?? row.latitude, NaN)
        if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null
        const value = toFiniteNumber(row.displayValue ?? row.value)
        const rawValue = toFiniteNumber(row.rawValue ?? row.earTagCount ?? row.ear_tag_count ?? row.value)
        return {
          ...row,
          key: row.key || `${row.town || ""}-${row.village || row.name || index}`,
          name: row.name || row.village || `村${index + 1}`,
          town: row.town || "",
          lng,
          lat,
          value,
          rawValue,
          recordCount: toFiniteNumber(row.recordCount ?? row.record_count, 0),
          imageRate: toFiniteNumber(row.imageRate ?? row.image_rate, 0),
          unit: row.displayUnit || row.unit || meta.unit,
          metricTitle: meta.title,
          metricLabel: row.metricLabel || meta.metricLabel || "",
        }
      })
      .filter(Boolean)
      .sort((a, b) => b.rawValue - a.rawValue)
      .map((row, index) => ({
        ...row,
        rank: index + 1,
      }))
  }
  getVillageRankingHtml(row, meta) {
    const townHtml = meta.hideTownNames ? "" : `<span>${escapeHtml(row.town || "红原县")}</span>`
    if (row.displayMode === "park-village" || meta.displayMode === "park-village") {
      return `<div class="epidemic-village-card is-park-village ${row.rank > 8 ? "is-small" : ""}">
        <div class="village-card-head">
          <strong>${escapeHtml(row.name)}</strong>
          ${townHtml}
        </div>
        <div class="village-card-body">
          <span class="metric">${escapeHtml(row.metricLabel || meta.metricLabel || "")}</span>
          <span class="number">${this.formatMapRankingValue(row.value)}</span>
          <span class="unit">${escapeHtml(row.unit || meta.unit || "")}</span>
        </div>
      </div>`
    }
    return `<div class="epidemic-village-card ${row.rank > 8 ? "is-small" : ""}">
      <div class="village-card-head">
        <strong>${escapeHtml(row.name)}</strong>
        ${townHtml}
      </div>
      <div class="village-card-body">
        <span class="metric">${escapeHtml(row.metricLabel || "防疫")}</span>
        <span class="number">${this.formatMapRankingValue(row.value)}</span>
        <span class="unit">${escapeHtml(row.unit || meta.unit || "")}</span>
      </div>
      <div class="village-card-foot">
        <span>台账 ${this.formatMapCompactValue(row.recordCount)}条</span>
        <span>耳标 ${this.formatMapCompactValue(row.rawValue)}头</span>
      </div>
    </div>`
  }
  ensureVillageRankingGroup() {
    if (this.villageRankingGroup) return
    this.villageRankingGroup = new Group()
    this.villageRankingGroup.rotation.x = -Math.PI / 2
    this.villageRankingGroup.visible = false
    this.scene.add(this.villageRankingGroup)
    this.villageRankingLabels = []
  }
  hideVillageRankingLabels() {
    if (this.villageRankingGroup) this.villageRankingGroup.visible = false
    this.villageRankingLabels?.forEach((label) => {
      label.visible = false
    })
  }
  hideAllMapStatisticLabels() {
    this.updateTownNameLabelStats(null)
    this.setTownNameLabelsVisible(false)
    this.hideVillageRankingLabels()
    this.hideTradeFlowLayer()
    this.clearInfoPointStats()
    this.allProvinceLabel?.forEach((label) => {
      label.visible = false
    })
    this.allBar?.forEach((bar) => {
      bar.visible = false
      bar.scale.set(1, 1, 0)
    })
    this.allBarMaterial?.forEach((material) => {
      material.opacity = 0
    })
    this.allGuangquan?.forEach((group) => {
      group.visible = false
    })
  }
  setTownNameLabelsVisible(visible = true) {
    this.townNameLabels?.forEach((label) => {
      label.visible = visible
    })
  }
  updateVillageRankingLabels(ranking = this.currentTownRanking) {
    this.ensureVillageRankingGroup()
    if (!isVillageRanking(ranking)) {
      this.hideVillageRankingLabels()
      return
    }
    const rows = this.getVillageRankingRows(ranking)
    const meta = this.getTownRankingMeta(ranking)
    if (!rows.length) {
      this.hideVillageRankingLabels()
      return
    }
    this.villageRankingGroup.visible = this.mapAnimationComplete
    rows.forEach((row, index) => {
      let label = this.villageRankingLabels[index]
      if (!label) {
        label = this.label3d.create("", "epidemic-village-label", true)
        this.label3d.setLabelStyle(label, 0.0135, "x")
        label.setParent(this.villageRankingGroup)
        this.villageRankingLabels.push(label)
      }
      const [x, y] = this.geoProjection([row.lng, row.lat])
      const offsetX = ((index % 3) - 1) * 0.06
      const offsetY = (Math.floor(index / 3) % 2) * 0.04
      label.init(
        this.getVillageRankingHtml(row, meta),
        new Vector3(x + offsetX, -y + offsetY, this.depth + 1.55 + Math.min(index, 6) * 0.015)
      )
      label.visible = true
      label.userData = { ...row }
    })
    this.villageRankingLabels.slice(rows.length).forEach((label) => {
      label.visible = false
    })
  }
  ensureTradeFlowGroup() {
    if (this.tradeFlowGroup) return
    this.tradeFlowGroup = new Group()
    this.tradeFlowGroup.name = "yak-trade-flow"
    this.tradeFlowGroup.visible = false
    this.tradeFlowMeshGroup = new Group()
    this.tradeFlowMeshGroup.name = "yak-trade-flow-mesh"
    this.tradeFlowMeshGroup.rotation.x = -Math.PI / 2
    this.tradeFlowLabelGroup = new Group()
    this.tradeFlowLabelGroup.name = "yak-trade-flow-label"
    this.tradeFlowLabelGroup.rotation.x = -Math.PI / 2
    this.tradeFlowGroup.add(this.tradeFlowMeshGroup, this.tradeFlowLabelGroup)
    this.scene.add(this.tradeFlowGroup)
  }
  hideTradeFlowLayer() {
    if (this.tradeFlowGroup) this.tradeFlowGroup.visible = false
    this.tradeFlowLabels?.forEach((label) => {
      label.visible = false
    })
    this.tradeFlowPulseSprites?.forEach((sprite) => {
      sprite.visible = false
    })
  }
  clearTradeFlowLayer() {
    if (!this.tradeFlowMeshGroup) return
    this.tradeFlowMeshGroup.children.forEach((child) => {
      child.traverse?.((object) => this.disposeObject3D(object))
    })
    this.tradeFlowMeshGroup.clear()
    this.tradeFlowMaterials = []
    this.tradeFlowPulseSprites = []
  }
  getTradeFlowSource(ranking = this.currentTownRanking) {
    const source = ranking?.source || {}
    const lng = toFiniteNumber(source.lng ?? source.longitude, NaN)
    const lat = toFiniteNumber(source.lat ?? source.latitude, NaN)
    if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null
    return {
      name: source.name || "红原牦牛交易市场",
      lng,
      lat,
      total: toFiniteNumber(source.total ?? source.headCount ?? source.value, NaN),
    }
  }
  getTradeFlowRows(ranking = this.currentTownRanking) {
    const meta = this.getTownRankingMeta(ranking)
    return (ranking?.rows || [])
      .map((row, index) => {
        const lng = toFiniteNumber(row.lng ?? row.longitude, NaN)
        const lat = toFiniteNumber(row.lat ?? row.latitude, NaN)
        const value = toFiniteNumber(row.headCount ?? row.head_count ?? row.rawValue ?? row.value)
        const displayName = row.displayName || row.display_name || row.name
        const townName = row.mapTownName || row.map_town_name || row.town || row.startTown || row.start_town || displayName
        const townMeta = this.townMetaByName.get(normalizeTownName(townName))
        const point = Number.isFinite(lng) && Number.isFinite(lat)
          ? { lng, lat }
          : townMeta?.center
            ? { lng: townMeta.center[0], lat: townMeta.center[1] }
            : null
        if (!point || !Number.isFinite(value)) return null
        return {
          ...row,
          ...townMeta,
          key: row.key || `${townMeta?.name || displayName || townName || "trade-source"}-${index}`,
          name: displayName || townName || townMeta?.name || `来源${index + 1}`,
          townName: townMeta?.name || townName,
          lng: point.lng,
          lat: point.lat,
          labelSide: row.labelSide || row.label_side || this.getTradeSourceLabelSide(townMeta, index),
          value,
          headCount: value,
          recordCount: toFiniteNumber(row.recordCount ?? row.record_count, 0),
          unit: row.unit || meta.unit || "头",
        }
      })
      .filter(Boolean)
      .sort((a, b) => b.value - a.value)
      .map((row, index) => ({
        ...row,
        rank: index + 1,
      }))
  }
  getTradeMarketHtml(source, rows) {
    const total = Number.isFinite(source.total)
      ? source.total
      : rows.reduce((sum, row) => sum + toFiniteNumber(row.value), 0)
    return `<div class="trade-market-card">
      <div class="market-title">${escapeHtml(source.name)}</div>
      <div class="market-total"><span>${this.formatMapIntegerValue(total)}</span><em>头</em></div>
    </div>`
  }
  getTradeFlowDisplayName(name = "") {
    const text = String(name || "")
      .replace(/^.*红原县/, "")
      .replace(/\s+/g, "")
    return text.length > 7 ? `${text.slice(0, 7)}…` : text || "来源区域"
  }
  getTradeFlowLabelHtml(row) {
    const sideClass = row.labelSide ? ` is-${String(row.labelSide).replace(/[^a-z0-9-]/gi, "")}` : ""
    return `<div class="trade-flow-card${sideClass}">
      <div class="flow-head"><span>${String(row.rank).padStart(2, "0")}</span><strong>${escapeHtml(this.getTradeFlowDisplayName(row.name))}</strong></div>
      <div class="flow-body"><em>${this.formatMapIntegerValue(row.value)}</em><b>${escapeHtml(row.unit || "头")}</b></div>
    </div>`
  }
  getTradeFlowLabelOffset(row, index) {
    return new Vector3(0, 0, 0)
  }
  getTradeSourceLabelSide(townMeta, index = 0) {
    const lng = Number(townMeta?.center?.[0])
    const lat = Number(townMeta?.center?.[1])
    if (Number.isFinite(lng) && lng < this.geoProjectionCenter[0] - 0.18) return "left"
    if (Number.isFinite(lng) && lng > this.geoProjectionCenter[0] + 0.2) return "right"
    if (Number.isFinite(lat) && lat > this.geoProjectionCenter[1] + 0.22) return "top"
    if (Number.isFinite(lat) && lat < this.geoProjectionCenter[1] - 0.26) return "bottom"
    return ["right", "left", "top", "bottom"][index % 4]
  }
  getTradeLocalPoint(lng, lat, localZ = 0) {
    const [x, y] = this.geoProjection([lng, lat])
    return new Vector3(x, -y, this.depth + localZ)
  }
  getTradeFlowPoint(row, localZ = 0) {
    return this.getTradeLocalPoint(row.lng, row.lat, localZ)
  }
  getLocalTradeFlowCurve(row, source, index) {
    const start = this.getTradeFlowPoint(row, 0.76)
    const end = this.getTradeLocalPoint(source.lng, source.lat, 0.86)
    const middle = start.clone().lerp(end, 0.5)
    const distance = start.distanceTo(end)
    middle.z += Math.max(0.32, Math.min(0.82, distance * 0.22 + index * 0.012))
    const side = new Vector3(-(end.y - start.y), end.x - start.x, 0)
    if (side.lengthSq() > 0) {
      side.normalize().multiplyScalar((index % 2 === 0 ? 1 : -1) * Math.min(0.18, 0.05 + distance * 0.02))
      middle.add(side)
    }
    return new CatmullRomCurve3([start, middle, end])
  }
  tradeGeoProjection(args) {
    return geoMercator().center(this.tradeGeoProjectionCenter).scale(this.tradeGeoProjectionScale).translate([0, 0])(args)
  }
  setTradeNationalMapVisible(visible) {
    const shouldShow = Boolean(visible)
    if (!shouldShow && !this.isTradeNationalMapActive && !this.tradeNationalMapGroup && !this.tradeFlowGroup) {
      return
    }
    if (shouldShow && !this.createTradeNationalMap()) {
      this.isTradeNationalMapActive = false
      return
    }
    this.isTradeNationalMapActive = shouldShow
    if (this.tradeNationalMapGroup) this.tradeNationalMapGroup.visible = shouldShow && this.mapAnimationComplete
    if (this.tradeNationalLabelGroup) this.tradeNationalLabelGroup.visible = shouldShow && this.mapAnimationComplete
    if (this.mapGroup) this.mapGroup.visible = !shouldShow
    if (this.labelGroup) this.labelGroup.visible = !shouldShow
    if (this.flyLineGroup) this.flyLineGroup.visible = !shouldShow && this.mapAnimationComplete
    if (this.flyLineFocusGroup) this.flyLineFocusGroup.visible = !shouldShow && this.mapAnimationComplete
    if (this.scatterGroup) this.scatterGroup.visible = !shouldShow && this.mapAnimationComplete
    if (this.topicLayerMaterial && shouldShow) this.topicLayerMaterial.opacity = 0
    if (shouldShow && this.mapAnimationComplete) {
      this.animateMapCamera(new Vector3(0, 11.4, 16.2), new Vector3(0, -0.05, 0), 0.65)
    } else if (!shouldShow && this.mapAnimationComplete) {
      this.applyLayerMapView(this.currentTopicLayer, { force: true })
    }
  }
  createTradeSourceMarker(row, index, maxValue, source, showFlowLine = true) {
    const color = new Color(index % 3 === 1 ? 0xffd76c : index % 3 === 2 ? 0x27f0c0 : 0x30dcff)
    const endPoint = this.getTradeFlowPoint(row, 0.72)
    if (source && showFlowLine) {
      const curve = this.getLocalTradeFlowCurve(row, source, index)
      const flowMaterial = new MeshBasicMaterial({
        color,
        transparent: true,
        opacity: 0.42,
        depthWrite: false,
        depthTest: false,
        fog: false,
        blending: AdditiveBlending,
      })
      const tubeRadius = 0.012 + Math.min(0.012, (toFiniteNumber(row.value) / Math.max(maxValue, 1)) * 0.01)
      const flowTube = new Mesh(new TubeGeometry(curve, 48, tubeRadius, 8, false), flowMaterial)
      flowTube.renderOrder = 31
      this.tradeFlowMeshGroup.add(flowTube)
      this.tradeFlowMaterials.push(flowMaterial)

      const pulseMaterial = new SpriteMaterial({
        map: this.assets.instance.getResource("point"),
        color,
        transparent: true,
        opacity: 0.92,
        depthWrite: false,
        depthTest: false,
        fog: false,
        blending: AdditiveBlending,
      })
      const pulse = new Sprite(pulseMaterial)
      const pulseScale = 0.12 + Math.min(0.08, (toFiniteNumber(row.value) / Math.max(maxValue, 1)) * 0.06)
      pulse.renderOrder = 36
      pulse.scale.set(pulseScale, pulseScale, pulseScale)
      pulse.userData = {
        curve,
        phase: (index * 0.13) % 1,
        speed: 0.16 + Math.min(0.16, (toFiniteNumber(row.value) / Math.max(maxValue, 1)) * 0.12),
      }
      pulse.position.copy(curve.getPointAt(pulse.userData.phase))
      this.tradeFlowPulseSprites.push(pulse)
      this.tradeFlowMeshGroup.add(pulse)
    }
    const endPointMaterial = new SpriteMaterial({
      map: this.assets.instance.getResource("point"),
      color,
      transparent: true,
      opacity: 0.95,
      depthWrite: false,
      depthTest: false,
      fog: false,
      blending: AdditiveBlending,
    })
    const endPointSprite = new Sprite(endPointMaterial)
    const endPointScale = 0.22 + Math.min(0.34, (toFiniteNumber(row.value) / Math.max(maxValue, 1)) * 0.28)
    endPointSprite.renderOrder = 34
    endPointSprite.scale.set(endPointScale, endPointScale, endPointScale)
    endPointSprite.position.copy(endPoint)
    this.tradeFlowMeshGroup.add(endPointSprite)

    const ringTexture = this.assets.instance.getResource("guangquan1")
    const ringMaterial = new MeshBasicMaterial({
      map: ringTexture,
      alphaMap: ringTexture,
      color,
      transparent: true,
      opacity: 0.52,
      depthWrite: false,
      depthTest: false,
      fog: false,
      blending: AdditiveBlending,
      side: DoubleSide,
    })
    const ringSize = 0.38 + Math.min(0.36, (toFiniteNumber(row.value) / Math.max(maxValue, 1)) * 0.28)
    const ring = new Mesh(new PlaneGeometry(ringSize, ringSize), ringMaterial)
    ring.renderOrder = 29
    ring.position.copy(this.getTradeFlowPoint(row, 0.5))
    this.tradeFlowMeshGroup.add(ring)
  }
  createTradeMarketMarker(source) {
    const pointTexture = this.assets.instance.getResource("point")
    const ringTexture = this.assets.instance.getResource("guangquan1")
    const sourcePoint = this.getTradeLocalPoint(source.lng, source.lat, 0.82)
    const markerMaterial = new SpriteMaterial({
      map: pointTexture,
      color: 0xfff36d,
      transparent: true,
      opacity: 1,
      depthWrite: false,
      depthTest: false,
      fog: false,
      blending: AdditiveBlending,
    })
    const marker = new Sprite(markerMaterial)
    marker.renderOrder = 35
    marker.scale.set(0.34, 0.34, 0.34)
    marker.position.copy(sourcePoint)
    this.tradeFlowMeshGroup.add(marker)

    const ringMaterial = new MeshBasicMaterial({
      map: ringTexture,
      alphaMap: ringTexture,
      color: 0xfff36d,
      transparent: true,
      opacity: 0.72,
      depthWrite: false,
      depthTest: false,
      fog: false,
      blending: AdditiveBlending,
      side: DoubleSide,
    })
    const ring = new Mesh(new PlaneGeometry(0.58, 0.58), ringMaterial)
    ring.renderOrder = 29
    ring.position.copy(this.getTradeLocalPoint(source.lng, source.lat, 0.58))
    this.tradeFlowMeshGroup.add(ring)
  }
  disposeObject3D(object) {
    object.geometry?.dispose?.()
    if (Array.isArray(object.material)) {
      object.material.forEach((material) => material?.dispose?.())
    } else {
      object.material?.dispose?.()
    }
  }
  updateTradeFlowPulses(delta = 0.016) {
    if (!this.tradeFlowGroup?.visible || !this.tradeFlowPulseSprites?.length) return
    this.tradeFlowPulseSprites.forEach((sprite) => {
      const curve = sprite.userData?.curve
      if (!curve) return
      const nextPhase = (toFiniteNumber(sprite.userData.phase) + toFiniteNumber(sprite.userData.speed, 0.16) * delta) % 1
      sprite.userData.phase = nextPhase
      sprite.position.copy(curve.getPointAt(nextPhase))
    })
  }
  updateTradeFlowLayer(ranking = this.currentTownRanking) {
    if (!isTradeFlowRanking(ranking)) {
      this.hideTradeFlowLayer()
      return
    }
    this.ensureTradeFlowGroup()
    const source = this.getTradeFlowSource(ranking)
    const rows = this.getTradeFlowRows(ranking)
    if (!source || !rows.length) {
      this.clearTradeFlowLayer()
      this.hideTradeFlowLayer()
      return
    }
    this.clearTradeFlowLayer()
    this.tradeFlowGroup.visible = this.mapAnimationComplete
    const showFlowLines = shouldShowTradeFlowLines(ranking)
    this.createTradeMarketMarker(source)
    const maxValue = Math.max(...rows.map((row) => row.value), 1)
    rows.forEach((row, index) => {
      this.createTradeSourceMarker(row, index, maxValue, source, showFlowLines)
    })

    const requiredLabelCount = rows.length + 1
    for (let index = this.tradeFlowLabels.length; index < requiredLabelCount; index += 1) {
      const label = this.label3d.create("", index === 0 ? "trade-market-label" : "trade-flow-label", true)
      this.label3d.setLabelStyle(label, index === 0 ? 0.014 : 0.0125, "x")
      label.setParent(this.tradeFlowLabelGroup)
      this.tradeFlowLabels.push(label)
    }
    const sourceLabel = this.tradeFlowLabels[0]
    sourceLabel.element.className = "trade-market-label"
    sourceLabel.init(
      this.getTradeMarketHtml(source, rows),
      this.getTradeLocalPoint(source.lng, source.lat, 1.46)
    )
    sourceLabel.visible = true
    sourceLabel.userData = { ...source, rows: rows.length }
    rows.forEach((row, index) => {
      const label = this.tradeFlowLabels[index + 1]
      label.element.className = "trade-flow-label"
      label.init(
        this.getTradeFlowLabelHtml(row),
        this.getTradeFlowPoint(row, 0.92 + Math.min(index, 5) * 0.035).add(this.getTradeFlowLabelOffset(row, index))
      )
      label.visible = true
      label.userData = { ...row }
    })
    this.tradeFlowLabels.slice(requiredLabelCount).forEach((label) => {
      label.visible = false
    })
  }
  hasTownRankingRows(ranking = this.currentTownRanking) {
    return this.getTownRankingRows(ranking, Number.POSITIVE_INFINITY, false).length > 0
  }
  formatMapRankingValue(value) {
    const number = toFiniteNumber(value)
    if (Math.abs(number) >= 100) return String(Math.round(number))
    if (Math.abs(number) >= 10) return String(Number(number.toFixed(1)))
    return String(Number(number.toFixed(2)))
  }
  formatMapCompactValue(value) {
    const number = toFiniteNumber(value)
    if (Math.abs(number) >= 10000) {
      return `${Number((number / 10000).toFixed(Math.abs(number) >= 100000 ? 1 : 2))}万`
    }
    if (Math.abs(number) >= 1000) return `${Number((number / 1000).toFixed(1))}千`
    return this.formatMapRankingValue(number)
  }
  formatMapIntegerValue(value) {
    const number = toFiniteNumber(value)
    return Math.round(number).toLocaleString("zh-CN")
  }
  getBarHeight(value, max) {
    const factor = 0.7
    const height = 4.0 * factor
    if (!max) return height * 0.25
    return Math.max(height * 0.12, height * (toFiniteNumber(value) / max))
  }
  createBarGeometry(geoHeight) {
    const factor = 0.7
    const geometry = new BoxGeometry(0.1 * factor, 0.1 * factor, geoHeight)
    geometry.translate(0, 0, geoHeight / 2)
    return geometry
  }
  getProvinceLabelHtml(data, index, meta) {
    return `<div class="provinces-label ${index > 4 ? "yellow" : ""}">
      <div class="provinces-label-wrap">
        <div class="label-main">
          <div class="label-title">${meta.title}</div>
          <div class="number"><span class="value" data-target-value="${data.value}">${this.formatMapRankingValue(data.value)}</span><span class="unit">${data.unit || meta.unit}</span></div>
          <div class="name">${data.name}</div>
        </div>
        <div class="no">${index + 1}</div>
      </div>
    </div>`
  }
  updateBarItem(areaBar, item, index, max, animate = true) {
    const geoHeight = this.getBarHeight(item.value, max)
    const [x, y] = this.geoProjection(item.centroid)
    areaBar.visible = true
    areaBar.geometry?.dispose?.()
    areaBar.geometry = this.createBarGeometry(geoHeight)
    areaBar.position.set(x, -y, this.depth + 0.45)
    areaBar.userData = {
      ...item,
      targetValue: item.value,
      geoHeight,
    }
    areaBar.children.forEach((child) => {
      child.geometry?.dispose?.()
      if (Array.isArray(child.material)) {
        child.material.forEach((material) => material?.dispose?.())
      } else {
        child.material?.dispose?.()
      }
    })
    areaBar.clear()
    areaBar.add(...this.createHUIGUANG(geoHeight, index > 3 ? 0xfffef4 : 0x77fbf5))
    if (animate) {
      gsap.to(areaBar.scale, {
        duration: 0.45,
        x: 1,
        y: 1,
        z: 1,
        ease: "power2.out",
      })
    } else if (this.mapAnimationComplete) {
      areaBar.scale.set(1, 1, 1)
    }
    return { x, y, geoHeight }
  }
  updateProvinceLabel(label, item, index, position, meta, animate = true) {
    const previousValue = toFiniteNumber(label.userData?.targetValue, item.value)
    label.visible = true
    label.userData = {
      ...item,
      targetValue: item.value,
    }
    label.position.copy(position)
    const title = label.element.querySelector(".label-title")
    const number = label.element.querySelector(".number .value")
    const unit = label.element.querySelector(".number .unit")
    const name = label.element.querySelector(".name")
    const rank = label.element.querySelector(".no")
    if (!title || !number || !unit || !name || !rank) {
      label.element.innerHTML = this.getProvinceLabelHtml(item, index, meta)
    } else {
      title.innerText = meta.title
      number.dataset.targetValue = String(item.value)
      number.innerText = this.formatMapRankingValue(item.value)
      unit.innerText = item.unit || meta.unit
      name.innerText = item.name
      rank.innerText = index + 1
    }
    const wrap = label.element.querySelector(".provinces-label-wrap")
    if (this.mapAnimationComplete) {
      wrap.style.opacity = "1"
      wrap.style.transform = "translate(50%, 0)"
    }
    if (animate && this.mapAnimationComplete) {
      const number = label.element.querySelector(".number .value")
      const fromValue = previousValue
      const tween = { value: fromValue }
      gsap.to(tween, {
        value: item.value,
        duration: 0.45,
        ease: "power2.out",
        onUpdate: () => {
          number.innerText = this.formatMapRankingValue(tween.value)
        },
        onComplete: () => {
          number.dataset.previousValue = String(item.value)
          number.innerText = this.formatMapRankingValue(item.value)
        },
      })
    }
  }
  clearTownNameLabelStat(label) {
    const wrap = label.element.querySelector(".town-stat-label")
    const value = label.element.querySelector(".town-stat-value")
    const unit = label.element.querySelector(".town-stat-unit")
    const rank = label.element.querySelector(".town-rank")
    const metric = label.element.querySelector(".town-stat-metric")
    wrap?.classList.remove("has-stat")
    if (value) value.innerText = ""
    if (unit) unit.innerText = ""
    if (rank) rank.innerText = ""
    if (metric) metric.innerText = ""
  }
  updateTownNameLabelStats(ranking = this.currentTownRanking) {
    if (!this.townNameLabels?.length) return
    const rows = this.getTownRankingRows(ranking, Number.POSITIVE_INFINITY, false)
    const meta = this.getTownRankingMeta(ranking)
    const rowByName = new Map(rows.map((row) => [normalizeTownName(row.name), row]))
    const canShowMissingStat = Boolean(meta.showMissingTownStats && ranking && rows.length && (meta.metricLabel || meta.unit || meta.title))
    this.townNameLabels.forEach((label) => {
      const townName = label.userData?.townName || label.userData?.name
      const rankedRow = rowByName.get(normalizeTownName(townName))
      if (meta.onlyRankedTownLabels && !rankedRow) {
        this.clearTownNameLabelStat(label)
        label.visible = false
        return
      }
      const row = rankedRow || (canShowMissingStat
        ? { name: townName, value: 0, unit: meta.unit, metricLabel: meta.metricLabel, rank: null }
        : null)
      if (!row) {
        this.clearTownNameLabelStat(label)
        return
      }
      const wrap = label.element.querySelector(".town-stat-label")
      const name = label.element.querySelector(".town-name")
      const value = label.element.querySelector(".town-stat-value")
      const unit = label.element.querySelector(".town-stat-unit")
      const rank = label.element.querySelector(".town-rank")
      const metric = label.element.querySelector(".town-stat-metric")
      wrap?.classList.add("has-stat")
      if (name) name.innerText = townName
      if (metric) metric.innerText = row.metricLabel || meta.metricLabel || ""
      if (value) value.innerText = this.formatMapRankingValue(row.value)
      if (unit) unit.innerText = row.unit || meta.unit || ""
      if (rank) rank.innerText = row.rank ? `第${row.rank}名` : ""
      label.userData = {
        ...label.userData,
        ...row,
        townName,
        targetValue: row.value,
      }
    })
  }
  getInfoPointHtml(data, meta = this.getTownRankingMeta()) {
    const metricLabel = String(data.metricLabel || meta.metricLabel || meta.title || "统计指标").replace(/排行$/, "")
    const rank = Number(data.rank)
    const rankText = Number.isFinite(rank) ? String(rank).padStart(2, "0") : "--"
    return `<div class="info-point-wrap ${rank <= 3 ? "is-prime" : ""}">
      <div class="info-point-wrap-inner">
        <div class="info-point-head">
          <span class="rank">${rankText}</span>
          <strong>${escapeHtml(data.name || "红原县")}</strong>
        </div>
        <div class="info-point-metric">${escapeHtml(metricLabel)}</div>
        <div class="info-point-value">
          <span>${this.formatMapRankingValue(data.value)}</span>
          <em>${escapeHtml(data.unit || meta.unit || "")}</em>
        </div>
      </div>
    </div>`
  }
  updateInfoPointStats() {
    if (!this.infoPointSprites?.length || !this.infoLabelElement?.length) return
    if (!this.showTownRankingPointMarkers) {
      this.clearInfoPointStats()
      return
    }
    const rankingRows = this.getTownRankingRows(this.currentTownRanking, Number.POSITIVE_INFINITY, false)
    if (!rankingRows.length) {
      this.clearInfoPointStats()
      return
    }
    const meta = this.getTownRankingMeta()
    const visibleRows = rankingRows.slice(0, this.infoPointSprites.length)
    const max = Math.max(...visibleRows.map((item) => item.value), 1)
    this.infoPointSprites.forEach((sprite, index) => {
      const row = visibleRows[index]
      const label = this.infoLabelElement[index]
      if (!row) {
        sprite.visible = false
        sprite.scale.set(0, 0, 0)
        if (label) label.visible = false
        return
      }
      const [x, y] = this.geoProjection(row.centroid || row.center)
      const position = [x, -y, this.depth + 0.72]
      sprite.userData = {
        ...sprite.userData,
        ...row,
        position,
        value: row.value,
        unit: row.unit || meta.unit,
        rank: row.rank,
        metricTitle: meta.title,
        index,
      }
      sprite.position.set(...position)
      sprite.visible = this.showTownRankingPointMarkers
      const scale = 0.78 + (row.value / max) * 0.44
      sprite.scale.set(scale, scale, scale)
      if (label) {
        const labelOffsetX = (index % 2 === 0 ? 0.22 : -0.18) + (index === 0 ? 0.12 : 0)
        const labelOffsetY = index % 3 === 0 ? 0.08 : -0.04
        label.position.set(x + labelOffsetX, -y + labelOffsetY, this.depth + 1.88 + Math.min(index, 4) * 0.03)
        label.userData = { ...row, index }
        label.element.innerHTML = this.getInfoPointHtml(row, meta)
      }
    })
    const activeLabel = this.infoLabelElement[this.infoPointIndex]
    if (activeLabel && this.showTownRankingPointMarkers && this.mapAnimationComplete) activeLabel.visible = true
    if (this.showTownRankingPointMarkers && this.mapAnimationComplete && !this.infoPointLabelTime) {
      this.createInfoPointLabelLoop()
    }
  }
  clearInfoPointStats() {
    clearInterval(this.infoPointLabelTime)
    this.infoPointLabelTime = null
    this.infoPointIndex = 0
    if (this.InfoPointGroup) {
      this.InfoPointGroup.visible = false
    }
    this.infoLabelElement?.forEach((label) => {
      label.visible = false
    })
    this.infoPointSprites?.forEach((sprite) => {
      sprite.visible = false
      sprite.scale.set(0, 0, 0)
    })
  }
  createBar() {
    const data = this.getTownRankingRows()
    const meta = this.getTownRankingMeta()
    const barGroup = new Group()
    this.barGroup = barGroup
    const max = Math.max(...data.map((item) => item.value), 1)
    this.allBar = []
    this.allBarMaterial = []
    this.allGuangquan = []
    this.allProvinceLabel = []
    data.map((item, index) => {
      const geoHeight = this.getBarHeight(item.value, max)
      const material = new MeshBasicMaterial({
        color: 0xffffff,
        transparent: true,
        opacity: 0,
        depthTest: false,
        fog: false,
      })
      new GradientShader(material, {
        uColor1: index > 3 ? 0xfbdf88 : 0x50bbfe,
        uColor2: index > 3 ? 0xfffef4 : 0x77fbf5,
        size: geoHeight,
        dir: "y",
      })
      const mesh = new Mesh(this.createBarGeometry(geoHeight), material)
      mesh.renderOrder = 5
      const [x, y] = this.geoProjection(item.centroid)
      mesh.position.set(x, -y, this.depth + 0.45)
      mesh.scale.set(1, 1, 0)
      mesh.userData = { ...item, targetValue: item.value, geoHeight }
      const guangQuan = this.createQuan(new Vector3(x, this.depth + 0.44, y), index)
      mesh.add(...this.createHUIGUANG(geoHeight, index > 3 ? 0xfffef4 : 0x77fbf5))
      barGroup.add(mesh)
      barGroup.rotation.x = -Math.PI / 2
      this.allBar.push(mesh)
      this.allBarMaterial.push(material)
      this.allGuangquan.push(guangQuan)
    })
    this.scene.add(barGroup)
    this.updateTownNameLabelStats(this.currentTownRanking)
  }
  setTownRanking(ranking) {
    this.currentTownRanking = ranking
    const meta = this.getTownRankingMeta(ranking)
    this.showTownRankingBars = Boolean(meta.showBars)
    this.showTownRankingPointMarkers = Boolean(meta.showPointMarkers)
    if (meta.hideMapLabels) {
      this.hideAllMapStatisticLabels()
      if (this.mapGroup) this.mapGroup.visible = true
      if (this.labelGroup) this.labelGroup.visible = true
      if (this.flyLineGroup) this.flyLineGroup.visible = this.mapAnimationComplete
      if (this.flyLineFocusGroup) this.flyLineFocusGroup.visible = this.mapAnimationComplete
      if (this.scatterGroup) this.scatterGroup.visible = this.mapAnimationComplete
      return
    }
    if (isTradeFlowRanking(ranking)) {
      this.setTradeNationalMapVisible(false)
      this.updateTownNameLabelStats(null)
      this.setTownNameLabelsVisible(false)
      this.hideVillageRankingLabels()
      this.clearInfoPointStats()
      if (this.mapGroup) this.mapGroup.visible = true
      if (this.labelGroup) this.labelGroup.visible = false
      if (this.flyLineGroup) this.flyLineGroup.visible = false
      if (this.flyLineFocusGroup) this.flyLineFocusGroup.visible = false
      if (this.scatterGroup) this.scatterGroup.visible = false
      if (this.allBar?.length) {
        this.allBar.forEach((bar) => {
          bar.visible = false
          bar.scale.set(1, 1, 0)
        })
        this.allBarMaterial?.forEach((material) => {
          material.opacity = 0
        })
        this.allProvinceLabel?.forEach((label) => {
          label.visible = false
        })
        this.allGuangquan?.forEach((group) => {
          group.visible = false
        })
      }
      this.updateTradeFlowLayer(ranking)
      if (ranking?.mapView || ranking?.cameraPosition) this.applyLayerMapView(null)
      return
    }
    this.setTradeNationalMapVisible(false)
    this.hideTradeFlowLayer()
    if (this.mapGroup) this.mapGroup.visible = true
    if (this.labelGroup) this.labelGroup.visible = true
    if (this.flyLineGroup) this.flyLineGroup.visible = this.mapAnimationComplete
    if (this.flyLineFocusGroup) this.flyLineFocusGroup.visible = this.mapAnimationComplete
    if (this.scatterGroup) this.scatterGroup.visible = this.mapAnimationComplete
    if (isVillageRanking(ranking)) {
      this.updateTownNameLabelStats(null)
      this.setTownNameLabelsVisible(!shouldHideTownNames(ranking))
      this.clearInfoPointStats()
      this.updateVillageRankingLabels(ranking)
      if (this.allBar?.length) {
        this.allBar.forEach((bar) => {
          bar.visible = false
          bar.scale.set(1, 1, 0)
        })
        this.allBarMaterial?.forEach((material) => {
          material.opacity = 0
        })
        this.allProvinceLabel?.forEach((label) => {
          label.visible = false
        })
        this.allGuangquan?.forEach((group) => {
          group.visible = false
        })
      }
      return
    }
    this.setTownNameLabelsVisible(!meta.calloutMode && !meta.hideTownNames)
    this.hideVillageRankingLabels()
    if (meta.calloutMode) {
      this.updateTownNameLabelStats(null)
    } else {
      this.updateTownNameLabelStats(ranking)
    }
    if (!this.allBar?.length) return
    if (!this.showTownRankingBars) {
      this.allBar.forEach((bar) => {
        bar.visible = false
        bar.scale.set(1, 1, 0)
      })
      this.allBarMaterial?.forEach((material) => {
        material.opacity = 0
      })
      this.allProvinceLabel?.forEach((label) => {
        label.visible = false
      })
      this.allGuangquan?.forEach((group) => {
        group.visible = false
      })
      if (this.showTownRankingPointMarkers) {
        if (this.InfoPointGroup) {
          this.InfoPointGroup.visible = this.mapAnimationComplete && this.hasTownRankingRows(ranking)
        }
        this.updateInfoPointStats()
      } else {
        this.clearInfoPointStats()
      }
      return
    }
    const data = this.getTownRankingRows(ranking, 7, false)
    if (!data.length) {
      this.allBar.forEach((bar) => {
        bar.visible = false
        bar.scale.set(1, 1, 0)
      })
      this.allProvinceLabel?.forEach((label) => {
        label.visible = false
      })
      this.allGuangquan?.forEach((group) => {
        group.visible = false
      })
      this.clearInfoPointStats()
      return
    }
    if (this.InfoPointGroup) {
      this.InfoPointGroup.visible = this.showTownRankingPointMarkers && this.mapAnimationComplete
    }
    this.infoPointSprites?.forEach((sprite) => {
      sprite.visible = this.showTownRankingPointMarkers
    })
    const max = Math.max(...data.map((item) => item.value), 1)
    this.allBar.forEach((bar, index) => {
      const visible = index < data.length
      bar.visible = visible
      if (!visible) bar.scale.set(1, 1, 0)
    })
    this.allProvinceLabel?.forEach((label) => {
      label.visible = false
    })
    this.allGuangquan.forEach((group, index) => {
      group.visible = index < data.length
    })
    data.forEach((item, index) => {
      const areaBar = this.allBar[index]
      const guangQuan = this.allGuangquan[index]
      if (!areaBar) return
      const { x, y } = this.updateBarItem(areaBar, item, index, max, this.mapAnimationComplete)
      if (guangQuan) {
        guangQuan.visible = true
        guangQuan.position.set(0, 0, 0)
        guangQuan.children.forEach((mesh) => {
          mesh.position.set(x, this.depth + 0.44, y)
          if (this.mapAnimationComplete) mesh.scale.set(1, 1, 1)
        })
      }
    })
    this.updateInfoPointStats()
  }
  createEvent() {
    let objectsHover = []
    const reset = (mesh) => {
      mesh.traverse((obj) => {
        if (obj.isMesh) {
          obj.material = this.defaultMaterial
        }
      })
    }
    const move = (mesh) => {
      mesh.traverse((obj) => {
        if (obj.isMesh) {
          obj.material = this.defaultLightMaterial
        }
      })
    }
    this.eventElement.map((mesh) => {
      this.interactionManager.add(mesh)
      mesh.addEventListener("mousedown", (ev) => {
        this.handleMapClick(ev)
      })
      mesh.addEventListener("mouseover", (event) => {
        if (!objectsHover.includes(event.target.parent)) {
          objectsHover.push(event.target.parent)
        }
        document.body.style.cursor = "pointer"
        move(event.target.parent)
      })
      mesh.addEventListener("mouseout", (event) => {
        objectsHover = objectsHover.filter((n) => n.userData.name !== event.target.parent.userData.name)
        if (objectsHover.length > 0) {
          const mesh = objectsHover[objectsHover.length - 1]
        }
        reset(event.target.parent)
        document.body.style.cursor = "default"
      })
    })
  }
  createHUIGUANG(h, color) {
    let geometry = new PlaneGeometry(0.35, h)
    geometry.translate(0, h / 2, 0)
    const texture = this.assets.instance.getResource("huiguang")
    texture.colorSpace = SRGBColorSpace
    texture.wrapS = RepeatWrapping
    texture.wrapT = RepeatWrapping
    let material = new MeshBasicMaterial({
      color: color,
      map: texture,
      transparent: true,
      opacity: 0.4,
      depthWrite: false,
      side: DoubleSide,
      blending: AdditiveBlending,
    })
    let mesh = new Mesh(geometry, material)
    mesh.renderOrder = 10
    mesh.rotateX(Math.PI / 2)
    let mesh2 = mesh.clone()
    let mesh3 = mesh.clone()
    mesh2.rotateY((Math.PI / 180) * 60)
    mesh3.rotateY((Math.PI / 180) * 120)
    return [mesh, mesh2, mesh3]
  }
  createQuan(position, index) {
    const guangquan1 = this.assets.instance.getResource("guangquan1")
    const guangquan2 = this.assets.instance.getResource("guangquan2")
    let geometry = new PlaneGeometry(0.5, 0.5)
    let material1 = new MeshBasicMaterial({
      color: 0xffffff,
      map: guangquan1,
      alphaMap: guangquan1,
      opacity: 1,
      transparent: true,
      depthTest: false,
      fog: false,
      blending: AdditiveBlending,
    })
    let material2 = new MeshBasicMaterial({
      color: 0xffffff,
      map: guangquan2,
      alphaMap: guangquan2,
      opacity: 1,
      transparent: true,
      depthTest: false,
      fog: false,
      blending: AdditiveBlending,
    })
    let mesh1 = new Mesh(geometry, material1)
    let mesh2 = new Mesh(geometry, material2)
    mesh1.renderOrder = 6
    mesh2.renderOrder = 6
    mesh1.rotateX(-Math.PI / 2)
    mesh2.rotateX(-Math.PI / 2)
    mesh1.position.copy(position)
    mesh2.position.copy(position)
    mesh2.position.y -= 0.001
    mesh1.scale.set(0, 0, 0)
    mesh2.scale.set(0, 0, 0)
    this.quanGroup = new Group()
    this.quanGroup.add(mesh1, mesh2)
    this.scene.add(this.quanGroup)
    this.time.on("tick", () => {
      mesh1.rotation.z += 0.05
    })
    return this.quanGroup
  }
  // 创建扩散
  createDiffuse() {
    let geometry = new PlaneGeometry(200, 200)
    let material = new MeshBasicMaterial({
      color: 0x000000,
      depthWrite: false,
      // depthTest: false,
      transparent: true,
      blending: CustomBlending,
    })
    // 使用CustomBlending  实现混合叠加
    material.blendEquation = AddEquation
    material.blendSrc = DstColorFactor
    material.blendDst = OneFactor
    let diffuse = new DiffuseShader({
      material,
      time: this.time,
      size: 60,
      diffuseSpeed: 8.0,
      diffuseColor: 0x71918e,
      diffuseWidth: 2.0,
      callback: (pointShader) => {
        const timer = setTimeout(() => {
          this.addSceneTween(gsap.to(pointShader.uniforms.uTime, {
            value: 4,
            repeat: -1,
            duration: 6,
            ease: "power1.easeIn",
          }))
        }, 3)
        this.addSceneTimer(timer)
      },
    })
    let mesh = new Mesh(geometry, material)
    mesh.renderOrder = 3
    mesh.rotation.x = -Math.PI / 2
    mesh.position.set(0, 0.21, 0)
    this.scene.add(mesh)
  }
  createGrid() {
    new Grid(this, {
      gridSize: 54,
      gridDivision: 24,
      gridColor: 0x1e5f8f,
      shapeSize: 0.55,
      shapeColor: 0x327faf,
      pointSize: 0.085,
      pointColor: 0x1e6ca3,
      pointBlending: AdditiveBlending,
    })
  }
  createBottomBg() {
    let geometry = new PlaneGeometry(24, 24)
    const texture = this.assets.instance.getResource("ocean")
    texture.colorSpace = SRGBColorSpace
    texture.wrapS = RepeatWrapping
    texture.wrapT = RepeatWrapping
    texture.repeat.set(1, 1)
    let material = new MeshBasicMaterial({
      map: texture,
      opacity: 0.92,
      fog: false,
    })
    let mesh = new Mesh(geometry, material)
    mesh.rotation.x = -Math.PI / 2
    mesh.position.set(0, -0.7, 0)
    this.scene.add(mesh)
  }
  createChinaBlurLine() {
    let geometry = new PlaneGeometry(154, 154)
    const texture = this.assets.instance.getResource("chinaBlurLine")
    texture.colorSpace = SRGBColorSpace
    texture.wrapS = RepeatWrapping
    texture.wrapT = RepeatWrapping
    texture.generateMipmaps = false
    texture.minFilter = NearestFilter
    texture.repeat.set(1, 1)
    let material = new MeshBasicMaterial({
      color: 0x3f82cd,
      alphaMap: texture,
      transparent: true,
      opacity: 0.62,
    })
    let mesh = new Mesh(geometry, material)
    mesh.rotateX(-Math.PI / 2)
    mesh.position.set(-19.3, -0.5, -19.7)
    this.scene.add(mesh)
  }

  createLabel() {
    let self = this
    let labelGroup = this.labelGroup
    let label3d = this.label3d
    let otherLabel = []
    let townNameLabels = []
    chinaData.map((province) => {
      if (province.hide == true) return false
      let label = labelStyle01(province, label3d, labelGroup)
      otherLabel.push(label)
      townNameLabels.push(label)
    })
    this.otherLabel = otherLabel
    this.townNameLabels = townNameLabels
    function labelStyle01(province, label3d, labelGroup) {
      let label = label3d.create("", `china-label ${province.blur ? " blur" : ""}`, true)
      const [x, y] = self.geoProjection(province.center)
      label.init(
        `<div class="other-label town-stat-label">
          <div class="town-name-row"><img class="label-icon" src="${labelIcon}"><span class="town-name">${province.name}</span><span class="town-rank"></span></div>
          <div class="town-stat-row"><span class="town-stat-metric"></span><span class="town-stat-value"></span><span class="town-stat-unit"></span></div>
        </div>`,
        new Vector3(x, -y, self.depth + 0.85)
      )
      label3d.setLabelStyle(label, 0.018, "x")
      label.setParent(labelGroup)
      label.userData = {
        ...province,
        townName: province.name,
      }
      return label
    }
  }
  createRotateBorder() {
    let max = 13.2
    let rotationBorder1 = this.assets.instance.getResource("rotationBorder1")
    let rotationBorder2 = this.assets.instance.getResource("rotationBorder2")
    let plane01 = new Plane(this, {
      width: max * 1.178,
      needRotate: true,
      rotateSpeed: 0.001,
      material: new MeshBasicMaterial({
        map: rotationBorder1,
        color: 0x48afff,
        transparent: true,
        opacity: 0.26,
        side: DoubleSide,
        depthWrite: false,
        blending: AdditiveBlending,
      }),
      position: new Vector3(0, 0.31, 0),
    })
    plane01.instance.rotation.x = -Math.PI / 2
    plane01.instance.renderOrder = 6
    plane01.instance.scale.set(0, 0, 0)
    plane01.setParent(this.scene)
    let plane02 = new Plane(this, {
      width: max * 1.116,
      needRotate: true,
      rotateSpeed: -0.004,
      material: new MeshBasicMaterial({
        map: rotationBorder2,
        color: 0x48afff,
        transparent: true,
        opacity: 0.5,
        side: DoubleSide,
        depthWrite: false,
        blending: AdditiveBlending,
      }),
      position: new Vector3(0, 0.33, 0),
    })
    plane02.instance.rotation.x = -Math.PI / 2
    plane02.instance.renderOrder = 6
    plane02.instance.scale.set(0, 0, 0)
    plane02.setParent(this.scene)
    this.rotateBorder1 = plane01.instance
    this.rotateBorder2 = plane02.instance
  }
  createFlyLine() {
    this.flyLineGroup = new Group()
    this.flyLineGroup.visible = false
    this.scene.add(this.flyLineGroup)
  }
  // 创建焦点
  createFocus() {
    this.flyLineFocusGroup.clear()
  }
  // 创建粒子
  createParticles() {
    this.particles = new Particles(this, {
      num: 10,
      range: 30,
      dir: "up",
      speed: 0.05,
      material: new PointsMaterial({
        map: Particles.createTexture(),
        size: 1,
        color: 0x00eeee,
        transparent: true,
        opacity: 1,
        depthTest: false,
        depthWrite: false,
        vertexColors: true,
        blending: AdditiveBlending,
        sizeAttenuation: true,
      }),
    })
    this.particleGroup = new Group()
    this.scene.add(this.particleGroup)
    this.particleGroup.rotation.x = -Math.PI / 2
    this.particles.setParent(this.particleGroup)
    this.particles.enable = true
    this.particleGroup.visible = true
  }
  createScatter() {
    this.scatterGroup = new Group()
    this.scatterGroup.visible = false
    this.scatterGroup.rotation.x = -Math.PI / 2
    this.scene.add(this.scatterGroup)
    const texture = this.assets.instance.getResource("arrow")
    const material = new SpriteMaterial({
      map: texture,
      color: 0xfffef4,
      fog: false,
      transparent: true,
      depthTest: false,
    })
    let scatterAllData = sortByValue(scatterData)
    let max = scatterAllData[0].value
    scatterAllData.map((data) => {
      const sprite = new Sprite(material)
      sprite.renderOrder = 23
      let scale = 0.1 + (data.value / max) * 0.2
      sprite.scale.set(scale, scale, scale)
      let [x, y] = this.geoProjection([data.lng, data.lat])
      sprite.position.set(x, -y, this.depth + 0.45)
      sprite.userData.position = [x, -y, this.depth + 0.45]
      this.scatterGroup.add(sprite)
    })
  }
  createInfoPoint() {
    let self = this
    this.InfoPointGroup = new Group()
    this.scene.add(this.InfoPointGroup)
    this.InfoPointGroup.visible = false
    this.InfoPointGroup.rotation.x = -Math.PI / 2
    this.infoPointIndex = 0
    this.infoPointLabelTime = null
    this.infoLabelElement = []
    this.infoPointSprites = []
    let label3d = this.label3d
    const texture = this.assets.instance.getResource("point")
    let colors = [0xfffef4, 0x77fbf5]
    let infoAllData = sortByValue(infoData)
    let max = infoAllData[0].value
    infoAllData.map((data, index) => {
      const material = new SpriteMaterial({
        map: texture,
        color: colors[index % colors.length],
        fog: false,
        transparent: true,
        depthTest: false,
      })
      const sprite = new Sprite(material)
      sprite.renderOrder = 23
      let scale = 0.7 + (data.value / max) * 0.4
      sprite.scale.set(scale, scale, scale)
      let [x, y] = this.geoProjection([data.lng, data.lat])
      let position = [x, -y, this.depth + 0.7]
      sprite.position.set(...position)
      sprite.userData.position = [...position]
      sprite.userData = {
        position: [x, -y, this.depth + 0.7],
        name: data.name,
        value: data.value,
        level: data.level,
        index: index,
      }
      this.InfoPointGroup.add(sprite)
      let label = infoLabel(data, label3d, this.InfoPointGroup, index)
      this.infoLabelElement.push(label)
      this.infoPointSprites.push(sprite)
      this.interactionManager.add(sprite)
      sprite.addEventListener("mousedown", (ev) => {
        if (this.clicked || !this.InfoPointGroup.visible) return false
        this.clicked = true
        this.infoPointIndex = ev.target.userData.index
        this.infoLabelElement.map((label) => {
          label.visible = false
        })
        label.visible = true
        this.createInfoPointLabelLoop()
      })
      sprite.addEventListener("mouseup", (ev) => {
        this.clicked = false
      })
      sprite.addEventListener("mouseover", (event) => {
        document.body.style.cursor = "pointer"
      })
      sprite.addEventListener("mouseout", (event) => {
        document.body.style.cursor = "default"
      })
    })
    function infoLabel(data, label3d, labelGroup, index) {
      let label = label3d.create("", "info-point", true)
      const [x, y] = self.geoProjection([data.lng, data.lat])
      label.init(
        self.getInfoPointHtml({ ...data, value: 0, rank: index + 1, unit: "" }, self.getTownRankingMeta()),
        new Vector3(x, -y, self.depth + 1.9)
      )
      label3d.setLabelStyle(label, 0.015, "x")
      label.setParent(labelGroup)
      label.visible = false
      return label
    }
  }
  createInfoPointLabelLoop() {
    if (this.scenePaused || !this.infoLabelElement?.length) return
    clearInterval(this.infoPointLabelTime)
    this.infoPointLabelTime = setInterval(() => {
      this.infoPointIndex++
      if (this.infoPointIndex >= this.infoLabelElement.length) {
        this.infoPointIndex = 0
      }
      this.infoLabelElement.map((label, i) => {
        if (this.infoPointIndex === i) {
          label.visible = true
        } else {
          label.visible = false
        }
      })
    }, 3000)
  }
  createStorke() {
    const mapStroke = this.staticMapData.mapStroke
    const texture = this.assets.instance.getResource("pathLine3")
    texture.wrapS = texture.wrapT = RepeatWrapping
    texture.repeat.set(2, 1)

    let pathLine = new Line(this, {
      geoProjectionCenter: this.geoProjectionCenter,
      geoProjectionScale: this.geoProjectionScale,
      position: new Vector3(0, 0, this.depth + 0.24),
      data: mapStroke,
      material: new MeshBasicMaterial({
        color: 0x2bc4dc,
        map: texture,
        alphaMap: texture,
        fog: false,
        transparent: true,
        opacity: 1,
        blending: AdditiveBlending,
      }),
      type: "Line3",
      renderOrder: 22,
      tubeRadius: 0.03,
    })
    // 设置父级
    this.focusMapGroup.add(pathLine.lineGroup)
    this.time.on("tick", () => {
      texture.offset.x += 0.005
    })
  }

  async handleMapClick(event) {
    if (!this.currentTopicLayer) return
    const screen = this.getScreenPointFromEvent(event)
    const lngLat = this.getLngLatFromEvent(event)
    if (!lngLat) {
      this.clearSelectedFeature()
      this.callbacks.onFeatureInfo?.({ feature: null })
      return
    }
    if (this.boundaryDrawingEnabled) {
      const sourceEvent = event?.data?.originalEvent || event?.originalEvent || event
      if (sourceEvent?.__analysisBoundaryHandled) return
      this.addBoundaryDrawPoint(lngLat)
      return
    }
    if (this.shouldClipLayerToHongyuanBoundary(this.currentTopicLayer) && !this.isLngLatInHongyuanBoundary(lngLat)) {
      this.clearSelectedFeature()
      this.callbacks.onFeatureInfo?.({ feature: null })
      this.callbacks.onLayerStatus?.({
        type: "success",
        text: "当前点击位置不在红原县边界内",
      })
      return
    }
    if (this.shouldClipLayerToLayerBounds(this.currentTopicLayer) && !this.isLngLatInLayerClipBounds(lngLat, this.currentTopicLayer)) {
      this.clearSelectedFeature()
      this.callbacks.onFeatureInfo?.({ feature: null })
      this.callbacks.onLayerStatus?.({
        type: "success",
        text: "当前点击位置不在图层显示范围内",
      })
      return
    }
    if (isReadOnlyTopicLayer(this.currentTopicLayer)) {
      this.clearSelectedFeature()
      this.callbacks.onFeatureInfo?.({ feature: null })
      return
    }
    if (isVectorTopicLayer(this.currentTopicLayer)) {
      const picked = this.pickVectorFeatureAtPoint(lngLat, screen)
      const feature = picked?.feature || picked
      let featureScreen = picked?.screen || screen
      if (feature) {
        if (this.shouldClipLayerToHongyuanBoundary(this.currentTopicLayer)) {
          this.clearSelectedGeometryGroup()
          this.setSelectedVectorFeatures([feature])
        } else {
          this.showSelectedFeature(feature)
        }
        if (this.currentTopicLayer?.key === "yak-industry-points") {
          this.setSelectedVectorFeatures([feature])
          featureScreen = this.getPointFeatureScreenPoint(feature) || featureScreen
        }
        this.callbacks.onFeatureInfo?.({
          feature: {
            id: feature.properties?.id || feature.properties?.feature_id || feature.properties?.project_name,
            properties: feature.properties || {},
            feature,
            layer: this.currentTopicLayer,
            lngLat,
          },
          screen: featureScreen,
        })
        this.callbacks.onLayerStatus?.({
          type: "success",
          text: "真实矢量要素已选中",
        })
      } else {
        this.clearSelectedFeature()
        this.callbacks.onFeatureInfo?.({ feature: null })
        this.callbacks.onLayerStatus?.({
          type: "success",
          text: "当前点击位置无生态保护要素",
        })
      }
      return
    }
    const pixel = this.lngLatToWmsPixel(lngLat)
    this.callbacks.onFeatureInfo?.({ feature: null })
    this.callbacks.onFeatureLoading?.(true)
    try {
      const response = await getFeatureInfo(this.currentTopicLayer, pixel, { size: WMS_IMAGE_SIZE, buffer: 10 })
      const feature = normalizeFeatureInfo(response)
      const fallbackFeature = feature ? null : await this.queryFallbackFeatureInfo(lngLat)
      const hitFeature = feature || fallbackFeature?.feature
      if (hitFeature) {
        const enrichedFeature = await this.enrichFeatureInfo(hitFeature, pixel, lngLat)
        this.showSelectedFeature(enrichedFeature.feature || enrichedFeature)
        this.callbacks.onFeatureInfo?.({ feature: { ...enrichedFeature, layer: fallbackFeature?.layer || this.currentTopicLayer, lngLat }, screen })
      } else {
        this.clearSelectedFeature()
        this.callbacks.onFeatureInfo?.({ feature: null })
      }
      if (!hitFeature) {
        this.callbacks.onLayerStatus?.({
          type: "success",
          text: "当前点击位置无图斑",
        })
      }
    } catch (error) {
      this.clearSelectedFeature()
      this.callbacks.onFeatureInfo?.({ feature: null })
      this.callbacks.onLayerStatus?.({
        type: "error",
        text: "图斑信息查询失败",
      })
    } finally {
      this.callbacks.onFeatureLoading?.(false)
    }
  }
  handleCanvasBoundaryPointerDown(event) {
    if (!this.boundaryDrawingEnabled) return
    const lngLat = this.getLngLatFromCanvasEvent(event)
    if (lngLat && this.addBoundaryDrawPoint(lngLat)) {
      event.__analysisBoundaryHandled = true
    }
    event.preventDefault?.()
    event.stopPropagation?.()
  }
  handleCanvasBoundaryDoubleClick(event) {
    if (!this.boundaryDrawingEnabled) return
    const lngLat = this.getLngLatFromCanvasEvent(event)
    if ((this.boundaryDrawPoints || []).length < 3 && lngLat) {
      this.addBoundaryDrawPoint(lngLat)
    }
    if ((this.boundaryDrawPoints || []).length >= 3) {
      this.finishBoundaryDrawing()
      event.__analysisBoundaryHandled = true
    }
    event.preventDefault?.()
    event.stopPropagation?.()
  }
  getLngLatFromCanvasEvent(event) {
    if (!this.camera?.instance || !this.focusMapGroup || !this.canvas) return null
    const rect = this.canvas.getBoundingClientRect()
    if (!rect.width || !rect.height) return null
    const raycaster = this.interactionManager?.raycaster
    if (!raycaster?.setFromCamera) return null
    const pointer = {
      x: ((event.clientX - rect.left) / rect.width) * 2 - 1,
      y: -((event.clientY - rect.top) / rect.height) * 2 + 1,
    }
    raycaster.setFromCamera(pointer, this.camera.instance)
    const intersections = raycaster.intersectObjects(this.eventElement || [], true)
    const hitPoint = intersections[0]?.point || this.getRayMapPlaneIntersection(raycaster.ray)
    if (!hitPoint) return null
    const localPoint = this.focusMapGroup.worldToLocal(hitPoint.clone())
    const projection = geoMercator()
      .center(this.geoProjectionCenter)
      .scale(this.geoProjectionScale)
      .translate([0, 0])
    const lngLat = projection.invert([localPoint.x, -localPoint.y])
    if (!lngLat) return null
    const lng = Number(lngLat[0])
    const lat = Number(lngLat[1])
    if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null
    if (
      lng < HONGYUAN_BOUNDS.west - 0.03
      || lng > HONGYUAN_BOUNDS.east + 0.03
      || lat < HONGYUAN_BOUNDS.south - 0.03
      || lat > HONGYUAN_BOUNDS.north + 0.03
    ) {
      return this.getFallbackLngLatFromPointer(event, rect)
    }
    return { lng, lat }
  }
  getFallbackLngLatFromPointer(event, rect = this.canvas?.getBoundingClientRect?.()) {
    if (!rect?.width || !rect?.height) return null
    const xRatio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width))
    const yRatio = Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height))
    const lng = HONGYUAN_BOUNDS.west + (HONGYUAN_BOUNDS.east - HONGYUAN_BOUNDS.west) * xRatio
    const lat = HONGYUAN_BOUNDS.north - (HONGYUAN_BOUNDS.north - HONGYUAN_BOUNDS.south) * yRatio
    return { lng, lat }
  }
  getRayMapPlaneIntersection(ray) {
    if (!ray?.origin || !ray?.direction || !this.focusMapGroup) return null
    const originLocal = this.focusMapGroup.worldToLocal(ray.origin.clone())
    const endLocal = this.focusMapGroup.worldToLocal(ray.origin.clone().add(ray.direction.clone()))
    const directionLocal = endLocal.sub(originLocal)
    const targetZ = this.depth + 0.22
    if (Math.abs(directionLocal.z) < 1e-8) return null
    const distance = (targetZ - originLocal.z) / directionLocal.z
    if (!Number.isFinite(distance) || distance < 0) return null
    const localPoint = originLocal.add(directionLocal.multiplyScalar(distance))
    return this.focusMapGroup.localToWorld(localPoint)
  }
  async enrichFeatureInfo(feature, pixel, lngLat) {
    const topicKey = this.getLayerTopicKey(this.currentTopicLayer)
    if (!["grassland", "woodland"].includes(topicKey)) return feature
    const layers = forestGrassWetLayers[topicKey] || []
    const baseLayer = layers.find((layer) => layer.key === `${topicKey}-distribution`)
    const visibleLayers = (this.visibleTopicLayers || [])
      .filter((layer) => this.getLayerTopicKey(layer) === topicKey)
      .slice(0, 5)
    const queryLayers = [baseLayer, this.currentTopicLayer, ...visibleLayers]
      .filter((layer) => layer?.layerName)
      .filter((layer, index, array) => array.findIndex((item) => this.getLayerQueryKey(item) === this.getLayerQueryKey(layer)) === index)
    if (!queryLayers.length) return feature
    const extraResponses = await Promise.allSettled([
      ...queryLayers
        .filter((layer) => this.getLayerQueryKey(layer) !== this.getLayerQueryKey(this.currentTopicLayer))
        .map(async (layer) => {
          const response = await getFeatureInfo(layer, pixel, { size: WMS_IMAGE_SIZE, buffer: 10 })
          const extraFeature = normalizeFeatureInfo(response)
          return extraFeature ? { layer, feature: extraFeature, source: "wms" } : null
        }),
      ...queryLayers.map(async (layer) => {
        const extraFeature = await this.queryLayerFeatureByPoint(layer, lngLat)
        return extraFeature ? { layer, feature: extraFeature, source: "wfs" } : null
      }),
    ])
    const mergedProperties = { ...(feature.properties || {}) }
    extraResponses.forEach((result) => {
      if (result.status !== "fulfilled" || !result.value?.feature?.properties) return
      this.mergeBusinessProperties(mergedProperties, result.value.feature.properties, result.value.layer)
    })
    return {
      ...feature,
      properties: mergedProperties,
      relatedFeatures: extraResponses
        .filter((result) => result.status === "fulfilled" && result.value?.feature)
        .map((result) => result.value),
      feature: {
        ...(feature.feature || {}),
        properties: mergedProperties,
      },
    }
  }
  async queryFallbackFeatureInfo(lngLat) {
    const topicKey = this.getLayerTopicKey(this.currentTopicLayer)
    if (!["grassland", "woodland"].includes(topicKey)) return null
    const layers = forestGrassWetLayers[topicKey] || []
    const preferredLayers = [
      layers.find((layer) => layer.key === `${topicKey}-distribution`),
      this.currentTopicLayer,
      ...(this.visibleTopicLayers || []).filter((layer) => this.getLayerTopicKey(layer) === topicKey),
    ].filter((layer, index, array) => layer?.layerName && array.findIndex((item) => this.getLayerQueryKey(item) === this.getLayerQueryKey(layer)) === index)
    for (const layer of preferredLayers) {
      const feature = await this.queryLayerFeatureByPoint(layer, lngLat, { requireContainment: true })
      if (feature) return { layer, feature }
    }
    return null
  }
  async queryLayerFeatureByPoint(layer, lngLat, options = {}) {
    if (!layer?.layerName || !Number.isFinite(lngLat?.lng) || !Number.isFinite(lngLat?.lat)) return null
    const tolerance = layer.pointQueryTolerance || 0.0015
    const geometryField = layer.geometryField || "geom"
    const minLng = lngLat.lng - tolerance
    const minLat = lngLat.lat - tolerance
    const maxLng = lngLat.lng + tolerance
    const maxLat = lngLat.lat + tolerance
    const response = await getWfsFeatureProperties(layer.layerName, {
      cqlFilter: `BBOX(${geometryField},${minLng},${minLat},${maxLng},${maxLat})`,
      maxFeatures: 8,
      timeout: 12000,
    })
    const pickedFeature = this.pickFeatureAtPoint(response?.features || [], lngLat, options)
    if (!pickedFeature) return null
    return {
      id: pickedFeature.id || pickedFeature.properties?.gid || pickedFeature.properties?.objectid || pickedFeature.properties?.OBJECTID,
      properties: pickedFeature.properties || {},
      feature: pickedFeature,
    }
  }
  pickFeatureAtPoint(features, lngLat, options = {}) {
    if (!Array.isArray(features) || !features.length) return null
    const containing = features
      .filter((feature) => this.isLngLatInGeometry(lngLat, feature.geometry))
      .sort((a, b) => this.getGeometryAreaScore(a.geometry) - this.getGeometryAreaScore(b.geometry))
    if (containing.length) return containing[0]
    if (options.requireContainment) return null
    return features
      .map((feature) => ({
        feature,
        distance: this.getGeometryDistanceScore(feature.geometry, lngLat),
      }))
      .sort((a, b) => a.distance - b.distance)[0]?.feature || null
  }
  isLngLatInGeometry(lngLat, geometry) {
    if (!lngLat || !geometry?.coordinates) return false
    if (geometry.type === "Polygon") return this.isLngLatInPolygon(lngLat, geometry.coordinates)
    if (geometry.type === "MultiPolygon") {
      return geometry.coordinates.some((polygon) => this.isLngLatInPolygon(lngLat, polygon))
    }
    return false
  }
  isLngLatInPolygon(lngLat, rings = []) {
    if (!rings.length) return false
    const inOuter = this.isLngLatInRing(lngLat, rings[0])
    if (!inOuter) return false
    return !rings.slice(1).some((ring) => this.isLngLatInRing(lngLat, ring))
  }
  isLngLatInRing(lngLat, ring = []) {
    if (ring.length < 3) return false
    let inside = false
    const x = lngLat.lng
    const y = lngLat.lat
    for (let currentIndex = 0, previousIndex = ring.length - 1; currentIndex < ring.length; previousIndex = currentIndex++) {
      const current = ring[currentIndex]
      const previous = ring[previousIndex]
      const xi = Number(current?.[0])
      const yi = Number(current?.[1])
      const xj = Number(previous?.[0])
      const yj = Number(previous?.[1])
      if (this.isPointOnSegment(x, y, xi, yi, xj, yj)) return true
      const intersects = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi
      if (intersects) inside = !inside
    }
    return inside
  }
  isPointOnSegment(px, py, x1, y1, x2, y2) {
    const cross = (px - x1) * (y2 - y1) - (py - y1) * (x2 - x1)
    if (Math.abs(cross) > 1e-10) return false
    const dot = (px - x1) * (px - x2) + (py - y1) * (py - y2)
    return dot <= 1e-10
  }
  getGeometryAreaScore(geometry) {
    if (!geometry?.coordinates) return Number.POSITIVE_INFINITY
    if (geometry.type === "Polygon") return this.getLngLatPolygonAreaScore(geometry.coordinates)
    if (geometry.type === "MultiPolygon") {
      return geometry.coordinates.reduce((sum, polygon) => sum + this.getLngLatPolygonAreaScore(polygon), 0)
    }
    return Number.POSITIVE_INFINITY
  }
  getLngLatPolygonAreaScore(rings = []) {
    const outer = rings[0] || []
    if (outer.length < 3) return Number.POSITIVE_INFINITY
    let area = 0
    for (let index = 0; index < outer.length; index++) {
      const current = outer[index]
      const next = outer[(index + 1) % outer.length]
      area += Number(current?.[0] || 0) * Number(next?.[1] || 0) - Number(next?.[0] || 0) * Number(current?.[1] || 0)
    }
    return Math.abs(area)
  }
  getGeometryDistanceScore(geometry, lngLat) {
    const bounds = this.getGeometryLngLatBounds(geometry)
    if (!bounds) return Number.POSITIVE_INFINITY
    const centerLng = (bounds.minLng + bounds.maxLng) / 2
    const centerLat = (bounds.minLat + bounds.maxLat) / 2
    return Math.hypot(centerLng - lngLat.lng, centerLat - lngLat.lat)
  }
  getGeometryLngLatBounds(geometry) {
    const coordinates = this.getGeometryCoordinateList(geometry)
    if (!coordinates.length) return null
    return coordinates.reduce(
      (bounds, coordinate) => ({
        minLng: Math.min(bounds.minLng, Number(coordinate?.[0] || 0)),
        minLat: Math.min(bounds.minLat, Number(coordinate?.[1] || 0)),
        maxLng: Math.max(bounds.maxLng, Number(coordinate?.[0] || 0)),
        maxLat: Math.max(bounds.maxLat, Number(coordinate?.[1] || 0)),
      }),
      {
        minLng: Number.POSITIVE_INFINITY,
        minLat: Number.POSITIVE_INFINITY,
        maxLng: Number.NEGATIVE_INFINITY,
        maxLat: Number.NEGATIVE_INFINITY,
      }
    )
  }
  getGeometryCoordinateList(geometry) {
    const coordinates = []
    const visit = (value) => {
      if (!Array.isArray(value)) return
      if (typeof value[0] === "number" && typeof value[1] === "number") {
        coordinates.push(value)
        return
      }
      value.forEach(visit)
    }
    visit(geometry?.coordinates)
    return coordinates
  }
  pickVectorFeatureAtPoint(lngLat, screen) {
    const features = this.getVisibleVectorFeatures(this.currentTopicLayer, this.currentVectorFeatures || [])
    if (this.currentTopicLayer?.key === "yak-industry-points") {
      const screenFeature = this.pickPointFeatureAtScreenPoint(features, screen)
      if (screenFeature) return screenFeature
    }
    const containing = features
      .filter((feature) => this.isLngLatInGeometry(lngLat, feature.geometry))
      .sort((a, b) => this.getGeometryAreaScore(a.geometry) - this.getGeometryAreaScore(b.geometry))
    if (containing.length) return containing[0]
    const pointLike = features.find((feature) => this.isPointOrLineNearLngLat(feature.geometry, lngLat))
    if (pointLike) return pointLike
    return null
  }
  pickPointFeatureAtScreenPoint(features, screen) {
    if (!screen || !this.camera?.instance || !this.focusMapGroup || !this.canvas) return null
    const candidates = (features || [])
      .filter((feature) => feature?.geometry?.type === "Point")
      .map((feature) => {
        const pointScreen = this.getPointFeatureScreenPoint(feature)
        if (!pointScreen) return null
        return {
          feature,
          screen: pointScreen,
          distance: Math.hypot(pointScreen.x - screen.x, pointScreen.y - screen.y),
        }
      })
      .filter(Boolean)
      .sort((a, b) => a.distance - b.distance)
    const nearest = candidates[0]
    return nearest?.distance <= 22 ? { feature: nearest.feature, screen: nearest.screen } : null
  }
  getPointFeatureScreenPoint(feature) {
    const geometry = feature?.geometry || feature?.feature?.geometry
    if (geometry?.type !== "Point") return null
    if (this.currentTopicLayer?.key === "yak-industry-points") {
      return this.lngLatToLayerScreenPoint(geometry.coordinates, this.depth + 0.36)
    }
    return this.lngLatToScreenPoint(geometry.coordinates, this.depth + 0.32)
  }
  lngLatToLayerLocalPoint(coordinate, localZ = 0) {
    const fallbackPoint = () => {
      const lng = Number(coordinate?.[0])
      const lat = Number(coordinate?.[1])
      if (!Number.isFinite(lng) || !Number.isFinite(lat)) return new Vector3(0, 0, Number(localZ) || 0)
      const [x, y] = this.geoProjection([lng, lat])
      return new Vector3(x, -y, Number(localZ) || 0)
    }
    const bounds = this.topicLayerUvBounds
    if (!bounds) return fallbackPoint()
    const layerPoint = this.lngLatToLayerCanvasPoint(coordinate)
    const canvas = this.topicLayerCanvas
    if (!canvas?.width || !canvas?.height) return fallbackPoint()
    const x = bounds.left + (layerPoint.x / canvas.width) * (bounds.right - bounds.left)
    const y = bounds.bottom + ((canvas.height - layerPoint.y) / canvas.height) * (bounds.top - bounds.bottom)
    return new Vector3(x, y, Number(localZ) || 0)
  }
  lngLatToLayerScreenPoint(coordinate, localZ = 0) {
    return this.localMapPointToScreenPoint(this.lngLatToLayerLocalPoint(coordinate, localZ))
  }
  lngLatToScreenPoint(coordinate, localZ = 0) {
    const lng = Number(coordinate?.[0])
    const lat = Number(coordinate?.[1])
    if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null
    const point = this.lngLatToWorldPoint([lng, lat], localZ)
    return this.worldPointToScreenPoint(point)
  }
  localMapPointToScreenPoint(localPoint) {
    const point = this.focusMapGroup ? this.focusMapGroup.localToWorld(localPoint) : localPoint
    return this.worldPointToScreenPoint(point)
  }
  worldPointToScreenPoint(point) {
    if (!this.camera?.instance || !this.canvas || !point) return null
    const rect = this.canvas.getBoundingClientRect()
    if (!rect.width || !rect.height) return null
    const projected = point.clone().project(this.camera.instance)
    if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) return null
    return {
      x: rect.left + ((projected.x + 1) / 2) * rect.width,
      y: rect.top + ((1 - projected.y) / 2) * rect.height,
    }
  }
  focusProject(projectName) {
    const normalizedName = String(projectName || "").trim()
    if (!normalizedName) return false
    const features = (this.currentVectorFeatures || []).filter((feature) =>
      String(feature?.properties?.project_name || "").trim() === normalizedName
    )
    if (!features.length) {
      this.callbacks.onLayerStatus?.({
        type: "success",
        text: "当前项目图斑尚未加载",
      })
      return false
    }
    const bounds = this.getFeatureListLngLatBounds(features)
    if (!bounds) return false
    const highlightFeatures = this.getProjectHighlightFeatures(features)
    if (highlightFeatures.length) {
      this.showSelectedFeatures(highlightFeatures)
      this.setSelectedVectorFeatures(highlightFeatures)
    }
    else this.clearSelectedFeature()
    const center = [
      (bounds.minLng + bounds.maxLng) / 2,
      (bounds.minLat + bounds.maxLat) / 2,
    ]
    const span = Math.max(bounds.maxLng - bounds.minLng, bounds.maxLat - bounds.minLat)
    const distanceScale = Math.max(0.22, Math.min(0.55, span * 1.25))
    const target = this.lngLatToWorldPoint(center, 0.72)
    const offset = this.defaultMapCameraPosition.clone().sub(this.defaultMapTarget).multiplyScalar(distanceScale)
    const position = target.clone().add(offset)
    this.animateMapCamera(position, target, 0.75)
    this.callbacks.onLayerStatus?.({
      type: "success",
      text: "已定位项目",
    })
    return true
  }
  setBoundaryDrawing(enabled) {
    this.boundaryDrawingEnabled = Boolean(enabled)
    if (this.boundaryDrawingEnabled) {
      this.boundaryDrawPoints = []
      this.lastBoundaryDrawPoint = null
      this.analysisBoundaryFeature = null
      this.analysisMatchedFeatures = []
      this.clearSelectedFeature()
      this.callbacks.onLayerStatus?.({
        type: "success",
        text: "请在地图上依次点击绘制分析边界",
      })
    } else {
      this.callbacks.onLayerStatus?.({
        type: "success",
        text: "已退出边界绘制",
      })
    }
    this.redrawVectorTopicLayer()
  }
  addBoundaryDrawPoint(lngLat) {
    if (!Number.isFinite(lngLat?.lng) || !Number.isFinite(lngLat?.lat)) return false
    const now = Date.now()
    const lastPoint = this.lastBoundaryDrawPoint
    const isDuplicate = lastPoint
      && now - lastPoint.time < 350
      && Math.hypot(lngLat.lng - lastPoint.lng, lngLat.lat - lastPoint.lat) < 0.0008
    if (isDuplicate) return false
    this.lastBoundaryDrawPoint = { lng: lngLat.lng, lat: lngLat.lat, time: now }
    this.boundaryDrawPoints = [...(this.boundaryDrawPoints || []), { lng: lngLat.lng, lat: lngLat.lat }]
    this.callbacks.onDrawBoundary?.({
      status: "drawing",
      points: this.boundaryDrawPoints,
      pointCount: this.boundaryDrawPoints.length,
    })
    this.callbacks.onLayerStatus?.({
      type: "success",
      text: `已添加边界点 ${this.boundaryDrawPoints.length} 个`,
    })
    this.redrawVectorTopicLayer()
    return true
  }
  undoBoundaryPoint() {
    if (!this.boundaryDrawPoints?.length) return false
    this.boundaryDrawPoints = this.boundaryDrawPoints.slice(0, -1)
    this.callbacks.onDrawBoundary?.({
      status: "drawing",
      points: this.boundaryDrawPoints,
      pointCount: this.boundaryDrawPoints.length,
    })
    this.redrawVectorTopicLayer()
    return true
  }
  finishBoundaryDrawing() {
    const points = this.boundaryDrawPoints || []
    if (points.length < 3) {
      this.callbacks.onLayerStatus?.({
        type: "error",
        text: "至少需要 3 个点才能形成分析边界",
      })
      return null
    }
    const coordinates = points.map((item) => [item.lng, item.lat])
    const first = coordinates[0]
    const last = coordinates[coordinates.length - 1]
    if (first[0] !== last[0] || first[1] !== last[1]) coordinates.push([...first])
    const feature = {
      type: "Feature",
      properties: {
        source: "draw",
        pointCount: points.length,
      },
      geometry: {
        type: "Polygon",
        coordinates: [coordinates],
      },
    }
    this.boundaryDrawingEnabled = false
    this.boundaryDrawPoints = []
    this.lastBoundaryDrawPoint = null
    this.analysisBoundaryFeature = feature
    this.callbacks.onDrawBoundary?.({
      status: "complete",
      feature,
      points,
      pointCount: points.length,
    })
    this.callbacks.onLayerStatus?.({
      type: "success",
      text: "分析边界已生成",
    })
    this.redrawVectorTopicLayer()
    return feature
  }
  clearAnalysisBoundary() {
    this.boundaryDrawingEnabled = false
    this.boundaryDrawPoints = []
    this.lastBoundaryDrawPoint = null
    this.analysisBoundaryFeature = null
    this.analysisMatchedFeatures = []
    this.redrawVectorTopicLayer()
    this.callbacks.onDrawBoundary?.({ status: "clear", points: [], pointCount: 0 })
    return true
  }
  showAnalysisResult(payload = {}) {
    const hasBoundaryPayload = Object.prototype.hasOwnProperty.call(payload, "boundary")
      || Object.prototype.hasOwnProperty.call(payload, "boundaryFeature")
      || Object.prototype.hasOwnProperty.call(payload, "feature")
    const boundary = payload.boundary || payload.boundaryFeature || payload.feature || null
    const matches = Array.isArray(payload.matches) ? payload.matches : []
    if (hasBoundaryPayload) {
      this.analysisBoundaryFeature = boundary?.type === "Feature"
        ? boundary
        : boundary?.geometry
          ? { type: "Feature", properties: boundary.properties || {}, geometry: boundary.geometry }
          : null
    }
    this.analysisMatchedFeatures = matches
      .map((item) => item?.feature || item)
      .filter((feature) => feature?.geometry)
    if (!payload.preserveView) {
      if (!hasBoundaryPayload && this.analysisMatchedFeatures.length) {
        this.focusAnalysisFeatures(this.analysisMatchedFeatures)
      } else if (this.analysisBoundaryFeature?.geometry) {
        this.focusAnalysisGeometry(this.analysisBoundaryFeature.geometry)
      }
    }
    this.redrawVectorTopicLayer()
    return true
  }
  focusAnalysisFeatures(features = []) {
    const bounds = this.getFeatureListLngLatBounds(features)
    if (!bounds) return false
    const center = [
      (bounds.minLng + bounds.maxLng) / 2,
      (bounds.minLat + bounds.maxLat) / 2,
    ]
    return this.flyToLngLat(center, {
      span: Math.max(bounds.maxLng - bounds.minLng, bounds.maxLat - bounds.minLat),
      statusText: "已定位命中项目",
    })
  }
  focusAnalysisGeometry(geometry) {
    const bounds = this.getGeometryLngLatBounds(geometry)
    if (!bounds) return false
    const center = [
      (bounds.minLng + bounds.maxLng) / 2,
      (bounds.minLat + bounds.maxLat) / 2,
    ]
    return this.flyToLngLat(center, {
      span: Math.max(bounds.maxLng - bounds.minLng, bounds.maxLat - bounds.minLat),
      statusText: "已定位分析边界",
    })
  }
  focusPasture(payload = {}) {
    const grasslands = [
      ...this.normalizePastureGrasslandRows(payload?.grasslands),
      ...this.normalizePastureGrasslandRows(payload?.wkt),
    ]
    const features = grasslands
      .map((row, index) => {
        const geometry = this.normalizePastureGrasslandGeometry(row)
        if (!geometry) return null
        const properties = this.normalizePastureGrasslandProperties(row)
        return {
          type: "Feature",
          properties: {
            ...properties,
            id: properties.id || properties.gid || `${payload?.name || "pasture"}-${index}`,
            pasture_name: payload?.name || properties.pastureName || properties.name,
          },
          geometry,
        }
      })
      .filter(Boolean)
    if (features.length) {
      const bounds = this.getFeatureListLngLatBounds(features)
      if (!bounds) return false
      this.showSelectedFeatures(features, { mode: "pasture" })
      this.setSelectedVectorFeatures(features)
      const center = [
        (bounds.minLng + bounds.maxLng) / 2,
        (bounds.minLat + bounds.maxLat) / 2,
      ]
      this.flyToLngLat(center, {
        span: Math.max(bounds.maxLng - bounds.minLng, bounds.maxLat - bounds.minLat),
        statusText: "已定位具体草场",
      })
      return true
    }
    this.callbacks.onLayerStatus?.({
      type: "warning",
      text: "当前牧户暂无可定位数据",
    })
    return false
  }
  normalizePastureGrasslandRows(value) {
    if (!value) return []
    if (typeof value === "string") return [value]
    if (Array.isArray(value)) return value
    if (value?.type === "FeatureCollection" && Array.isArray(value.features)) return value.features
    if (value?.type === "Feature" || this.isGeoJsonGeometry(value)) return [value]
    if (Array.isArray(value?.rows)) return value.rows
    if (Array.isArray(value?.data)) return value.data
    if (value?.data) return this.normalizePastureGrasslandRows(value.data)
    if (Array.isArray(value?.list)) return value.list
    if (Array.isArray(value?.features)) return value.features
    if (value?.wkt || value?.geo || value?.geom || value?.geometryWkt || value?.geometry_wkt || value?.shape || value?.location || value?.geometry) return [value]
    return []
  }
  normalizePastureGrasslandGeometry(row) {
    if (typeof row === "string") return this.parseGeometryText(row)
    if (row?.type === "Feature") return this.normalizePastureGrasslandGeometry(row.geometry)
    if (this.isGeoJsonGeometry(row)) return row
    if (typeof row?.geometry === "string") return this.parseGeometryText(row.geometry)
    if (row?.geometry?.type === "Feature") return this.normalizePastureGrasslandGeometry(row.geometry.geometry)
    if (this.isGeoJsonGeometry(row?.geometry)) return row.geometry
    return this.parseGeometryText(row?.wkt || row?.geo || row?.geom || row?.geometryWkt || row?.geometry_wkt || row?.shape || row?.location)
  }
  normalizePastureGrasslandProperties(row) {
    if (!row || typeof row !== "object" || Array.isArray(row)) return { wkt: row }
    if (row.type === "Feature") return row.properties || {}
    return row
  }
  parseGeometryText(value) {
    const text = String(value || "").trim()
    if (!text) return null
    if (text.startsWith("{") || text.startsWith("[")) {
      try {
        const parsed = JSON.parse(text)
        if (parsed?.type === "Feature") return this.normalizePastureGrasslandGeometry(parsed)
        if (this.isGeoJsonGeometry(parsed)) return parsed
      } catch (error) {
        return null
      }
    }
    return this.parseWktGeometry(text)
  }
  isGeoJsonGeometry(value) {
    return Boolean(
      value &&
      ["Polygon", "MultiPolygon", "LineString", "MultiLineString", "Point"].includes(value.type) &&
      value.coordinates
    )
  }
  flyToLngLat(lngLat, options = {}) {
    const coordinate = Array.isArray(lngLat) ? lngLat : [lngLat?.lng, lngLat?.lat]
    const lng = Number(coordinate?.[0])
    const lat = Number(coordinate?.[1])
    if (!Number.isFinite(lng) || !Number.isFinite(lat)) return false
    const span = Number(options.span)
    const distanceScale = Number.isFinite(span) ? Math.max(0.2, Math.min(0.55, span * 1.25)) : 0.32
    const target = this.lngLatToWorldPoint([lng, lat], 0.72)
    const offset = this.defaultMapCameraPosition.clone().sub(this.defaultMapTarget).multiplyScalar(distanceScale)
    const position = target.clone().add(offset)
    this.animateMapCamera(position, target, options.duration || 0.75)
    this.callbacks.onLayerStatus?.({
      type: "success",
      text: options.statusText || "已定位",
    })
    return true
  }
  getProjectHighlightFeatures(features) {
    const drawableFeatures = features.filter((feature) => {
      const type = String(feature?.geometry?.type || "")
      return type.includes("Polygon") || type.includes("LineString")
    })
    const rangeFeatures = drawableFeatures.filter((feature) => feature?.properties?.feature_role === "工程范围")
    return rangeFeatures.length ? rangeFeatures : drawableFeatures
  }
  getFeatureListLngLatBounds(features) {
    return features.reduce((merged, feature) => {
      const bounds = this.getGeometryLngLatBounds(feature.geometry)
      if (!bounds) return merged
      if (!merged) return { ...bounds }
      return {
        minLng: Math.min(merged.minLng, bounds.minLng),
        minLat: Math.min(merged.minLat, bounds.minLat),
        maxLng: Math.max(merged.maxLng, bounds.maxLng),
        maxLat: Math.max(merged.maxLat, bounds.maxLat),
      }
    }, null)
  }
  isPointOrLineNearLngLat(geometry, lngLat) {
    if (!geometry?.coordinates) return false
    if (geometry.type === "Point") return this.getCoordinateDistance(geometry.coordinates, [lngLat.lng, lngLat.lat]) <= 0.02
    if (geometry.type === "LineString") return this.isCoordinateNearLine(geometry.coordinates, lngLat, 0.004)
    if (geometry.type === "MultiLineString") return geometry.coordinates.some((line) => this.isCoordinateNearLine(line, lngLat, 0.004))
    return false
  }
  isCoordinateNearLine(line, lngLat, tolerance) {
    if (!Array.isArray(line) || line.length < 2) return false
    const point = [lngLat.lng, lngLat.lat]
    for (let index = 1; index < line.length; index += 1) {
      if (this.getCoordinateSegmentDistance(point, line[index - 1], line[index]) <= tolerance) return true
    }
    return false
  }
  getCoordinateDistance(first, second) {
    return Math.hypot(Number(first?.[0]) - Number(second?.[0]), Number(first?.[1]) - Number(second?.[1]))
  }
  getCoordinateSegmentDistance(point, first, second) {
    const x = Number(first?.[0])
    const y = Number(first?.[1])
    const dx = Number(second?.[0]) - x
    const dy = Number(second?.[1]) - y
    if (!Number.isFinite(dx) || !Number.isFinite(dy)) return Number.POSITIVE_INFINITY
    if (!dx && !dy) return this.getCoordinateDistance(point, first)
    const t = Math.max(0, Math.min(1, ((point[0] - x) * dx + (point[1] - y) * dy) / (dx * dx + dy * dy)))
    return this.getCoordinateDistance(point, [x + t * dx, y + t * dy])
  }
  getLayerTopicKey(layer) {
    const layerName = layer?.layerName
    const layerKey = layer?.key
    const found = Object.entries({ ...forestGrassWetLayers, ...ecologicalProtectionLayers }).find(([, layers]) =>
      layers.some((item) => item.key === layerKey || item.layerName === layerName)
    )
    return found?.[0] || ""
  }
  getLayerQueryKey(layer) {
    return [layer?.layerName || "", layer?.styles || "", layer?.cqlFilter || "", layer?.key || ""].join("|")
  }
  mergeBusinessProperties(target, source, layer) {
    const layerKey = layer?.key || ""
    Object.entries(source || {}).forEach(([key, value]) => {
      if (value === undefined || value === null || value === "") return
      if (target[key] === undefined || target[key] === null || target[key] === "") {
        target[key] = value
      }
    })
    if (layerKey === "basic-grassland") {
      const basicValue = source?.basic_type_name || source?.grassland_category_name || source?.land_class_name || source?.land_use_name || source?.base_grass
      if (basicValue !== undefined && basicValue !== null && basicValue !== "") target.basic_type_name = basicValue
    }
    if (layerKey === "grassland-level") {
      const levelValue = source?.grassland_level || source?.level || source?.grassland_level_name || source?.level_name
      if (levelValue !== undefined && levelValue !== null && levelValue !== "") target.grassland_level = levelValue
    }
    if (layerKey === "grassland-type" || layerKey === "wetland-type") {
      const typeValue = source?.grass_min_type || source?.grass_type || source?.grassland_type || source?.type_name || source?.type
      if (typeValue !== undefined && typeValue !== null && typeValue !== "") target.grass_min_type = typeValue
    }
    if (layerKey === "grassland-yield" || layerKey === "wetland-yield") {
      const yieldValue = source?.yield || source?.xc_yield || source?.xb_xc_yield || source?.fresh_grass_yield || source?.value
      const muYieldValue = source?.mu_yield || source?.xc_mu_yield || source?.xb_xc_mu_yield || source?.yield_per_mu
      if (yieldValue !== undefined && yieldValue !== null && yieldValue !== "") target.fresh_grass_yield = yieldValue
      if (muYieldValue !== undefined && muYieldValue !== null && muYieldValue !== "") target.mu_yield = muYieldValue
    }
    if (layerKey === "grassland-health") {
      const healthValue = source?.health_level || source?.health_level_name || source?.ndvi_level || source?.level || source?.level_name
      const ndviValue = source?.ndvi || source?.NDVI || source?.value
      if (healthValue !== undefined && healthValue !== null && healthValue !== "") target.health_level = healthValue
      if (ndviValue !== undefined && ndviValue !== null && ndviValue !== "") target.ndvi = ndviValue
      const degradationValue = source?.degradation_level || source?.degradation_level_name || source?.level || source?.level_name
      if (degradationValue !== undefined && degradationValue !== null && degradationValue !== "") target.degradation_level = degradationValue
    }
  }
  getScreenPointFromEvent(event) {
    const source = event?.data?.originalEvent || event?.originalEvent || event
    if (source?.clientX !== undefined && source?.clientY !== undefined) {
      return {
        x: source.clientX,
        y: source.clientY,
      }
    }
    return null
  }
  getLngLatFromEvent(event) {
    const intersections = this.interactionManager?.raycaster?.intersectObject(event.target, false) || []
    const point = intersections[0]?.point
    if (!point) return null
    const localPoint = this.focusMapGroup.worldToLocal(point.clone())
    const projection = geoMercator()
      .center(this.geoProjectionCenter)
      .scale(this.geoProjectionScale)
      .translate([0, 0])
    const lngLat = projection.invert([localPoint.x, -localPoint.y])
    if (!lngLat) return null
    return {
      lng: lngLat[0],
      lat: lngLat[1],
    }
  }
  lngLatToWmsPixel({ lng, lat }) {
    const x = ((lng - HONGYUAN_BOUNDS.west) / (HONGYUAN_BOUNDS.east - HONGYUAN_BOUNDS.west)) * WMS_IMAGE_SIZE.width
    const y = ((HONGYUAN_BOUNDS.north - lat) / (HONGYUAN_BOUNDS.north - HONGYUAN_BOUNDS.south)) * WMS_IMAGE_SIZE.height
    return { x, y }
  }
  showSelectedFeature(feature, options = {}) {
    const geometry = feature?.geometry || feature?.feature?.geometry
    this.clearSelectedFeature()
    this.createSelectedFeatureGroup()
    this.addSelectedGeometry(geometry, options)
    if (!this.selectedFeatureGroup.children.length) {
      this.clearSelectedFeature()
    }
  }
  showSelectedFeatures(features = [], options = {}) {
    this.clearSelectedFeature()
    this.createSelectedFeatureGroup()
    features.forEach((feature) => {
      const geometry = feature?.geometry || feature?.feature?.geometry
      this.addSelectedGeometry(geometry, options)
    })
    if (!this.selectedFeatureGroup.children.length) {
      this.clearSelectedFeature()
    }
  }
  setSelectedVectorFeatures(features = []) {
    this.selectedVectorFeatures = [...features]
    this.redrawVectorTopicLayer()
  }
  getSelectedFeatureStyle(options = {}) {
    const isPasture = options.mode === "pasture"
    return {
      fillColor: SELECTED_FEATURE_FILL_HEX,
      fillOpacity: isPasture ? 0.46 : 0.54,
      lineColor: SELECTED_FEATURE_STROKE_HEX,
      lineOpacity: 0.98,
      glowColor: SELECTED_FEATURE_GLOW_HEX,
      glowOpacity: isPasture ? 0.92 : 0.96,
      renderOrderBase: isPasture ? 42 : 35,
      boundary: isPasture,
      boundaryColor: SELECTED_FEATURE_FILL_HEX,
      boundaryGlowColor: SELECTED_FEATURE_GLOW_HEX,
      boundaryWidth: 0.018,
      boundaryGlowWidth: 0.06,
      boundaryOpacity: 0.95,
      boundaryGlowOpacity: 0.42,
    }
  }
  addSelectedGeometry(geometry, options = {}) {
    if (!geometry?.coordinates || !this.selectedFeatureGroup) return
    const style = this.getSelectedFeatureStyle(options)
    const polygons = this.getDisplayFeaturePolygons(geometry)
    const fillMaterial = new MeshBasicMaterial({
      color: style.fillColor,
      transparent: true,
      opacity: style.fillOpacity,
      side: DoubleSide,
      depthWrite: false,
      fog: false,
    })
    const lineMaterial = new LineBasicMaterial({
      color: style.lineColor,
      transparent: true,
      opacity: style.lineOpacity,
      linewidth: 2,
      depthTest: false,
      depthWrite: false,
      fog: false,
    })
    const glowMaterial = new LineBasicMaterial({
      color: style.glowColor,
      transparent: true,
      opacity: style.glowOpacity,
      linewidth: 3,
      depthTest: false,
      depthWrite: false,
      fog: false,
    })
    const boundaryMaterial = style.boundary
      ? new MeshBasicMaterial({
          color: style.boundaryColor,
          transparent: true,
          opacity: style.boundaryOpacity,
          side: DoubleSide,
          depthTest: false,
          depthWrite: false,
          fog: false,
        })
      : null
    const boundaryGlowMaterial = style.boundary
      ? new MeshBasicMaterial({
          color: style.boundaryGlowColor,
          transparent: true,
          opacity: style.boundaryGlowOpacity,
          side: DoubleSide,
          depthTest: false,
          depthWrite: false,
          fog: false,
          blending: AdditiveBlending,
        })
      : null
    if (polygons.length) {
      polygons.forEach((rings) => {
        const shape = this.createProjectedShape(rings)
        if (!shape) return
        const fillGeometry = new ShapeGeometry(shape)
        const fill = new Mesh(fillGeometry, fillMaterial)
        fill.renderOrder = style.renderOrderBase
        this.selectedFeatureGroup.add(fill)
        rings.forEach((ring) => {
          const points = this.createProjectedRingPoints(ring)
          if (points.length < 3) return
          if (style.boundary) {
            this.addSelectedBoundaryBand(points, boundaryGlowMaterial, style.boundaryGlowWidth, style.renderOrderBase + 1, true)
            this.addSelectedBoundaryBand(points, boundaryMaterial, style.boundaryWidth, style.renderOrderBase + 4, true)
          }
          const lineGeometry = new BufferGeometry().setFromPoints(points)
          const line = new LineLoop(lineGeometry, lineMaterial)
          line.renderOrder = style.renderOrderBase + 5
          this.selectedFeatureGroup.add(line)
          const glowGeometry = new BufferGeometry().setFromPoints(points)
          const glow = new LineLoop(glowGeometry, glowMaterial)
          glow.renderOrder = style.renderOrderBase + 3
          glow.scale.set(1.004, 1.004, 1)
          this.selectedFeatureGroup.add(glow)
        })
      })
    }
    const lines = this.normalizeFeatureLineStrings(geometry)
    if (lines.length) {
      lines.forEach((lineCoordinates) => {
        const points = this.createProjectedRingPoints(lineCoordinates)
        if (points.length < 2) return
        if (style.boundary) {
          this.addSelectedBoundaryBand(points, boundaryGlowMaterial, style.boundaryGlowWidth, style.renderOrderBase + 1, false)
          this.addSelectedBoundaryBand(points, boundaryMaterial, style.boundaryWidth, style.renderOrderBase + 4, false)
        }
        const glowGeometry = new BufferGeometry().setFromPoints(points)
        const glow = new ThreeLine(glowGeometry, glowMaterial)
        glow.renderOrder = style.renderOrderBase + 3
        glow.scale.set(1.004, 1.004, 1)
        this.selectedFeatureGroup.add(glow)
        const lineGeometry = new BufferGeometry().setFromPoints(points)
        const line = new ThreeLine(lineGeometry, lineMaterial)
        line.renderOrder = style.renderOrderBase + 5
        this.selectedFeatureGroup.add(line)
      })
    }
  }
  addSelectedBoundaryBand(points, material, width, renderOrder, closed = true) {
    if (!material || !this.selectedFeatureGroup) return
    const geometry = this.createPathBandGeometry(points, width, closed)
    if (!geometry) return
    const band = new Mesh(geometry, material)
    band.renderOrder = renderOrder
    this.selectedFeatureGroup.add(band)
  }
  createPathBandGeometry(points, width, closed = true) {
    const cleanPoints = this.normalizePathBandPoints(points, closed)
    if (cleanPoints.length < (closed ? 3 : 2)) return null
    const halfWidth = Math.max(Number(width) || 0, 0) / 2
    if (!halfWidth) return null
    const positions = []
    const segmentCount = closed ? cleanPoints.length : cleanPoints.length - 1
    for (let index = 0; index < segmentCount; index += 1) {
      const start = cleanPoints[index]
      const end = cleanPoints[(index + 1) % cleanPoints.length]
      const dx = end.x - start.x
      const dy = end.y - start.y
      const length = Math.hypot(dx, dy)
      if (!length) continue
      const offsetX = (-dy / length) * halfWidth
      const offsetY = (dx / length) * halfWidth
      const startOuter = [start.x + offsetX, start.y + offsetY, 0.006]
      const startInner = [start.x - offsetX, start.y - offsetY, 0.006]
      const endOuter = [end.x + offsetX, end.y + offsetY, 0.006]
      const endInner = [end.x - offsetX, end.y - offsetY, 0.006]
      positions.push(...startOuter, ...startInner, ...endOuter, ...endOuter, ...startInner, ...endInner)
    }
    if (!positions.length) return null
    const geometry = new BufferGeometry()
    geometry.setAttribute("position", new Float32BufferAttribute(positions, 3))
    geometry.computeVertexNormals()
    return geometry
  }
  normalizePathBandPoints(points, closed) {
    const cleanPoints = (points || [])
      .filter((point) => Number.isFinite(point?.x) && Number.isFinite(point?.y))
      .map((point) => point.clone())
    if (closed && cleanPoints.length > 2) {
      const first = cleanPoints[0]
      const last = cleanPoints[cleanPoints.length - 1]
      if (first.distanceToSquared(last) < 1e-10) cleanPoints.pop()
    }
    return cleanPoints
  }
  clearSelectedFeature() {
    const hadSelectedVectorFeatures = Boolean(this.selectedVectorFeatures?.length)
    this.selectedVectorFeatures = []
    if (hadSelectedVectorFeatures) this.redrawVectorTopicLayer()
    this.clearSelectedGeometryGroup()
  }
  clearSelectedGeometryGroup() {
    if (!this.selectedFeatureGroup) return
    const materials = new Set()
    while (this.selectedFeatureGroup.children.length) {
      const child = this.selectedFeatureGroup.children.pop()
      child.geometry?.dispose?.()
      if (Array.isArray(child.material)) {
        child.material.forEach((material) => materials.add(material))
      } else {
        materials.add(child.material)
      }
    }
    materials.forEach((material) => material?.dispose?.())
  }
  normalizeFeaturePolygons(geometry) {
    return getFeaturePolygons(geometry)
  }
  normalizeFeatureLineStrings(geometry) {
    if (!geometry?.coordinates) return []
    if (geometry.type === "LineString") return [geometry.coordinates]
    if (geometry.type === "MultiLineString") return geometry.coordinates
    return []
  }
  getDisplayFeaturePolygons(geometry) {
    const polygons = this.normalizeFeaturePolygons(geometry)
    const clipBounds = this.getLayerClipBounds(this.currentTopicLayer)
    if (!clipBounds) return polygons
    return polygons
      .map((rings) => {
        const outerRing = this.clipRingToBounds(rings?.[0], clipBounds)
        if (outerRing.length < 3) return null
        const holeRings = rings
          .slice(1)
          .map((ring) => this.clipRingToBounds(ring, clipBounds))
          .filter((ring) => ring.length >= 3)
        return [outerRing, ...holeRings]
      })
      .filter(Boolean)
  }
  clipRingToBounds(ring = [], bounds) {
    let points = ring
      .map((coordinate) => [Number(coordinate?.[0]), Number(coordinate?.[1])])
      .filter(([lng, lat]) => Number.isFinite(lng) && Number.isFinite(lat))
    const edges = [
      {
        inside: ([lng]) => lng >= bounds.west,
        intersect: (start, end) => this.getLngLatLineIntersection(start, end, "lng", bounds.west),
      },
      {
        inside: ([lng]) => lng <= bounds.east,
        intersect: (start, end) => this.getLngLatLineIntersection(start, end, "lng", bounds.east),
      },
      {
        inside: ([, lat]) => lat >= bounds.south,
        intersect: (start, end) => this.getLngLatLineIntersection(start, end, "lat", bounds.south),
      },
      {
        inside: ([, lat]) => lat <= bounds.north,
        intersect: (start, end) => this.getLngLatLineIntersection(start, end, "lat", bounds.north),
      },
    ]
    edges.forEach((edge) => {
      const input = points
      points = []
      if (!input.length) return
      input.forEach((current, index) => {
        const previous = input[(index + input.length - 1) % input.length]
        const currentInside = edge.inside(current)
        const previousInside = edge.inside(previous)
        if (currentInside) {
          if (!previousInside) points.push(edge.intersect(previous, current))
          points.push(current)
        } else if (previousInside) {
          points.push(edge.intersect(previous, current))
        }
      })
    })
    return points
  }
  getLngLatLineIntersection(start, end, axis, value) {
    const axisIndex = axis === "lng" ? 0 : 1
    const otherIndex = axis === "lng" ? 1 : 0
    const delta = end[axisIndex] - start[axisIndex]
    if (!delta) return [...start]
    const ratio = (value - start[axisIndex]) / delta
    const result = [...start]
    result[axisIndex] = value
    result[otherIndex] = start[otherIndex] + (end[otherIndex] - start[otherIndex]) * ratio
    return result
  }
  createProjectedShape(rings) {
    const outer = rings?.[0]
    if (!Array.isArray(outer) || outer.length < 3) return null
    const shape = new Shape()
    const outerPoints = this.createProjectedRingPoints(outer)
    outerPoints.forEach((point, index) => {
      if (index === 0) shape.moveTo(point.x, point.y)
      else shape.lineTo(point.x, point.y)
    })
    rings.slice(1).forEach((ring) => {
      const holePoints = this.createProjectedRingPoints(ring)
      if (holePoints.length < 3) return
      const hole = new Shape()
      holePoints.forEach((point, index) => {
        if (index === 0) hole.moveTo(point.x, point.y)
        else hole.lineTo(point.x, point.y)
      })
      shape.holes.push(hole)
    })
    return shape
  }
  createProjectedRingPoints(ring) {
    if (!Array.isArray(ring)) return []
    const points = []
    ring.forEach((coord) => {
      if (!Array.isArray(coord) || coord.length < 2) return
      const lng = Number(coord[0])
      const lat = Number(coord[1])
      if (!Number.isFinite(lng) || !Number.isFinite(lat)) return
      const [x, y] = this.geoProjection([lng, lat])
      points.push(new Vector3(x, -y, 0))
    })
    return points
  }
  enhanceTexture(texture) {
    const capabilities = this.renderer?.instance?.capabilities
    texture.anisotropy = capabilities?.getMaxAnisotropy?.() || 1
    texture.needsUpdate = true
  }

  geoProjection(args) {
    return geoMercator().center(this.geoProjectionCenter).scale(this.geoProjectionScale).translate([0, 0])(args)
  }
  update(delta) {
    super.update(delta)
    this.updateDynamicSatelliteTiles()
    this.updateTradeFlowPulses(delta)
    this.interactionManager && this.interactionManager.update()
  }
  pauseScene() {
    this.scenePaused = true
    this.time?.pause?.()
    this.sceneTweens.forEach((tween) => tween?.pause?.())
    clearInterval(this.infoPointLabelTime)
    this.infoPointLabelTime = null
  }
  resumeScene() {
    this.scenePaused = false
    this.time?.resume?.()
    this.sceneTweens.forEach((tween) => tween?.resume?.())
    if (this.showTownRankingPointMarkers && this.mapAnimationComplete && !this.infoPointLabelTime) {
      this.createInfoPointLabelLoop()
    }
  }
  destroy() {
    this.pauseScene()
    this.sceneTimers.forEach((timer) => clearTimeout(timer))
    this.sceneTimers = []
    this.sceneTweens.forEach((tween) => tween?.kill?.())
    this.sceneTweens = []
    this.callbacks = {}
    this.canvas?.removeEventListener?.("pointerdown", this.handleCanvasBoundaryPointerDown, true)
    this.canvas?.removeEventListener?.("dblclick", this.handleCanvasBoundaryDoubleClick, true)
    this.clearTradeFlowLayer()
    this.clearSelectedFeature()
    super.destroy()
    this.label3d && this.label3d.destroy()
  }
}
