Initial commit: offline flight planner (Rust, PFPX-class)
Clean-room reproduction of PFPX: route generation + IFPS validation + OFP. Independent design — official ICAO/EUROCONTROL data only (RAD, FRA points, IFPUV oracle); no community FPL sources. Workspace crates: core (routing/discover/rad/navdata/perf/export), cli, server, rad (Annex parser), gui (Tauri v2 + React + MapLibre). Route discovery: oracle-in-the-loop repair against Eurocontrol IFPUV. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Tauri + React + Typescript
|
||||
|
||||
This template should help get you started developing with Tauri, React and Typescript in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri + React + Typescript</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "gui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluentui/react-components": "^9.74.6",
|
||||
"@fluentui/react-icons": "^2.0.337",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"7.css": "^0.21.1",
|
||||
"maplibre-gl": "^6.4.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "flightplanner-gui"
|
||||
version = "0.1.0"
|
||||
description = "Flight Planner desktop GUI (Tauri)"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix keeps the lib name distinct from the bin on Windows.
|
||||
# See https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "gui_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
flightplanner-core = { workspace = true }
|
||||
flightplanner-rad = { path = "../../crates/rad" }
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-unmaximize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-start-dragging"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,388 @@
|
||||
//! Tauri backend for the Flight Planner GUI.
|
||||
//!
|
||||
//! The `plan` command just delegates to the shared engine in `core::api`, so the
|
||||
//! local (in-process) path runs the exact same code as the custom HTTP server.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use flightplanner_core::api::{self, CachedRouteDto, PlanRequest, PlanResult};
|
||||
use flightplanner_core::error::CoreError;
|
||||
use flightplanner_core::rad::RadData;
|
||||
use flightplanner_core::routing::discover::{DiscoverResult, IfpsErr, IfpsValidator, IfpsVerdict};
|
||||
use tauri::{Emitter, State};
|
||||
|
||||
/// The RAD loaded once at startup (None if the file isn't present).
|
||||
struct RadState(Option<Arc<RadData>>);
|
||||
|
||||
/// Run route + optional fuel plan locally. Errors are stringified for the UI.
|
||||
#[tauri::command]
|
||||
fn plan(req: PlanRequest, rad: State<RadState>) -> Result<PlanResult, String> {
|
||||
api::plan(&req, rad.0.as_deref()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Authoritative IFPS validator backed by the IFPUV scraper (`validate.mjs`).
|
||||
/// Emits a `discover-progress` event before each ~25 s round-trip so the UI can
|
||||
/// show what's being checked.
|
||||
struct NodeIfps {
|
||||
dir: String,
|
||||
app: Option<tauri::AppHandle>,
|
||||
step: AtomicUsize,
|
||||
}
|
||||
|
||||
impl IfpsValidator for NodeIfps {
|
||||
fn validate(&self, adep: &str, ades: &str, route: &str, fl: i32) -> Result<IfpsVerdict, CoreError> {
|
||||
let step = self.step.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if let Some(app) = &self.app {
|
||||
let _ = app.emit(
|
||||
"discover-progress",
|
||||
serde_json::json!({
|
||||
"step": step,
|
||||
"adep": adep, "ades": ades, "route": route, "fl": fl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
let payload = serde_json::json!({
|
||||
"adep": adep, "ades": ades, "route": route, "level": format!("F{:03}", fl),
|
||||
});
|
||||
let out = std::process::Command::new("node")
|
||||
.arg("validate.mjs")
|
||||
.arg(payload.to_string())
|
||||
.current_dir(&self.dir)
|
||||
.output()?; // io::Error → CoreError::Io
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let line = stdout
|
||||
.lines()
|
||||
.rev()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.ok_or_else(|| {
|
||||
CoreError::Other(format!(
|
||||
"IFPUV validator produced no result. stderr: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
))
|
||||
})?;
|
||||
#[derive(serde::Deserialize)]
|
||||
struct V {
|
||||
accepted: Option<bool>,
|
||||
#[serde(default)]
|
||||
errors: Vec<IfpsErr>,
|
||||
}
|
||||
let v: V = serde_json::from_str(line)?; // serde error → CoreError::Json
|
||||
Ok(IfpsVerdict { accepted: v.accepted.unwrap_or(false), errors: v.errors })
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate an IFPS-valid route via the oracle loop (live IFPUV, ~1–3 min the
|
||||
/// first time), store it, and return the discovery result. Runs off-thread.
|
||||
#[tauri::command]
|
||||
async fn generate_validated(
|
||||
app: tauri::AppHandle,
|
||||
req: PlanRequest,
|
||||
rad: State<'_, RadState>,
|
||||
) -> Result<DiscoverResult, String> {
|
||||
let rad = rad.0.clone();
|
||||
let dir = std::env::var("FP_IFPUV_DIR")
|
||||
.unwrap_or_else(|_| r"C:\Users\Alexandre\flightplanner\tools\ifpuv".to_string());
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let validator = NodeIfps { dir, app: Some(app), step: AtomicUsize::new(0) };
|
||||
api::discover_route(&req, rad.as_deref(), &validator).map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
/// Stored routes for a city pair from our own route database (local file).
|
||||
#[tauri::command]
|
||||
fn recent_routes(db_path: String, dep: String, dest: String) -> Result<Vec<CachedRouteDto>, String> {
|
||||
api::recent_routes(&db_path, &dep, &dest, 10).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Total number of stored routes.
|
||||
#[tauri::command]
|
||||
fn route_db_count(db_path: String) -> Result<i64, String> {
|
||||
api::route_db_count(&db_path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Run a Playwright IFPUV script (`script`) with the JSON `payload` and return
|
||||
/// its single JSON result line. Round-trips the public Eurocontrol validator.
|
||||
fn run_ifpuv(script: &str, payload: serde_json::Value) -> Result<serde_json::Value, String> {
|
||||
let dir = std::env::var("FP_IFPUV_DIR")
|
||||
.unwrap_or_else(|_| r"C:\Users\Alexandre\flightplanner\tools\ifpuv".to_string());
|
||||
let out = std::process::Command::new("node")
|
||||
.arg(script)
|
||||
.arg(payload.to_string())
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.map_err(|e| format!("cannot launch Node.js validator: {e}"))?;
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let line = stdout
|
||||
.lines()
|
||||
.rev()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.ok_or_else(|| {
|
||||
format!("validator produced no result. stderr: {}", String::from_utf8_lossy(&out.stderr).trim())
|
||||
})?;
|
||||
serde_json::from_str::<serde_json::Value>(line).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Authoritative IFPS validation: builds an ICAO FPL from `payload` and submits
|
||||
/// it to the public Eurocontrol IFPUV. Returns `{ accepted, errors:[{code,msg}], raw, fpl }`.
|
||||
/// Network round-trip, ~20–30 s (guest GWT app load).
|
||||
#[tauri::command]
|
||||
async fn ifps_validate(payload: serde_json::Value) -> Result<serde_json::Value, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || run_ifpuv("validate.mjs", payload))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
/// Oracle-driven IFPS auto-correction: validates and mechanically fixes the
|
||||
/// tractable errors (RAD level caps, unknown designators) against the IFPUV,
|
||||
/// looping until accepted. Returns `{ accepted, level, route, iterations, log, errors }`.
|
||||
#[tauri::command]
|
||||
async fn ifps_autofix(payload: serde_json::Value) -> Result<serde_json::Value, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || run_ifpuv("autofix.mjs", payload))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
/// Load the RAD workbook once (env `FP_RAD`, else the project default).
|
||||
fn load_rad() -> Option<RadData> {
|
||||
let path = std::env::var("FP_RAD")
|
||||
.unwrap_or_else(|_| r"C:\Users\Alexandre\flightplanner\rad\RAD_current.xlsx".to_string());
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
eprintln!("RAD: file not found ({path}) — RAD checks disabled");
|
||||
return None;
|
||||
}
|
||||
match flightplanner_rad::parse(&path) {
|
||||
Ok(mut rad) => {
|
||||
let (f, o, c) = rad.dct_counts();
|
||||
// Official EUROCONTROL FRA points (separate file) for the FRA-points router.
|
||||
let fra_path = std::env::var("FP_FRA_POINTS")
|
||||
.unwrap_or_else(|_| r"C:\Users\Alexandre\flightplanner\rad\fra-points.xlsx".to_string());
|
||||
rad.fra_points = flightplanner_rad::parse_fra_points(&fra_path).unwrap_or_default();
|
||||
eprintln!(
|
||||
"RAD loaded: {} areas, {} DCT ({f} forbidden / {o} conditional / {c} compulsory), {} level caps, {} FRA points",
|
||||
rad.areas.len(),
|
||||
rad.dct.len(),
|
||||
rad.level_caps.len(),
|
||||
rad.fra_points.len(),
|
||||
);
|
||||
Some(rad)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("RAD: parse failed ({e}) — RAD checks disabled");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.manage(RadState(load_rad().map(Arc::new)))
|
||||
.invoke_handler(tauri::generate_handler![plan, recent_routes, route_db_count, ifps_validate, ifps_autofix, generate_validated])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// End-to-end smoke test of the shared `plan` engine against the real DB.
|
||||
/// Skips (passes) when the DB isn't present so the test stays portable.
|
||||
#[test]
|
||||
fn plan_command_end_to_end() {
|
||||
let db = "C:\\Users\\Alexandre\\flightplanner\\real.db";
|
||||
let dir = "C:\\Users\\Alexandre\\flightplanner\\data\\aircraft";
|
||||
if !std::path::Path::new(db).exists() {
|
||||
return;
|
||||
}
|
||||
// Load the RAD so the FRA graph router is exercised (as in the real app).
|
||||
let rad = load_rad();
|
||||
let res = api::plan(
|
||||
&PlanRequest {
|
||||
from: "LFPG".into(),
|
||||
to: "EGLL".into(),
|
||||
db_path: db.into(),
|
||||
aircraft: Some("A320".into()),
|
||||
aircraft_dir: dir.into(),
|
||||
cifp_dir: Some("C:\\Users\\Alexandre\\flightplanner\\navdata\\CIFP".into()),
|
||||
cruise_fl: Some(360),
|
||||
alternate: None,
|
||||
payload_kg: Some(16_000.0),
|
||||
},
|
||||
rad.as_ref(),
|
||||
)
|
||||
.expect("plan should succeed");
|
||||
|
||||
eprintln!("ROUTE: {}", res.route_string);
|
||||
assert!(!res.legs.is_empty(), "route should have legs");
|
||||
assert!(res.route_string.starts_with("LFPG"), "{}", res.route_string);
|
||||
assert!(res.ofp.contains("OPERATIONAL FLIGHT PLAN"), "OFP present");
|
||||
let fuel = res.fuel.expect("A320 ⇒ fuel plan");
|
||||
assert!(fuel.block_fuel_kg > fuel.trip_fuel_kg, "block > trip");
|
||||
let mass = fuel.mass.expect("OpenAP ⇒ mass breakdown");
|
||||
assert!(mass.takeoff_kg > mass.landing_kg, "TOW > LDW");
|
||||
let last = res.legs.last().unwrap();
|
||||
assert!((last.cum_dist_nm - res.total_nm).abs() < 1.0, "cum dist ends at total");
|
||||
}
|
||||
|
||||
/// Plan several European pairs at once (RAD loaded once) and print each
|
||||
/// route + the enroute item-15 (fixes joined by DCT) ready for the IFPUV
|
||||
/// oracle. Run: `cargo test -p flightplanner-gui plan_pairs -- --nocapture`.
|
||||
#[test]
|
||||
fn plan_pairs() {
|
||||
let db = "C:\\Users\\Alexandre\\flightplanner\\real.db";
|
||||
let dir = "C:\\Users\\Alexandre\\flightplanner\\data\\aircraft";
|
||||
let cifp = "C:\\Users\\Alexandre\\flightplanner\\navdata\\CIFP";
|
||||
if !std::path::Path::new(db).exists() {
|
||||
return;
|
||||
}
|
||||
let rad = load_rad();
|
||||
let pairs_env = std::env::var("FP_TEST_PAIRS")
|
||||
.unwrap_or_else(|_| "LFPG-EGLL,EGLL-LFPG,LFPG-EDDF,EHAM-LSZH,LFPG-LEMD,EDDM-LEBL".into());
|
||||
let pairs: Vec<(String, String)> = pairs_env
|
||||
.split(',')
|
||||
.filter_map(|p| p.split_once('-').map(|(a, b)| (a.trim().to_uppercase(), b.trim().to_uppercase())))
|
||||
.collect();
|
||||
for (from, to) in &pairs {
|
||||
let (from, to) = (from.as_str(), to.as_str());
|
||||
let res = api::plan(
|
||||
&PlanRequest {
|
||||
from: from.into(),
|
||||
to: to.into(),
|
||||
db_path: db.into(),
|
||||
aircraft: Some("A320".into()),
|
||||
aircraft_dir: dir.into(),
|
||||
cifp_dir: Some(cifp.into()),
|
||||
cruise_fl: Some(360),
|
||||
alternate: None,
|
||||
payload_kg: Some(16_000.0),
|
||||
},
|
||||
rad.as_ref(),
|
||||
);
|
||||
match res {
|
||||
Ok(r) => {
|
||||
// item 15 = enroute fix chain (each leg's `to` except the destination).
|
||||
let n = r.legs.len();
|
||||
let item15: Vec<String> =
|
||||
r.legs.iter().take(n.saturating_sub(1)).map(|l| l.to.clone()).collect();
|
||||
eprintln!("PAIR {from}->{to}\n ROUTE : {}\n ITEM15: {}", r.route_string, item15.join(" DCT "));
|
||||
}
|
||||
Err(e) => eprintln!("PAIR {from}->{to} ERROR: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch coverage: run discovery over several European pairs and report the
|
||||
/// no-error rate + which error codes block the failures. Ignored (very slow,
|
||||
/// many live IFPUV round-trips). Pairs via env `FP_BATCH_PAIRS` (comma-sep
|
||||
/// `LFPG-EGLL,...`). Run: `cargo test -p flightplanner-gui batch_coverage -- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore = "live IFPUV batch, many minutes"]
|
||||
fn batch_coverage() {
|
||||
let db = "C:\\Users\\Alexandre\\flightplanner\\real.db";
|
||||
let dir = "C:\\Users\\Alexandre\\flightplanner\\data\\aircraft";
|
||||
let cifp = "C:\\Users\\Alexandre\\flightplanner\\navdata\\CIFP";
|
||||
if !std::path::Path::new(db).exists() {
|
||||
return;
|
||||
}
|
||||
let rad = load_rad();
|
||||
let ifdir = std::env::var("FP_IFPUV_DIR")
|
||||
.unwrap_or_else(|_| "C:\\Users\\Alexandre\\flightplanner\\tools\\ifpuv".to_string());
|
||||
let validator = NodeIfps { dir: ifdir, app: None, step: std::sync::atomic::AtomicUsize::new(0) };
|
||||
let pairs_env = std::env::var("FP_BATCH_PAIRS").unwrap_or_else(|_| {
|
||||
"LFPG-EGLL,LFPG-LFMN,LFPG-EDDF,EHAM-LSZH,LEMD-LEBL,EDDM-LEBL".into()
|
||||
});
|
||||
let pairs: Vec<(String, String)> = pairs_env
|
||||
.split(',')
|
||||
.filter_map(|p| p.split_once('-').map(|(a, b)| (a.trim().to_uppercase(), b.trim().to_uppercase())))
|
||||
.collect();
|
||||
|
||||
let mut ok = 0usize;
|
||||
let mut hist: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
for (from, to) in &pairs {
|
||||
let req = PlanRequest {
|
||||
from: from.clone(),
|
||||
to: to.clone(),
|
||||
db_path: db.into(),
|
||||
aircraft: Some("A320".into()),
|
||||
aircraft_dir: dir.into(),
|
||||
cifp_dir: Some(cifp.into()),
|
||||
cruise_fl: Some(360),
|
||||
alternate: None,
|
||||
payload_kg: Some(16_000.0),
|
||||
};
|
||||
match api::discover_route(&req, rad.as_ref(), &validator) {
|
||||
Ok(r) => {
|
||||
let codes: Vec<&str> = r.errors.iter().map(|e| e.code.as_str()).collect();
|
||||
eprintln!(
|
||||
"BATCH {from}->{to}: {} F{:03} iters={} errs={} [{}]",
|
||||
if r.accepted { "OK " } else { "FAIL" },
|
||||
r.fl,
|
||||
r.iterations,
|
||||
r.errors.len(),
|
||||
codes.join(",")
|
||||
);
|
||||
if r.accepted {
|
||||
ok += 1;
|
||||
}
|
||||
for e in &r.errors {
|
||||
*hist.entry(e.code.clone()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("BATCH {from}->{to}: ERROR {e}"),
|
||||
}
|
||||
}
|
||||
let mut codes: Vec<(String, usize)> = hist.into_iter().collect();
|
||||
codes.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
eprintln!("BATCH COVERAGE: {ok}/{} no-error ({:.0}%)", pairs.len(), 100.0 * ok as f64 / pairs.len().max(1) as f64);
|
||||
eprintln!("BATCH blocking codes: {codes:?}");
|
||||
}
|
||||
|
||||
/// Live end-to-end: run the oracle discovery loop against the real IFPUV.
|
||||
/// Ignored by default (network + several ~25 s round-trips). Run with:
|
||||
/// `cargo test -p flightplanner-gui discover_e2e -- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore = "live IFPUV round-trips (minutes) + network"]
|
||||
fn discover_e2e() {
|
||||
let db = "C:\\Users\\Alexandre\\flightplanner\\real.db";
|
||||
let dir = "C:\\Users\\Alexandre\\flightplanner\\data\\aircraft";
|
||||
let cifp = "C:\\Users\\Alexandre\\flightplanner\\navdata\\CIFP";
|
||||
if !std::path::Path::new(db).exists() {
|
||||
return;
|
||||
}
|
||||
let rad = load_rad();
|
||||
let ifdir = std::env::var("FP_IFPUV_DIR")
|
||||
.unwrap_or_else(|_| "C:\\Users\\Alexandre\\flightplanner\\tools\\ifpuv".to_string());
|
||||
let validator = NodeIfps { dir: ifdir, app: None, step: std::sync::atomic::AtomicUsize::new(0) };
|
||||
let pairs_env = std::env::var("FP_TEST_PAIRS").unwrap_or_else(|_| "LFPG-EGLL,LFPG-LFMN".into());
|
||||
let pairs: Vec<(String, String)> = pairs_env
|
||||
.split(',')
|
||||
.filter_map(|p| p.split_once('-').map(|(a, b)| (a.trim().to_uppercase(), b.trim().to_uppercase())))
|
||||
.collect();
|
||||
for (from, to) in &pairs {
|
||||
let (from, to) = (from.as_str(), to.as_str());
|
||||
let req = PlanRequest {
|
||||
from: from.into(),
|
||||
to: to.into(),
|
||||
db_path: db.into(),
|
||||
aircraft: Some("A320".into()),
|
||||
aircraft_dir: dir.into(),
|
||||
cifp_dir: Some(cifp.into()),
|
||||
cruise_fl: Some(360),
|
||||
alternate: None,
|
||||
payload_kg: Some(16_000.0),
|
||||
};
|
||||
match api::discover_route(&req, rad.as_ref(), &validator) {
|
||||
Ok(r) => eprintln!(
|
||||
"== {from}->{to} accepted={} fl={} nm={:.0}\n route : {}\n item15: {}\n log:\n {}",
|
||||
r.accepted, r.fl, r.total_nm, r.route_string, r.item15, r.log.join("\n ")
|
||||
),
|
||||
Err(e) => eprintln!("== {from}->{to} ERROR: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
gui_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Flight Planner",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.flightplanner.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Flight Planner",
|
||||
"width": 1180,
|
||||
"height": 820,
|
||||
"minWidth": 900,
|
||||
"minHeight": 640,
|
||||
"decorations": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/* ── Modern dark dispatch dashboard — one deliberate design system ─────────── */
|
||||
:root {
|
||||
--bg: #0b0e14;
|
||||
--panel: #11151d;
|
||||
--panel-2: #161b25;
|
||||
--elev: #1b2230;
|
||||
--border: #232b39;
|
||||
--border-soft: #1a212c;
|
||||
--text: #e6edf3;
|
||||
--muted: #8a97a8;
|
||||
--faint: #5b6675;
|
||||
--accent: #4c8dff;
|
||||
--accent-2: #2f6bd6;
|
||||
--accent-weak: #14233d;
|
||||
--good: #40c463;
|
||||
--warn: #ff5c5c;
|
||||
--amber: #f0a92b;
|
||||
--radius: 8px;
|
||||
--radius-sm: 6px;
|
||||
--mono: "Cascadia Mono", "JetBrains Mono", Consolas, monospace;
|
||||
font-family: "Segoe UI Variable", "Segoe UI", Inter, system-ui, sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
color-scheme: dark;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { margin: 0; height: 100%; }
|
||||
body { background: var(--bg); overflow: hidden; }
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-thumb { background: #263041; border-radius: 6px; border: 2px solid var(--bg); }
|
||||
::-webkit-scrollbar-thumb:hover { background: #33405600; background: #334056; }
|
||||
|
||||
.app { height: 100vh; width: 100vw; display: flex; flex-direction: column; }
|
||||
|
||||
/* Title bar (borderless window) */
|
||||
.titlebar {
|
||||
height: 40px; flex: 0 0 40px; display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 8px 0 14px; background: var(--panel); border-bottom: 1px solid var(--border);
|
||||
user-select: none;
|
||||
}
|
||||
.tb-left { display: flex; align-items: center; gap: 10px; font-weight: 600; }
|
||||
.logo { color: var(--accent); font-size: 15px; }
|
||||
.tb-flight { color: var(--muted); font-weight: 500; font-family: var(--mono); font-size: 12px; }
|
||||
.win-controls { display: flex; gap: 2px; }
|
||||
.win-controls button {
|
||||
width: 34px; height: 28px; border: none; background: transparent; color: var(--muted);
|
||||
border-radius: var(--radius-sm); cursor: pointer; font-size: 13px; display: grid; place-items: center;
|
||||
}
|
||||
.win-controls button:hover { background: var(--elev); color: var(--text); }
|
||||
.win-controls button.close:hover { background: #b3202a; color: #fff; }
|
||||
|
||||
/* Body */
|
||||
.body { flex: 1; display: grid; grid-template-columns: 320px 1fr; min-height: 0; }
|
||||
.sidebar { background: var(--panel); border-right: 1px solid var(--border); overflow-y: auto; display: flex; flex-direction: column; }
|
||||
.main { display: flex; flex-direction: column; min-height: 0; }
|
||||
|
||||
/* Sidebar header (primary inputs + action) */
|
||||
.side-head { padding: 14px; border-bottom: 1px solid var(--border-soft); display: flex; flex-direction: column; gap: 10px; }
|
||||
.flightid { display: flex; align-items: center; gap: 8px; }
|
||||
.flightid input { text-align: center; font-family: var(--mono); font-size: 15px; letter-spacing: 1px; font-weight: 600; }
|
||||
.flightid .arrow { color: var(--faint); }
|
||||
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.row3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; }
|
||||
|
||||
/* Sections */
|
||||
.sections { padding: 6px 14px 16px; display: flex; flex-direction: column; }
|
||||
.section { padding: 12px 0; border-bottom: 1px solid var(--border-soft); }
|
||||
.section:last-child { border-bottom: none; }
|
||||
.section-title {
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.7px; color: var(--muted);
|
||||
margin: 0 0 10px; display: flex; align-items: center; gap: 7px;
|
||||
}
|
||||
.section-title::before { content: ""; width: 3px; height: 12px; background: var(--accent); border-radius: 2px; }
|
||||
|
||||
/* Fields */
|
||||
.field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.field + .field, .row2 + .field, .field + .row2, .row2 + .row2, .row3 + .field, .field + .row3 { margin-top: 8px; }
|
||||
.field label { font-size: 11px; color: var(--muted); }
|
||||
input, select {
|
||||
width: 100%; height: 30px; padding: 0 9px; background: var(--panel-2); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: var(--radius-sm); outline: none; font-family: inherit; font-size: 13px;
|
||||
transition: border-color .12s, box-shadow .12s;
|
||||
}
|
||||
input:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
||||
input::placeholder { color: var(--faint); }
|
||||
select { appearance: none; background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path d='M1 1l4 4 4-4' stroke='%238a97a8' stroke-width='1.5' fill='none' stroke-linecap='round'/></svg>"); background-repeat: no-repeat; background-position: right 9px center; padding-right: 26px; }
|
||||
|
||||
/* Buttons */
|
||||
.btn { height: 32px; padding: 0 14px; border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--elev); color: var(--text); cursor: pointer; font: inherit; font-weight: 500; display: inline-flex; align-items: center; gap: 7px; }
|
||||
.btn:hover { border-color: #33405a; background: #1f2735; }
|
||||
.btn:disabled { opacity: .45; cursor: default; }
|
||||
.btn-primary { border: none; background: linear-gradient(180deg, var(--accent), var(--accent-2)); color: #fff; font-weight: 600; width: 100%; height: 36px; justify-content: center; }
|
||||
.btn-primary:hover { filter: brightness(1.07); }
|
||||
.btn-primary:disabled { filter: none; opacity: .6; }
|
||||
.err { color: var(--warn); font-size: 12px; }
|
||||
|
||||
/* KPI strip */
|
||||
.kpis { display: flex; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--border); background: var(--panel); overflow-x: auto; }
|
||||
.kpi { min-width: 96px; background: var(--panel-2); border: 1px solid var(--border); border-radius: var(--radius); padding: 8px 12px; }
|
||||
.kpi .k { font-size: 10px; text-transform: uppercase; letter-spacing: .6px; color: var(--muted); }
|
||||
.kpi .v { font-size: 18px; font-weight: 700; margin-top: 2px; font-family: var(--mono); }
|
||||
.kpi .s { font-size: 11px; color: var(--faint); }
|
||||
.kpi.warn .v { color: var(--warn); }
|
||||
|
||||
/* Map */
|
||||
.map { flex: 1.5; min-height: 220px; position: relative; }
|
||||
.maproot { width: 100%; height: 100%; }
|
||||
.map .hint { position: absolute; top: 12px; left: 12px; z-index: 1; background: rgba(11,14,20,.75); border: 1px solid var(--border); padding: 5px 10px; border-radius: var(--radius-sm); color: var(--muted); font-size: 12px; pointer-events: none; }
|
||||
.maplibregl-ctrl-group { background: var(--panel) !important; border: 1px solid var(--border) !important; box-shadow: none !important; }
|
||||
.maplibregl-ctrl-group button + button { border-top: 1px solid var(--border) !important; }
|
||||
.maplibregl-ctrl-group button span { filter: invert(1) brightness(1.4); }
|
||||
|
||||
/* Dock */
|
||||
.dock { flex: 1; min-height: 0; display: flex; flex-direction: column; background: var(--panel); border-top: 1px solid var(--border); }
|
||||
.tabs { display: flex; gap: 2px; padding: 6px 10px 0; border-bottom: 1px solid var(--border); }
|
||||
.tab { padding: 8px 14px; border: none; background: transparent; color: var(--muted); cursor: pointer; font: inherit; font-size: 12.5px; border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.on { color: var(--text); border-bottom-color: var(--accent); font-weight: 600; }
|
||||
.dock-body { flex: 1; overflow: auto; padding: 14px; }
|
||||
.empty { color: var(--muted); text-align: center; padding: 40px 20px; }
|
||||
|
||||
/* Route string */
|
||||
.route-str { font-family: var(--mono); font-size: 12.5px; line-height: 1.7; color: #cfe0ff; background: var(--panel-2); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; word-break: break-word; margin-bottom: 12px; }
|
||||
.route-meta { color: var(--muted); font-size: 12px; margin-top: 6px; }
|
||||
|
||||
/* Tables */
|
||||
.tbl { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||
.tbl thead th { position: sticky; top: 0; background: var(--panel-2); text-align: left; padding: 8px 12px; color: var(--muted); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; border-bottom: 1px solid var(--border); }
|
||||
.tbl td { padding: 7px 12px; border-bottom: 1px solid var(--border-soft); }
|
||||
.tbl tbody tr:hover { background: var(--panel-2); }
|
||||
.tbl .mono { font-family: var(--mono); }
|
||||
.tbl .num { text-align: right; font-family: var(--mono); }
|
||||
.tbl .via { color: var(--accent); font-weight: 600; }
|
||||
.card { background: var(--panel-2); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
||||
.cardtitle { font-size: 11px; text-transform: uppercase; letter-spacing: .6px; color: var(--muted); padding: 10px 12px; border-bottom: 1px solid var(--border); }
|
||||
.grid3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; align-items: start; }
|
||||
.kvrow { display: flex; justify-content: space-between; padding: 6px 12px; border-bottom: 1px solid var(--border-soft); }
|
||||
.kvrow:last-child { border-bottom: none; }
|
||||
.kvrow.total { border-top: 1px solid var(--border); font-weight: 700; }
|
||||
.kvrow .val { font-family: var(--mono); }
|
||||
.ofp-pre { font-family: var(--mono); font-size: 11.5px; white-space: pre; overflow-x: auto; margin: 0; color: #cdd9e5; }
|
||||
.badge-warn { color: var(--warn); font-size: 11px; margin-left: 6px; }
|
||||
|
||||
/* Status bar */
|
||||
.status { height: 26px; flex: 0 0 26px; display: flex; align-items: center; gap: 18px; padding: 0 14px; background: var(--panel); border-top: 1px solid var(--border); color: var(--muted); font-size: 11.5px; }
|
||||
.status .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--good); display: inline-block; margin-right: 6px; }
|
||||
.status .sp { flex: 1; }
|
||||
|
||||
/* ── Ribbon (PFPX-style disposition, modern skin) ─────────────────────────── */
|
||||
.ribbon-tabs { display: flex; gap: 2px; padding: 4px 10px 0; background: var(--panel); }
|
||||
.ribbon-tab { padding: 6px 16px; border: none; background: transparent; color: var(--muted); cursor: pointer; font: inherit; font-size: 12.5px; border-radius: 6px 6px 0 0; }
|
||||
.ribbon-tab:hover { color: var(--text); background: var(--panel-2); }
|
||||
.ribbon-tab.on { color: var(--text); background: var(--elev); font-weight: 600; }
|
||||
.ribbon { display: flex; align-items: stretch; gap: 0; padding: 8px 10px; background: var(--elev); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); overflow-x: auto; }
|
||||
.rgroup { display: flex; flex-direction: column; align-items: center; padding: 0 10px; gap: 4px; }
|
||||
.rgroup-btns { display: flex; flex-wrap: wrap; gap: 3px; justify-content: center; max-width: 190px; }
|
||||
.rgroup-cap { font-size: 10px; color: var(--faint); }
|
||||
.rbtn { display: inline-flex; align-items: center; gap: 5px; height: 26px; padding: 0 8px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font: inherit; font-size: 11.5px; cursor: pointer; white-space: nowrap; }
|
||||
.rbtn:hover { border-color: #33405a; background: #1f2735; }
|
||||
.rbtn:disabled { opacity: .4; cursor: default; }
|
||||
.rbtn.accent { border-color: var(--accent-2); background: var(--accent-weak); color: #dce9ff; }
|
||||
.rbtn .g { color: var(--accent); font-size: 12px; }
|
||||
.rbtn.on { border-color: var(--accent); color: #dce9ff; background: var(--accent-weak); }
|
||||
.rsep { width: 1px; background: var(--border); margin: 2px 6px; }
|
||||
|
||||
/* Menubar (tiny top toolbar) */
|
||||
.menubar { height: 30px; flex: 0 0 30px; display: flex; align-items: center; gap: 4px; padding: 0 10px; background: var(--panel); border-bottom: 1px solid var(--border-soft); }
|
||||
.iconbtn { width: 26px; height: 22px; border: 1px solid transparent; background: transparent; color: var(--muted); border-radius: 5px; cursor: pointer; display: grid; place-items: center; font-size: 13px; }
|
||||
.iconbtn:hover { background: var(--elev); color: var(--text); border-color: var(--border); }
|
||||
|
||||
/* Flight info bar */
|
||||
.flightbar { display: flex; align-items: center; gap: 10px; padding: 8px 14px; background: var(--panel); border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
||||
.flightbar .tag { font-family: var(--mono); font-weight: 700; }
|
||||
.flightbar .apt { color: var(--muted); }
|
||||
.flightbar .apt b { color: var(--text); font-family: var(--mono); }
|
||||
.flightbar input { width: 74px; text-align: center; font-family: var(--mono); font-weight: 600; }
|
||||
.flightbar .sp { flex: 1; }
|
||||
|
||||
/* Alternates mini table */
|
||||
.alt-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 6px; }
|
||||
.alt-table th { text-align: left; color: var(--muted); font-weight: 600; font-size: 10.5px; text-transform: uppercase; padding: 3px 6px; border-bottom: 1px solid var(--border); }
|
||||
.alt-table td { padding: 2px 4px; }
|
||||
.alt-table input, .alt-table select { height: 26px; font-size: 12px; }
|
||||
|
||||
/* Map area (right) */
|
||||
.maparea { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
||||
.map-toolbar { display: flex; align-items: center; gap: 3px; padding: 5px 8px; background: var(--panel); border-bottom: 1px solid var(--border); }
|
||||
.map-tabs { display: flex; gap: 2px; padding: 4px 8px; background: var(--panel); border-top: 1px solid var(--border); }
|
||||
.map-tab { padding: 5px 12px; border: none; background: transparent; color: var(--muted); cursor: pointer; font: inherit; font-size: 12px; border-radius: 5px; }
|
||||
.map-tab:hover { color: var(--text); }
|
||||
.map-tab.on { color: #dce9ff; background: var(--accent-weak); }
|
||||
|
||||
/* Bottom output dock (full width) */
|
||||
.output { flex: 0 0 var(--dockh, 210px); display: flex; flex-direction: column; background: var(--panel); border-top: 1px solid var(--border); min-height: 0; }
|
||||
|
||||
/* Settings modal */
|
||||
.overlay { position: fixed; inset: 0; background: rgba(2,4,8,.6); display: grid; place-items: center; z-index: 50; }
|
||||
.modal { width: 760px; max-width: 92vw; height: 520px; max-height: 88vh; background: var(--panel); border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 24px 60px rgba(0,0,0,.5); display: flex; flex-direction: column; overflow: hidden; }
|
||||
.modal-head { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 600; }
|
||||
.modal-main { flex: 1; display: grid; grid-template-columns: 190px 1fr; min-height: 0; }
|
||||
.modal-cats { border-right: 1px solid var(--border); padding: 8px; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
|
||||
.modal-cat { text-align: left; padding: 8px 12px; border: none; background: transparent; color: var(--muted); border-radius: 6px; cursor: pointer; font: inherit; }
|
||||
.modal-cat:hover { background: var(--panel-2); color: var(--text); }
|
||||
.modal-cat.on { background: var(--accent-weak); color: #dce9ff; }
|
||||
.modal-body { padding: 16px 20px; overflow-y: auto; }
|
||||
.modal-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); }
|
||||
.set-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 0; border-bottom: 1px solid var(--border-soft); }
|
||||
.set-row .lbl { font-size: 13px; }
|
||||
.set-row .desc { font-size: 11px; color: var(--muted); margin-top: 2px; }
|
||||
.set-row .ctl { flex: 0 0 auto; }
|
||||
.set-row select, .set-row input[type="text"], .set-row input[type="number"] { width: 180px; }
|
||||
.switch { width: 40px; height: 22px; border-radius: 12px; background: var(--border); border: none; cursor: pointer; position: relative; transition: background .15s; }
|
||||
.switch.on { background: var(--accent); }
|
||||
.switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: transform .15s; }
|
||||
.switch.on::after { transform: translateX(18px); }
|
||||
.section-sub { font-size: 11px; text-transform: uppercase; letter-spacing: .6px; color: var(--faint); margin: 14px 0 4px; }
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@@ -0,0 +1,756 @@
|
||||
import { useState, useRef, useEffect, type ReactNode } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import * as maplibregl from "maplibre-gl";
|
||||
|
||||
const appWindow = getCurrentWindow();
|
||||
|
||||
// ── Result DTOs ────────────────────────────────────────────────────────────
|
||||
interface Phase { dist_nm: number; time_min: number; fuel_kg: number }
|
||||
interface Mass { payload_kg: number; zfw_kg: number; takeoff_kg: number; landing_kg: number; over_mtow: boolean; over_mlw: boolean }
|
||||
interface Fuel {
|
||||
aircraft: string; cruise_fl: number; climb: Phase; cruise: Phase; descent: Phase;
|
||||
trip_fuel_kg: number; trip_time_min: number; taxi_kg: number; contingency_kg: number;
|
||||
alternate_kg: number; final_reserve_kg: number; block_fuel_kg: number; mass: Mass | null;
|
||||
}
|
||||
interface Leg { from: string; via: string; to: string; dist_nm: number; cum_dist_nm: number; time_min: number | null; cum_time_min: number | null; fuel_kg: number | null; cum_fuel_kg: number | null }
|
||||
interface Point { ident: string; lat: number; lon: number }
|
||||
interface ProcTrack { name: string; kind: string; points: Point[] }
|
||||
interface Ifps { accepted: boolean; errors: string[]; warnings: string[] }
|
||||
interface IfpsErr { code: string; msg: string }
|
||||
interface IfpsCheck { accepted: boolean | null; errors: IfpsErr[]; raw?: string; fpl?: string; error?: string | null }
|
||||
interface IfpsFix { accepted: boolean; level: string | null; route: string | null; iterations: number; log: string[]; errors: IfpsErr[]; error?: string | null }
|
||||
interface Discover { accepted: boolean; route_string: string; item15: string; fl: number; total_nm: number; errors: IfpsErr[]; iterations: number; log: string[]; error?: string | null }
|
||||
interface CachedRoute { dep: string; dest: string; cruise_fl: number; route_string: string; dist_nm: number; ifps_ok: boolean; source: string; generated_at: string }
|
||||
interface PlanResult { route_string: string; via_airways: boolean; total_nm: number; cruise_fl: number | null; legs: Leg[]; fuel: Fuel | null; geometry: Point[]; procedures: ProcTrack[]; ofp: string; ifps: Ifps }
|
||||
|
||||
const DEFAULT_DB = "C:\\Users\\Alexandre\\flightplanner\\real.db";
|
||||
const DEFAULT_AIRCRAFT_DIR = "C:\\Users\\Alexandre\\flightplanner\\data\\aircraft";
|
||||
const DEFAULT_CIFP = "C:\\Users\\Alexandre\\flightplanner\\navdata\\CIFP";
|
||||
const kg = (n: number) => Math.round(n).toLocaleString("en-US");
|
||||
const nm = (n: number) => `${Math.round(n)}`;
|
||||
const hm = (min: number) => `${Math.floor(Math.round(min) / 60)}:${String(Math.round(min) % 60).padStart(2, "0")}`;
|
||||
const flText = (fl: number | null) => (fl != null ? `FL${String(fl).padStart(3, "0")}` : "—");
|
||||
|
||||
// ── Dark offline world map with a glowing route ────────────────────────────
|
||||
const C = { sea: "#0b0e14", land: "#161d29", border: "#263143", route: "#4c8dff", label: "#cdd9e5", labelBg: "rgba(11,14,20,.82)" };
|
||||
function RouteMap({ points, procedures }: { points: Point[]; procedures: ProcTrack[] }) {
|
||||
const container = useRef<HTMLDivElement | null>(null);
|
||||
const map = useRef<maplibregl.Map | null>(null);
|
||||
const markers = useRef<maplibregl.Marker[]>([]);
|
||||
const [ready, setReady] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!container.current || map.current) return;
|
||||
const style: maplibregl.StyleSpecification = { version: 8, sources: {}, layers: [{ id: "bg", type: "background", paint: { "background-color": C.sea } }] };
|
||||
const m = new maplibregl.Map({ container: container.current, style, center: [5, 46], zoom: 3, attributionControl: false });
|
||||
m.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
|
||||
m.on("load", async () => {
|
||||
try {
|
||||
const gj = await (await fetch("/world.geojson")).json();
|
||||
m.addSource("world", { type: "geojson", data: gj });
|
||||
m.addLayer({ id: "land", type: "fill", source: "world", paint: { "fill-color": C.land } });
|
||||
m.addLayer({ id: "border", type: "line", source: "world", paint: { "line-color": C.border, "line-width": 0.7 } });
|
||||
} catch { /* offline */ }
|
||||
setReady(true);
|
||||
});
|
||||
map.current = m;
|
||||
return () => { m.remove(); map.current = null; };
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const m = map.current;
|
||||
if (!m || !ready) return;
|
||||
const coords = points.map((p) => [p.lon, p.lat] as [number, number]);
|
||||
const line = { type: "Feature" as const, properties: {}, geometry: { type: "LineString" as const, coordinates: coords } };
|
||||
const pts = { type: "FeatureCollection" as const, features: points.map((p) => ({ type: "Feature" as const, properties: {}, geometry: { type: "Point" as const, coordinates: [p.lon, p.lat] as [number, number] } })) };
|
||||
const src = m.getSource("route") as maplibregl.GeoJSONSource | undefined;
|
||||
if (src) { src.setData(line); (m.getSource("wpts") as maplibregl.GeoJSONSource).setData(pts); }
|
||||
else {
|
||||
m.addSource("route", { type: "geojson", data: line });
|
||||
m.addLayer({ id: "route-glow", type: "line", source: "route", paint: { "line-color": C.route, "line-width": 9, "line-blur": 9, "line-opacity": 0.35 }, layout: { "line-cap": "round", "line-join": "round" } });
|
||||
m.addLayer({ id: "route-line", type: "line", source: "route", paint: { "line-color": C.route, "line-width": 2.4 }, layout: { "line-cap": "round", "line-join": "round" } });
|
||||
m.addSource("wpts", { type: "geojson", data: pts });
|
||||
m.addLayer({ id: "wpts-dot", type: "circle", source: "wpts", paint: { "circle-radius": 3.4, "circle-color": C.sea, "circle-stroke-color": C.route, "circle-stroke-width": 1.6 } });
|
||||
}
|
||||
markers.current.forEach((mk) => mk.remove());
|
||||
markers.current = points.map((p) => {
|
||||
const el = document.createElement("div");
|
||||
el.textContent = p.ident;
|
||||
el.style.cssText = `font:600 10px/1 'Segoe UI',sans-serif;color:${C.label};background:${C.labelBg};padding:1px 4px;border-radius:3px;white-space:nowrap;transform:translateY(-9px);`;
|
||||
return new maplibregl.Marker({ element: el, anchor: "bottom" }).setLngLat([p.lon, p.lat]).addTo(m);
|
||||
});
|
||||
// SID/STAR procedure tracks (amber, dashed).
|
||||
const procFc = { type: "FeatureCollection" as const, features: procedures.map((pr) => ({ type: "Feature" as const, properties: {}, geometry: { type: "LineString" as const, coordinates: pr.points.map((p) => [p.lon, p.lat] as [number, number]) } })) };
|
||||
const procDots = { type: "FeatureCollection" as const, features: procedures.flatMap((pr) => pr.points.map((p) => ({ type: "Feature" as const, properties: {}, geometry: { type: "Point" as const, coordinates: [p.lon, p.lat] as [number, number] } }))) };
|
||||
const procSrc = m.getSource("proc") as maplibregl.GeoJSONSource | undefined;
|
||||
if (procSrc) { procSrc.setData(procFc); (m.getSource("proc-pts") as maplibregl.GeoJSONSource).setData(procDots); }
|
||||
else {
|
||||
m.addSource("proc", { type: "geojson", data: procFc });
|
||||
m.addLayer({ id: "proc-line", type: "line", source: "proc", paint: { "line-color": "#f0a92b", "line-width": 2, "line-dasharray": [2, 1.6] }, layout: { "line-cap": "round", "line-join": "round" } });
|
||||
m.addSource("proc-pts", { type: "geojson", data: procDots });
|
||||
m.addLayer({ id: "proc-dot", type: "circle", source: "proc-pts", paint: { "circle-radius": 2.2, "circle-color": "#f0a92b" } });
|
||||
}
|
||||
if (coords.length > 0) { const b = new maplibregl.LngLatBounds(coords[0], coords[0]); coords.forEach((co) => b.extend(co)); procedures.forEach((pr) => pr.points.forEach((p) => b.extend([p.lon, p.lat]))); m.fitBounds(b, { padding: 70, maxZoom: 7, duration: 700 }); }
|
||||
}, [points, procedures, ready]);
|
||||
return <div className="maproot" ref={container} />;
|
||||
}
|
||||
|
||||
// ── Small building blocks ──────────────────────────────────────────────────
|
||||
function Field(props: { label: string; children: ReactNode }) { return <div className="field"><label>{props.label}</label>{props.children}</div>; }
|
||||
function RB(props: { g: string; label: string; onClick?: () => void; disabled?: boolean; on?: boolean; accent?: boolean }) {
|
||||
return <button className={`rbtn${props.on ? " on" : ""}${props.accent ? " accent" : ""}`} onClick={props.onClick} disabled={props.disabled}><span className="g">{props.g}</span>{props.label}</button>;
|
||||
}
|
||||
function Grp(props: { cap: string; children: ReactNode }) { return <div className="rgroup"><div className="rgroup-btns">{props.children}</div><div className="rgroup-cap">{props.cap}</div></div>; }
|
||||
function Sw(props: { on: boolean; onClick: () => void }) { return <button className={`switch${props.on ? " on" : ""}`} onClick={props.onClick} />; }
|
||||
|
||||
// ── Settings modal (many cosmetic options for a real preview) ──────────────
|
||||
const SET_CATS = ["Mode", "Backend", "General", "Units", "Weather", "Navigation data", "Simulator export", "Fuel policy", "Map", "About"];
|
||||
function Settings(props: { onClose: () => void; backend: string; setBackend: (v: string) => void; url: string; setUrl: (v: string) => void; mode: string; setMode: (v: string) => void }) {
|
||||
const { onClose, backend, setBackend, url, setUrl, mode, setMode } = props;
|
||||
const [cat, setCat] = useState("Mode");
|
||||
const [testStatus, setTestStatus] = useState("");
|
||||
async function testConn() {
|
||||
setTestStatus("checking…");
|
||||
try {
|
||||
const r = await fetch(url.replace(/\/$/, "") + "/health");
|
||||
setTestStatus(r.ok ? "✓ online" : `✗ error ${r.status}`);
|
||||
} catch { setTestStatus("✗ unreachable"); }
|
||||
}
|
||||
const [sw, setSw] = useState<Record<string, boolean>>({
|
||||
autosave: true, checkUpdates: true, confirmExit: false, sounds: false,
|
||||
dlWeather: true, windUplink: true, turbulence: false, notams: true,
|
||||
autoAirac: true, validateIfps: true, prefRoutes: true,
|
||||
etops: false, tankering: false, redispatch: false,
|
||||
airways: true, fixes: false, firs: true, tracks: true, terrain: false,
|
||||
});
|
||||
const t = (k: string) => setSw((s) => ({ ...s, [k]: !s[k] }));
|
||||
const Row = (p: { lbl: string; desc?: string; children: ReactNode }) => (
|
||||
<div className="set-row"><div><div className="lbl">{p.lbl}</div>{p.desc ? <div className="desc">{p.desc}</div> : null}</div><div className="ctl">{p.children}</div></div>
|
||||
);
|
||||
return (
|
||||
<div className="overlay" onMouseDown={onClose}>
|
||||
<div className="modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="modal-head"><span>⚙ Settings</span><button className="iconbtn" onClick={onClose}>✕</button></div>
|
||||
<div className="modal-main">
|
||||
<div className="modal-cats">{SET_CATS.map((c) => <button key={c} className={`modal-cat${cat === c ? " on" : ""}`} onClick={() => setCat(c)}>{c}</button>)}</div>
|
||||
<div className="modal-body">
|
||||
{cat === "Mode" && (<>
|
||||
<div className="section-sub">Operating mode</div>
|
||||
<Row lbl="Mode" desc="Free Flight = one-off flights (SimBrief-style). Career = run your airline: fleet, registrations, roster.">
|
||||
<select value={mode} onChange={(e) => setMode(e.target.value)}>
|
||||
<option value="freeflight">Free Flight</option>
|
||||
<option value="career">Career</option>
|
||||
</select>
|
||||
</Row>
|
||||
{mode === "career" ? (
|
||||
<div className="desc" style={{ marginTop: 10 }}>
|
||||
<b>Career</b> — manage a virtual airline: operator/ICAO callsign, a fleet of aircraft with individual registrations, and scheduled flights. Company data can live on the shared server (multi-tenant, for VAs). <i>Fleet & company management is being built.</i>
|
||||
</div>
|
||||
) : (
|
||||
<div className="desc" style={{ marginTop: 10 }}>
|
||||
<b>Free Flight</b> — plan any city pair on demand, generate the OFP, no persistent company. Switch to Career to manage a fleet and airline.
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
{cat === "Backend" && (<>
|
||||
<div className="section-sub">Compute backend</div>
|
||||
<Row lbl="Mode" desc="Local runs everything offline on this machine. Custom server calls a shared backend over HTTP.">
|
||||
<select value={backend} onChange={(e) => setBackend(e.target.value)}>
|
||||
<option value="local">Local (offline)</option>
|
||||
<option value="server">Custom server</option>
|
||||
</select>
|
||||
</Row>
|
||||
<Row lbl="Server URL"><input type="text" value={url} onChange={(e) => setUrl(e.target.value)} disabled={backend !== "server"} /></Row>
|
||||
<Row lbl="Connection">
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<button className="btn" style={{ height: 28 }} disabled={backend !== "server"} onClick={testConn}>Test</button>
|
||||
<span style={{ fontSize: 12, color: "var(--muted)" }}>{testStatus}</span>
|
||||
</div>
|
||||
</Row>
|
||||
<div className="desc" style={{ marginTop: 10 }}>Server mode calls <b>POST /plan</b> on the URL above. Run it with <span className="mono">flightplanner-server</span> (env <span className="mono">FP_DB</span>, <span className="mono">FP_BIND</span>). The engine (routing/perf) is identical to local.</div>
|
||||
</>)}
|
||||
{cat === "General" && (<>
|
||||
<Row lbl="Auto-save flights" desc="Keep a working copy of the current flight"><Sw on={sw.autosave} onClick={() => t("autosave")} /></Row>
|
||||
<Row lbl="Check for updates on start"><Sw on={sw.checkUpdates} onClick={() => t("checkUpdates")} /></Row>
|
||||
<Row lbl="Confirm before exit"><Sw on={sw.confirmExit} onClick={() => t("confirmExit")} /></Row>
|
||||
<Row lbl="Interface sounds"><Sw on={sw.sounds} onClick={() => t("sounds")} /></Row>
|
||||
<div className="section-sub">Startup</div>
|
||||
<Row lbl="Default view"><select defaultValue="Flight"><option>Schedule</option><option>Flight</option><option>Traffic</option></select></Row>
|
||||
<Row lbl="Operator / airline"><input type="text" defaultValue="AIR FRANCE" /></Row>
|
||||
</>)}
|
||||
{cat === "Units" && (<>
|
||||
<Row lbl="Weight"><select defaultValue="kg"><option>kg</option><option>lb</option></select></Row>
|
||||
<Row lbl="Distance"><select defaultValue="NM"><option>NM</option><option>km</option></select></Row>
|
||||
<Row lbl="Altitude"><select defaultValue="ft"><option>ft</option><option>m</option></select></Row>
|
||||
<Row lbl="Speed"><select defaultValue="kt"><option>kt</option><option>km/h</option><option>Mach</option></select></Row>
|
||||
<Row lbl="Temperature"><select defaultValue="°C"><option>°C</option><option>°F</option></select></Row>
|
||||
<Row lbl="Coordinates"><select defaultValue="DMS"><option>DMS</option><option>Decimal</option></select></Row>
|
||||
</>)}
|
||||
{cat === "Weather" && (<>
|
||||
<Row lbl="Download winds & temps (GRIB)" desc="GFS global forecast, offline once cached"><Sw on={sw.dlWeather} onClick={() => t("dlWeather")} /></Row>
|
||||
<Row lbl="Wind uplink to simulator"><Sw on={sw.windUplink} onClick={() => t("windUplink")} /></Row>
|
||||
<Row lbl="Turbulence overlay"><Sw on={sw.turbulence} onClick={() => t("turbulence")} /></Row>
|
||||
<Row lbl="METAR / TAF & NOTAMs"><Sw on={sw.notams} onClick={() => t("notams")} /></Row>
|
||||
<Row lbl="Forecast model"><select defaultValue="GFS 0.25°"><option>GFS 0.25°</option><option>GFS 0.5°</option><option>ECMWF</option></select></Row>
|
||||
<Row lbl="Refresh interval (min)"><input type="number" defaultValue={30} /></Row>
|
||||
</>)}
|
||||
{cat === "Navigation data" && (<>
|
||||
<Row lbl="Auto-update AIRAC cycle"><Sw on={sw.autoAirac} onClick={() => t("autoAirac")} /></Row>
|
||||
<Row lbl="IFPS validation (Eurocontrol)"><Sw on={sw.validateIfps} onClick={() => t("validateIfps")} /></Row>
|
||||
<Row lbl="Use preferred / RAD routes"><Sw on={sw.prefRoutes} onClick={() => t("prefRoutes")} /></Row>
|
||||
<Row lbl="Active cycle"><input type="text" defaultValue="AIRAC 2608" /></Row>
|
||||
<Row lbl="Provider"><select defaultValue="Navigraph"><option>Navigraph</option><option>Custom</option></select></Row>
|
||||
</>)}
|
||||
{cat === "Simulator export" && (<>
|
||||
<Row lbl="MSFS / Prepar3D (.pln)"><Sw on={true} onClick={() => {}} /></Row>
|
||||
<Row lbl="X-Plane (.fms)"><Sw on={true} onClick={() => {}} /></Row>
|
||||
<Row lbl="PMDG (.rte)"><Sw on={false} onClick={() => {}} /></Row>
|
||||
<Row lbl="Export folder"><input type="text" defaultValue="C:\\Users\\Alexandre\\Documents\\Flights" /></Row>
|
||||
</>)}
|
||||
{cat === "Fuel policy" && (<>
|
||||
<Row lbl="Policy"><select defaultValue="ICAO"><option>ICAO</option><option>Company</option><option>Minimum</option></select></Row>
|
||||
<Row lbl="Contingency (%)"><input type="number" defaultValue={5} /></Row>
|
||||
<Row lbl="Final reserve (min)"><input type="number" defaultValue={30} /></Row>
|
||||
<Row lbl="Taxi fuel (kg)"><input type="number" defaultValue={200} /></Row>
|
||||
<Row lbl="Tankering optimisation"><Sw on={sw.tankering} onClick={() => t("tankering")} /></Row>
|
||||
<Row lbl="ETOPS planning"><Sw on={sw.etops} onClick={() => t("etops")} /></Row>
|
||||
<Row lbl="Redispatch"><Sw on={sw.redispatch} onClick={() => t("redispatch")} /></Row>
|
||||
</>)}
|
||||
{cat === "Map" && (<>
|
||||
<Row lbl="Airways"><Sw on={sw.airways} onClick={() => t("airways")} /></Row>
|
||||
<Row lbl="Fixes & navaids"><Sw on={sw.fixes} onClick={() => t("fixes")} /></Row>
|
||||
<Row lbl="FIR boundaries"><Sw on={sw.firs} onClick={() => t("firs")} /></Row>
|
||||
<Row lbl="Oceanic tracks (NAT/PACOTS)"><Sw on={sw.tracks} onClick={() => t("tracks")} /></Row>
|
||||
<Row lbl="Terrain shading"><Sw on={sw.terrain} onClick={() => t("terrain")} /></Row>
|
||||
<Row lbl="Projection"><select defaultValue="Mercator"><option>Mercator</option><option>Globe</option></select></Row>
|
||||
</>)}
|
||||
{cat === "About" && (<>
|
||||
<div className="lbl">Flight Planner</div>
|
||||
<div className="desc">Offline flight planning · Rust core + OpenAP performance · Navigraph AIRAC 2608</div>
|
||||
<div className="section-sub">Version</div><div className="desc">0.1.0 (dev)</div>
|
||||
<div className="section-sub">Engine</div><div className="desc">Routing A* · WGS84 geodesic · OpenAP fuel model</div>
|
||||
</>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-foot"><button className="btn" onClick={onClose}>Cancel</button><button className="btn-primary" style={{ width: "auto", padding: "0 18px", height: 32 }} onClick={onClose}>Save</button></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
// real (wired) inputs
|
||||
const [from, setFrom] = useState("LFPG");
|
||||
const [to, setTo] = useState("EGLL");
|
||||
const [type, setType] = useState("A320");
|
||||
const [cruiseFl, setCruiseFl] = useState("");
|
||||
const [alternate, setAlternate] = useState("EGKK");
|
||||
const [adults, setAdults] = useState("150");
|
||||
const [children, setChildren] = useState("8");
|
||||
const [infants, setInfants] = useState("2");
|
||||
const [cargo, setCargo] = useState("2000");
|
||||
const [dbPath] = useState(DEFAULT_DB);
|
||||
const [aircraftDir] = useState(DEFAULT_AIRCRAFT_DIR);
|
||||
|
||||
const [result, setResult] = useState<PlanResult | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [ifpsCheck, setIfpsCheck] = useState<IfpsCheck | null>(null);
|
||||
const [ifpsLoading, setIfpsLoading] = useState(false);
|
||||
const [ifpsFix, setIfpsFix] = useState<IfpsFix | null>(null);
|
||||
const [fixLoading, setFixLoading] = useState(false);
|
||||
const [discover, setDiscover] = useState<Discover | null>(null);
|
||||
const [discoverLoading, setDiscoverLoading] = useState(false);
|
||||
const [dbRoutes, setDbRoutes] = useState<CachedRoute[]>([]);
|
||||
const [progress, setProgress] = useState<{ step: number; route: string; fl: number } | null>(null);
|
||||
useEffect(() => {
|
||||
const un = listen<{ step: number; route: string; fl: number }>("discover-progress", (e) => setProgress(e.payload));
|
||||
return () => { un.then((f) => f()); };
|
||||
}, []);
|
||||
const [ribbonTab, setRibbonTab] = useState("Flight");
|
||||
const [mapTab, setMapTab] = useState("map");
|
||||
const [outTab, setOutTab] = useState("navlog");
|
||||
const [settings, setSettings] = useState(false);
|
||||
const [payloadPreset, setPayloadPreset] = useState("min");
|
||||
const [clock, setClock] = useState("");
|
||||
const [backend, setBackend] = useState(() => localStorage.getItem("fp_backend") || "local");
|
||||
const [mode, setMode] = useState(() => localStorage.getItem("fp_mode") || "freeflight");
|
||||
const [serverUrl, setServerUrl] = useState(() => localStorage.getItem("fp_server_url") || "http://127.0.0.1:8787");
|
||||
const [routeCount, setRouteCount] = useState<number | null>(null);
|
||||
useEffect(() => { localStorage.setItem("fp_backend", backend); }, [backend]);
|
||||
useEffect(() => { localStorage.setItem("fp_mode", mode); }, [mode]);
|
||||
useEffect(() => { localStorage.setItem("fp_server_url", serverUrl); }, [serverUrl]);
|
||||
|
||||
async function refreshCount() {
|
||||
try {
|
||||
if (backend === "server") {
|
||||
const s = await fetch(serverUrl.replace(/\/$/, "") + "/stats").then((r) => r.json());
|
||||
setRouteCount(s.routes);
|
||||
} else {
|
||||
setRouteCount(await invoke<number>("route_db_count", { dbPath }));
|
||||
}
|
||||
} catch { setRouteCount(null); }
|
||||
}
|
||||
|
||||
// Stored routes for the current city pair (our own IFPS route DB).
|
||||
async function refreshDbRoutes() {
|
||||
const dep = from.trim().toUpperCase();
|
||||
const dest = to.trim().toUpperCase();
|
||||
if (!dep || !dest) { setDbRoutes([]); return; }
|
||||
try {
|
||||
if (backend === "server") {
|
||||
const url = serverUrl.replace(/\/$/, "") + `/routes?dep=${dep}&dest=${dest}`;
|
||||
setDbRoutes(await fetch(url).then((r) => r.json()));
|
||||
} else {
|
||||
setDbRoutes(await invoke<CachedRoute[]>("recent_routes", { dbPath, dep, dest }));
|
||||
}
|
||||
} catch { setDbRoutes([]); }
|
||||
}
|
||||
useEffect(() => { refreshCount(); }, [backend]);
|
||||
|
||||
useEffect(() => {
|
||||
const tick = () => setClock(new Date().toUTCString().slice(17, 22));
|
||||
tick(); const id = setInterval(tick, 30000); return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
async function compute() {
|
||||
setError("");
|
||||
if (!from.trim() || !to.trim()) { setError("Departure and destination are required."); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
const pax = Number(adults || 0) * 100 + Number(children || 0) * 50 + Number(infants || 0) * 15;
|
||||
const pl = pax + Number(cargo || 0);
|
||||
const req = { from: from.trim(), to: to.trim(), db_path: dbPath, aircraft: type || null, aircraft_dir: aircraftDir, cifp_dir: DEFAULT_CIFP, cruise_fl: cruiseFl.trim() === "" ? null : Number(cruiseFl), alternate: alternate.trim() || null, payload_kg: pl > 0 ? pl : null };
|
||||
if (backend === "server") {
|
||||
const res = await fetch(serverUrl.replace(/\/$/, "") + "/plan", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(req) });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
setResult(await res.json());
|
||||
} else {
|
||||
setResult(await invoke<PlanResult>("plan", { req }));
|
||||
}
|
||||
setOutTab("navlog");
|
||||
setIfpsCheck(null);
|
||||
setIfpsFix(null);
|
||||
setDiscover(null);
|
||||
refreshCount(); refreshDbRoutes();
|
||||
} catch (e) { setError(typeof e === "string" ? e : String(e)); setResult(null); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
function reset() { setResult(null); setError(""); setIfpsCheck(null); setIfpsFix(null); setDiscover(null); setDbRoutes([]); }
|
||||
|
||||
// Build the IFPUV payload from the current plan (item 15 = route without ADEP/ADES).
|
||||
function ifpsPayload() {
|
||||
if (!result) return null;
|
||||
const toks = result.route_string.trim().split(/\s+/);
|
||||
const mid = toks.slice(1, -1);
|
||||
// Drop leading SID / trailing STAR *designators* (e.g. OPAL6A, OCK1Z) — IFPS
|
||||
// item 15 lists fixes, and derives the procedure from the runway + first/last point.
|
||||
const isProc = (t: string) => /^[A-Z]{2,5}\d[A-Z]$/.test(t);
|
||||
while (mid.length && isProc(mid[0])) mid.shift();
|
||||
while (mid.length && isProc(mid[mid.length - 1])) mid.pop();
|
||||
const route = mid.join(" ") || "DCT";
|
||||
const fl = result.cruise_fl ?? (cruiseFl.trim() ? Number(cruiseFl) : 350);
|
||||
return {
|
||||
adep: from.trim().toUpperCase(),
|
||||
ades: to.trim().toUpperCase(),
|
||||
route,
|
||||
actype: (type || "A320").toUpperCase(),
|
||||
level: `F${String(fl).padStart(3, "0")}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Authoritative IFPS validation via the Eurocontrol IFPUV (Playwright scraper).
|
||||
async function validateIfps() {
|
||||
const payload = ifpsPayload();
|
||||
if (!payload) return;
|
||||
setIfpsLoading(true);
|
||||
try {
|
||||
setIfpsCheck(await invoke<IfpsCheck>("ifps_validate", { payload }));
|
||||
} catch (e) { setIfpsCheck({ accepted: null, errors: [], error: typeof e === "string" ? e : String(e) }); }
|
||||
finally { setIfpsLoading(false); }
|
||||
}
|
||||
|
||||
// Oracle-driven auto-correction (RAD level caps, unknown designators).
|
||||
async function autofixIfps() {
|
||||
const payload = ifpsPayload();
|
||||
if (!payload) return;
|
||||
setFixLoading(true);
|
||||
setIfpsFix(null);
|
||||
try {
|
||||
setIfpsFix(await invoke<IfpsFix>("ifps_autofix", { payload }));
|
||||
} catch (e) { setIfpsFix({ accepted: false, level: null, route: null, iterations: 0, log: [], errors: [], error: typeof e === "string" ? e : String(e) }); }
|
||||
finally { setFixLoading(false); }
|
||||
}
|
||||
|
||||
// Oracle-in-the-loop: generate a candidate, validate + repair against the live
|
||||
// IFPUV until IFPS-accepted, and store it in the route DB (slow first time).
|
||||
async function generateValidated() {
|
||||
if (!from.trim() || !to.trim()) { setError("Departure and destination are required."); return; }
|
||||
setDiscoverLoading(true);
|
||||
setDiscover(null);
|
||||
setProgress(null);
|
||||
try {
|
||||
const pax = Number(adults || 0) * 100 + Number(children || 0) * 50 + Number(infants || 0) * 15;
|
||||
const pl = pax + Number(cargo || 0);
|
||||
const req = { from: from.trim(), to: to.trim(), db_path: dbPath, aircraft: type || null, aircraft_dir: aircraftDir, cifp_dir: DEFAULT_CIFP, cruise_fl: cruiseFl.trim() === "" ? null : Number(cruiseFl), alternate: alternate.trim() || null, payload_kg: pl > 0 ? pl : null };
|
||||
setDiscover(await invoke<Discover>("generate_validated", { req }));
|
||||
refreshCount(); refreshDbRoutes();
|
||||
} catch (e) { setDiscover({ accepted: false, route_string: "", item15: "", fl: 0, total_nm: 0, errors: [], iterations: 0, log: [], error: typeof e === "string" ? e : String(e) }); }
|
||||
finally { setDiscoverLoading(false); }
|
||||
}
|
||||
|
||||
const f = result?.fuel ?? null;
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{settings ? <Settings onClose={() => setSettings(false)} backend={backend} setBackend={setBackend} url={serverUrl} setUrl={setServerUrl} mode={mode} setMode={setMode} /> : null}
|
||||
|
||||
{/* Title bar */}
|
||||
<div className="titlebar" data-tauri-drag-region>
|
||||
<div className="tb-left"><span className="logo">✈</span> Flight Planner<span className="tb-flight">{result ? `— AFR1234 · ${from.toUpperCase()}–${to.toUpperCase()}` : ""}</span></div>
|
||||
<div className="win-controls">
|
||||
<button onClick={() => appWindow.minimize()} title="Minimize">─</button>
|
||||
<button onClick={() => appWindow.toggleMaximize()} title="Maximize">▢</button>
|
||||
<button className="close" onClick={() => appWindow.close()} title="Close">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tiny toolbar */}
|
||||
<div className="menubar">
|
||||
<button className="iconbtn" title="New">+</button>
|
||||
<button className="iconbtn" title="Open">📂</button>
|
||||
<button className="iconbtn" title="Save">💾</button>
|
||||
<div style={{ width: 1, height: 16, background: "var(--border)", margin: "0 4px" }} />
|
||||
<button className="iconbtn" title="Print">🖨</button>
|
||||
<button className="iconbtn" title="Export">⇱</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="iconbtn" title="Settings" onClick={() => setSettings(true)}>⚙</button>
|
||||
</div>
|
||||
|
||||
{/* Ribbon tabs */}
|
||||
<div className="ribbon-tabs">
|
||||
{["Schedule", "Flight", "Traffic", "Browser"].map((tb) => (
|
||||
<button key={tb} className={`ribbon-tab${ribbonTab === tb ? " on" : ""}`} onClick={() => setRibbonTab(tb)}>{tb}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Ribbon */}
|
||||
<div className="ribbon">
|
||||
<Grp cap="Flight">
|
||||
<RB g="▸" label="Compute" onClick={compute} accent />
|
||||
<RB g="+" label="New" />
|
||||
<RB g="↺" label="Reset" onClick={reset} />
|
||||
<RB g="✈" label="In-flight" />
|
||||
</Grp>
|
||||
<div className="rsep" />
|
||||
<Grp cap="Performance"><RB g="↗" label="Take-off" /><RB g="↘" label="Landing" /></Grp>
|
||||
<div className="rsep" />
|
||||
<Grp cap="Payload">
|
||||
<RB g="○" label="None" on={payloadPreset === "none"} onClick={() => setPayloadPreset("none")} />
|
||||
<RB g="◔" label="Min" on={payloadPreset === "min"} onClick={() => setPayloadPreset("min")} />
|
||||
<RB g="●" label="Max" on={payloadPreset === "max"} onClick={() => setPayloadPreset("max")} />
|
||||
<RB g="⚄" label="Random" onClick={() => setPayloadPreset("rnd")} />
|
||||
</Grp>
|
||||
<div className="rsep" />
|
||||
<Grp cap="Fuel"><RB g="⭳" label="Load" /><RB g="▤" label="Build" /><RB g="✓" label="Validate" /><RB g="⇱" label="Export" /></Grp>
|
||||
<div className="rsep" />
|
||||
<Grp cap="Route"><RB g="✎" label="Edit" /><RB g="⌕" label="Find" /><RB g="✕" label="Clear" /></Grp>
|
||||
<div className="rsep" />
|
||||
<Grp cap="Alternates"><RB g="↗" label="Take-off" /><RB g="→" label="Enroute" /></Grp>
|
||||
<div className="rsep" />
|
||||
<Grp cap="ETOPS"><RB g="❄" label="Icing" /><RB g="⊘" label="Disable" /></Grp>
|
||||
</div>
|
||||
|
||||
{/* Flight info bar */}
|
||||
<div className="flightbar">
|
||||
<span className="tag">AFR1234</span>
|
||||
<span className="apt">· F-GKXA ·</span>
|
||||
<input value={from} onChange={(e) => setFrom(e.target.value.toUpperCase())} />
|
||||
<span className="apt"><b>{from.toUpperCase()}</b></span>
|
||||
<span className="arrow">→</span>
|
||||
<input value={to} onChange={(e) => setTo(e.target.value.toUpperCase())} />
|
||||
<span className="apt"><b>{to.toUpperCase()}</b></span>
|
||||
<button className="btn-primary" style={{ width: "auto", height: 30, padding: "0 16px", marginLeft: 8 }} onClick={compute} disabled={loading}>{loading ? "Computing…" : "Compute Flight"}</button>
|
||||
{error ? <span className="err" style={{ marginLeft: 8 }}>⚠ {error}</span> : null}
|
||||
<span className="sp" />
|
||||
{result ? (
|
||||
discover?.accepted || ifpsCheck?.accepted || ifpsFix?.accepted
|
||||
? <span style={{ color: "var(--good)", fontWeight: 600 }} title="Validated by the real Eurocontrol IFPUV">IFPS ✓ Eurocontrol</span>
|
||||
: (ifpsCheck && !ifpsCheck.accepted && !ifpsCheck.error)
|
||||
? <span style={{ color: "var(--warn)", fontWeight: 600 }} title="Rejected by Eurocontrol IFPUV">IFPS ✗ Eurocontrol</span>
|
||||
: <span style={{ color: "var(--muted)", fontWeight: 600 }} title="Offline draft — use Validate / Generate for the authoritative Eurocontrol check">IFPS draft</span>
|
||||
) : null}
|
||||
{result ? <span className="apt mono">{flText(result.cruise_fl)} · {result.legs.length} legs · {nm(result.total_nm)} nm{f ? ` · ${hm(f.trip_time_min)} · ${kg(f.block_fuel_kg)} kg` : ""}</span> : null}
|
||||
</div>
|
||||
|
||||
{/* Body: left setup + right map */}
|
||||
<div className="body" style={{ gridTemplateColumns: "344px 1fr" }}>
|
||||
<aside className="sidebar">
|
||||
<div className="sections">
|
||||
<div className="section">
|
||||
<div className="section-title">Flight</div>
|
||||
<div className="row2"><Field label="Airline"><input defaultValue="AFR" /></Field><Field label="Flight number"><input defaultValue="1234" /></Field></div>
|
||||
<div className="row2"><Field label="Commercial flt nr"><input defaultValue="AF1234" /></Field><Field label="Flight type"><select defaultValue="Scheduled"><option>Scheduled</option><option>Charter</option><option>Cargo</option></select></Field></div>
|
||||
</div>
|
||||
<div className="section">
|
||||
<div className="section-title">Aircraft</div>
|
||||
<div className="row2"><Field label="Registration"><input defaultValue="F-GKXA" /></Field><Field label="Type"><select value={type} onChange={(e) => setType(e.target.value)}><option value="">Route only</option><option value="A320">A320-200</option><option value="B738">B737-800</option></select></Field></div>
|
||||
<div className="row3"><Field label="Climb"><input defaultValue="250" /></Field><Field label="Cruise"><input defaultValue="0.78" /></Field><Field label="Cost idx"><input defaultValue="30" /></Field></div>
|
||||
</div>
|
||||
<div className="section">
|
||||
<div className="section-title">Optimization</div>
|
||||
<div className="row2"><Field label="Profile"><select defaultValue="Minimum fuel"><option>Minimum fuel</option><option>Minimum cost</option><option>Minimum time</option></select></Field><Field label="Cruise FL"><input value={cruiseFl} onChange={(e) => setCruiseFl(e.target.value)} placeholder="auto" /></Field></div>
|
||||
</div>
|
||||
<div className="section">
|
||||
<div className="section-title">Payload</div>
|
||||
<div className="row3"><Field label="Adults"><input value={adults} onChange={(e) => setAdults(e.target.value)} /></Field><Field label="Children"><input value={children} onChange={(e) => setChildren(e.target.value)} /></Field><Field label="Infants"><input value={infants} onChange={(e) => setInfants(e.target.value)} /></Field></div>
|
||||
<div className="row2"><Field label="Cargo (kg)"><input value={cargo} onChange={(e) => setCargo(e.target.value)} /></Field><Field label="Pax capacity"><input defaultValue="180" /></Field></div>
|
||||
</div>
|
||||
<div className="section">
|
||||
<div className="section-title">Fuel</div>
|
||||
<div className="row2"><Field label="Policy"><select defaultValue="ICAO"><option>ICAO</option><option>Company</option><option>Minimum</option></select></Field><Field label="Extra (kg)"><input defaultValue="0" /></Field></div>
|
||||
</div>
|
||||
<div className="section">
|
||||
<div className="section-title">Route</div>
|
||||
<div className="row2"><Field label="Route name"><input defaultValue="LFPGEGLL01" /></Field><Field label="Distance"><input readOnly value={result ? `${nm(result.total_nm)} nm` : "—"} /></Field></div>
|
||||
</div>
|
||||
<div className="section">
|
||||
<div className="section-title">Alternates</div>
|
||||
<div className="row3"><Field label="Required"><select defaultValue="One"><option>None</option><option>One</option><option>Two</option></select></Field><Field label="Priority"><select defaultValue="Fuel"><option>Fuel</option><option>Distance</option></select></Field><Field label="Auto"><input defaultValue="(Auto)" readOnly /></Field></div>
|
||||
<table className="alt-table">
|
||||
<thead><tr><th>Alternate</th><th>Rwy</th><th>Route</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><input value={alternate} onChange={(e) => setAlternate(e.target.value.toUpperCase())} /></td><td><input defaultValue="26L" style={{ width: 54 }} /></td><td><input defaultValue="WOD" /></td></tr>
|
||||
<tr><td><input defaultValue="EGLC" /></td><td><input defaultValue="27" style={{ width: 54 }} /></td><td><input defaultValue="LYD" /></td></tr>
|
||||
<tr><td><input defaultValue="EGSS" /></td><td><input defaultValue="22" style={{ width: 54 }} /></td><td><input defaultValue="DET" /></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Right: map area */}
|
||||
<div className="maparea">
|
||||
<div className="map-toolbar">
|
||||
<button className="iconbtn" title="Zoom in">+</button>
|
||||
<button className="iconbtn" title="Zoom out">-</button>
|
||||
<button className="iconbtn" title="Fit route">⤢</button>
|
||||
<div style={{ width: 1, height: 16, background: "var(--border)", margin: "0 4px" }} />
|
||||
<button className="iconbtn" title="Airways">A</button>
|
||||
<button className="iconbtn" title="Fixes">◇</button>
|
||||
<button className="iconbtn" title="FIRs">▦</button>
|
||||
<button className="iconbtn" title="Weather">☁</button>
|
||||
<button className="iconbtn" title="Measure">📏</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<span style={{ fontSize: 11, color: "var(--muted)" }}>{result ? `${result.geometry.length} waypoints` : "no route"}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, position: "relative", minHeight: 0 }}>
|
||||
<RouteMap points={result?.geometry ?? []} procedures={result?.procedures ?? []} />
|
||||
{!result && mapTab === "map" ? <div className="hint">Compute a flight to plot the route.</div> : null}
|
||||
{mapTab !== "map" ? (
|
||||
<div style={{ position: "absolute", inset: 0, background: "var(--bg)", display: "grid", placeItems: "center", color: "var(--muted)", textAlign: "center", padding: 20 }}>
|
||||
{mapTab === "wx" ? "Weather & NOTAMs — winds aloft (GFS GRIB), METAR/TAF, SIGWX. Coming next." :
|
||||
mapTab === "tracks" ? "Track Messages — NAT / PACOTS oceanic tracks. Coming next." :
|
||||
"Scratchpad — free notes for this flight."}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="map-tabs">
|
||||
{[["map", "World Map"], ["wx", "Weather & NOTAMs"], ["tracks", "Track Messages"], ["scratch", "Scratchpad"]].map(([id, lb]) => (
|
||||
<button key={id} className={`map-tab${mapTab === id ? " on" : ""}`} onClick={() => setMapTab(id)}>{lb}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom output dock */}
|
||||
<div className="output">
|
||||
<div className="tabs">
|
||||
{[["navlog", "Nav Log"], ["fuel", "Fuel & Load"], ["ofp", "OFP"], ["ifps", "IFPS"]].map(([id, lb]) => (
|
||||
<button key={id} className={`tab${outTab === id ? " on" : ""}`} onClick={() => setOutTab(id)}>{lb}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="dock-body">
|
||||
{!result ? (
|
||||
<div className="empty">No active flight — set a route and press <b>Compute Flight</b>.</div>
|
||||
) : outTab === "navlog" ? (
|
||||
<div className="card">
|
||||
<table className="tbl">
|
||||
<thead><tr><th>From</th><th>Via</th><th>To</th><th className="num">Dist</th><th className="num">Cum</th>{f ? (<><th className="num">ETE</th><th className="num">Time</th><th className="num">Fuel</th><th className="num">Used</th></>) : null}</tr></thead>
|
||||
<tbody>
|
||||
{result.legs.map((l, i) => (
|
||||
<tr key={i}><td className="mono">{l.from}</td><td className="mono via">{l.via}</td><td className="mono">{l.to}</td><td className="num">{Math.round(l.dist_nm)}</td><td className="num">{Math.round(l.cum_dist_nm)}</td>
|
||||
{f ? (<><td className="num">{l.time_min != null ? hm(l.time_min) : "—"}</td><td className="num">{l.cum_time_min != null ? hm(l.cum_time_min) : "—"}</td><td className="num">{l.fuel_kg != null ? Math.round(l.fuel_kg) : "—"}</td><td className="num">{l.cum_fuel_kg != null ? kg(l.cum_fuel_kg) : "—"}</td></>) : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : outTab === "fuel" ? (
|
||||
!f ? <div className="empty">Select an aircraft to compute fuel and weights.</div> : (
|
||||
<div className="grid3">
|
||||
<div className="card"><div className="cardtitle">Phases</div><table className="tbl"><thead><tr><th>Phase</th><th className="num">Dist</th><th className="num">Time</th><th className="num">Fuel</th></tr></thead><tbody>
|
||||
{([["Climb", f.climb], ["Cruise", f.cruise], ["Descent", f.descent]] as [string, Phase][]).map(([n2, p]) => (<tr key={n2}><td>{n2}</td><td className="num">{Math.round(p.dist_nm)}</td><td className="num">{hm(p.time_min)}</td><td className="num">{kg(p.fuel_kg)}</td></tr>))}
|
||||
</tbody></table></div>
|
||||
<div className="card"><div className="cardtitle">Fuel</div>
|
||||
<div className="kvrow"><span>Taxi</span><span className="val">{kg(f.taxi_kg)}</span></div>
|
||||
<div className="kvrow"><span>Trip</span><span className="val">{kg(f.trip_fuel_kg)}</span></div>
|
||||
<div className="kvrow"><span>Contingency</span><span className="val">{kg(f.contingency_kg)}</span></div>
|
||||
<div className="kvrow"><span>Alternate</span><span className="val">{kg(f.alternate_kg)}</span></div>
|
||||
<div className="kvrow"><span>Final reserve</span><span className="val">{kg(f.final_reserve_kg)}</span></div>
|
||||
<div className="kvrow total"><span>Block</span><span className="val">{kg(f.block_fuel_kg)}</span></div>
|
||||
</div>
|
||||
{f.mass ? (<div className="card"><div className="cardtitle">Weights</div>
|
||||
<div className="kvrow"><span>Payload</span><span className="val">{kg(f.mass.payload_kg)}</span></div>
|
||||
<div className="kvrow"><span>Zero-fuel</span><span className="val">{kg(f.mass.zfw_kg)}</span></div>
|
||||
<div className="kvrow"><span>Take-off{f.mass.over_mtow ? <span className="badge-warn">> MTOW</span> : null}</span><span className="val">{kg(f.mass.takeoff_kg)}</span></div>
|
||||
<div className="kvrow"><span>Landing{f.mass.over_mlw ? <span className="badge-warn">> MLW</span> : null}</span><span className="val">{kg(f.mass.landing_kg)}</span></div>
|
||||
</div>) : null}
|
||||
</div>
|
||||
)
|
||||
) : outTab === "ofp" ? (
|
||||
<div className="card" style={{ padding: 12 }}><pre className="ofp-pre">{result.ofp}</pre></div>
|
||||
) : (
|
||||
<div className="card" style={{ padding: 14 }}>
|
||||
{/* Our own route DB for this pair (IFPS-validated routes are reused). */}
|
||||
<div style={{ marginBottom: 14, paddingBottom: 12, borderBottom: "1px solid var(--border-soft)" }}>
|
||||
<div style={{ fontSize: 11, textTransform: "uppercase", letterSpacing: ".5px", color: "var(--muted)", marginBottom: 6 }}>
|
||||
Route DB — {from.toUpperCase()}→{to.toUpperCase()} ({dbRoutes.length})
|
||||
</div>
|
||||
{dbRoutes.length === 0 ? (
|
||||
<div style={{ color: "var(--muted)", fontSize: 12 }}>No stored routes yet — Generate & Validate to add one.</div>
|
||||
) : (
|
||||
dbRoutes.slice(0, 6).map((r, i) => (
|
||||
<div key={i} style={{ fontSize: 12, padding: "3px 0", borderBottom: "1px solid var(--border-soft)", display: "flex", gap: 8, alignItems: "baseline" }}>
|
||||
<span style={{ fontWeight: 700, color: r.ifps_ok ? "var(--good)" : "var(--muted)", minWidth: 66 }}>
|
||||
{r.ifps_ok ? "IFPS ✓" : "unvalidated"}
|
||||
</span>
|
||||
<span className="tag" style={{ minWidth: 54 }}>{r.source.split(" ")[0]}</span>
|
||||
<span className="mono" style={{ minWidth: 44 }}>FL{String(r.cruise_fl).padStart(3, "0")}</span>
|
||||
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={r.route_string}>{r.route_string}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{/* Authoritative Eurocontrol IFPS validation (live IFPUV). */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||
<button className="btn" onClick={validateIfps} disabled={ifpsLoading || fixLoading}>
|
||||
{ifpsLoading ? "Validating with Eurocontrol…" : "Validate with Eurocontrol IFPS"}
|
||||
</button>
|
||||
<button className="btn" onClick={autofixIfps} disabled={ifpsLoading || fixLoading}>
|
||||
{fixLoading ? "Auto-correcting…" : "Auto-fix (Eurocontrol)"}
|
||||
</button>
|
||||
<button className="btn-primary" style={{ width: "auto", height: 30, padding: "0 14px" }} onClick={generateValidated} disabled={discoverLoading}>
|
||||
{discoverLoading ? "Generating…" : "Generate & Validate (IFPS)"}
|
||||
</button>
|
||||
<span style={{ fontSize: 11, color: "var(--muted)" }}>Live IFPUV round-trip (~25 s)</span>
|
||||
</div>
|
||||
{discoverLoading ? (
|
||||
<div style={{ fontSize: 12, color: "var(--muted)", marginBottom: 12 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span className="spin" style={{ display: "inline-block", width: 12, height: 12, border: "2px solid var(--border)", borderTopColor: "var(--accent)", borderRadius: "50%", animation: "spin 0.8s linear infinite" }} />
|
||||
<span>Discovering an IFPS-valid route (real Eurocontrol check, ~25 s per step; cached after)…</span>
|
||||
</div>
|
||||
{progress ? (
|
||||
<div style={{ marginTop: 6, fontFamily: "monospace", fontSize: 11.5, color: "var(--text)" }}>
|
||||
Step {progress.step} · checking @ FL{String(progress.fl).padStart(3, "0")}<br />
|
||||
<span style={{ color: "var(--muted)" }}>{progress.route}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{discover ? (
|
||||
<div style={{ marginBottom: 14, paddingBottom: 12, borderBottom: "1px solid var(--border-soft)" }}>
|
||||
{discover.error ? (
|
||||
<div style={{ color: "var(--warn)", fontSize: 12.5 }}>Generator error: {discover.error}</div>
|
||||
) : (<>
|
||||
<div style={{ fontWeight: 700, marginBottom: 6, color: discover.accepted ? "var(--good)" : "var(--warn)" }}>
|
||||
{discover.accepted
|
||||
? `✓ IFPS-valid route found — FL${String(discover.fl).padStart(3, "0")} · ${Math.round(discover.total_nm)} nm · saved to route DB`
|
||||
: `⚠ Best route found is not IFPS-accepted yet (${discover.iterations} iteration${discover.iterations > 1 ? "s" : ""})`}
|
||||
</div>
|
||||
{discover.route_string ? <div style={{ fontSize: 12.5, marginBottom: 6 }}><b>{discover.route_string}</b></div> : null}
|
||||
{discover.log.map((l, i) => <div key={i} style={{ fontSize: 11.5, color: "var(--muted)", fontFamily: "monospace", padding: "1px 0" }}>{l}</div>)}
|
||||
{!discover.accepted && discover.errors.length ? (
|
||||
<div style={{ marginTop: 6 }}>{discover.errors.map((e, i) => <div key={i} style={{ fontSize: 12, padding: "2px 0" }}><b style={{ color: "var(--warn)" }}>{e.code}</b> {e.msg}</div>)}</div>
|
||||
) : null}
|
||||
</>)}
|
||||
</div>
|
||||
) : null}
|
||||
{ifpsLoading ? <div style={{ fontSize: 12, color: "var(--muted)", marginBottom: 12 }}>Submitting FPL to the public Eurocontrol validator…</div> : null}
|
||||
{fixLoading ? <div style={{ fontSize: 12, color: "var(--muted)", marginBottom: 12 }}>Iterating with the Eurocontrol validator (level caps, designators)…</div> : null}
|
||||
{ifpsFix ? (
|
||||
<div style={{ marginBottom: 14, paddingBottom: 12, borderBottom: "1px solid var(--border-soft)" }}>
|
||||
{ifpsFix.error ? (
|
||||
<div style={{ color: "var(--warn)", fontSize: 12.5 }}>Auto-fix error: {ifpsFix.error}</div>
|
||||
) : (<>
|
||||
<div style={{ fontWeight: 700, marginBottom: 6, color: ifpsFix.accepted ? "var(--good)" : "var(--warn)" }}>
|
||||
{ifpsFix.accepted
|
||||
? `✓ Auto-corrected — IFPS ACCEPTED at ${ifpsFix.level} (${ifpsFix.iterations} iteration${ifpsFix.iterations > 1 ? "s" : ""})`
|
||||
: `⚠ Could not fully auto-correct (${ifpsFix.iterations} iteration${ifpsFix.iterations > 1 ? "s" : ""})`}
|
||||
</div>
|
||||
{ifpsFix.accepted && ifpsFix.route ? (
|
||||
<div style={{ fontSize: 12.5, marginBottom: 6 }}>Route: <b>{from.toUpperCase()} {ifpsFix.route} {to.toUpperCase()}</b> · <b>{ifpsFix.level}</b></div>
|
||||
) : null}
|
||||
{ifpsFix.log.map((l, i) => <div key={i} style={{ fontSize: 11.5, color: "var(--muted)", fontFamily: "monospace", padding: "1px 0" }}>{l}</div>)}
|
||||
{!ifpsFix.accepted && ifpsFix.errors.length ? (
|
||||
<div style={{ marginTop: 6 }}>{ifpsFix.errors.map((e, i) => <div key={i} style={{ fontSize: 12, padding: "2px 0" }}><b style={{ color: "var(--warn)" }}>{e.code}</b> {e.msg}</div>)}</div>
|
||||
) : null}
|
||||
</>)}
|
||||
</div>
|
||||
) : null}
|
||||
{ifpsCheck ? (
|
||||
<div style={{ marginBottom: 14, paddingBottom: 12, borderBottom: "1px solid var(--border-soft)" }}>
|
||||
{ifpsCheck.error ? (
|
||||
<div style={{ color: "var(--warn)", fontSize: 12.5 }}>Validator error: {ifpsCheck.error}</div>
|
||||
) : (<>
|
||||
<div style={{ fontWeight: 700, marginBottom: 6, color: ifpsCheck.accepted ? "var(--good)" : "var(--warn)" }}>
|
||||
{ifpsCheck.accepted ? "✓ IFPS ACCEPTED — Eurocontrol (authoritative)" : `✗ IFPS REJECTED — Eurocontrol — ${ifpsCheck.errors.length} error(s)`}
|
||||
</div>
|
||||
{ifpsCheck.errors.map((e, i) => (
|
||||
<div key={i} style={{ fontSize: 12.5, padding: "3px 0", borderBottom: "1px solid var(--border-soft)" }}>
|
||||
<b style={{ color: "var(--warn)" }}>{e.code}</b> {e.msg}
|
||||
</div>
|
||||
))}
|
||||
{ifpsCheck.accepted ? <div style={{ color: "var(--good)", fontSize: 12.5 }}>NO ERRORS — the flight plan is IFPS-valid.</div> : null}
|
||||
{ifpsCheck.fpl ? <pre className="ofp-pre" style={{ marginTop: 8, fontSize: 10.5, maxHeight: 120 }}>{ifpsCheck.fpl}</pre> : null}
|
||||
</>)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ fontSize: 11, textTransform: "uppercase", letterSpacing: ".5px", color: "var(--muted)", marginBottom: 6 }}>Offline pre-check</div>
|
||||
<div style={{ fontWeight: 700, marginBottom: 6, color: result.ifps.accepted ? "var(--good)" : "var(--warn)" }}>
|
||||
{result.ifps.accepted ? "IFPS ✓ Accepted (offline pre-check)" : `IFPS ✗ Rejected — ${result.ifps.errors.length} error(s)`}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "var(--muted)", marginBottom: 12 }}>Best-effort offline structural pre-check — not an authoritative Eurocontrol IFPS validation.</div>
|
||||
{result.ifps.errors.length > 0 ? (<>
|
||||
<div style={{ fontSize: 11, textTransform: "uppercase", letterSpacing: ".5px", color: "var(--warn)", marginBottom: 4 }}>Errors</div>
|
||||
{result.ifps.errors.map((e, i) => <div key={i} style={{ fontSize: 12.5, padding: "3px 0", borderBottom: "1px solid var(--border-soft)" }}>✗ {e}</div>)}
|
||||
</>) : null}
|
||||
{result.ifps.warnings.length > 0 ? (<>
|
||||
<div style={{ fontSize: 11, textTransform: "uppercase", letterSpacing: ".5px", color: "var(--amber)", margin: "12px 0 4px" }}>Warnings</div>
|
||||
{result.ifps.warnings.map((w, i) => <div key={i} style={{ fontSize: 12.5, padding: "3px 0", borderBottom: "1px solid var(--border-soft)", color: "var(--muted)" }}>! {w}</div>)}
|
||||
</>) : null}
|
||||
{result.ifps.errors.length === 0 && result.ifps.warnings.length === 0 ? <div style={{ color: "var(--muted)" }}>No structural issues found.</div> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<div className="status">
|
||||
<span><span className="dot" />Ready</span>
|
||||
<span>Nav: AIRAC 2608</span>
|
||||
<span>WX: <b style={{ color: "var(--good)" }}>Online</b></span>
|
||||
<span>Tracks: <b style={{ color: "var(--good)" }}>Online</b></span>
|
||||
<span>{result ? `${from.toUpperCase()}–${to.toUpperCase()} · ${nm(result.total_nm)} nm` : "No flight"}</span>
|
||||
<span className="sp" />
|
||||
<span>Route DB: <b>{routeCount ?? "—"}</b></span>
|
||||
<span>Mode: <b style={{ color: mode === "career" ? "var(--amber)" : "var(--good)" }}>{mode === "career" ? "Career" : "Free Flight"}</b></span>
|
||||
<span>Backend: <b style={{ color: backend === "server" ? "var(--amber)" : "var(--good)" }}>{backend === "server" ? "Server" : "Local"}</b></span>
|
||||
<span>Perf: OpenAP</span>
|
||||
<span>{clock} UTC</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import App from "./App";
|
||||
import "./App.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [react()],
|
||||
|
||||
// Keep MapLibre out of Vite's dep pre-bundling: its web worker breaks when the
|
||||
// optimizer re-bundles mid-session ("maplibre-gl-worker.mjs does not exist"),
|
||||
// which silently drops all GeoJSON layers (land + route). Excluding it makes
|
||||
// the worker resolve reliably in `tauri dev`.
|
||||
optimizeDeps: {
|
||||
exclude: ["maplibre-gl"],
|
||||
},
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell Vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||