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
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) => ({
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
"\"": """,
|
|
"'": "'",
|
|
}[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()
|
|
}
|
|
}
|
|
|