Initialize web-test frontend
This commit is contained in:
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Dependencies and local runtimes
|
||||||
|
node_modules/
|
||||||
|
**/node_modules/
|
||||||
|
venv/
|
||||||
|
**/venv/
|
||||||
|
.venv/
|
||||||
|
**/.venv/
|
||||||
|
offline-deps/python-runtime/
|
||||||
|
|
||||||
|
# Build outputs and generated assets
|
||||||
|
dist/
|
||||||
|
**/dist/
|
||||||
|
web-test/public/cesium/
|
||||||
|
coverage/
|
||||||
|
**/coverage/
|
||||||
|
__pycache__/
|
||||||
|
**/__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
|
# Local environment and runtime logs
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
service/.env.local
|
||||||
|
*.log
|
||||||
|
*.err.log
|
||||||
|
*.out.log
|
||||||
|
|
||||||
|
# Local/private reference material
|
||||||
|
docs/
|
||||||
|
|
||||||
|
# OS/editor files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
15
web-test/README.md
Normal file
15
web-test/README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Cesium Web Test
|
||||||
|
|
||||||
|
This frontend is the local Cesium test client.
|
||||||
|
|
||||||
|
- Direct dependency: `cesium`
|
||||||
|
- Dev server: `vite`
|
||||||
|
- Local simulation service integration
|
||||||
|
- Trajectory rendering and playback
|
||||||
|
- ArcRotate camera around the aircraft
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm.cmd run web:dev
|
||||||
|
```
|
||||||
115
web-test/index.html
Normal file
115
web-test/index.html
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Cesium Reset Baseline</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div id="hud">
|
||||||
|
<div class="hud-title">Flight Sim Prototype</div>
|
||||||
|
<div class="hud-row">
|
||||||
|
<span>Service</span>
|
||||||
|
<strong id="serviceStatus">Checking</strong>
|
||||||
|
</div>
|
||||||
|
<div class="hud-row">
|
||||||
|
<span>Aircraft</span>
|
||||||
|
<strong id="aircraftStatus">-</strong>
|
||||||
|
</div>
|
||||||
|
<div class="hud-row">
|
||||||
|
<span>Simulation</span>
|
||||||
|
<strong id="simulationStatus">Waiting</strong>
|
||||||
|
</div>
|
||||||
|
<div class="hud-row">
|
||||||
|
<span>Playback</span>
|
||||||
|
<strong id="playbackStatus">Idle</strong>
|
||||||
|
</div>
|
||||||
|
<div class="control-grid">
|
||||||
|
<label>
|
||||||
|
<span>Maneuver</span>
|
||||||
|
<select id="maneuverType">
|
||||||
|
<option value="cobra">Cobra</option>
|
||||||
|
<option value="control-script">Control script</option>
|
||||||
|
<option value="level-turn">Level turn</option>
|
||||||
|
<option value="straight">Straight</option>
|
||||||
|
<option value="climb">Climb</option>
|
||||||
|
<option value="descent">Descent</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Duration</span>
|
||||||
|
<input id="durationSec" type="number" min="1" max="120" step="1" value="5" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Speed</span>
|
||||||
|
<input id="speedMps" type="number" min="20" max="600" step="5" value="120" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Bank</span>
|
||||||
|
<input id="bankDeg" type="number" min="-80" max="80" step="5" value="0" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="cobraControls" class="cobra-controls">
|
||||||
|
<div class="panel-title">Cobra Controller</div>
|
||||||
|
<div class="control-grid">
|
||||||
|
<label>
|
||||||
|
<span>Target alpha</span>
|
||||||
|
<input id="targetAlphaDeg" type="number" min="45" max="130" step="5" value="80" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Pitch-rate cap</span>
|
||||||
|
<input id="pitchRateLimitDegS" type="number" min="20" max="220" step="5" value="80" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Pull time</span>
|
||||||
|
<input id="pullDurationSec" type="number" min="0.1" max="3" step="0.05" value="0.7" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Recovery time</span>
|
||||||
|
<input id="recoveryDurationSec" type="number" min="0.1" max="5" step="0.1" value="2.0" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Pull elevator</span>
|
||||||
|
<input id="maxElevatorCmd" type="number" min="-1" max="0" step="0.05" value="-0.45" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Recovery elevator</span>
|
||||||
|
<input id="recoveryElevatorCmd" type="number" min="0" max="1" step="0.05" value="0.65" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Pull TVC</span>
|
||||||
|
<input id="pullTvcCmd" type="number" min="-1" max="0" step="0.05" value="-0.05" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Recovery TVC</span>
|
||||||
|
<input id="recoveryTvcCmd" type="number" min="-1" max="1" step="0.05" value="0.85" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<button id="simulateButton" type="button">Simulate</button>
|
||||||
|
<button id="retryButton" type="button">Retry</button>
|
||||||
|
</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<button id="playButton" type="button">Play</button>
|
||||||
|
<button id="pauseButton" type="button">Pause</button>
|
||||||
|
<button id="resetButton" type="button">Reset</button>
|
||||||
|
<select id="playbackSpeed">
|
||||||
|
<option value="0.25">0.25x</option>
|
||||||
|
<option value="1" selected>1x</option>
|
||||||
|
<option value="2">2x</option>
|
||||||
|
<option value="5">5x</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="researchPanel">
|
||||||
|
<div class="panel-title">F-22 Cobra Telemetry</div>
|
||||||
|
<canvas id="telemetryCanvas" width="420" height="300"></canvas>
|
||||||
|
<div id="telemetrySummary" class="telemetry-summary">No simulation</div>
|
||||||
|
</div>
|
||||||
|
<div id="cesiumContainer"></div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1462
web-test/package-lock.json
generated
Normal file
1462
web-test/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
web-test/package.json
Normal file
18
web-test/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "flight-sim-cesium-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 127.0.0.1 --port 5173",
|
||||||
|
"build": "npm run copy:cesium && vite build",
|
||||||
|
"preview": "vite preview --host 127.0.0.1 --port 4173",
|
||||||
|
"copy:cesium": "node ../tools/copy-cesium-assets.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cesium": "^1.138.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
161
web-test/src/api/simulationService.js
Normal file
161
web-test/src/api/simulationService.js
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
const DEFAULT_BASE_URL = "http://127.0.0.1:4317";
|
||||||
|
|
||||||
|
export class SimulationServiceClient {
|
||||||
|
constructor(baseUrl = DEFAULT_BASE_URL) {
|
||||||
|
this.baseUrl = baseUrl.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
getHealth() {
|
||||||
|
return this.request("/health");
|
||||||
|
}
|
||||||
|
|
||||||
|
getAircraft() {
|
||||||
|
return this.request("/aircraft");
|
||||||
|
}
|
||||||
|
|
||||||
|
simulateManeuver(payload) {
|
||||||
|
return this.request("/simulate", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(path, options = {}) {
|
||||||
|
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message ?? `${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLevelTurnRequest(aircraftId) {
|
||||||
|
return createManeuverRequest({
|
||||||
|
aircraftId,
|
||||||
|
maneuverType: "level-turn",
|
||||||
|
durationSec: 20,
|
||||||
|
speedMps: 150,
|
||||||
|
bankDeg: 45,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCobraRequest(aircraftId) {
|
||||||
|
return createManeuverRequest({
|
||||||
|
aircraftId,
|
||||||
|
maneuverType: "cobra",
|
||||||
|
durationSec: 5,
|
||||||
|
speedMps: 120,
|
||||||
|
bankDeg: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createManeuverRequest({
|
||||||
|
aircraftId,
|
||||||
|
maneuverType,
|
||||||
|
durationSec,
|
||||||
|
speedMps,
|
||||||
|
bankDeg,
|
||||||
|
cobraParameters,
|
||||||
|
}) {
|
||||||
|
const parameters = createManeuverParameters({
|
||||||
|
maneuverType,
|
||||||
|
speedMps,
|
||||||
|
bankDeg,
|
||||||
|
cobraParameters,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isF22Research = aircraftId === "f22-jsbsim" || aircraftId === "f22cobra-jsbsim";
|
||||||
|
const entrySpeedMps = isF22Research ? Math.max(speedMps, 120) : speedMps;
|
||||||
|
|
||||||
|
return {
|
||||||
|
aircraftId,
|
||||||
|
sampleRateHz: 30,
|
||||||
|
integrationRateHz: 120,
|
||||||
|
trim: !isF22Research,
|
||||||
|
durationSec,
|
||||||
|
initialState: {
|
||||||
|
position: [0, isF22Research ? 3000 : 1200, 0],
|
||||||
|
velocityMps: entrySpeedMps,
|
||||||
|
headingDeg: 0,
|
||||||
|
},
|
||||||
|
maneuver: {
|
||||||
|
type: maneuverType,
|
||||||
|
parameters,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createManeuverParameters({ maneuverType, speedMps, bankDeg, cobraParameters = {} }) {
|
||||||
|
if (maneuverType === "cobra") {
|
||||||
|
return {
|
||||||
|
speedMps,
|
||||||
|
controlMode: "closed-loop",
|
||||||
|
flightPathAngleDeg: 0,
|
||||||
|
entryDelaySec: 0.15,
|
||||||
|
pullDurationSec: 0.7,
|
||||||
|
holdDurationSec: 0,
|
||||||
|
recoveryDurationSec: 2.0,
|
||||||
|
targetAlphaDeg: 80,
|
||||||
|
recoveryAlphaDeg: 8,
|
||||||
|
pitchRateLimitDegS: 80,
|
||||||
|
holdPitchRateDegS: 0,
|
||||||
|
maxElevatorCmd: -0.45,
|
||||||
|
recoveryElevatorCmd: 0.65,
|
||||||
|
throttleCmd: 1,
|
||||||
|
pullTvcCmd: -0.05,
|
||||||
|
recoveryTvcCmd: 0.85,
|
||||||
|
speedbrakeCmd: 0,
|
||||||
|
...sanitizeCobraParameters(cobraParameters),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maneuverType === "control-script") {
|
||||||
|
return {
|
||||||
|
speedMps,
|
||||||
|
flightPathAngleDeg: 0,
|
||||||
|
timeline: [
|
||||||
|
{ t: 0, elevator: 0, aileron: 0, rudder: 0, throttle: 0.85, speedbrake: 0 },
|
||||||
|
{ t: 0.5, elevator: -0.8, aileron: 0, rudder: 0, throttle: 1, tvc: 0, speedbrake: 1 },
|
||||||
|
{ t: 1.6, elevator: 0, aileron: 0, rudder: 0, throttle: 1, tvc: 0, speedbrake: 1 },
|
||||||
|
{ t: 2.4, elevator: 0.25, aileron: 0, rudder: 0, throttle: 1, tvc: 1, speedbrake: 1 },
|
||||||
|
{ t: 4.0, elevator: 0, aileron: 0, rudder: 0, throttle: 0.85, tvc: 0, speedbrake: 0 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bankDeg,
|
||||||
|
speedMps,
|
||||||
|
flightPathAngleDeg: 8,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeCobraParameters(parameters) {
|
||||||
|
return {
|
||||||
|
targetAlphaDeg: clampNumber(parameters.targetAlphaDeg, 45, 130, 80),
|
||||||
|
pitchRateLimitDegS: clampNumber(parameters.pitchRateLimitDegS, 20, 220, 80),
|
||||||
|
pullDurationSec: clampNumber(parameters.pullDurationSec, 0.1, 3, 0.7),
|
||||||
|
recoveryDurationSec: clampNumber(parameters.recoveryDurationSec, 0.1, 5, 2.0),
|
||||||
|
maxElevatorCmd: clampNumber(parameters.maxElevatorCmd, -1, 0, -0.45),
|
||||||
|
recoveryElevatorCmd: clampNumber(parameters.recoveryElevatorCmd, 0, 1, 0.65),
|
||||||
|
pullTvcCmd: clampNumber(parameters.pullTvcCmd, -1, 0, -0.05),
|
||||||
|
recoveryTvcCmd: clampNumber(parameters.recoveryTvcCmd, -1, 1, 0.85),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampNumber(value, minimum, maximum, fallback) {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(minimum, Math.min(maximum, value));
|
||||||
|
}
|
||||||
182
web-test/src/arcRotateCamera.js
Normal file
182
web-test/src/arcRotateCamera.js
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import * as Cesium from "cesium";
|
||||||
|
|
||||||
|
const MIN_PITCH = Cesium.Math.toRadians(-89);
|
||||||
|
const MAX_PITCH = Cesium.Math.toRadians(89);
|
||||||
|
|
||||||
|
export class ArcRotateCamera {
|
||||||
|
constructor(viewer, target, options = {}) {
|
||||||
|
this.viewer = viewer;
|
||||||
|
this.target = Cesium.Cartesian3.clone(target);
|
||||||
|
this.heading = options.heading ?? Cesium.Math.toRadians(35);
|
||||||
|
this.pitch = options.pitch ?? Cesium.Math.toRadians(-28);
|
||||||
|
this.radius = options.radius ?? 2500;
|
||||||
|
this.minRadius = options.minRadius ?? 350;
|
||||||
|
this.maxRadius = options.maxRadius ?? 25000;
|
||||||
|
this.rotateSensitivity = options.rotateSensitivity ?? 0.004;
|
||||||
|
this.zoomSensitivity = options.zoomSensitivity ?? 0.0012;
|
||||||
|
this.inertiaFactor = options.inertiaFactor ?? 0.9;
|
||||||
|
|
||||||
|
this.velocityHeading = 0;
|
||||||
|
this.velocityPitch = 0;
|
||||||
|
this.velocityZoom = 0;
|
||||||
|
|
||||||
|
this.isLeftDragging = false;
|
||||||
|
this.isRightDragging = false;
|
||||||
|
this.lastPointerX = 0;
|
||||||
|
this.lastPointerY = 0;
|
||||||
|
|
||||||
|
this.canvas = viewer.scene.canvas;
|
||||||
|
this.controller = viewer.scene.screenSpaceCameraController;
|
||||||
|
this.controller.enableInputs = false;
|
||||||
|
|
||||||
|
this.onPointerDown = this.handlePointerDown.bind(this);
|
||||||
|
this.onPointerMove = this.handlePointerMove.bind(this);
|
||||||
|
this.onPointerUp = this.handlePointerUp.bind(this);
|
||||||
|
this.onWheel = this.handleWheel.bind(this);
|
||||||
|
this.onContextMenu = (event) => event.preventDefault();
|
||||||
|
this.onPreRender = this.update.bind(this);
|
||||||
|
|
||||||
|
this.canvas.addEventListener("pointerdown", this.onPointerDown);
|
||||||
|
window.addEventListener("pointermove", this.onPointerMove);
|
||||||
|
window.addEventListener("pointerup", this.onPointerUp);
|
||||||
|
this.canvas.addEventListener("wheel", this.onWheel, { passive: false });
|
||||||
|
this.canvas.addEventListener("contextmenu", this.onContextMenu);
|
||||||
|
viewer.scene.preRender.addEventListener(this.onPreRender);
|
||||||
|
|
||||||
|
this.applyCamera();
|
||||||
|
}
|
||||||
|
|
||||||
|
setTarget(target) {
|
||||||
|
Cesium.Cartesian3.clone(target, this.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.canvas.removeEventListener("pointerdown", this.onPointerDown);
|
||||||
|
window.removeEventListener("pointermove", this.onPointerMove);
|
||||||
|
window.removeEventListener("pointerup", this.onPointerUp);
|
||||||
|
this.canvas.removeEventListener("wheel", this.onWheel);
|
||||||
|
this.canvas.removeEventListener("contextmenu", this.onContextMenu);
|
||||||
|
this.viewer.scene.preRender.removeEventListener(this.onPreRender);
|
||||||
|
this.controller.enableInputs = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePointerDown(event) {
|
||||||
|
if (event.button !== 0 && event.button !== 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.canvas.setPointerCapture?.(event.pointerId);
|
||||||
|
this.lastPointerX = event.clientX;
|
||||||
|
this.lastPointerY = event.clientY;
|
||||||
|
this.isLeftDragging = event.button === 0;
|
||||||
|
this.isRightDragging = event.button === 2;
|
||||||
|
|
||||||
|
if (this.isLeftDragging) {
|
||||||
|
this.velocityHeading = 0;
|
||||||
|
this.velocityPitch = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isRightDragging) {
|
||||||
|
this.velocityZoom = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePointerMove(event) {
|
||||||
|
if (!this.isLeftDragging && !this.isRightDragging) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaX = event.clientX - this.lastPointerX;
|
||||||
|
const deltaY = event.clientY - this.lastPointerY;
|
||||||
|
this.lastPointerX = event.clientX;
|
||||||
|
this.lastPointerY = event.clientY;
|
||||||
|
|
||||||
|
if (this.isLeftDragging) {
|
||||||
|
const headingDelta = deltaX * this.rotateSensitivity;
|
||||||
|
const pitchDelta = -deltaY * this.rotateSensitivity;
|
||||||
|
this.heading += headingDelta;
|
||||||
|
this.pitch = clamp(this.pitch + pitchDelta, MIN_PITCH, MAX_PITCH);
|
||||||
|
this.velocityHeading = headingDelta;
|
||||||
|
this.velocityPitch = pitchDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isRightDragging) {
|
||||||
|
this.zoomBy(deltaY);
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePointerUp(event) {
|
||||||
|
if (event.button === 0) {
|
||||||
|
this.isLeftDragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.button === 2) {
|
||||||
|
this.isRightDragging = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleWheel(event) {
|
||||||
|
this.zoomBy(event.deltaY);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
zoomBy(deltaY) {
|
||||||
|
const zoomFactor = 1 + deltaY * this.zoomSensitivity;
|
||||||
|
this.radius = clamp(this.radius * zoomFactor, this.minRadius, this.maxRadius);
|
||||||
|
this.velocityZoom = zoomFactor;
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
if (!this.isLeftDragging) {
|
||||||
|
if (Math.abs(this.velocityHeading) > 0.00001) {
|
||||||
|
this.heading += this.velocityHeading;
|
||||||
|
this.velocityHeading *= this.inertiaFactor;
|
||||||
|
} else {
|
||||||
|
this.velocityHeading = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(this.velocityPitch) > 0.00001) {
|
||||||
|
this.pitch = clamp(
|
||||||
|
this.pitch + this.velocityPitch,
|
||||||
|
MIN_PITCH,
|
||||||
|
MAX_PITCH
|
||||||
|
);
|
||||||
|
this.velocityPitch *= this.inertiaFactor;
|
||||||
|
} else {
|
||||||
|
this.velocityPitch = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isRightDragging && this.velocityZoom !== 0) {
|
||||||
|
if (Math.abs(this.velocityZoom - 1) > 0.001) {
|
||||||
|
this.radius = clamp(
|
||||||
|
this.radius * this.velocityZoom,
|
||||||
|
this.minRadius,
|
||||||
|
this.maxRadius
|
||||||
|
);
|
||||||
|
this.velocityZoom =
|
||||||
|
1 + (this.velocityZoom - 1) * this.inertiaFactor;
|
||||||
|
} else {
|
||||||
|
this.velocityZoom = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.applyCamera();
|
||||||
|
}
|
||||||
|
|
||||||
|
applyCamera() {
|
||||||
|
this.viewer.camera.lookAt(
|
||||||
|
this.target,
|
||||||
|
new Cesium.HeadingPitchRange(this.heading, this.pitch, this.radius)
|
||||||
|
);
|
||||||
|
this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
122
web-test/src/main.js
Normal file
122
web-test/src/main.js
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
window.CESIUM_BASE_URL = "/cesium";
|
||||||
|
|
||||||
|
import "cesium/Build/Cesium/Widgets/widgets.css";
|
||||||
|
import {
|
||||||
|
SimulationServiceClient,
|
||||||
|
createManeuverRequest,
|
||||||
|
} from "./api/simulationService.js";
|
||||||
|
import { createTrajectoryPlayer } from "./playback/trajectoryPlayer.js";
|
||||||
|
import { createCesiumScene } from "./renderer/cesiumScene.js";
|
||||||
|
import { createControls } from "./ui/controls.js";
|
||||||
|
import { createHud } from "./ui/hud.js";
|
||||||
|
import { createResearchPanel } from "./ui/researchPanel.js";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
const ORIGIN = {
|
||||||
|
longitudeDeg: 116.3913,
|
||||||
|
latitudeDeg: 39.9075,
|
||||||
|
heightM: 3000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PREFERRED_AIRCRAFT_ID = "f22cobra-jsbsim";
|
||||||
|
|
||||||
|
const hud = createHud();
|
||||||
|
const researchPanel = createResearchPanel();
|
||||||
|
const scene = createCesiumScene({
|
||||||
|
containerId: "cesiumContainer",
|
||||||
|
origin: ORIGIN,
|
||||||
|
});
|
||||||
|
const simulationService = new SimulationServiceClient();
|
||||||
|
let activeAircraftId = null;
|
||||||
|
|
||||||
|
const trajectoryPlayer = createTrajectoryPlayer({
|
||||||
|
onPlay: () => hud.setPlayback("Playing"),
|
||||||
|
onPause: () => hud.setPlayback("Paused"),
|
||||||
|
onReset: () => hud.setPlayback("0.0s"),
|
||||||
|
onEmpty: () => hud.setPlayback("No samples"),
|
||||||
|
onFrame: ({ sample, nextSample, timeSec }) => {
|
||||||
|
scene.setAircraftSample(sample, nextSample);
|
||||||
|
hud.setPlayback(`${timeSec.toFixed(1)}s`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const controls = createControls({
|
||||||
|
onSimulate: (values) => runSimulation(values),
|
||||||
|
onRetry: () => connectSimulationService(),
|
||||||
|
onPlay: () => trajectoryPlayer.play(),
|
||||||
|
onPause: () => trajectoryPlayer.pause(),
|
||||||
|
onReset: () => trajectoryPlayer.reset(),
|
||||||
|
onSpeedChange: (speed) => trajectoryPlayer.setSpeed(speed),
|
||||||
|
});
|
||||||
|
|
||||||
|
connectSimulationService();
|
||||||
|
|
||||||
|
window.__viewer = scene.viewer;
|
||||||
|
window.__arcRotateCamera = scene.arcRotateCamera;
|
||||||
|
window.__simulationService = simulationService;
|
||||||
|
window.__trajectoryPlayer = trajectoryPlayer;
|
||||||
|
|
||||||
|
async function connectSimulationService() {
|
||||||
|
hud.reset();
|
||||||
|
trajectoryPlayer.reset();
|
||||||
|
activeAircraftId = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const health = await simulationService.getHealth();
|
||||||
|
console.log("[service] health", health);
|
||||||
|
hud.setService(health.ok ? `Online ${health.version}` : "Unhealthy");
|
||||||
|
|
||||||
|
const aircraftResponse = await simulationService.getAircraft();
|
||||||
|
console.log("[service] aircraft", aircraftResponse);
|
||||||
|
const aircraft = aircraftResponse.aircraft ?? [];
|
||||||
|
hud.setAircraft(`${aircraft.length}`);
|
||||||
|
|
||||||
|
const aircraftId =
|
||||||
|
aircraft.find((item) => item.id === PREFERRED_AIRCRAFT_ID)?.id ?? aircraft[0]?.id;
|
||||||
|
if (!aircraftId) {
|
||||||
|
hud.setSimulation("No aircraft");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeAircraftId = aircraftId;
|
||||||
|
hud.setAircraft(aircraftId);
|
||||||
|
|
||||||
|
await runSimulation(controls.getValues());
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[service] unavailable", error);
|
||||||
|
hud.setService("Offline");
|
||||||
|
hud.setSimulation("Service unavailable");
|
||||||
|
hud.setPlayback("Idle");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSimulation(values) {
|
||||||
|
if (!activeAircraftId) {
|
||||||
|
hud.setSimulation("No aircraft");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
controls.setBusy(true);
|
||||||
|
hud.setSimulation(`Running ${values.maneuverType}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = createManeuverRequest({
|
||||||
|
aircraftId: activeAircraftId,
|
||||||
|
...values,
|
||||||
|
});
|
||||||
|
const simulation = await simulationService.simulateManeuver(request);
|
||||||
|
console.log("[service] simulation request", request);
|
||||||
|
console.log("[service] simulation result", simulation);
|
||||||
|
|
||||||
|
window.__lastSimulation = simulation;
|
||||||
|
scene.renderSimulation(simulation);
|
||||||
|
researchPanel.render(simulation);
|
||||||
|
hud.setSimulation(`${simulation.samples?.length ?? 0} samples`);
|
||||||
|
trajectoryPlayer.load(simulation);
|
||||||
|
trajectoryPlayer.play();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[service] simulation failed", error);
|
||||||
|
hud.setSimulation("Simulation failed");
|
||||||
|
hud.setPlayback("Idle");
|
||||||
|
} finally {
|
||||||
|
controls.setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
204
web-test/src/playback/trajectoryPlayer.js
Normal file
204
web-test/src/playback/trajectoryPlayer.js
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
export function createTrajectoryPlayer({ onFrame, onPlay, onPause, onReset, onEmpty }) {
|
||||||
|
let animationFrame = 0;
|
||||||
|
let lastFrameTime = 0;
|
||||||
|
let timeSec = 0;
|
||||||
|
let speed = 1;
|
||||||
|
let simulation = null;
|
||||||
|
let isPlaying = false;
|
||||||
|
|
||||||
|
function load(nextSimulation) {
|
||||||
|
stopLoop();
|
||||||
|
simulation = nextSimulation;
|
||||||
|
timeSec = 0;
|
||||||
|
emitFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
function play(nextSimulation = null) {
|
||||||
|
if (nextSimulation) {
|
||||||
|
load(nextSimulation);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasSamples()) {
|
||||||
|
onEmpty?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isPlaying = true;
|
||||||
|
lastFrameTime = performance.now();
|
||||||
|
onPlay?.();
|
||||||
|
animationFrame = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pause() {
|
||||||
|
if (!isPlaying) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopLoop();
|
||||||
|
onPause?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
stopLoop();
|
||||||
|
timeSec = 0;
|
||||||
|
emitFrame();
|
||||||
|
onReset?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSpeed(nextSpeed) {
|
||||||
|
speed = Number.isFinite(nextSpeed) ? nextSpeed : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick(now) {
|
||||||
|
if (!simulation) {
|
||||||
|
stopLoop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaSec = (now - lastFrameTime) / 1000;
|
||||||
|
lastFrameTime = now;
|
||||||
|
timeSec = (timeSec + deltaSec * speed) % simulation.durationSec;
|
||||||
|
emitFrame();
|
||||||
|
animationFrame = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitFrame() {
|
||||||
|
const samples = simulation?.samples ?? [];
|
||||||
|
if (samples.length < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const samplePair = getSamplePair(samples, timeSec);
|
||||||
|
const sample = interpolateSamples(
|
||||||
|
samplePair.current,
|
||||||
|
samplePair.next,
|
||||||
|
samplePair.amount
|
||||||
|
);
|
||||||
|
|
||||||
|
onFrame?.({
|
||||||
|
sample,
|
||||||
|
nextSample: samplePair.next,
|
||||||
|
timeSec,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopLoop() {
|
||||||
|
if (animationFrame) {
|
||||||
|
cancelAnimationFrame(animationFrame);
|
||||||
|
animationFrame = 0;
|
||||||
|
}
|
||||||
|
isPlaying = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSamples() {
|
||||||
|
return (simulation?.samples?.length ?? 0) >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
load,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
reset,
|
||||||
|
setSpeed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSamplePair(samples, timeSec) {
|
||||||
|
let nextIndex = samples.findIndex((sample) => sample.t >= timeSec);
|
||||||
|
if (nextIndex <= 0) {
|
||||||
|
nextIndex = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = samples[nextIndex - 1];
|
||||||
|
const next = samples[nextIndex] ?? samples[0];
|
||||||
|
const span = next.t - current.t || 1;
|
||||||
|
const amount = clamp((timeSec - current.t) / span, 0, 1);
|
||||||
|
|
||||||
|
return { current, next, amount };
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolateSamples(current, next, amount) {
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
t: lerp(current.t, next.t, amount),
|
||||||
|
position: [
|
||||||
|
lerp(current.position[0], next.position[0], amount),
|
||||||
|
lerp(current.position[1], next.position[1], amount),
|
||||||
|
lerp(current.position[2], next.position[2], amount),
|
||||||
|
],
|
||||||
|
rotation: interpolateQuat(current.rotation, next.rotation, amount),
|
||||||
|
headingDeg: interpolateAngleDeg(current.headingDeg, next.headingDeg, amount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolateQuat(start, end, amount) {
|
||||||
|
if (!isQuat(start) || !isQuat(end)) {
|
||||||
|
return isQuat(start) ? start : end;
|
||||||
|
}
|
||||||
|
|
||||||
|
let [ex, ey, ez, ew] = end;
|
||||||
|
let dot = start[0] * ex + start[1] * ey + start[2] * ez + start[3] * ew;
|
||||||
|
if (dot < 0) {
|
||||||
|
dot = -dot;
|
||||||
|
ex = -ex;
|
||||||
|
ey = -ey;
|
||||||
|
ez = -ez;
|
||||||
|
ew = -ew;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dot > 0.9995) {
|
||||||
|
return normalizeQuat([
|
||||||
|
lerp(start[0], ex, amount),
|
||||||
|
lerp(start[1], ey, amount),
|
||||||
|
lerp(start[2], ez, amount),
|
||||||
|
lerp(start[3], ew, amount),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const theta0 = Math.acos(clamp(dot, -1, 1));
|
||||||
|
const theta = theta0 * amount;
|
||||||
|
const sinTheta = Math.sin(theta);
|
||||||
|
const sinTheta0 = Math.sin(theta0);
|
||||||
|
const s0 = Math.cos(theta) - (dot * sinTheta) / sinTheta0;
|
||||||
|
const s1 = sinTheta / sinTheta0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
start[0] * s0 + ex * s1,
|
||||||
|
start[1] * s0 + ey * s1,
|
||||||
|
start[2] * s0 + ez * s1,
|
||||||
|
start[3] * s0 + ew * s1,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isQuat(value) {
|
||||||
|
return Array.isArray(value) && value.length === 4 && value.every(Number.isFinite);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeQuat(value) {
|
||||||
|
const length = Math.hypot(value[0], value[1], value[2], value[3]);
|
||||||
|
if (length <= 0) {
|
||||||
|
return [0, 0, 0, 1];
|
||||||
|
}
|
||||||
|
return value.map((item) => item / length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolateAngleDeg(start, end, amount) {
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(end)) {
|
||||||
|
return Number.isFinite(start) ? start : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = ((end - start + 540) % 360) - 180;
|
||||||
|
return start + delta * amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerp(start, end, amount) {
|
||||||
|
return start + (end - start) * amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
231
web-test/src/renderer/cesiumScene.js
Normal file
231
web-test/src/renderer/cesiumScene.js
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import * as Cesium from "cesium";
|
||||||
|
import { ArcRotateCamera } from "../arcRotateCamera.js";
|
||||||
|
|
||||||
|
export function createCesiumScene({ containerId, origin }) {
|
||||||
|
const viewer = new Cesium.Viewer(containerId, {
|
||||||
|
animation: false,
|
||||||
|
timeline: false,
|
||||||
|
baseLayerPicker: false,
|
||||||
|
geocoder: false,
|
||||||
|
homeButton: false,
|
||||||
|
sceneModePicker: false,
|
||||||
|
navigationHelpButton: false,
|
||||||
|
fullscreenButton: false,
|
||||||
|
infoBox: false,
|
||||||
|
selectionIndicator: false,
|
||||||
|
terrainProvider: new Cesium.EllipsoidTerrainProvider(),
|
||||||
|
imageryProvider: new Cesium.UrlTemplateImageryProvider({
|
||||||
|
url: "https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
|
||||||
|
credit: "Esri World Imagery",
|
||||||
|
maximumLevel: 19,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
configureScene(viewer);
|
||||||
|
|
||||||
|
const originPosition = Cesium.Cartesian3.fromDegrees(
|
||||||
|
origin.longitudeDeg,
|
||||||
|
origin.latitudeDeg,
|
||||||
|
origin.heightM
|
||||||
|
);
|
||||||
|
const originTransform = Cesium.Transforms.eastNorthUpToFixedFrame(
|
||||||
|
originPosition
|
||||||
|
);
|
||||||
|
const aircraftPrimitive = createAircraftPrimitive(viewer, originPosition);
|
||||||
|
const targetMarkerEntity = createTargetMarker(viewer, originPosition);
|
||||||
|
const arcRotateCamera = new ArcRotateCamera(viewer, originPosition, {
|
||||||
|
heading: Cesium.Math.toRadians(35),
|
||||||
|
pitch: Cesium.Math.toRadians(-28),
|
||||||
|
radius: 2500,
|
||||||
|
});
|
||||||
|
|
||||||
|
let trajectoryEntity = null;
|
||||||
|
|
||||||
|
function renderSimulation(simulation) {
|
||||||
|
const samples = simulation.samples ?? [];
|
||||||
|
if (samples.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const positions = samples.map((sample) =>
|
||||||
|
localPositionToWorld(sample.position)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (trajectoryEntity) {
|
||||||
|
viewer.entities.remove(trajectoryEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
trajectoryEntity = viewer.entities.add({
|
||||||
|
polyline: {
|
||||||
|
positions,
|
||||||
|
width: 3,
|
||||||
|
material: Cesium.Color.CYAN.withAlpha(0.92),
|
||||||
|
clampToGround: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setAircraftSample(samples[0], samples[1]);
|
||||||
|
viewer.scene.requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAircraftSample(sample, nextSample = null) {
|
||||||
|
const position = localPositionToWorld(sample.position);
|
||||||
|
|
||||||
|
aircraftPrimitive.modelMatrix = getAircraftModelMatrix(position, sample, nextSample);
|
||||||
|
targetMarkerEntity.position = new Cesium.ConstantPositionProperty(position);
|
||||||
|
arcRotateCamera.setTarget(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
function localPositionToWorld(position) {
|
||||||
|
const [east, up, north] = position;
|
||||||
|
return Cesium.Matrix4.multiplyByPoint(
|
||||||
|
originTransform,
|
||||||
|
new Cesium.Cartesian3(east, north, up - origin.heightM),
|
||||||
|
new Cesium.Cartesian3()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
viewer,
|
||||||
|
arcRotateCamera,
|
||||||
|
renderSimulation,
|
||||||
|
setAircraftSample,
|
||||||
|
localPositionToWorld,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureScene(viewer) {
|
||||||
|
viewer.scene.globe.baseColor = Cesium.Color.fromCssColorString("#24323f");
|
||||||
|
viewer.scene.skyAtmosphere.show = false;
|
||||||
|
viewer.scene.fog.enabled = false;
|
||||||
|
viewer.scene.moon.show = false;
|
||||||
|
viewer.scene.sun.show = false;
|
||||||
|
viewer.scene.screenSpaceCameraController.inertiaSpin = 0;
|
||||||
|
viewer.scene.screenSpaceCameraController.inertiaTranslate = 0;
|
||||||
|
viewer.scene.screenSpaceCameraController.inertiaZoom = 0;
|
||||||
|
viewer.scene.screenSpaceCameraController.enableCollisionDetection = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAircraftPrimitive(viewer, position) {
|
||||||
|
const primitive = viewer.scene.primitives.add(
|
||||||
|
new Cesium.Primitive({
|
||||||
|
geometryInstances: new Cesium.GeometryInstance({
|
||||||
|
geometry: new Cesium.CylinderGeometry({
|
||||||
|
length: 320,
|
||||||
|
topRadius: 0,
|
||||||
|
bottomRadius: 78,
|
||||||
|
slices: 32,
|
||||||
|
vertexFormat: Cesium.PerInstanceColorAppearance.VERTEX_FORMAT,
|
||||||
|
}),
|
||||||
|
attributes: {
|
||||||
|
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||||
|
Cesium.Color.ORANGE.withAlpha(0.96)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
appearance: new Cesium.PerInstanceColorAppearance({
|
||||||
|
closed: true,
|
||||||
|
translucent: true,
|
||||||
|
}),
|
||||||
|
asynchronous: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
primitive.modelMatrix = getAircraftModelMatrix(position, 0);
|
||||||
|
return primitive;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTargetMarker(viewer, position) {
|
||||||
|
return viewer.entities.add({
|
||||||
|
position,
|
||||||
|
point: {
|
||||||
|
pixelSize: 8,
|
||||||
|
color: Cesium.Color.WHITE,
|
||||||
|
outlineColor: Cesium.Color.BLACK,
|
||||||
|
outlineWidth: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAircraftModelMatrix(position, sampleOrHeadingDeg, nextSample = null) {
|
||||||
|
const orientation =
|
||||||
|
typeof sampleOrHeadingDeg === "number"
|
||||||
|
? fallbackOrientation(position, sampleOrHeadingDeg, 0, 0)
|
||||||
|
: sampleOrientation(position, sampleOrHeadingDeg, nextSample);
|
||||||
|
|
||||||
|
return Cesium.Matrix4.fromTranslationQuaternionRotationScale(
|
||||||
|
position,
|
||||||
|
orientation,
|
||||||
|
new Cesium.Cartesian3(1, 1, 1),
|
||||||
|
new Cesium.Matrix4()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleOrientation(position, sample, nextSample) {
|
||||||
|
if (isQuat(sample?.rotation)) {
|
||||||
|
return localEunQuaternionToWorld(position, sample.rotation);
|
||||||
|
}
|
||||||
|
|
||||||
|
const estimatedHeadingDeg = estimateHeadingDeg(sample, nextSample);
|
||||||
|
const headingDeg = Number.isFinite(estimatedHeadingDeg)
|
||||||
|
? estimatedHeadingDeg
|
||||||
|
: numberOrDefault(sample?.headingDeg, 0);
|
||||||
|
|
||||||
|
return fallbackOrientation(
|
||||||
|
position,
|
||||||
|
headingDeg,
|
||||||
|
numberOrDefault(sample?.pitchDeg, 0),
|
||||||
|
numberOrDefault(sample?.rollDeg, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function localEunQuaternionToWorld(position, rotation) {
|
||||||
|
const localToWorld = Cesium.Transforms.eastNorthUpToFixedFrame(position);
|
||||||
|
const localRotation = Cesium.Matrix3.fromQuaternion(
|
||||||
|
new Cesium.Quaternion(rotation[0], rotation[1], rotation[2], rotation[3]),
|
||||||
|
new Cesium.Matrix3()
|
||||||
|
);
|
||||||
|
const geometryRotation = Cesium.Matrix3.fromRotationY(
|
||||||
|
Cesium.Math.PI_OVER_TWO,
|
||||||
|
new Cesium.Matrix3()
|
||||||
|
);
|
||||||
|
const worldRotation = Cesium.Matrix3.multiply(
|
||||||
|
Cesium.Matrix4.getMatrix3(localToWorld, new Cesium.Matrix3()),
|
||||||
|
Cesium.Matrix3.multiply(localRotation, geometryRotation, new Cesium.Matrix3()),
|
||||||
|
new Cesium.Matrix3()
|
||||||
|
);
|
||||||
|
return Cesium.Quaternion.fromRotationMatrix(worldRotation, new Cesium.Quaternion());
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackOrientation(position, headingDeg, pitchDeg = 0, rollDeg = 0) {
|
||||||
|
return Cesium.Transforms.headingPitchRollQuaternion(
|
||||||
|
position,
|
||||||
|
new Cesium.HeadingPitchRoll(
|
||||||
|
Cesium.Math.toRadians(headingDeg + 90),
|
||||||
|
Cesium.Math.PI_OVER_TWO - Cesium.Math.toRadians(pitchDeg),
|
||||||
|
Cesium.Math.toRadians(rollDeg)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateHeadingDeg(sample, nextSample) {
|
||||||
|
if (!nextSample) {
|
||||||
|
return Number.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eastDelta = nextSample.position[0] - sample.position[0];
|
||||||
|
const northDelta = nextSample.position[2] - sample.position[2];
|
||||||
|
if (Math.hypot(eastDelta, northDelta) < 0.01) {
|
||||||
|
return Number.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Cesium.Math.toDegrees(Math.atan2(eastDelta, northDelta));
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberOrDefault(value, fallback) {
|
||||||
|
return Number.isFinite(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isQuat(value) {
|
||||||
|
return Array.isArray(value) && value.length === 4 && value.every(Number.isFinite);
|
||||||
|
}
|
||||||
175
web-test/src/styles.css
Normal file
175
web-test/src/styles.css
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app,
|
||||||
|
#cesiumContainer {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0f1720;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
color: #e6eef5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#hud {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
left: 16px;
|
||||||
|
z-index: 10;
|
||||||
|
width: 320px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid rgba(170, 200, 220, 0.25);
|
||||||
|
background: rgba(12, 18, 24, 0.82);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#researchPanel {
|
||||||
|
position: absolute;
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
z-index: 10;
|
||||||
|
width: min(440px, calc(100vw - 32px));
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid rgba(170, 200, 220, 0.25);
|
||||||
|
background: rgba(12, 18, 24, 0.82);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#telemetryCanvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-summary {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #c8d6df;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #b8c7d3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-row strong {
|
||||||
|
color: #f4f8fb;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cobra-controls {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid rgba(170, 200, 220, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cobra-controls[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-grid label {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #b8c7d3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-grid input,
|
||||||
|
.control-grid select,
|
||||||
|
.button-row button,
|
||||||
|
.button-row select {
|
||||||
|
min-width: 0;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid rgba(170, 200, 220, 0.26);
|
||||||
|
background: rgba(20, 30, 40, 0.92);
|
||||||
|
color: #f4f8fb;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-grid input,
|
||||||
|
.control-grid select {
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row button {
|
||||||
|
flex: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row select {
|
||||||
|
width: 72px;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row button:hover,
|
||||||
|
.control-grid input:focus,
|
||||||
|
.control-grid select:focus,
|
||||||
|
.button-row select:focus {
|
||||||
|
border-color: rgba(116, 213, 255, 0.75);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row button:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cesium-viewer-toolbar,
|
||||||
|
.cesium-viewer-animationContainer,
|
||||||
|
.cesium-viewer-timelineContainer,
|
||||||
|
.cesium-viewer-bottom {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 780px) {
|
||||||
|
#hud,
|
||||||
|
#researchPanel {
|
||||||
|
width: calc(100vw - 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#researchPanel {
|
||||||
|
left: 16px;
|
||||||
|
right: auto;
|
||||||
|
bottom: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
93
web-test/src/ui/controls.js
vendored
Normal file
93
web-test/src/ui/controls.js
vendored
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
export function createControls(handlers = {}) {
|
||||||
|
const elements = {
|
||||||
|
maneuverType: document.querySelector("#maneuverType"),
|
||||||
|
durationSec: document.querySelector("#durationSec"),
|
||||||
|
speedMps: document.querySelector("#speedMps"),
|
||||||
|
bankDeg: document.querySelector("#bankDeg"),
|
||||||
|
cobraControls: document.querySelector("#cobraControls"),
|
||||||
|
targetAlphaDeg: document.querySelector("#targetAlphaDeg"),
|
||||||
|
pitchRateLimitDegS: document.querySelector("#pitchRateLimitDegS"),
|
||||||
|
pullDurationSec: document.querySelector("#pullDurationSec"),
|
||||||
|
recoveryDurationSec: document.querySelector("#recoveryDurationSec"),
|
||||||
|
maxElevatorCmd: document.querySelector("#maxElevatorCmd"),
|
||||||
|
recoveryElevatorCmd: document.querySelector("#recoveryElevatorCmd"),
|
||||||
|
pullTvcCmd: document.querySelector("#pullTvcCmd"),
|
||||||
|
recoveryTvcCmd: document.querySelector("#recoveryTvcCmd"),
|
||||||
|
simulateButton: document.querySelector("#simulateButton"),
|
||||||
|
retryButton: document.querySelector("#retryButton"),
|
||||||
|
playButton: document.querySelector("#playButton"),
|
||||||
|
pauseButton: document.querySelector("#pauseButton"),
|
||||||
|
resetButton: document.querySelector("#resetButton"),
|
||||||
|
playbackSpeed: document.querySelector("#playbackSpeed"),
|
||||||
|
};
|
||||||
|
|
||||||
|
elements.simulateButton?.addEventListener("click", () => {
|
||||||
|
handlers.onSimulate?.(getValues());
|
||||||
|
});
|
||||||
|
elements.retryButton?.addEventListener("click", () => {
|
||||||
|
handlers.onRetry?.();
|
||||||
|
});
|
||||||
|
elements.playButton?.addEventListener("click", () => {
|
||||||
|
handlers.onPlay?.();
|
||||||
|
});
|
||||||
|
elements.pauseButton?.addEventListener("click", () => {
|
||||||
|
handlers.onPause?.();
|
||||||
|
});
|
||||||
|
elements.resetButton?.addEventListener("click", () => {
|
||||||
|
handlers.onReset?.();
|
||||||
|
});
|
||||||
|
elements.playbackSpeed?.addEventListener("change", () => {
|
||||||
|
handlers.onSpeedChange?.(readNumber(elements.playbackSpeed, 1));
|
||||||
|
});
|
||||||
|
elements.maneuverType?.addEventListener("change", updateMode);
|
||||||
|
updateMode();
|
||||||
|
|
||||||
|
function getValues() {
|
||||||
|
const maneuverType = elements.maneuverType?.value ?? "level-turn";
|
||||||
|
return {
|
||||||
|
maneuverType,
|
||||||
|
durationSec: readNumber(elements.durationSec, 20),
|
||||||
|
speedMps: readNumber(elements.speedMps, 150),
|
||||||
|
bankDeg: readNumber(elements.bankDeg, 45),
|
||||||
|
cobraParameters: readCobraParameters(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusy(isBusy) {
|
||||||
|
if (elements.simulateButton) {
|
||||||
|
elements.simulateButton.disabled = isBusy;
|
||||||
|
elements.simulateButton.textContent = isBusy ? "Running" : "Simulate";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getValues,
|
||||||
|
setBusy,
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateMode() {
|
||||||
|
if (!elements.cobraControls) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.cobraControls.hidden = elements.maneuverType?.value !== "cobra";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCobraParameters() {
|
||||||
|
return {
|
||||||
|
targetAlphaDeg: readNumber(elements.targetAlphaDeg, 80),
|
||||||
|
pitchRateLimitDegS: readNumber(elements.pitchRateLimitDegS, 80),
|
||||||
|
pullDurationSec: readNumber(elements.pullDurationSec, 0.7),
|
||||||
|
recoveryDurationSec: readNumber(elements.recoveryDurationSec, 2.0),
|
||||||
|
maxElevatorCmd: readNumber(elements.maxElevatorCmd, -0.45),
|
||||||
|
recoveryElevatorCmd: readNumber(elements.recoveryElevatorCmd, 0.65),
|
||||||
|
pullTvcCmd: readNumber(elements.pullTvcCmd, -0.05),
|
||||||
|
recoveryTvcCmd: readNumber(elements.recoveryTvcCmd, 0.85),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(element, fallback) {
|
||||||
|
const value = Number.parseFloat(element?.value ?? "");
|
||||||
|
return Number.isFinite(value) ? value : fallback;
|
||||||
|
}
|
||||||
39
web-test/src/ui/hud.js
Normal file
39
web-test/src/ui/hud.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
export function createHud() {
|
||||||
|
const elements = {
|
||||||
|
service: document.querySelector("#serviceStatus"),
|
||||||
|
aircraft: document.querySelector("#aircraftStatus"),
|
||||||
|
simulation: document.querySelector("#simulationStatus"),
|
||||||
|
playback: document.querySelector("#playbackStatus"),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
reset() {
|
||||||
|
setText(elements.service, "Checking");
|
||||||
|
setText(elements.aircraft, "-");
|
||||||
|
setText(elements.simulation, "Waiting");
|
||||||
|
setText(elements.playback, "Idle");
|
||||||
|
},
|
||||||
|
|
||||||
|
setService(value) {
|
||||||
|
setText(elements.service, value);
|
||||||
|
},
|
||||||
|
|
||||||
|
setAircraft(value) {
|
||||||
|
setText(elements.aircraft, value);
|
||||||
|
},
|
||||||
|
|
||||||
|
setSimulation(value) {
|
||||||
|
setText(elements.simulation, value);
|
||||||
|
},
|
||||||
|
|
||||||
|
setPlayback(value) {
|
||||||
|
setText(elements.playback, value);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setText(element, value) {
|
||||||
|
if (element) {
|
||||||
|
element.textContent = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
169
web-test/src/ui/researchPanel.js
Normal file
169
web-test/src/ui/researchPanel.js
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
const SERIES = [
|
||||||
|
{ key: "alphaDeg", label: "alpha", color: "#5eead4", min: 0, max: 120 },
|
||||||
|
{ key: "pitchDeg", label: "pitch", color: "#fbbf24", min: -20, max: 120 },
|
||||||
|
{ key: "pitchRateDegS", label: "q", color: "#fb7185", min: -260, max: 260 },
|
||||||
|
{ key: "velocityMps", label: "speed", color: "#93c5fd", min: 0, max: 260 },
|
||||||
|
{ key: "altitudeDeltaM", label: "dAlt", color: "#c4b5fd", min: -200, max: 600 },
|
||||||
|
{ key: "elevatorCmd", label: "elev", color: "#f97316", min: -1, max: 1 },
|
||||||
|
{ key: "tvcCmd", label: "tvc", color: "#a3e635", min: -1, max: 1 },
|
||||||
|
{ key: "speedbrakeCmd", label: "brake", color: "#e5e7eb", min: 0, max: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function createResearchPanel() {
|
||||||
|
const canvas = document.querySelector("#telemetryCanvas");
|
||||||
|
const summary = document.querySelector("#telemetrySummary");
|
||||||
|
const context = canvas?.getContext("2d");
|
||||||
|
|
||||||
|
function render(simulation) {
|
||||||
|
if (!canvas || !context) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const samples = normalizeSamples(simulation?.samples ?? []);
|
||||||
|
drawBackground(context, canvas);
|
||||||
|
|
||||||
|
if (samples.length < 2) {
|
||||||
|
setText(summary, "No simulation");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawGrid(context, canvas);
|
||||||
|
for (const series of SERIES) {
|
||||||
|
drawSeries(context, canvas, samples, simulation.durationSec, series);
|
||||||
|
}
|
||||||
|
drawLegend(context, canvas);
|
||||||
|
setText(summary, createSummary(samples));
|
||||||
|
}
|
||||||
|
|
||||||
|
render(null);
|
||||||
|
|
||||||
|
return { render };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSamples(samples) {
|
||||||
|
const startAltitude = samples[0]?.position?.[1] ?? 0;
|
||||||
|
return samples.map((sample) => ({
|
||||||
|
...sample,
|
||||||
|
altitudeDeltaM: (sample.position?.[1] ?? startAltitude) - startAltitude,
|
||||||
|
elevatorCmd: sample.controlInputs?.elevator ?? 0,
|
||||||
|
tvcCmd: sample.controlInputs?.tvc ?? 0,
|
||||||
|
speedbrakeCmd: sample.controlInputs?.speedbrake ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBackground(context, canvas) {
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
context.fillStyle = "rgba(8, 13, 18, 0.82)";
|
||||||
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawGrid(context, canvas) {
|
||||||
|
context.save();
|
||||||
|
context.strokeStyle = "rgba(180, 205, 220, 0.16)";
|
||||||
|
context.lineWidth = 1;
|
||||||
|
for (let x = 40; x < canvas.width; x += 76) {
|
||||||
|
line(context, x, 18, x, canvas.height - 34);
|
||||||
|
}
|
||||||
|
for (let y = 28; y < canvas.height - 24; y += 48) {
|
||||||
|
line(context, 34, y, canvas.width - 12, y);
|
||||||
|
}
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSeries(context, canvas, samples, durationSec, series) {
|
||||||
|
const left = 34;
|
||||||
|
const top = 18;
|
||||||
|
const right = canvas.width - 12;
|
||||||
|
const bottom = canvas.height - 34;
|
||||||
|
const width = right - left;
|
||||||
|
const height = bottom - top;
|
||||||
|
|
||||||
|
context.save();
|
||||||
|
context.strokeStyle = series.color;
|
||||||
|
context.lineWidth = series.key.includes("Cmd") ? 1.5 : 2;
|
||||||
|
context.globalAlpha = series.key.includes("Cmd") ? 0.85 : 0.95;
|
||||||
|
context.beginPath();
|
||||||
|
|
||||||
|
samples.forEach((sample, index) => {
|
||||||
|
const x = left + (sample.t / durationSec) * width;
|
||||||
|
const value = clamp(numberOrDefault(sample[series.key], 0), series.min, series.max);
|
||||||
|
const y = bottom - ((value - series.min) / (series.max - series.min)) * height;
|
||||||
|
if (index === 0) {
|
||||||
|
context.moveTo(x, y);
|
||||||
|
} else {
|
||||||
|
context.lineTo(x, y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
context.stroke();
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawLegend(context, canvas) {
|
||||||
|
context.save();
|
||||||
|
context.font = "11px Arial, Helvetica, sans-serif";
|
||||||
|
let x = 34;
|
||||||
|
const y = canvas.height - 12;
|
||||||
|
for (const series of SERIES) {
|
||||||
|
context.fillStyle = series.color;
|
||||||
|
context.fillRect(x, y - 7, 8, 2);
|
||||||
|
context.fillText(series.label, x + 12, y - 3);
|
||||||
|
x += context.measureText(series.label).width + 28;
|
||||||
|
}
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSummary(samples) {
|
||||||
|
const maxAlpha = maxOf(samples, "alphaDeg");
|
||||||
|
const maxPitch = maxOf(samples, "pitchDeg");
|
||||||
|
const maxQ = maxAbsOf(samples, "pitchRateDegS");
|
||||||
|
const minSpeed = minOf(samples, "velocityMps");
|
||||||
|
const end = samples.at(-1);
|
||||||
|
const maxBrake = maxOf(samples, "speedbrakeCmd");
|
||||||
|
|
||||||
|
return [
|
||||||
|
`max alpha ${format(maxAlpha)} deg`,
|
||||||
|
`max pitch ${format(maxPitch)} deg`,
|
||||||
|
`max q ${format(maxQ)} deg/s`,
|
||||||
|
`min speed ${format(minSpeed)} m/s`,
|
||||||
|
`end alpha ${format(end?.alphaDeg)} deg`,
|
||||||
|
`brake ${format(maxBrake, 1)}`,
|
||||||
|
].join(" | ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxOf(samples, key) {
|
||||||
|
return Math.max(...samples.map((sample) => numberOrDefault(sample[key], -Infinity)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function minOf(samples, key) {
|
||||||
|
return Math.min(...samples.map((sample) => numberOrDefault(sample[key], Infinity)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxAbsOf(samples, key) {
|
||||||
|
return Math.max(...samples.map((sample) => Math.abs(numberOrDefault(sample[key], 0))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function format(value, digits = 1) {
|
||||||
|
return Number.isFinite(value) ? value.toFixed(digits) : "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function line(context, x1, y1, x2, y2) {
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(x1, y1);
|
||||||
|
context.lineTo(x2, y2);
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberOrDefault(value, fallback) {
|
||||||
|
return Number.isFinite(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setText(element, value) {
|
||||||
|
if (element) {
|
||||||
|
element.textContent = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
web-test/vite.config.js
Normal file
15
web-test/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ["cesium"],
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 4173,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user