up
This commit is contained in:
1
assets/100000_full copy.json
Normal file
1
assets/100000_full copy.json
Normal file
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
395
assets/js/map3d.js
Normal 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();
|
||||||
|
}
|
||||||
474
index.html
474
index.html
@@ -11,6 +11,9 @@
|
|||||||
<meta name="title" content="烟台岸基网络科技有限公司">
|
<meta name="title" content="烟台岸基网络科技有限公司">
|
||||||
<meta name="Description" content="烟台岸基网络科技有限公司是从事于全程物流链科技服务的互联网软件公司,致力于打造港航企业的互联网生态圈,研发出了航运电子商务平台系统、多码头智能操作系统、全程物流链管理系统等软件产品。">
|
<meta name="Description" content="烟台岸基网络科技有限公司是从事于全程物流链科技服务的互联网软件公司,致力于打造港航企业的互联网生态圈,研发出了航运电子商务平台系统、多码头智能操作系统、全程物流链管理系统等软件产品。">
|
||||||
<meta name="Keywords" content="岸基网络,烟台岸基科技,岸基网络科技,智慧物流,协同运输,物流平台开发,O2O物流平台,航运,航运大数据,航运电商,互联网+航运,云码头,智慧码头,船代软件,数字化航运,码头管理系统,航运电子商务平台系统">
|
<meta name="Keywords" content="岸基网络,烟台岸基科技,岸基网络科技,智慧物流,协同运输,物流平台开发,O2O物流平台,航运,航运大数据,航运电商,互联网+航运,云码头,智慧码头,船代软件,数字化航运,码头管理系统,航运电子商务平台系统">
|
||||||
|
<!-- 预连接优化:提前建立与 CDN 的连接 -->
|
||||||
|
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
|
||||||
|
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net">
|
||||||
<link href="assets/css/themify-icons.css" rel="stylesheet">
|
<link href="assets/css/themify-icons.css" rel="stylesheet">
|
||||||
<link href="assets/css/flaticon.css" rel="stylesheet">
|
<link href="assets/css/flaticon.css" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="assets/css/bootstrap-3.3.4.css">
|
<link rel="stylesheet" href="assets/css/bootstrap-3.3.4.css">
|
||||||
@@ -31,11 +34,13 @@
|
|||||||
<script src="assets/js/html5shiv.min.js"></script>
|
<script src="assets/js/html5shiv.min.js"></script>
|
||||||
<script src="assets/js/respond.min.js"></script>
|
<script src="assets/js/respond.min.js"></script>
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<script src="assets/js/d3.min.js"></script>
|
<!-- d3 延迟加载,仅用于地图 -->
|
||||||
|
<script src="assets/js/d3.min.js" defer></script>
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<!-- importmap 用于解析 Three.js 子模块的依赖 -->
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
@@ -44,7 +49,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- start page-wrapper -->
|
<!-- start page-wrapper -->
|
||||||
<div class="page-wrapper">
|
<div class="page-wrapper">
|
||||||
|
|
||||||
@@ -963,467 +968,8 @@
|
|||||||
|
|
||||||
<!-- Custom script for this template -->
|
<!-- Custom script for this template -->
|
||||||
<script src="assets/js/script.js"></script>
|
<script src="assets/js/script.js"></script>
|
||||||
<script type="module">
|
|
||||||
import * as THREE from 'three';
|
<!-- 3D 地图模块 - 懒加载 -->
|
||||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
<script src="assets/js/map3d.js" type="module"></script>
|
||||||
import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
|
|
||||||
import { Line2 } from 'three/addons/lines/Line2.js';
|
|
||||||
import { LineMaterial } from 'three/addons/lines/LineMaterial.js';
|
|
||||||
import { LineGeometry } from 'three/addons/lines/LineGeometry.js';
|
|
||||||
|
|
||||||
const scene = new THREE.Scene();
|
|
||||||
|
|
||||||
// const axesHelper = new THREE.AxesHelper(5);
|
|
||||||
// scene.add(axesHelper);
|
|
||||||
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);
|
|
||||||
|
|
||||||
const container = document.getElementById("map");
|
|
||||||
container.style.position = "relative";
|
|
||||||
|
|
||||||
const camera = new THREE.PerspectiveCamera(
|
|
||||||
75,
|
|
||||||
container.clientWidth / container.clientHeight,
|
|
||||||
0.1,
|
|
||||||
1000
|
|
||||||
);
|
|
||||||
|
|
||||||
// 设置相机位置: position.set(x, y, z)
|
|
||||||
// x: 左右移动 (负值向左, 正值向右)
|
|
||||||
// y: 上下移动 (值越大视角越高, 当前100为俯视角度)
|
|
||||||
// z: 前后移动 (负值向前, 正值向后, 0.1接近正上方俯视)
|
|
||||||
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);
|
|
||||||
renderer.setSize(container.clientWidth, container.clientHeight);
|
|
||||||
container.appendChild(renderer.domElement);
|
|
||||||
const controls = new OrbitControls(camera, renderer.domElement);
|
|
||||||
controls.enableRotate = false;
|
|
||||||
controls.enableZoom = false;
|
|
||||||
controls.enablePan = false;
|
|
||||||
controls.update();
|
|
||||||
|
|
||||||
// objects tracked globally for interactions/animation
|
|
||||||
const highlightMeshes = [];
|
|
||||||
const pulseRings = [];
|
|
||||||
|
|
||||||
// tooltip for highlighted provinces
|
|
||||||
const tooltip = document.createElement("div");
|
|
||||||
tooltip.style.position = "absolute";
|
|
||||||
tooltip.style.padding = "6px 10px";
|
|
||||||
tooltip.style.background = "rgba(24, 118, 242, 0.92)";
|
|
||||||
tooltip.style.color = "#fff";
|
|
||||||
tooltip.style.fontSize = "13px";
|
|
||||||
tooltip.style.borderRadius = "6px";
|
|
||||||
tooltip.style.pointerEvents = "none";
|
|
||||||
tooltip.style.boxShadow = "0 2px 6px rgba(0,0,0,0.25)";
|
|
||||||
tooltip.style.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;
|
|
||||||
|
|
||||||
// 每个点以 3s 周期从中心向外扩散一次,使用平滑缓入缓出避免突兀
|
|
||||||
const progress = (t / period + phase) % 1; // 0..1
|
|
||||||
const eased = progress * progress * (3 - 2 * progress); // smoothstep(0..1)
|
|
||||||
|
|
||||||
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 currentMap = null;
|
|
||||||
let hoverMesh = null;
|
|
||||||
const hoverColor = new THREE.Color().setHSL(210 / 360, 0.7, 0.55);
|
|
||||||
|
|
||||||
const showTooltip = (event) => {
|
|
||||||
const rect = container.getBoundingClientRect();
|
|
||||||
tooltip.style.left = `${event.clientX - rect.left + 12}px`;
|
|
||||||
tooltip.style.top = `${event.clientY - rect.top + 12}px`;
|
|
||||||
tooltip.textContent = "客户数:2";
|
|
||||||
tooltip.style.display = "block";
|
|
||||||
};
|
|
||||||
|
|
||||||
const hideTooltip = () => {
|
|
||||||
tooltip.style.display = "none";
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyHoverState = (mesh) => {
|
|
||||||
if (hoverMesh && hoverMesh !== mesh) {
|
|
||||||
// reset previous hover color
|
|
||||||
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);
|
|
||||||
// showTooltip(event);
|
|
||||||
} 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);
|
|
||||||
// update LineMaterial resolution on resize to keep linewidth consistent
|
|
||||||
if (lineMaterials && lineMaterials.length) {
|
|
||||||
lineMaterials.forEach((m) => {
|
|
||||||
if (m && m.resolution) {
|
|
||||||
m.resolution.set(container.clientWidth, container.clientHeight);
|
|
||||||
m.needsUpdate = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// collect LineMaterial instances so we can update resolution on resize
|
|
||||||
const lineMaterials = [];
|
|
||||||
|
|
||||||
|
|
||||||
const url = "/assets/100000_full.json";
|
|
||||||
fetch(url)
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((data) => {
|
|
||||||
const map = createMap(data, 0.05);
|
|
||||||
currentMap = map;
|
|
||||||
map.add(createPulseRings(data, 0.05));
|
|
||||||
scene.add(map);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// colorCenterHex: 中心颜色, colorEdgeHex: 边缘颜色, opacityVal: 整体透明度, alphaInner: 内圈透明度, alphaOuter: 外圈透明度
|
|
||||||
const makeMaterial = (colorCenterHex, colorEdgeHex, opacityVal, alphaInner = 0.45, alphaOuter = 1.0) => {
|
|
||||||
const m = new THREE.ShaderMaterial({
|
|
||||||
transparent: true,
|
|
||||||
depthWrite: false,
|
|
||||||
depthTest: false, // keep rings always visible above map
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
return m;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 固定底环:中心更深,向外变浅,且更高不透明度
|
|
||||||
const baseRing = new THREE.Mesh(
|
|
||||||
geometry.clone(),
|
|
||||||
// 颜色统一,alpha: 内圈高、外圈低
|
|
||||||
makeMaterial(0x31bbf7, 0x31bbf7, 0.1, 1.0, 1.0)
|
|
||||||
);
|
|
||||||
baseRing.rotation.x = 0;
|
|
||||||
baseRing.position.set(x, -y, depth + 0.02);
|
|
||||||
baseRing.renderOrder = 10; // render above map geometry
|
|
||||||
group.add(baseRing);
|
|
||||||
|
|
||||||
// 扩散环:从固定底环处开始向外扩散
|
|
||||||
// 扩散环:内圈浅、外圈深,整体更淡
|
|
||||||
const pulseRing = new THREE.Mesh(
|
|
||||||
geometry.clone(),
|
|
||||||
// alpha: 内圈低、外圈高
|
|
||||||
makeMaterial(0x9fdcff, 0x31bbf7, 0.45, 0.35, 1.0)
|
|
||||||
);
|
|
||||||
pulseRing.rotation.x = 0;
|
|
||||||
pulseRing.position.set(x, -y, depth + 0.02);
|
|
||||||
pulseRing.renderOrder = 11; // render above base ring
|
|
||||||
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(); // 普通颜色
|
|
||||||
|
|
||||||
}
|
|
||||||
//
|
|
||||||
|
|
||||||
// const color = new THREE.Color().setHSL(233/360, (Math.random() * 30 + 55)/100, (Math.random() * 30 + 55)/100).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);
|
|
||||||
var material1 = new THREE.ShaderMaterial({
|
|
||||||
uniforms: {
|
|
||||||
// 添加一个uniform变量来控制颜色渐变
|
|
||||||
color1: { value: new THREE.Color(0x2e95fb) }, // 开始颜色
|
|
||||||
color2: { value: new THREE.Color(0x0131a8) } // 结束颜色
|
|
||||||
},
|
|
||||||
vertexShader: `
|
|
||||||
varying float vZPosition;
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
// 传递Z轴位置给片段着色器
|
|
||||||
vZPosition = position.z;
|
|
||||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
fragmentShader: `
|
|
||||||
uniform vec3 color1;
|
|
||||||
uniform vec3 color2;
|
|
||||||
varying float vZPosition;
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
// 使用线性插值根据Z轴位置计算颜色
|
|
||||||
vec3 color = mix(color1, color2, vZPosition);
|
|
||||||
gl_FragColor = vec4(color, 1.0);
|
|
||||||
}
|
|
||||||
`
|
|
||||||
});
|
|
||||||
|
|
||||||
const material = new THREE.MeshStandardMaterial(materialSettings);
|
|
||||||
|
|
||||||
const mesh = new THREE.Mesh(geometry, material);
|
|
||||||
|
|
||||||
return mesh;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createLine = (data, depth) => {
|
|
||||||
// 使用 Line2/LineMaterial/LineGeometry 来支持更粗的线并且抗锯齿
|
|
||||||
const positions = [];
|
|
||||||
let firstX = null;
|
|
||||||
let 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);
|
|
||||||
|
|
||||||
// linewidth 单位为像素;略微收细并改为黑色,保持可读性
|
|
||||||
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);
|
|
||||||
|
|
||||||
// 记录以便在 resize 时更新
|
|
||||||
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];
|
|
||||||
};
|
|
||||||
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user