1
0
Files
ag-index/assets/js/map3d.js
2025-12-23 14:25:28 +08:00

396 lines
15 KiB
JavaScript

/**
* 3D 地图模块 - 懒加载版本
* 通过 IntersectionObserver 触发加载,避免阻塞首屏渲染
*/
let mapInitialized = false;
async function initMap3D() {
if (mapInitialized) return;
mapInitialized = true;
const container = document.getElementById("map");
if (!container) return;
// 动态导入 Three.js 模块
const threeModule = await import('https://cdn.jsdelivr.net/npm/three@latest/build/three.module.js');
const THREE = threeModule.default || threeModule;
const [
{ OrbitControls },
{ CSS2DRenderer, CSS2DObject },
{ Line2 },
{ LineMaterial },
{ LineGeometry }
] = await Promise.all([
import('https://cdn.jsdelivr.net/npm/three@latest/examples/jsm/controls/OrbitControls.js'),
import('https://cdn.jsdelivr.net/npm/three@latest/examples/jsm/renderers/CSS2DRenderer.js'),
import('https://cdn.jsdelivr.net/npm/three@latest/examples/jsm/lines/Line2.js'),
import('https://cdn.jsdelivr.net/npm/three@latest/examples/jsm/lines/LineMaterial.js'),
import('https://cdn.jsdelivr.net/npm/three@latest/examples/jsm/lines/LineGeometry.js')
]);
const scene = new THREE.Scene();
const ambientLight = new THREE.AmbientLight(0xd4e7fd, 4);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xe8eaeb, 0.2);
directionalLight.position.set(0, 10, 5);
const directionalLight2 = directionalLight.clone();
directionalLight2.position.set(0, 10, -5);
const directionalLight3 = directionalLight.clone();
directionalLight3.position.set(5, 10, 0);
const directionalLight4 = directionalLight.clone();
directionalLight4.position.set(-5, 10, 0);
scene.add(directionalLight);
scene.add(directionalLight2);
scene.add(directionalLight3);
scene.add(directionalLight4);
container.style.position = "relative";
const camera = new THREE.PerspectiveCamera(
75,
container.clientWidth / container.clientHeight,
0.1,
1000
);
camera.position.set(0, 100, 10);
camera.lookAt(0, 0, 0);
camera.up.set(0, 1, 0);
const labelRenderer = new CSS2DRenderer();
labelRenderer.domElement.style.position = "absolute";
labelRenderer.domElement.style.top = "0px";
labelRenderer.domElement.style.pointerEvents = "none";
labelRenderer.setSize(container.clientWidth, container.clientHeight);
container.appendChild(labelRenderer.domElement);
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio || 1);
container.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableRotate = false;
controls.enableZoom = false;
controls.enablePan = false;
controls.update();
const highlightMeshes = [];
const pulseRings = [];
const lineMaterials = [];
const tooltip = document.createElement("div");
tooltip.style.cssText = "position:absolute;padding:6px 10px;background:rgba(24,118,242,0.92);color:#fff;font-size:13px;border-radius:6px;pointer-events:none;box-shadow:0 2px 6px rgba(0,0,0,0.25);display:none;";
container.appendChild(tooltip);
const animate = () => {
requestAnimationFrame(animate);
const t = performance.now() * 0.001;
pulseRings.forEach((mesh) => {
const { minScale, maxScale, period, phase, baseOpacity } = mesh.userData;
const progress = (t / period + phase) % 1;
const eased = progress * progress * (3 - 2 * progress);
const s = minScale + eased * (maxScale - minScale);
mesh.scale.setScalar(s);
const fadeIn = progress < 0.12 ? progress / 0.12 : 1.0;
const fadeOut = progress > 0.7 ? Math.max(0.0, 1.0 - (progress - 0.7) / 0.3) : 1.0;
const alpha = baseOpacity * Math.max(0.0, Math.min(1.0, fadeIn * fadeOut));
if (mesh.material && mesh.material.uniforms && mesh.material.uniforms.opacity) {
mesh.material.uniforms.opacity.value = alpha;
}
});
controls.update();
renderer.render(scene, camera);
labelRenderer.render(scene, camera);
};
animate();
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
let hoverMesh = null;
const hoverColor = new THREE.Color().setHSL(210 / 360, 0.7, 0.55);
const hideTooltip = () => { tooltip.style.display = "none"; };
const applyHoverState = (mesh) => {
if (hoverMesh && hoverMesh !== mesh) {
if (hoverMesh.material && hoverMesh.userData.baseColor) {
hoverMesh.material.color.copy(hoverMesh.userData.baseColor);
}
}
hoverMesh = mesh;
if (hoverMesh && hoverMesh.material) {
hoverMesh.material.color.copy(hoverColor);
}
};
const clearHoverState = () => {
if (hoverMesh && hoverMesh.material && hoverMesh.userData.baseColor) {
hoverMesh.material.color.copy(hoverMesh.userData.baseColor);
}
hoverMesh = null;
};
const handlePointerMove = (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 intersects = raycaster.intersectObjects(highlightMeshes, false);
if (intersects.length > 0) {
applyHoverState(intersects[0].object);
} else {
clearHoverState();
hideTooltip();
}
};
container.addEventListener("mousemove", handlePointerMove);
container.addEventListener("mouseleave", () => {
clearHoverState();
hideTooltip();
});
window.addEventListener("resize", () => {
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
labelRenderer.setSize(container.clientWidth, container.clientHeight);
lineMaterials.forEach((m) => {
if (m && m.resolution) {
m.resolution.set(container.clientWidth, container.clientHeight);
m.needsUpdate = true;
}
});
});
const offsetXY = d3.geoMercator();
const highlightProvinces = ["山东省", "海南省", "天津市", "广东省", "浙江省", "江苏省", "北京市", "河北省", "辽宁省", "上海市"];
const pulseProvinces = ["北京市", "上海市", "广东省"];
const createPulseRings = (data, depth) => {
const group = new THREE.Object3D();
data.features.forEach((feature) => {
const { centroid, center, name } = feature.properties;
if (!pulseProvinces.includes(name)) return;
const point = centroid || center || [0, 0];
const [x, y] = offsetXY(point);
const geometry = new THREE.RingGeometry(0.0, 18.0, 160);
const makeMaterial = (colorCenterHex, colorEdgeHex, opacityVal, alphaInner = 0.45, alphaOuter = 1.0) => {
return new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide,
uniforms: {
colorCenter: { value: new THREE.Color(colorCenterHex) },
colorEdge: { value: new THREE.Color(colorEdgeHex) },
opacity: { value: opacityVal },
innerRatio: { value: geometry.parameters.innerRadius / geometry.parameters.outerRadius },
alphaInner: { value: alphaInner },
alphaOuter: { value: alphaOuter },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
varying vec2 vUv;
uniform vec3 colorCenter;
uniform vec3 colorEdge;
uniform float opacity;
uniform float innerRatio;
uniform float alphaInner;
uniform float alphaOuter;
void main() {
vec2 p = vUv - vec2(0.5);
float rawR = clamp(length(p) * 2.0, 0.0, 1.0);
float r = clamp((rawR - innerRatio) / max(1.0 - innerRatio, 0.0001), 0.0, 1.0);
float k = smoothstep(0.0, 1.0, r);
vec3 color = mix(colorEdge, colorCenter, 1.0 - k);
float alpha = opacity * mix(alphaInner, alphaOuter, k);
if (alpha < 0.01) discard;
gl_FragColor = vec4(color, alpha);
}
`,
});
};
const baseRing = new THREE.Mesh(geometry.clone(), makeMaterial(0x31bbf7, 0x31bbf7, 0.1, 1.0, 1.0));
baseRing.rotation.x = 0;
baseRing.position.set(x, -y, depth + 0.02);
baseRing.renderOrder = 10;
group.add(baseRing);
const pulseRing = new THREE.Mesh(geometry.clone(), makeMaterial(0x9fdcff, 0x31bbf7, 0.45, 0.35, 1.0));
pulseRing.rotation.x = 0;
pulseRing.position.set(x, -y, depth + 0.02);
pulseRing.renderOrder = 11;
pulseRing.userData = {
minScale: 1.0,
maxScale: 1.3,
period: 3.0,
phase: Math.random(),
baseOpacity: 0.25,
};
pulseRings.push(pulseRing);
group.add(pulseRing);
});
return group;
};
const createMap = (data, depth) => {
const map = new THREE.Object3D();
const center = data.features[0].properties.centroid;
offsetXY.center(center).translate([0, 0]);
data.features.forEach((feature) => {
const unit = new THREE.Object3D();
const { centroid, center, name } = feature.properties;
const { coordinates, type } = feature.geometry;
const point = centroid || center || [0, 0];
let color;
if (highlightProvinces.includes(name)) {
color = 0x2658F7;
} else {
color = new THREE.Color().setHSL(233 / 360, 0, 1).getHex();
}
coordinates.forEach((coordinate) => {
if (type === "MultiPolygon") coordinate.forEach((item) => fn(item));
if (type === "Polygon") fn(coordinate);
function fn(coordinate) {
unit.name = name;
unit.centroid = point;
const mesh = createMesh(coordinate, color, depth);
mesh.userData = { name, centroid: point, baseColor: new THREE.Color(color) };
if (highlightProvinces.includes(name)) {
highlightMeshes.push(mesh);
}
const line = createLine(coordinate, depth);
unit.add(mesh, ...line);
}
});
map.add(unit);
setCenter(map);
});
return map;
};
const createMesh = (data, color, depth) => {
const shape = new THREE.Shape();
data.forEach((item, idx) => {
const [x, y] = offsetXY(item);
if (idx === 0) shape.moveTo(x, -y);
else shape.lineTo(x, -y);
});
const extrudeSettings = { depth: depth, bevelEnabled: false };
const materialSettings = {
color: color,
emissive: 0x000000,
roughness: 0.45,
metalness: 0.8,
transparent: true,
side: THREE.DoubleSide,
};
const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
const material = new THREE.MeshStandardMaterial(materialSettings);
return new THREE.Mesh(geometry, material);
};
const createLine = (data, depth) => {
const positions = [];
let firstX = null, firstY = null;
data.forEach((item, idx) => {
const [x, y] = offsetXY(item);
positions.push(x, -y, 0);
if (idx === 0) { firstX = x; firstY = y; }
});
if (firstX !== null && firstY !== null) {
positions.push(firstX, -firstY, 0);
}
const lineGeometry = new LineGeometry();
lineGeometry.setPositions(positions);
const lineMaterialSettings = {
color: 0xffffff,
linewidth: 0.5,
dashed: false,
transparent: true,
opacity: 0.9,
};
const uplineMaterial = new LineMaterial(lineMaterialSettings);
const downlineMaterial = new LineMaterial(lineMaterialSettings);
uplineMaterial.resolution.set(container.clientWidth, container.clientHeight);
downlineMaterial.resolution.set(container.clientWidth, container.clientHeight);
lineMaterials.push(uplineMaterial, downlineMaterial);
const upLine = new Line2(lineGeometry, uplineMaterial);
const downLine = new Line2(lineGeometry.clone(), downlineMaterial);
downLine.position.z = -0.0001;
upLine.position.z = depth + 0.0001;
return [upLine, downLine];
};
const setCenter = (map) => {
map.rotation.x = -Math.PI / 2;
const box = new THREE.Box3().setFromObject(map);
const center = box.getCenter(new THREE.Vector3());
const offset = [0, 0];
map.position.x = map.position.x - center.x - offset[0];
map.position.z = map.position.z - center.z - offset[1];
};
// 加载 GeoJSON 数据
const url = "/assets/100000_full.json";
try {
const res = await fetch(url);
const data = await res.json();
const map = createMap(data, 0.05);
map.add(createPulseRings(data, 0.05));
scene.add(map);
} catch (err) {
console.error("Failed to load map data:", err);
}
}
// 使用 IntersectionObserver 实现懒加载
function setupLazyMap() {
const mapContainer = document.getElementById("map");
if (!mapContainer) return;
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
initMap3D();
observer.disconnect();
}
}, {
rootMargin: '200px' // 提前 200px 开始加载
});
observer.observe(mapContainer);
} else {
// 降级:直接加载
initMap3D();
}
}
// 页面加载完成后设置懒加载
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupLazyMap);
} else {
setupLazyMap();
}