1
0
This commit is contained in:
2025-12-23 14:25:28 +08:00
parent 63e9d70eb5
commit e7a8f8ea79
4 changed files with 407 additions and 465 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

395
assets/js/map3d.js Normal file
View File

@@ -0,0 +1,395 @@
/**
* 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();
}