2025-10-27 17:29:42 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="map-3d-container">
|
|
|
|
|
|
<div id="app-32-map"></div>
|
|
|
|
|
|
<!-- 全国牛源地TOP5列表 -->
|
|
|
|
|
|
<div class="cattle-source-top5">
|
|
|
|
|
|
<div class="top5-header">
|
|
|
|
|
|
<h3>全国牛源地TOP3</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="top5-list">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="(item, index) in cattleSourceTop5"
|
|
|
|
|
|
:key="index"
|
|
|
|
|
|
class="top5-item"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="rank">{{ index + 1 }}</div>
|
|
|
|
|
|
<div class="province">{{ item.province }}</div>
|
|
|
|
|
|
<div class="count">{{ item.count }}万</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
|
import { Earth3d as BaseEarth } from '@/utils';
|
|
|
|
|
|
import TWEEN from '@tweenjs/tween.js';
|
|
|
|
|
|
import * as THREE from 'three';
|
|
|
|
|
|
import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js';
|
|
|
|
|
|
import { onBeforeUnmount, onMounted, onUnmounted, ref } from 'vue';
|
|
|
|
|
|
import { random } from '@/utils';
|
|
|
|
|
|
import useFileLoader from '@/hooks/useFileLoader.js';
|
|
|
|
|
|
import useCountry from '@/hooks/useCountry.js';
|
|
|
|
|
|
import useCoord from '@/hooks/useCoord.js';
|
|
|
|
|
|
import useConversionStandardData from '@/hooks/useConversionStandardData.js';
|
|
|
|
|
|
import useSequenceFrameAnimate from '@/hooks/useSequenceFrameAnimate';
|
|
|
|
|
|
import useCSS2DRender from '@/hooks/useCSS2DRenderer';
|
|
|
|
|
|
import { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer';
|
|
|
|
|
|
// import FarmPopup from './FarmPopup.vue'; // 移除弹窗组件导入
|
|
|
|
|
|
|
|
|
|
|
|
let centerXY = [106.2581, 38.4681]; // 宁夏回族自治区中心坐标
|
|
|
|
|
|
|
|
|
|
|
|
// 移除养殖场数据,不再需要标记点
|
|
|
|
|
|
// const farmData = [...];
|
|
|
|
|
|
|
|
|
|
|
|
export default {
|
|
|
|
|
|
name: '3dMap30',
|
|
|
|
|
|
components: {
|
|
|
|
|
|
// FarmPopup // 移除弹窗组件注册
|
|
|
|
|
|
},
|
2025-11-07 14:04:09 +08:00
|
|
|
|
emits: ['province-click'], // 添加省份点击事件
|
|
|
|
|
|
setup(props, { emit }) {
|
2025-10-27 17:29:42 +08:00
|
|
|
|
let baseEarth = null;
|
|
|
|
|
|
// 移除养殖场标记相关变量
|
|
|
|
|
|
// let farmMarkers = []; // 存储养殖场标记
|
|
|
|
|
|
// let selectedFarm = null; // 当前选中的养殖场
|
|
|
|
|
|
|
|
|
|
|
|
// 移除弹窗状态管理
|
|
|
|
|
|
// const showPopup = ref(false);
|
|
|
|
|
|
// const selectedFarmData = ref(null);
|
|
|
|
|
|
|
|
|
|
|
|
// 全国牛源地TOP5数据
|
|
|
|
|
|
const cattleSourceTop5 = ref([
|
|
|
|
|
|
{ province: '广西', count: 760 },
|
|
|
|
|
|
{ province: '云南', count: 247 },
|
|
|
|
|
|
{ province: '贵州', count: 186 },
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
// 重置
|
|
|
|
|
|
const resize = () => {
|
|
|
|
|
|
baseEarth.resize();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const { requestData } = useFileLoader();
|
|
|
|
|
|
const { transfromGeoJSON } = useConversionStandardData();
|
|
|
|
|
|
const { getBoundingBox, geoSphereCoord } = useCoord();
|
|
|
|
|
|
const { createCountryFlatLine } = useCountry();
|
|
|
|
|
|
const { initCSS2DRender, create2DTag } = useCSS2DRender();
|
|
|
|
|
|
// 序列帧
|
|
|
|
|
|
const { createSequenceFrame } = useSequenceFrameAnimate();
|
|
|
|
|
|
|
|
|
|
|
|
const texture = new THREE.TextureLoader();
|
|
|
|
|
|
const textureMap = texture.load('/data/map/gz-map.jpg');
|
|
|
|
|
|
const texturefxMap = texture.load('/data/map/gz-map-fx.jpg');
|
|
|
|
|
|
const rotatingApertureTexture = texture.load('/data/map/rotatingAperture.png');
|
|
|
|
|
|
const rotatingPointTexture = texture.load('/data/map/rotating-point2.png');
|
|
|
|
|
|
const circlePoint = texture.load('/data/map/circle-point.png');
|
|
|
|
|
|
const sceneBg = texture.load('/data/map/scene-bg2.png');
|
|
|
|
|
|
textureMap.wrapS = texturefxMap.wrapS = THREE.RepeatWrapping;
|
|
|
|
|
|
textureMap.wrapT = texturefxMap.wrapT = THREE.RepeatWrapping;
|
|
|
|
|
|
textureMap.flipY = texturefxMap.flipY = false;
|
|
|
|
|
|
textureMap.rotation = texturefxMap.rotation = THREE.MathUtils.degToRad(45);
|
|
|
|
|
|
const scale = 0.128;
|
|
|
|
|
|
textureMap.repeat.set(scale, scale);
|
|
|
|
|
|
texturefxMap.repeat.set(scale, scale);
|
|
|
|
|
|
const topFaceMaterial = new THREE.MeshPhongMaterial({
|
|
|
|
|
|
map: textureMap,
|
|
|
|
|
|
color: '#84acf0',
|
|
|
|
|
|
combine: THREE.MultiplyOperation,
|
|
|
|
|
|
transparent: true,
|
|
|
|
|
|
opacity: 1,
|
|
|
|
|
|
});
|
|
|
|
|
|
const sideMaterial = new THREE.MeshLambertMaterial({
|
|
|
|
|
|
color: 0x123024,
|
|
|
|
|
|
transparent: true,
|
|
|
|
|
|
opacity: 0.9,
|
|
|
|
|
|
});
|
|
|
|
|
|
const bottomZ = -0.2;
|
|
|
|
|
|
// 初始化gui
|
|
|
|
|
|
// const initGui = () => {
|
|
|
|
|
|
// const gui = new GUI();
|
|
|
|
|
|
// const guiParams = {
|
|
|
|
|
|
// topColor: '84acf0',
|
|
|
|
|
|
// sideColor: '#123024',
|
|
|
|
|
|
// scale:0.1,
|
|
|
|
|
|
// };
|
|
|
|
|
|
// gui.addColor(guiParams, 'topColor').onChange((val) => {
|
|
|
|
|
|
// topFaceMaterial.color = new THREE.Color(val);
|
|
|
|
|
|
// });
|
|
|
|
|
|
// gui.addColor(guiParams, 'sideColor').onChange((val) => {
|
|
|
|
|
|
// sideMaterial.color = new THREE.Color(val);
|
|
|
|
|
|
// });
|
|
|
|
|
|
// gui.add(guiParams, 'scale', 0, 1).onChange((val) => {
|
|
|
|
|
|
// textureMap.repeat.set(val, val);
|
|
|
|
|
|
// texturefxMap.repeat.set(val, val);
|
|
|
|
|
|
// });
|
|
|
|
|
|
// };
|
|
|
|
|
|
// 初始化旋转光圈
|
|
|
|
|
|
const initRotatingAperture = (scene, width) => {
|
|
|
|
|
|
let plane = new THREE.PlaneGeometry(width, width);
|
|
|
|
|
|
let material = new THREE.MeshBasicMaterial({
|
|
|
|
|
|
map: rotatingApertureTexture,
|
|
|
|
|
|
transparent: true,
|
|
|
|
|
|
opacity: 1,
|
|
|
|
|
|
depthTest: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
let mesh = new THREE.Mesh(plane, material);
|
|
|
|
|
|
mesh.position.set(...centerXY, 0);
|
|
|
|
|
|
mesh.scale.set(1.1, 1.1, 1.1);
|
|
|
|
|
|
scene.add(mesh);
|
|
|
|
|
|
return mesh;
|
|
|
|
|
|
};
|
|
|
|
|
|
// 初始化旋转点
|
|
|
|
|
|
const initRotatingPoint = (scene, width) => {
|
|
|
|
|
|
let plane = new THREE.PlaneGeometry(width, width);
|
|
|
|
|
|
let material = new THREE.MeshBasicMaterial({
|
|
|
|
|
|
map: rotatingPointTexture,
|
|
|
|
|
|
transparent: true,
|
|
|
|
|
|
opacity: 1,
|
|
|
|
|
|
depthTest: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
let mesh = new THREE.Mesh(plane, material);
|
|
|
|
|
|
mesh.position.set(...centerXY, bottomZ - 0.02);
|
|
|
|
|
|
mesh.scale.set(1.1, 1.1, 1.1);
|
|
|
|
|
|
scene.add(mesh);
|
|
|
|
|
|
return mesh;
|
|
|
|
|
|
};
|
|
|
|
|
|
// 初始化背景
|
|
|
|
|
|
const initSceneBg = (scene, width) => {
|
|
|
|
|
|
let plane = new THREE.PlaneGeometry(width * 4, width * 4);
|
|
|
|
|
|
let material = new THREE.MeshPhongMaterial({
|
|
|
|
|
|
// color: 0x061920,
|
|
|
|
|
|
color: '#2AF4FC',
|
|
|
|
|
|
map: sceneBg,
|
|
|
|
|
|
transparent: true,
|
|
|
|
|
|
opacity: 1,
|
|
|
|
|
|
depthTest: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
let mesh = new THREE.Mesh(plane, material);
|
|
|
|
|
|
mesh.position.set(...centerXY, bottomZ - 0.2);
|
|
|
|
|
|
scene.add(mesh);
|
|
|
|
|
|
};
|
|
|
|
|
|
// 初始化原点
|
|
|
|
|
|
const initCirclePoint = (scene, width) => {
|
|
|
|
|
|
let plane = new THREE.PlaneGeometry(width, width);
|
|
|
|
|
|
let material = new THREE.MeshPhongMaterial({
|
|
|
|
|
|
color: 0x00ffff,
|
|
|
|
|
|
map: circlePoint,
|
|
|
|
|
|
transparent: true,
|
|
|
|
|
|
opacity: 1,
|
|
|
|
|
|
// depthTest: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
let mesh = new THREE.Mesh(plane, material);
|
|
|
|
|
|
mesh.position.set(...centerXY, bottomZ - 0.1);
|
|
|
|
|
|
// let mesh2 = mesh.clone()
|
|
|
|
|
|
// mesh2.position.set(...centerXY, bottomZ - 0.001)
|
|
|
|
|
|
scene.add(mesh);
|
|
|
|
|
|
};
|
|
|
|
|
|
// 初始化粒子
|
|
|
|
|
|
const initParticle = (scene, bound) => {
|
|
|
|
|
|
// 获取中心点和中间地图大小
|
|
|
|
|
|
let { center, size } = bound;
|
|
|
|
|
|
// 构建范围,中间地图的2倍
|
|
|
|
|
|
let minX = center.x - size.x;
|
|
|
|
|
|
let maxX = center.x + size.x;
|
|
|
|
|
|
let minY = center.y - size.y;
|
|
|
|
|
|
let maxY = center.y + size.y;
|
|
|
|
|
|
let minZ = -6;
|
|
|
|
|
|
let maxZ = 6;
|
|
|
|
|
|
|
|
|
|
|
|
let particleArr = [];
|
|
|
|
|
|
for (let i = 0; i < 16; i++) {
|
|
|
|
|
|
const particle = createSequenceFrame({
|
|
|
|
|
|
image: './data/map/上升粒子1.png',
|
|
|
|
|
|
width: 180,
|
|
|
|
|
|
height: 189,
|
|
|
|
|
|
frame: 9,
|
|
|
|
|
|
column: 9,
|
|
|
|
|
|
row: 1,
|
|
|
|
|
|
speed: 0.5,
|
|
|
|
|
|
});
|
|
|
|
|
|
let particleScale = random(5, 10) / 1000;
|
|
|
|
|
|
particle.scale.set(particleScale, particleScale, particleScale);
|
|
|
|
|
|
particle.rotation.x = Math.PI / 2;
|
|
|
|
|
|
let x = random(minX, maxX);
|
|
|
|
|
|
let y = random(minY, maxY);
|
|
|
|
|
|
let z = random(minZ, maxZ);
|
|
|
|
|
|
particle.position.set(x, y, z);
|
|
|
|
|
|
particleArr.push(particle);
|
|
|
|
|
|
}
|
|
|
|
|
|
scene.add(...particleArr);
|
|
|
|
|
|
return particleArr;
|
|
|
|
|
|
};
|
|
|
|
|
|
// 创建顶部底部边线 - 优化边界线显示效果
|
|
|
|
|
|
const initBorderLine = (data, mapGroup) => {
|
|
|
|
|
|
let lineTop = createCountryFlatLine(
|
|
|
|
|
|
data,
|
|
|
|
|
|
{
|
|
|
|
|
|
color: 0xffffff,
|
|
|
|
|
|
linewidth: 0.002, // 增加线宽,提高清晰度
|
|
|
|
|
|
transparent: true,
|
|
|
|
|
|
opacity: 0.8, // 增加不透明度
|
|
|
|
|
|
depthTest: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
'Line2'
|
|
|
|
|
|
);
|
|
|
|
|
|
lineTop.position.z += 0.305;
|
|
|
|
|
|
let lineBottom = createCountryFlatLine(
|
|
|
|
|
|
data,
|
|
|
|
|
|
{
|
|
|
|
|
|
color: 0x61fbfd,
|
|
|
|
|
|
linewidth: 0.0025, // 增加底部线宽
|
|
|
|
|
|
transparent: true,
|
|
|
|
|
|
opacity: 0.9, // 增加底部线不透明度
|
|
|
|
|
|
depthTest: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
'Line2'
|
|
|
|
|
|
);
|
|
|
|
|
|
lineBottom.position.z -= 0.1905;
|
|
|
|
|
|
// 添加边线
|
|
|
|
|
|
mapGroup.add(lineTop);
|
|
|
|
|
|
mapGroup.add(lineBottom);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 创建各市区边界线 - 优化城市边界显示
|
|
|
|
|
|
const initCityBorderLines = (data, mapGroup) => {
|
|
|
|
|
|
// 为不同城市定义不同颜色
|
|
|
|
|
|
const cityColors = {
|
|
|
|
|
|
'银川市': 0x00ffff, // 青色
|
|
|
|
|
|
'石嘴山市': 0xff6600, // 橙色
|
|
|
|
|
|
'吴忠市': 0x0066ff, // 蓝色
|
|
|
|
|
|
'固原市': 0xff0066, // 粉红色
|
|
|
|
|
|
'中卫市': 0xffff00 // 黄色
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// console.log('开始创建城市边界线,数据:', data);
|
|
|
|
|
|
|
|
|
|
|
|
if (!data || !data.features) {
|
|
|
|
|
|
// console.log('没有找到城市数据');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
data.features.forEach((feature, index) => {
|
|
|
|
|
|
if (feature.geometry && feature.geometry.coordinates && feature.properties) {
|
|
|
|
|
|
const cityName = feature.properties.name;
|
|
|
|
|
|
const color = 0xffffff; // 统一设置为白色
|
|
|
|
|
|
|
|
|
|
|
|
// console.log(`创建城市边界线: ${cityName}`);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 手动创建边界线,使用与地图相同的坐标处理方式
|
|
|
|
|
|
const borderGroup = new THREE.Group();
|
|
|
|
|
|
const coordinates = feature.geometry.coordinates;
|
|
|
|
|
|
|
|
|
|
|
|
coordinates.forEach((multiPolygon) => {
|
|
|
|
|
|
multiPolygon.forEach((polygon) => {
|
|
|
|
|
|
const points = [];
|
|
|
|
|
|
|
|
|
|
|
|
// 提取坐标点,与地图处理方式一致
|
|
|
|
|
|
for (let i = 0; i < polygon.length; i++) {
|
|
|
|
|
|
let [x, y] = polygon[i];
|
|
|
|
|
|
points.push(new THREE.Vector3(x, y, 0.32)); // 稍微提高z坐标,确保边界线清晰可见
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (points.length > 1) {
|
|
|
|
|
|
// 创建线条几何体
|
|
|
|
|
|
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
|
|
|
|
|
const material = new THREE.LineBasicMaterial({
|
|
|
|
|
|
color: color,
|
|
|
|
|
|
transparent: true,
|
|
|
|
|
|
opacity: 0.7, // 增加不透明度
|
|
|
|
|
|
linewidth: 2 // 增加线宽
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const line = new THREE.Line(geometry, material);
|
|
|
|
|
|
borderGroup.add(line);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (borderGroup.children.length > 0) {
|
|
|
|
|
|
borderGroup.name = `cityBorder_${cityName}`;
|
|
|
|
|
|
mapGroup.add(borderGroup);
|
|
|
|
|
|
// console.log(`已添加城市边界线: ${cityName}, 线条数量: ${borderGroup.children.length}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
// console.error(`创建城市边界线时出错: ${cityName}`, error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// console.log('城市边界线创建完成,mapGroup子对象数量:', mapGroup.children.length);
|
|
|
|
|
|
};
|
|
|
|
|
|
// 已移除光柱功能,使用养殖场标记点替代
|
|
|
|
|
|
|
|
|
|
|
|
// 移除养殖场标记初始化函数
|
|
|
|
|
|
// const initFarmMarkers = (mapGroup) => { ... };
|
|
|
|
|
|
|
|
|
|
|
|
// 移除养殖场点击事件处理函数
|
|
|
|
|
|
// const initFarmClickHandler = () => { ... };
|
|
|
|
|
|
|
|
|
|
|
|
// 移除弹窗相关函数
|
|
|
|
|
|
// const showFarmPopup = (farm) => { ... };
|
|
|
|
|
|
// const closePopup = () => { ... };
|
|
|
|
|
|
// 创建标签 - 优化省份名称标注位置和样式
|
2025-11-07 14:04:09 +08:00
|
|
|
|
// 标签注册表:名称到CSS2DObject的映射
|
|
|
|
|
|
const labelRegistry = new Map();
|
|
|
|
|
|
const createdLabels = [];
|
|
|
|
|
|
// 全局标签缩放因子:统一缩小标签字号(例如 0.85 = 缩小15%)
|
|
|
|
|
|
const LABEL_SIZE_FACTOR = 0.85;
|
|
|
|
|
|
// 省份简称映射(默认取首字作为后备)
|
|
|
|
|
|
const provinceAbbrMap = {
|
|
|
|
|
|
'北京市': '京', '天津市': '津', '上海市': '沪', '重庆市': '渝',
|
|
|
|
|
|
'河北省': '冀', '山西省': '晋', '辽宁省': '辽', '吉林省': '吉', '黑龙江省': '黑',
|
|
|
|
|
|
'江苏省': '苏', '浙江省': '浙', '安徽省': '皖', '福建省': '闽', '江西省': '赣',
|
|
|
|
|
|
'山东省': '鲁', '河南省': '豫', '湖北省': '鄂', '湖南省': '湘', '广东省': '粤',
|
|
|
|
|
|
'海南省': '琼', '四川省': '川', '贵州省': '黔', '云南省': '滇', '陕西省': '陕',
|
|
|
|
|
|
'甘肃省': '甘', '青海省': '青', '内蒙古自治区': '蒙', '广西壮族自治区': '桂',
|
|
|
|
|
|
'西藏自治区': '藏', '宁夏回族自治区': '宁', '新疆维吾尔自治区': '新',
|
|
|
|
|
|
'香港特别行政区': '港', '澳门特别行政区': '澳', '台湾省': '台'
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const initLabel = (properties, scene, provinceObj = null) => {
|
|
|
|
|
|
// 标签地理中心(优先使用数据中心,缺失时回退到包围盒中心)
|
|
|
|
|
|
const centerCandidate = properties.center || properties.centroid || properties.cp;
|
|
|
|
|
|
let posVec = null;
|
|
|
|
|
|
if (Array.isArray(centerCandidate)) {
|
|
|
|
|
|
posVec = new THREE.Vector3(centerCandidate[0], centerCandidate[1], 1.2);
|
|
|
|
|
|
} else if (centerCandidate && typeof centerCandidate.x === 'number' && typeof centerCandidate.y === 'number') {
|
|
|
|
|
|
posVec = new THREE.Vector3(centerCandidate.x, centerCandidate.y, 1.2);
|
|
|
|
|
|
} else if (provinceObj) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const boxForCenter = new THREE.Box3().setFromObject(provinceObj);
|
|
|
|
|
|
const center = boxForCenter.getCenter(new THREE.Vector3());
|
|
|
|
|
|
posVec = new THREE.Vector3(center.x, center.y, 1.2);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 创建省份名称标签(默认只显示简称/图标,悬停显示全称)
|
|
|
|
|
|
const fullName = properties.name;
|
|
|
|
|
|
const abbr = provinceAbbrMap[fullName] || (typeof fullName === 'string' ? fullName[0] : '');
|
|
|
|
|
|
const initialHtml = `<span class="abbr">${abbr}</span><span class="full">${fullName}</span>`;
|
|
|
|
|
|
const label = create2DTag(initialHtml, 'province-name-label');
|
|
|
|
|
|
// 字体与可读性增强
|
|
|
|
|
|
label.element.style.fontFamily = "Microsoft YaHei, SimHei, Arial, sans-serif";
|
|
|
|
|
|
label.element.style.webkitTextStroke = '0.8px rgba(0,0,0,0.6)';
|
|
|
|
|
|
label.element.style.textShadow = '1px 1px 2px rgba(0,0,0,0.6), 0 0 6px rgba(0, 212, 255, 0.5)';
|
|
|
|
|
|
// 基于省份面积自适应字号
|
|
|
|
|
|
let fontSizePx = 14; // 默认字号
|
|
|
|
|
|
if (provinceObj) {
|
|
|
|
|
|
const box = new THREE.Box3().setFromObject(provinceObj);
|
|
|
|
|
|
const size = box.getSize(new THREE.Vector3());
|
|
|
|
|
|
const areaApprox = size.x * size.y;
|
|
|
|
|
|
// 将面积映射到字号范围 [12, 22]
|
|
|
|
|
|
const minArea = 20; // 小省份的近似面积阈值
|
|
|
|
|
|
const maxArea = 600; // 大省份的近似面积阈值
|
|
|
|
|
|
const clamp = (v, min, max) => Math.min(Math.max(v, min), max);
|
|
|
|
|
|
const t = clamp((areaApprox - minArea) / (maxArea - minArea), 0, 1);
|
|
|
|
|
|
fontSizePx = Math.round(12 + t * (22 - 12));
|
|
|
|
|
|
}
|
|
|
|
|
|
// 应用全局缩放因子,统一缩小标签
|
|
|
|
|
|
fontSizePx = Math.max(10, Math.round(fontSizePx * LABEL_SIZE_FACTOR));
|
|
|
|
|
|
label.element.style.fontSize = fontSizePx + 'px';
|
|
|
|
|
|
|
2025-10-27 17:29:42 +08:00
|
|
|
|
scene.add(label);
|
2025-11-07 14:04:09 +08:00
|
|
|
|
// 初始位置:略微抬高避免与边界贴合
|
|
|
|
|
|
// 默认显示简称内容(已在 initialHtml 中),设置坐标
|
|
|
|
|
|
label.show(initialHtml, posVec.clone());
|
|
|
|
|
|
// 青海省标签向左微调,避免与区域重叠
|
|
|
|
|
|
if (fullName === '青海省') {
|
|
|
|
|
|
const pos = posVec.clone();
|
|
|
|
|
|
pos.x -= 2; // 微调量,可根据实际效果再行调整
|
|
|
|
|
|
label.position.copy(pos);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 注册标签用于交互联动与碰撞处理
|
|
|
|
|
|
labelRegistry.set(properties.name, label);
|
|
|
|
|
|
createdLabels.push({
|
|
|
|
|
|
name: properties.name,
|
|
|
|
|
|
label,
|
|
|
|
|
|
center: posVec.clone(),
|
|
|
|
|
|
fontSizePx
|
|
|
|
|
|
});
|
2025-10-27 17:29:42 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 省份牛品种和存栏量数据 - 扩展为所有省份
|
|
|
|
|
|
const provinceData = {
|
|
|
|
|
|
'北京市': {
|
|
|
|
|
|
breeds: ['荷斯坦牛', '西门塔尔牛', '安格斯牛'],
|
|
|
|
|
|
inventory: 45000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'天津市': {
|
|
|
|
|
|
breeds: ['荷斯坦牛', '西门塔尔牛'],
|
|
|
|
|
|
inventory: 32000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'河北省': {
|
|
|
|
|
|
breeds: ['西门塔尔牛', '安格斯牛', '夏洛莱牛', '荷斯坦牛'],
|
|
|
|
|
|
inventory: 1180000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'山西省': {
|
|
|
|
|
|
breeds: ['西门塔尔牛', '夏洛莱牛', '晋南牛'],
|
|
|
|
|
|
inventory: 680000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'内蒙古自治区': {
|
|
|
|
|
|
breeds: ['西门塔尔牛', '安格斯牛', '夏洛莱牛', '利木赞牛'],
|
|
|
|
|
|
inventory: 1250000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'辽宁省': {
|
|
|
|
|
|
breeds: ['西门塔尔牛', '安格斯牛', '夏洛莱牛'],
|
|
|
|
|
|
inventory: 890000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'吉林省': {
|
|
|
|
|
|
breeds: ['西门塔尔牛', '安格斯牛', '延边牛'],
|
|
|
|
|
|
inventory: 750000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'黑龙江省': {
|
|
|
|
|
|
breeds: ['西门塔尔牛', '安格斯牛', '夏洛莱牛', '荷斯坦牛'],
|
|
|
|
|
|
inventory: 1380000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'上海市': {
|
|
|
|
|
|
breeds: ['荷斯坦牛', '西门塔尔牛'],
|
|
|
|
|
|
inventory: 28000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'江苏省': {
|
|
|
|
|
|
breeds: ['西门塔尔牛', '安格斯牛', '苏北牛'],
|
|
|
|
|
|
inventory: 520000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'浙江省': {
|
|
|
|
|
|
breeds: ['西门塔尔牛', '安格斯牛', '金华牛'],
|
|
|
|
|
|
inventory: 380000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'安徽省': {
|
|
|
|
|
|
breeds: ['西门塔尔牛', '夏洛莱牛', '皖南牛'],
|
|
|
|
|
|
inventory: 650000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'福建省': {
|
|
|
|
|
|
breeds: ['西门塔尔牛', '安格斯牛', '闽南牛'],
|
|
|
|
|
|
inventory: 420000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'江西省': {
|
|
|
|
|
|
breeds: ['西门塔尔牛', '夏洛莱牛', '赣州牛'],
|
|
|
|
|
|
inventory: 580000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'山东省': {
|
|
|
|
|
|
breeds: ['西门塔尔牛', '安格斯牛', '夏洛莱牛', '鲁西黄牛'],
|
|
|
|
|
|
inventory: 1420000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'河南省': {
|
|
|
|
|
|
breeds: ['西门塔尔牛', '夏洛莱牛', '南阳牛'],
|
|
|
|
|
|
inventory: 1350000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'湖北省': {
|
|
|
|
|
|
breeds: ['西门塔尔牛', '安格斯牛', '江汉牛'],
|
|
|
|
|
|
inventory: 780000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'湖南省': {
|
|
|
|
|
|
breeds: ['西门塔尔牛', '夏洛莱牛', '湘西牛'],
|
|
|
|
|
|
inventory: 820000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'广东省': {
|
|
|
|
|
|
breeds: ['西门塔尔牛', '安格斯牛', '雷州牛'],
|
|
|
|
|
|
inventory: 680000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'广西壮族自治区': {
|
|
|
|
|
|
breeds: ['西门塔尔牛', '安格斯牛', '德保矮马牛'],
|
|
|
|
|
|
inventory: 950000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'海南省': {
|
|
|
|
|
|
breeds: ['西门塔尔牛', '安格斯牛', '海南牛'],
|
|
|
|
|
|
inventory: 180000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'重庆市': {
|
|
|
|
|
|
breeds: ['西门塔尔牛', '夏洛莱牛', '川东牛'],
|
|
|
|
|
|
inventory: 450000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'四川省': {
|
|
|
|
|
|
breeds: ['西门塔尔牛', '夏洛莱牛', '利木赞牛', '安格斯牛'],
|
|
|
|
|
|
inventory: 1150000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'贵州省': {
|
|
|
|
|
|
breeds: ['西门塔尔牛', '夏洛莱牛', '关岭牛'],
|
|
|
|
|
|
inventory: 720000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'云南省': {
|
|
|
|
|
|
breeds: ['云南黄牛', '西门塔尔牛', '婆罗门牛', '安格斯牛'],
|
|
|
|
|
|
inventory: 890000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'西藏自治区': {
|
|
|
|
|
|
breeds: ['牦牛', '西门塔尔牛', '藏牛'],
|
|
|
|
|
|
inventory: 480000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'陕西省': {
|
|
|
|
|
|
breeds: ['西门塔尔牛', '夏洛莱牛', '秦川牛'],
|
|
|
|
|
|
inventory: 680000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'甘肃省': {
|
|
|
|
|
|
breeds: ['西门塔尔牛', '夏洛莱牛', '利木赞牛', '秦川牛'],
|
|
|
|
|
|
inventory: 760000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'青海省': {
|
|
|
|
|
|
breeds: ['牦牛', '西门塔尔牛', '青海牛'],
|
|
|
|
|
|
inventory: 520000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'宁夏回族自治区': {
|
|
|
|
|
|
breeds: ['西门塔尔牛', '安格斯牛', '宁夏牛'],
|
|
|
|
|
|
inventory: 380000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'新疆维吾尔自治区': {
|
|
|
|
|
|
breeds: ['褐牛', '西门塔尔牛', '安格斯牛', '海福特牛'],
|
|
|
|
|
|
inventory: 980000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'台湾省': {
|
|
|
|
|
|
breeds: ['西门塔尔牛', '安格斯牛', '台湾牛'],
|
|
|
|
|
|
inventory: 120000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'香港特别行政区': {
|
|
|
|
|
|
breeds: ['进口牛', '西门塔尔牛'],
|
|
|
|
|
|
inventory: 5000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
},
|
|
|
|
|
|
'澳门特别行政区': {
|
|
|
|
|
|
breeds: ['进口牛', '西门塔尔牛'],
|
|
|
|
|
|
inventory: 2000,
|
|
|
|
|
|
color: 0x86FFFF
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-07 14:04:09 +08:00
|
|
|
|
// 仅创建省份名称文本标签(替换原圆圈标记)
|
2025-10-27 17:29:42 +08:00
|
|
|
|
const createProvinceBubble = (properties, scene) => {
|
|
|
|
|
|
const provinceName = properties.name;
|
|
|
|
|
|
// 排除香港、澳门、台湾
|
|
|
|
|
|
if (provinceName === '香港特别行政区' || provinceName === '澳门特别行政区' || provinceName === '台湾省') {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const center = properties.center || properties.centroid || properties.cp;
|
|
|
|
|
|
if (!center) {
|
|
|
|
|
|
console.warn(`省份 ${provinceName} 缺少坐标信息`);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-11-07 14:04:09 +08:00
|
|
|
|
// 此处不再创建任何 Three.js 圆圈或光柱,仅依赖上方 initLabel 创建的省份名称标签
|
|
|
|
|
|
// 如果需要在部分省份单独微调位置,可在此添加偏移逻辑:
|
|
|
|
|
|
// 例如:
|
|
|
|
|
|
// const label = labelRegistry.get(provinceName);
|
|
|
|
|
|
// if (label) label.position.set(center[0] + dx, center[1] + dy, 1.2);
|
2025-10-27 17:29:42 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 创建悬浮信息提示框
|
|
|
|
|
|
const createTooltip = () => {
|
|
|
|
|
|
const tooltip = document.createElement('div');
|
|
|
|
|
|
tooltip.id = 'bubble-tooltip';
|
|
|
|
|
|
tooltip.style.cssText = `
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
background: rgba(0, 0, 0, 0.8);
|
|
|
|
|
|
color: white;
|
|
|
|
|
|
padding: 10px 15px;
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
z-index: 10001;
|
|
|
|
|
|
display: none;
|
|
|
|
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
|
|
|
|
border: 1px solid #84acf0;
|
|
|
|
|
|
max-width: 300px;
|
|
|
|
|
|
`;
|
|
|
|
|
|
document.body.appendChild(tooltip);
|
|
|
|
|
|
return tooltip;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 鼠标交互处理
|
|
|
|
|
|
const initBubbleInteraction = (container, camera, scene) => {
|
|
|
|
|
|
const tooltip = createTooltip();
|
|
|
|
|
|
const raycaster = new THREE.Raycaster();
|
|
|
|
|
|
const mouse = new THREE.Vector2();
|
|
|
|
|
|
let hoveredBubble = null;
|
|
|
|
|
|
|
|
|
|
|
|
const onMouseMove = (event) => {
|
|
|
|
|
|
const rect = container.getBoundingClientRect();
|
|
|
|
|
|
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
|
|
|
|
|
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
|
|
|
|
|
|
|
|
|
|
|
raycaster.setFromCamera(mouse, camera);
|
|
|
|
|
|
|
|
|
|
|
|
// 查找所有气泡对象
|
|
|
|
|
|
const bubbles = scene.children.filter(child =>
|
|
|
|
|
|
child.userData && child.userData.type === 'bubble'
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const intersects = raycaster.intersectObjects(bubbles);
|
|
|
|
|
|
|
|
|
|
|
|
if (intersects.length > 0) {
|
|
|
|
|
|
const bubble = intersects[0].object;
|
|
|
|
|
|
|
|
|
|
|
|
if (hoveredBubble !== bubble) {
|
|
|
|
|
|
// 重置之前悬浮的气泡
|
|
|
|
|
|
if (hoveredBubble) {
|
|
|
|
|
|
hoveredBubble.material.opacity = 0.8;
|
|
|
|
|
|
hoveredBubble.scale.set(1, 1, 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 设置新悬浮的气泡
|
|
|
|
|
|
hoveredBubble = bubble;
|
|
|
|
|
|
bubble.material.opacity = 1.0;
|
|
|
|
|
|
bubble.scale.set(1.2, 1.2, 1.2);
|
|
|
|
|
|
|
|
|
|
|
|
// 显示提示框
|
|
|
|
|
|
const data = bubble.userData.cattleData;
|
|
|
|
|
|
tooltip.innerHTML = `
|
|
|
|
|
|
<div style="font-weight: bold; margin-bottom: 8px; color: #84acf0;">
|
|
|
|
|
|
${bubble.userData.province}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style="margin-bottom: 5px;">
|
|
|
|
|
|
<strong>主要牛品种:</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style="margin-bottom: 8px; padding-left: 10px;">
|
|
|
|
|
|
${data.breeds.join('、')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<strong>存栏量:</strong> ${data.inventory.toLocaleString()} 头
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
tooltip.style.display = 'block';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新提示框位置
|
|
|
|
|
|
tooltip.style.left = (event.clientX + 15) + 'px';
|
|
|
|
|
|
tooltip.style.top = (event.clientY - 10) + 'px';
|
|
|
|
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 没有悬浮在气泡上
|
|
|
|
|
|
if (hoveredBubble) {
|
|
|
|
|
|
hoveredBubble.material.opacity = 0.8;
|
|
|
|
|
|
hoveredBubble.scale.set(1, 1, 1);
|
|
|
|
|
|
hoveredBubble = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
tooltip.style.display = 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
container.addEventListener('mousemove', onMouseMove);
|
|
|
|
|
|
|
|
|
|
|
|
// 返回清理函数
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
container.removeEventListener('mousemove', onMouseMove);
|
|
|
|
|
|
if (tooltip.parentNode) {
|
|
|
|
|
|
tooltip.parentNode.removeChild(tooltip);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
|
console.log('=== Map3D组件已挂载 ===');
|
|
|
|
|
|
// console.log('farmData:', farmData); // 移除farmData引用
|
|
|
|
|
|
// 等待DOM完全渲染
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
|
|
|
|
|
|
|
|
|
|
// 检查容器尺寸但不阻止初始化
|
|
|
|
|
|
const container = document.getElementById('app-32-map');
|
|
|
|
|
|
// console.log('容器尺寸:', container ? `${container.offsetWidth}x${container.offsetHeight}` : '容器不存在');
|
|
|
|
|
|
|
|
|
|
|
|
if (container && (container.offsetWidth === 0 || container.offsetHeight === 0)) {
|
|
|
|
|
|
// console.log('容器尺寸为0,但继续初始化...');
|
|
|
|
|
|
// 减少等待时间,不阻止初始化
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 宁夏回族自治区数据
|
|
|
|
|
|
let provinceData;
|
|
|
|
|
|
try {
|
|
|
|
|
|
provinceData = await requestData('./data/map/中华人民共和国.json');
|
|
|
|
|
|
provinceData = transfromGeoJSON(provinceData);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
// console.error('地图数据加载失败:', error);
|
|
|
|
|
|
return; // 如果数据加载失败,直接返回
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class CurrentEarth extends BaseEarth {
|
|
|
|
|
|
constructor(props) {
|
|
|
|
|
|
super(props);
|
|
|
|
|
|
this.particleArr = []; // 初始化粒子数组
|
|
|
|
|
|
}
|
|
|
|
|
|
initCamera() {
|
|
|
|
|
|
let { width, height } = this.options;
|
|
|
|
|
|
let rate = width / height;
|
|
|
|
|
|
// 设置50°的透视相机,最大化减少视野角度让地图显示最大
|
|
|
|
|
|
this.camera = new THREE.PerspectiveCamera(50, rate, 0.001, 90000000);
|
|
|
|
|
|
this.camera.up.set(0, 0, 1);
|
|
|
|
|
|
// 调整相机位置,让地图显示最大 - 使用更近的距离
|
|
|
|
|
|
this.camera.position.set(106.2581, 25.4681, 30); //相机在Three.js坐标系中的位置,将在initModel中重新计算
|
|
|
|
|
|
this.camera.lookAt(...centerXY, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
initModel() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 创建组
|
|
|
|
|
|
this.mapGroup = new THREE.Group();
|
|
|
|
|
|
// 标签 初始化 - 确保使用有效的尺寸
|
|
|
|
|
|
const validOptions = {
|
|
|
|
|
|
...this.options,
|
|
|
|
|
|
width: Math.max(this.options.width || this.container.offsetWidth || 800, 800),
|
|
|
|
|
|
height: Math.max(this.options.height || this.container.offsetHeight || 600, 600)
|
|
|
|
|
|
};
|
|
|
|
|
|
this.css2dRender = initCSS2DRender(validOptions, this.container);
|
|
|
|
|
|
|
|
|
|
|
|
// 确保CSS2D渲染器的DOM元素有正确的样式
|
|
|
|
|
|
this.css2dRender.domElement.style.zIndex = '10000'; // 确保在WebGL canvas之上
|
|
|
|
|
|
this.css2dRender.domElement.style.pointerEvents = 'none';
|
|
|
|
|
|
|
|
|
|
|
|
console.log('CSS2D渲染器初始化完成:', {
|
|
|
|
|
|
renderer: this.css2dRender,
|
|
|
|
|
|
domElement: this.css2dRender.domElement,
|
|
|
|
|
|
containerChildren: this.container.children.length
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// console.log('开始处理省份数据,features数量:', provinceData.features.length);
|
|
|
|
|
|
provinceData.features.forEach((elem, index) => {
|
|
|
|
|
|
// console.log(`处理第${index + 1}个feature:`, elem.properties.name);
|
|
|
|
|
|
|
|
|
|
|
|
// 定一个省份对象
|
|
|
|
|
|
const province = new THREE.Object3D();
|
|
|
|
|
|
// 坐标
|
|
|
|
|
|
const coordinates = elem.geometry.coordinates;
|
|
|
|
|
|
// city 属性
|
|
|
|
|
|
const properties = elem.properties;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 循环坐标
|
|
|
|
|
|
coordinates.forEach((multiPolygon) => {
|
|
|
|
|
|
multiPolygon.forEach((polygon) => {
|
|
|
|
|
|
|
|
|
|
|
|
const shape = new THREE.Shape();
|
|
|
|
|
|
// 绘制shape
|
|
|
|
|
|
for (let i = 0; i < polygon.length; i++) {
|
|
|
|
|
|
|
|
|
|
|
|
let [x, y] = polygon[i];
|
|
|
|
|
|
if (i === 0) {
|
|
|
|
|
|
shape.moveTo(x, y);
|
|
|
|
|
|
}
|
|
|
|
|
|
shape.lineTo(x, y);
|
|
|
|
|
|
}
|
|
|
|
|
|
// 拉伸设置
|
|
|
|
|
|
const extrudeSettings = {
|
|
|
|
|
|
depth: 0.2,
|
|
|
|
|
|
bevelEnabled: true,
|
|
|
|
|
|
bevelSegments: 1,
|
|
|
|
|
|
bevelThickness: 0.1,
|
|
|
|
|
|
};
|
|
|
|
|
|
const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
|
|
|
|
|
|
const mesh = new THREE.Mesh(geometry, [topFaceMaterial, sideMaterial]);
|
|
|
|
|
|
|
|
|
|
|
|
// 为省份网格添加标识信息,用于悬停交互
|
|
|
|
|
|
mesh.userData = {
|
|
|
|
|
|
type: 'province',
|
|
|
|
|
|
name: properties.name,
|
|
|
|
|
|
originalMaterial: [topFaceMaterial, sideMaterial]
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
province.add(mesh);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 设置省份组的名称和用户数据
|
|
|
|
|
|
province.name = `province_${properties.name}`;
|
|
|
|
|
|
province.userData = {
|
|
|
|
|
|
type: 'provinceGroup',
|
|
|
|
|
|
name: properties.name
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
this.mapGroup.add(province);
|
2025-11-07 14:04:09 +08:00
|
|
|
|
// 创建标点和标签(传入省份对象用于面积估算)
|
|
|
|
|
|
initLabel(properties, this.scene, province);
|
2025-10-27 17:29:42 +08:00
|
|
|
|
|
|
|
|
|
|
// 为每个省份创建气泡标记
|
|
|
|
|
|
createProvinceBubble(properties, this.scene);
|
|
|
|
|
|
});
|
|
|
|
|
|
// 创建上下边框
|
|
|
|
|
|
initBorderLine(provinceData, this.mapGroup);
|
|
|
|
|
|
|
|
|
|
|
|
// 创建各市区边界线
|
|
|
|
|
|
initCityBorderLines(provinceData, this.mapGroup);
|
|
|
|
|
|
|
|
|
|
|
|
let earthGroupBound = getBoundingBox(this.mapGroup);
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化省份悬停交互功能
|
|
|
|
|
|
this.initProvinceHoverInteraction();
|
|
|
|
|
|
centerXY = [earthGroupBound.center.x, earthGroupBound.center.y];
|
|
|
|
|
|
let { size } = earthGroupBound;
|
|
|
|
|
|
let width = size.x < size.y ? size.y + 1 : size.x + 1;
|
|
|
|
|
|
|
|
|
|
|
|
// 计算合适的相机距离,让地图显示得更大一些
|
|
|
|
|
|
const mapSize = Math.max(size.x, size.y);
|
|
|
|
|
|
const cameraDistance = mapSize * 1.3; // 减少距离系数,让地图显示得更大
|
2025-11-07 14:04:09 +08:00
|
|
|
|
this.baseCameraDistance = cameraDistance;
|
2025-10-27 17:29:42 +08:00
|
|
|
|
// 添加背景,修饰元素
|
|
|
|
|
|
// this.rotatingApertureMesh = initRotatingAperture(this.scene, width); // 注释掉旋转光圈
|
|
|
|
|
|
// this.rotatingPointMesh = initRotatingPoint(this.scene, width - 2); // 注释掉旋转点
|
|
|
|
|
|
// initCirclePoint(this.scene, width); // 注释掉圆形点
|
|
|
|
|
|
initSceneBg(this.scene, width);
|
|
|
|
|
|
|
2025-11-07 14:04:09 +08:00
|
|
|
|
// 解决标签重叠并根据初始缩放调整标签大小
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
this.resolveLabelCollisions();
|
|
|
|
|
|
this.updateLabelScale();
|
|
|
|
|
|
}, 100);
|
|
|
|
|
|
|
2025-10-27 17:29:42 +08:00
|
|
|
|
// 将组添加到场景中
|
|
|
|
|
|
// console.log('将mapGroup添加到场景中,mapGroup子对象数量:', this.mapGroup.children.length);
|
|
|
|
|
|
this.scene.add(this.mapGroup);
|
|
|
|
|
|
// console.log('场景中对象数量:', this.scene.children.length);
|
|
|
|
|
|
this.particleArr = initParticle(this.scene, earthGroupBound);
|
|
|
|
|
|
// console.log('粒子系统初始化完成');
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化省份悬停交互功能
|
|
|
|
|
|
this.initProvinceHoverInteraction();
|
2025-11-07 14:04:09 +08:00
|
|
|
|
// 监听缩放与旋转变化,动态缩放标签
|
|
|
|
|
|
if (this.controls) {
|
|
|
|
|
|
this.controls.addEventListener('change', () => {
|
|
|
|
|
|
this.updateLabelScale();
|
|
|
|
|
|
// 缩放/旋转时也重新做简单的重叠修正
|
|
|
|
|
|
this.resolveLabelCollisions(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
// 窗口尺寸变化时,重算标签缩放与轻量碰撞
|
|
|
|
|
|
window.addEventListener('resize', () => {
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
this.updateLabelScale();
|
|
|
|
|
|
this.resolveLabelCollisions(true);
|
|
|
|
|
|
}, 100);
|
|
|
|
|
|
});
|
|
|
|
|
|
// 初始化地图拖拽平移交互(PC与移动端)
|
|
|
|
|
|
this.initDragPan(earthGroupBound);
|
2025-10-27 17:29:42 +08:00
|
|
|
|
|
|
|
|
|
|
// 养殖场标记将在baseEarth.run()完成后初始化
|
|
|
|
|
|
|
|
|
|
|
|
// 更新相机目标到新的中心点
|
|
|
|
|
|
this.camera.lookAt(...centerXY, 0);
|
|
|
|
|
|
|
|
|
|
|
|
// 重新调整相机位置,让地图显示得更大
|
|
|
|
|
|
this.camera.position.set(centerXY[0], centerXY[1] - cameraDistance * 0.3, cameraDistance);
|
|
|
|
|
|
|
|
|
|
|
|
if (this.controls) {
|
|
|
|
|
|
this.controls.target.set(...centerXY, 0);
|
|
|
|
|
|
this.controls.object.position.copy(this.camera.position);
|
|
|
|
|
|
this.controls.update();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 强制调整相机以确保地图完整显示
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
if (this.adjustCameraForFullView) {
|
|
|
|
|
|
this.adjustCameraForFullView();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 100);
|
|
|
|
|
|
|
|
|
|
|
|
initGui();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
// console.log(error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-11-07 14:04:09 +08:00
|
|
|
|
|
|
|
|
|
|
// 根据相机距离动态调整标签缩放
|
|
|
|
|
|
updateLabelScale() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!this.camera || createdLabels.length === 0) return;
|
|
|
|
|
|
const camPos = this.camera.position.clone();
|
|
|
|
|
|
const center = new THREE.Vector3(...centerXY, 0);
|
|
|
|
|
|
const dist = camPos.distanceTo(center);
|
|
|
|
|
|
const base = this.baseCameraDistance || dist;
|
|
|
|
|
|
// 相机越近,标签越小;相机越远,标签越大(做轻微反向缩放)
|
|
|
|
|
|
const scale = Math.max(0.75, Math.min(1.4, base / dist));
|
|
|
|
|
|
createdLabels.forEach(({ label, fontSizePx }) => {
|
|
|
|
|
|
const size = Math.round(fontSizePx * scale);
|
|
|
|
|
|
label.element.style.fontSize = size + 'px';
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 粗略的标签碰撞检测与位置微调
|
|
|
|
|
|
resolveLabelCollisions(isRealtime = false) {
|
|
|
|
|
|
if (!this.camera || !this.renderer || createdLabels.length === 0) return;
|
|
|
|
|
|
const rects = [];
|
|
|
|
|
|
const toScreen = (pos) => {
|
|
|
|
|
|
const p = pos.clone().project(this.camera);
|
|
|
|
|
|
const w = this.renderer.domElement.width || this.options.width;
|
|
|
|
|
|
const h = this.renderer.domElement.height || this.options.height;
|
|
|
|
|
|
return {
|
|
|
|
|
|
x: (p.x + 1) * 0.5 * w,
|
|
|
|
|
|
y: (1 - (p.y + 1) * 0.5) * h
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
// 采集当前屏幕矩形
|
|
|
|
|
|
createdLabels.forEach(({ label, center, name }) => {
|
|
|
|
|
|
const screen = toScreen(center);
|
|
|
|
|
|
const bbox = label.element.getBoundingClientRect();
|
|
|
|
|
|
rects.push({
|
|
|
|
|
|
name,
|
|
|
|
|
|
label,
|
|
|
|
|
|
center,
|
|
|
|
|
|
x: screen.x - bbox.width / 2,
|
|
|
|
|
|
y: screen.y - bbox.height / 2,
|
|
|
|
|
|
w: bbox.width,
|
|
|
|
|
|
h: bbox.height
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
// 简单冲突检测:若重叠则按网格步进偏移
|
|
|
|
|
|
const moved = new Set();
|
|
|
|
|
|
const step = isRealtime ? 0.15 : 0.3; // 实时偏移更小
|
|
|
|
|
|
for (let i = 0; i < rects.length; i++) {
|
|
|
|
|
|
for (let j = i + 1; j < rects.length; j++) {
|
|
|
|
|
|
const a = rects[i];
|
|
|
|
|
|
const b = rects[j];
|
|
|
|
|
|
const overlap = !(a.x + a.w < b.x || b.x + b.w < a.x || a.y + a.h < b.y || b.y + b.h < a.y);
|
|
|
|
|
|
if (overlap) {
|
|
|
|
|
|
// 沿着x方向微调一个向左,一个向右
|
|
|
|
|
|
if (!moved.has(a.name)) {
|
|
|
|
|
|
a.center.x -= step;
|
|
|
|
|
|
a.label.position.copy(a.center);
|
|
|
|
|
|
moved.add(a.name);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!moved.has(b.name)) {
|
|
|
|
|
|
b.center.x += step;
|
|
|
|
|
|
b.label.position.copy(b.center);
|
|
|
|
|
|
moved.add(b.name);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-10-27 17:29:42 +08:00
|
|
|
|
|
2025-11-07 14:04:09 +08:00
|
|
|
|
// 初始化省份悬停和点击交互功能
|
2025-10-27 17:29:42 +08:00
|
|
|
|
initProvinceHoverInteraction() {
|
|
|
|
|
|
const raycaster = new THREE.Raycaster();
|
|
|
|
|
|
const mouse = new THREE.Vector2();
|
|
|
|
|
|
let hoveredProvince = null;
|
|
|
|
|
|
|
2025-11-07 14:04:09 +08:00
|
|
|
|
// 检测设备类型
|
|
|
|
|
|
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
|
|
|
|
|
|
2025-10-27 17:29:42 +08:00
|
|
|
|
// 创建高亮材质
|
|
|
|
|
|
const highlightTopMaterial = new THREE.MeshBasicMaterial({
|
|
|
|
|
|
color: 0x00d4ff,
|
|
|
|
|
|
transparent: true,
|
|
|
|
|
|
opacity: 0.8
|
|
|
|
|
|
});
|
|
|
|
|
|
const highlightSideMaterial = new THREE.MeshBasicMaterial({
|
|
|
|
|
|
color: 0x0099cc,
|
|
|
|
|
|
transparent: true,
|
|
|
|
|
|
opacity: 0.8
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-11-07 14:04:09 +08:00
|
|
|
|
// 创建点击高亮材质
|
|
|
|
|
|
const clickHighlightTopMaterial = new THREE.MeshBasicMaterial({
|
|
|
|
|
|
color: 0x00ff88,
|
|
|
|
|
|
transparent: true,
|
|
|
|
|
|
opacity: 0.9
|
|
|
|
|
|
});
|
|
|
|
|
|
const clickHighlightSideMaterial = new THREE.MeshBasicMaterial({
|
|
|
|
|
|
color: 0x00cc66,
|
|
|
|
|
|
transparent: true,
|
|
|
|
|
|
opacity: 0.9
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 获取鼠标/触摸位置的通用函数
|
|
|
|
|
|
const getPointerPosition = (event) => {
|
2025-10-27 17:29:42 +08:00
|
|
|
|
const rect = this.container.getBoundingClientRect();
|
2025-11-07 14:04:09 +08:00
|
|
|
|
let clientX, clientY;
|
|
|
|
|
|
|
|
|
|
|
|
if (event.touches && event.touches.length > 0) {
|
|
|
|
|
|
// 触摸事件
|
|
|
|
|
|
clientX = event.touches[0].clientX;
|
|
|
|
|
|
clientY = event.touches[0].clientY;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 鼠标事件
|
|
|
|
|
|
clientX = event.clientX;
|
|
|
|
|
|
clientY = event.clientY;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
x: ((clientX - rect.left) / rect.width) * 2 - 1,
|
|
|
|
|
|
y: -((clientY - rect.top) / rect.height) * 2 + 1,
|
|
|
|
|
|
clientX,
|
|
|
|
|
|
clientY
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 处理省份交互的通用函数
|
|
|
|
|
|
const handleProvinceInteraction = (event, isClick = false) => {
|
|
|
|
|
|
const pointer = getPointerPosition(event);
|
|
|
|
|
|
mouse.x = pointer.x;
|
|
|
|
|
|
mouse.y = pointer.y;
|
2025-10-27 17:29:42 +08:00
|
|
|
|
|
|
|
|
|
|
raycaster.setFromCamera(mouse, this.camera);
|
|
|
|
|
|
const intersects = raycaster.intersectObjects(this.mapGroup.children, true);
|
|
|
|
|
|
|
2025-11-07 14:04:09 +08:00
|
|
|
|
if (!isClick) {
|
|
|
|
|
|
// 悬停处理:恢复之前高亮的省份
|
|
|
|
|
|
if (hoveredProvince) {
|
|
|
|
|
|
hoveredProvince.children.forEach(mesh => {
|
|
|
|
|
|
if (mesh.userData.type === 'province') {
|
|
|
|
|
|
mesh.material = mesh.userData.originalMaterial;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
// 恢复标签样式
|
|
|
|
|
|
if (hoveredProvince.userData && hoveredProvince.userData.name) {
|
|
|
|
|
|
const prevLabel = labelRegistry.get(hoveredProvince.userData.name);
|
|
|
|
|
|
if (prevLabel) prevLabel.element.classList.remove('province-name-label-hover');
|
2025-10-27 17:29:42 +08:00
|
|
|
|
}
|
2025-11-07 14:04:09 +08:00
|
|
|
|
hoveredProvince = null;
|
|
|
|
|
|
}
|
2025-10-27 17:29:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-07 14:04:09 +08:00
|
|
|
|
// 检查是否悬停/点击在省份上
|
2025-10-27 17:29:42 +08:00
|
|
|
|
if (intersects.length > 0) {
|
2025-11-07 14:04:09 +08:00
|
|
|
|
// 选择首个命中省份网格,避免边界线/其他对象抢占命中
|
|
|
|
|
|
let intersectedObject = null;
|
|
|
|
|
|
for (let i = 0; i < intersects.length; i++) {
|
|
|
|
|
|
const obj = intersects[i].object;
|
|
|
|
|
|
if (obj && obj.userData && obj.userData.type === 'province') {
|
|
|
|
|
|
intersectedObject = obj;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (intersectedObject && intersectedObject.userData.type === 'province') {
|
2025-10-27 17:29:42 +08:00
|
|
|
|
// 找到省份组
|
|
|
|
|
|
let provinceGroup = intersectedObject.parent;
|
|
|
|
|
|
while (provinceGroup && provinceGroup.userData.type !== 'provinceGroup') {
|
|
|
|
|
|
provinceGroup = provinceGroup.parent;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (provinceGroup) {
|
2025-11-07 14:04:09 +08:00
|
|
|
|
if (isClick && provinceGroup.userData.name) {
|
|
|
|
|
|
// 点击处理
|
|
|
|
|
|
const provinceName = provinceGroup.userData.name;
|
|
|
|
|
|
|
|
|
|
|
|
// 添加点击视觉反馈效果
|
|
|
|
|
|
provinceGroup.children.forEach(mesh => {
|
|
|
|
|
|
if (mesh.userData.type === 'province') {
|
|
|
|
|
|
mesh.material = [clickHighlightTopMaterial, clickHighlightSideMaterial];
|
|
|
|
|
|
|
|
|
|
|
|
// 0.3秒后恢复原始材质
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
mesh.material = mesh.userData.originalMaterial;
|
|
|
|
|
|
}, 300);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 创建点击反馈提示
|
|
|
|
|
|
this.showClickFeedback({ clientX: pointer.clientX, clientY: pointer.clientY }, provinceName);
|
|
|
|
|
|
|
|
|
|
|
|
// 触发省份点击事件
|
|
|
|
|
|
emit('province-click', provinceName);
|
|
|
|
|
|
|
|
|
|
|
|
console.log(`点击了省份: ${provinceName}`);
|
|
|
|
|
|
} else if (!isClick) {
|
|
|
|
|
|
// 悬停处理
|
|
|
|
|
|
hoveredProvince = provinceGroup;
|
|
|
|
|
|
// 高亮整个省份
|
|
|
|
|
|
provinceGroup.children.forEach(mesh => {
|
|
|
|
|
|
if (mesh.userData.type === 'province') {
|
|
|
|
|
|
mesh.material = [highlightTopMaterial, highlightSideMaterial];
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
// 高亮对应标签
|
|
|
|
|
|
const label = labelRegistry.get(provinceGroup.userData.name);
|
|
|
|
|
|
if (label) {
|
|
|
|
|
|
label.element.classList.add('province-name-label-hover');
|
|
|
|
|
|
// 悬停时保证字号不低于14px
|
|
|
|
|
|
try {
|
|
|
|
|
|
const currentSize = parseInt(window.getComputedStyle(label.element).fontSize) || 14;
|
|
|
|
|
|
if (currentSize < 14) {
|
|
|
|
|
|
label.element.style.fontSize = '14px';
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {}
|
2025-10-27 17:29:42 +08:00
|
|
|
|
}
|
2025-11-07 14:04:09 +08:00
|
|
|
|
|
|
|
|
|
|
// 改变鼠标样式(仅桌面端)
|
|
|
|
|
|
if (!isMobile) {
|
|
|
|
|
|
this.container.style.cursor = 'pointer';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-10-27 17:29:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-11-07 14:04:09 +08:00
|
|
|
|
} else if (!isClick) {
|
|
|
|
|
|
// 恢复默认鼠标样式(仅桌面端)
|
|
|
|
|
|
if (!isMobile) {
|
|
|
|
|
|
this.container.style.cursor = 'default';
|
|
|
|
|
|
}
|
|
|
|
|
|
// 清除标签高亮
|
|
|
|
|
|
if (hoveredProvince && hoveredProvince.userData && hoveredProvince.userData.name) {
|
|
|
|
|
|
const label = labelRegistry.get(hoveredProvince.userData.name);
|
|
|
|
|
|
if (label) {
|
|
|
|
|
|
label.element.classList.remove('province-name-label-hover');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 鼠标移动事件(桌面端)
|
|
|
|
|
|
const onMouseMove = (event) => {
|
|
|
|
|
|
if (!isMobile) {
|
|
|
|
|
|
handleProvinceInteraction(event, false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 点击/触摸事件(桌面端和移动端)
|
|
|
|
|
|
const onPointerClick = (event) => {
|
|
|
|
|
|
// 防止事件冒泡
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
|
|
|
|
|
|
handleProvinceInteraction(event, true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 触摸开始事件(移动端长按触发悬浮效果)
|
|
|
|
|
|
let touchHoverTimer = null;
|
|
|
|
|
|
const onTouchStart = (event) => {
|
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
|
clearTimeout(touchHoverTimer);
|
|
|
|
|
|
const startEvent = event;
|
|
|
|
|
|
touchHoverTimer = setTimeout(() => {
|
|
|
|
|
|
handleProvinceInteraction(startEvent, false);
|
|
|
|
|
|
}, 500);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 触摸结束事件(移动端清除悬停效果)
|
|
|
|
|
|
const onTouchEnd = (event) => {
|
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
|
clearTimeout(touchHoverTimer);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isMobile && hoveredProvince) {
|
|
|
|
|
|
hoveredProvince.children.forEach(mesh => {
|
|
|
|
|
|
if (mesh.userData.type === 'province') {
|
|
|
|
|
|
mesh.material = mesh.userData.originalMaterial;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
const label = labelRegistry.get(hoveredProvince.userData.name);
|
|
|
|
|
|
if (label) label.element.classList.remove('province-name-label-hover');
|
|
|
|
|
|
hoveredProvince = null;
|
2025-10-27 17:29:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 添加事件监听器
|
2025-11-07 14:04:09 +08:00
|
|
|
|
if (!isMobile) {
|
|
|
|
|
|
// 桌面端事件
|
|
|
|
|
|
this.container.addEventListener('mousemove', onMouseMove);
|
|
|
|
|
|
this.container.addEventListener('click', onPointerClick);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 移动端事件
|
|
|
|
|
|
this.container.addEventListener('touchstart', onTouchStart, { passive: false });
|
|
|
|
|
|
this.container.addEventListener('touchend', onTouchEnd, { passive: false });
|
|
|
|
|
|
this.container.addEventListener('touchstart', onPointerClick, { passive: false });
|
|
|
|
|
|
}
|
2025-10-27 17:29:42 +08:00
|
|
|
|
|
|
|
|
|
|
// 存储事件处理函数以便后续清理
|
|
|
|
|
|
this.provinceHoverHandler = onMouseMove;
|
2025-11-07 14:04:09 +08:00
|
|
|
|
this.provinceClickHandler = onPointerClick;
|
|
|
|
|
|
this.provinceTouchStartHandler = onTouchStart;
|
|
|
|
|
|
this.provinceTouchEndHandler = onTouchEnd;
|
|
|
|
|
|
this.isMobileDevice = isMobile;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 显示点击反馈提示
|
|
|
|
|
|
showClickFeedback(event, provinceName) {
|
|
|
|
|
|
const feedback = document.createElement('div');
|
|
|
|
|
|
feedback.className = 'province-click-feedback';
|
|
|
|
|
|
feedback.textContent = `正在跳转到 ${provinceName} 预警监测`;
|
|
|
|
|
|
|
|
|
|
|
|
// 设置位置
|
|
|
|
|
|
feedback.style.left = `${event.clientX - 100}px`;
|
|
|
|
|
|
feedback.style.top = `${event.clientY - 50}px`;
|
|
|
|
|
|
|
|
|
|
|
|
document.body.appendChild(feedback);
|
|
|
|
|
|
|
|
|
|
|
|
// 0.6秒后移除
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
if (feedback.parentNode) {
|
|
|
|
|
|
feedback.parentNode.removeChild(feedback);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 600);
|
2025-10-27 17:29:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
getDataRenderMap() {}
|
|
|
|
|
|
|
|
|
|
|
|
destroy() {
|
2025-11-07 14:04:09 +08:00
|
|
|
|
// 清理省份悬停和点击事件监听器
|
|
|
|
|
|
if (this.container) {
|
|
|
|
|
|
if (this.provinceHoverHandler) {
|
|
|
|
|
|
this.container.removeEventListener('mousemove', this.provinceHoverHandler);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (this.provinceClickHandler) {
|
|
|
|
|
|
this.container.removeEventListener('click', this.provinceClickHandler);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (this.provinceTouchStartHandler) {
|
|
|
|
|
|
this.container.removeEventListener('touchstart', this.provinceTouchStartHandler);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (this.provinceTouchEndHandler) {
|
|
|
|
|
|
this.container.removeEventListener('touchend', this.provinceTouchEndHandler);
|
|
|
|
|
|
}
|
2025-10-27 17:29:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
initControls() {
|
|
|
|
|
|
super.initControls();
|
|
|
|
|
|
this.controls.target = new THREE.Vector3(...centerXY, 0);
|
|
|
|
|
|
// 设置控制器的初始距离,确保地图完整显示
|
|
|
|
|
|
this.controls.object.position.copy(this.camera.position);
|
|
|
|
|
|
this.controls.update();
|
|
|
|
|
|
|
|
|
|
|
|
// 添加自动调整相机位置的方法
|
|
|
|
|
|
this.adjustCameraForFullView = () => {
|
|
|
|
|
|
if (this.mapGroup && this.camera) {
|
|
|
|
|
|
const box = new THREE.Box3().setFromObject(this.mapGroup);
|
|
|
|
|
|
const size = box.getSize(new THREE.Vector3());
|
|
|
|
|
|
const center = box.getCenter(new THREE.Vector3());
|
|
|
|
|
|
const maxDim = Math.max(size.x, size.y, size.z);
|
|
|
|
|
|
const distance = maxDim * 1.2; // 减少距离系数,让地图显示得更大
|
|
|
|
|
|
|
|
|
|
|
|
this.camera.position.set(center.x, center.y - distance * 0.3, distance);
|
|
|
|
|
|
this.camera.lookAt(center);
|
|
|
|
|
|
|
|
|
|
|
|
if (this.controls) {
|
|
|
|
|
|
this.controls.target.copy(center);
|
|
|
|
|
|
this.controls.object.position.copy(this.camera.position);
|
|
|
|
|
|
this.controls.update();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
initLight() {
|
|
|
|
|
|
// 平行光1 - 主光源
|
|
|
|
|
|
let directionalLight1 = new THREE.DirectionalLight(0x7af4ff, 1.2);
|
|
|
|
|
|
directionalLight1.position.set(...centerXY, 50);
|
|
|
|
|
|
directionalLight1.castShadow = false;
|
|
|
|
|
|
|
|
|
|
|
|
// 平行光2 - 辅助光源
|
|
|
|
|
|
let directionalLight2 = new THREE.DirectionalLight(0x7af4ff, 0.8);
|
|
|
|
|
|
directionalLight2.position.set(centerXY[0] + 20, centerXY[1] + 20, 40);
|
|
|
|
|
|
directionalLight2.castShadow = false;
|
|
|
|
|
|
|
|
|
|
|
|
// 平行光3 - 顶部光源,增强边界线可见性
|
|
|
|
|
|
let directionalLight3 = new THREE.DirectionalLight(0xffffff, 0.6);
|
|
|
|
|
|
directionalLight3.position.set(...centerXY, 80);
|
|
|
|
|
|
directionalLight3.castShadow = false;
|
|
|
|
|
|
|
|
|
|
|
|
// 环境光 - 增强整体亮度
|
|
|
|
|
|
let ambientLight = new THREE.AmbientLight(0x7af4ff, 1.5);
|
|
|
|
|
|
|
|
|
|
|
|
// 将光源添加到场景中
|
|
|
|
|
|
this.addObject(directionalLight1);
|
|
|
|
|
|
this.addObject(directionalLight2);
|
|
|
|
|
|
this.addObject(directionalLight3);
|
|
|
|
|
|
this.addObject(ambientLight);
|
|
|
|
|
|
}
|
|
|
|
|
|
initRenderer() {
|
|
|
|
|
|
super.initRenderer();
|
|
|
|
|
|
// this.renderer.outputEncoding = THREE.sRGBEncoding
|
|
|
|
|
|
}
|
2025-11-07 14:04:09 +08:00
|
|
|
|
// 地图拖拽平移交互:支持鼠标与触摸,阻尼与惯性、边界弹性
|
|
|
|
|
|
initDragPan(groupBound) {
|
|
|
|
|
|
const container = this.container;
|
|
|
|
|
|
if (!container || !this.controls || !this.renderer) return;
|
|
|
|
|
|
const size = groupBound.size;
|
|
|
|
|
|
const center = new THREE.Vector3(...centerXY, 0);
|
|
|
|
|
|
// 平移边界(以中心为基准的最大偏移量)
|
|
|
|
|
|
const maxOffsetX = size.x * 0.35; // 可根据视觉需求微调
|
|
|
|
|
|
const maxOffsetY = size.y * 0.35;
|
|
|
|
|
|
const elasticRatio = 0.1; // 10% 过拖弹性区域
|
|
|
|
|
|
const elasticX = maxOffsetX * (1 + elasticRatio);
|
|
|
|
|
|
const elasticY = maxOffsetY * (1 + elasticRatio);
|
|
|
|
|
|
|
|
|
|
|
|
// 拖拽状态
|
|
|
|
|
|
this.panState = {
|
|
|
|
|
|
dragging: false,
|
|
|
|
|
|
lastPointer: { x: 0, y: 0 },
|
|
|
|
|
|
velocity: new THREE.Vector2(0, 0),
|
|
|
|
|
|
friction: 0.92,
|
|
|
|
|
|
spring: 0.08,
|
|
|
|
|
|
maxOffsetX,
|
|
|
|
|
|
maxOffsetY,
|
|
|
|
|
|
elasticX,
|
|
|
|
|
|
elasticY,
|
|
|
|
|
|
trailCanvas: null,
|
|
|
|
|
|
trailCtx: null,
|
|
|
|
|
|
trailAlpha: 0.8
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 轨迹反馈画布
|
|
|
|
|
|
const setupTrail = () => {
|
|
|
|
|
|
if (this.panState.trailCanvas) return;
|
|
|
|
|
|
const cvs = document.createElement('canvas');
|
|
|
|
|
|
cvs.width = container.clientWidth || this.options.width;
|
|
|
|
|
|
cvs.height = container.clientHeight || this.options.height;
|
|
|
|
|
|
cvs.style.position = 'absolute';
|
|
|
|
|
|
cvs.style.left = '0';
|
|
|
|
|
|
cvs.style.top = '0';
|
|
|
|
|
|
cvs.style.zIndex = '9999';
|
|
|
|
|
|
cvs.style.pointerEvents = 'none';
|
|
|
|
|
|
container.appendChild(cvs);
|
|
|
|
|
|
this.panState.trailCanvas = cvs;
|
|
|
|
|
|
this.panState.trailCtx = cvs.getContext('2d');
|
|
|
|
|
|
this.panState.trailCtx.strokeStyle = 'rgba(0, 212, 255, 0.6)';
|
|
|
|
|
|
this.panState.trailCtx.lineWidth = 2;
|
|
|
|
|
|
this.panState.trailCtx.lineJoin = 'round';
|
|
|
|
|
|
this.panState.trailCtx.lineCap = 'round';
|
|
|
|
|
|
};
|
|
|
|
|
|
const resizeTrail = () => {
|
|
|
|
|
|
if (!this.panState.trailCanvas) return;
|
|
|
|
|
|
this.panState.trailCanvas.width = container.clientWidth || this.options.width;
|
|
|
|
|
|
this.panState.trailCanvas.height = container.clientHeight || this.options.height;
|
|
|
|
|
|
};
|
|
|
|
|
|
window.addEventListener('resize', resizeTrail);
|
|
|
|
|
|
|
|
|
|
|
|
// 屏幕像素位移 -> 世界坐标位移(基于相机距离与FOV近似计算)
|
|
|
|
|
|
const screenDeltaToWorld = (dx, dy) => {
|
|
|
|
|
|
const dist = this.camera.position.distanceTo(center);
|
|
|
|
|
|
const fovRad = (this.camera.fov || 45) * Math.PI / 180;
|
|
|
|
|
|
const viewHeight = 2 * Math.tan(fovRad / 2) * dist;
|
|
|
|
|
|
const canvasH = this.renderer.domElement.height || this.options.height;
|
|
|
|
|
|
const canvasW = this.renderer.domElement.width || this.options.width;
|
|
|
|
|
|
const worldPerPixelY = viewHeight / canvasH;
|
|
|
|
|
|
const worldPerPixelX = worldPerPixelY * (canvasW / canvasH);
|
|
|
|
|
|
return new THREE.Vector2(dx * worldPerPixelX, -dy * worldPerPixelY);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 应用平移并做边界与弹性处理
|
|
|
|
|
|
const applyPan = (deltaWorld) => {
|
|
|
|
|
|
const target = this.controls.target;
|
|
|
|
|
|
const cam = this.controls.object;
|
|
|
|
|
|
target.x += deltaWorld.x; target.y += deltaWorld.y;
|
|
|
|
|
|
cam.position.x += deltaWorld.x; cam.position.y += deltaWorld.y;
|
|
|
|
|
|
|
|
|
|
|
|
// 边界与弹性:在弹性范围外施加弹簧回弹
|
|
|
|
|
|
const ox = target.x - center.x;
|
|
|
|
|
|
const oy = target.y - center.y;
|
|
|
|
|
|
// X轴
|
|
|
|
|
|
if (Math.abs(ox) > elasticX) {
|
|
|
|
|
|
const sign = Math.sign(ox);
|
|
|
|
|
|
const desired = center.x + sign * elasticX;
|
|
|
|
|
|
const back = (desired - target.x) * this.panState.spring;
|
|
|
|
|
|
target.x += back; cam.position.x += back;
|
|
|
|
|
|
this.panState.velocity.x *= 0.8;
|
|
|
|
|
|
} else if (Math.abs(ox) > maxOffsetX) {
|
|
|
|
|
|
const sign = Math.sign(ox);
|
|
|
|
|
|
const desired = center.x + sign * maxOffsetX;
|
|
|
|
|
|
const back = (desired - target.x) * this.panState.spring;
|
|
|
|
|
|
target.x += back; cam.position.x += back;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Y轴
|
|
|
|
|
|
if (Math.abs(oy) > elasticY) {
|
|
|
|
|
|
const sign = Math.sign(oy);
|
|
|
|
|
|
const desired = center.y + sign * elasticY;
|
|
|
|
|
|
const back = (desired - target.y) * this.panState.spring;
|
|
|
|
|
|
target.y += back; cam.position.y += back;
|
|
|
|
|
|
this.panState.velocity.y *= 0.8;
|
|
|
|
|
|
} else if (Math.abs(oy) > maxOffsetY) {
|
|
|
|
|
|
const sign = Math.sign(oy);
|
|
|
|
|
|
const desired = center.y + sign * maxOffsetY;
|
|
|
|
|
|
const back = (desired - target.y) * this.panState.spring;
|
|
|
|
|
|
target.y += back; cam.position.y += back;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
// 暴露给渲染循环使用
|
|
|
|
|
|
this.applyPan = applyPan;
|
|
|
|
|
|
|
|
|
|
|
|
// 事件绑定
|
|
|
|
|
|
const onPointerDown = (e) => {
|
|
|
|
|
|
this.panState.dragging = true;
|
|
|
|
|
|
if (this.controls) this.controls.enabled = false; // 避免与OrbitControls冲突
|
|
|
|
|
|
setupTrail();
|
|
|
|
|
|
const p = ('touches' in e) ? e.touches[0] : e;
|
|
|
|
|
|
this.panState.lastPointer.x = p.clientX;
|
|
|
|
|
|
this.panState.lastPointer.y = p.clientY;
|
|
|
|
|
|
if (this.panState.trailCtx) {
|
|
|
|
|
|
const ctx = this.panState.trailCtx;
|
|
|
|
|
|
ctx.clearRect(0,0,this.panState.trailCanvas.width,this.panState.trailCanvas.height);
|
|
|
|
|
|
ctx.beginPath(); ctx.moveTo(p.clientX, p.clientY);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
const onPointerMove = (e) => {
|
|
|
|
|
|
if (!this.panState.dragging) return;
|
|
|
|
|
|
if ('touches' in e) { try { e.preventDefault(); } catch(err){} }
|
|
|
|
|
|
const p = ('touches' in e) ? e.touches[0] : e;
|
|
|
|
|
|
const dx = p.clientX - this.panState.lastPointer.x;
|
|
|
|
|
|
const dy = p.clientY - this.panState.lastPointer.y;
|
|
|
|
|
|
this.panState.lastPointer.x = p.clientX;
|
|
|
|
|
|
this.panState.lastPointer.y = p.clientY;
|
|
|
|
|
|
const deltaWorld = screenDeltaToWorld(dx, dy);
|
|
|
|
|
|
this.applyPan(deltaWorld);
|
|
|
|
|
|
// 更新速度(用于惯性)
|
|
|
|
|
|
this.panState.velocity.copy(deltaWorld);
|
|
|
|
|
|
// 轨迹绘制
|
|
|
|
|
|
if (this.panState.trailCtx) {
|
|
|
|
|
|
const ctx = this.panState.trailCtx;
|
|
|
|
|
|
ctx.lineTo(p.clientX, p.clientY); ctx.stroke();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
const onPointerUp = () => {
|
|
|
|
|
|
this.panState.dragging = false;
|
|
|
|
|
|
if (this.controls) this.controls.enabled = true;
|
|
|
|
|
|
// 轨迹在惯性阶段逐渐淡出
|
|
|
|
|
|
this.panState.trailAlpha = 0.8;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 绑定PC与移动端事件
|
|
|
|
|
|
container.addEventListener('mousedown', onPointerDown);
|
|
|
|
|
|
window.addEventListener('mousemove', onPointerMove);
|
|
|
|
|
|
window.addEventListener('mouseup', onPointerUp);
|
|
|
|
|
|
container.addEventListener('touchstart', onPointerDown, { passive: false });
|
|
|
|
|
|
window.addEventListener('touchmove', onPointerMove, { passive: false });
|
|
|
|
|
|
window.addEventListener('touchend', onPointerUp, { passive: false });
|
|
|
|
|
|
|
|
|
|
|
|
// 保存以便销毁
|
|
|
|
|
|
this.dragPanHandlers = { onPointerDown, onPointerMove, onPointerUp, resizeTrail };
|
|
|
|
|
|
}
|
2025-10-27 17:29:42 +08:00
|
|
|
|
loop() {
|
|
|
|
|
|
this.animationStop = window.requestAnimationFrame(() => {
|
|
|
|
|
|
this.loop();
|
|
|
|
|
|
});
|
|
|
|
|
|
// 检查渲染器是否存在
|
|
|
|
|
|
if (!this.renderer || !this.scene || !this.camera) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检查渲染器尺寸是否有效(放宽检查条件)
|
|
|
|
|
|
if (this.renderer.domElement && (this.renderer.domElement.width === 0 || this.renderer.domElement.height === 0)) {
|
|
|
|
|
|
// 只在连续多帧都是0尺寸时才跳过渲染
|
|
|
|
|
|
if (!this.zeroSizeFrameCount) this.zeroSizeFrameCount = 0;
|
|
|
|
|
|
this.zeroSizeFrameCount++;
|
|
|
|
|
|
if (this.zeroSizeFrameCount > 10) {
|
|
|
|
|
|
// console.warn('渲染器canvas尺寸持续为0,跳过渲染');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.zeroSizeFrameCount = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检查CSS2D渲染器是否有效(只检查是否存在,不检查尺寸)
|
|
|
|
|
|
// CSS2DRenderer的domElement可能不会有正确的offsetWidth/offsetHeight
|
|
|
|
|
|
|
|
|
|
|
|
// 这里是你自己业务上需要的code
|
|
|
|
|
|
this.renderer.render(this.scene, this.camera);
|
|
|
|
|
|
// 控制相机旋转缩放的更新
|
|
|
|
|
|
if (this.options.controls.visibel && this.controls) {
|
|
|
|
|
|
this.controls.update();
|
|
|
|
|
|
}
|
|
|
|
|
|
// 统计更新 - 添加更严格的检查
|
|
|
|
|
|
if (this.options.statsVisibel && this.stats && this.stats.dom) {
|
|
|
|
|
|
// 检查Stats内部canvas是否有效
|
|
|
|
|
|
const canvas = this.stats.dom.querySelector('canvas');
|
|
|
|
|
|
if (canvas && canvas.width > 0 && canvas.height > 0) {
|
|
|
|
|
|
this.stats.update();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// 旋转光圈和旋转点动画已禁用
|
|
|
|
|
|
// if (this.rotatingApertureMesh) {
|
|
|
|
|
|
// this.rotatingApertureMesh.rotation.z += 0.0005;
|
|
|
|
|
|
// }
|
|
|
|
|
|
// if (this.rotatingPointMesh) {
|
|
|
|
|
|
// this.rotatingPointMesh.rotation.z -= 0.0005;
|
|
|
|
|
|
// }
|
|
|
|
|
|
// 渲染标签 - 使用CSS2D渲染器
|
|
|
|
|
|
if (this.css2dRender && this.scene && this.camera) {
|
|
|
|
|
|
this.css2dRender.render(this.scene, this.camera);
|
|
|
|
|
|
// 每100帧输出一次调试信息
|
|
|
|
|
|
if (this.frameCount === undefined) this.frameCount = 0;
|
|
|
|
|
|
this.frameCount++;
|
|
|
|
|
|
if (this.frameCount % 100 === 0) {
|
|
|
|
|
|
console.log('CSS2D渲染器状态:', {
|
|
|
|
|
|
renderer: !!this.css2dRender,
|
|
|
|
|
|
scene: !!this.scene,
|
|
|
|
|
|
camera: !!this.camera,
|
|
|
|
|
|
sceneChildren: this.scene.children.length
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-11-07 14:04:09 +08:00
|
|
|
|
// 惯性与阻尼更新(拖拽松开后的滑动与边界弹性回弹)
|
|
|
|
|
|
if (this.panState && !this.panState.dragging) {
|
|
|
|
|
|
// 应用速度
|
|
|
|
|
|
if (Math.abs(this.panState.velocity.x) > 1e-5 || Math.abs(this.panState.velocity.y) > 1e-5) {
|
|
|
|
|
|
this.applyPan(this.panState.velocity.clone());
|
|
|
|
|
|
// 阻尼
|
|
|
|
|
|
this.panState.velocity.multiplyScalar(this.panState.friction);
|
|
|
|
|
|
// 速度阈值
|
|
|
|
|
|
if (this.panState.velocity.length() < 1e-4) {
|
|
|
|
|
|
this.panState.velocity.set(0,0);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// 轨迹淡出并清除
|
|
|
|
|
|
if (this.panState.trailCtx && this.panState.trailAlpha > 0) {
|
|
|
|
|
|
const ctx = this.panState.trailCtx;
|
|
|
|
|
|
ctx.globalAlpha = this.panState.trailAlpha;
|
|
|
|
|
|
ctx.fillStyle = 'rgba(0,0,0,0.06)';
|
|
|
|
|
|
ctx.fillRect(0,0,this.panState.trailCanvas.width,this.panState.trailCanvas.height);
|
|
|
|
|
|
ctx.globalAlpha = 1.0;
|
|
|
|
|
|
this.panState.trailAlpha -= 0.05;
|
|
|
|
|
|
if (this.panState.trailAlpha <= 0) {
|
|
|
|
|
|
ctx.clearRect(0,0,this.panState.trailCanvas.width,this.panState.trailCanvas.height);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-10-27 17:29:42 +08:00
|
|
|
|
// 粒子上升
|
|
|
|
|
|
if (this.particleArr.length) {
|
|
|
|
|
|
for (let i = 0; i < this.particleArr.length; i++) {
|
|
|
|
|
|
this.particleArr[i].updateSequenceFrame();
|
|
|
|
|
|
this.particleArr[i].position.z += 0.01;
|
|
|
|
|
|
if (this.particleArr[i].position.z >= 6) {
|
|
|
|
|
|
this.particleArr[i].position.z = -6;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 执行气泡动画
|
|
|
|
|
|
if (window.bubbleAnimations) {
|
|
|
|
|
|
window.bubbleAnimations.forEach(animate => animate());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
TWEEN.update();
|
|
|
|
|
|
}
|
|
|
|
|
|
resize() {
|
|
|
|
|
|
super.resize();
|
|
|
|
|
|
|
|
|
|
|
|
// 确保尺寸有效
|
|
|
|
|
|
const validWidth = Math.max(this.options.width || 800, 800);
|
|
|
|
|
|
const validHeight = Math.max(this.options.height || 600, 600);
|
|
|
|
|
|
|
|
|
|
|
|
// 更新options中的尺寸
|
|
|
|
|
|
this.options.width = validWidth;
|
|
|
|
|
|
this.options.height = validHeight;
|
|
|
|
|
|
|
|
|
|
|
|
// 确保渲染器已准备就绪再执行渲染
|
|
|
|
|
|
if (this.renderer && this.scene && this.camera) {
|
|
|
|
|
|
// 重新设置渲染器尺寸
|
|
|
|
|
|
this.renderer.setSize(validWidth, validHeight);
|
|
|
|
|
|
this.renderer.setPixelRatio(window.devicePixelRatio);
|
|
|
|
|
|
// 这里是你自己业务上需要的code
|
|
|
|
|
|
this.renderer.render(this.scene, this.camera);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (this.css2dRender) {
|
|
|
|
|
|
this.css2dRender.setSize(validWidth, validHeight);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// console.log('开始创建Earth实例...');
|
|
|
|
|
|
baseEarth = new CurrentEarth({
|
|
|
|
|
|
container: '#app-32-map',
|
|
|
|
|
|
axesVisibel: false,
|
|
|
|
|
|
controls: {
|
|
|
|
|
|
enableDamping: true, // 阻尼
|
|
|
|
|
|
maxPolarAngle: (Math.PI / 2) * 0.98,
|
|
|
|
|
|
minDistance: 2, // 最小缩放距离
|
|
|
|
|
|
maxDistance: 150, // 最大缩放距离
|
|
|
|
|
|
enableZoom: true, // 启用缩放
|
|
|
|
|
|
enableRotate: false, // 禁用旋转
|
|
|
|
|
|
enablePan: true, // 启用平移
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
// console.log('Earth实例创建完成,开始运行...');
|
|
|
|
|
|
baseEarth.run();
|
|
|
|
|
|
// console.log('Earth实例运行完成');
|
|
|
|
|
|
|
2025-11-07 14:04:09 +08:00
|
|
|
|
// 重新挂载/初始化 CSS2DRenderer(initRenderer 会清空容器,需要在其后恢复)
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
const containerEl = document.getElementById('app-32-map');
|
|
|
|
|
|
if (baseEarth && containerEl) {
|
|
|
|
|
|
if (baseEarth.css2dRender && baseEarth.css2dRender.domElement) {
|
|
|
|
|
|
// 重新设置尺寸与样式并挂载回容器
|
|
|
|
|
|
const w = Math.max(baseEarth.options.width || containerEl.offsetWidth || 800, 800);
|
|
|
|
|
|
const h = Math.max(baseEarth.options.height || containerEl.offsetHeight || 600, 600);
|
|
|
|
|
|
baseEarth.css2dRender.setSize(w, h);
|
|
|
|
|
|
baseEarth.css2dRender.domElement.style.position = 'absolute';
|
|
|
|
|
|
baseEarth.css2dRender.domElement.style.left = '0px';
|
|
|
|
|
|
baseEarth.css2dRender.domElement.style.top = '0px';
|
|
|
|
|
|
baseEarth.css2dRender.domElement.style.zIndex = '10000';
|
|
|
|
|
|
baseEarth.css2dRender.domElement.style.pointerEvents = 'none';
|
|
|
|
|
|
baseEarth.css2dRender.domElement.style.overflow = 'visible';
|
|
|
|
|
|
containerEl.appendChild(baseEarth.css2dRender.domElement);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 若不存在则重新创建
|
|
|
|
|
|
baseEarth.css2dRender = initCSS2DRender({
|
|
|
|
|
|
width: Math.max(containerEl.offsetWidth || 800, 800),
|
|
|
|
|
|
height: Math.max(containerEl.offsetHeight || 600, 600)
|
|
|
|
|
|
}, containerEl);
|
|
|
|
|
|
baseEarth.css2dRender.domElement.style.zIndex = '10000';
|
|
|
|
|
|
baseEarth.css2dRender.domElement.style.pointerEvents = 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 300);
|
2025-10-27 17:29:42 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 移除养殖场标记初始化
|
|
|
|
|
|
// console.log('准备调用initFarmMarkers,mapGroup:', baseEarth.mapGroup);
|
|
|
|
|
|
// initFarmMarkers(baseEarth.mapGroup);
|
|
|
|
|
|
// console.log('initFarmMarkers调用完成,mapGroup子对象数量:', baseEarth.mapGroup.children.length);
|
|
|
|
|
|
|
|
|
|
|
|
// 在养殖场标记添加完成后,再次调整相机以确保完整显示
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
if (baseEarth && baseEarth.adjustCameraForFullView) {
|
|
|
|
|
|
baseEarth.adjustCameraForFullView();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 200);
|
|
|
|
|
|
|
|
|
|
|
|
// 移除养殖场点击事件监听器
|
|
|
|
|
|
// initFarmClickHandler();
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化气泡交互功能
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
const container = document.getElementById('app-32-map');
|
|
|
|
|
|
if (container && baseEarth && baseEarth.camera && baseEarth.scene) {
|
|
|
|
|
|
const cleanupInteraction = initBubbleInteraction(container, baseEarth.camera, baseEarth.scene);
|
|
|
|
|
|
|
|
|
|
|
|
// 在组件卸载时清理事件监听器
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
if (cleanupInteraction) {
|
|
|
|
|
|
cleanupInteraction();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 300);
|
|
|
|
|
|
|
|
|
|
|
|
window.addEventListener('resize', resize);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
// 移除弹窗相关返回值
|
|
|
|
|
|
// showPopup,
|
|
|
|
|
|
// selectedFarmData,
|
|
|
|
|
|
// closePopup,
|
|
|
|
|
|
// initFarmMarkers
|
|
|
|
|
|
cattleSourceTop5
|
|
|
|
|
|
};
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
|
window.removeEventListener('resize', resize);
|
|
|
|
|
|
if (baseEarth) {
|
|
|
|
|
|
// 清理Three.js资源
|
|
|
|
|
|
if (baseEarth.renderer) {
|
|
|
|
|
|
baseEarth.renderer.dispose();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (baseEarth.scene) {
|
|
|
|
|
|
baseEarth.scene.clear();
|
|
|
|
|
|
}
|
|
|
|
|
|
// 停止动画循环
|
|
|
|
|
|
if (baseEarth.animationStop) {
|
|
|
|
|
|
cancelAnimationFrame(baseEarth.animationStop);
|
|
|
|
|
|
}
|
|
|
|
|
|
baseEarth = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
</script>
|
|
|
|
|
|
<style>
|
|
|
|
|
|
/* 地图容器样式 - 响应式设计 */
|
|
|
|
|
|
.map-3d-container {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#app-32-map {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
min-height: 700px; /* 最大化增加最小高度 */
|
|
|
|
|
|
min-width: 900px; /* 最大化增加最小宽度 */
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 响应式设计 - 适应不同屏幕尺寸 */
|
|
|
|
|
|
@media screen and (max-width: 1200px) {
|
|
|
|
|
|
.province-name-label {
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
padding: 4px 8px;
|
|
|
|
|
|
min-width: 60px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.province-label {
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
padding: 2px 6px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cattle-source-top5 {
|
|
|
|
|
|
width: 180px;
|
|
|
|
|
|
padding: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.top5-header h3 {
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.top5-item {
|
|
|
|
|
|
padding: 6px 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.top5-item .rank {
|
|
|
|
|
|
width: 18px;
|
|
|
|
|
|
height: 18px;
|
|
|
|
|
|
font-size: 9px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.top5-item .province,
|
|
|
|
|
|
.top5-item .count {
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media screen and (max-width: 768px) {
|
|
|
|
|
|
#app-32-map {
|
|
|
|
|
|
min-height: 500px;
|
|
|
|
|
|
min-width: 600px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.province-name-label {
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
padding: 3px 6px;
|
|
|
|
|
|
min-width: 50px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.province-label {
|
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
|
padding: 1px 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cattle-source-top5 {
|
|
|
|
|
|
width: 160px;
|
|
|
|
|
|
padding: 6px;
|
|
|
|
|
|
margin-left: 400px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.top5-header h3 {
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.top5-item {
|
|
|
|
|
|
padding: 4px 6px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.top5-item .rank {
|
|
|
|
|
|
width: 16px;
|
|
|
|
|
|
height: 16px;
|
|
|
|
|
|
font-size: 8px;
|
|
|
|
|
|
margin-right: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.top5-item .province,
|
|
|
|
|
|
.top5-item .count {
|
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 原有地图标签样式 */
|
|
|
|
|
|
.map-32-label {
|
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
z-index: 10;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-07 14:04:09 +08:00
|
|
|
|
/* 省份名称标签样式 - 优化显示效果和配色方案 */
|
|
|
|
|
|
.province-name-label {
|
|
|
|
|
|
font-size: 14px; /* 默认字号,悬停时提升 */
|
|
|
|
|
|
color: #ffffff;
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
text-shadow: 2px 2px 6px rgba(0, 0, 0, 0.9), 0 0 10px rgba(0, 212, 255, 0.5); /* 增强文字阴影和发光效果 */
|
|
|
|
|
|
background: linear-gradient(135deg, rgba(0, 30, 60, 0.9), rgba(0, 50, 100, 0.8)); /* 渐变背景 */
|
|
|
|
|
|
padding: 0; /* 默认隐藏:不显示标签内容,悬停时再展开 */
|
|
|
|
|
|
border-radius: 8px; /* 圆角 */
|
|
|
|
|
|
border: 2px solid rgba(0, 212, 255, 0.6); /* 增强边框 */
|
|
|
|
|
|
backdrop-filter: blur(8px); /* 增强背景模糊 */
|
|
|
|
|
|
box-shadow: 0 4px 15px rgba(0, 212, 255, 0.3); /* 添加阴影效果 */
|
|
|
|
|
|
z-index: 10;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
min-width: 0; /* 简称状态下按内容宽度渲染(一个字宽度) */
|
|
|
|
|
|
transition: opacity 0.25s ease, transform 0.25s ease, color 0.25s ease, padding 0.25s ease, min-width 0.25s ease; /* 添加过渡效果 */
|
|
|
|
|
|
opacity: 0; /* 默认不显示标签 */
|
|
|
|
|
|
transform: scale(0.96); /* 默认略缩小,悬停时放大 */
|
|
|
|
|
|
}
|
2025-10-27 17:29:42 +08:00
|
|
|
|
|
2025-11-07 14:04:09 +08:00
|
|
|
|
/* 默认仅显示简称,完整名称隐藏 */
|
|
|
|
|
|
.province-name-label .abbr {
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
display: inline-block;
|
|
|
|
|
|
transition: opacity 0.3s ease, transform 0.3s ease;
|
|
|
|
|
|
/* 在默认字体基础上缩小1px,保持清晰可读 */
|
|
|
|
|
|
font-size: calc(1em - 1px);
|
|
|
|
|
|
transform: scale(0.92);
|
|
|
|
|
|
line-height: 1.1;
|
|
|
|
|
|
}
|
|
|
|
|
|
.province-name-label .full {
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
display: block; /* 默认不参与布局宽度计算 */
|
|
|
|
|
|
position: absolute; /* 绝对定位,避免影响容器宽度 */
|
|
|
|
|
|
left: 0;
|
|
|
|
|
|
top: 0;
|
|
|
|
|
|
transform: translateY(2px) scale(0.98);
|
|
|
|
|
|
transition: opacity 0.3s ease, transform 0.3s ease;
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 省份名称标签悬停高亮样式 */
|
|
|
|
|
|
.province-name-label-hover {
|
|
|
|
|
|
color: #00ffdd;
|
|
|
|
|
|
border-color: rgba(0, 255, 200, 0.8);
|
|
|
|
|
|
box-shadow: 0 0 18px rgba(0, 255, 200, 0.6);
|
|
|
|
|
|
transform: scale(1.08);
|
|
|
|
|
|
font-size: 16px; /* 悬停时提升字号,至少14px以上 */
|
|
|
|
|
|
padding: 8px 16px; /* 全称时更大的内边距,提升可读性 */
|
|
|
|
|
|
min-width: 96px; /* 全称显示更宽的标签 */
|
|
|
|
|
|
opacity: 1; /* 悬停淡入显示 */
|
|
|
|
|
|
}
|
|
|
|
|
|
.province-name-label-hover .abbr {
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
transform: translateY(-2px) scale(0.92);
|
|
|
|
|
|
}
|
|
|
|
|
|
.province-name-label-hover .full {
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
position: static; /* 参与布局,让容器宽度扩展为全称宽度 */
|
|
|
|
|
|
transform: translateY(0) scale(1.06);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 小屏或低分辨率下适度进一步缩小,保持视觉平衡 */
|
|
|
|
|
|
@media (max-width: 1366px) {
|
|
|
|
|
|
.province-name-label .abbr { font-size: calc(1em - 2px); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 省份标签样式 - 气泡标记上的标签 */
|
2025-10-27 17:29:42 +08:00
|
|
|
|
.province-label {
|
2025-11-07 14:04:09 +08:00
|
|
|
|
font-size: 11px; /* 整体缩小以保持界面平衡 */
|
2025-10-27 17:29:42 +08:00
|
|
|
|
color: #00d4ff;
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
text-shadow: 0 0 10px rgba(0, 212, 255, 0.9), 2px 2px 4px rgba(0, 0, 0, 0.8); /* 增强阴影效果 */
|
|
|
|
|
|
background: linear-gradient(135deg, rgba(0, 30, 60, 0.8), rgba(0, 50, 100, 0.7)); /* 渐变背景 */
|
2025-11-07 14:04:09 +08:00
|
|
|
|
padding: 2px 6px; /* 缩小内边距 */
|
2025-10-27 17:29:42 +08:00
|
|
|
|
border-radius: 6px; /* 增大圆角 */
|
|
|
|
|
|
border: 1px solid rgba(0, 212, 255, 0.7); /* 增强边框 */
|
|
|
|
|
|
backdrop-filter: blur(6px); /* 增强背景模糊 */
|
|
|
|
|
|
box-shadow: 0 2px 10px rgba(0, 212, 255, 0.4); /* 添加阴影效果 */
|
|
|
|
|
|
z-index: 10;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
transition: all 0.3s ease; /* 添加过渡效果 */
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-07 14:04:09 +08:00
|
|
|
|
/* 省份点击动画效果 */
|
|
|
|
|
|
@keyframes provinceClickPulse {
|
|
|
|
|
|
0% {
|
|
|
|
|
|
transform: scale(1);
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
50% {
|
|
|
|
|
|
transform: scale(1.05);
|
|
|
|
|
|
opacity: 0.8;
|
|
|
|
|
|
}
|
|
|
|
|
|
100% {
|
|
|
|
|
|
transform: scale(1);
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 省份悬停效果增强 */
|
|
|
|
|
|
@keyframes provinceHoverGlow {
|
|
|
|
|
|
0% {
|
|
|
|
|
|
box-shadow: 0 0 5px rgba(0, 212, 255, 0.3);
|
|
|
|
|
|
}
|
|
|
|
|
|
50% {
|
|
|
|
|
|
box-shadow: 0 0 20px rgba(0, 212, 255, 0.6);
|
|
|
|
|
|
}
|
|
|
|
|
|
100% {
|
|
|
|
|
|
box-shadow: 0 0 5px rgba(0, 212, 255, 0.3);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 点击反馈提示 - 响应式优化 */
|
|
|
|
|
|
.province-click-feedback {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
background: linear-gradient(135deg, rgba(0, 255, 136, 0.9), rgba(0, 204, 102, 0.8));
|
|
|
|
|
|
color: white;
|
|
|
|
|
|
padding: 8px 16px;
|
|
|
|
|
|
border-radius: 20px;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
z-index: 10001;
|
|
|
|
|
|
animation: clickFeedbackShow 0.6s ease-out;
|
|
|
|
|
|
box-shadow: 0 4px 15px rgba(0, 255, 136, 0.4);
|
|
|
|
|
|
border: 1px solid rgba(0, 255, 136, 0.6);
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 移动端优化 */
|
|
|
|
|
|
@media screen and (max-width: 768px) {
|
|
|
|
|
|
.province-click-feedback {
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
padding: 6px 12px;
|
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 触摸设备优化 */
|
|
|
|
|
|
@media (hover: none) and (pointer: coarse) {
|
|
|
|
|
|
.province-click-feedback {
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
padding: 8px 14px;
|
|
|
|
|
|
animation-duration: 0.8s;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@keyframes clickFeedbackShow {
|
|
|
|
|
|
0% {
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
transform: scale(0.5) translateY(20px);
|
|
|
|
|
|
}
|
|
|
|
|
|
50% {
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
transform: scale(1.1) translateY(-10px);
|
|
|
|
|
|
}
|
|
|
|
|
|
100% {
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
transform: scale(1) translateY(-30px);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-10-27 17:29:42 +08:00
|
|
|
|
.cattle-source-top5 {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
bottom:80px; /* 定位到底部 */
|
|
|
|
|
|
left: 20px; /* 定位到左侧 */
|
|
|
|
|
|
width: 220px;
|
|
|
|
|
|
background: linear-gradient(135deg, rgba(0, 30, 60, 0.95), rgba(0, 50, 100, 0.9)); /* 增强背景不透明度 */
|
|
|
|
|
|
border: 2px solid rgba(0, 212, 255, 0.8); /* 增强边框 */
|
|
|
|
|
|
border-radius: 8px; /* 增大圆角 */
|
|
|
|
|
|
padding: 12px; /* 增加内边距 */
|
|
|
|
|
|
box-shadow: 0 6px 25px rgba(0, 212, 255, 0.4); /* 增强阴影效果 */
|
|
|
|
|
|
backdrop-filter: blur(12px); /* 增强背景模糊 */
|
|
|
|
|
|
z-index: 1000;
|
|
|
|
|
|
margin-left: 600px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.top5-header {
|
|
|
|
|
|
margin-bottom: 12px; /* 增加底部边距 */
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.top5-header h3 {
|
|
|
|
|
|
color: #00d4ff;
|
|
|
|
|
|
font-size: 16px; /* 增大字号 */
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
text-shadow: 0 0 12px rgba(0, 212, 255, 0.9), 2px 2px 4px rgba(0, 0, 0, 0.8); /* 增强文字效果 */
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.top5-list {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 8px; /* 增加间距 */
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.top5-item {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
padding: 8px 12px; /* 增加内边距 */
|
|
|
|
|
|
background: rgba(0, 100, 200, 0.3); /* 增加背景不透明度 */
|
|
|
|
|
|
border: 1px solid rgba(0, 212, 255, 0.5); /* 增强边框 */
|
|
|
|
|
|
border-radius: 6px; /* 增大圆角 */
|
|
|
|
|
|
transition: all 0.3s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.top5-item:hover {
|
|
|
|
|
|
background: rgba(0, 212, 255, 0.4); /* 增强悬停效果 */
|
|
|
|
|
|
border-color: rgba(0, 255, 255, 0.8);
|
|
|
|
|
|
transform: translateX(5px);
|
|
|
|
|
|
box-shadow: 0 4px 15px rgba(0, 212, 255, 0.3); /* 添加悬停阴影 */
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.top5-item .rank {
|
|
|
|
|
|
width: 22px; /* 增大尺寸 */
|
|
|
|
|
|
height: 22px;
|
|
|
|
|
|
background: linear-gradient(135deg, #00d4ff, #0099cc);
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
font-size: 11px; /* 增大字号 */
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
margin-right: 12px; /* 增加右边距 */
|
|
|
|
|
|
box-shadow: 0 3px 10px rgba(0, 212, 255, 0.5); /* 增强阴影 */
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.top5-item .province {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
color: #ffffff;
|
|
|
|
|
|
font-size: 13px; /* 增大字号 */
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.7); /* 增强文字阴影 */
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.top5-item .count {
|
|
|
|
|
|
color: #00d4ff;
|
|
|
|
|
|
font-size: 13px; /* 增大字号 */
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
text-shadow: 0 0 10px rgba(0, 212, 255, 0.8), 1px 1px 3px rgba(0, 0, 0, 0.7); /* 增强文字效果 */
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 移除养殖场标签样式 */
|
|
|
|
|
|
/* .farm-label { ... } */
|
|
|
|
|
|
</style>
|