|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="UTF-8" />
|
|
|
|
|
<title>三维能级渲染 & 服务端生成 GIF</title>
|
|
|
|
|
<style>
|
|
|
|
|
html,
|
|
|
|
|
body {
|
|
|
|
|
margin: 0;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#container {
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
position: relative;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#controls {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 10px;
|
|
|
|
|
right: 10px;
|
|
|
|
|
background: rgba(255, 255, 255, 0.9);
|
|
|
|
|
padding: 10px;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
font-family: sans-serif;
|
|
|
|
|
z-index: 20;
|
|
|
|
|
max-width: 320px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#controls label {
|
|
|
|
|
display: block;
|
|
|
|
|
margin: 6px 0;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#controls input {
|
|
|
|
|
width: 100px;
|
|
|
|
|
margin-left: 4px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.time-group {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
margin: 6px 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#loading {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 0;
|
|
|
|
|
left: 0;
|
|
|
|
|
right: 0;
|
|
|
|
|
bottom: 0;
|
|
|
|
|
background: rgba(0, 0, 0, 0.8);
|
|
|
|
|
display: none;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
color: #fff;
|
|
|
|
|
font-family: sans-serif;
|
|
|
|
|
z-index: 30;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#loading-bar {
|
|
|
|
|
width: 300px;
|
|
|
|
|
height: 20px;
|
|
|
|
|
background: #333;
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
margin-top: 10px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#loading-inner {
|
|
|
|
|
width: 0;
|
|
|
|
|
height: 100%;
|
|
|
|
|
background: #4caf50;
|
|
|
|
|
transition: width .2s;
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
<script type="importmap">
|
|
|
|
|
{ "imports": {
|
|
|
|
|
"three": "/three/build/three.module.js",
|
|
|
|
|
"three/examples/jsm/": "/three/examples/jsm/"
|
|
|
|
|
} }
|
|
|
|
|
</script>
|
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
|
|
|
|
|
</head>
|
|
|
|
|
|
|
|
|
|
<body>
|
|
|
|
|
<div id="timeStamp" style="
|
|
|
|
|
position:absolute;
|
|
|
|
|
top:10px;
|
|
|
|
|
left:10px;
|
|
|
|
|
padding:4px 8px;
|
|
|
|
|
background:rgba(0,0,0,0.5);
|
|
|
|
|
color:#fff;
|
|
|
|
|
font-family:sans-serif;
|
|
|
|
|
z-index:50;">—</div>
|
|
|
|
|
|
|
|
|
|
<div id="volume" style="
|
|
|
|
|
position:absolute;
|
|
|
|
|
top:50px;
|
|
|
|
|
left:10px;
|
|
|
|
|
padding:4px 8px;
|
|
|
|
|
background:rgba(0,0,0,0.5);
|
|
|
|
|
color:#fff;
|
|
|
|
|
font-family:sans-serif;
|
|
|
|
|
z-index:50;">—</div>
|
|
|
|
|
<div id="container"></div>
|
|
|
|
|
<div id="controls">
|
|
|
|
|
<label>基准日期 <input id="baseDate" type="date" value="2013-04-01" /></label>
|
|
|
|
|
<label>基准时间 <input id="baseTime" type="time" step="1" value="00:00:00" /></label>
|
|
|
|
|
<label>层数 <input id="levels" type="number" value="20" min="10" max="500" /></label>
|
|
|
|
|
<label>网格分辨率 <input id="gridRes" type="number" value="3.0" min="1" step="1" /></label>
|
|
|
|
|
<label>τ 阈值 <input id="tau" type="number" value="0.1" min="0" max="1" step="0.1" /></label>
|
|
|
|
|
<label>透明度
|
|
|
|
|
<input id="meshOpacity" type="number" value="0.8" min="0" max="1" step="0.1" />
|
|
|
|
|
</label>
|
|
|
|
|
<hr />
|
|
|
|
|
<label>S₁ <input id="S1" type="number" value="3.0" step="0.1" /></label>
|
|
|
|
|
<label>R₁ <input id="R1" type="number" value="30" step="1" /></label>
|
|
|
|
|
<label>S₂ <input id="S2" type="number" value="2.0" step="0.1" /></label>
|
|
|
|
|
<label>R₂ <input id="R2" type="number" value="20" step="1" /></label>
|
|
|
|
|
<label>S₃ <input id="S3" type="number" value="1.0" step="0.1" /></label>
|
|
|
|
|
<label>R₃ <input id="R3" type="number" value="10" step="1" /></label>
|
|
|
|
|
<hr />
|
|
|
|
|
<label>视角
|
|
|
|
|
<select id="viewSelect">
|
|
|
|
|
<option value="三维视角">三维视角</option>
|
|
|
|
|
<option value="主视图">主视图</option>
|
|
|
|
|
<option value="左视图">左视图</option>
|
|
|
|
|
<option value="右视图">右视图</option>
|
|
|
|
|
<option value="俯视图">俯视图</option>
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
<label>水平角(Azimuth, °)
|
|
|
|
|
<input id="azimuth" type="number" value="0" step="1" />
|
|
|
|
|
</label>
|
|
|
|
|
<label>俯仰角(Polar, °)
|
|
|
|
|
<input id="polar" type="number" value="180" step="1" />
|
|
|
|
|
</label>
|
|
|
|
|
<hr />
|
|
|
|
|
<label>起始时间
|
|
|
|
|
<input id="startTime" type="number" value="0" min="0" />
|
|
|
|
|
</label>
|
|
|
|
|
<label>时间间隔(分钟)
|
|
|
|
|
<input id="intervalMin" type="number" value="10" min="1" />
|
|
|
|
|
</label>
|
|
|
|
|
<label>截止时间
|
|
|
|
|
<input id="endTime" type="number" />
|
|
|
|
|
</label>
|
|
|
|
|
<div>当前时间: <span id="timeLabel">–</span> 分</div>
|
|
|
|
|
<div class="time-group">
|
|
|
|
|
<button id="apply">渲染</button>
|
|
|
|
|
<button id="play">播放</button>
|
|
|
|
|
<button id="stop">停止</button>
|
|
|
|
|
<button id="record">录制并下载 GIF</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="loading">
|
|
|
|
|
<div id="loading-text">加载中... 0%</div>
|
|
|
|
|
<div id="loading-bar">
|
|
|
|
|
<div id="loading-inner"></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<script type="module">
|
|
|
|
|
import * as THREE from 'three';
|
|
|
|
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
|
|
|
|
import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js';
|
|
|
|
|
|
|
|
|
|
// 不再硬编码 baseDate,由 UI 输入驱动
|
|
|
|
|
let baseDate = new Date();
|
|
|
|
|
const dateInput = document.getElementById('baseDate');
|
|
|
|
|
const timeInput = document.getElementById('baseTime');
|
|
|
|
|
function updateBaseDate() {
|
|
|
|
|
const d = dateInput.value; // YYYY-MM-DD
|
|
|
|
|
const t = timeInput.value; // HH:MM:SS
|
|
|
|
|
baseDate = new Date(`${d}T${t}`);
|
|
|
|
|
}
|
|
|
|
|
const bounds = {
|
|
|
|
|
x: [-150, 150],
|
|
|
|
|
y: [-100, 100],
|
|
|
|
|
z: [-600, -750]
|
|
|
|
|
};
|
|
|
|
|
dateInput.addEventListener('change', updateBaseDate);
|
|
|
|
|
timeInput.addEventListener('change', updateBaseDate);
|
|
|
|
|
updateBaseDate();
|
|
|
|
|
|
|
|
|
|
let scene, camera, renderer, controls, currentGroup, axisGroup, labelRenderer = null;
|
|
|
|
|
let timeMin = 0, timeMax = 1, playIntervalSec = 600;
|
|
|
|
|
let isPlaying = false, frameIdx = 0;
|
|
|
|
|
const interfaceUrl = 'http://127.0.0.1:5000';
|
|
|
|
|
|
|
|
|
|
const coldMap = ['#800080', '#0000ff', '#00ffff'];
|
|
|
|
|
const warmMap = ['#00ff00', '#ffff00', '#ff7f00', '#ff0000'];
|
|
|
|
|
function lerpColor(arr, t) {
|
|
|
|
|
const n = arr.length, s = t * (n - 1), i = Math.floor(s), f = s - i;
|
|
|
|
|
if (i >= n - 1) return new THREE.Color(arr[n - 1]);
|
|
|
|
|
return new THREE.Color(arr[i]).lerp(new THREE.Color(arr[i + 1]), f);
|
|
|
|
|
}
|
|
|
|
|
function getHeatColor(t) {
|
|
|
|
|
return t <= 0.4
|
|
|
|
|
? lerpColor(coldMap, t / 0.4)
|
|
|
|
|
: lerpColor(warmMap, (t - 0.4) / 0.6);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
initScene();
|
|
|
|
|
initControls();
|
|
|
|
|
|
|
|
|
|
// 获取时间间隔(分钟)
|
|
|
|
|
function getIntervalMin() {
|
|
|
|
|
return Number(document.getElementById('intervalMin').value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 获取 time_range,转换为分钟并四舍五入
|
|
|
|
|
fetch(interfaceUrl + '/time_range')
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
.then(({ timeMin: secMin, timeMax: secMax }) => {
|
|
|
|
|
const minM = Math.round(secMin / 60);
|
|
|
|
|
const maxM = Math.round(secMax / 60);
|
|
|
|
|
timeMin = minM;
|
|
|
|
|
timeMax = maxM;
|
|
|
|
|
const st = document.getElementById('startTime'),
|
|
|
|
|
et = document.getElementById('endTime');
|
|
|
|
|
st.min = et.min = minM;
|
|
|
|
|
st.max = et.max = maxM;
|
|
|
|
|
st.value = minM;
|
|
|
|
|
et.value = maxM;
|
|
|
|
|
updateTimeLabel(st.value);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function initScene() {
|
|
|
|
|
const C = document.getElementById('container');
|
|
|
|
|
scene = new THREE.Scene();
|
|
|
|
|
scene.background = new THREE.Color(0xffffff);
|
|
|
|
|
|
|
|
|
|
camera = new THREE.PerspectiveCamera(50, C.clientWidth / C.clientHeight, 0.1, 5000);
|
|
|
|
|
camera.position.set(200, 200, 200);
|
|
|
|
|
|
|
|
|
|
renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
|
|
|
renderer.setSize(C.clientWidth, C.clientHeight);
|
|
|
|
|
C.appendChild(renderer.domElement);
|
|
|
|
|
|
|
|
|
|
// 光照
|
|
|
|
|
scene.add(new THREE.AmbientLight(0xffffff, 0.8));
|
|
|
|
|
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
|
|
|
dirLight.position.set(200, 300, 200);
|
|
|
|
|
dirLight.castShadow = true;
|
|
|
|
|
scene.add(dirLight);
|
|
|
|
|
const fillLight = new THREE.DirectionalLight(0xffffff, 0.4);
|
|
|
|
|
fillLight.position.set(-200, -100, -200);
|
|
|
|
|
scene.add(fillLight);
|
|
|
|
|
renderer.shadowMap.enabled = true;
|
|
|
|
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
|
|
|
|
|
|
|
|
// CSS2DRenderer for axis labels
|
|
|
|
|
labelRenderer = new CSS2DRenderer();
|
|
|
|
|
labelRenderer.setSize(C.clientWidth, C.clientHeight);
|
|
|
|
|
labelRenderer.domElement.style.position = 'absolute';
|
|
|
|
|
labelRenderer.domElement.style.top = '0';
|
|
|
|
|
labelRenderer.domElement.style.pointerEvents = 'none';
|
|
|
|
|
C.appendChild(labelRenderer.domElement);
|
|
|
|
|
|
|
|
|
|
// OrbitControls
|
|
|
|
|
controls = new OrbitControls(camera, renderer.domElement);
|
|
|
|
|
controls.target.set(150, 100, -80);
|
|
|
|
|
controls.enableDamping = true;
|
|
|
|
|
controls.dampingFactor = 0.05;
|
|
|
|
|
controls.enablePan = false;
|
|
|
|
|
|
|
|
|
|
let userInteracting = false;
|
|
|
|
|
controls.addEventListener('start', () => userInteracting = true);
|
|
|
|
|
controls.addEventListener('end', () => userInteracting = false);
|
|
|
|
|
controls.addEventListener('change', () => {
|
|
|
|
|
if (!userInteracting) return;
|
|
|
|
|
const az = THREE.MathUtils.radToDeg(controls.getAzimuthalAngle());
|
|
|
|
|
const po = THREE.MathUtils.radToDeg(controls.getPolarAngle());
|
|
|
|
|
document.getElementById('azimuth').value = az.toFixed(0);
|
|
|
|
|
document.getElementById('polar').value = po.toFixed(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
window.addEventListener('resize', () => {
|
|
|
|
|
camera.aspect = C.clientWidth / C.clientHeight;
|
|
|
|
|
camera.updateProjectionMatrix();
|
|
|
|
|
renderer.setSize(C.clientWidth, C.clientHeight);
|
|
|
|
|
labelRenderer.setSize(C.clientWidth, C.clientHeight);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// render loop
|
|
|
|
|
(function animate() {
|
|
|
|
|
requestAnimationFrame(animate);
|
|
|
|
|
controls.update();
|
|
|
|
|
renderer.render(scene, camera);
|
|
|
|
|
labelRenderer.render(scene, camera);
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
// initial zoom-in
|
|
|
|
|
function zoomByScroll(ticks, factorPerTick = 0.8) {
|
|
|
|
|
const dir = new THREE.Vector3().subVectors(camera.position, controls.target);
|
|
|
|
|
dir.multiplyScalar(Math.pow(factorPerTick, ticks));
|
|
|
|
|
camera.position.copy(controls.target).add(dir);
|
|
|
|
|
camera.updateProjectionMatrix();
|
|
|
|
|
controls.update();
|
|
|
|
|
}
|
|
|
|
|
zoomByScroll(8, 0.99);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function drawAxes() {
|
|
|
|
|
// 清除旧的 axisGroup
|
|
|
|
|
if (axisGroup) {
|
|
|
|
|
scene.remove(axisGroup);
|
|
|
|
|
axisGroup.children.forEach(c => c.geometry && c.geometry.dispose());
|
|
|
|
|
}
|
|
|
|
|
axisGroup = new THREE.Group();
|
|
|
|
|
|
|
|
|
|
const { x: [xmin, xmax], y: [ymin, ymax], z: [zmin, zmax] } = bounds;
|
|
|
|
|
const lenX = xmax - xmin, lenY = ymax - ymin, lenZ = zmax - zmin;
|
|
|
|
|
const ticksMap = { X: 10, Y: 10, Z: 10 };
|
|
|
|
|
const tickesNameMap = {X: '经度(X轴)', Y: '纬度(Y轴)', Z: '深度(Z轴)'};
|
|
|
|
|
|
|
|
|
|
// 通用刻度文字样式
|
|
|
|
|
const LABEL_STYLE = {
|
|
|
|
|
fontSize: '12px',
|
|
|
|
|
color: '#000', // 黑色
|
|
|
|
|
margin: '0px',
|
|
|
|
|
padding: '0px',
|
|
|
|
|
whiteSpace: 'nowrap',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 画单条轴及其刻度和标签
|
|
|
|
|
[['X', xmin, lenX, 'x', 0xff0000],
|
|
|
|
|
['Y', ymin, lenY, 'y', 0x00ff00],
|
|
|
|
|
['Z', zmin, lenZ, 'z', 0x0000ff]
|
|
|
|
|
].forEach(([name, start, length, axis, color]) => {
|
|
|
|
|
const mat = new THREE.LineBasicMaterial({ color });
|
|
|
|
|
// 计算轴起点
|
|
|
|
|
const origin = new THREE.Vector3(
|
|
|
|
|
axis === 'x' ? start : xmin,
|
|
|
|
|
axis === 'y' ? start : ymin,
|
|
|
|
|
axis === 'z' ? start : zmin
|
|
|
|
|
);
|
|
|
|
|
// 计算轴方向向量
|
|
|
|
|
const dir = new THREE.Vector3(
|
|
|
|
|
axis === 'x' ? 1 : 0,
|
|
|
|
|
axis === 'y' ? 1 : 0,
|
|
|
|
|
axis === 'z' ? 1 : 0
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// —— 绘制轴主线 ——
|
|
|
|
|
const lineGeo = new THREE.BufferGeometry().setFromPoints([
|
|
|
|
|
origin,
|
|
|
|
|
origin.clone().add(dir.clone().multiplyScalar(length))
|
|
|
|
|
]);
|
|
|
|
|
axisGroup.add(new THREE.Line(lineGeo, mat));
|
|
|
|
|
|
|
|
|
|
// 计算刻度外推方向(针对不同轴分别处理)
|
|
|
|
|
let offsetDir;
|
|
|
|
|
// Vector3(z,x,y)
|
|
|
|
|
if (axis === 'x') offsetDir = new THREE.Vector3(0, -0.5, 0); // X 轴刻度文字向下
|
|
|
|
|
else if (axis === 'y') offsetDir = new THREE.Vector3(0, 0, 1); // Y 轴刻度文字向前
|
|
|
|
|
else offsetDir = new THREE.Vector3(1.5, 0, 0); // Z 轴刻度文字向左
|
|
|
|
|
const labelOffset = offsetDir.multiplyScalar(length * 0.05);
|
|
|
|
|
|
|
|
|
|
// —— 绘制刻度线和数字标签 ——
|
|
|
|
|
const ticks = ticksMap[name];
|
|
|
|
|
for (let i = 0; i <= ticks; i++) {
|
|
|
|
|
const t = i / ticks;
|
|
|
|
|
const pos = origin.clone().add(dir.clone().multiplyScalar(length * t));
|
|
|
|
|
|
|
|
|
|
// 刻度线
|
|
|
|
|
const mkGeo = new THREE.BufferGeometry().setFromPoints([
|
|
|
|
|
pos.clone().add(offsetDir.clone().multiplyScalar(0.5)),
|
|
|
|
|
pos.clone().add(offsetDir.clone().multiplyScalar(-0.5))
|
|
|
|
|
]);
|
|
|
|
|
axisGroup.add(new THREE.Line(mkGeo, mat));
|
|
|
|
|
|
|
|
|
|
// 数字标签
|
|
|
|
|
const val = (start + length * t).toFixed(0);
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
Object.assign(div.style, LABEL_STYLE);
|
|
|
|
|
div.textContent = val;
|
|
|
|
|
const label = new CSS2DObject(div);
|
|
|
|
|
label.position.copy(pos.clone().add(labelOffset));
|
|
|
|
|
axisGroup.add(label);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// —— 绘制轴标题 ——
|
|
|
|
|
const titleDiv = document.createElement('div');
|
|
|
|
|
Object.assign(titleDiv.style, LABEL_STYLE, { fontWeight: 'bold' });
|
|
|
|
|
titleDiv.textContent = tickesNameMap[name];
|
|
|
|
|
const titleLabel = new CSS2DObject(titleDiv);
|
|
|
|
|
let worldPos;
|
|
|
|
|
if (axis === 'x') {
|
|
|
|
|
worldPos = origin.clone()
|
|
|
|
|
.add(dir.clone().multiplyScalar(length * 0.5))
|
|
|
|
|
.add(offsetDir.clone().multiplyScalar(length * 0.008));
|
|
|
|
|
} else if (axis === 'y') {
|
|
|
|
|
worldPos = origin.clone()
|
|
|
|
|
.add(dir.clone().multiplyScalar(length * 0.5))
|
|
|
|
|
.add(offsetDir.clone().multiplyScalar(length * 0.018));
|
|
|
|
|
} else {
|
|
|
|
|
worldPos = origin.clone()
|
|
|
|
|
.add(dir.clone().multiplyScalar(length * 0.5))
|
|
|
|
|
.add(offsetDir.clone().multiplyScalar(length * -0.018));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
titleLabel.position.copy(worldPos);
|
|
|
|
|
axisGroup.add(titleLabel);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// —— 手工生成 XY/XZ/YZ 三个平面上的网格 ——
|
|
|
|
|
const gridMat = new THREE.LineBasicMaterial({
|
|
|
|
|
color: 0xcccccc,
|
|
|
|
|
opacity: 0.6,
|
|
|
|
|
transparent: true
|
|
|
|
|
});
|
|
|
|
|
function makePlaneGrid(fixedAxis, fixedValue, axis1, range1, div1, axis2, range2, div2) {
|
|
|
|
|
const geom = new THREE.BufferGeometry();
|
|
|
|
|
const pos = [];
|
|
|
|
|
const step1 = (range1[1] - range1[0]) / div1;
|
|
|
|
|
const step2 = (range2[1] - range2[0]) / div2;
|
|
|
|
|
// 沿 axis1 方向的线
|
|
|
|
|
for (let j = 0; j <= div2; j++) {
|
|
|
|
|
const c2 = range2[0] + step2 * j;
|
|
|
|
|
const p1 = {}, p2 = {};
|
|
|
|
|
p1[fixedAxis] = fixedValue; p2[fixedAxis] = fixedValue;
|
|
|
|
|
p1[axis2] = c2; p2[axis2] = c2;
|
|
|
|
|
p1[axis1] = range1[0]; p2[axis1] = range1[1];
|
|
|
|
|
pos.push(p1.x||0, p1.y||0, p1.z||0, p2.x||0, p2.y||0, p2.z||0);
|
|
|
|
|
}
|
|
|
|
|
// 沿 axis2 方向的线
|
|
|
|
|
for (let i = 0; i <= div1; i++) {
|
|
|
|
|
const c1 = range1[0] + step1 * i;
|
|
|
|
|
const q1 = {}, q2 = {};
|
|
|
|
|
q1[fixedAxis] = fixedValue; q2[fixedAxis] = fixedValue;
|
|
|
|
|
q1[axis1] = c1; q2[axis1] = c1;
|
|
|
|
|
q1[axis2] = range2[0]; q2[axis2] = range2[1];
|
|
|
|
|
pos.push(q1.x||0, q1.y||0, q1.z||0, q2.x||0, q2.y||0, q2.z||0);
|
|
|
|
|
}
|
|
|
|
|
geom.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
|
|
|
|
return new THREE.LineSegments(geom, gridMat);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// XY 平面: z = zmin
|
|
|
|
|
axisGroup.add(makePlaneGrid('z', zmin, 'x', [xmin, xmax], ticksMap.X, 'y', [ymin, ymax], ticksMap.Y));
|
|
|
|
|
// XZ 平面: y = ymin
|
|
|
|
|
axisGroup.add(makePlaneGrid('y', ymin, 'x', [xmin, xmax], ticksMap.X, 'z', [zmin, zmax], ticksMap.Z));
|
|
|
|
|
// YZ 平面: x = xmin
|
|
|
|
|
axisGroup.add(makePlaneGrid('x', xmin, 'z', [zmin, zmax], ticksMap.Z, 'y', [ymin, ymax], ticksMap.Y));
|
|
|
|
|
|
|
|
|
|
// 加入场景
|
|
|
|
|
scene.add(axisGroup);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyView() {
|
|
|
|
|
if(!currentGroup) return;
|
|
|
|
|
const sel = document.getElementById('viewSelect').value;
|
|
|
|
|
const box = new THREE.Box3().setFromObject(currentGroup);
|
|
|
|
|
const center = box.getCenter(new THREE.Vector3());
|
|
|
|
|
const d = box.getSize(new THREE.Vector3()).length() * 1.2;
|
|
|
|
|
let pos;
|
|
|
|
|
switch(sel){
|
|
|
|
|
case '主视图': pos = new THREE.Vector3(0,d,0); break;
|
|
|
|
|
case '左视图': pos = new THREE.Vector3(-d,0,0); break;
|
|
|
|
|
case '右视图': pos = new THREE.Vector3(d,0,0); break;
|
|
|
|
|
case '俯视图': pos = new THREE.Vector3(0,0,d); break;
|
|
|
|
|
default: pos = new THREE.Vector3(d,d,d); break;
|
|
|
|
|
}
|
|
|
|
|
camera.position.copy(center.clone().add(pos));
|
|
|
|
|
controls.target.copy(center);
|
|
|
|
|
camera.lookAt(center);
|
|
|
|
|
controls.update();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function initControls() {
|
|
|
|
|
// 底层按钮和输入绑定
|
|
|
|
|
document.getElementById('apply').onclick = renderSimple;
|
|
|
|
|
document.getElementById('play').onclick = startPlay;
|
|
|
|
|
document.getElementById('stop').onclick = stopPlay;
|
|
|
|
|
document.getElementById('record').onclick = startRecording;
|
|
|
|
|
document.getElementById('startTime').addEventListener('input', () => {
|
|
|
|
|
let st = +startTime.value;
|
|
|
|
|
if (st < timeMin) st = timeMin;
|
|
|
|
|
if (st > timeMax) st = timeMax;
|
|
|
|
|
startTime.value = st;
|
|
|
|
|
document.getElementById('timeLabel').innerText = st;
|
|
|
|
|
});
|
|
|
|
|
// 限制 endTime 在 [startTime, timeMax]
|
|
|
|
|
document.getElementById('endTime').addEventListener('input', () => {
|
|
|
|
|
let et = +endTime.value, st = +startTime.value;
|
|
|
|
|
if (et < st) et = st;
|
|
|
|
|
if (et > timeMax) et = timeMax;
|
|
|
|
|
endTime.value = et;
|
|
|
|
|
});
|
|
|
|
|
document.getElementById('viewSelect').onchange = applyView;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getStartEndInput() {
|
|
|
|
|
return {
|
|
|
|
|
start: +document.getElementById('startTime').value,
|
|
|
|
|
end: +document.getElementById('endTime').value
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getCurrentLabel() {
|
|
|
|
|
return +document.getElementById('timeLabel').innerText || 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateTimeLabel(timeValue) {
|
|
|
|
|
const labelEl = document.getElementById('timeLabel');
|
|
|
|
|
// 取当前文本,尝试解析为浮点数;如果不是数字(包括空字符串),则默认 0
|
|
|
|
|
const current = parseFloat(labelEl.innerText) || 0;
|
|
|
|
|
const updated = current + timeValue;
|
|
|
|
|
if (updated > timeMax) {
|
|
|
|
|
updated = timeMax;
|
|
|
|
|
}
|
|
|
|
|
labelEl.innerText = updated;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 简单渲染:完全使用 input 里的 start/end
|
|
|
|
|
async function renderSimple() {
|
|
|
|
|
const { start, end } = getStartEndInput();
|
|
|
|
|
await renderFrame(start, end);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 播放:start 固定,endLabel 动态
|
|
|
|
|
async function startPlay() {
|
|
|
|
|
const { start, end } = getStartEndInput();
|
|
|
|
|
let current = start;
|
|
|
|
|
document.getElementById('timeLabel').innerText = current;
|
|
|
|
|
isPlaying = true;
|
|
|
|
|
play.disabled = true;
|
|
|
|
|
|
|
|
|
|
const interval = getIntervalMin(); // 分钟
|
|
|
|
|
|
|
|
|
|
while (isPlaying && current <= end) {
|
|
|
|
|
current += interval;
|
|
|
|
|
await renderFrame(start, current);
|
|
|
|
|
document.getElementById('timeLabel').innerText = current;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
stopPlay();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stopPlay() {
|
|
|
|
|
isPlaying = false;
|
|
|
|
|
play.disabled = false;
|
|
|
|
|
alert("结束");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 录制:逻辑跟播放一样,每帧多存图
|
|
|
|
|
async function startRecording() {
|
|
|
|
|
const { start, end } = getStartEndInput();
|
|
|
|
|
let current = start;
|
|
|
|
|
document.getElementById('timeLabel').innerText = current;
|
|
|
|
|
isPlaying = true;
|
|
|
|
|
frameIdx = 0;
|
|
|
|
|
await fetch(interfaceUrl + '/init_frames', { method: 'POST' });
|
|
|
|
|
|
|
|
|
|
const interval = getIntervalMin();
|
|
|
|
|
|
|
|
|
|
while (isPlaying && current <= end) {
|
|
|
|
|
current += interval;
|
|
|
|
|
await renderFrame(start, current);
|
|
|
|
|
|
|
|
|
|
// 截图上传
|
|
|
|
|
const shot = await html2canvas(container, { backgroundColor: null, useCORS: true });
|
|
|
|
|
const png = shot.toDataURL();
|
|
|
|
|
await fetch(interfaceUrl + '/save_frame', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type':'application/json' },
|
|
|
|
|
body: JSON.stringify({ idx: frameIdx++, image: png })
|
|
|
|
|
});
|
|
|
|
|
document.getElementById('timeLabel').innerText = current;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 合成 GIF 并下载
|
|
|
|
|
const res = await fetch(interfaceUrl + '/make_gif');
|
|
|
|
|
const blob = await res.blob();
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
const a = document.createElement('a');
|
|
|
|
|
a.href = url; a.download = `animation_${Date.now()}.gif`;
|
|
|
|
|
document.body.appendChild(a); a.click();
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
|
|
|
|
|
stopPlay();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let initialFocused = false;
|
|
|
|
|
async function renderFrame(startTime, endTime) {
|
|
|
|
|
console.log("处理范围:" + startTime + "," + endTime);
|
|
|
|
|
startTime = startTime * 60;
|
|
|
|
|
endTime = endTime * 60;
|
|
|
|
|
const levels = +document.getElementById('levels').value; // 等值面层数
|
|
|
|
|
const gridRes = +document.getElementById('gridRes').value; // 体素尺寸
|
|
|
|
|
const tau = +document.getElementById('tau').value; // 阈值
|
|
|
|
|
const S1 = +document.getElementById('S1').value,
|
|
|
|
|
R1 = +document.getElementById('R1').value;
|
|
|
|
|
const S2 = +document.getElementById('S2').value,
|
|
|
|
|
R2 = +document.getElementById('R2').value;
|
|
|
|
|
const S3 = +document.getElementById('S3').value,
|
|
|
|
|
R3 = +document.getElementById('R3').value;
|
|
|
|
|
const opacity = +document.getElementById('meshOpacity').value;
|
|
|
|
|
// 前端时间输入是分钟,需要转换成秒再传给后端
|
|
|
|
|
const timeLabelVal = getCurrentLabel() * 60;
|
|
|
|
|
|
|
|
|
|
updateBaseDate(); // 保证读到最新输入
|
|
|
|
|
const actualDate = new Date(baseDate.getTime() + timeLabelVal * 1000);
|
|
|
|
|
const hh = String(actualDate.getHours()).padStart(2, '0');
|
|
|
|
|
const mm = String(actualDate.getMinutes()).padStart(2, '0');
|
|
|
|
|
const ss = String(actualDate.getSeconds()).padStart(2, '0');
|
|
|
|
|
|
|
|
|
|
document.getElementById('timeStamp').innerText = `${actualDate.getFullYear()}-${(actualDate.getMonth() + 1).toString().padStart(2, '0')}-${actualDate.getDate().toString().padStart(2, '0')} ${hh}:${mm}:${ss}`;
|
|
|
|
|
|
|
|
|
|
const url = `${interfaceUrl}/get_meshes?levels=${levels}`
|
|
|
|
|
+ `&energy_thresh=${tau}&grid_res=${gridRes}`
|
|
|
|
|
+ `&S1=${S1}&R1=${R1}&S2=${S2}&R2=${R2}&S3=${S3}&R3=${R3}`
|
|
|
|
|
+ `&startTime=${startTime}&endTime=${endTime}`;
|
|
|
|
|
const { meshes, bounds, volume } = await (await fetch(url)).json();
|
|
|
|
|
|
|
|
|
|
document.getElementById('volume').innerText = '体积:' + volume + '立方米';
|
|
|
|
|
|
|
|
|
|
// drawAxes(bounds);
|
|
|
|
|
|
|
|
|
|
const newG = new THREE.Group();
|
|
|
|
|
newG.position.set(bounds.x[0], bounds.y[0], bounds.z[0]);
|
|
|
|
|
for (let i = 0; i < meshes.length; i++) {
|
|
|
|
|
const m = meshes[i];
|
|
|
|
|
const geom = new THREE.BufferGeometry();
|
|
|
|
|
geom.setAttribute('position', new THREE.BufferAttribute(
|
|
|
|
|
new Float32Array(m.vertices.flat()), 3
|
|
|
|
|
));
|
|
|
|
|
geom.setIndex(new THREE.BufferAttribute(
|
|
|
|
|
new Uint32Array(m.faces.flat()), 1
|
|
|
|
|
));
|
|
|
|
|
geom.computeVertexNormals();
|
|
|
|
|
const iso = i / (levels - 1);
|
|
|
|
|
const mat = new THREE.MeshPhongMaterial({
|
|
|
|
|
color: getHeatColor(iso),
|
|
|
|
|
transparent: true,
|
|
|
|
|
side: THREE.DoubleSide,
|
|
|
|
|
opacity: opacity,
|
|
|
|
|
shininess: 100, // 控制高光粗细
|
|
|
|
|
specular: 0x888888, // 高光颜色
|
|
|
|
|
depthWrite: false,
|
|
|
|
|
depthTest: false
|
|
|
|
|
});
|
|
|
|
|
const mesh = new THREE.Mesh(geom, mat);
|
|
|
|
|
// mesh.renderOrder = meshes.length - i;
|
|
|
|
|
mesh.renderOrder = i;
|
|
|
|
|
newG.add(mesh);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scene.add(newG);
|
|
|
|
|
if (currentGroup) {
|
|
|
|
|
scene.remove(currentGroup);
|
|
|
|
|
currentGroup.traverse(o => o.geometry && o.geometry.dispose());
|
|
|
|
|
}
|
|
|
|
|
currentGroup = newG;
|
|
|
|
|
|
|
|
|
|
// 仅第一次渲染时对焦
|
|
|
|
|
if (!initialFocused) {
|
|
|
|
|
const box = new THREE.Box3().setFromObject(currentGroup);
|
|
|
|
|
const ctr = box.getCenter(new THREE.Vector3());
|
|
|
|
|
controls.target.copy(ctr);
|
|
|
|
|
camera.position.set(ctr.x, ctr.y, ctr.z);
|
|
|
|
|
controls.update();
|
|
|
|
|
initialFocused = true;
|
|
|
|
|
|
|
|
|
|
// 第一次渲染才绘制坐标轴
|
|
|
|
|
drawAxes();
|
|
|
|
|
}
|
|
|
|
|
// 后续帧只更新 mesh,不触碰 camera.target
|
|
|
|
|
controls.update();
|
|
|
|
|
renderer.render(scene, camera);
|
|
|
|
|
labelRenderer.render(scene, camera);
|
|
|
|
|
applyView();
|
|
|
|
|
// applyAngles();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 新增:根据两个输入框的度数,计算摄像机球坐标
|
|
|
|
|
function applyAngles() {
|
|
|
|
|
// 1) 解析当前角度输入
|
|
|
|
|
const azDeg = Number(document.getElementById('azimuth').value);
|
|
|
|
|
const poDeg = Number(document.getElementById('polar').value);
|
|
|
|
|
const az = THREE.MathUtils.degToRad(azDeg);
|
|
|
|
|
const po = THREE.MathUtils.degToRad(poDeg);
|
|
|
|
|
|
|
|
|
|
// 2) 取出并更新 OrbitControls 自己维护的 target
|
|
|
|
|
const target = controls.target; // **不要** clone()
|
|
|
|
|
|
|
|
|
|
// 3) 球面半径:相机到 target 的当前距离(保持不变)
|
|
|
|
|
const radius = camera.position.distanceTo(target);
|
|
|
|
|
|
|
|
|
|
// 4) 用球坐标公式计算新的相机位置
|
|
|
|
|
const sinP = Math.sin(po);
|
|
|
|
|
const cosP = Math.cos(po);
|
|
|
|
|
const sinA = Math.sin(az);
|
|
|
|
|
const cosA = Math.cos(az);
|
|
|
|
|
|
|
|
|
|
camera.position.set(
|
|
|
|
|
target.x + radius * sinP * cosA,
|
|
|
|
|
target.y + radius * cosP,
|
|
|
|
|
target.z + radius * sinP * sinA
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 5) 让相机正对 target
|
|
|
|
|
camera.lookAt(target);
|
|
|
|
|
|
|
|
|
|
// 6) 同步 OrbitControls 的内部状态一次,之后不要在本函数里重复调用
|
|
|
|
|
controls.update();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
|
|
|
|
|
</html>
|