/** * 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(); }