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>
This commit is contained in:
+218
@@ -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); } }
|
||||
+756
@@ -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>,
|
||||
);
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user