You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

4921 lines
182 KiB

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 } 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, 22.8, 18.8)
const DEFAULT_MAP_TARGET = new Vector3(0, 0, 0)
const INTRO_MAP_CAMERA_POSITION = new Vector3(-7.2, 24.2, 31.2)
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 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 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.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: 1,
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"
)
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 } = this.createProvince()
china.setParent(mapGroup)
chinaTopLine.setParent(mapGroup)
// 创建扩散
this.createDiffuse()
map.setParent(focusMapGroup)
mapTop.setParent(focusMapGroup)
mapLine.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,
})
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: 0.94,
roughness: 0.82,
// fog: false,
})
let faceGradientShader = 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: 8,
})
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,
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
return {
map,
mapTop,
mapLine,
}
}
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 view = layer?.mapView || (layer?.cameraPosition ? { cameraPosition: layer.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)
}
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 = 1
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.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.255)
|| 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: baseStyle.stroke,
stroke: baseStyle.stroke,
polygonAlpha: 0,
lineAlpha: 1,
lineWidth: 8,
glow: true,
})
})
}
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 glowStyle = {
fill: "#FFE15A",
stroke: "#24F6C4",
polygonAlpha: 0.24,
lineAlpha: 0.98,
lineWidth: 12,
pointRadius: 11,
glow: true,
halo: true,
}
const focusStyle = {
fill: "#FFF06A",
stroke: "#FFFDF2",
polygonAlpha: 0.46,
lineAlpha: 1,
lineWidth: 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: 0.28,
lineAlpha: 0.98,
lineWidth: 5,
pointRadius: 6,
glow: true,
})
})
if (boundary?.geometry) {
this.drawVectorFeature(context, boundary.geometry, {
fill: "#24F6C4",
stroke: "#FFFFFF",
polygonAlpha: 0.18,
lineAlpha: 1,
lineWidth: 4,
pointRadius: 6,
glow: true,
})
}
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: 0.12,
lineAlpha: 1,
lineWidth: 4,
pointRadius: 6,
glow: true,
})
coordinates.forEach((coordinate) => {
this.drawVectorFeature(context, { type: "Point", coordinates: coordinate }, {
fill: "#FFFFFF",
stroke: "#24F6C4",
polygonAlpha: 0.2,
lineAlpha: 1,
lineWidth: 3,
pointRadius: 5.5,
glow: true,
})
})
}
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)
const position = this.lngLatToLayerLocalPoint(feature.geometry.coordinates, this.depth + 0.98)
const scale = Number(layer?.pointSpriteScale) || 0.34
if (!marker) {
marker = new Sprite(material)
marker.name = "yak-industry-point-marker"
marker.renderOrder = 42
marker.center.set(0.5, 0.08)
this.bindYakIndustryPointMarkerEvents(marker)
this.interactionManager.add(marker)
this.yakIndustryPointMarkerGroup.add(marker)
this.yakIndustryPointMarkers.push(marker)
}
marker.material = material
marker.position.copy(position)
marker.scale.set(scale, scale * 1.32, 1)
marker.visible = true
marker.userData = {
feature,
layer,
index,
}
})
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) {
const key = `${style.fill || "#24F6C4"}-${style.stroke || "#B7FFF2"}`
const cached = this.yakIndustryMarkerMaterialCache.get(key)
if (cached) return cached
const texture = this.createYakIndustryMarkerTexture(style.fill, style.stroke)
const material = new SpriteMaterial({
map: texture,
transparent: true,
depthTest: false,
depthWrite: false,
fog: false,
})
this.yakIndustryMarkerMaterialCache.set(key, material)
return material
}
createYakIndustryMarkerTexture(fill = "#24F6C4", stroke = "#B7FFF2") {
const canvas = document.createElement("canvas")
canvas.width = 96
canvas.height = 124
const context = canvas.getContext("2d")
const x = 48
const top = 10
const tip = 111
const headY = 43
const radius = 28
context.clearRect(0, 0, canvas.width, canvas.height)
context.beginPath()
context.arc(x, 62, 38, 0, Math.PI * 2)
context.fillStyle = this.hexToRgba(fill, 0.18)
context.shadowColor = fill
context.shadowBlur = 18
context.fill()
context.beginPath()
context.moveTo(x, tip)
context.bezierCurveTo(x + 31, 79, x + 38, top + 20, x, top)
context.bezierCurveTo(x - 38, top + 20, x - 31, 79, x, tip)
context.closePath()
context.fillStyle = this.hexToRgba(fill, 0.96)
context.strokeStyle = this.hexToRgba(stroke || fill, 0.98)
context.lineWidth = 5
context.shadowColor = stroke || fill
context.shadowBlur = 14
context.fill()
context.stroke()
context.shadowBlur = 0
context.beginPath()
context.arc(x, headY, radius * 0.46, 0, Math.PI * 2)
context.fillStyle = this.hexToRgba("#FFFFFF", 0.95)
context.fill()
context.beginPath()
context.arc(x, headY, radius * 0.74, 0, Math.PI * 2)
context.strokeStyle = this.hexToRgba("#FFFFFF", 0.38)
context.lineWidth = 4
context.stroke()
const texture = new CanvasTexture(canvas)
texture.colorSpace = SRGBColorSpace
texture.needsUpdate = true
return texture
}
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 + 1.08 + Math.min(index, 5) * 0.015)
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
}
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 (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,
}
}
if (layer?.key === "national-park-zoning") {
return {
fill: legend.fillColor || "#30DCFF",
stroke: legend.strokeColor || legend.fillColor || "#F2FFFF",
polygonAlpha: 0.54,
lineAlpha: 1,
lineWidth: 4.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,
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,
}
}
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) {
const color = new Color(index % 3 === 1 ? 0xffd76c : index % 3 === 2 ? 0x27f0c0 : 0x30dcff)
const endPoint = this.getTradeFlowPoint(row, 0.72)
if (source) {
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
this.createTradeMarketMarker(source)
const maxValue = Math.max(...rows.map((row) => row.value), 1)
rows.forEach((row, index) => {
this.createTradeSourceMarker(row, index, maxValue, source)
})
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 canShowZeroStat = Boolean(ranking && rows.length && (meta.metricLabel || meta.unit || meta.title))
this.townNameLabels.forEach((label) => {
const townName = label.userData?.townName || label.userData?.name
const row = rowByName.get(normalizeTownName(townName)) || (canShowZeroStat
? { 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)
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 (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.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.255)
}
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: isPasture ? 0xffd34d : 0xffe15a,
fillOpacity: isPasture ? 0.42 : 0.5,
lineColor: 0xffffff,
lineOpacity: 0.98,
glowColor: isPasture ? 0x23f9d0 : 0x24f6c4,
glowOpacity: isPasture ? 0.92 : 0.96,
renderOrderBase: isPasture ? 42 : 35,
boundary: isPasture,
boundaryColor: 0xfff06a,
boundaryGlowColor: 0x24f6c4,
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.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()
}
}