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(`
${escapeHtml(displayName)}
`, 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 `
${escapeHtml(category)}
${escapeHtml(name)}
${escapeHtml(town)} ${metric ? `${escapeHtml(metric)}` : ""}
` } 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 ", /* 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 ", /* 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 ? "" : `${escapeHtml(row.town || "红原县")}` if (row.displayMode === "park-village" || meta.displayMode === "park-village") { return `
${escapeHtml(row.name)} ${townHtml}
${escapeHtml(row.metricLabel || meta.metricLabel || "")} ${this.formatMapRankingValue(row.value)} ${escapeHtml(row.unit || meta.unit || "")}
` } return `
${escapeHtml(row.name)} ${townHtml}
${escapeHtml(row.metricLabel || "防疫")} ${this.formatMapRankingValue(row.value)} ${escapeHtml(row.unit || meta.unit || "")}
台账 ${this.formatMapCompactValue(row.recordCount)}条 耳标 ${this.formatMapCompactValue(row.rawValue)}头
` } 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 `
${escapeHtml(source.name)}
${this.formatMapIntegerValue(total)}
` } 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 `
${String(row.rank).padStart(2, "0")}${escapeHtml(this.getTradeFlowDisplayName(row.name))}
${this.formatMapIntegerValue(row.value)}${escapeHtml(row.unit || "头")}
` } 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 `
${meta.title}
${this.formatMapRankingValue(data.value)}${data.unit || meta.unit}
${data.name}
${index + 1}
` } 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 `
${rankText} ${escapeHtml(data.name || "红原县")}
${escapeHtml(metricLabel)}
${this.formatMapRankingValue(data.value)} ${escapeHtml(data.unit || meta.unit || "")}
` } 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( `
${province.name}
`, 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() } }