Initialize web-test frontend

This commit is contained in:
moriy
2026-04-28 17:24:58 +08:00
commit 6074a5739d
15 changed files with 3037 additions and 0 deletions

36
.gitignore vendored Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

18
web-test/package.json Normal file
View 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"
}
}

View 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));
}

View 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
View 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);
}
}

View 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);
}

View 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
View 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
View 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
View 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;
}
}

View 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
View 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,
},
});