diff --git a/.cursor/rules/windows-cmd-shell.mdc b/.cursor/rules/windows-cmd-shell.mdc new file mode 100644 index 00000000..687a370e --- /dev/null +++ b/.cursor/rules/windows-cmd-shell.mdc @@ -0,0 +1,31 @@ +--- +description: Force Windows CMD (batch) syntax for all terminal commands; never PowerShell +alwaysApply: true +--- + +# Windows CMD Shell Only + +NEVER use PowerShell commands in terminal suggestions or Shell tool calls. Always use Windows CMD commands (batch commands) for all operations. + +## Hard rules + +- Use CMD / batch syntax only +- Use Windows environment variables with `%VARIABLE%` syntax +- NEVER use PowerShell `$env:VARIABLE` +- NEVER use PowerShell cmdlets (`Get-ChildItem`, `Select-Object`, `Write-Host`, `Set-Location`, etc.) +- NEVER use PowerShell-only constructs (`ForEach-Object`, `$_`, splatting, PowerShell here-strings) +- Prefer `cd /d`, `dir`, `type`, `copy`, `move`, `del`, `mkdir`, `rmdir`, `set`, `echo` + +## Examples + +```bat +REM ❌ BAD (PowerShell) +cd F:\Flight_Assistant; git status +echo $env:PATH +Get-ChildItem -Recurse *.py + +REM ✅ GOOD (CMD) +cd /d F:\Flight_Assistant && git status +echo %PATH% +dir /s /b *.py +``` diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..83d8bcbe --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# 复制为 .env 后按需修改(.env 已被 gitignore) + +# --- AuthDB(登录鉴权)--- +AUTH_DB_HOST=quizbase.cn +AUTH_DB_PORT=3306 +AUTH_DB_USER=flight_assistant +AUTH_DB_PASSWORD=1q2w3e4r +AUTH_DB_NAME=talent_auth_info_schema +AUTH_DB_CHARSET=utf8mb4 +AUTH_LOGIN_TIMEOUT_S=8 +AUTH_USER_TABLE=aero_quiz_base_user_info + +# --- AppDB(业务库,骨架;暂无调用也可先配)--- +APP_DB_HOST=127.0.0.1 +APP_DB_PORT=3306 +APP_DB_USER=root +APP_DB_PASSWORD= +APP_DB_NAME=flight_assistant +APP_DB_CHARSET=utf8mb4 + +# --- 模拟器安装根目录 --- +# 已改为按登录用户写入 Auth user_defined_4(键 fsx_root),登录后校验本地目录。 +# 勿再使用环境变量硬编码(各用户路径可能不同)。 diff --git a/.github/workflows/test-and-build.yml b/.github/workflows/test-and-build.yml deleted file mode 100644 index 7a662a61..00000000 --- a/.github/workflows/test-and-build.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Test and Publish -on: - push: - paths: - - 'SimConnect/__init__.py' - -jobs: - build-n-publish: - name: Build and publish Python distributions to PyPI - runs-on: ubuntu-18.04 - steps: - - uses: actions/checkout@master - - name: Set up Python 3.8 - uses: actions/setup-python@v1 - with: - python-version: 3.8 - - name: Install dependencies - run: python3 -m pip install --user --upgrade setuptools wheel twine - - name: Build source - run: python3 setup.py sdist bdist_wheel - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@master - with: - user: __token__ - password: ${{ secrets.pypi_password }} diff --git a/.gitignore b/.gitignore index 9fa9ecd7..f52ed095 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ MANIFEST # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec +!Flight_Assistant.spec # Installer logs pip-log.txt @@ -131,6 +132,7 @@ dmypy.json #vscode .vscode +.VSCodeCounter .idea/inspectionProfiles/profiles_settings.xml .idea/misc.xml .idea/modules.xml diff --git a/.wheels/numpy-2.5.2-cp314-cp314-win_amd64.whl b/.wheels/numpy-2.5.2-cp314-cp314-win_amd64.whl new file mode 100644 index 00000000..05e6c6cd Binary files /dev/null and b/.wheels/numpy-2.5.2-cp314-cp314-win_amd64.whl differ diff --git a/.wheels/pandas-3.0.5-cp314-cp314-win_amd64.whl b/.wheels/pandas-3.0.5-cp314-cp314-win_amd64.whl new file mode 100644 index 00000000..56f742ee Binary files /dev/null and b/.wheels/pandas-3.0.5-cp314-cp314-win_amd64.whl differ diff --git a/.wheels/python_dateutil-2.9.0.post0-py2.py3-none-any.whl b/.wheels/python_dateutil-2.9.0.post0-py2.py3-none-any.whl new file mode 100644 index 00000000..b9a14e1b Binary files /dev/null and b/.wheels/python_dateutil-2.9.0.post0-py2.py3-none-any.whl differ diff --git a/.wheels/six-1.17.0-py2.py3-none-any.whl b/.wheels/six-1.17.0-py2.py3-none-any.whl new file mode 100644 index 00000000..c506fd05 Binary files /dev/null and b/.wheels/six-1.17.0-py2.py3-none-any.whl differ diff --git a/.wheels/tzdata-2026.3-py2.py3-none-any.whl b/.wheels/tzdata-2026.3-py2.py3-none-any.whl new file mode 100644 index 00000000..c41abfd1 Binary files /dev/null and b/.wheels/tzdata-2026.3-py2.py3-none-any.whl differ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..169b8234 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,734 @@ +# AGENTS.md — Cursor Agent 项目专属规则 + +> 适用范围:本项目全部代码、UI、图表、状态机、数据持久化、构建与评审。 +> 本文件由原 `AGENTS.md`、`UI 抽象结构(参数无关).txt`、`Debug.md`、`DESIGN.md` 合并而成,作为 Cursor Agent 的项目唯一规则入口。 +> 每次交互前必须先检查本文件是否有更新;每次都必须按最新版本要求执行。 + +--- + +## 0. 交互与执行总则 + +### 0.1 核心响应规则 + +- Always respond in Simplified Chinese (UTF-8); Traditional Chinese is prohibited. + - 始终以简体中文(UTF-8)回复;禁止繁体。 +- The thinking process should be still presented in Chinese, let me know what you are doing. + - 思考过程始终用中文呈现,让我知道你在干什么。 +- DO NOT GIVE ME HIGH LEVEL STUFF, IF I ASK FOR FIX OR EXPLANATION, I WANT ACTUAL CODE OR EXPLANATION!!! + - 不要给我高层次的东西。如果我要求修复或解释,我要实际代码或解释,不要“你可以这样那样”之类的话。 +- You are a expert full stack engineer. + - 你是一名专家级全栈工程师。 +- Recheck whether the AGENTS.md file has been updated before each interaction. + - 每次交互前,先检查 `AGENTS.md` 是否有更新;每次都需要按最新版本中载明的要求进行交互。 + +### 0.2 执行流程 + +- If commands need to be executed, please pause file creation and let me execute the commands first. + - 如果需要执行命令,请暂停创建文件,让我先执行命令。 +- Please think step by step according to my needs, and if there are UI elements, they must be sufficiently aesthetic and modern. + - 请根据我的需求逐步思考;如有 UI 元素,必须足够美观和现代。 +- Do not affect other functionalities and interface layout styles. + - 不要影响其他功能和界面布局风格。 +- All operations in the terminal should run in the foreground, allowing me to see the output during the process and the final result. + - 终端中的所有操作都应在前台运行,让我在过程中和最终结果时看到输出。 +- You need to get my permission and confirmation before executing any code. + - 在执行任何代码之前,需要获得我的许可和确认。 + +### 0.3 沟通风格 + +- Be casual unless otherwise specified. + - 除非另有说明,否则随意一些。 +- Be terse. + - 言简意赅。 +- Give the answer immediately. Provide detailed explanations and restate my query in your own words if necessary after giving the answer. + - 立即给出答案。必要时用自己的话重述问题,然后提供详细解释。 +- Unless I explicitly ask for it, you don't need to provide summary explanations for the results or suggestions you give. + - 除非我明确要求,否则不需要为你给出的结果或建议提供总结性解释。 +- Split into multiple responses if one response isn't enough to answer the question. + - 如果一个回答不足以回答该问题,请分成多个回复。 + +### 0.4 建议与决策 + +- Suggest solutions that I didn't think about—anticipate my needs. + - 提出我没想过的解决方案,预判我的需求。 +- Treat me as an expert. + - 把我当作专家对待。 +- Be accurate and thorough. + - 准确且全面。 +- Value good arguments over authorities, the source is irrelevant. + - 重视好的论证而非权威,来源无关紧要。 +- Consider new technologies and contrarian ideas, not just the conventional wisdom. + - 考虑新技术和反常规想法,而不只是传统智慧。 +- You may use high levels of speculation or prediction, just flag it for me. + - 可以使用高程度推测或预测,但必须标记。 +- When providing suggestions, you are not limited to the existing code structure within the working directory. + - 提供建议时,不限于工作目录中的现有代码结构,可考虑广泛选择,包括库、模块、技术栈、实现方法。 + +### 0.5 代码提供规范 + +- Please respect my prettier preferences when you provide code. + - 提供代码时尊重我的 prettier 偏好。 +- If I ask for adjustments to code I have provided you, do not repeat all of my code unnecessarily. + - 如果我要求调整已提供的代码,不要不必要地重复全部代码;只给修改前后几行即可,多个代码块也可以。 + +### 0.6 引用与声明 + +- Cite sources whenever possible at the end, not inline. + - 尽可能在末尾引用来源,不要行内引用。 +- No need to mention your knowledge cutoff. + - 无需提及知识截止日期。 +- No need to disclose you're an AI. + - 无需披露你是 AI。 + +--- + +## 1. 项目概述 + +- 本项目为模拟飞行数据收集与分析系统。 +- 架构:Qt 建构应用程序框架 + 通过 PySide2 建构图表 + 通过 Pymysql 操作 MySQL 后端 + 通过 ctypes 操作 C 语言开发的 dll。 +- 核心技术栈:ctypes、Qt、PySide2、Pymysql、Pandas、Numpy。 + +--- + +## 2. 项目结构 - Project Structure + +```text +pages/ # 页面内容根(page_{业务名}.py) +components/ +├── Frame/ # 窗体层(主壳 / 并发对话框) +├── Container/ # 容器层(版式分割) +├── widget/ # 控件层(可复用控件库) +└── overlay/ # 蒙版层(global / local) +charts/ # 图表子系统(域内命名,非 UI 四层) +models/ # 全局状态 / 领域模型 +├── state_bus/ # 分组状态总线(Store / 订阅 / 采集写 / 分析) +services/ # API / 业务服务封装 +app_run.py # 主进程入口 +app_controller.py # 业务控制器(编排 + 命令) +setting.py # 业务与 UI 常量 +``` + +- Never edit `dist/` — 构建产物。 +- 整个项目中主进程级的启动入口文件命名时加 `app_` 作为前缀。 +- 所有单个页面保存到项目根目录下 `pages/page_{页面业务名称}.py` 文件中;一页一个页面内容根,挂入唯一主窗 `Frame_Shell_AppMain` 的页面栈。 +- 非 UI 组件命名:`{前缀}[App/component/Until/API/Constant]_{主要功能名称}.py`。 +- 应用部署及程序运行相关的常量参数在 `.env` 文件中维护,通过调用使用。 +- 业务相关的参数常量在 `setting.py` 文件中维护,通过调用使用。 + +--- + +## 3. 编码风格 - Coding Style + +- 除非另有说明,否则修改和重构时无需兼容旧版本。 +- 除非另有说明,否则数据库操作一律采用中间件函数实现,不直接使用数据库命令。 +- 能用简洁的方式实现,就不用复杂的方式。 +- 能用结构化的方式实现,就不用临时的方式。 +- 每当增加一个新功能,必须先考虑同一个项目中是否可能有其他功能与该功能在结构上并列且成组。 + - 例如:包括但不限于前进和后退、确认与取消等。这里仅做举例,并不绝对。 + - 再考虑是用一次性使用的逻辑来设计,还是结构化的逻辑来设计。 + - 如不确定,你可提出建议的处理方案,但必须让我来决定采用哪种方案。 +- 当接到新需求时,先检索当前项目中是否有类似的功能实现,举一反三进行设计,保持编码风格统一。 + - 如不确定,你可提出建议的处理方案,但必须让我来决定采用哪种方案。 +- Never 对参数进行硬编码。 +- Never 对变量进行透传式引用。 + +### 3.1 编码前思考 - Think before coding + +- Clarify assumptions; ask rather than guess when uncertain. + - 明确假设,不确定时询问而非猜测。 +- When ambiguity exists, list multiple interpretations; do not silently settle on a single approach. + - 存在歧义时,列出多种解释,不默默选定单一方案。 +- If there is a significantly simpler way to accomplish the task, directly point out the optimization idea. + - 如果任务有明显更简单的做法,直接指出优化思路。 +- Pause promptly when encountering code contradictions or logical inconsistencies, and request clarification. + - 发现代码矛盾、逻辑不一致时及时暂停,请求信息澄清。 + +### 3.2 简洁优先 - Simplicity first + +- Solve problems with minimal code; reject redundant implementations. + - 用最少的代码解决问题,拒绝冗余实现。 +- Do not create abstraction layers or complex architectures for one-off needs. + - 不为一次性需求创建抽象层、复杂架构。 +- Do not blindly add extensibility or configurability to handle "might be used in the future" scenarios. + - 不盲目增加扩展性、可配置性,应对“未来可能用到”的场景。 +- If code can be drastically simplified, proactively rewrite and optimize it. + - 若代码可大幅精简,主动重写优化。 +- Validation standard: from a senior engineer's perspective, if the code is overly complex, simplify it immediately. + - 校验标准:以资深工程师视角判断,代码若过于复杂,立即简化。 + +### 3.3 精准修改 - Precise modifications + +- Modify only code directly related to the current task. + - 仅修改与当前任务直接相关的代码内容。 +- Do not opportunistically optimize adjacent code, comments, or formatting. + - 不顺手优化相邻代码、注释、排版格式。 +- Do not refactor code modules that are already working correctly. + - 不重构原本可以正常运行的代码模块。 +- Strictly match the project's existing code style and preserve original coding conventions. + - 严格匹配项目现有代码风格,保留原有编码习惯。 +- Unused imports or deprecated variables resulting from this modification may be deleted directly. + - 因本次修改产生的无效导入、废弃变量,可直接删除。 +- If you discover existing dead code or redundant content in the project, only provide a textual reminder; do not delete it without permission. + - 发现项目中原有的死代码、冗余内容,仅做文字提醒,不擅自删除。 + +### 3.4 目标驱动执行 - Goal-driven execution + +- Before executing a task, define clear and actionable success criteria. + - 执行任务前,定义清晰、可落地的成功标准。 +- Translate "fix a bug" into: write a test case to reproduce the issue, then debug until the test case passes. + - 将“修复 Bug”转化为:编写用例复现问题,再调试至用例正常通过。 +- Translate "add validation feature" into: write test cases for abnormal inputs and ensure all pass. + - 将“新增校验功能”转化为:针对异常输入编写测试用例,保证全部通过。 +- Translate "refactor code" into: after completing the refactoring, ensure all existing test cases run normally. + - 将“代码重构”转化为:完成重构后,确保原有所有测试用例正常运行。 +- For multi-step complex tasks, first output a brief execution plan, and mark the verification method for each step. + - 多步骤复杂任务,先输出简短执行计划,同时标注每一步的验证方式。 + +--- + +## 4. 持久化分流 + +### 4.1 应用/部署级(非按用户) + +- 写入 `.env` / `setting.py` / 打包只读资源(如 `Missions/`、字体)。 +- 全站相同或仅部署环境不同。 +- 成品运行期禁止写盘。 + +### 4.2 按用户账号 + +- 仅允许 Auth 表 `user_defined_3`、`user_defined_4`(经 `models.db_operate` 中间件)。 + - `user_defined_3`:用户内容(航迹等)。 + - `user_defined_4`:用户自定义设置(摇杆映射等,多层字典可扩展)。 + +### 4.3 禁止 + +- 禁止本程序读写 `user_defined_1`、`user_defined_2`(其他程序字段)。 +- 不用 `user_defined_5`。 +- 禁止用本地 `config/*.json`、`track_data/*.csv` 作为成品持久化出口。 +- 未登录不可持久化用户数据(仅内存,提示登录)。 +- 禁止以本地 `track_data/*.csv`、`config/*.json` 作为成品持久化出口。 +- 实现细节见 `Docs/status.md` 与 `models/state_bus/lifecycle.py`。 + +--- + +## 5. UI 结构 - UI Structure + +### 5.1 抽象模型 + +```text +UI = Layers + Tokens + ComponentMap + Pipeline + Scale/DPI + Constraints +``` + +- Layers:Frame / Container / Widget / Overlay / Chart / Page。 +- Tokens:品牌、表面、文字、状态、信号、图表、开关、字体、圆角、间距、窗口几何、资源路径。 +- ComponentMap:objectName / 类名 → 消费的令牌类别。 +- Pipeline:`setting.py` → `UITheme` → QSS / `charts.theme` → 组件。 +- Scale/DPI:Qt High DPI + `UITheme.apply_scale` / `UITheme.scaled`。 +- Constraints:禁止硬编码、复用优先、层级嵌套方向、文字不截断、控件不重叠。 + +### 5.2 层级结构 + +| 层级 | 职责 | 层级关系 | 约束 | +|------|------|----------|------| +| Frame | 主业务窗体 / 并发对话框 | 窗体间有上下层级 | 主业务窗体唯一;弹窗可新建并发 Frame | +| Container | 版式分割 | 容器可嵌套,有上下层级 | 后续层组件布局在容器中 | +| Widget | 最小交互单位 | 无上下层级 | 尺寸完整显示,互不遮挡 | +| Overlay | 全局 / 本地蒙版 | global 跨容器;local 指定容器 | 半透明;透明背景不穿透下层 | +| Chart | 图表子系统 | 图表间有上下层级 | 可叠 widget;冲突需用户决策 | +| Page | 页面内容根 | 一页一内容根 | 挂入唯一主窗页面栈 | + +- 页面文件:`pages/page_{页面业务名称}.py`。 +- 控件使用前先查 `components/` 是否可复用;无则先入控件库再调用。 +- 窗体层(Frame):主业务窗体仅一个;如有弹窗,可新建为并发 Frame;窗体与窗体之间有层级上下之分。 +- 容器层(Container):在窗体层中用于版式分割;后续层中的组件均布局在容器层中;容器可嵌套排列,有层级上下之分。 +- 图表(Chart):有层级上下之分;除图表原生控件外,可使用控件层控件,在图表指定位置叠加;如自定义控件与图表原生控件存在功能冲突或重复,你可提出建议处理方案,但必须让我来决定采用哪种方案。 +- 控件层(widget):各类实现具体交互功能的输入输出组件;组件之间没有层级上下之分;多控件可在其共同所在的容器中实现布局。 +- 蒙版层(overlay):分为全局蒙版(global_overlay)和本地蒙版(local_overlay);蒙版一般为半透明设置;全局蒙版尺寸与窗体层相同,可跨容器层显示覆盖;本地蒙版仅能实现容器级覆盖,且使用时需先指定所要覆盖的容器,可实现跨控件层覆盖。 +- 控件为 UI 构成的最小结构单位,其自定义尺寸大小必须能得到完整显示,且相互之间不遮挡。 +- 容器可跟随控件大小弹性变化,但如果容器被指定了固定尺寸除外;如出现控件尺寸和容器尺寸冲突,你可提出建议处理方案,但必须让我来决定采用哪种方案。 +- 任何有可能会导致信息显示不全的地方,或任何会导致尺寸冲突的地方,必须让我知道。 +- 控件在使用时,先查看控件库 `components/` 中是否有相同功能的组件,有则复用;若没有,则设计并先存入控件库,再进行调用;设计控件时,需满足可复用的要求。 +- 所有涉及尺寸缩放的场合,直接计算出缩放后的值,填入相应变量待调用使用,存放位置根据业务性质,按本文件要求存放到相应位置。 + +### 5.3 命名与引用 + +- 页面:`page_{业务名}.py` +- UI 组件:`{层名}[Frame/Container/widget/overlay]_{组件功能名}_{业务功能名}` +- 非 UI 组件:`{前缀}[App/component/Until/API/Constant]_{主要功能名称}.py` +- 文档 / 评审 / 表头:`{中文名} - {英文名}`,英文名对齐类名或 objectName。 +- 新增表格必须复用「表头字段词典」,不得另造同义列名。 + +### 5.4 表头字段词典(全文统一) + +| 中文名 - 英文名 | 含义 | +|-----------------|------| +| 令牌 - Token | `UITheme` 常量名或语义别名 | +| 色值 - Hex | `#RRGGBB` | +| 取值 - Value | 引用其它令牌或字面量 | +| 用途 - Usage | 使用场景 / 说明(取代旧列名「说明」「典型用途」) | +| 色键 - Color Key | 图表字段 `color_key` | +| 角色 - Role | 字体用途角色 | +| 字体族 - Family | 字库 family 名 | +| 路径 - Path | 资源或文件路径 | +| 字号 - Size | 字号 pt | +| 场景 - Scene | 应用对象 / 布局场景 | +| 圆角 - Radius | border-radius | +| 级别 - Level | 间距档位 | +| 典型值 - Typical | 设计稿常用数值 | +| 名称 - Name | 组件中英名(`{中文名} - {英文名}`) | +| 功能标识 - Feature ID | `feature_id` | +| 设计尺寸 - Design Size | 设计稿 w×h | +| 尺寸策略 - Size Strategy | 实际窗口如何由设计稿推导 | +| 类型 - Type | 资源类别 | + +### 5.5 UI 组件命名规范 + +- 所有组件命名遵循:`{层名}[Frame/Container/widget/overlay]_{组件功能名}(同样类型的控件功能名相同,如按钮、下拉菜单等,同种功能,功能名唯一)_{自定义的组件名}(最能描述组件所承担的业务功能的名称)` +- 当前数据在图表中显示的透明度为 90%,历史数据透明度为 70%。 +- 浮窗/摇杆语义色纳入 UI 标准(`setting` → `UITheme`): + - 当前数据黄色 `DATA_CURRENT`(= `CHART_PITCH`)。 + - 历史数据蓝色 `DATA_HISTORY`(= `FLASH_BLUE`)。 + - 浮窗固定行(时间/空速/高度)用 `CHART_FLOAT_DEFAULT`。 + - 下拉选中字段当前黄/历史蓝。 +- 在某层背景为透明的状态下,该层背景色不应穿透其下层 UI 层的背景色。 +- 对于所有层级,层级与上下层级组件之间,层级与本层组件之间,间距保持同一套规范执行,可在专门文件中设定该参数。 +- Never 用数学表达式来处理缩放。 +- Never 重复设计功能相同的控件。 +- Never UI 样式参数硬编码。 +- Never UI 组件不按规范随意取名。 +- Never 控件显示重叠或遮挡。 +- Never 除可变菜单类控件的菜单内容外,其他控件上的显示,如按钮标签、文本框、标题栏等输出信息的地方,文字出现显示不全或者遮挡。 + +--- + +## 6. 设计系统与令牌值 + +> 色值 / 字号字面量真相源:`setting.py` 的 `UI_COLOR_*` / `UI_FONT_*` 等。 +> `components.Frame.Frame_Theme_UI.UITheme` 再导出上述常量,并生成 QSS(`UITheme.qss()`)。 +> 图表配色适配:`charts/theme.py`(委托 UITheme,不另开色票)。 +> 改色 / 改字号 → 改 `setting` → 本文件同步更新。 +> 命名约定:凡组件 / 控件 / 可复用 UI 单元,以及所有表头字段,一律写 `{中文名} - {英文名}`,避免中英混用歧义。 + +### 6.1 品牌与视觉方向 + +- 产品名:`TALENT ACADEMY` +- 基调:深蓝航空 HUD(深底 + 冷蓝表面 + 亮蓝强调) +- 窗口透明度:`WINDOW_ALPHA = 0.85` +- Win32 标题栏 - Title Bar:暗色,底 `BG`,字 `FG` + +### 6.2 品牌色(蓝阶锚点) + +| 令牌 - Token | 色值 - Hex | 用途 - Usage | +|--------------|------------|--------------| +| `BLUE_DEEP` | `#0B1420` | 页面底、折叠体 - Collapsible 底 | +| `BLUE_MID` | `#152A40` | 表面 - Surface / 卡片 - Card / 工具栏 - Toolbar | +| `BLUE_LIGHT` | `#2E4F73` | 主色 / 边框 | + +语义别名: + +| 令牌 - Token | 取值 - Value | 用途 - Usage | +|--------------|--------------|--------------| +| 主色 `ACCENT` | `BLUE_LIGHT` `#2E4F73` | 按钮 - Button、选中、焦点 | +| 主色悬浮 `ACCENT_HOVER` | `#3A638F` | hover | +| 主色背景 / 页面底 `BG` | `BLUE_DEEP` | 全局底 | +| 表面 - Surface `SURFACE` | `BLUE_MID` | 卡片 - Card / 图表 - Chart 容器 | +| 边框 `BORDER` | `BLUE_LIGHT` | 与主色同阶 | + +### 6.3 表面层级 + +由深到浅;嵌套时下一层应更深或更暗。 + +| 令牌 - Token | 色值 - Hex | 用途 - Usage | +|--------------|------------|--------------| +| `BG` | `#0B1420` | 页面 / 主窗 / 对话框 - Dialog 底 | +| `METRIC_SURFACE` | `#101E2E` | 指标卡 - MetricCard 内底 | +| `CHART_WELL` | `#112233` | 图表井区 - Chart Well、嵌套下拉框 - ComboBox、对比样式面板 - CompareStylePanel(相对 SURFACE 明度 ×0.8) | +| `SURFACE` | `#152A40` | 卡片 - Card、工具栏 - Toolbar、杆量输入面板 - StickInputPane | +| `METRIC_BORDER` | `#243D58` | 指标卡 - MetricCard 描边(2px) | + +航迹视窗自由 / 锁定钮(叠在 `CHART_WELL` 上,填充须 ≠ 井区): + +| 令牌 - Token | 色值 - Hex | 用途 - Usage | +|--------------|------------|--------------| +| `TRACK_LOCK_FREE_BG` | `#1E3A56` | 自由态底(抬起面 + 标准描边) | +| `TRACK_LOCK_FREE_BORDER` | `BORDER` `#3A5F82` | 自由态边框 | +| `TRACK_LOCK_FREE_HOVER_BG` | `BLUE_LIGHT` `#2E4F73` | 自由悬停加亮 | +| `TRACK_LOCK_ON_BG` | `FLASH_BLUE` `#1e90ff` | 锁定态底 | +| `TRACK_LOCK_ON_HOVER_BG` | `#4AA3FF` | 锁定悬停加亮 | + +折叠区 - Collapsible: + +| 令牌 - Token | 色值 - Hex | 用途 - Usage | +|--------------|------------|--------------| +| `COLLAPSIBLE_TITLE_BG` | `BLUE_DEEP` | 折叠内容区 - CollapsibleBody 实际用此作底(见 QSS) | +| `COLLAPSIBLE_BODY_BG` | `BLUE_MID` | 折叠标题条 - CollapsibleTitle | + +### 6.4 文字色 + +| 令牌 - Token | 色值 - Hex | 用途 - Usage | +|--------------|------------|--------------| +| 主文字 `FG` | `#C5D6E8` | 正文、数值默认 | +| 次要文字 `FG_MUTED` | `#7A9BB8` | 提示、单位、弱化标签 - Muted / 副标题 - Subtitle | +| 标题 `FG_TITLE` | `#8AABC8` | 页标题 - Title / 指标标题 - MetricTitle / 对话框标题 - DialogTitle | +| 强调上字 `FG_ON_ACCENT` | `#E8F1F8` | 主色按钮 - FlatButton 文字 | + +### 6.5 状态色与信号 + +| 令牌 - Token | 色值 - Hex | 用途 - Usage | +|--------------|------------|--------------| +| `SUCCESS` | `#2ecc71` | 正常 / 健康绿带 | +| `WARNING` | `#f39c12` | 警告;复选框 - Checkbox 选中;监测停止 - Monitor STOP hover 邻近色 | +| `ERROR` | `#e74c3c` | 错误 / 危险;监测保存 - Monitor SAVE | +| `FLASH_BLUE` | `#1e90ff` | 冷舱强调、强调小钮 - IconSmAccent | + +信号(自检清单 - Check List): + +| 令牌 - Token | 色值 - Hex | 用途 - Usage | +|--------------|------------|--------------| +| `SIGNAL_IDLE` | `#4A6278` | 空闲 | +| `SIGNAL_RUN` | `#5E7F9A` | 运行中 | +| `SIGNAL_OK` | `#5F8F75` | 通过 | +| `SIGNAL_FAIL` | `#946868` | 失败 | + +业务动态着色(函数,非固定令牌 - Token): + +- `cht_color`:低于阈值 → `FLASH_BLUE`,否则 `ERROR` +- `torque_color`:区间 → `FLASH_BLUE` / `WARNING` / `ERROR` +- `health_color`:带区 → `FLASH_BLUE` / `SUCCESS` / `WARNING` / `ERROR` +- `status_kind_color`:`ok`/`warn`/`error`/`muted` + +### 6.6 图表色 + +折线 / 字段(`charts.theme.field_line_color`): + +| 令牌 - Token | 色值 - Hex | 色键 - Color Key | 用途 - Usage | +|--------------|------------|------------------|--------------| +| `CHART_TORQUE` | `#4a90d9` | torque | 扭矩折线 | +| `CHART_HEALTH` | `#e67e22` | health | 健康折线 | +| `CHART_AIRSPEED` | `#2ecc71` | airspeed | 空速折线 | +| `CHART_GROUNDSPEED` | `#1abc9c` | groundspeed | 地速折线 | +| `CHART_SPEED_RATE` | `#3498db` | speed_rate | 空速变化率 | +| `CHART_ACCEL_RATE` | `#8e44ad` | accel_rate | 加速度变化率 | +| `CHART_PITCH` | `#f1c40f` | pitch | 俯仰折线 | +| `CHART_BANK` | `#e74c3c` | bank | 坡度折线 | +| `CHART_ETA_PROP` | `#00cec9` | eta_prop | 推进效率代理 | +| `CHART_DI_PROXY` | `#e17055` | di_proxy | 诱导阻相对强度 | +| `CHART_VG_COURSE` | `#0984e3` | vg_course | 沿航迹地速 | +| `CHART_REFERENCE` | `#9b59b6` | (参考) | 历史对比参考线 | + +数据语义色(浮窗 / 摇杆 / 航迹): + +| 令牌 - Token | 取值 - Value | 用途 - Usage | +|--------------|--------------|--------------| +| `DATA_CURRENT` | `CHART_PITCH` `#f1c40f` | 当前数据(黄) | +| `DATA_HISTORY` | `FLASH_BLUE` `#1e90ff` | 历史数据(蓝) | +| `CHART_FLOAT_DEFAULT` | `#e8edf5` | 浮窗固定行(时间 / 空速 / 高度) | + +圈次 - LAP 固定色 `CHART_LAP_COLORS`: + +1. `#4a90d9` +2. `#e67e22` +3. `#2ecc71` +4. `#e74c3c` + +健康色带填充(alpha = `CHART_HEALTH_SPAN_ALPHA` = **0.18**): + +| 令牌 - Token | 色值 - Hex | 用途 - Usage | +|--------------|------------|--------------| +| `CHART_HEALTH_RED_FILL` | `#e74c3c` | 健康红带填充 | +| `CHART_HEALTH_ORANGE_FILL` | `#f39c12` | 健康橙带填充 | +| `CHART_HEALTH_BLUE_FILL` | `#1e90ff` | 健康蓝带填充 | + +透明度:`CHART_LIVE_OPACITY` = **0.90**(当前);`CHART_HISTORY_OPACITY` = **0.70**(历史)。 +历史对比:原色 HSV 降饱和(默认保留 0.45)+ 历史透明度(`desaturate_color` / `compare_*_qcolor`)。 + +### 6.7 开关 - Toggle 色 + +| 令牌 - Token | 色值 - Hex | 用途 - Usage | +|--------------|------------|--------------| +| `TOGGLE_TRACK_OFF` | `#122436` | 轨道关 | +| `TOGGLE_TRACK_ON` | `BLUE_LIGHT` | 轨道开 | +| `TOGGLE_TRACK_BORDER` | `#3A5F82` | 轨道边框 | +| `TOGGLE_KNOB_OFF` | `#8FAEC8` | 旋钮关 | +| `TOGGLE_KNOB_ON` | `FG_ON_ACCENT` | 旋钮开 | + +对应组件:滑动开关 - SlideToggle。 + +### 6.8 字体 + +| 角色 - Role | 字体族 - Family | 路径 - Path | +|-------------|-----------------|-------------| +| UI 正文 | `Noto Sans SC` | `resources/fonts/NotoSansSC-{Regular,Bold}.ttf` | +| 技术数值 | `Orbitron` | `resources/fonts/Orbitron-{Medium,Bold}.ttf` | + +字号(pt,`UITheme.FONT_*`): + +| 令牌 - Token | 字号 - Size | 用途 - Usage | +|--------------|-------------|--------------| +| `FONT_XS` / `FONT_HINT` | 8 | 极小提示 | +| `FONT_SM` | 9 | 弱化标签 - Muted、图表提示 - ChartHint、状态栏 - StatusBar、图表工具钮 - ChartTool | +| `FONT_MD` | 10 | 全局默认、指标标题 - MetricTitle、正文 | +| `FONT_LG` | 11 | 副标题 - Subtitle、主按钮 - FlatButton、对话框标题 - DialogTitle | +| `FONT_METRIC_TITLE` | 12 | 指标卡标题 - MetricCardTitle | +| `FONT_UNIT` | 14 | 单位 - MetricUnit | +| `FONT_TITLE` / `FONT_LAP_TOTAL` | 20 | 页标题 - Title / 圈时总计 - LapTotalValue | +| `FONT_RACE_SUCCESS` | 18 | 完赛成功对话框标题 | +| `FONT_CHT` | 36 | 大指标数值 - MetricValue / CHTValue(Orbitron) | + +API:`UITheme.font(size, bold)` / `UITheme.font_tech(size, bold)`。 + +### 6.9 圆角 + +| 场景 - Scene | 圆角 - Radius | +|--------------|---------------| +| 输入框 - LineEdit / 按钮 - Button / 下拉框 - ComboBox | **3px** | +| 卡片 - Card / 表面 - Surface / 折叠盒 - CollapsibleBox 外框 | **4px** | +| 指标卡 - MetricCard / 自检列表卡 - CheckListCard | **8px** | +| 复选框指示器 - Checkbox Indicator | **2px** | + +### 6.10 间距系统 + +#### 基础 + +- 无独立 `SPACING` 常量;惯例以 **4 的倍数** 为设计稿尺寸。 +- 缩放:`UITheme.apply_scale(window_w, window_h, design_w, design_h)` + → `scale = min(w/design_w, h/design_h)` + → `UITheme.scaled(value, minimum=0)` +- 所有边距 / 间距 / 控件尺寸应走 `scaled(design, min)`,避免硬编码 px。 + +#### 常用设计稿间距(未缩放) + +| 级别 - Level | 典型值 - Typical | 场景 - Scene | +|--------------|------------------|--------------| +| 紧 | 4–8 | 图表工具栏 - ChartToolbar 内边、指标卡 - MetricCard 内 pad、小 gap | +| 组件 | 10–14 | 按钮列 spacing、训练辅助页 - Training Assist 区块 gap | +| 区块 | 16–24 | 列表项、对话框 - Dialog 边距 | +| 页面 | 20–36 | 训练辅助页 - Training Assist pad=20;仿真自检页 - Sim Check 外框 32–36 | + +训练辅助页 - Training Assist 参考:`pad = scaled(20, 8)`,`gap = scaled(14, 6)`。 +对话框 - Dialog 边距常见:`scaled(24–28, 10–12)` 水平,`scaled(20–24, 10)` 垂直。 + +### 6.11 窗口几何 + +窗口几何 - WindowGeom + `FEATURE_WINDOW_SPECS`: + +| 功能标识 - Feature ID | 设计尺寸 - Design Size | 尺寸策略 - Size Strategy | +|-----------------------|------------------------|--------------------------| +| 训练辅助 - training_assist | 680×820 | 主窗:`screen_w/3` × `screen_h*3/5+180` | +| 认证流 - flow.auth | 360×400 | `scale_dialog_size` | +| 仿真自检流 - flow.sim_check | 460×400 | `scale_dialog_size` | + +对话框 - Dialog 缩放:不超过主窗上限,且 `scale ≤ 1`;下限约 280×140。 +进入功能页时:`apply_scale(实际窗, design)` → 后续 `scaled()` 生效。 + +### 6.12 资源路径 + +| 类型 - Type | 路径 - Path | +|-------------|-------------| +| 字体 | `resources/fonts/` | +| 图标 | `resources/icons/`(含下拉箭头 - `combo_down_arrow.png`) | +| 应用图标 | `resources/icons/icon.png` | + +打包后 root = `sys._MEIPASS`,开发 = 项目根。 + +### 6.13 控件清单(objectName → 样式) + +格式:`{中文名} - {英文名}`;英文名即 QSS `objectName`(或类名)。 + +| 名称 - Name | 用途 - Usage | +|-------------|--------------| +| 卡片 - Card / 表面 - Surface | 表面卡片(1px border, r=4) | +| 无边框表面 - SurfaceFlat | 无边框表面 | +| 指标卡 - MetricCard | 指标卡(2px metric border, r=8) | +| 自检列表卡 - CheckListCard | 自检列表卡 | +| 页标题 - Title / 副标题 - Subtitle / 弱化标签 - Muted / 对话框标题 - DialogTitle | 文字层级 | +| 指标卡标题 - MetricCardTitle / 指标标题 - MetricTitle / 指标数值 - MetricValue / 指标单位 - MetricUnit | 指标 | +| CHT 数值 - CHTValue / 信息数值 - InfoValue / 圈时总计 - LapTotalValue | 大数值 / 信息 | +| 会话任务标签 - SessionMissionLabel / 会话模式标签 - SessionModeLabel | 圈板 - LapBoard 会话行 | +| 主按钮 - FlatButton | 主按钮 | +| 控制器设定钮 - ControllerSettingsButton | 摇杆面板设定(较小 padding/字号) | +| 图表工具钮 - ChartTool / 小图标钮 - IconSm / 强调小钮 - IconSmAccent | 紧凑 / 强调小钮 | +| 监测按钮 - MonitorButton + `monitorKind` | `stop`→WARNING,`save`→ERROR | +| 折叠盒 - CollapsibleBox / 折叠标题条 - CollapsibleTitle / 折叠内容区 - CollapsibleBody / 折叠标题钮 - CollapsibleHeader | 折叠区 - CollapsibleSection | +| 图表工具栏 - ChartToolbar / 工具栏单元格 - ChartToolbarCell / 图表提示 - ChartHint | 图表工具栏 | +| 杆量输入面板 - StickInputPane / 对比样式面板 - CompareStylePanel | 图表嵌套井区控件 | +| 自检等待 - CheckWait / 自检运行 - CheckRun / 自检通过 - CheckOk / 自检失败 - CheckFail / 自检详情 - CheckItemDetail | 自检状态字色 | +| 状态栏 - StatusBar / 状态事件 - StatusEvent / 状态速率 - StatusRate | 状态栏 | +| 操作栏 - ActionBar / 操作栏行 - ActionBarRow / 监测槽位 - MonitorSlot | 透明布局容器 | +| 滑动开关 - SlideToggle | 开关轨道 / 旋钮色见 Toggle 色 | +| 圆角滑条 - RoundSlider | 弹窗内连续调节 | +| 信号灯指示 - SignalLightIndicator | 自检旁路指示 | +| 指标条 - MetricStrip | 多枚指标卡 - MetricCard 横排 | +| 圈板 - LapBoard | 圈次与会话信息 | +| 卡片框 - CardFrame | 工厂:产出 指标卡 - MetricCard / 无边框表面 - SurfaceFlat / 卡片 - Card | +| 主题提示框 - ThemeAlertDialog | 主题化提示对话框 - Dialog | +| 开始监测对话框 - StartMonitoringDialog | 监测启动选项 | +| 硬件设定对话框 - HardwareSettingsDialog | 摇杆映射等硬件设定 | +| 任务切换对话框 - MissionSwitchDialog | 监测中切换任务 | +| 航迹库选择对话框 - TrackStorePickDialog | 云端航迹列表选取 | +| 完赛成功对话框 - RaceSuccessDialog | 任务完成结果 | +| 影子跟飞设定面板 - ShadowFollowSettings | 跟飞参数(Container 面板,非独立 Frame) | +| 影子跟飞选项面板 - ShadowFollowOptions | 跟飞选项 | +| 轨迹保存对话框 - TrajectorySaveDialog | 轨迹保存确认(写入 Auth) | +| 仿真未找到对话框 - SimNotFoundDialog | 连接失败提示 | +| 趋势图 - TrendChart | 图表外观层成品(facade) | +| 时间轴拖条 - TimelineScrubber | 图表时间 scrub | +| 航迹色带工具栏 - TrackColorToolbar | 航迹图色带切换 | +| 图表 HUD 浮窗 - ChartHudOverlay | 趋势图时标浮窗 | + +- 复选框 - Checkbox:indicator 14×14,未选井区底,选中 = `WARNING`。 +- 下拉框 - ComboBox:全局用 `SURFACE`;图表工具栏 - ChartToolbar 嵌套下拉用 `CHART_WELL`。 + +### 6.14 使用约定 + +1. 禁止在页面/图表 - Chart 里散落硬编码色值;走 `UITheme.*` 或 `charts.theme` helper。 +2. 新表面嵌套:页面 `BG` → 卡片 - Card / 表面 - Surface `SURFACE` → 图表井区 - Chart Well `CHART_WELL`,勿反过来。 +3. 新按钮优先复用 主按钮 - FlatButton / 图表工具钮 - ChartTool / 监测按钮 - MonitorButton 的 objectName,勿另写一长段 QSS。 +4. 布局尺寸一律 `UITheme.scaled(design, min)`。 +5. 文档与代码评审中提及组件时,使用 `{中文名} - {英文名}`,英文名与类名或 objectName 对齐。 +6. 新增表格时,表头必须复用上文「表头字段词典」,不得另造同义列名(如勿再写「说明」「典型用途」替代 用途 - Usage)。 + +--- + +## 7. 生成管线与约束 + +```text +setting.py(参数真相源) + → UITheme(再导出常量、font/font_tech、qss()) + → QSS / 组件 objectName + → charts.theme(委托 UITheme,不另开色票) + → 图表 color_key 映射 +``` + +- 修改参数:改 `setting` → 同步文档 → 不散落硬编码。 +- 新表面嵌套:页面 `BG` → `SURFACE` → `CHART_WELL`,勿反过来。 +- 新按钮优先复用 `FlatButton` / `ChartTool` / `MonitorButton`,勿另写长 QSS。 +- 布局尺寸一律 `UITheme.scaled(design, min)`。 +- 组件评审使用 `{中文名} - {英文名}`。 + +--- + +## 8. 缩放与高 DPI 抽象 + +### 8.1 DPI 初始化 + +- 所有 DPI 设置必须在 `QApplication` 实例化之前。 +- 使用 PassThrough 舍入策略支持非整数缩放。 +- `AA_EnableHighDpiScaling` 必须配舍入策略,不单独使用。 +- 入口:`app_bootstrap.py` / `app_run.py` / `create_app()` / `_ensure_pygame()`。 + +### 8.2 双缩放职责 + +| 职责 | 处理方 | 说明 | +|------|--------|------| +| OS / 显示器 DPI | Qt High DPI(`app_bootstrap`) | 逻辑像素 | +| 窗口相对设计稿 | `UITheme.apply_scale` / `scaled` | 设计稿倍率 | + +- 两者不替代、不取消。 + +### 8.3 布局规范 + +- 禁止 `setGeometry` / `move` / `resize` 对控件绝对定位;顶层窗口初始尺寸除外。 +- 必须使用 `QVBoxLayout` / `QHBoxLayout` / `QGridLayout` / `QFormLayout` / `QSplitter`。 +- 主窗结构:顶部固定高 → 中部伸缩 → 底部固定高。 +- 比例分配用 `setStretchFactor` 或 `addWidget(..., stretch=...)`。 + +### 8.4 尺寸与字体 + +- 硬编码像素值必须能被 DPI 缩放正确响应。 +- 字体使用 `QFont`,优先系统默认字体。 +- 图标优先矢量;位图需多倍图或说明缩放策略。 + +### 8.5 pyqtgraph + +- 图表控件随父容器伸缩,设置合适 `sizePolicy`。 +- 散点高 DPI 模糊可 `useCache=False`,需注释原因。 +- 坐标轴、图例字体随 DPI 一致缩放。 + +### 8.6 pygame + +- pygame 无跨平台高 DPI。 +- Windows 下可用 ctypes 调 `SetProcessDPIAware()`,必须在 pygame 初始化前。 +- 必须注明 Windows 专属。 + +### 8.7 风险规避 + +- 严禁 `QtWin + win32gui.SetWindowLong` 自定义原生窗口。 +- 如需无边框:纯 Qt `FramelessWindowHint` + 自绘标题栏,并注释限制。 +- 非整数缩放必须 PassThrough。 +- 不升级到 PySide6;不引入额外 Qt 主题库;不通过原生 API 改造无边框客户区。 + +--- + +## 9. 状态机接口 + +- 本项目的所有全局时间状态仅由三部分数据业务决定:1_数据获取,2_数据保存,3_数据消费。 +- 各大业务可根据自身数据量的大小和性能需要建立缓存机制,但各业务的缓存仅限其自己使用。 +- 对于数据获取业务,仅有 开始(start)暂停(paused)停止(stop)三种全局时间状态。 +- 对于数据保存,仅有开始(start)停止(stop)两种全局时间状态。 + - 一旦数据获取业务开始获取到数据,则数据保存业务必须同步启动,直至获取到的数据被全部保存完毕,则停止。 + - 对于数据获取过程中的暂停,对于数据保存业务来说,可以进行异步操作:如果当前还有数据没有保存完,则不停止,直至完成;如果没有待保存的数据,则停止;如重新出现待保存的数据,则开始。 +- 成品持久化落点:保存板块的内存主缓冲冲刷后,经 `models/track_store` / `models/user_settings_store` 与 `models.db_operate` 写入 Auth 表 `user_defined_3`(航迹)与 `user_defined_4`(用户设置,如摇杆映射);禁止以本地 `track_data/*.csv`、`config/*.json` 作为成品持久化出口;未登录仅可内存持有并提示登录。实现细节见 `Docs/status.md` 与 `models/state_bus/lifecycle.py`。 +- 数据保存逻辑上可以采用一主一副的并行架构(根据业务需要而定,非必需)。 + - 主逻辑负责保存数据。 + - 副本逻辑用来给数据消费业务(对实时性要求高,以至于性能需求上无法容忍先保存后读取的方式)提供实时数据。 + - 副本必须为主数据的增量拷贝,两者仅相差最后一个数据保存批次。 +- 数据消费业务仅有 开始(start)暂停(paused)停止(stop)三种全局时间状态。 + - 数据消费业务可根据业务需要自行设定自己的数据缓存方式,同板块下的不同子业务之间可共享同一个缓存。 +- 所有数据消费业务,非紧急类的,只能采用读取已保存数据的方式工作;紧急类的,只能从唯一副本逻辑中获取并截取自己所需的数据片段,不允许增加任何形式的额外数据副本。 +- 如以上三个板块同时协同工作,则不同数据业务板块在时间状态上开始必须同步对齐,停止可以异步对齐,以数据获取业务为最高优先级,其他业务板块开始后没数据可以等待(如仅需其中一个板块工作,则不受此限制)。 +- 所有用于协同的状态值必须在总线状态机上维护。 +- 本项目有且只能有一个总线状态机,且其中相同状态必须有唯一状态名和布尔值。 +- 所有业务逻辑只能自行实现所需逻辑,与各数据业务定义的时间状态进行对齐。 +- UI 只消费状态,不定义状态。 +- Never 业务逻辑的实现不得修改全局时间状态的定义。 +- Never 跨数据业务共享同一套缓存。 +- Never 需要板块间协同维护的状态值各自散落在各个板块中,通过交叉调用方式获取。 + +--- + +## 10. 图表标准名称 + +- 性能图:左右 Y 轴共用一个 X 轴。 +- 航迹图:平面坐标系,基于经纬度航迹。 +- 摇杆图:外设操纵杆控制信号相关图与参数列表。 +- 后续交互中采用上述图表名称作为标准名称。 +- 图表(Chart)有层级上下之分;除图表原生控件外,可使用控件层控件,在图表指定位置进行叠加;如自定义控件与图表原生控件存在功能冲突或重复,你可提出建议处理方案,但必须让我来决定采用哪种方案。 + +--- + +## 11. 再编码检查清单 + +- □ 令牌槽位是否覆盖:品牌、语义别名、表面、文字、状态、信号、图表、开关、字体、圆角、间距、窗口几何、资源路径。 +- □ `setting.py` 是否作为参数真相源。 +- □ `UITheme` 是否再导出常量并生成 `qss()`。 +- □ `charts.theme` 是否委托 `UITheme`,未另开色票。 +- □ 组件 `objectName` / 类名是否按 `{中文名} - {英文名}` 对齐。 +- □ 表面嵌套方向是否正确:`BG` → `SURFACE` → `CHART_WELL`。 +- □ 按钮是否优先复用 `FlatButton` / `ChartTool` / `MonitorButton`。 +- □ 布局尺寸是否一律 `UITheme.scaled(design, min)`。 +- □ DPI 初始化是否在 `QApplication` 之前,且使用 PassThrough。 +- □ 双缩放职责是否分离:Qt DPI vs `UITheme.apply_scale`。 +- □ 是否禁止绝对定位、自定义原生窗口、白名单外依赖。 +- □ 文字是否不截断,控件是否不重叠、不遮挡。 +- □ 当前 / 历史数据透明度语义是否保留。 +- □ 浮窗 / 摇杆语义色、圈次固定色、健康色带填充、信号色是否按槽位消费。 +- □ 表头字段是否复用「表头字段词典」,未另造同义列名。 + +--- + +## 12. 构建命令与环境 + +### 12.1 构建命令 + +```bash +# 开发服务器 +# 生产构建 +# 格式化 +``` + +> 当前原文件未给出具体命令,待补充。执行前必须先确认环境为本项目默认开发环境。 + +### 12.2 环境 + +- 安装依赖和运行代码时,先检查环境是否为当前在开发项目默认的环境,确认后方可进行下一步操作。 \ No newline at end of file diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..b3bd26cd --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,337 @@ +# DESIGN.md + +> 色值 / 字号字面量真相源:`setting.py` 的 `UI_COLOR_*` / `UI_FONT_*` 等。 +> `components.Frame.Frame_Theme_UI.UITheme` 再导出上述常量,并生成 QSS(`UITheme.qss()`)。 +> 图表配色适配:`charts/theme.py`(委托 UITheme,不另开色票)。 +> 改色 / 改字号 → 改 `setting` → 本文件同步更新。 +> **命名约定**:凡组件 / 控件 / 可复用 UI 单元,以及**所有表头字段**,一律写 `{中文名} - {英文名}`,避免中英混用歧义。 + +### 表头字段词典(全文统一) + +| 中文名 - 英文名 | 含义 | +|-----------------|------| +| 令牌 - Token | `UITheme` 常量名或语义别名 | +| 色值 - Hex | `#RRGGBB` | +| 取值 - Value | 引用其它令牌或字面量 | +| 用途 - Usage | 使用场景 / 说明(取代旧列名「说明」「典型用途」) | +| 色键 - Color Key | 图表字段 `color_key` | +| 角色 - Role | 字体用途角色 | +| 字体族 - Family | 字库 family 名 | +| 路径 - Path | 资源或文件路径 | +| 字号 - Size | 字号 pt | +| 场景 - Scene | 应用对象 / 布局场景 | +| 圆角 - Radius | border-radius | +| 级别 - Level | 间距档位 | +| 典型值 - Typical | 设计稿常用数值 | +| 名称 - Name | 组件中英名(`{中文名} - {英文名}`) | +| 功能标识 - Feature ID | `feature_id` | +| 设计尺寸 - Design Size | 设计稿 w×h | +| 尺寸策略 - Size Strategy | 实际窗口如何由设计稿推导 | +| 类型 - Type | 资源类别 | + +--- + +## 品牌与视觉方向 + +- 产品名:`TALENT ACADEMY` +- 基调:深蓝航空 HUD(深底 + 冷蓝表面 + 亮蓝强调) +- 窗口透明度:`WINDOW_ALPHA = 0.85` +- Win32 标题栏 - Title Bar:暗色,底 `BG`,字 `FG` + +--- + +## 品牌色(蓝阶锚点) + +| 令牌 - Token | 色值 - Hex | 用途 - Usage | +|--------------|------------|--------------| +| `BLUE_DEEP` | `#0B1420` | 页面底、折叠体 - Collapsible 底 | +| `BLUE_MID` | `#152A40` | 表面 - Surface / 卡片 - Card / 工具栏 - Toolbar | +| `BLUE_LIGHT` | `#2E4F73` | 主色 / 边框 | + +语义别名: + +| 令牌 - Token | 取值 - Value | 用途 - Usage | +|--------------|--------------|--------------| +| 主色 `ACCENT` | `BLUE_LIGHT` `#2E4F73` | 按钮 - Button、选中、焦点 | +| 主色悬浮 `ACCENT_HOVER` | `#3A638F` | hover | +| 主色背景 / 页面底 `BG` | `BLUE_DEEP` | 全局底 | +| 表面 - Surface `SURFACE` | `BLUE_MID` | 卡片 - Card / 图表 - Chart 容器 | +| 边框 `BORDER` | `BLUE_LIGHT` | 与主色同阶 | + +--- + +## 表面层级 + +由深到浅(嵌套时下一层应更深或更暗): + +| 令牌 - Token | 色值 - Hex | 用途 - Usage | +|--------------|------------|--------------| +| `BG` | `#0B1420` | 页面 / 主窗 / 对话框 - Dialog 底 | +| `METRIC_SURFACE` | `#101E2E` | 指标卡 - MetricCard 内底 | +| `CHART_WELL` | `#112233` | 图表井区 - Chart Well、嵌套下拉框 - ComboBox、对比样式面板 - CompareStylePanel(相对 SURFACE 明度 ×0.8) | +| `SURFACE` | `#152A40` | 卡片 - Card、工具栏 - Toolbar、杆量输入面板 - StickInputPane | +| `METRIC_BORDER` | `#243D58` | 指标卡 - MetricCard 描边(2px) | + +航迹视窗自由 / 锁定钮(叠在 `CHART_WELL` 上,填充须 ≠ 井区): + +| 令牌 - Token | 色值 - Hex | 用途 - Usage | +|--------------|------------|--------------| +| `TRACK_LOCK_FREE_BG` | `#1E3A56` | 自由态底(抬起面 + 标准描边) | +| `TRACK_LOCK_FREE_BORDER` | `BORDER` `#3A5F82` | 自由态边框 | +| `TRACK_LOCK_FREE_HOVER_BG` | `BLUE_LIGHT` `#2E4F73` | 自由悬停加亮 | +| `TRACK_LOCK_ON_BG` | `FLASH_BLUE` `#1e90ff` | 锁定态底 | +| `TRACK_LOCK_ON_HOVER_BG` | `#4AA3FF` | 锁定悬停加亮 | + +折叠区 - Collapsible: + +| 令牌 - Token | 色值 - Hex | 用途 - Usage | +|--------------|------------|--------------| +| `COLLAPSIBLE_TITLE_BG` | `BLUE_DEEP` | 折叠内容区 - CollapsibleBody 实际用此作底(见 QSS) | +| `COLLAPSIBLE_BODY_BG` | `BLUE_MID` | 折叠标题条 - CollapsibleTitle | + +--- + +## 文字色 + +| 令牌 - Token | 色值 - Hex | 用途 - Usage | +|--------------|------------|--------------| +| 主文字 `FG` | `#C5D6E8` | 正文、数值默认 | +| 次要文字 `FG_MUTED` | `#7A9BB8` | 提示、单位、弱化标签 - Muted / 副标题 - Subtitle | +| 标题 `FG_TITLE` | `#8AABC8` | 页标题 - Title / 指标标题 - MetricTitle / 对话框标题 - DialogTitle | +| 强调上字 `FG_ON_ACCENT` | `#E8F1F8` | 主色按钮 - FlatButton 文字 | + +--- + +## 状态色 + +| 令牌 - Token | 色值 - Hex | 用途 - Usage | +|--------------|------------|--------------| +| `SUCCESS` | `#2ecc71` | 正常 / 健康绿带 | +| `WARNING` | `#f39c12` | 警告;复选框 - Checkbox 选中;监测停止 - Monitor STOP hover 邻近色 | +| `ERROR` | `#e74c3c` | 错误 / 危险;监测保存 - Monitor SAVE | +| `FLASH_BLUE` | `#1e90ff` | 冷舱强调、强调小钮 - IconSmAccent | + +信号(自检清单 - Check List): + +| 令牌 - Token | 色值 - Hex | 用途 - Usage | +|--------------|------------|--------------| +| `SIGNAL_IDLE` | `#4A6278` | 空闲 | +| `SIGNAL_RUN` | `#5E7F9A` | 运行中 | +| `SIGNAL_OK` | `#5F8F75` | 通过 | +| `SIGNAL_FAIL` | `#946868` | 失败 | + +业务动态着色(函数,非固定令牌 - Token): + +- `cht_color`:低于阈值 → `FLASH_BLUE`,否则 `ERROR` +- `torque_color`:区间 → `FLASH_BLUE` / `WARNING` / `ERROR` +- `health_color`:带区 → `FLASH_BLUE` / `SUCCESS` / `WARNING` / `ERROR` +- `status_kind_color`:`ok`/`warn`/`error`/`muted` + +--- + +## 图表色 + +折线 / 字段(`charts.theme.field_line_color`): + +| 令牌 - Token | 色值 - Hex | 色键 - Color Key | 用途 - Usage | +|--------------|------------|------------------|--------------| +| `CHART_TORQUE` | `#4a90d9` | torque | 扭矩折线 | +| `CHART_HEALTH` | `#e67e22` | health | 健康折线 | +| `CHART_AIRSPEED` | `#2ecc71` | airspeed | 空速折线 | +| `CHART_GROUNDSPEED` | `#1abc9c` | groundspeed | 地速折线 | +| `CHART_SPEED_RATE` | `#3498db` | speed_rate | 空速变化率 | +| `CHART_ACCEL_RATE` | `#8e44ad` | accel_rate | 加速度变化率 | +| `CHART_PITCH` | `#f1c40f` | pitch | 俯仰折线 | +| `CHART_BANK` | `#e74c3c` | bank | 坡度折线 | +| `CHART_ETA_PROP` | `#00cec9` | eta_prop | 推进效率代理 | +| `CHART_DI_PROXY` | `#e17055` | di_proxy | 诱导阻相对强度 | +| `CHART_VG_COURSE` | `#0984e3` | vg_course | 沿航迹地速 | +| `CHART_REFERENCE` | `#9b59b6` | (参考) | 历史对比参考线 | + +数据语义色(浮窗 / 摇杆 / 航迹): + +| 令牌 - Token | 取值 - Value | 用途 - Usage | +|--------------|--------------|--------------| +| `DATA_CURRENT` | `CHART_PITCH` `#f1c40f` | 当前数据(黄) | +| `DATA_HISTORY` | `FLASH_BLUE` `#1e90ff` | 历史数据(蓝) | +| `CHART_FLOAT_DEFAULT` | `#e8edf5` | 浮窗固定行(时间 / 空速 / 高度) | + +圈次 - LAP 固定色 `CHART_LAP_COLORS`: + +1. `#4a90d9` 2. `#e67e22` 3. `#2ecc71` 4. `#e74c3c` + +健康色带填充(alpha = `CHART_HEALTH_SPAN_ALPHA` = **0.18**): + +| 令牌 - Token | 色值 - Hex | 用途 - Usage | +|--------------|------------|--------------| +| `CHART_HEALTH_RED_FILL` | `#e74c3c` | 健康红带填充 | +| `CHART_HEALTH_ORANGE_FILL` | `#f39c12` | 健康橙带填充 | +| `CHART_HEALTH_BLUE_FILL` | `#1e90ff` | 健康蓝带填充 | + +透明度:`CHART_LIVE_OPACITY` = **0.90**(当前);`CHART_HISTORY_OPACITY` = **0.70**(历史)。 +历史对比:原色 HSV 降饱和(默认保留 0.45)+ 历史透明度(`desaturate_color` / `compare_*_qcolor`)。 + +--- + +## 开关 - Toggle 色 + +| 令牌 - Token | 色值 - Hex | 用途 - Usage | +|--------------|------------|--------------| +| `TOGGLE_TRACK_OFF` | `#122436` | 轨道关 | +| `TOGGLE_TRACK_ON` | `BLUE_LIGHT` | 轨道开 | +| `TOGGLE_TRACK_BORDER` | `#3A5F82` | 轨道边框 | +| `TOGGLE_KNOB_OFF` | `#8FAEC8` | 旋钮关 | +| `TOGGLE_KNOB_ON` | `FG_ON_ACCENT` | 旋钮开 | + +对应组件:滑动开关 - SlideToggle。 + +--- + +## 字体 + +| 角色 - Role | 字体族 - Family | 路径 - Path | +|-------------|-----------------|-------------| +| UI 正文 | `Noto Sans SC` | `resources/fonts/NotoSansSC-{Regular,Bold}.ttf` | +| 技术数值 | `Orbitron` | `resources/fonts/Orbitron-{Medium,Bold}.ttf` | + +字号(pt,`UITheme.FONT_*`): + +| 令牌 - Token | 字号 - Size | 用途 - Usage | +|--------------|-------------|--------------| +| `FONT_XS` / `FONT_HINT` | 8 | 极小提示 | +| `FONT_SM` | 9 | 弱化标签 - Muted、图表提示 - ChartHint、状态栏 - StatusBar、图表工具钮 - ChartTool | +| `FONT_MD` | 10 | 全局默认、指标标题 - MetricTitle、正文 | +| `FONT_LG` | 11 | 副标题 - Subtitle、主按钮 - FlatButton、对话框标题 - DialogTitle | +| `FONT_METRIC_TITLE` | 12 | 指标卡标题 - MetricCardTitle | +| `FONT_UNIT` | 14 | 单位 - MetricUnit | +| `FONT_TITLE` / `FONT_LAP_TOTAL` | 20 | 页标题 - Title / 圈时总计 - LapTotalValue | +| `FONT_RACE_SUCCESS` | 18 | 完赛成功对话框标题 | +| `FONT_CHT` | 36 | 大指标数值 - MetricValue / CHTValue(Orbitron) | + +API:`UITheme.font(size, bold)` / `UITheme.font_tech(size, bold)`。 + +--- + +## 圆角 + +| 场景 - Scene | 圆角 - Radius | +|--------------|---------------| +| 输入框 - LineEdit / 按钮 - Button / 下拉框 - ComboBox | **3px** | +| 卡片 - Card / 表面 - Surface / 折叠盒 - CollapsibleBox 外框 | **4px** | +| 指标卡 - MetricCard / 自检列表卡 - CheckListCard | **8px** | +| 复选框指示器 - Checkbox Indicator | **2px** | + +--- + +## 间距系统 + +### 基础 + +- 无独立 `SPACING` 常量;惯例以 **4 的倍数** 为设计稿尺寸。 +- 缩放:`UITheme.apply_scale(window_w, window_h, design_w, design_h)` + → `scale = min(w/design_w, h/design_h)` + → `UITheme.scaled(value, minimum=0)` +- **所有边距 / 间距 / 控件尺寸应走 `scaled(design, min)`**,避免硬编码 px。 + +### 常用设计稿间距(未缩放) + +| 级别 - Level | 典型值 - Typical | 场景 - Scene | +|--------------|------------------|--------------| +| 紧 | 4–8 | 图表工具栏 - ChartToolbar 内边、指标卡 - MetricCard 内 pad、小 gap | +| 组件 | 10–14 | 按钮列 spacing、训练辅助页 - Training Assist 区块 gap | +| 区块 | 16–24 | 列表项、对话框 - Dialog 边距 | +| 页面 | 20–36 | 训练辅助页 - Training Assist pad=20;仿真自检页 - Sim Check 外框 32–36 | + +训练辅助页 - Training Assist 参考:`pad = scaled(20, 8)`,`gap = scaled(14, 6)`。 + +对话框 - Dialog 边距常见:`scaled(24–28, 10–12)` 水平,`scaled(20–24, 10)` 垂直。 + +--- + +## 控件清单(objectName → 样式) + +格式:`{中文名} - {英文名}`;英文名即 QSS `objectName`(或类名)。 + +| 名称 - Name | 用途 - Usage | +|-------------|--------------| +| 卡片 - Card / 表面 - Surface | 表面卡片(1px border, r=4) | +| 无边框表面 - SurfaceFlat | 无边框表面 | +| 指标卡 - MetricCard | 指标卡(2px metric border, r=8) | +| 自检列表卡 - CheckListCard | 自检列表卡 | +| 页标题 - Title / 副标题 - Subtitle / 弱化标签 - Muted / 对话框标题 - DialogTitle | 文字层级 | +| 指标卡标题 - MetricCardTitle / 指标标题 - MetricTitle / 指标数值 - MetricValue / 指标单位 - MetricUnit | 指标 | +| CHT 数值 - CHTValue / 信息数值 - InfoValue / 圈时总计 - LapTotalValue | 大数值 / 信息 | +| 会话任务标签 - SessionMissionLabel / 会话模式标签 - SessionModeLabel | 圈板 - LapBoard 会话行 | +| 主按钮 - FlatButton | 主按钮 | +| 控制器设定钮 - ControllerSettingsButton | 摇杆面板设定(较小 padding/字号) | +| 图表工具钮 - ChartTool / 小图标钮 - IconSm / 强调小钮 - IconSmAccent | 紧凑 / 强调小钮 | +| 监测按钮 - MonitorButton + `monitorKind` | `stop`→WARNING,`save`→ERROR | +| 折叠盒 - CollapsibleBox / 折叠标题条 - CollapsibleTitle / 折叠内容区 - CollapsibleBody / 折叠标题钮 - CollapsibleHeader | 折叠区 - CollapsibleSection | +| 图表工具栏 - ChartToolbar / 工具栏单元格 - ChartToolbarCell / 图表提示 - ChartHint | 图表工具栏 | +| 杆量输入面板 - StickInputPane / 对比样式面板 - CompareStylePanel | 图表嵌套井区控件 | +| 自检等待 - CheckWait / 自检运行 - CheckRun / 自检通过 - CheckOk / 自检失败 - CheckFail / 自检详情 - CheckItemDetail | 自检状态字色 | +| 状态栏 - StatusBar / 状态事件 - StatusEvent / 状态速率 - StatusRate | 状态栏 | +| 操作栏 - ActionBar / 操作栏行 - ActionBarRow / 监测槽位 - MonitorSlot | 透明布局容器 | +| 滑动开关 - SlideToggle | 开关轨道 / 旋钮色见 Toggle 色 | +| 圆角滑条 - RoundSlider | 弹窗内连续调节 | +| 信号灯指示 - SignalLightIndicator | 自检旁路指示 | +| 指标条 - MetricStrip | 多枚指标卡 - MetricCard 横排 | +| 圈板 - LapBoard | 圈次与会话信息 | +| 卡片框 - CardFrame | 工厂:产出 指标卡 - MetricCard / 无边框表面 - SurfaceFlat / 卡片 - Card | +| 主题提示框 - ThemeAlertDialog | 主题化提示对话框 - Dialog | +| 开始监测对话框 - StartMonitoringDialog | 监测启动选项 | +| 硬件设定对话框 - HardwareSettingsDialog | 摇杆映射等硬件设定 | +| 任务切换对话框 - MissionSwitchDialog | 监测中切换任务 | +| 航迹库选择对话框 - TrackStorePickDialog | 云端航迹列表选取 | +| 完赛成功对话框 - RaceSuccessDialog | 任务完成结果 | +| 影子跟飞设定面板 - ShadowFollowSettings | 跟飞参数(Container 面板,非独立 Frame) | +| 影子跟飞选项面板 - ShadowFollowOptions | 跟飞选项 | +| 轨迹保存对话框 - TrajectorySaveDialog | 轨迹保存确认(写入 Auth) | +| 仿真未找到对话框 - SimNotFoundDialog | 连接失败提示 | +| 趋势图 - TrendChart | 图表外观层成品(facade) | +| 时间轴拖条 - TimelineScrubber | 图表时间 scrub | +| 航迹色带工具栏 - TrackColorToolbar | 航迹图色带切换 | +| 图表 HUD 浮窗 - ChartHudOverlay | 趋势图时标浮窗 | + +复选框 - Checkbox:indicator 14×14,未选井区底,选中 = `WARNING`。 + +下拉框 - ComboBox:全局用 `SURFACE`;图表工具栏 - ChartToolbar 嵌套下拉用 `CHART_WELL`。 + +--- + +## 窗口几何 + +窗口几何 - WindowGeom + `FEATURE_WINDOW_SPECS`: + +| 功能标识 - Feature ID | 设计尺寸 - Design Size | 尺寸策略 - Size Strategy | +|-----------------------|------------------------|--------------------------| +| 训练辅助 - training_assist | 680×820 | 主窗:`screen_w/3` × `screen_h*3/5+180` | +| 认证流 - flow.auth | 360×400 | `scale_dialog_size` | +| 仿真自检流 - flow.sim_check | 460×400 | `scale_dialog_size` | + +对话框 - Dialog 缩放:不超过主窗上限,且 `scale ≤ 1`;下限约 280×140。 + +进入功能页时:`apply_scale(实际窗, design)` → 后续 `scaled()` 生效。 + +--- + +## 资源路径 + +| 类型 - Type | 路径 - Path | +|-------------|-------------| +| 字体 | `resources/fonts/` | +| 图标 | `resources/icons/`(含下拉箭头 - combo_down_arrow.png) | +| 应用图标 | `resources/icons/icon.png` | + +打包后 root = `sys._MEIPASS`,开发 = 项目根。 + +--- + +## 使用约定 + +1. **禁止**在页面/图表 - Chart 里散落硬编码色值;走 `UITheme.*` 或 `charts.theme` helper。 +2. 新表面嵌套:页面 `BG` → 卡片 - Card / 表面 - Surface `SURFACE` → 图表井区 - Chart Well `CHART_WELL`,勿反过来。 +3. 新按钮优先复用 主按钮 - FlatButton / 图表工具钮 - ChartTool / 监测按钮 - MonitorButton 的 objectName,勿另写一长段 QSS。 +4. 布局尺寸一律 `UITheme.scaled(design, min)`。 +5. 文档与代码评审中提及组件时,使用 `{中文名} - {英文名}`,英文名与类名或 objectName 对齐。 +6. 新增表格时,表头必须复用上文「表头字段词典」,不得另造同义列名(如勿再写「说明」「典型用途」替代 用途 - Usage)。 diff --git a/Debug.md b/Debug.md new file mode 100644 index 00000000..4564825b --- /dev/null +++ b/Debug.md @@ -0,0 +1,25 @@ +# 状态机设定 + +- 本项目的所有全局时间状态仅由三部分数据业务决定:1_数据获取,2_数据保存,3_数据消费 +- 各大业务可根据自身数据量的大小和性能需要建立缓存机制,但各业务的缓存仅限其自己使用 +- 对于数据获取业务,仅有 开始(start) 暂停(paused) 停止(stop) 三种全局时间状态 +- 对于数据保存,仅有开始(start) 停止(stop) 两种全局时间状态,一旦数据获取业务开始获取到数据,则数据保存业务必须同步启动,直至获取到的数据被全部保存完毕,则停止,对于数据获取过程中的暂停,对于数据保存业务来说,可以进行异步操作(如果当前还有数据没有保存完,则不停止,直至完成;如果没有待保存的数据,则停止;如重新出现待保存的数据,则开始) +- **成品持久化落点**:保存板块的内存主缓冲冲刷后,经 `models/track_store` / `models/user_settings_store` 与 `models.db_operate` 写入 Auth 表 `user_defined_3`(航迹)与 `user_defined_4`(用户设置,如摇杆映射);禁止以本地 `track_data/*.csv`、`config/*.json` 作为成品持久化出口;未登录仅可内存持有并提示登录。实现细节见 `Docs/status.md` 与 `models/state_bus/lifecycle.py`。 +- 数据保存逻辑上可以采用一主一副的并行架构(根据业务需要而定,非必需),主逻辑负责保存数据,副本逻辑用来给数据消费业务(对实时性要求高,以至于性能需求上无法容忍先保存后读取的方式)提供实时数据。副本必须为主数据的增量拷贝,两者仅相差最后一个数据保存批次 +- 数据消费业务仅有 开始(start) 暂停(paused) 停止(stop) 三种全局时间状态。数据消费业务可根据业务需要自行设定自己的数据缓存方式,同板块下的不同子业务之间可共享同一个缓存 +- 所有数据消费业务,非紧急类的,只能采用读取已保存数据的方式工作;紧急类的,只能从唯一副本逻辑中获取并截取自己所需的数据片段,不允许增加任何形式的额外数据副本 +- 如以上三个板块同时协同工作,则不同数据业务板块在时间状态上开始必须同步对齐,停止可以异步对齐,以数据获取业务为最高优先级,其他业务板块开始后没数据可以等待(如仅需其中一个板块工作,则不受此限制) +- 所有用于协同的状态值必须在总线状态机上维护 +- 本项目有且只能有一个总线状态机,且其中相同状态必须有唯一状态名和布尔值 +- 所有业务逻辑只能自行实现所需逻辑,与各数据业务定义的时间状态进行对齐 + +- Never 业务逻辑的实现不得修改全局时间状态的定义 +- Never 跨数据业务共享同一套缓存 +- Never 需要板块间协同维护的状态值各自散落在各个板块中,通过交叉调用方式获取 + +# 图表命名设定 + +- 项目中有一个性能图(带左右Y轴共用一个X轴的图表) +- 项目中有一个航迹图(建立在平面坐标系上的,基于经纬度航迹) +- 项目中有一个摇杆图(显示外设操纵杆控制信号相关信息的图和参数列表) +- 后续交互中采用上述图表名称作为标准名称 \ No newline at end of file diff --git a/Docs/README.md b/Docs/README.md new file mode 100644 index 00000000..e0fbbc0d --- /dev/null +++ b/Docs/README.md @@ -0,0 +1,1204 @@ +# Flight Assistant — 图表与仿真变量详表 + +> 项目总览与快速开始见根目录 [`README.md`](../README.md)。 +> 本文前半为图表 / 状态总线导读;后半为 `Simulation Variables.pdf` 中文对齐清单。 + +## 图表模块架构 + +图表功能拆为两层: + +- **`models/`**:数据与配置(与 Qt / UI 无关);含 `state_bus` 分组状态总线、航迹/设置云存 +- **`charts/`**:pyqtgraph 渲染与交互(趋势图 / 航迹图 / 摇杆图) + +``` +app_controller(编排 + 命令) + └── models/state_bus ← 读数/会话权威(分组 Store + 订阅) + └── ChartHistoryBuffer ← models/chart.py(时序附件) + └── read_aq_snapshot() ← models/data_bridge.py(单拍 AQ) + └── track_store / user_settings_store ← Auth user_defined_3 / 4 + +models/data_bridge.py + └── FieldDef / build_overlay_config() / trajectory_to_history() + +charts/ + ├── LineChart ← 读 ChartConfig + field_data + ├── HealthSpanMask ← 读 mask spec + health_span_kind + └── TrendChart (facade) ← 组装为页面可直接使用的 widget +``` + +页面侧用法: + +```python +from charts import TrendChart # 成品趋势图(推荐) +from charts.slot import ChartSlot # 自行拼装时才需要 +from charts.base.line_chart import LineChart +``` + +--- + +## `models/chart.py` — 数据层 + +| 内容 | 作用 | 谁在用 | +|------|------|--------| +| `ChartFieldSpec` / `ChartConfig` | 字段定义、Y 轴范围、视窗时长 | `LineChart`、`TrendChart`、`ChartHistoryBuffer` | +| `ChartHistoryBuffer` | 存时间序列;`append` / `slice_view` | 录制路径写,图表只读 | +| `TRAINING_CHART` 等预置 | 训练页默认图表配置 | controller、facade | +| `health_band` / `health_span_kind` | 健康度业务阈值(常量见 `setting.HEALTH_BOUND_*`) | `Container_Strip_Metric`、`HealthSpanMask` | +| `view_bounds` / `relative_times` / `series_points` | 视窗切片、坐标换算 | `ChartSlot`、`LineChart` | + +数据流:`read_aq_snapshot` → Store(`live`) + `ChartHistoryBuffer.append()` + 轨迹录制 → 图表订阅 `chart` seq 后 `update(history)`。 + +--- + +## `models/data_bridge.py` — 字段与蒙版 + +| 内容 | 作用 | 谁在用 | +|------|------|--------| +| `FieldDef` / `FIELDS` | 图表字段出数 + 轴/线型/蒙版元数据 | `extract`、`frame_to_history` | +| `read_aq_snapshot` | 单拍 SimConnect 快照(图表+录制共用) | 采集写线程、`shadow_plane` | +| `trajectory_to_history` | `Trajectory` → `ChartHistoryBuffer` | `track_compare` | +| `ThresholdSpanOverlaySpec` / `LapMarkerOverlaySpec` | 蒙版规格 | `charts.mask.registry.create_masks()` | +| `build_overlay_config` / `load_overlay_config` | 从 FieldDef 组装蒙版 | `TrendChart` 初始化 | +| `kind_resolver` | `"health_span_kind"` → `models.chart.health_span_kind` | `HealthSpanMask` | + +字段蒙版与图级 `lap_marker` 由 `FIELDS` / `CHART_LEVEL_OVERLAYS` 定义(不再使用 JSON)。 +扩字段时同步看 `models/track_fields.py`(采样列 / Auth 航迹列)。 + +--- + +## 用户持久化(Auth) + +| 通道 | 字段 | 模块 | 内容 | +|------|------|------|------| +| 航迹 | `user_defined_3` | `models/track_store.py` | 轨迹包 JSON;对比经 `load_track_compare_record` | +| 设置 | `user_defined_4` | `models/user_settings_store.py` | 摇杆映射等可扩展字典 | +| 中间件 | — | `models/db_operate.py` | 唯一 DB 读写入口 | + +未登录:仅内存,提示登录。禁止成品出口写 `track_data/*.csv`、`config/*.json`。 +UI:`Frame_Dialog_TrackStorePick` / `Frame_Dialog_TrajectorySave`。 + +正式对比路径:`AnalyzeWorker.enqueue_load_compare` → `load_track_compare_record(user_id, record_id)` → Store `analyze`。 + +--- + +## `models/state_bus/` — 分组状态总线 + +同进程单写多读:采集写线程 commit → Store;主线程按组 `period_ms` 节流订阅;分析线程承接云端对比等冷路径。 + +| 组 | 内容 | 典型消费者 | +|----|------|------------| +| `session` | 任务模式 / 任务名 / 联机 | 圈板 | +| `runtime` | 监测阶段 / 暂停 / 坠毁闩锁 | 监测按钮、状态栏 | +| `live` | CHT / 扭矩 / 健康 | 指标条 | +| `stick` | 摇杆轴 | `StickInputPane` | +| `track` / `chart` | 轨迹/趋势 seq | 航迹图、趋势图 | +| `analyze` | 对比载荷结果 | 对比面板 | + +周期常量见 `setting.py` 中 `STATE_GROUP_*_MS`(字面量)。权威时间态见根目录 `Debug.md` 与 `Docs/status.md`。 + +--- + +## `charts/` 目录结构 + +``` +charts/ + pg_bind.py # pyqtgraph 绑定 + context.py # ChartRenderContext + theme.py # 配色适配(委托 UITheme) + slot.py # ChartDisplayArea / ChartPane / ChartSlot(含平移) + axis_proxy.py # 轴代理 + base/ # LineChart + mask/ # HealthSpanMask、LapMarkerMask、registry + facade/ # TrendChart + widgets/ # ChartToolbar、TrackMapPane、StickInput、 + # TimelineScrubber、ChartHudOverlay、 + # CompareStylePanel、TrackColorToolbar… +``` + +### 三层职责 + +1. **槽位/显示区**(`slot.py`):总显示区域 + 底部附图布局;统一 render / 平移 +2. **基底图表**(`base/`):按类型渲染业务数据(折线),不含蒙版 +3. **蒙版图层**(`mask/`):健康色带、LAP 竖线等 + +### `charts/facade/` — 外观层 + +``` +TrainingAssistPage + └── TrendChart + ├── ChartToolbar + ├── ChartDisplayArea + │ ├── ChartSlot(LineChart + masks) + │ └── TrackMapPane + StickInputPane + │ └── StickInputPane:控制器设定按钮 + 当前设备名(无设备下拉) + └── TimelineScrubber +``` + +--- + +## 修改指南 + +| 要改的内容 | 文件 | +|------------|------| +| 扭矩 / 健康 Y 轴范围、视窗 150s | `models/chart.py` 或 `TRAINING_CHART` | +| 健康阈值 96.5 / 97.5 / 99.5 | `setting.py` → `HEALTH_BOUND_*` | +| 色带透明度 / UI 色 | `setting.py`(`UI_CHART_HEALTH_SPAN_ALPHA` 等)+ `charts/theme.py` | +| 字段 / 采样列扩展 | `models/data_bridge.py` + `models/track_fields.py` | +| 状态组刷新周期 | `setting.py` → `STATE_GROUP_*_MS` | +| 折线样式、拖拽、布局 | `charts/` | +| SimVar / 弧度转度 | `services/simulator.py` | +| 航迹云存 / 对比加载 | `models/track_store.py`、`models/track_compare.py` | + +--- + +# 附件:模拟器 Simulation Variables 参数清单 + +> 来源:`Docs/Simulation Variables.pdf`(FSX/SimConnect 官方仿真变量表),并与项目 `SimConnect/RequestList.py` 中的结构化定义对齐。 +> 补充说明见 `Docs/项目原理.md`。 + +**表格列说明:** 变量名 | 英文说明(官方原文) | 中文说明 | 单位 | 读写 + +## 读写规则说明 + +| 标记 | 含义 | 本项目用法 | +|---|---|---| +| **只读 (N)** | 只能通过 get / request_data 订阅读取 | `AircraftRequests.get(...)` 或 `services/sim_batch` 批量读 | +| **可写 (Y)** | 可通过 set_data 直接写入数值 | `AircraftRequests.set(...)` / `services/simulator.py` | + +**交互补充(非变量写入):** + +- 许多开关/手柄(如自动驾驶开关、灯光开关)在变量表中为 **只读**,需通过 **SimConnect 事件**(`EventList.py` 的 `Event`,如 `PAUSE_ON`)触发才能交互。 +- 带 `:index` 的变量需要指定系统索引(发动机从 0,通讯设备从 1)。 +- PDF 另有 **Program Data**(`P:`,如 `SIMULATION RATE`、`UNITS OF MEASURE`)与 **Units of Measurement** 单位表;本清单主体为 Aircraft/Environment 的 `A:` 仿真变量。 +- 变量表与 `SimConnect/RequestList.py` 对齐;源自 `Docs/Simulation Variables.pdf`。本附件为静态文档,直接维护本节即可。 + +## 总览 + +- **变量总数**:830 +- **可直接写入 (Y)**:191 +- **只能读取 (N)**:639 + +| 分类 | 总数 | 可写 | 只读 | +|---|---:|---:|---:| +| 一、Aircraft Engine Data(发动机) | 110 | 40 | 70 | +| 二、Aircraft Fuel Data(燃油) | 48 | 22 | 26 | +| 三、Aircraft Lights Data(灯光) | 24 | 0 | 24 | +| 四、Aircraft Position and Speed Data(位置与速度) | 34 | 24 | 10 | +| 五、Aircraft Flight Instrumentation Data(飞行仪表) | 45 | 24 | 21 | +| 六、Aircraft Avionics Data(航电) | 115 | 4 | 111 | +| 七、Aircraft Controls Data(操纵/控制) | 48 | 18 | 30 | +| 八、Aircraft Autopilot Data(自动驾驶) | 38 | 0 | 38 | +| 九、Aircraft Landing Gear Data(起落架) | 54 | 6 | 48 | +| 十、Aircraft Environment Data(飞机环境) | 19 | 0 | 19 | +| 十一、Helicopter Specific Data(直升机) | 18 | 0 | 18 | +| 十二、Slings and Hoists(吊索/绞车) | 9 | 2 | 7 | +| 十三、Aircraft Miscellaneous Systems Data(杂项系统) | 50 | 24 | 26 | +| 十四、Miscellaneous Data(杂项) | 159 | 14 | 145 | +| 十五、Aircraft String Data(字符串) | 12 | 3 | 9 | +| 十六、AI Controlled Aircraft(AI 飞机) | 13 | 6 | 7 | +| 十七、Carrier Operations(航母作业) | 10 | 0 | 10 | +| 十八、Racing(竞速) | 10 | 4 | 6 | +| 十九、Environment Data(全局环境) | 14 | 0 | 14 | + +--- + +## 附录 A:Fuel Tank Selection(油箱选择枚举值) + +以下不是独立 SimVar,而是 `FUEL TANK SELECTOR` / `RECIP ENG FUEL TANK SELECTOR` 等枚举取值(文档章节 *Fuel Tank Selection*): + +| 枚举值 | 英文含义 | 中文含义 | +|---:|---|---| +| 0 | Off | 关闭 | +| 1 | All | 全部 | +| 2 | Left | 左 | +| 3 | Right | 右 | +| 4 | Left auxiliary | 左辅助 | +| 5 | Right auxiliary | 右辅助 | +| 6 | Center | 中央 | +| 7 | Center2 | 中央2 | +| 8 | Center3 | 中央3 | +| 9 | External1 | 外部1 | +| 10 | External2 | 外部2 | +| 11 | Right tip | 右翼尖 | +| 12 | Left tip | 左翼尖 | +| 13 | Crossfeed | 串油 | +| 14 | Crossfeed left to right | 左向右串油 | +| 15 | Crossfeed right to left | 右向左串油 | +| 16 | Both | 两侧 | +| 17 | External | 外部 | +| 18 | Isolate | 隔离 | +| 19 | Left main | 左主 | +| 20 | Right main | 右主 | + +## 附录 B:Program Data(程序数据,PDF 有、RequestList 未收录) + +| 变量名 | 英文说明 | 中文说明 | 单位 | 读写 | +|---|---|---|---|---| +| `SIMULATION RATE` | Time acceleration factor | 时间加速倍率 | Number | 只读 | +| `UNITS OF MEASURE` | Units of measure: 0=English; 1=Metric(alt feet); 2=Metric(alt meters) | 单位制:0=英制;1=公制(高度英尺);2=公制(高度米) | Enum | 只读 | + +--- + +## 一、Aircraft Engine Data(发动机) + +共 **110** 项(可写 40 / 只读 70) + +| 变量名 | 英文说明 | 中文说明 | 单位 | 读写 | +|---|---|---|---|---| +| `NUMBER OF ENGINES` | Number of engines (minimum 0, maximum 4) | 发动机数量(最小0,最大4) | Number(数值) | 只读 | +| `ENGINE CONTROL SELECT` | Selected engines (combination of bit flags); 1 = Engine 1; 2 = Engine 2; 4 = Engine 3; 8 = Engine 4 | 选中的发动机(位标志组合);1=发动机1;2=发动机2;4=发动机3;8=发动机4 | Mask(掩码) | **可写** | +| `THROTTLE LOWER LIMIT` | Percent throttle defining lower limit (negative for reverse thrust equipped airplanes) | 定义油门下限的百分比(反推飞机可为负值) | Percent(百分比) | 只读 | +| `ENGINE TYPE` | Engine type:; 0 = Piston; 1 = Jet; 2 = None; 3 = Helo(Bell) turbine; 4 = Unsupported; 5 = Turboprop | 发动机类型:0=活塞;1=喷气;2=无;3=贝尔涡轮(直升机);4=不支持;5=涡桨 | Enum(枚举) | 只读 | +| `MASTER IGNITION SWITCH` | Aircraft master ignition switch (grounds all engines magnetos) | 飞机主点火开关(接地所有发动机磁电机) | Bool(布尔) | 只读 | +| `GENERAL ENG COMBUSTION:index` | Combustion flag | 燃烧状态标志(索引) | Bool(布尔) | **可写** | +| `GENERAL ENG MASTER ALTERNATOR:index` | Alternator (generator) switch | 交流发电机开关(索引) | Bool(布尔) | 只读 | +| `GENERAL ENG FUEL PUMP SWITCH:index` | Fuel pump switch | 燃油泵开关(索引) | Bool(布尔) | 只读 | +| `GENERAL ENG FUEL PUMP ON:index` | Fuel pump on/off | 燃油泵开/关(索引) | Bool(布尔) | 只读 | +| `GENERAL ENG RPM:index` | Engine rpm | 发动机转速(索引) | Rpm(转/分) | 只读 | +| `GENERAL ENG PCT MAX RPM:index` | Percent of max rated rpm | 占最大额定转速的百分比(索引) | Percent(百分比) | 只读 | +| `GENERAL ENG MAX REACHED RPM:index` | Maximum attained rpm | 发动机最大达到转速(索引) | Rpm(转/分) | 只读 | +| `GENERAL ENG THROTTLE LEVER POSITION:index` | Percent of max throttle position | 油门杆最大行程的百分比(索引) | Percent(百分比) | **可写** | +| `GENERAL ENG MIXTURE LEVER POSITION:index` | Percent of max mixture lever position | 混合比杆最大行程的百分比(索引) | Percent(百分比) | **可写** | +| `GENERAL ENG PROPELLER LEVER POSITION:index` | Percent of max prop lever position | 桨距杆最大行程的百分比(索引) | Percent(百分比) | **可写** | +| `GENERAL ENG STARTER:index` | Engine starter on/off | 发动机起动机开/关(索引) | Bool(布尔) | 只读 | +| `GENERAL ENG EXHAUST GAS TEMPERATURE:index` | Engine exhaust gas temperature. | 发动机排气温度(索引) | Rankine(兰氏度) | **可写** | +| `GENERAL ENG OIL PRESSURE:index` | Engine oil pressure | 发动机滑油压力(索引) | Psf(磅/平方英尺) | **可写** | +| `GENERAL ENG OIL LEAKED PERCENT:index` | Percent of max oil capacity leaked | 发动机滑油泄漏百分比(索引) | Percent(百分比) | 只读 | +| `GENERAL ENG COMBUSTION SOUND PERCENT:index` | Percent of maximum engine sound | 发动机燃烧音频百分比(索引) | Percent(百分比) | 只读 | +| `GENERAL ENG DAMAGE PERCENT:index` | Percent of total engine damage | 发动机损伤百分比(索引) | Percent(百分比) | 只读 | +| `GENERAL ENG OIL TEMPERATURE:index` | Engine oil temperature | 发动机滑油温度(索引) | Rankine(兰氏度) | **可写** | +| `GENERAL ENG FAILED:index` | Fail flag | 故障标志(索引) | Bool(布尔) | 只读 | +| `GENERAL ENG GENERATOR SWITCH:index` | Alternator (generator) switch | 交流发电机开关(索引) | Bool(布尔) | 只读 | +| `GENERAL ENG GENERATOR ACTIVE:index` | Alternator (generator) on/off | 交流发电机开/关(索引) | Bool(布尔) | **可写** | +| `GENERAL ENG ANTI ICE POSITION:index` | Engine anti-ice switch | 发动机防冰位置(索引) | Bool(布尔) | 只读 | +| `GENERAL ENG FUEL VALVE:index` | Fuel valve state | 发动机燃油阀门(索引) | Bool(布尔) | 只读 | +| `GENERAL ENG FUEL PRESSURE:index` | Engine fuel pressure | 发动机燃油压力(索引) | Psi(磅/平方英寸) | **可写** | +| `GENERAL ENG ELAPSED TIME:index` | Total engine elapsed time | 发动机累计时间(索引) | Hours(小时) | 只读 | +| `RECIP ENG COWL FLAP POSITION:index` | Percent cowl flap opened | 往复式发动机整流罩襟翼位置(索引) | Percent(百分比) | **可写** | +| `RECIP ENG PRIMER:index` | Engine primer position | 往复式发动机注油器(索引) | Bool(布尔) | **可写** | +| `RECIP ENG MANIFOLD PRESSURE:index` | Engine manifold pressure | 往复式发动机歧管压力(索引) | Psi(磅/平方英寸) | **可写** | +| `RECIP ENG ALTERNATE AIR POSITION:index` | Alternate air control | 往复式发动机备用空气位置(索引) | Position(位置) | **可写** | +| `RECIP ENG COOLANT RESERVOIR PERCENT:index` | Percent coolant available | 往复式发动机冷却液储液罐百分比(索引) | Percent(百分比) | **可写** | +| `RECIP ENG LEFT MAGNETO:index` | Left magneto state | 往复式发动机左磁电机(索引) | Bool(布尔) | **可写** | +| `RECIP ENG RIGHT MAGNETO:index` | Right magneto state | 往复式发动机右磁电机(索引) | Bool(布尔) | **可写** | +| `RECIP ENG BRAKE POWER:index` | Brake power produced by engine | 往复式发动机制动功率(索引) | Foot pounds per second(英尺·磅/秒) | **可写** | +| `RECIP ENG STARTER TORQUE:index` | Torque produced by engine | 往复式发动机起动机扭矩(索引) | Foot pound(英尺·磅) | **可写** | +| `RECIP ENG TURBOCHARGER FAILED:index` | Turbo failed state | 往复式发动机涡轮增压器故障(索引) | Bool(布尔) | **可写** | +| `RECIP ENG EMERGENCY BOOST ACTIVE:index` | War emergency power active | 往复式发动机紧急增压激活(索引) | Bool(布尔) | **可写** | +| `RECIP ENG EMERGENCY BOOST ELAPSED TIME:index` | Elapsed time war emergency power active | 往复式发动机紧急增压累计时间(索引) | Hours(小时) | **可写** | +| `RECIP ENG WASTEGATE POSITION:index` | Percent turbo wastegate closed | 往复式发动机废气门位置(索引) | Percent(百分比) | **可写** | +| `RECIP ENG TURBINE INLET TEMPERATURE:index` | Engine turbine inlet temperature | 往复式发动机涡轮进气温度(索引) | Celsius(摄氏度) | **可写** | +| `RECIP ENG CYLINDER HEAD TEMPERATURE:index` | Engine cylinder head temperature | 往复式发动机气缸缸盖温度(索引) | Celsius(摄氏度) | **可写** | +| `RECIP ENG RADIATOR TEMPERATURE:index` | Engine radiator temperature | 往复式发动机散热器温度(索引) | Celsius(摄氏度) | **可写** | +| `RECIP ENG FUEL AVAILABLE:index` | True if fuel is available | 往复式发动机燃油可用(索引) | Bool(布尔) | **可写** | +| `RECIP ENG FUEL FLOW:index` | Engine fuel flow | 往复式发动机燃油流量(索引) | Pounds per hour(磅/小时) | **可写** | +| `RECIP ENG FUEL TANK SELECTOR:index` | Fuel tank selected for engine. See fuel tank list. | 往复式发动机油箱选择器(索引) | Enum(枚举) | 只读 | +| `RECIP ENG FUEL NUMBER TANKS USED:index` | Number of tanks currently being used | 往复式发动机燃油数量油箱使用(索引) | Number(数值) | 只读 | +| `RECIP CARBURETOR TEMPERATURE:index` | Carburetor temperature | 往复式化油器温度(索引) | Celsius(摄氏度) | **可写** | +| `RECIP MIXTURE RATIO:index` | Fuel / Air mixture ratio | 往复式混合比比(索引) | Ratio(比值) | **可写** | +| `TURB ENG N1:index` | Turbine engine N1 | 涡轮发动机N1(索引) | Percent(百分比) | **可写** | +| `TURB ENG N2:index` | Turbine engine N2 | 涡轮发动机N2(索引) | Percent(百分比) | **可写** | +| `TURB ENG CORRECTED N1:index` | Turbine engine corrected N1 | 涡轮发动机修正N1(索引) | Percent(百分比) | **可写** | +| `TURB ENG CORRECTED N2:index` | Turbine engine corrected N2 | 涡轮发动机修正N2(索引) | Percent(百分比) | **可写** | +| `TURB ENG CORRECTED FF:index` | Corrected fuel flow | 涡轮发动机修正燃油流量(索引) | Pounds per hour(磅/小时) | **可写** | +| `TURB ENG MAX TORQUE PERCENT:index` | Percent of max rated torque | 涡轮发动机最大扭矩百分比(索引) | Percent(百分比) | **可写** | +| `TURB ENG PRESSURE RATIO:index` | Engine pressure ratio | 涡轮发动机压力比(索引) | Ratio(比值) | **可写** | +| `TURB ENG ITT:index` | Engine ITT | 涡轮发动机ITT(索引) | Rankine(兰氏度) | **可写** | +| `TURB ENG AFTERBURNER:index` | Afterburner state | 涡轮发动机加力燃烧室(索引) | Bool(布尔) | 只读 | +| `TURB ENG JET THRUST:index` | Engine jet thrust | 涡轮发动机喷气推力(索引) | Pounds(磅) | 只读 | +| `TURB ENG BLEED AIR:index` | Bleed air pressure | 涡轮发动机引气空气(索引) | Psi(磅/平方英寸) | 只读 | +| `TURB ENG TANK SELECTOR:index` | Fuel tank selected for engine. See fuel tank list. | 涡轮发动机油箱选择器(索引) | Enum(枚举) | 只读 | +| `TURB ENG NUM TANKS USED:index` | Number of tanks currently being used | 涡轮发动机数量油箱使用(索引) | Number(数值) | 只读 | +| `TURB ENG FUEL FLOW PPH:index` | Engine fuel flow | 涡轮发动机燃油流量磅/小时(索引) | Pounds per hour(磅/小时) | 只读 | +| `TURB ENG FUEL AVAILABLE:index` | True if fuel is available | 涡轮发动机燃油可用(索引) | Bool(布尔) | 只读 | +| `TURB ENG REVERSE NOZZLE PERCENT:index` | Percent thrust reverser nozzles deployed | 涡轮发动机反推喷管百分比(索引) | Percent(百分比) | 只读 | +| `TURB ENG VIBRATION:index` | Engine vibration value | 涡轮发动机振动(索引) | Number(数值) | 只读 | +| `ENG FAILED:index` | Failure flag | 故障标志(索引) | Number(数值) | 只读 | +| `ENG RPM ANIMATION PERCENT:index` | Percent max rated rpm used for visual animation | 发动机转速动画百分比(索引) | Percent(百分比) | 只读 | +| `ENG ON FIRE:index` | On fire state | 着火状态(索引) | Bool(布尔) | **可写** | +| `ENG FUEL FLOW BUG POSITION:index` | Fuel flow reference | 燃油流量游标位置(索引) | Pounds per hour(磅/小时) | 只读 | +| `PROP RPM:index` | Propeller rpm | 螺旋桨转速 | Rpm(转/分) | **可写** | +| `PROP MAX RPM PERCENT:index` | Percent of max rated rpm | 占最大额定转速的百分比(索引) | Percent(百分比) | 只读 | +| `PROP THRUST:index` | Propeller thrust | 螺旋桨推力 | Pounds(磅) | 只读 | +| `PROP BETA:index` | Prop blade pitch angle | PROP桨距角(索引) | Radians(弧度) | 只读 | +| `PROP FEATHERING INHIBIT:index` | Feathering inhibit flag | 螺旋桨顺桨抑制(索引) | Bool(布尔) | 只读 | +| `PROP FEATHERED:index` | Feathered state | 顺桨状态 | Bool(布尔) | 只读 | +| `PROP SYNC DELTA LEVER:index` | Corrected prop correction input on slaved engine | 螺旋桨同步增量杆(索引) | Position(位置) | 只读 | +| `PROP AUTO FEATHER ARMED:index` | Auto-feather armed state | 螺旋桨自动顺桨预位(索引) | Bool(布尔) | 只读 | +| `PROP FEATHER SWITCH:index` | Prop feather switch | 螺旋桨顺桨开关(索引) | Bool(布尔) | 只读 | +| `PANEL AUTO FEATHER SWITCH:index` | Auto-feather arming switch | 面板自动顺桨开关(索引) | Bool(布尔) | 只读 | +| `PROP SYNC ACTIVE:index` | True if prop sync is active | 螺旋桨同步激活(索引) | Bool(布尔) | 只读 | +| `PROP DEICE SWITCH:index` | True if prop deice switch on | 螺旋桨除冰开关(索引) | Bool(布尔) | 只读 | +| `ENG COMBUSTION` | True if the engine is running | 当the发动机为running时为真 | Bool(布尔) | 只读 | +| `ENG N1 RPM:index` | Engine N1 rpm | 发动机 N1 转速(索引) | Rpm (0 to 16384 = 0 to 100%)(转/分(0-16384=0-100%)) | 只读 | +| `ENG N2 RPM:index` | Engine N2 rpm | 发动机 N2 转速(索引) | Rpm(0 to 16384 = 0 to 100%)(转/分(0-16384=0-100%)) | 只读 | +| `ENG FUEL FLOW GPH:index` | Engine fuel flow | 发动机燃油流量加仑/小时(索引) | Gallons per hour(加仑/小时) | 只读 | +| `ENG FUEL FLOW PPH:index` | Engine fuel flow | 发动机燃油流量磅/小时(索引) | Pounds per hour(磅/小时) | 只读 | +| `ENG TORQUE:index` | Torque | 发动机扭矩(索引) | Foot pounds(英尺·磅) | 只读 | +| `ENG ANTI ICE:index` | Anti-ice switch | 发动机防冰(索引) | Bool(布尔) | 只读 | +| `ENG PRESSURE RATIO:index` | Engine pressure ratio | 发动机压比(索引) | Ratio (0-16384)(比值(0-16384)) | 只读 | +| `ENG EXHAUST GAS TEMPERATURE:index` | Exhaust gas temperature | 发动机排气温度(索引) | Rankine(兰氏度) | 只读 | +| `ENG EXHAUST GAS TEMPERATURE GES:index` | Governed engine setting | 发动机排气温度受控设定(索引) | Percent over 100(百分比(超100)) | 只读 | +| `ENG CYLINDER HEAD TEMPERATURE:index` | Engine cylinder head temperature | 发动机缸盖温度(索引) | Rankine(兰氏度) | 只读 | +| `ENG OIL TEMPERATURE:index` | Engine oil temperature | 发动机滑油温度(索引) | Rankine(兰氏度) | 只读 | +| `ENG OIL PRESSURE:index` | Engine oil pressure | 发动机滑油压力(索引) | foot pounds | 只读 | +| `ENG OIL QUANTITY:index` | Engine oil quantitiy as a percentage of full capacity | 发动机滑油quantitiy作为a百分比的full容量 | Percent over 100(百分比(超100)) | 只读 | +| `ENG HYDRAULIC PRESSURE:index` | Engine hydraulic pressure | 发动机液压压力(索引) | foot pounds | 只读 | +| `ENG HYDRAULIC QUANTITY:index` | Engine hydraulic fluid quantity, as a percentage of total capacity | 发动机液压fluid数量,作为a百分比的总容量 | Percent over 100(百分比(超100)) | 只读 | +| `ENG MANIFOLD PRESSURE:index` | Engine manifold pressure. | 发动机歧管压力(索引) | inHG.(英寸汞柱) | 只读 | +| `ENG VIBRATION:index` | Engine vibration | 发动机振动(索引) | Number(数值) | 只读 | +| `ENG RPM SCALER:index` | Obsolete | 已废弃(索引) | Scalar(标量) | 只读 | +| `ENG MAX RPM` | Maximum rpm | 发动机最大转速 | Rpm(转/分) | 只读 | +| `GENERAL ENG STARTER ACTIVE` | True if engine starter is active | 当发动机起动机为激活时为真 | Bool(布尔) | 只读 | +| `GENERAL ENG FUEL USED SINCE START` | Fuel used since the engines were last started | 自启动以来发动机耗油 | Pounds(磅) | 只读 | +| `TURB ENG PRIMARY NOZZLE PERCENT:index` | Percent thrust of primary nozzle | 涡轮发动机主喷管百分比(索引) | Percent over 100(百分比(超100)) | 只读 | +| `TURB ENG IGNITION SWITCH` | True if the turbine engine ignition switch is on | 当the涡轮发动机点火开关为开时为真 | Bool(布尔) | 只读 | +| `TURB ENG MASTER STARTER SWITCH` | True if the turbine engine master starter switch is on | 当the涡轮发动机主起动机开关为开时为真 | Bool(布尔) | 只读 | +| `TURB ENG AFTERBURNER STAGE ACTIVE` | The stage of the afterburner, or 0 if the afterburner is not active. | The级的the加力燃烧室,或0若the加力燃烧室为not激活. | Number(数值) | 只读 | + +## 二、Aircraft Fuel Data(燃油) + +共 **48** 项(可写 22 / 只读 26) + +| 变量名 | 英文说明 | 中文说明 | 单位 | 读写 | +|---|---|---|---|---| +| `FUEL TANK CENTER LEVEL` | Percent of maximum capacity | 占最大容量的百分比 | Percent Over 100(百分比(超100)) | **可写** | +| `FUEL TANK CENTER2 LEVEL` | Percent of maximum capacity | 占最大容量的百分比 | Percent Over 100(百分比(超100)) | **可写** | +| `FUEL TANK CENTER3 LEVEL` | Percent of maximum capacity | 占最大容量的百分比 | Percent Over 100(百分比(超100)) | **可写** | +| `FUEL TANK LEFT MAIN LEVEL` | Percent of maximum capacity | 占最大容量的百分比 | Percent Over 100(百分比(超100)) | **可写** | +| `FUEL TANK LEFT AUX LEVEL` | Percent of maximum capacity | 占最大容量的百分比 | Percent Over 100(百分比(超100)) | **可写** | +| `FUEL TANK LEFT TIP LEVEL` | Percent of maximum capacity | 占最大容量的百分比 | Percent Over 100(百分比(超100)) | **可写** | +| `FUEL TANK RIGHT MAIN LEVEL` | Percent of maximum capacity | 占最大容量的百分比 | Percent Over 100(百分比(超100)) | **可写** | +| `FUEL TANK RIGHT AUX LEVEL` | Percent of maximum capacity | 占最大容量的百分比 | Percent Over 100(百分比(超100)) | **可写** | +| `FUEL TANK RIGHT TIP LEVEL` | Percent of maximum capacity | 占最大容量的百分比 | Percent Over 100(百分比(超100)) | **可写** | +| `FUEL TANK EXTERNAL1 LEVEL` | Percent of maximum capacity | 占最大容量的百分比 | Percent Over 100(百分比(超100)) | **可写** | +| `FUEL TANK EXTERNAL2 LEVEL` | Percent of maximum capacity | 占最大容量的百分比 | Percent Over 100(百分比(超100)) | **可写** | +| `FUEL TANK CENTER CAPACITY` | Maximum capacity in volume | 最大容积容量 | Gallons(加仑) | 只读 | +| `FUEL TANK CENTER2 CAPACITY` | Maximum capacity in volume | 最大容积容量 | Gallons(加仑) | 只读 | +| `FUEL TANK CENTER3 CAPACITY` | Maximum capacity in volume | 最大容积容量 | Gallons(加仑) | 只读 | +| `FUEL TANK LEFT MAIN CAPACITY` | Maximum capacity in volume | 最大容积容量 | Gallons(加仑) | 只读 | +| `FUEL TANK LEFT AUX CAPACITY` | Maximum capacity in volume | 最大容积容量 | Gallons(加仑) | 只读 | +| `FUEL TANK LEFT TIP CAPACITY` | Maximum capacity in volume | 最大容积容量 | Gallons(加仑) | 只读 | +| `FUEL TANK RIGHT MAIN CAPACITY` | Maximum capacity in volume | 最大容积容量 | Gallons(加仑) | 只读 | +| `FUEL TANK RIGHT AUX CAPACITY` | Maximum capacity in volume | 最大容积容量 | Gallons(加仑) | 只读 | +| `FUEL TANK RIGHT TIP CAPACITY` | Maximum capacity in volume | 最大容积容量 | Gallons(加仑) | 只读 | +| `FUEL TANK EXTERNAL1 CAPACITY` | Maximum capacity in volume | 最大容积容量 | Gallons(加仑) | 只读 | +| `FUEL TANK EXTERNAL2 CAPACITY` | Maximum capacity in volume | 最大容积容量 | Gallons(加仑) | 只读 | +| `FUEL LEFT CAPACITY` | Maximum capacity in volume | 最大容积容量 | Gallons(加仑) | 只读 | +| `FUEL RIGHT CAPACITY` | Maximum capacity in volume | 最大容积容量 | Gallons(加仑) | 只读 | +| `FUEL TANK CENTER QUANTITY` | Current quantity in volume | 当前油量(容积) | Gallons(加仑) | **可写** | +| `FUEL TANK CENTER2 QUANTITY` | Current quantity in volume | 当前油量(容积) | Gallons(加仑) | **可写** | +| `FUEL TANK CENTER3 QUANTITY` | Current quantity in volume | 当前油量(容积) | Gallons(加仑) | **可写** | +| `FUEL TANK LEFT MAIN QUANTITY` | Current quantity in volume | 当前油量(容积) | Gallons(加仑) | **可写** | +| `FUEL TANK LEFT AUX QUANTITY` | Current quantity in volume | 当前油量(容积) | Gallons(加仑) | **可写** | +| `FUEL TANK LEFT TIP QUANTITY` | Current quantity in volume | 当前油量(容积) | Gallons(加仑) | **可写** | +| `FUEL TANK RIGHT MAIN QUANTITY` | Current quantity in volume | 当前油量(容积) | Gallons(加仑) | **可写** | +| `FUEL TANK RIGHT AUX QUANTITY` | Current quantity in volume | 当前油量(容积) | Gallons(加仑) | **可写** | +| `FUEL TANK RIGHT TIP QUANTITY` | Current quantity in volume | 当前油量(容积) | Gallons(加仑) | **可写** | +| `FUEL TANK EXTERNAL1 QUANTITY` | Current quantity in volume | 当前油量(容积) | Gallons(加仑) | **可写** | +| `FUEL TANK EXTERNAL2 QUANTITY` | Current quantity in volume | 当前油量(容积) | Gallons(加仑) | **可写** | +| `FUEL LEFT QUANTITY` | Current quantity in volume | 当前油量(容积) | Gallons(加仑) | 只读 | +| `FUEL RIGHT QUANTITY` | Current quantity in volume | 当前油量(容积) | Gallons(加仑) | 只读 | +| `FUEL TOTAL QUANTITY` | Current quantity in volume | 当前油量(容积) | Gallons(加仑) | 只读 | +| `FUEL WEIGHT PER GALLON` | Fuel weight per gallon | 每加仑燃油重量 | Pounds(磅) | 只读 | +| `FUEL TANK SELECTOR:index` | Which tank is selected. See fuel tank list. | 油箱选择器(索引) | Enum(枚举) | 只读 | +| `FUEL CROSS FEED` | Cross feed valve:; 0 = Closed; 1 = Open | 串油阀门:;0=Closed;1=打开 | Enum(枚举) | 只读 | +| `FUEL TOTAL CAPACITY` | Total capacity of the aircraft | 总容量的the飞机 | Gallons(加仑) | 只读 | +| `FUEL SELECTED QUANTITY PERCENT` | Percent or capacity for selected tank | 百分比或容量用于selected油箱 | Percent Over 100(百分比(超100)) | 只读 | +| `FUEL SELECTED QUANTITY` | Quantity of selected tank | 数量的selected油箱 | Gallons(加仑) | 只读 | +| `FUEL TOTAL QUANTITY WEIGHT` | Current total fuel weight of the aircraft | 当前总燃油重量的the飞机 | Pounds(磅) | 只读 | +| `NUM FUEL SELECTORS` | Number of selectors on the aircraft | 数量的selectors开the飞机 | Number(数值) | 只读 | +| `UNLIMITED FUEL` | Unlimited fuel flag | Unlimited燃油标志 | Bool(布尔) | 只读 | +| `ESTIMATED FUEL FLOW` | Estimated fuel flow at cruise | Estimated燃油流量在cruise | Pounds per hour(磅/小时) | 只读 | + +## 三、Aircraft Lights Data(灯光) + +共 **24** 项(可写 0 / 只读 24) + +| 变量名 | 英文说明 | 中文说明 | 单位 | 读写 | +|---|---|---|---|---| +| `LIGHT STROBE` | Light switch state | 灯光开关状态 | Bool(布尔) | 只读 | +| `LIGHT PANEL` | Light switch state | 灯光开关状态 | Bool(布尔) | 只读 | +| `LIGHT LANDING` | Light switch state | 灯光开关状态 | Bool(布尔) | 只读 | +| `LIGHT TAXI` | Light switch state | 灯光开关状态 | Bool(布尔) | 只读 | +| `LIGHT BEACON` | Light switch state | 灯光开关状态 | Bool(布尔) | 只读 | +| `LIGHT NAV` | Light switch state | 灯光开关状态 | Bool(布尔) | 只读 | +| `LIGHT LOGO` | Light switch state | 灯光开关状态 | Bool(布尔) | 只读 | +| `LIGHT WING` | Light switch state | 灯光开关状态 | Bool(布尔) | 只读 | +| `LIGHT RECOGNITION` | Light switch state | 灯光开关状态 | Bool(布尔) | 只读 | +| `LIGHT CABIN` | Light switch state | 灯光开关状态 | Bool(布尔) | 只读 | +| `LIGHT ON STATES` | Bit mask:; 0x0001: Nav; 0x0002: Beacon; 0x0004: Landing; 0x0008: Taxi; 0x0010: Strobe; 0x0020: Panel; 0x0040: Recognition; 0x0080: Wing; 0x0100: Logo; 0x0200: Cabin | 位mask:;0x0001:导航;0x0002:信标;0x0004:着陆;0x0008:滑行;0x0010:频闪;0x0020:面板;0x0040:识别;0x0080:机翼;0x0100:标志;0x0200:客舱 | Mask(掩码) | 只读 | +| `LIGHT STATES` | Same as LIGHT ON STATES | Same作为灯光开状态 | Mask(掩码) | 只读 | +| `LIGHT TAXI ON` | Return true if the light is on. | 灯光开启时返回真 | Bool(布尔) | 只读 | +| `LIGHT STROBE ON` | Return true if the light is on. | 灯光开启时返回真 | Bool(布尔) | 只读 | +| `LIGHT PANEL ON` | Return true if the light is on. | 灯光开启时返回真 | Bool(布尔) | 只读 | +| `LIGHT RECOGNITION ON` | Return true if the light is on. | 灯光开启时返回真 | Bool(布尔) | 只读 | +| `LIGHT WING ON` | Return true if the light is on. | 灯光开启时返回真 | Bool(布尔) | 只读 | +| `LIGHT LOGO ON` | Return true if the light is on. | 灯光开启时返回真 | Bool(布尔) | 只读 | +| `LIGHT CABIN ON` | Return true if the light is on. | 灯光开启时返回真 | Bool(布尔) | 只读 | +| `LIGHT HEAD ON` | Return true if the light is on. | 灯光开启时返回真 | Bool(布尔) | 只读 | +| `LIGHT BRAKE ON` | Return true if the light is on. | 灯光开启时返回真 | Bool(布尔) | 只读 | +| `LIGHT NAV ON` | Return true if the light is on. | 灯光开启时返回真 | Bool(布尔) | 只读 | +| `LIGHT BEACON ON` | Return true if the light is on. | 灯光开启时返回真 | Bool(布尔) | 只读 | +| `LIGHT LANDING ON` | Return true if the light is on. | 灯光开启时返回真 | Bool(布尔) | 只读 | + +## 四、Aircraft Position and Speed Data(位置与速度) + +共 **34** 项(可写 24 / 只读 10) + +| 变量名 | 英文说明 | 中文说明 | 单位 | 读写 | +|---|---|---|---|---| +| `GROUND VELOCITY` | Speed relative to the earths surface | 速度相对至theearthssurface | Knots(节) | 只读 | +| `TOTAL WORLD VELOCITY` | Speed relative to the earths center | 速度相对至theearths中央 | Feet per second(英尺/秒) | 只读 | +| `VELOCITY BODY Z` | True longitudinal speed, relative to aircraft axis | 真longitudinal速度,相对至飞机axis | Feet per second(英尺/秒) | **可写** | +| `VELOCITY BODY X` | True lateral speed, relative to aircraft axis | 真lateral速度,相对至飞机axis | Feet per second(英尺/秒) | **可写** | +| `VELOCITY BODY Y` | True vertical speed, relative to aircraft axis | 真垂直速度,相对至飞机axis | Feet per second(英尺/秒) | **可写** | +| `VELOCITY WORLD Z` | Speed relative to earth, in North/South direction | 世界速度垂向 | Feet per second(英尺/秒) | **可写** | +| `VELOCITY WORLD X` | Speed relative to earth, in East/West direction | 世界速度横向 | Feet per second(英尺/秒) | **可写** | +| `VELOCITY WORLD Y` | Speed relative to earth, in vertical direction | 速度相对至earth,在垂直方向 | Feet per second(英尺/秒) | **可写** | +| `ACCELERATION WORLD X` | Acceleration relative to earth, in east/west direction | 世界加速度横向 | Feet per second squared(英尺/秒²) | **可写** | +| `ACCELERATION WORLD Y` | Acceleration relative to earch, in vertical direction | 加速度相对至earch,在垂直方向 | Feet per second squared(英尺/秒²) | **可写** | +| `ACCELERATION WORLD Z` | Acceleration relative to earth, in north/south direction | 世界加速度垂向 | Feet per second squared(英尺/秒²) | **可写** | +| `ACCELERATION BODY X` | Acceleration relative to aircraft axix, in east/west direction | 加速度相对至飞机axix,在east/west方向 | Feet per second squared(英尺/秒²) | **可写** | +| `ACCELERATION BODY Y` | Acceleration relative to aircraft axis, in vertical direction | 加速度相对至飞机axis,在垂直方向 | Feet per second squared(英尺/秒²) | **可写** | +| `ACCELERATION BODY Z` | Acceleration relative to aircraft axis, in north/south direction | 加速度相对至飞机axis,在north/south方向 | Feet per second squared(英尺/秒²) | **可写** | +| `ROTATION VELOCITY BODY X` | Rotation relative to aircraft axis | 机体旋转角速度横向 | Feet per second(英尺/秒) | **可写** | +| `ROTATION VELOCITY BODY Y` | Rotation relative to aircraft axis | 机体旋转角速度纵向 | Feet per second(英尺/秒) | **可写** | +| `ROTATION VELOCITY BODY Z` | Rotation relative to aircraft axis | 机体旋转角速度垂向 | Feet per second(英尺/秒) | **可写** | +| `RELATIVE WIND VELOCITY BODY X` | Lateral speed relative to wind | 相对风机体速度横向 | Feet per second(英尺/秒) | 只读 | +| `RELATIVE WIND VELOCITY BODY Y` | Vertical speed relative to wind | 相对风机体速度纵向 | Feet per second(英尺/秒) | 只读 | +| `RELATIVE WIND VELOCITY BODY Z` | Longitudinal speed relative to wind | 相对风机体速度垂向 | Feet per second(英尺/秒) | 只读 | +| `PLANE ALT ABOVE GROUND` | Altitude above the surface | 飞机离地高度 | Feet(英尺) | **可写** | +| `PLANE LATITUDE` | Latitude of aircraft, North is positive, South negative | 飞机纬度 | Degrees(度) | **可写** | +| `PLANE LONGITUDE` | Longitude of aircraft, East is positive, West negative | 飞机经度 | Degrees(度) | **可写** | +| `PLANE ALTITUDE` | Altitude of aircraft | 高度的飞机 | Feet(英尺) | **可写** | +| `PLANE PITCH DEGREES` | Pitch angle, although the name mentions degrees the units used are radians | 俯仰角,althoughthe名称mentions度the单位使用areradians | Radians(弧度) | **可写** | +| `PLANE BANK DEGREES` | Bank angle, although the name mentions degrees the units used are radians | 坡度角,althoughthe名称mentions度the单位使用areradians | Radians(弧度) | **可写** | +| `PLANE HEADING DEGREES TRUE` | Heading relative to true north, although the name mentions degrees the units used are radians | 航向相对至真north,althoughthe名称mentions度the单位使用areradians | Radians(弧度) | **可写** | +| `PLANE HEADING DEGREES MAGNETIC` | Heading relative to magnetic north, although the name mentions degrees the units used are radians | 航向相对至磁north,althoughthe名称mentions度the单位使用areradians | Radians(弧度) | **可写** | +| `MAGVAR` | Magnetic variation | 磁偏角 | Degrees(度) | 只读 | +| `GROUND ALTITUDE` | Altitude of surface | 地面高度 | Meters(米) | 只读 | +| `SIM ON GROUND` | On ground flag | 模拟开地面 | Bool(布尔) | 只读 | +| `INCIDENCE ALPHA` | Angle of attack | INCIDENCE迎角 | Radians(弧度) | 只读 | +| `INCIDENCE BETA` | Sideslip angle | INCIDENCE桨距角 | Radians(弧度) | 只读 | +| `WING FLEX PCT:index` | The current wing flex. Different values can be set for each wing (for example, during banking). Set an index of 1 for the left wing, and 2 for the right wing. | The当前机翼弯曲.Differentvaluescanbe设定用于each机翼(用于example,duringbanking).设定an索引的1用于the左机翼,和2用于the右机翼. | Percent over 100(百分比(超100)) | **可写** | + +## 五、Aircraft Flight Instrumentation Data(飞行仪表) + +共 **45** 项(可写 24 / 只读 21) + +| 变量名 | 英文说明 | 中文说明 | 单位 | 读写 | +|---|---|---|---|---| +| `AIRSPEED TRUE` | True airspeed | 真空速 | Knots(节) | **可写** | +| `AIRSPEED INDICATED` | Indicated airspeed | 指示空速 | Knots(节) | **可写** | +| `AIRSPEED TRUE CALIBRATE` | Angle of True calibration scale on airspeed indicator | 角的真calibrationscale开空速指示器 | Degrees(度) | **可写** | +| `AIRSPEED BARBER POLE` | Redline airspeed (dynamic on some aircraft) | Redline空速(dynamic开some飞机) | Knots(节) | 只读 | +| `AIRSPEED MACH` | Current mach | 空速MACH | Mach | 只读 | +| `VERTICAL SPEED` | Vertical speed indication | 垂直速度指示 | feet/minute(英尺/分钟) | **可写** | +| `MACH MAX OPERATE` | Maximum design mach | 最大设计mach | Mach | 只读 | +| `STALL WARNING` | Stall warning state | Stall警告状态 | Bool(布尔) | 只读 | +| `OVERSPEED WARNING` | Overspeed warning state | Overspeed警告状态 | Bool(布尔) | 只读 | +| `BARBER POLE MACH` | Mach associated with maximum airspeed | Machassociated带最大空速 | Mach | 只读 | +| `INDICATED ALTITUDE` | Altimeter indication | 高度表指示 | Feet(英尺) | **可写** | +| `KOHLSMAN SETTING MB` | Altimeter setting | 高度表气压设定 | Millibars(毫巴) | **可写** | +| `KOHLSMAN SETTING HG` | Altimeter setting | 高度表气压设定 | inHg(英寸汞柱) | 只读 | +| `ATTITUDE INDICATOR PITCH DEGREES` | AI pitch indication | 姿态指示器俯仰度 | Radians(弧度) | 只读 | +| `ATTITUDE INDICATOR BANK DEGREES` | AI bank indication | 姿态指示器坡度度 | Radians(弧度) | 只读 | +| `ATTITUDE BARS POSITION` | AI reference pitch reference bars | 姿态BARS位置 | Percent Over 100(百分比(超100)) | 只读 | +| `ATTITUDE CAGE` | AI caged state | AI锁定状态 | Bool(布尔) | 只读 | +| `WISKEY COMPASS INDICATION DEGREES` | Magnetic compass indication | 磁罗盘指示 | Degrees(度) | **可写** | +| `PLANE HEADING DEGREES GYRO` | Heading indicator (directional gyro) indication | 航向指示器(directional陀螺)indication | Radians(弧度) | **可写** | +| `HEADING INDICATOR` | Heading indicator (directional gyro) indication | 航向指示器(directional陀螺)indication | Radians(弧度) | 只读 | +| `GYRO DRIFT ERROR` | Angular error of heading indicator | Angular误差的航向指示器 | Radians(弧度) | 只读 | +| `DELTA HEADING RATE` | Rate of turn of heading indicator | 速率的转弯的航向指示器 | Radians per second(弧度/秒) | **可写** | +| `TURN COORDINATOR BALL` | Turn coordinator ball position | 转弯coordinatorball位置 | Position(位置) | 只读 | +| `ANGLE OF ATTACK INDICATOR` | AoA indication | 角度OFATTACK指示器 | Radians(弧度) | 只读 | +| `RADIO HEIGHT` | Radar altitude | RADIO高度 | Feet(英尺) | 只读 | +| `PARTIAL PANEL ADF` | Gauge fail flag (0 = ok, 1 = fail, 2 = blank) | 仪表故障标志(0=正常,1=故障,2=空白) | Enum(枚举) | **可写** | +| `PARTIAL PANEL AIRSPEED` | Gauge fail flag (0 = ok, 1 = fail, 2 = blank) | 仪表故障标志(0=正常,1=故障,2=空白) | Enum(枚举) | **可写** | +| `PARTIAL PANEL ALTIMETER` | Gauge fail flag (0 = ok, 1 = fail, 2 = blank) | 仪表故障标志(0=正常,1=故障,2=空白) | Enum(枚举) | **可写** | +| `PARTIAL PANEL ATTITUDE` | Gauge fail flag (0 = ok, 1 = fail, 2 = blank) | 仪表故障标志(0=正常,1=故障,2=空白) | Enum(枚举) | **可写** | +| `PARTIAL PANEL COMM` | Gauge fail flag (0 = ok, 1 = fail, 2 = blank) | 仪表故障标志(0=正常,1=故障,2=空白) | Enum(枚举) | **可写** | +| `PARTIAL PANEL COMPASS` | Gauge fail flag (0 = ok, 1 = fail, 2 = blank) | 仪表故障标志(0=正常,1=故障,2=空白) | Enum(枚举) | **可写** | +| `PARTIAL PANEL ELECTRICAL` | Gauge fail flag (0 = ok, 1 = fail, 2 = blank) | 仪表故障标志(0=正常,1=故障,2=空白) | Enum(枚举) | **可写** | +| `PARTIAL PANEL AVIONICS` | Gauge fail flag (0 = ok, 1 = fail, 2 = blank) | 仪表故障标志(0=正常,1=故障,2=空白) | Enum(枚举) | 只读 | +| `PARTIAL PANEL ENGINE` | Gauge fail flag (0 = ok, 1 = fail, 2 = blank) | 仪表故障标志(0=正常,1=故障,2=空白) | Enum(枚举) | **可写** | +| `PARTIAL PANEL FUEL INDICATOR` | Gauge fail flag (0 = ok, 1 = fail, 2 = blank) | 仪表故障标志(0=正常,1=故障,2=空白) | Enum(枚举) | 只读 | +| `PARTIAL PANEL HEADING` | Gauge fail flag (0 = ok, 1 = fail, 2 = blank) | 仪表故障标志(0=正常,1=故障,2=空白) | Enum(枚举) | **可写** | +| `PARTIAL PANEL VERTICAL VELOCITY` | Gauge fail flag (0 = ok, 1 = fail, 2 = blank) | 仪表故障标志(0=正常,1=故障,2=空白) | Enum(枚举) | **可写** | +| `PARTIAL PANEL TRANSPONDER` | Gauge fail flag (0 = ok, 1 = fail, 2 = blank) | 仪表故障标志(0=正常,1=故障,2=空白) | Enum(枚举) | **可写** | +| `PARTIAL PANEL NAV` | Gauge fail flag (0 = ok, 1 = fail, 2 = blank) | 仪表故障标志(0=正常,1=故障,2=空白) | Enum(枚举) | **可写** | +| `PARTIAL PANEL PITOT` | Gauge fail flag (0 = ok, 1 = fail, 2 = blank) | 仪表故障标志(0=正常,1=故障,2=空白) | Enum(枚举) | **可写** | +| `PARTIAL PANEL TURN COORDINATOR` | Gauge fail flag (0 = ok, 1 = fail, 2 = blank) | 仪表故障标志(0=正常,1=故障,2=空白) | Enum(枚举) | 只读 | +| `PARTIAL PANEL VACUUM` | Gauge fail flag (0 = ok, 1 = fail, 2 = blank) | 仪表故障标志(0=正常,1=故障,2=空白) | Enum(枚举) | **可写** | +| `MAX G FORCE` | Maximum G force attained | 最大GFORCE | Gforce | 只读 | +| `MIN G FORCE` | Minimum G force attained | 最小Gforceattained | Gforce | 只读 | +| `SUCTION PRESSURE` | Vacuum system suction pressure | 真空系统吸力压力 | inHg(英寸汞柱) | **可写** | + +## 六、Aircraft Avionics Data(航电) + +共 **115** 项(可写 4 / 只读 111) + +| 变量名 | 英文说明 | 中文说明 | 单位 | 读写 | +|---|---|---|---|---| +| `AVIONICS MASTER SWITCH` | Avionics switch state | 航电总开关 | Bool(布尔) | 只读 | +| `NAV SOUND:index` | Nav audio flag. Index of 1 or 2. | 导航音频标志(索引1或2) | Bool(布尔) | 只读 | +| `DME SOUND` | DME audio flag | DME 音频标志 | Bool(布尔) | 只读 | +| `ADF SOUND:index` | ADF audio flag. Index of 0 or 1. | ADF 音频标志(索引0或1) | Bool(布尔) | 只读 | +| `MARKER SOUND` | Marker audio flag | 指点标音频标志 | Bool(布尔) | 只读 | +| `COM TRANSMIT:index` | Audio panel com transmit state. Index of 1 or 2. | 音频面板通讯发射状态(索引) | Bool(布尔) | 只读 | +| `COM RECIEVE ALL` | Flag if all Coms receiving | 通讯全接收标志 | Bool(布尔) | 只读 | +| `COM ACTIVE FREQUENCY:index` | Com frequency. Index is 1 or 2. | 通讯频率(索引) | MHz(兆赫) | 只读 | +| `COM STANDBY FREQUENCY:index` | Com standby frequency. Index is 1 or 2. | 通讯备用频率(索引) | MHz(兆赫) | 只读 | +| `NAV AVAILABLE:index` | Flag if Nav equipped on aircraft | 导航设备可用标志(索引) | Bool(布尔) | 只读 | +| `NAV ACTIVE FREQUENCY:index` | Nav active frequency. Index is 1 or 2. | 导航激活频率(索引) | MHz(兆赫) | 只读 | +| `NAV STANDBY FREQUENCY:index` | Nav standby frequency. Index is 1 or 2. | 导航备用频率(索引) | MHz(兆赫) | 只读 | +| `NAV SIGNAL:index` | Nav signal strength | 导航信号强度(索引) | Number(数值) | 只读 | +| `NAV HAS NAV:index` | Flag if Nav has signal | 导航信号可用标志(索引) | Bool(布尔) | 只读 | +| `NAV HAS LOCALIZER:index` | Flag if tuned station is a localizer | 调谐台为航向道标志(索引) | Bool(布尔) | 只读 | +| `NAV HAS DME:index` | Flag if tuned station has a DME | 导航台 DME 可用标志(索引) | Bool(布尔) | 只读 | +| `NAV HAS GLIDE SLOPE:index` | Flag if tuned station has a glideslope | 导航台下滑道可用标志(索引) | Bool(布尔) | 只读 | +| `NAV BACK COURSE FLAGS:index` | Returns the following bit flags:; BIT0: 1=back course available; BIT1: 1=localizer tuned in; BIT2: 1=on course; BIT7: 1=station active | 导航背台标志位(索引) | Flags(标志位) | 只读 | +| `NAV MAGVAR:index` | Magnetic variation of tuned nav station | 导航台磁偏角(索引) | Degrees(度) | 只读 | +| `NAV RADIAL:index` | Radial that aircraft is on | 飞机所在径向(索引) | Degrees(度) | 只读 | +| `NAV RADIAL ERROR:index` | Difference between current radial and OBS tuned radial | 当前径向与 OBS 调谐径向之差(索引) | Degrees(度) | 只读 | +| `NAV LOCALIZER:index` | Localizer course heading | 航向道航道航向(索引) | Degrees(度) | 只读 | +| `NAV GLIDE SLOPE ERROR:index` | Difference between current position and glideslope angle. Note that this provides 32 bit floating point precision, rather than the 8 bit integer precision of NAV GSI. | 当前位置与下滑道角之差(索引) | Degrees(度) | 只读 | +| `NAV CDI:index` | CDI needle deflection (+/- 127) | CDI 指针偏转(索引) | Number(数值) | 只读 | +| `NAV GSI:index` | Glideslope needle deflection (+/- 119). Note that this provides only 8 bit precision, whereas NAV GLIDE SLOPE ERROR provides 32 bit floating point precision. | 下滑道指针偏转(索引) | Number(数值) | 只读 | +| `NAV GS FLAG:index` | Glideslope flag | 下滑道标志(索引) | Bool(布尔) | 只读 | +| `NAV OBS:index` | OBS setting. Index of 1 or 2. | OBS 设定(索引) | Degrees(度) | 只读 | +| `NAV DME:index` | DME distance | DME 距离(索引) | Nautical miles(海里) | 只读 | +| `NAV DMESPEED:index` | DME speed | DME 速度(索引) | Knots(节) | 只读 | +| `ADF ACTIVE FREQUENCY:index` | ADF frequency. Index of 1 or 2. | ADF 频率(索引) | Frequency ADF BCD32(ADF BCD32 频率) | 只读 | +| `ADF STANDBY FREQUENCY:index` | ADF standby frequency | ADF 备用频率(索引) | Hz(赫兹) | 只读 | +| `ADF RADIAL:index` | Current direction from NDB station | 相对 NDB 台当前方位(索引) | Degrees(度) | 只读 | +| `ADF SIGNAL:index` | Signal strength | 信号强度 | Number(数值) | 只读 | +| `TRANSPONDER CODE:index` | 4-digit code | 四位编码(索引) | BCO16 | 只读 | +| `MARKER BEACON STATE` | Marker beacon state:; 0 = None; 1 = Outer; 2 = Middle; 3 = Inner | 指点标信标状态:;0=无;1=Outer;2=Middle;3=Inner | Enum(枚举) | **可写** | +| `INNER MARKER` | Inner marker state | Inner指点标状态 | Bool(布尔) | **可写** | +| `MIDDLE MARKER` | Middle marker state | Middle指点标状态 | Bool(布尔) | **可写** | +| `OUTER MARKER` | Outer marker state | Outer指点标状态 | Bool(布尔) | **可写** | +| `NAV RAW GLIDE SLOPE:index` | Glide slope angle | 导航RAW下滑道(索引) | Degrees(度) | 只读 | +| `ADF CARD` | ADF compass rose setting | ADF罗盘罗盘设定 | Degrees(度) | 只读 | +| `HSI CDI NEEDLE` | Needle deflection (+/- 127) | 指针偏转(+/-127) | Number(数值) | 只读 | +| `HSI GSI NEEDLE` | Needle deflection (+/- 119) | 指针偏转(+/-119) | Number(数值) | 只读 | +| `HSI CDI NEEDLE VALID` | Signal valid | HSI CDI 指针信号有效 | Bool(布尔) | 只读 | +| `HSI GSI NEEDLE VALID` | Signal valid | HSI GSI 指针信号有效 | Bool(布尔) | 只读 | +| `HSI TF FLAGS` | Nav TO/FROM flag:; 0 = Off; 1 = TO; 2 = FROM | 导航至/来自标志:;0=关;1=至;2=来自 | Enum(枚举) | 只读 | +| `HSI BEARING VALID` | This will return true if the HSI BEARING variable contains valid data. | 此will返回真若theHSI方位variablecontains有效data. | Bool(布尔) | 只读 | +| `HSI BEARING` | If the GPS DRIVES NAV1 variable is true and the HSI BEARING VALID variable is true, this variable contains the HSI needle bearing. If the GPS DRIVES NAV1 variable is false and the HSI BEARING VALID variable is true, this variable contains the ADF1 frequency. | 若theGPSDRIVESNAV1variable为真和theHSI方位有效variable为真,此variablecontainstheHSI指针方位.若theGPSDRIVESNAV1variable为false和theHSI方位有效variable为真,此variablecontainstheADF1频率. | Degrees(度) | 只读 | +| `HSI HAS LOCALIZER` | Station is a localizer | 台站为a航向道 | Bool(布尔) | 只读 | +| `HSI SPEED` | DME/GPS speed | HSI速度 | Knots(节) | 只读 | +| `HSI DISTANCE` | DME/GPS distance | HSI距离 | Nautical miles(海里) | 只读 | +| `GPS POSITION LAT` | Current GPS latitude | 当前GPS纬度 | Degrees(度) | 只读 | +| `GPS POSITION LON` | Current GPS longitude | 当前GPS经度 | Degrees(度) | 只读 | +| `GPS POSITION ALT` | Current GPS altitude | GPS位置高度 | Meters(米) | 只读 | +| `GPS MAGVAR` | Current GPS magnetic variation | 当前GPS磁偏角 | Radians(弧度) | 只读 | +| `GPS IS ACTIVE FLIGHT PLAN` | Flight plan mode active | 飞行plan模式激活 | Bool(布尔) | 只读 | +| `GPS IS ACTIVE WAY POINT` | Waypoint mode active | Waypoint模式激活 | Bool(布尔) | 只读 | +| `GPS IS ARRIVED` | Is flight plan destination reached | 为飞行plandestination达到 | Bool(布尔) | 只读 | +| `GPS IS DIRECTTO FLIGHTPLAN` | Is Direct To Waypoint mode active | 为Direct至Waypoint模式激活 | Bool(布尔) | 只读 | +| `GPS GROUND SPEED` | Current ground speed | 当前地面速度 | Meters per second | 只读 | +| `GPS GROUND TRUE HEADING` | Current true heading | 当前真航向 | Radians(弧度) | 只读 | +| `GPS GROUND MAGNETIC TRACK` | Current magnetic ground track | 当前磁地面track | Radians(弧度) | 只读 | +| `GPS GROUND TRUE TRACK` | Current true ground track | 当前真地面track | Radians(弧度) | 只读 | +| `GPS WP DISTANCE` | Distance to waypoint | GPS航路点距离 | Meters(米) | 只读 | +| `GPS WP BEARING` | Magnetic bearing to waypoint | GPS航路点方位 | Radians(弧度) | 只读 | +| `GPS WP TRUE BEARING` | True bearing to waypoint | GPS航路点真方位 | Radians(弧度) | 只读 | +| `GPS WP CROSS TRK` | Cross track distance | GPS航路点串TRK | Meters(米) | 只读 | +| `GPS WP DESIRED TRACK` | Desired track to waypoint | GPS航路点目标TRACK | Radians(弧度) | 只读 | +| `GPS WP TRUE REQ HDG` | Required true heading to waypoint | GPS航路点真REQHDG | Radians(弧度) | 只读 | +| `GPS WP VERTICAL SPEED` | Vertical speed to waypoint | GPS航路点垂直速度 | Meters per second | 只读 | +| `GPS WP TRACK ANGLE ERROR` | Tracking angle error to waypoint | GPS航路点TRACK角度误差 | Radians(弧度) | 只读 | +| `GPS ETE` | Estimated time enroute to destination | Estimated时间enroute至destination | Seconds(秒) | 只读 | +| `GPS ETA` | Estimated time of arrival at destination | Estimated时间的arrival在destination | Seconds(秒) | 只读 | +| `GPS WP NEXT LAT` | Latitude of next waypoint | 纬度的下一waypoint | Degrees(度) | 只读 | +| `GPS WP NEXT LON` | Longitude of next waypoint | 经度的下一waypoint | Degrees(度) | 只读 | +| `GPS WP NEXT ALT` | Altitude of next waypoint | GPS航路点下一高度 | Meters(米) | 只读 | +| `GPS WP PREV VALID` | Is previous waypoint valid (i.e. current waypoint is not the first waypoint) | GPS航路点上一有效 | Bool(布尔) | 只读 | +| `GPS WP PREV LAT` | Latitude of previous waypoint | GPS航路点上一LAT | Degrees(度) | 只读 | +| `GPS WP PREV LON` | Longitude of previous waypoint | GPS航路点上一LON | Degrees(度) | 只读 | +| `GPS WP PREV ALT` | Altitude of previous waypoint | GPS航路点上一高度 | Meters(米) | 只读 | +| `GPS WP ETE` | Estimated time enroute to waypoint | GPS航路点ETE | Seconds(秒) | 只读 | +| `GPS WP ETA` | Estimated time of arrival at waypoint | Estimated时间的arrival在waypoint | Seconds(秒) | 只读 | +| `GPS COURSE TO STEER` | Suggested heading to steer (for autopilot) | Suggested航向至转向(用于自动驾驶) | Radians(弧度) | 只读 | +| `GPS FLIGHT PLAN WP INDEX` | Index of waypoint | GPS飞行PLAN航路点索引 | Number(数值) | 只读 | +| `GPS FLIGHT PLAN WP COUNT` | Number of waypoints | GPS飞行PLAN航路点COUNT | Number(数值) | 只读 | +| `GPS IS ACTIVE WP LOCKED` | Is switching to next waypoint locked | GPSIS激活航路点LOCKED | Bool(布尔) | 只读 | +| `GPS IS APPROACH LOADED` | Is approach loaded | 为进近loaded | Bool(布尔) | 只读 | +| `GPS IS APPROACH ACTIVE` | Is approach mode active | 为进近模式激活 | Bool(布尔) | 只读 | +| `GPS APPROACH IS WP RUNWAY` | Waypoint is the runway | GPS进近IS航路点RUNWAY | Bool(布尔) | 只读 | +| `GPS APPROACH APPROACH INDEX` | Index of approach for given airport | 索引的进近用于givenairport | Number(数值) | 只读 | +| `GPS APPROACH TRANSITION INDEX` | Index of approach transition | 索引的进近transition | Number(数值) | 只读 | +| `GPS APPROACH IS FINAL` | Is approach transition final approach segment | 为进近transitionfinal进近segment | Bool(布尔) | 只读 | +| `GPS APPROACH IS MISSED` | Is approach segment missed approach segment | 为进近segmentmissed进近segment | Bool(布尔) | 只读 | +| `GPS APPROACH TIMEZONE DEVIATION` | Deviation of local time from GMT | 偏差的本地时间来自世界时 | Seconds(秒) | 只读 | +| `GPS APPROACH WP INDEX` | Index of current waypoint | GPS进近航路点索引 | Number(数值) | 只读 | +| `GPS APPROACH WP COUNT` | Number of waypoints | GPS进近航路点COUNT | Number(数值) | 只读 | +| `GPS DRIVES NAV1` | GPS is driving Nav 1 indicator | GPS为driving导航1指示器 | Bool(布尔) | 只读 | +| `COM RECEIVE ALL` | Toggles all COM radios to receive on | Toggles全部通讯radios至接收开 | Bool(布尔) | 只读 | +| `COM AVAILABLE` | True if either COM1 or COM2 is available | 当eitherCOM1或COM2为可用时为真 | Bool(布尔) | 只读 | +| `COM TEST:index` | Enter an index of 1 or 2. True if the COM system is working. | Enteran索引的1或2.真若the通讯系统为working. | Bool(布尔) | 只读 | +| `TRANSPONDER AVAILABLE` | True if a transponder is available | 当a应答机为可用时为真 | Bool(布尔) | 只读 | +| `ADF AVAILABLE` | True if ADF is available | 当ADF为可用时为真 | Bool(布尔) | 只读 | +| `ADF FREQUENCY:index` | Legacy, use ADF ACTIVE FREQUENCY | ADF频率(索引) | Frequency BCD16 | 只读 | +| `ADF EXT FREQUENCY:index` | Legacy, use ADF ACTIVE FREQUENCY | ADFEXT频率(索引) | Frequency BCD16 | 只读 | +| `ADF IDENT` | ICAO code | ADF识别 | String(字符串) | 只读 | +| `ADF NAME` | Descriptive name | ADF 描述名称 | String(字符串) | 只读 | +| `NAV IDENT` | ICAO code | 导航识别 | String(字符串) | 只读 | +| `NAV NAME` | Descriptive name | 描述性名称 | String(字符串) | 只读 | +| `NAV CODES:index` | Returns bit flags with the following meaning:; BIT7: 0= VOR 1= Localizer; BIT6: 1= glideslope available; BIT5: 1= no localizer backcourse; BIT4: 1= DME transmitter at glide slope transmitter; BIT3: 1= no nav signal available; BIT2: 1= voice available; BIT1: 1 = TACAN available; BIT0: 1= DME available | 返回位标志带the以下meaning:;BIT7:0=VOR1=航向道;BIT6:1=下滑道可用;BIT5:1=禁航向道背台;BIT4:1=DMEtransmitter在下滑道transmitter;BIT3:1=禁导航信号可用;BIT2:1=voice可用;BIT1:1=TACAN可用;BIT0:1=DME可用 | Flags(标志位) | 只读 | +| `NAV GLIDE SLOPE` | The glide slope gradient. | 导航下滑道 | Number(数值) | 只读 | +| `NAV RELATIVE BEARING TO STATION:index` | Relative bearing to station | 导航相对方位至台站(索引) | Degrees(度) | 只读 | +| `SELECTED DME` | Selected DME | 选中的 DME | Number(数值) | 只读 | +| `GPS WP NEXT ID` | ID of next GPS waypoint | GPS航路点下一ID | String(字符串) | 只读 | +| `GPS WP PREV ID` | ID of previous GPS waypoint | GPS航路点上一ID | String(字符串) | 只读 | +| `GPS TARGET DISTANCE` | Distance to target | 距离至目标 | Meters(米) | 只读 | +| `GPS TARGET ALTITUDE` | Altitude of GPS target | 高度的GPS目标 | Meters(米) | 只读 | + +## 七、Aircraft Controls Data(操纵/控制) + +共 **48** 项(可写 18 / 只读 30) + +| 变量名 | 英文说明 | 中文说明 | 单位 | 读写 | +|---|---|---|---|---| +| `YOKE Y POSITION` | Percent control deflection fore/aft (for animation) | 百分比控制偏转fore/aft(用于动画) | Position(位置) | **可写** | +| `YOKE X POSITION` | Percent control deflection left/right (for animation) | 百分比控制偏转左/右(用于动画) | Position(位置) | **可写** | +| `RUDDER PEDAL POSITION` | Percent rudder pedal deflection (for animation) | 百分比方向舵踏板偏转(用于动画) | Position(位置) | **可写** | +| `RUDDER POSITION` | Percent rudder input deflection | 百分比方向舵input偏转 | Position(位置) | **可写** | +| `ELEVATOR POSITION` | Percent elevator input deflection | 百分比升降舵input偏转 | Position(位置) | **可写** | +| `AILERON POSITION` | Percent aileron input left/right | 百分比副翼input左/右 | Position(位置) | **可写** | +| `ELEVATOR TRIM POSITION` | Elevator trim deflection | 升降舵配平位置 | Radians(弧度) | **可写** | +| `ELEVATOR TRIM INDICATOR` | Percent elevator trim (for indication) | 百分比升降舵配平(用于indication) | Position(位置) | 只读 | +| `ELEVATOR TRIM PCT` | Percent elevator trim | 升降舵配平百分比 | Percent Over 100(百分比(超100)) | 只读 | +| `BRAKE LEFT POSITION` | Percent left brake | 百分比左制动 | Position(位置) | **可写** | +| `BRAKE RIGHT POSITION` | Percent right brake | 百分比右制动 | Position(位置) | **可写** | +| `BRAKE INDICATOR` | Brake on indication | 制动指示器 | Position(位置) | 只读 | +| `BRAKE PARKING POSITION` | Parking brake on | 停留刹车位置 | Position(位置) | **可写** | +| `BRAKE PARKING INDICATOR` | Parking brake indicator | 停留刹车指示器 | Bool(布尔) | 只读 | +| `SPOILERS ARMED` | Auto-spoilers armed | 自动-扰流板预位 | Bool(布尔) | 只读 | +| `SPOILERS HANDLE POSITION` | Spoiler handle position | 扰流板手柄位置 | Percent Over 100(百分比(超100)) | **可写** | +| `SPOILERS LEFT POSITION` | Percent left spoiler deflected | 百分比左扰流板deflected | Percent Over 100(百分比(超100)) | 只读 | +| `SPOILERS RIGHT POSITION` | Percent right spoiler deflected | 百分比右扰流板deflected | Percent Over 100(百分比(超100)) | 只读 | +| `FLAPS HANDLE PERCENT` | Percent flap handle extended | 百分比襟翼手柄伸出 | Percent Over 100(百分比(超100)) | 只读 | +| `FLAPS HANDLE INDEX` | Index of current flap position | 索引的当前襟翼位置 | Number(数值) | **可写** | +| `FLAPS NUM HANDLE POSITIONS` | Number of flap positions | 襟翼数量手柄位置数 | Number(数值) | 只读 | +| `TRAILING EDGE FLAPS LEFT PERCENT` | Percent left trailing edge flap extended | 百分比左后缘缘襟翼伸出 | Percent Over 100(百分比(超100)) | **可写** | +| `TRAILING EDGE FLAPS RIGHT PERCENT` | Percent right trailing edge flap extended | 百分比右后缘缘襟翼伸出 | Percent Over 100(百分比(超100)) | **可写** | +| `TRAILING EDGE FLAPS LEFT ANGLE` | Angle left trailing edge flap extended. Use TRAILING EDGE FLAPS LEFT PERCENT to set a value. | 角左后缘缘襟翼伸出.Use后缘缘襟翼左百分比至设定avalue. | Radians(弧度) | 只读 | +| `TRAILING EDGE FLAPS RIGHT ANGLE` | Angle right trailing edge flap extended. Use TRAILING EDGE FLAPS RIGHT PERCENT to set a value. | 角右后缘缘襟翼伸出.Use后缘缘襟翼右百分比至设定avalue. | Radians(弧度) | 只读 | +| `LEADING EDGE FLAPS LEFT PERCENT` | Percent left leading edge flap extended | 百分比左前缘缘襟翼伸出 | Percent Over 100(百分比(超100)) | **可写** | +| `LEADING EDGE FLAPS RIGHT PERCENT` | Percent right leading edge flap extended | 百分比右前缘缘襟翼伸出 | Percent Over 100(百分比(超100)) | **可写** | +| `LEADING EDGE FLAPS LEFT ANGLE` | Angle left leading edge flap extended. Use LEADING EDGE FLAPS LEFT PERCENT to set a value. | 角左前缘缘襟翼伸出.Use前缘缘襟翼左百分比至设定avalue. | Radians(弧度) | 只读 | +| `LEADING EDGE FLAPS RIGHT ANGLE` | Angle right leading edge flap extended. Use LEADING EDGE FLAPS RIGHT PERCENT to set a value. | 角右前缘缘襟翼伸出.Use前缘缘襟翼右百分比至设定avalue. | Radians(弧度) | 只读 | +| `AILERON LEFT DEFLECTION` | Angle deflection | 左副翼偏转 | Radians(弧度) | 只读 | +| `AILERON LEFT DEFLECTION PCT` | Percent deflection | 左副翼偏转百分比 | Percent Over 100(百分比(超100)) | 只读 | +| `AILERON RIGHT DEFLECTION` | Angle deflection | 右副翼偏转 | Radians(弧度) | 只读 | +| `AILERON RIGHT DEFLECTION PCT` | Percent deflection | 右副翼偏转百分比 | Percent Over 100(百分比(超100)) | 只读 | +| `AILERON AVERAGE DEFLECTION` | Angle deflection | 副翼平均偏转 | Radians(弧度) | 只读 | +| `AILERON TRIM` | Angle deflection | 副翼配平 | Radians(弧度) | 只读 | +| `AILERON TRIM PCT` | The trim position of the ailerons. Zero is fully retracted. | 副翼配平位置,零为完全收回 | Percent over 100(百分比(超100)) | **可写** | +| `RUDDER DEFLECTION` | Angle deflection | 方向舵偏转 | Radians(弧度) | 只读 | +| `RUDDER DEFLECTION PCT` | Percent deflection | 方向舵偏转百分比 | Percent Over 100(百分比(超100)) | 只读 | +| `RUDDER TRIM` | Angle deflection | 方向舵配平 | Radians(弧度) | 只读 | +| `RUDDER TRIM PCT` | The trim position of the rudder. Zero is no trim. | 方向舵配平位置,零为无配平 | Percent over 100(百分比(超100)) | **可写** | +| `FLAPS AVAILABLE` | True if flaps available | 当襟翼可用时为真 | Bool(布尔) | 只读 | +| `FLAP DAMAGE BY SPEED` | True if flagps are damaged by excessive speed | 襟翼损伤BY速度 | Bool(布尔) | 只读 | +| `FLAP SPEED EXCEEDED` | True if safe speed limit for flaps exceeded | 当safe速度限制用于襟翼exceeded时为真 | Bool(布尔) | 只读 | +| `ELEVATOR DEFLECTION` | Angle deflection | 升降舵偏转 | Radians(弧度) | 只读 | +| `ELEVATOR DEFLECTION PCT` | Percent deflection | 升降舵偏转百分比 | Percent Over 100(百分比(超100)) | 只读 | +| `ALTERNATE STATIC SOURCE OPEN` | Alternate static air source | 备用静压源 | Bool(布尔) | 只读 | +| `FOLDING WING HANDLE POSITION` | True if the folding wing handle is engaged. | 当thefolding机翼手柄为engaged时为真 | Bool(布尔) | 只读 | +| `FUEL DUMP SWITCH` | If true the aircraft is dumping fuel at the rate set in the configuration file. | 若真the飞机为dumping燃油在the速率设定在theconfigurationfile. | Bool(布尔) | 只读 | + +## 八、Aircraft Autopilot Data(自动驾驶) + +共 **38** 项(可写 0 / 只读 38) + +| 变量名 | 英文说明 | 中文说明 | 单位 | 读写 | +|---|---|---|---|---| +| `AUTOPILOT AVAILABLE` | Available flag | 自动驾驶可用 | Bool(布尔) | 只读 | +| `AUTOPILOT MASTER` | On/off flag | 自动驾驶主开关 | Bool(布尔) | 只读 | +| `AUTOPILOT NAV SELECTED` | Index of Nav radio selected | 自动驾驶导航SELECTED | Number(数值) | 只读 | +| `AUTOPILOT WING LEVELER` | Wing leveler active | 自动驾驶改平 | Bool(布尔) | 只读 | +| `AUTOPILOT NAV1 LOCK` | True if autopilot nav1 lock applied | 当自动驾驶nav1锁定applied时为真 | Bool(布尔) | 只读 | +| `AUTOPILOT HEADING LOCK` | Heading mode active | 自动驾驶航向锁定 | Bool(布尔) | 只读 | +| `AUTOPILOT HEADING LOCK DIR` | Selected heading | 自动驾驶航向锁定方向 | Degrees(度) | 只读 | +| `AUTOPILOT ALTITUDE LOCK` | Altitude hole active | 自动驾驶高度锁定 | Bool(布尔) | 只读 | +| `AUTOPILOT ALTITUDE LOCK VAR` | Selected altitude | 自动驾驶高度锁定VAR | Feet(英尺) | 只读 | +| `AUTOPILOT ATTITUDE HOLD` | Attitude hold active | 自动驾驶姿态保持 | Bool(布尔) | 只读 | +| `AUTOPILOT GLIDESLOPE HOLD` | GS hold active | 自动驾驶下滑道保持 | Bool(布尔) | 只读 | +| `AUTOPILOT PITCH HOLD REF` | Current reference pitch | 自动驾驶俯仰保持REF | Radians(弧度) | 只读 | +| `AUTOPILOT APPROACH HOLD` | Approach mode active | 自动驾驶进近保持 | Bool(布尔) | 只读 | +| `AUTOPILOT BACKCOURSE HOLD` | Back course mode active | 自动驾驶背台保持 | Bool(布尔) | 只读 | +| `AUTOPILOT VERTICAL HOLD VAR` | Selected vertical speed | 自动驾驶垂直保持VAR | Feet/minute | 只读 | +| `AUTOPILOT PITCH HOLD` | Set to True if the autopilot pitch hold has is engaged. | 设定至真若the自动驾驶俯仰保持具有为engaged. | Bool(布尔) | 只读 | +| `AUTOPILOT FLIGHT DIRECTOR ACTIVE` | Flight director active | 飞行指引仪激活 | Bool(布尔) | 只读 | +| `AUTOPILOT FLIGHT DIRECTOR PITCH` | Reference pitch angle | 自动驾驶飞行指引俯仰 | Radians(弧度) | 只读 | +| `AUTOPILOT FLIGHT DIRECTOR BANK` | Reference bank angle | 自动驾驶飞行指引坡度 | Radians(弧度) | 只读 | +| `AUTOPILOT AIRSPEED HOLD` | Airspeed hold active | 自动驾驶空速保持 | Bool(布尔) | 只读 | +| `AUTOPILOT AIRSPEED HOLD VAR` | Selected airspeed | 自动驾驶空速保持VAR | Knots(节) | 只读 | +| `AUTOPILOT MACH HOLD` | Mach hold active | 自动驾驶MACH保持 | Bool(布尔) | 只读 | +| `AUTOPILOT MACH HOLD VAR` | Selected mach | 自动驾驶MACH保持VAR | Number(数值) | 只读 | +| `AUTOPILOT YAW DAMPER` | Yaw damper active | 偏航阻尼器 | Bool(布尔) | 只读 | +| `AUTOPILOT RPM HOLD VAR` | Selected rpm | 自动驾驶转速保持VAR | Number(数值) | 只读 | +| `AUTOPILOT THROTTLE ARM` | Autothrottle armed | 自动驾驶油门ARM | Bool(布尔) | 只读 | +| `AUTOPILOT TAKEOFF POWER ACTIVE` | Takeoff / Go Around power mode active | 起飞功率激活 | Bool(布尔) | 只读 | +| `AUTOTHROTTLE ACTIVE` | Auto-throttle active | 自动-油门激活 | Bool(布尔) | 只读 | +| `AUTOPILOT VERTICAL HOLD` | True if autopilot vertical hold applied | 当自动驾驶垂直保持applied时为真 | Bool(布尔) | 只读 | +| `AUTOPILOT RPM HOLD` | True if autopilot rpm hold applied | 当自动驾驶转速保持applied时为真 | Bool(布尔) | 只读 | +| `AUTOPILOT MAX BANK` | True if autopilot max bank applied | 当自动驾驶最大坡度applied时为真 | Radians(弧度) | 只读 | +| `FLY BY WIRE ELAC SWITCH` | True if the fly by wire Elevators and Ailerons computer is on. | 当thefly由wireElevators和Aileronscomputer为开时为真 | Bool(布尔) | 只读 | +| `FLY BY WIRE FAC SWITCH` | True if the fly by wire Flight Augmentation computer is on. | 当thefly由wire飞行Augmentationcomputer为开时为真 | Bool(布尔) | 只读 | +| `FLY BY WIRE SEC SWITCH` | True if the fly by wire Spoilers and Elevators computer is on. | 当thefly由wire扰流板和Elevatorscomputer为开时为真 | Bool(布尔) | 只读 | +| `FLY BY WIRE ELAC FAILED` | True if the Elevators and Ailerons computer has failed. | 当theElevators和Aileronscomputer具有故障时为真 | Bool(布尔) | 只读 | +| `FLY BY WIRE FAC FAILED` | True if the Flight Augmentation computer has failed. | 当the飞行Augmentationcomputer具有故障时为真 | Bool(布尔) | 只读 | +| `FLY BY WIRE SEC FAILED` | True if the Spoilers and Elevators computer has failed. | 当the扰流板和Elevatorscomputer具有故障时为真 | Bool(布尔) | 只读 | +| `AUTOPILOT FLIGHT LEVEL CHANGE` | True if autopilot FLC mode applied | 当自动驾驶FLC模式applied时为真 | Bool(布尔) | 只读 | + +## 九、Aircraft Landing Gear Data(起落架) + +共 **54** 项(可写 6 / 只读 48) + +| 变量名 | 英文说明 | 中文说明 | 单位 | 读写 | +|---|---|---|---|---| +| `IS GEAR RETRACTABLE` | True if gear can be retracted | 当起落架canberetracted时为真 | Bool(布尔) | 只读 | +| `IS GEAR SKIS` | True if landing gear is skis | 当着陆起落架为skis时为真 | Bool(布尔) | 只读 | +| `IS GEAR FLOATS` | True if landing gear is floats | 当着陆起落架为浮筒时为真 | Bool(布尔) | 只读 | +| `IS GEAR SKIDS` | True if landing gear is skids | 当着陆起落架为skids时为真 | Bool(布尔) | 只读 | +| `IS GEAR WHEELS` | True if landing gear is wheels | 当着陆起落架为wheels时为真 | Bool(布尔) | 只读 | +| `GEAR HANDLE POSITION` | True if gear handle is applied | 当起落架手柄为applied时为真 | Bool(布尔) | **可写** | +| `GEAR HYDRAULIC PRESSURE` | Gear hydraulic pressure | 起落架液压压力 | psf | 只读 | +| `TAILWHEEL LOCK ON` | True if tailwheel lock applied | 当tailwheel锁定applied时为真 | Bool(布尔) | 只读 | +| `GEAR CENTER POSITION` | Percent center gear extended | 百分比中央起落架伸出 | Percent Over 100(百分比(超100)) | **可写** | +| `GEAR LEFT POSITION` | Percent left gear extended | 百分比左起落架伸出 | Percent Over 100(百分比(超100)) | **可写** | +| `GEAR RIGHT POSITION` | Percent right gear extended | 百分比右起落架伸出 | Percent Over 100(百分比(超100)) | **可写** | +| `GEAR TAIL POSITION` | Percent tail gear extended | 百分比tail起落架伸出 | Percent Over 100(百分比(超100)) | 只读 | +| `GEAR AUX POSITION` | Percent auxiliary gear extended | 百分比辅助起落架伸出 | Percent Over 100(百分比(超100)) | 只读 | +| `GEAR POSITION:index` | Position of landing gear:; 0 = unknown; 1 = up; 2 = down | 位置的着陆起落架:;0=unknown;1=收起;2=down | Enum(枚举) | **可写** | +| `GEAR ANIMATION POSITION:index` | Percent gear animation extended | 百分比起落架动画伸出 | Number(数值) | 只读 | +| `GEAR TOTAL PCT EXTENDED` | Percent total gear extended | 起落架总放出百分比 | Percentage | 只读 | +| `AUTO BRAKE SWITCH CB` | Auto brake switch position | 自动制动开关位置 | Number(数值) | 只读 | +| `WATER RUDDER HANDLE POSITION` | Position of the water rudder handle (0 handle retracted, 100 rudder handle applied) | 位置的the水方向舵手柄(0手柄retracted,100方向舵手柄applied) | Percent Over 100(百分比(超100)) | **可写** | +| `WATER LEFT RUDDER EXTENDED` | Percent extended | 水左方向舵伸出 | Percentage | 只读 | +| `WATER RIGHT RUDDER EXTENDED` | Percent extended | 水右方向舵伸出 | Percentage | 只读 | +| `GEAR CENTER STEER ANGLE` | Center wheel angle, negative to the left, positive to the right. | 起落架中央转向角度 | Percent Over 100(百分比(超100)) | 只读 | +| `GEAR LEFT STEER ANGLE` | Left wheel angle, negative to the left, positive to the right. | 起落架左转向角度 | Percent Over 100(百分比(超100)) | 只读 | +| `GEAR RIGHT STEER ANGLE` | Right wheel angle, negative to the left, positive to the right. | 起落架右转向角度 | Percent Over 100(百分比(超100)) | 只读 | +| `GEAR AUX STEER ANGLE` | Aux wheel angle, negative to the left, positive to the right. The aux wheel is the fourth set of gear, sometimes used on helicopters. | 辅助机轮角,negative至the左,positive至the右.The辅助机轮为thefourth设定的起落架,sometimes使用开直升机. | Percent Over 100(百分比(超100)) | 只读 | +| `GEAR STEER ANGLE:index` | Alternative method of getting the steer angle. Index is; 0 = center; 1 = left; 2 = right; 3 = aux | Alternativemethod的gettingthe转向角.索引为;0=中央;1=左;2=右;3=辅助 | Percent Over 100(百分比(超100)) | 只读 | +| `WATER LEFT RUDDER STEER ANGLE` | Water left rudder angle, negative to the left, positive to the right. | 水左方向舵转向角度 | Percent Over 100(百分比(超100)) | 只读 | +| `WATER RIGHT RUDDER STEER ANGLE` | Water right rudder angle, negative to the left, positive to the right. | 水右方向舵转向角度 | Percent Over 100(百分比(超100)) | 只读 | +| `GEAR CENTER STEER ANGLE PCT` | Center steer angle as a percentage | 起落架中央转向角度百分比 | Percent Over 100(百分比(超100)) | 只读 | +| `GEAR LEFT STEER ANGLE PCT` | Left steer angle as a percentage | 起落架左转向角度百分比 | Percent Over 100(百分比(超100)) | 只读 | +| `GEAR RIGHT STEER ANGLE PCT` | Right steer angle as a percentage | 起落架右转向角度百分比 | Percent Over 100(百分比(超100)) | 只读 | +| `GEAR AUX STEER ANGLE PCT` | Aux steer angle as a percentage | 起落架辅助转向角度百分比 | Percent Over 100(百分比(超100)) | 只读 | +| `GEAR STEER ANGLE PCT:index` | Alternative method of getting steer angle as a percentage. Index is; 0 = center; 1 = left; 2 = right; 3 = aux | Alternativemethod的getting转向角作为a百分比.索引为;0=中央;1=左;2=右;3=辅助 | Percent Over 100(百分比(超100)) | 只读 | +| `WATER LEFT RUDDER STEER ANGLE PCT` | Water left rudder angle as a percentage | 水左方向舵转向角度百分比 | Percent Over 100(百分比(超100)) | 只读 | +| `WATER RIGHT RUDDER STEER ANGLE PCT` | Water right rudder as a percentage | 水右方向舵转向角度百分比 | Percent Over 100(百分比(超100)) | 只读 | +| `WHEEL RPM:index` | Wheel rpm. Index is; 0 = center; 1 = left; 2 = right; 3 = aux | 机轮转速.索引为;0=中央;1=左;2=右;3=辅助 | Rpm(转/分) | 只读 | +| `CENTER WHEEL RPM` | Center landing gear rpm | 中央着陆起落架转速 | Rpm(转/分) | 只读 | +| `LEFT WHEEL RPM` | Left landing gear rpm | 左着陆起落架转速 | Rpm(转/分) | 只读 | +| `RIGHT WHEEL RPM` | Right landing gear rpm | 右着陆起落架转速 | Rpm(转/分) | 只读 | +| `AUX WHEEL RPM` | Rpm of fourth set of gear wheels. | 转速的fourth设定的起落架wheels. | Rpm(转/分) | 只读 | +| `WHEEL ROTATION ANGLE:index` | Wheel rotation angle. Index is; 0 = center; 1 = left; 2 = right; 3 = aux | 机轮旋转角.索引为;0=中央;1=左;2=右;3=辅助 | Radians(弧度) | 只读 | +| `CENTER WHEEL ROTATION ANGLE` | Center wheel rotation angle | 中央机轮旋转角度 | Radians(弧度) | 只读 | +| `LEFT WHEEL ROTATION ANGLE` | Left wheel rotation angle | 左机轮旋转角度 | Radians(弧度) | 只读 | +| `RIGHT WHEEL ROTATION ANGLE` | Right wheel rotation angle | 右机轮旋转角度 | Radians(弧度) | 只读 | +| `AUX WHEEL ROTATION ANGLE` | Aux wheel rotation angle | 辅助机轮旋转角度 | Radians(弧度) | 只读 | +| `GEAR EMERGENCY HANDLE POSITION` | True if gear emergency handle applied | 当起落架紧急手柄applied时为真 | Bool(布尔) | 只读 | +| `GEAR WARNING` | One of:; 0: unknown; 1: normal; 2: amphib | 起落架警告 | Enum(枚举) | 只读 | +| `ANTISKID BRAKES ACTIVE` | True if antiskid brakes active | 当antiskidbrakes激活时为真 | Bool(布尔) | 只读 | +| `RETRACT FLOAT SWITCH` | True if retract float switch on | 当收放浮筒开关开时为真 | Bool(布尔) | 只读 | +| `RETRACT LEFT FLOAT EXTENDED` | If aircraft has retractable floats. | 左浮筒收放位置 | Percent(百分比) | 只读 | +| `RETRACT RIGHT FLOAT EXTENDED` | If aircraft has retractable floats. | 右浮筒收放位置 | Percent(百分比) | 只读 | +| `STEER INPUT CONTROL` | Position of steering tiller | 转向INPUT控制 | Percent over 100(百分比(超100)) | 只读 | +| `GEAR DAMAGE BY SPEED` | True if gear has been damaged by excessive speed | 当起落架具有beendamaged由excessive速度时为真 | Bool(布尔) | 只读 | +| `GEAR SPEED EXCEEDED` | True if safe speed limit for gear exceeded | 当safe速度限制用于起落架exceeded时为真 | Bool(布尔) | 只读 | +| `NOSEWHEEL LOCK ON` | True if the nosewheel lock is engaged. | 当thenosewheel锁定为engaged时为真 | Bool(布尔) | 只读 | + +## 十、Aircraft Environment Data(飞机环境) + +共 **19** 项(可写 0 / 只读 19) + +| 变量名 | 英文说明 | 中文说明 | 单位 | 读写 | +|---|---|---|---|---| +| `AMBIENT DENSITY` | Ambient density | 空气密度 | Slugs per cubic feet | 只读 | +| `AMBIENT TEMPERATURE` | Ambient temperature | 环境温度 | Celsius(摄氏度) | 只读 | +| `AMBIENT PRESSURE` | Ambient pressure | 环境压力 | inHg(英寸汞柱) | 只读 | +| `AMBIENT WIND VELOCITY` | Wind velocity | 环境风速度 | Knots(节) | 只读 | +| `AMBIENT WIND DIRECTION` | Wind direction | 环境风方向 | Degrees(度) | 只读 | +| `AMBIENT WIND X` | Wind component in East/West direction. | 环境风横向 | Meters per second | 只读 | +| `AMBIENT WIND Y` | Wind component in vertical direction. | 风component在垂直方向. | Meters per second | 只读 | +| `AMBIENT WIND Z` | Wind component in North/South direction. | 环境风垂向 | Meters per second | 只读 | +| `STRUCT AMBIENT WIND` | X (latitude), Y (vertical) and Z (longitude) components of the wind. | 横向(纬度),纵向(垂直)和垂向(经度)components的the风. | Feet per second(英尺/秒) | 只读 | +| `AIRCRAFT WIND X` | Wind component in aircraft lateral axis | 风component在飞机lateralaxis | Knots(节) | 只读 | +| `AIRCRAFT WIND Y` | Wind component in aircraft vertical axis | 风component在飞机垂直axis | Knots(节) | 只读 | +| `AIRCRAFT WIND Z` | Wind component in aircraft longitudinal axis | 风component在飞机longitudinalaxis | Knots(节) | 只读 | +| `BAROMETER PRESSURE` | Barometric pressure | BAROMETER压力 | Millibars(毫巴) | 只读 | +| `SEA LEVEL PRESSURE` | Barometric pressure at sea level | Barometric压力在海液位 | Millibars(毫巴) | 只读 | +| `TOTAL AIR TEMPERATURE` | Total air temperature is the air temperature at the front of the aircraft where the ram pressure from the speed of the aircraft is taken into account. | 总空气温度为the空气温度在thefront的the飞机wheretheram压力来自the速度的the飞机为takenintoaccount. | Celsius(摄氏度) | 只读 | +| `WINDSHIELD RAIN EFFECT AVAILABLE` | Is visual effect available on this aircraft | 为visualeffect可用开此飞机 | Bool(布尔) | 只读 | +| `AMBIENT IN CLOUD` | True if the aircraft is in a cloud. | 当the飞机为在a云时为真 | Bool(布尔) | 只读 | +| `AMBIENT VISIBILITY` | Ambient visibility | 环境能见度 | Meters(米) | 只读 | +| `STANDARD ATM TEMPERATURE` | Outside temperature on the standard ATM scale | 标准大气温度 | Rankine(兰氏度) | 只读 | + +## 十一、Helicopter Specific Data(直升机) + +共 **18** 项(可写 0 / 只读 18) + +| 变量名 | 英文说明 | 中文说明 | 单位 | 读写 | +|---|---|---|---|---| +| `ROTOR BRAKE HANDLE POS` | Percent actuated | 旋翼制动手柄POS | Percent Over 100(百分比(超100)) | 只读 | +| `ROTOR BRAKE ACTIVE` | Active | 激活 | Bool(布尔) | 只读 | +| `ROTOR CLUTCH SWITCH POS` | Switch position | 开关位置 | Bool(布尔) | 只读 | +| `ROTOR CLUTCH ACTIVE` | Active | 激活 | Bool(布尔) | 只读 | +| `ROTOR TEMPERATURE` | Main rotor transmission temperature | 主旋翼传动温度 | Rankine(兰氏度) | 只读 | +| `ROTOR CHIP DETECTED` | Chip detection | 旋翼CHIP探测 | Bool(布尔) | 只读 | +| `ROTOR GOV SWITCH POS` | Switch position | 开关位置 | Bool(布尔) | 只读 | +| `ROTOR GOV ACTIVE` | Active | 激活 | Bool(布尔) | 只读 | +| `ROTOR LATERAL TRIM PCT` | Trim percent | 旋翼LATERAL配平百分比 | Percent Over 100(百分比(超100)) | 只读 | +| `ROTOR RPM PCT` | Percent max rated rpm | 旋翼转速百分比 | Percent Over 100(百分比(超100)) | 只读 | +| `ENG TURBINE TEMPERATURE` | Turbine temperature. Applies only to Bell helicopter. | 涡轮温度.Applies仅至Bell直升机. | Celsius(摄氏度) | 只读 | +| `ENG TORQUE PERCENT:index` | Torque. Returns main rotor torque for Bell helicopter, or the indexed rotor torque of other helicopters. | 扭矩.返回主旋翼扭矩用于Bell直升机,或theindexed旋翼扭矩的other直升机. | Percent scalar 16K (Ft/lbs * 16384) | 只读 | +| `ENG FUEL PRESSURE` | Fuel pressure. Applies only to Bell helicopter. | 燃油压力.Applies仅至Bell直升机. | PSI(磅/平方英寸) | 只读 | +| `ENG ELECTRICAL LOAD` | Electrical load. Applies only to Bell helicopter. | 电气负载.Applies仅至Bell直升机. | Percent(百分比) | 只读 | +| `ENG TRANSMISSION PRESSURE` | Transmission pressure. Applies only to Bell helicopter. | 传动压力.Applies仅至Bell直升机. | PSI(磅/平方英寸) | 只读 | +| `ENG TRANSMISSION TEMPERATURE` | Transmission temperature. Applies only to Bell helicopter. | 传动温度.Applies仅至Bell直升机. | Celsius(摄氏度) | 只读 | +| `ENG ROTOR RPM:index` | Rotor rpm. Returns main rotor rpm for Bell helicopter, or the indexed rotor rpm of other helicopters. | 旋翼转速.返回主旋翼转速用于Bell直升机,或theindexed旋翼转速的other直升机. | Percent scalar 16K (Max rpm * 16384) | 只读 | +| `COLLECTIVE POSITION` | The position of the helicopter's collective. 0 is fully up, 100 fully depressed. | The位置的the直升机's总距.0为完全收起,100完全depressed. | Percent over 100(百分比(超100)) | 只读 | + +## 十二、Slings and Hoists(吊索/绞车) + +共 **9** 项(可写 2 / 只读 7) + +| 变量名 | 英文说明 | 中文说明 | 单位 | 读写 | +|---|---|---|---|---| +| `NUM SLING CABLES` | The number of sling cables (not hoists) that are configured for the aircraft. Refer to the document Notes on Aircraft Systems. | The数量的吊索cables(nothoists)thatareconfigured用于the飞机.Refer至thedocumentNotes开飞机Systems. | Number(数值) | 只读 | +| `PAYLOAD STATION OBJECT:index` | Places the named object at the payload station identified by the index (starting from 1). The string is the Container name (refer to the title property of Simulation Object Configuration Files). | Placesthenamed对象在thepayload台站identified由the索引(starting来自1).The字符串为theContainer名称(refer至the标题property的模拟对象ConfigurationFiles). | String(字符串) | 只读 | +| `PAYLOAD STATION NUM SIMOBJECTS:index` | The number of objects at the payload station (indexed from 1). | The数量的objects在thepayload台站(indexed来自1). | Number(数值) | 只读 | +| `SLING OBJECT ATTACHED:index` | If units are set as boolean, returns True if a sling object is attached. If units are set as a string, returns the container title of the object. There can be multiple sling positions, indexed from 1. The sling positions are set in the Aircraft Configuration File. | 若单位are设定作为boolean,返回真若a吊索对象为已连接.若单位are设定作为a字符串,返回thecontainer标题的the对象.Therecanbemultiple吊索位置数,indexed来自1.The吊索位置数are设定在the飞机ConfigurationFile. | Bool/String | 只读 | +| `SLING CABLE BROKEN:index` | True if the cable is broken. | 当the索为断裂时为真 | Bool(布尔) | 只读 | +| `SLING CABLE EXTENDED LENGTH:index` | The length of the cable extending from the aircraft. | 吊索伸出长度(索引) | Feet(英尺) | **可写** | +| `SLING ACTIVE PAYLOAD STATION:index` | The payload station (identified by the parameter) where objects will be placed from the sling (identified by the index). | 吊索激活PAYLOAD台站(索引) | Number(数值) | **可写** | +| `SLING HOIST PERCENT DEPLOYED:index` | The percentage of the full length of the sling cable deployed. | The百分比的thefull长度的the吊索索放出. | Percent over 100(百分比(超100)) | 只读 | +| `IS ATTACHED TO SLING` | Set to true if this object is attached to a sling. | 设定至真若此对象为已连接至a吊索. | Bool(布尔) | 只读 | + +## 十三、Aircraft Miscellaneous Systems Data(杂项系统) + +共 **50** 项(可写 24 / 只读 26) + +| 变量名 | 英文说明 | 中文说明 | 单位 | 读写 | +|---|---|---|---|---| +| `SMOKE ENABLE` | Set to True to activate the smoke system, if one is available (for example, on the Extra). | 设定至真至activatethesmoke系统,若一为可用(用于example,开theExtra). | Bool(布尔) | **可写** | +| `SMOKESYSTEM AVAILABLE` | Smoke system available | Smoke系统可用 | Bool(布尔) | 只读 | +| `PITOT HEAT` | Pitot heat active | 皮托heat激活 | Bool(布尔) | 只读 | +| `FOLDING WING LEFT PERCENT` | Left folding wing position, 100 is fully folded | 左folding机翼位置,100为完全folded | Percent Over 100(百分比(超100)) | **可写** | +| `FOLDING WING RIGHT PERCENT` | Right folding wing position, 100 is fully folded | 右folding机翼位置,100为完全folded | Percent Over 100(百分比(超100)) | **可写** | +| `CANOPY OPEN` | Percent primary door/exit open | 百分比主door/出口打开 | Percent Over 100(百分比(超100)) | **可写** | +| `TAILHOOK POSITION` | Percent tail hook extended | 百分比tail钩伸出 | Percent Over 100(百分比(超100)) | **可写** | +| `EXIT OPEN:index` | Percent door/exit open | 百分比door/出口打开 | Percent Over 100(百分比(超100)) | **可写** | +| `STALL HORN AVAILABLE` | True if stall alarm available | 当stallalarm可用时为真 | Bool(布尔) | 只读 | +| `ENGINE MIXURE AVAILABLE` | True if engine mixture is available for prop engines. Obsolete value as mixture is always available. Spelling error in variable name. | 当发动机混合比为可用用于prop发动机.Obsoletevalue作为混合比为always可用.Spelling误差在variable名称时为真 | Bool(布尔) | 只读 | +| `CARB HEAT AVAILABLE` | True if carb heat available | 当carbheat可用时为真 | Bool(布尔) | 只读 | +| `SPOILER AVAILABLE` | True if spoiler system available | 当扰流板系统可用时为真 | Bool(布尔) | 只读 | +| `IS TAIL DRAGGER` | True if the aircraft is a taildragger | 当the飞机为ataildragger时为真 | Bool(布尔) | 只读 | +| `STROBES AVAILABLE` | True if strobe lights are available | 当频闪lightsare可用时为真 | Bool(布尔) | 只读 | +| `TOE BRAKES AVAILABLE` | True if toe brakes are available | 当toebrakesare可用时为真 | Bool(布尔) | 只读 | +| `PUSHBACK STATE` | Type of pushback :; 0 = Straight; 1 = Left; 2 = Right | 类型的pushback:;0=Straight;1=左;2=右 | Enum(枚举) | **可写** | +| `ELECTRICAL MASTER BATTERY` | Battery switch position | Battery开关位置 | Bool(布尔) | **可写** | +| `ELECTRICAL TOTAL LOAD AMPS` | Total load amps | 电气总负载AMPS | Amperes | **可写** | +| `ELECTRICAL BATTERY LOAD` | Battery load | 电气BATTERY负载 | Amperes | **可写** | +| `ELECTRICAL BATTERY VOLTAGE` | Battery voltage | 电气BATTERYVOLTAGE | Volts(伏特) | **可写** | +| `ELECTRICAL MAIN BUS VOLTAGE` | Main bus voltage | 电气主BUSVOLTAGE | Volts(伏特) | **可写** | +| `ELECTRICAL MAIN BUS AMPS` | Main bus current | 电气主BUSAMPS | Amperes | **可写** | +| `ELECTRICAL AVIONICS BUS VOLTAGE` | Avionics bus voltage | 电气航电BUSVOLTAGE | Volts(伏特) | **可写** | +| `ELECTRICAL AVIONICS BUS AMPS` | Avionics bus current | 电气航电BUSAMPS | Amperes | **可写** | +| `ELECTRICAL HOT BATTERY BUS VOLTAGE` | Voltage available when battery switch is turned off | 电压可用当battery开关为turned关 | Volts(伏特) | **可写** | +| `ELECTRICAL HOT BATTERY BUS AMPS` | Current available when battery switch is turned off | 当前可用当battery开关为turned关 | Amperes | **可写** | +| `ELECTRICAL BATTERY BUS VOLTAGE` | Battery bus voltage | 电气BATTERYBUSVOLTAGE | Volts(伏特) | **可写** | +| `ELECTRICAL BATTERY BUS AMPS` | Battery bus current | 电气BATTERYBUSAMPS | Amperes | **可写** | +| `ELECTRICAL GENALT BUS VOLTAGE:index` | Genalt bus voltage (takes engine index) | Genaltbus电压(takes发动机索引) | Volts(伏特) | **可写** | +| `ELECTRICAL GENALT BUS AMPS:index` | Genalt bus current (takes engine index) | Genaltbus当前(takes发动机索引) | Amperes | **可写** | +| `CIRCUIT GENERAL PANEL ON` | Is electrical power available to this circuit | 为电气动力可用至此circuit | Bool(布尔) | 只读 | +| `CIRCUIT FLAP MOTOR ON` | Is electrical power available to this circuit | 为电气动力可用至此circuit | Bool(布尔) | 只读 | +| `CIRCUIT GEAR MOTOR ON` | Is electrical power available to this circuit | 为电气动力可用至此circuit | Bool(布尔) | 只读 | +| `CIRCUIT AUTOPILOT ON` | Is electrical power available to this circuit | 为电气动力可用至此circuit | Bool(布尔) | 只读 | +| `CIRCUIT AVIONICS ON` | Is electrical power available to this circuit | 为电气动力可用至此circuit | Bool(布尔) | 只读 | +| `CIRCUIT PITOT HEAT ON` | Is electrical power available to this circuit | 为电气动力可用至此circuit | Bool(布尔) | 只读 | +| `CIRCUIT PROP SYNC ON` | Is electrical power available to this circuit | 为电气动力可用至此circuit | Bool(布尔) | 只读 | +| `CIRCUIT AUTO FEATHER ON` | Is electrical power available to this circuit | 为电气动力可用至此circuit | Bool(布尔) | 只读 | +| `CIRCUIT AUTO BRAKES ON` | Is electrical power available to this circuit | 为电气动力可用至此circuit | Bool(布尔) | 只读 | +| `CIRCUIT STANDY VACUUM ON` | Is electrical power available to this circuit | 为电气动力可用至此circuit | Bool(布尔) | 只读 | +| `CIRCUIT MARKER BEACON ON` | Is electrical power available to this circuit | 为电气动力可用至此circuit | Bool(布尔) | 只读 | +| `CIRCUIT GEAR WARNING ON` | Is electrical power available to this circuit | 为电气动力可用至此circuit | Bool(布尔) | 只读 | +| `CIRCUIT HYDRAULIC PUMP ON` | Is electrical power available to this circuit | 为电气动力可用至此circuit | Bool(布尔) | 只读 | +| `HYDRAULIC PRESSURE:index` | Hydraulic system pressure. Indexes start at 1. | 液压系统压力.Indexes开始在1. | Pound force per square foot | 只读 | +| `HYDRAULIC RESERVOIR PERCENT:index` | Hydraulic pressure changes will follow changes to this variable. Indexes start at 1. | 液压储液罐百分比(索引) | Percent Over 100(百分比(超100)) | **可写** | +| `HYDRAULIC SYSTEM INTEGRITY` | Percent system functional | 百分比系统functional | Percent Over 100(百分比(超100)) | 只读 | +| `STRUCTURAL DEICE SWITCH` | True if the aircraft structure deice switch is on | 当the飞机structure除冰开关为开时为真 | Bool(布尔) | 只读 | +| `APPLY HEAT TO SYSTEMS` | Used when too close to a fire. | 使用当tooclose至a火. | Bool(布尔) | **可写** | +| `DROPPABLE OBJECTS TYPE:index` | The type of droppable object at the station number identified by the index. | The类型的droppable对象在the台站数量identified由the索引. | String(字符串) | **可写** | +| `DROPPABLE OBJECTS COUNT:index` | The number of droppable objects at the station number identified by the index. | The数量的droppableobjects在the台站数量identified由the索引. | Number(数值) | 只读 | + +## 十四、Miscellaneous Data(杂项) + +共 **159** 项(可写 14 / 只读 145) + +| 变量名 | 英文说明 | 中文说明 | 单位 | 读写 | +|---|---|---|---|---| +| `TOTAL WEIGHT` | Total weight of the aircraft | 总重量的the飞机 | Pounds(磅) | 只读 | +| `MAX GROSS WEIGHT` | Maximum gross weight of the aircaft | 最大gross重量的theaircaft | Pounds(磅) | 只读 | +| `EMPTY WEIGHT` | Empty weight of the aircraft | Empty重量的the飞机 | Pounds(磅) | 只读 | +| `IS USER SIM` | Is this the user loaded aircraft | 为此the用户loaded飞机 | Bool(布尔) | 只读 | +| `SIM DISABLED` | Is sim disabled | 模拟器是否禁用 | Bool(布尔) | **可写** | +| `G FORCE` | Current g force | 当前gforce | GForce | **可写** | +| `ATC HEAVY` | Is this aircraft recognized by ATC as heavy | 为此飞机recognized由ATC作为heavy | Bool(布尔) | **可写** | +| `AUTO COORDINATION` | Is auto-coordination active | 为自动-coordination激活 | Bool(布尔) | **可写** | +| `REALISM` | General realism percent | 通用realism百分比 | Number(数值) | **可写** | +| `TRUE AIRSPEED SELECTED` | True if True Airspeed has been selected | 当真空速具有beenselected时为真 | Bool(布尔) | **可写** | +| `DESIGN SPEED VS0` | Design speed at VS0 | 设计速度在VS0 | Feet per second(英尺/秒) | 只读 | +| `DESIGN SPEED VS1` | Design speed at VS1 | 设计速度在VS1 | Feet per second(英尺/秒) | 只读 | +| `DESIGN SPEED VC` | Design speed at VC | 设计速度在VC | Feet per second(英尺/秒) | 只读 | +| `MIN DRAG VELOCITY` | Minimum drag velocity | 最小drag速度 | Feet per second(英尺/秒) | 只读 | +| `ESTIMATED CRUISE SPEED` | Estimated cruise speed | ESTIMATEDCRUISE速度 | Feet per second(英尺/秒) | 只读 | +| `CG PERCENT` | Longitudinal CG position as a percent of reference chord | Longitudinal重心位置作为a百分比的referencechord | Percent over 100(百分比(超100)) | 只读 | +| `CG PERCENT LATERAL` | Lateral CG position as a percent of reference chord | Lateral重心位置作为a百分比的referencechord | Percent over 100(百分比(超100)) | 只读 | +| `IS SLEW ACTIVE` | True if slew is active | 当slew为激活时为真 | Bool(布尔) | **可写** | +| `IS SLEW ALLOWED` | True if slew is enabled | 当slew为enabled时为真 | Bool(布尔) | **可写** | +| `ATC SUGGESTED MIN RWY TAKEOFF` | Suggested minimum runway length for takeoff. Used by ATC | Suggested最小runway长度用于takeoff.使用由ATC | Feet(英尺) | 只读 | +| `ATC SUGGESTED MIN RWY LANDING` | Suggested minimum runway length for landing. Used by ATC | Suggested最小runway长度用于着陆.使用由ATC | Feet(英尺) | 只读 | +| `PAYLOAD STATION WEIGHT:index` | Individual payload station weight | PAYLOAD台站重量(索引) | Pounds(磅) | **可写** | +| `PAYLOAD STATION COUNT` | Number of payload stations | 数量的payloadstations | Number(数值) | 只读 | +| `USER INPUT ENABLED` | Is input allowed from the user | 为inputallowed来自the用户 | Bool(布尔) | **可写** | +| `TYPICAL DESCENT RATE` | Normal descent rate | TYPICALDESCENT速率 | Feet per minute | 只读 | +| `VISUAL MODEL RADIUS` | Model radius | VISUALMODEL半径 | Meters(米) | 只读 | +| `SIGMA SQRT` | Sigma sqrt | 西格玛平方根 | Number(数值) | 只读 | +| `DYNAMIC PRESSURE` | Dynamic pressure | DYNAMIC压力 | foot pounds | 只读 | +| `TOTAL VELOCITY` | Velocity regardless of direction. For example, if a helicopter is ascending vertically at 100 fps, getting this variable will return 100. | 速度regardless的方向.用于example,若a直升机为ascendingvertically在100fps,getting此variablewill返回100. | Feet per second(英尺/秒) | 只读 | +| `AIRSPEED SELECT INDICATED OR TRUE` | The airspeed, whether true or indicated airspeed has been selected. | The空速,whether真或指示空速具有beenselected. | Knots(节) | 只读 | +| `VARIOMETER RATE` | Variometer rate | VARIOMETER速率 | Feet per second(英尺/秒) | 只读 | +| `VARIOMETER SWITCH` | True if the variometer switch is on | 当thevariometer开关为开时为真 | Bool(布尔) | 只读 | +| `PRESSURE ALTITUDE` | Altitude reading | 压力高度 | Meters(米) | 只读 | +| `MAGNETIC COMPASS` | Compass reading | 磁罗盘 | Degrees(度) | 只读 | +| `TURN INDICATOR RATE` | Turn indicator reading | TURN指示器速率 | Radians per second(弧度/秒) | 只读 | +| `TURN INDICATOR SWITCH` | True if turn indicator switch is on | 当转弯指示器开关为开时为真 | Bool(布尔) | 只读 | +| `YOKE Y INDICATOR` | Yoke position in vertical direction | 操纵杆位置在垂直方向 | Position(位置) | 只读 | +| `YOKE X INDICATOR` | Yoke position in horizontal direction | 操纵杆横向指示器 | Position(位置) | 只读 | +| `RUDDER PEDAL INDICATOR` | Rudder pedal position | 方向舵踏板指示器 | Position(位置) | 只读 | +| `BRAKE DEPENDENT HYDRAULIC PRESSURE` | Brake dependent hydraulic pressure reading | 制动DEPENDENT液压压力 | foot pounds | 只读 | +| `PANEL ANTI ICE SWITCH` | True if panel anti-ice switch is on | 当面板防-冰开关为开时为真 | Bool(布尔) | 只读 | +| `WING AREA` | Total wing area | 总机翼area | Square feet | 只读 | +| `WING SPAN` | Total wing span | 总机翼span | Feet(英尺) | 只读 | +| `BETA DOT` | Beta dot | 桨距角DOT | Radians per second(弧度/秒) | 只读 | +| `LINEAR CL ALPHA` | Linear CL alpha | 线性升力系数迎角 | Per radian(每弧度) | 只读 | +| `STALL ALPHA` | Stall alpha | STALL迎角 | Radians(弧度) | 只读 | +| `ZERO LIFT ALPHA` | Zero lift alpha | 零升力迎角 | Radians(弧度) | 只读 | +| `CG AFT LIMIT` | Aft limit of CG | Aft限制的重心 | Percent over 100(百分比(超100)) | 只读 | +| `CG FWD LIMIT` | Forward limit of CG | Forward限制的重心 | Percent over 100(百分比(超100)) | 只读 | +| `CG MAX MACH` | Max mach CG | 重心最大MACH | Machs | 只读 | +| `CG MIN MACH` | Min mach CG | 重心MINMACH | Machs | 只读 | +| `PAYLOAD STATION NAME` | Descriptive name for payload station | PAYLOAD台站名称 | String(字符串) | 只读 | +| `ELEVON DEFLECTION` | Elevon deflection | ELEVON偏转 | Radians(弧度) | 只读 | +| `EXIT TYPE` | One of:; 0: Main; 1: Cargo; 2: Emergency; 3: Unknown | 一的:;0:主;1:Cargo;2:紧急;3:Unknown | Enum(枚举) | 只读 | +| `EXIT POSX` | Position of exit relative to datum reference point | 位置的出口相对至datumreference点 | Feet(英尺) | 只读 | +| `EXIT POSY` | Position of exit relative to datum reference point | 位置的出口相对至datumreference点 | Feet(英尺) | 只读 | +| `EXIT POSZ` | Position of exit relative to datum reference point | 位置的出口相对至datumreference点 | Feet(英尺) | 只读 | +| `DECISION HEIGHT` | Design decision height | 决断高度 | Feet(英尺) | 只读 | +| `DECISION ALTITUDE MSL` | Design decision altitude above mean sea level | 设计决断高度高于mean海液位 | Feet(英尺) | 只读 | +| `EMPTY WEIGHT PITCH MOI` | Empty weight pitch moment of inertia | Empty重量俯仰moment的inertia | slug feet squared | 只读 | +| `EMPTY WEIGHT ROLL MOI` | Empty weight roll moment of inertia | Empty重量rollmoment的inertia | slug feet squared | 只读 | +| `EMPTY WEIGHT YAW MOI` | Empty weight yaw moment of inertia | Empty重量yawmoment的inertia | slug feet squared | 只读 | +| `EMPTY WEIGHT CROSS COUPLED MOI` | Empty weigth cross coupled moment of inertia | EMPTY重量串COUPLEDMOI | slug feet squared | 只读 | +| `TOTAL WEIGHT PITCH MOI` | Total weight pitch moment of inertia | 总重量俯仰moment的inertia | slug feet squared | 只读 | +| `TOTAL WEIGHT ROLL MOI` | Total weight roll moment of inertia | 总重量rollmoment的inertia | slug feet squared | 只读 | +| `TOTAL WEIGHT YAW MOI` | Total weight yaw moment of inertia | 总重量yawmoment的inertia | slug feet squared | 只读 | +| `TOTAL WEIGHT CROSS COUPLED MOI` | Total weight cross coupled moment of inertia | 总重量串coupledmoment的inertia | slug feet squared | 只读 | +| `WATER BALLAST VALVE` | True if water ballast valve is available | 当水ballast阀门为可用时为真 | Bool(布尔) | 只读 | +| `MAX RATED ENGINE RPM` | Maximum rated rpm | 最大RATED发动机转速 | Rpm(转/分) | 只读 | +| `FULL THROTTLE THRUST TO WEIGHT RATIO` | Full throttle thrust to weight ratio | FULL油门推力至重量比 | Number(数值) | 只读 | +| `PROP AUTO CRUISE ACTIVE` | True if prop auto cruise active | 当prop自动cruise激活时为真 | Bool(布尔) | 只读 | +| `PROP ROTATION ANGLE` | Prop rotation angle | PROP旋转角度 | Radians(弧度) | 只读 | +| `PROP BETA MAX` | Prop beta max | PROP桨距角最大 | Radians(弧度) | 只读 | +| `PROP BETA MIN` | Prop beta min | PROP桨距角MIN | Radians(弧度) | 只读 | +| `PROP BETA MIN REVERSE` | Prop beta min reverse | PROP桨距角MIN反推 | Radians(弧度) | 只读 | +| `FUEL SELECTED TRANSFER MODE` | One of:; -1: off; 0: auto; 1: forward; 2: aft; 3: manual | 燃油SELECTED传输模式 | Enum(枚举) | 只读 | +| `DROPPABLE OBJECTS UI NAME` | Descriptive name, used in User Interface dialogs, of a droppable object | DROPPABLEOBJECTSUI名称 | String(字符串) | 只读 | +| `MANUAL FUEL PUMP HANDLE` | Position of manual fuel pump handle. 100 is fully deployed. | 位置的manual燃油泵手柄.100为完全放出. | Percent over 100(百分比(超100)) | 只读 | +| `BLEED AIR SOURCE CONTROL` | One of:; 0: min; 1: auto; 2: off; 3: apu; 4: engines | 一的:;0:min;1:自动;2:关;3:APU;4:发动机 | Enum(枚举) | 只读 | +| `ELECTRICAL OLD CHARGING AMPS` | Legacy, use ELECTRICAL BATTERY LOAD | Legacy,use电气BATTERY负载 | Amps | 只读 | +| `HYDRAULIC SWITCH` | True if hydraulic switch is on | 当液压开关为开时为真 | Bool(布尔) | 只读 | +| `CONCORDE VISOR POSITION PERCENT` | 0 = up, 1.0 = extended/down | 0=收起,1.0=伸出/down | Percent over 100(百分比(超100)) | 只读 | +| `CONCORDE NOSE ANGLE` | 0 = up | 0=收起 | Radians(弧度) | 只读 | +| `REALISM CRASH WITH OTHERS` | True indicates crashing with other aircraft is possible. | 真indicatescrashing带other飞机为possible. | Bool(布尔) | 只读 | +| `REALISM CRASH DETECTION` | True indicates crash detection is turned on. | 真indicates坠毁detection为turned开. | Bool(布尔) | 只读 | +| `MANUAL INSTRUMENT LIGHTS` | True if instrument lights are set manually | 当instrumentlightsare设定manually时为真 | Bool(布尔) | 只读 | +| `PITOT ICE PCT` | Amount of pitot ice. 100 is fully iced. | 皮托冰百分比 | Percent over 100(百分比(超100)) | 只读 | +| `SEMIBODY LOADFACTOR Y` | Semibody loadfactor x and z are not supported. | 半机体载荷因子横向和垂向arenotsupported. | Number(数值) | 只读 | +| `SEMIBODY LOADFACTOR YDOT` | Semibody loadfactory ydot | 半机体法向载荷因子变化率 | Per second(每秒) | 只读 | +| `RAD INS SWITCH` | True if Rad INS switch on | 当RadINS开关开时为真 | Bool(布尔) | 只读 | +| `SIMULATED RADIUS` | Simulated radius | 模拟半径 | Feet(英尺) | 只读 | +| `STRUCTURAL ICE PCT` | Amount of ice on aircraft structure. 100 is fully iced. | Amount的冰开飞机structure.100为完全iced. | Percent over 100(百分比(超100)) | 只读 | +| `ARTIFICIAL GROUND ELEVATION` | In case scenery is not loaded for AI planes, this variable can be used to set a default surface elevation. | 在casescenery为notloaded用于AIplanes,此variablecanbe使用至设定adefaultsurfaceelevation. | Feet(英尺) | 只读 | +| `SURFACE INFO VALID` | True indicates SURFACE CONDITION is meaningful. | SURFACEINFO有效 | Bool(布尔) | 只读 | +| `SURFACE CONDITION` | One of:; 0: Normal; 1: Wet; 2: Icy; 3: Snow | 一的:;0:Normal;1:Wet;2:Icy;3:Snow | Enum(枚举) | 只读 | +| `PUSHBACK ANGLE` | Pushback angle (the heading of the tug) | Pushback角(the航向的thetug) | Radians(弧度) | 只读 | +| `PUSHBACK CONTACTX` | The towpoint position, relative to the aircrafts datum reference point. | Thetowpoint位置,相对至theaircraftsdatumreference点. | Feet(英尺) | 只读 | +| `PUSHBACK CONTACTY` | Pushback contact position in vertical direction | Pushbackcontact位置在垂直方向 | Feet(英尺) | 只读 | +| `PUSHBACK CONTACTZ` | Pushback contact position in fore/aft direction | Pushbackcontact位置在fore/aft方向 | Feet(英尺) | 只读 | +| `PUSHBACK WAIT` | True if waiting for pushback. | 当waiting用于pushback时为真 | Bool(布尔) | 只读 | +| `YAW STRING ANGLE` | The yaw string angle. Yaw strings are attached to gliders as visible indicators of the yaw angle. An animation of this is not implemented in ESP. | Theyaw字符串角.Yawstringsare已连接至gliders作为visibleindicators的theyaw角.An动画的此为notimplemented在ESP. | Radians(弧度) | 只读 | +| `YAW STRING PCT EXTENDED` | Yaw string angle as a percentage | Yaw字符串角作为a百分比 | Percent over 100(百分比(超100)) | 只读 | +| `INDUCTOR COMPASS PERCENT DEVIATION` | Inductor compass deviation reading | INDUCTOR罗盘百分比偏差 | Percent over 100(百分比(超100)) | 只读 | +| `INDUCTOR COMPASS HEADING REF` | Inductor compass heading | Inductor罗盘航向 | Radians(弧度) | 只读 | +| `ANEMOMETER PCT RPM` | Anemometer rpm as a percentage | Anemometer转速作为a百分比 | Percent over 100(百分比(超100)) | 只读 | +| `ROTOR ROTATION ANGLE` | Main rotor rotation angle (helicopters only) | 主旋翼旋转角(仅直升机) | Radians(弧度) | 只读 | +| `DISK PITCH ANGLE` | Main rotor pitch angle (helicopters only) | 旋翼盘俯仰角 | Radians(弧度) | 只读 | +| `DISK BANK ANGLE` | Main rotor bank angle (helicopters only) | 旋翼盘坡度角 | Radians(弧度) | 只读 | +| `DISK PITCH PCT` | Main rotor pitch percent (helicopters only) | 旋翼盘俯仰百分比 | Percent over 100(百分比(超100)) | 只读 | +| `DISK BANK PCT` | Main rotor bank percent (helicopters only) | 旋翼盘坡度百分比 | Percent over 100(百分比(超100)) | 只读 | +| `DISK CONING PCT` | Main rotor coning percent (helicopters only) | 旋翼盘锥度百分比 | Percent over 100(百分比(超100)) | 只读 | +| `STATIC CG TO GROUND` | Static CG to ground | 静重心离地高度 | Feet(英尺) | 只读 | +| `STATIC PITCH` | Static pitch | 静俯仰角 | Radians(弧度) | 只读 | +| `CRASH SEQUENCE` | One of:; 0: off; 1: complete; 3: reset; 4: pause; 11: start | 枚举:0=关闭;1=完成;3=重置;4=暂停;11=开始 | Enum(枚举) | 只读 | +| `CRASH FLAG` | One of:; 0: None; 2: Mountain; 4: General; 6: Building; 8: Splash; 10: Gear up; 12: Overstress; 14: Building; 16: Aircraft; 18: Fuel Truck | 枚举:0=无;2=撞山;4=一般;6=建筑物;8=落水;10=起落架收起;12=过载;14=建筑物;16=飞机;18=加油车 | Enum(枚举) | 只读 | +| `TOW RELEASE HANDLE` | Position of tow release handle. 100 is fully deployed. | 拖曳释放手柄位置,100为完全放出 | Percent over 100(百分比(超100)) | 只读 | +| `TOW CONNECTION` | True if a towline is connected to both tow plane and glider. | 拖缆同时连接拖机与滑翔机时为真 | Bool(布尔) | 只读 | +| `APU PCT RPM` | Auxiliary power unit rpm, as a percentage | APU 转速百分比 | Percent over 100(百分比(超100)) | 只读 | +| `APU PCT STARTER` | Auxiliary power unit starter, as a percentage | APU 起动机百分比 | Percent over 100(百分比(超100)) | 只读 | +| `APU VOLTS` | Auxiliary power unit voltage | APU 电压 | Volts(伏特) | 只读 | +| `APU GENERATOR SWITCH` | True if APU generator switch on | APU 发电机开关 | Bool(布尔) | 只读 | +| `APU GENERATOR ACTIVE` | True if APU generator active | APU 发电机激活 | Bool(布尔) | 只读 | +| `APU ON FIRE DETECTED` | True if APU on fire | APU 着火探测 | Bool(布尔) | 只读 | +| `PRESSURIZATION CABIN ALTITUDE` | The current altitude of the cabin pressurization.. | 客舱增压高度 | Feet(英尺) | 只读 | +| `PRESSURIZATION CABIN ALTITUDE GOAL` | The set altitude of the cabin pressurization. | 客舱增压目标高度 | Feet(英尺) | 只读 | +| `PRESSURIZATION CABIN ALTITUDE RATE` | The rate at which cabin pressurization changes. | 客舱增压高度变化率 | Feet per second(英尺/秒) | 只读 | +| `PRESSURIZATION PRESSURE DIFFERENTIAL` | The difference in pressure between the set altitude pressurization and the current pressurization. | 客舱增压压差 | foot pounds | 只读 | +| `PRESSURIZATION DUMP SWITCH` | True if the cabin pressurization dump switch is on. | 客舱增压泄压开关 | Bool(布尔) | 只读 | +| `FIRE BOTTLE SWITCH` | True if the fire bottle switch is on. | 灭火瓶开关 | Bool(布尔) | 只读 | +| `FIRE BOTTLE DISCHARGED` | True if the fire bottle is discharged. | 灭火瓶已释放 | Bool(布尔) | 只读 | +| `CABIN NO SMOKING ALERT SWITCH` | True if the No Smoking switch is on. | 客舱禁烟告警开关 | Bool(布尔) | **可写** | +| `CABIN SEATBELTS ALERT SWITCH` | True if the Seatbelts switch is on. | 客舱系安全带告警开关 | Bool(布尔) | **可写** | +| `GPWS WARNING` | True if Ground Proximity Warning System installed. | 近地警告系统已安装 | Bool(布尔) | 只读 | +| `GPWS SYSTEM ACTIVE` | True if the Ground Proximity Warning System is active | 近地警告系统激活 | Bool(布尔) | **可写** | +| `IS ALTITUDE FREEZE ON` | True if the altitude of the aircraft is frozen. | 高度冻结开启 | Bool(布尔) | 只读 | +| `IS ATTITUDE FREEZE ON` | True if the attitude (pitch, bank and heading) of the aircraft is frozen. | 姿态冻结开启 | Bool(布尔) | 只读 | +| `SLING HOOK IN PICKUP MODE:index` | | 吊索钩处于拾取模式(索引) | Bool(布尔) | 只读 | +| `AI TRAFFIC STATE` | | AI 交通状态 | String(字符串) | 只读 | +| `AI TRAFFIC ASSIGNED PARKING` | | AI 分配停机位 | String(字符串) | 只读 | +| `RECIP ENG FUEL TANKS USED:index` | | 往复式发动机使用油箱(索引) | Mask(掩码) | **可写** | +| `TURB ENG TANKS USED:index` | | 涡轮发动机使用油箱(索引) | Mask(掩码) | 只读 | +| `ENG TURBINE TEMPERATURE:index` | | 发动机涡轮温度(索引) | Celsius(摄氏度) | 只读 | +| `ENG ELECTRICAL LOAD:index` | | 发动机电气负载(索引) | Percent(百分比) | 只读 | +| `ENG TRANSMISSION PRESSURE:index` | | 发动机传动箱压力(索引) | PSI(磅/平方英寸) | 只读 | +| `ENG TRANSMISSION TEMPERATURE:index` | | 发动机传动箱温度(索引) | Celsius(摄氏度) | 只读 | +| `SURFACE TYPE` | | SURFACE类型 | Enum(枚举) | 只读 | +| `COM STATUS:index` | | 通讯STATUS(索引) | Enum(枚举) | 只读 | +| `NAV TOFROM:index` | | 导航TOFROM(索引) | Enum(枚举) | 只读 | +| `GPS APPROACH MODE` | | GPS进近模式 | Enum(枚举) | 只读 | +| `GPS APPROACH WP TYPE` | | GPS进近航路点类型 | Enum(枚举) | 只读 | +| `GPS APPROACH SEGMENT TYPE` | | GPS进近SEGMENT类型 | Enum(枚举) | 只读 | +| `GPS APPROACH APPROACH TYPE` | | GPS进近进近类型 | Enum(枚举) | 只读 | +| `AMBIENT PRECIP STATE` | | 降水状态 | Mask(掩码) | 只读 | +| `CATEGORY` | | 飞机类别 | String(字符串) | 只读 | +| `CONCORDE VISOR NOSE HANDLE` | | CONCORDEVISORNOSE手柄 | Enum(枚举) | 只读 | +| `IS LATITUDE LONGITUDE FREEZE ON` | | IS纬度经度冻结开 | Bool(布尔) | 只读 | +| `TIME OF DAY` | | 时段 | Enum(枚举) | 只读 | +| `SIMULATION RATE` | | 模拟速率 | Number(数值) | 只读 | +| `UNITS OF MEASURE` | | 单位制 | Enum(枚举) | 只读 | + +## 十五、Aircraft String Data(字符串) + +共 **12** 项(可写 3 / 只读 9) + +| 变量名 | 英文说明 | 中文说明 | 单位 | 读写 | +|---|---|---|---|---| +| `ATC TYPE` | Type used by ATC | 类型使用由ATC | String(字符串) | 只读 | +| `ATC MODEL` | Model used by ATC | Model使用由ATC | String(字符串) | 只读 | +| `ATC ID` | ID used by ATC | ID使用由ATC | String(字符串) | **可写** | +| `ATC AIRLINE` | Airline used by ATC | 航空公司使用由ATC | String(字符串) | **可写** | +| `ATC FLIGHT NUMBER` | Flight Number used by ATC | 飞行数量使用由ATC | String(字符串) | **可写** | +| `TITLE` | Title from aircraft.cfg | 标题来自飞机.cfg | String(字符串) | 只读 | +| `HSI STATION IDENT` | Tuned station identifier | HSI台站识别 | String(字符串) | 只读 | +| `GPS WP PREV ID` | ID of previous GPS waypoint | GPS航路点上一ID | String(字符串) | 只读 | +| `GPS WP NEXT ID` | ID of next GPS waypoint | GPS航路点下一ID | String(字符串) | 只读 | +| `GPS APPROACH AIRPORT ID` | ID of airport | GPS进近AIRPORTID | String(字符串) | 只读 | +| `GPS APPROACH APPROACH ID` | ID of approach | GPS进近进近ID | String(字符串) | 只读 | +| `GPS APPROACH TRANSITION ID` | ID of approach transition | ID的进近transition | String(字符串) | 只读 | + +## 十六、AI Controlled Aircraft(AI 飞机) + +共 **13** 项(可写 6 / 只读 7) + +| 变量名 | 英文说明 | 中文说明 | 单位 | 读写 | +|---|---|---|---|---| +| `AI DESIRED SPEED` | Desired speed of the AI object. | 目标速度的theAI对象. | Knots(节) | **可写** | +| `AI CURRENT WAYPOINT` | Current waypoint in the list | 当前waypoint在thelist | Number(数值) | **可写** | +| `AI DESIRED HEADING` | Desired heading of the AI object. | 目标航向的theAI对象. | Degrees(度) | **可写** | +| `AI GROUNDTURNTIME` | Time to make a 90 degree turn. | 90度转弯所需时间 | Seconds(秒) | **可写** | +| `AI GROUNDCRUISESPEED` | Cruising speed. | AI 地面巡航速度 | Knots(节) | **可写** | +| `AI GROUNDTURNSPEED` | Turning speed. | AI 地面转弯速度 | Knots(节) | **可写** | +| `AI TRAFFIC ISIFR` | Request whether this aircraft is IFR or VFR See Note 1. | 请求whether此飞机为IFR或VFRSee注意1. | Boolean | 只读 | +| `AI TRAFFIC CURRENT AIRPORT` | ICAO code of current airport. See Note 1. | AI交通当前AIRPORT | String(字符串) | 只读 | +| `AI TRAFFIC ASSIGNED RUNWAY` | Assigned runway name (for example: "32R"). See Note 1. | 分配runway名称(用于example:"32R").See注意1. | String(字符串) | 只读 | +| `AI TRAFFIC FROMAIRPORT` | ICAO code of the departure airport in the current schedule. See Note 2. | ICAOcode的thedepartureairport在the当前schedule.See注意2. | String(字符串) | 只读 | +| `AI TRAFFIC TOAIRPORT` | ICAO code of the destination airport in the current schedule. See Note 2. | ICAOcode的thedestinationairport在the当前schedule.See注意2. | String(字符串) | 只读 | +| `AI TRAFFIC ETD` | Estimated time of departure for the current schedule entry, given as the number of seconds difference from the current simulation time. This can be negative if ETD is earlier than the current simulation time. See Note 2. | Estimated时间的departure用于the当前scheduleentry,given作为the数量的秒差值来自the当前模拟时间.此canbenegative若ETD为earlier比the当前模拟时间.See注意2. | Seconds(秒) | 只读 | +| `AI TRAFFIC ETA` | Estimated time of arrival for the current schedule entry, given as the number of seconds difference from the current simulated time. This can be negative if ETA is earlier than the current simulated time. See Note 2. | Estimated时间的arrival用于the当前scheduleentry,given作为the数量的秒差值来自the当前半径时间.此canbenegative若ETA为earlier比the当前半径时间.See注意2. | Seconds(秒) | 只读 | + +## 十七、Carrier Operations(航母作业) + +共 **10** 项(可写 0 / 只读 10) + +| 变量名 | 英文说明 | 中文说明 | 单位 | 读写 | +|---|---|---|---|---| +| `LAUNCHBAR POSITION` | Installed on aircraft before takeoff from a carrier catapult. Note that gear cannot retract with this extended. 100 = fully extended. Refer to the document Notes on Aircraft Systems. | 已安装开飞机beforetakeoff来自a航母弹射.注意that起落架cannot收放带此伸出.100=完全伸出.Refer至thedocumentNotes开飞机Systems. | Percent over 100(百分比(超100)) | 只读 | +| `LAUNCHBAR SWITCH` | If this is set to True the launch bar switch has been engaged. | 若此为设定至真thelaunchbar开关具有beenengaged. | Bool(布尔) | 只读 | +| `LAUNCHBAR HELD EXTENDED` | This will be True if the launchbar is fully extended, and can be used, for example, to change the color of an instrument light. | 此willbe真若thelaunchbar为完全伸出,和canbe使用,用于example,至changethecolor的aninstrument灯光. | Bool(布尔) | 只读 | +| `NUMBER OF CATAPULTS` | Maximum of 4. A model can contain more than 4 catapults, but only the first four will be read and recognized by the simulation. | 最大的4.Amodelcancontainmore比4catapults,but仅thefirstfourwillberead和recognized由the模拟. | Number(数值) | 只读 | +| `CATAPULT STROKE POSITION:index` | Catapults are indexed from 1. This value will be 0 before the catapult fires, and then up to 100 as the aircraft is propelled down the catapult. The aircraft may takeoff before the value reaches 100 (depending on the aircraft weight, power applied, and other factors), in which case this value will not be further updated. This value could be used to drive a bogie animation. | Catapultsareindexed来自1.此valuewillbe0beforethe弹射fires,和then收起至100作为the飞机为propelleddownthe弹射.The飞机maytakeoffbeforethevaluereaches100(depending开the飞机重量,动力applied,和otherfactors),在whichcase此valuewillnotbefurtherupdated.此valuecouldbe使用至driveabogie动画. | Number(数值) | 只读 | +| `HOLDBACK BAR INSTALLED` | Holdback bars allow build up of thrust before takeoff from a catapult, and are installed by the deck crew of an aircraft carrier. | Holdbackbarsallowbuild收起的推力beforetakeoff来自a弹射,和are已安装由thedeckcrew的an飞机航母. | Bool(布尔) | 只读 | +| `BLAST SHIELD POSITION:index` | Indexed from 1, 100 is fully deployed, 0 flat on deck | Indexed来自1,100为完全放出,0flat开deck | Percent over 100(百分比(超100)) | 只读 | +| `CABLE CAUGHT BY TAILHOOK` | A number 1 through 4 for the cable number caught by the tailhook. Cable 1 is the one closest to the stern of the carrier. A value of 0 indicates no cable was caught. | A数量1through4用于the索数量caught由the尾钩.索1为the一closest至thestern的the航母.Avalue的0indicates禁索wascaught. | Number(数值) | 只读 | +| `TAILHOOK HANDLE` | True if the tailhook handle is engaged. | 当the尾钩手柄为engaged时为真 | Bool(布尔) | 只读 | +| `SURFACE RELATIVE GROUND SPEED` | The speed of the aircraft relative to the speed of the first surface directly underneath it. Use this to retrieve, for example, an aircraft's taxiing speed while it is moving on a moving carrier. It also applies to airborne aircraft, for example when a helicopter is successfully hovering above a moving ship, this value should be zero. The returned value will be the same as GROUND VELOCITY if the first surface beneath it is not moving. | The速度的the飞机相对至the速度的thefirstsurfacedirectlyunderneathit.Use此至retrieve,用于example,an飞机'staxiing速度whileit为moving开amoving航母.Italsoapplies至airborne飞机,用于example当a直升机为successfullyhovering高于amovingship,此valueshouldbe零.Thereturnedvaluewillbethesame作为地面速度若thefirstsurfacebeneathit为notmoving. | Feet per second(英尺/秒) | 只读 | + +## 十八、Racing(竞速) + +共 **10** 项(可写 4 / 只读 6) + +| 变量名 | 英文说明 | 中文说明 | 单位 | 读写 | +|---|---|---|---|---| +| `RECIP ENG DETONATING:index` | Indexed from 1. Set to True if the engine is detonating. | Indexed来自1.设定至真若the发动机为detonating. | Bool(布尔) | 只读 | +| `RECIP ENG CYLINDER HEALTH:index` | Index high 16 bits is engine number, low 16 cylinder number, both indexed from 1. | 索引high16bits为发动机数量,low16气缸数量,双方indexed来自1. | Percent over 100(百分比(超100)) | 只读 | +| `RECIP ENG NUM CYLINDERS` | Indexed from 1. The number of engine cylinders. | 往复式发动机数量CYLINDERS | Number(数值) | 只读 | +| `RECIP ENG NUM CYLINDERS FAILED` | Indexed from 1. The number of cylinders that have failed. | 往复式发动机数量CYLINDERS故障 | Number(数值) | 只读 | +| `RECIP ENG ANTIDETONATION TANK VALVE:index` | Indexed from 1, each engine can have one antidetonation tank. Installed on racing aircraft. Refer to the document Notes on Aircraft Systems. | Indexed来自1,each发动机can具有一antidetonation油箱.已安装开racing飞机.Refer至thedocumentNotes开飞机Systems. | Bool(布尔) | **可写** | +| `RECIP ENG ANTIDETONATION TANK QUANTITY:index` | Indexed from 1. Refer to the Mission Creation documentationfor the procedure for refilling tanks. | 往复式发动机ANTIDETONATION油箱数量(索引) | Gallons(加仑) | **可写** | +| `RECIP ENG ANTIDETONATION TANK MAX QUANTITY:index` | Indexed from 1. This value set in the Aircraft Configuration File. | 往复式发动机ANTIDETONATION油箱最大数量(索引) | Gallons(加仑) | 只读 | +| `RECIP ENG NITROUS TANK VALVE:index` | Indexed from 1. Each engine can have one Nitrous fuel tank installed. | Indexed来自1.Each发动机can具有一Nitrous燃油油箱已安装. | Bool(布尔) | **可写** | +| `RECIP ENG NITROUS TANK QUANTITY:index` | Indexed from 1. Refer to the Mission Creation documentationfor the procedure for refilling tanks. | 往复式发动机NITROUS油箱数量(索引) | Gallons(加仑) | **可写** | +| `RECIP ENG NITROUS TANK MAX QUANTITY:index` | Indexed from 1. This value set in the Aircraft Configuration File. | 往复式发动机NITROUS油箱最大数量(索引) | Gallons(加仑) | 只读 | + +## 十九、Environment Data(全局环境) + +共 **14** 项(可写 0 / 只读 14) + +| 变量名 | 英文说明 | 中文说明 | 单位 | 读写 | +|---|---|---|---|---| +| `ABSOLUTE TIME` | Time, as referenced from 12:00 AM January 1, 0000 | 时间,作为referenced来自12:00AMJanuary1,0000 | Seconds(秒) | 只读 | +| `ZULU TIME` | Greenwich Mean Time (GMT) | GreenwichMean时间(世界时) | Seconds(秒) | 只读 | +| `ZULU DAY OF WEEK` | GMT day of week | 世界时日的周 | Number(数值) | 只读 | +| `ZULU DAY OF MONTH` | GMT day of month | 世界时日的月 | Number(数值) | 只读 | +| `ZULU MONTH OF YEAR` | GMT month of year | 世界时月的年 | Number(数值) | 只读 | +| `ZULU DAY OF YEAR` | GMT day of year | 世界时年内天数 | Number(数值) | 只读 | +| `ZULU YEAR` | GMT year | 世界时年份 | Number(数值) | 只读 | +| `LOCAL TIME` | Local time | 本地时间 | Seconds(秒) | 只读 | +| `LOCAL DAY OF WEEK` | Local day of week | 本地日的周 | Number(数值) | 只读 | +| `LOCAL DAY OF MONTH` | Local day of month | 本地日的月 | Number(数值) | 只读 | +| `LOCAL MONTH OF YEAR` | Local month of year | 本地月的年 | Number(数值) | 只读 | +| `LOCAL DAY OF YEAR` | Local day of year | 本地日的年 | Number(数值) | 只读 | +| `LOCAL YEAR` | Local year | 本地年 | Number(数值) | 只读 | +| `TIME ZONE OFFSET` | Local time difference from GMT | 本地时间差值来自世界时 | Seconds(秒) | 只读 | + diff --git a/Docs/Simulation Variables.pdf b/Docs/Simulation Variables.pdf new file mode 100644 index 00000000..0a954130 Binary files /dev/null and b/Docs/Simulation Variables.pdf differ diff --git a/Docs/status.md b/Docs/status.md new file mode 100644 index 00000000..76267e5d --- /dev/null +++ b/Docs/status.md @@ -0,0 +1,201 @@ +# Flight Assistant 状态标签体系分析报告 + +> 更新日期:2026-09-12(对齐 `Debug.md` 三板块总线时间态 + Auth 持久化) +> 范围:acquire / save / consume 全局时间态、暂停、坠毁、任务名/模式、监测按钮、航迹持久化 + +权威标准见仓库根目录 `Debug.md`。业务逻辑只对齐时间态,不得改写全局时间态定义。 + +--- + +## 1. 概述 + +| 模块 | 职责 | +|------|------| +| `models/state_bus/` | **唯一**总线:Store / Hub / `DataLifecycle` / 摇杆采集 / 分析 | +| `models/state_bus/lifecycle.py` | 三板块布尔时间态权威(acquire / save / consume) | +| `models/runtime_watch.py` | 仅 `PauseDetector` + `aircraft_has_crashed`(传感器输入) | +| `app_controller.py` | 编排:启停监测、poll、跟飞;写生命周期命令,不散落权威态 | +| `models/shadow_plane.py` | 保存主缓冲 + 增量副本;影子机回放本地门控 | + +### 1.1 UI 状态标签 + +| 类别 | UI | 载体 | +|------|-----|------| +| 运行消息 | `widget_Bar_Status` | `train.set_status` | +| 会话模式 / 任务名 | `Container_Board_Lap` | Store `session.*` | +| 监测按钮 | 开始/停止/保存 | 页面 UI + `DataLifecycle` 派生 | +| 自检灯 | 自检页 | 独立流程(非三板块时序) | + +--- + +## 2. 三板块时间状态机(`DataLifecycle`) + +协同状态全部为**唯一布尔名**,写入 Store 组 `runtime`: + +| 板块 | 布尔键 | 互斥规则 | +|------|--------|----------| +| 获取 acquire | `acquire_start` / `acquire_paused` / `acquire_stop` | 三者恰一为真 | +| 保存 save | `save_start` / `save_stop` | 二者恰一为真;**无 paused** | +| 消费 consume | `consume_start` / `consume_paused` / `consume_stop` | 三者恰一为真 | +| 旁路 | `save_pending` / `crash_latched` | 非时间态;pending 驱动 save 异步停 | + +### 2.1 派生查询(编排/UI 用) + +| 方法 | 条件 | +|------|------| +| `is_active()` | `acquire != stop` | +| `is_waiting()` | `acquire_paused` 且 `save_stop`(已点监测、未出数) | +| `is_recording()` | `save_start` 且 `acquire != stop`(停监测后仅剩异步 save 不算录制) | + +### 2.2 命令与对齐 + +```mermaid +stateDiagram-v2 + [*] --> Idle: reset / 初始 + Idle --> Waiting: enter_waiting\nacquire=paused, save=stop, consume=stop + Waiting --> Recording: sync_start_recording\n三板块 start 对齐 + Recording --> RecPaused: apply_acquire_paused(True)\nacquire=paused, consume=paused\nsave 保持 start(会话内) + RecPaused --> Recording: apply_acquire_paused(False) + Recording --> IdleFlush: return_to_idle\nacquire=stop, consume=stop\nsave=start if pending + Waiting --> Idle: return_to_idle + IdleFlush --> Idle: mark_save_flushed +``` + +- **开始对齐**:出数时 `sync_start_recording()`,获取优先,save/consume 同步 start。 +- **暂停**:只强制获取 `paused`;save 无 paused(有 pending/会话则保持 start);consume 跟随 paused。 +- **停止**:获取先 stop;save 可异步冲刷至 `mark_save_flushed`;consume stop。 + +### 2.3 缓存隔离(Debug.md) + +| 缓冲 | 归属 | 读者 | +|------|------|------| +| `TrajectoryRecorder._samples` | **save 主内存** | 保存按钮路径 → `track_store` 写入 Auth `user_defined_3` | +| `TrajectoryRecorder._replica` | **save 副**(增量,差 ≤1 批) | 紧急消费(航迹)`replica_snapshot()` | +| `ChartHistoryBuffer` | consume 本板块缓存 | 趋势图(见 §11 待办) | +| 摇杆 `stick.axes` | 获取写 → 消费订 | 训练页摇杆 | + +禁止:消费直读 `_samples`;禁止跨板块共享同一套缓存。 + +### 2.3.1 持久化与登录 + +- 航迹:`models/track_store.py` → Auth `user_defined_3`(经 `db_operate`)。 +- 用户设置(摇杆映射等):`models/user_settings_store.py` → Auth `user_defined_4`。 +- 未登录:不可持久化用户数据(仅内存,提示登录)。 +- 禁止成品出口:本地 `track_data/*.csv`、`config/*.json`。 + +### 2.4 摇杆采集对齐 + +`AcquireWriter` 采样门控: + +- 总线 `acquire_start` **或** `acquire_paused` → 采 +- `set_ui_preview(True)` → 训练页未监测时的预览(**单板块**,不同步 save/consume) + +### 2.5 影子机对齐 + +`AppController` 订 `GROUP_RUNTIME`:仅当 `acquire_start` 时 `set_replay_paused(False)`,否则暂停回放。 +**不再**在 poll 里交叉调用 `_apply_shadow_pause`。 + +--- + +## 3. 暂停检测(传感器,非时间态权威) + +1. 主动:`PAUSE_ON`(从不自动 `PAUSE_OFF`)。 +2. 被动:`PauseDetector.refresh` 以 `ABSOLUTE TIME` 为准。 +3. 结果经 `_bus_commit_runtime` → `apply_acquire_paused` 写入总线。 + +录制器 `TrajectoryRecorder.paused` 仅为保存板块**本地门控**(暂停时不追加),不是 save 全局时间态。 + +--- + +## 4. 坠毁 + +- 读数:`aircraft_has_crashed(aq)` +- 锁存:`DataLifecycle.crash_latched`(总线 `crash_latched`) +- 处理:锁存后 `return_to_idle` / 停跟飞;闲置无会话不锁存 + +--- + +## 5. 任务名称 / 会话模式 + +仍由 `AppController` 持有并 commit 到 `session`: + +| 事件 | 效果 | +|------|------| +| `FlightLoaded` | `parse_mission_title` → `mission_title` + mode | +| `MissionCompleted`(非 MP) | 清标题 → freeflight | +| 监测中 freeflight 解析 | 强制 `mission_sp` / `mission_mp`(不降级) | + +路径缺 `.FLT` 时 `normalize_flt_path` 补后缀。 + +--- + +## 6. 监测按钮 + +| 条件 | 按钮 | +|------|------| +| `is_recording()` | 停止监测 | +| `is_waiting()` | 停止监测(pending 样式) | +| 非 active + 有图 + `has_pending_samples` | **保存当前数据** | +| 其他 | 隐藏保存槽 / 开始监测可用 | + +停监测后若 `save_pending`,`save_start` 可仍为真,但 `is_recording()` 为假 → 显示保存。 + +--- + +## 7. poll 要点 + +```text +paused = pause_detector.refresh +→ apply_acquire_paused(总线) +→ 影子机由 Hub 订阅自动对齐 +→ waiting + !paused → sync_start_recording + recorder.start +→ recording + !paused → history.append + sample_from_aq + 同步 replica / save_pending +``` + +--- + +## 8. 关键符号 + +| 主题 | 文件 | 符号 | +|------|------|------| +| 时间态权威 | `models/state_bus/lifecycle.py` | `DataLifecycle` | +| 布尔键 | `models/state_bus/keys.py` | `KEY_ACQUIRE_*` / `KEY_SAVE_*` / `KEY_CONSUME_*` | +| 暂停/坠毁输入 | `models/runtime_watch.py` | `PauseDetector`, `aircraft_has_crashed` | +| 摇杆 | `models/state_bus/acquire.py` | `_should_sample`, `set_ui_preview` | +| 保存主/副 | `models/shadow_plane.py` | `_samples`, `_replica`, `replica_snapshot` | +| 航迹云存 | `models/track_store.py` | 读写 `user_defined_3` | +| 用户设置 | `models/user_settings_store.py` | 读写 `user_defined_4` | +| 编排 | `app_controller.py` | `_runtime`(=lifecycle)、`_on_bus_runtime`、`_poll` | + +--- + +## 9. 相对早期实现(已迁移) + +| 旧 | 现 | +|----|----| +| `MonitorPhase` / `RuntimeWatch.phase` | `DataLifecycle` 三板块布尔 | +| Store `phase` / `paused` 字符串镜像 | `acquire_*` / `save_*` / `consume_*` | +| 航迹读 `samples_snapshot` | `replica_snapshot` | +| poll 直调影子暂停 | Hub 订 `acquire_start` | +| 摇杆仅 `set_enabled` | 对齐 `acquire_*` + UI preview | +| 本地轨迹 CSV / `track_data` | Auth `user_defined_3`(`track_store`) | + +--- + +## 10. 设计注意 + +1. 无自动解除暂停。 +2. 业务不得修改全局时间态定义(`Debug.md`)。 +3. 紧急消费只从唯一副本截取;非紧急只读已保存数据。 +4. `return_to_idle` 保留 `crash_latched`;`reset` 全清。 + +--- + +## 11. 已知待办(继续对齐) + +1. **趋势图 `ChartHistoryBuffer`**:仍由 poll 直写 live 序列;按 `Debug.md` 紧急消费应只从 save 唯一副本截取,需把图表字段纳入主/副缓冲或改为读副本(与 Auth 落库正交)。 +2. **FlightLoaded / SystemState 分流**(监测后任务名偶发不刷)——与状态机正交,可单开。 + +--- + +*以 `Debug.md` + `models/state_bus/lifecycle.py` + `app_controller.py` 为准。* diff --git "a/Docs/\347\251\272\346\260\224\345\212\250\345\212\233\345\255\246\345\216\237\347\220\206\345\217\212\345\210\206\346\236\220.md" "b/Docs/\347\251\272\346\260\224\345\212\250\345\212\233\345\255\246\345\216\237\347\220\206\345\217\212\345\210\206\346\236\220.md" new file mode 100644 index 00000000..ab758fa4 --- /dev/null +++ "b/Docs/\347\251\272\346\260\224\345\212\250\345\212\233\345\255\246\345\216\237\347\220\206\345\217\212\345\210\206\346\236\220.md" @@ -0,0 +1,355 @@ +# Extra 特技任务:空气动力学原理及分析 + +> 适用机型:Extra 300 / Extra 300S(活塞螺旋桨特技机) +> 数据接口:SimConnect `AircraftRequests`(变量全表见 `Docs/README.md` 附件) +> 目标口径:**任务功率最大化** = 抬高有效输出功率 + 压低完成航线所需能量损失,用 **完成时间 \(T\)** 验收。 + +--- + +## 0. 结论速览 + +| 层级 | 要盯什么 | 验收 | +|------|----------|------| +| 原因链(输出) | 油门 / RPM / 桨距 / 歧管压 / 扭矩 → 推力 | \(\bar{P}_{\text{out}}=\overline{T V_{\text{TAS}}}\) 高且稳 | +| 原因链(损失) | α、G、重量、侧滑 → 阻力 | \(E_{\text{loss}}/S\)、诱导阻相对强度低 | +| 环境 | 密度 / 高度 / 气温;风 | IAS↔TAS、GS 投影分开看 | +| 结果链 | IAS / TAS / GS / VS / G | 圈时 \(T\) 最短 = 任务功率最大 | + +当前轨迹采样(内存主缓冲 → Auth `user_defined_3`)已有:IAS、GS、姿态、扭矩、位置、健康、摇杆。 +扩录最小集见 **§5**;对比呈现见 **§7**。列语义对齐 `models/track_fields.py` / `ROW_KEYS`。 + +--- + +## 1. 原因链与结果链 + +``` +油门 / RPM / 桨距 / 歧管压 / 扭矩 ──→ 推力 ──→ P_out = T · V_TAS +α、G、重量、侧滑 ──────────────────→ 阻力 ──→ P_loss = D · V_TAS +密度 / 高度 / 气温 ──→ 发动机功率上限 + IAS↔TAS +风 ─────────────────→ 地速 V_g(几乎不改空速能量方程主项) + +结果:IAS / TAS / GS / VS / G → 圈时 T_mission +``` + +**任务功率(时间口径):** + +\[ +P_{\text{mission}} \triangleq \frac{W_{\text{task}}}{T_{\text{mission}}} +\propto \frac{S_{\text{course}}}{T_{\text{mission}}} = \bar{V}_{g,\text{course}} +\] + +闭合圈起终点机械能相近时:\(\bar{P}_{\text{out}}\approx\bar{P}_{\text{loss}}=\overline{D V_a}\)。 +只拉满油门但乱拉 G → 轴功率高、**任务功率**差。 + +--- + +## 2. 涉及的物理定律 + +### 2.1 牛顿第二定律(轨迹切向 / 法向) + +切向: + +\[ +m\dot{V} = T\cos\varepsilon - D - mg\sin\gamma +\] + +法向(过载 \(n=|G|\)): + +\[ +L \sim n\,mg +\] + +→ 同高度机动时 \(C_L\uparrow\) → **诱导阻力↑** → 可用加速功率被吃掉。 + +### 2.2 动压与升阻 + +\[ +q=\tfrac12\rho V_a^2,\quad +L=qS\,C_L(\alpha),\quad +D=qS\,C_D(\alpha,\beta,\text{构型}) +\] + +\[ +C_D = C_{D0} + \frac{C_L^2}{\pi e\,AR} + C_{D,\beta}+\cdots +\] + +诱导阻力量级(平飞转弯近似): + +\[ +D_i \propto \frac{(n W)^2}{q S} \propto \frac{n^2}{\rho V_a^2} +\] + +**低速大 G = 耗散黑洞。** + +### 2.3 螺旋桨功率传输 + +\[ +P_{\text{shaft}}=\tau\omega,\quad +P_{\text{prop}}=T V_a=\eta_p P_{\text{shaft}},\quad +J=\frac{V_a}{nD} +\] + +\(\eta_p\) 随 RPM、桨距 \(\beta\)、前进比 \(J\) 变化;油门拉满不在高效区也会亏。 + +### 2.4 大气与 IAS–TAS + +\[ +V_{\text{TAS}} \approx V_{\text{IAS}}\sqrt{\rho_0/\rho} +\] + +密度↓ → 同 IAS 下 TAS↑,但活塞机可用功率大致随 \(\rho\) 下降。 + +### 2.5 风的相对运动 + +\[ +\vec{V}_g=\vec{V}_a+\vec{V}_w +\] + +空速性能看 \(V_a\);圈时看 \(V_g\) 沿航线投影。二者必须分列,禁止混比。 + +### 2.6 机械能与耗散 + +\[ +\frac{d}{dt}\Big(\tfrac12 m V_a^2 + mgh\Big) += P_{\text{prop}} - D V_a - \dot{E}_{\text{other}} +\] + +闭合圈 \(\Delta E_{\text{mech}}\approx 0\) 时: + +\[ +\int_0^{T} P_{\text{prop}}\,dt \approx \int_0^{T} D V_a\,dt = E_{\text{loss}} +\] + +更快圈 ≈ 同样(或更少)耗散下,平均沿航线地速更高。 + +--- + +## 3. 任务功率:输出最大化 × 损失最小化 + +| 目标 | 数学含义 | 飞行含义 | +|------|----------|----------| +| 抬高 \(\bar{P}_{\text{out}}\) | \(\overline{\eta_p P_{\text{shaft}}}\) 大 | 油门/RPM/桨距在高效区 | +| 压低 \(\bar{P}_{\text{loss}}\) | \(\overline{D V_a}\) 小 | 少无用 α、少多余 G、少侧滑 | +| 验收 \(P_{\text{mission}}\) | \(S/T\) 或 \(1/T\) | 圈时最短 | + +可操作比值: + +\[ +\kappa=\frac{S_{\text{course}}}{E_{\text{loss}}} +\quad(\text{单位耗散飞过的航线长,越大越好}) +\] + +\[ +\eta_{\text{mission}}=\frac{S_{\text{course}}{\bar{V}_{a,\text{ref}}\,T} +\quad(\text{相对参考空速的时间效率}) +\] + +### Extra 特技段策略(可检验假说) + +1. **直道/加速:** 最大化 \(\eta_p P_{\text{shaft}}\),减小 α/β。 +2. **转弯/筋斗:** 航线约束下取 **最小必要 \(n\)**;避免低速大 G。 +3. **垂直机动:** \(mgh\) 当电池——上抛换高度、下俯回收 \(V\);门前应变成动能而非被 \(D\) 烧掉。 +4. **密度日:** 承认 \(P_{\text{eng}}\) 上限下降;用 TAS/GS 解释圈时,勿只盯 IAS。 +5. **风:** 逆风低 GS ≠ 推力不够。 + +--- + +## 4. 由采样计算的量(公式) + +采样间隔 \(\Delta t\)(项目默认采集约 10 Hz,\(\Delta t\approx 0.1\,\mathrm{s}\))。 +角速度:\(\omega=2\pi\cdot\mathrm{RPM}/60\)。 +质量:\(m=W/g\)(`TOTAL WEIGHT` 为磅时先换算)。 + +| 符号 | 公式 | 说明 | +|------|------|------| +| \(\hat{P}_{\text{shaft}}\) | \(\tau\cdot\omega\) | 需扭矩 + RPM | +| \(\hat{P}_{\text{prop}}\) | \(T_{\text{prop}}\cdot V_{\text{TAS}}\) | 需 PROP THRUST + TAS | +| \(\hat\eta_p\) | \(\hat{P}_{\text{prop}}/\hat{P}_{\text{shaft}}\) | 推进效率代理 | +| \(E_{\text{mech}}\) | \(\tfrac12 m V_{\text{TAS}}^2 + m g h\) | 需重量 + TAS + 高度 | +| \(E_{\text{loss}}\) | \(\sum\hat{P}_{\text{prop}}\Delta t-\Delta E_{\text{mech}}\) | 圈积分;起终点应对齐 | +| \(D_{i,\text{proxy}}\) | \(n^2 W^2/(\rho V_{\text{TAS}}^2)\) | 诱导阻相对强度(可归一化) | +| \(S_{\text{course}}\) | 经纬度折线累积长 | 或固定赛道标称周长 | +| \(P_{\text{mission}}\) | \(S/T_{\text{lap}}\) | **主验收** | +| \(\bar{V}_g\) | \(S/T\) | 与上等价 | + +稳态短段(\(|\dot{V}|\)、\(|VS|\) 小)可用 \(\hat{D}\approx T-mg\sin\gamma\) 校核能量法。 + +--- + +## 5. 最小计算表(落地列定义) + +### 5.1 列分级 + +| 级别 | 含义 | +|------|------| +| **A 已有** | 当前轨迹采样列已采集,可经 Auth 航迹记录导出/分析 | +| **B 扩录** | SimConnect 增订即可算齐最小功率/损失链 | +| **C 派生** | 事后由 A+B 计算,不必进实时采样(可写分析旁路表) | + +### 5.2 时序表(逐采样,建议分析旁路或扩录采样列) + +| 列名 | 级别 | 来源 / 算法 | 单位 | 检测要点 | +|------|------|-------------|------|----------| +| `timestamp_s` | A | 任务钟 | s | 单调;圈边界对齐 | +| `lap` | A | 任务逻辑 | — | 1…N 完整 | +| `latitude` / `longitude` | A | Sim | deg | 算 \(S\) | +| `altitude_ft` | A | `PLANE ALTITUDE` | ft | \(E_{\text{mech}}\);换 m | +| `airspeed_kts` | A | `AIRSPEED INDICATED` | kt | IAS | +| `groundspeed_kts` | A | `GROUND VELOCITY` | kt | GS | +| `pitch_deg` / `bank_deg` | A | 姿态 | deg | 与 α/G 对照 | +| `torque` | A | `ENG TORQUE:1` | ft·lb | \(P_{\text{shaft}}\) | +| `health_pct` | A | 损伤换算 | % | 功率损失背景 | +| `stick_*` | A | 本地摇杆 | — | 与舵面列对照,非 Sim 推力 | +| `tas_kts` | B | `AIRSPEED TRUE` | kt | **能量/功率必用** | +| `vs_fpm` | B | `VERTICAL SPEED` | ft/min | 高度↔速度 | +| `rpm` | B | `GENERAL ENG RPM:1` 或 `PROP RPM:1` | rpm | \(P_{\text{shaft}}\) | +| `manifold_psi` | B | `ENG MANIFOLD PRESSURE:1` | psi | 功率设定点 | +| `prop_thrust_lbf` | B | `PROP THRUST:1` | lbf | \(P_{\text{prop}}\) | +| `alpha_deg` | B | `INCIDENCE ALPHA`→° | deg | 阻力原因 | +| `beta_deg` | B | `INCIDENCE BETA`→° | deg | 侧滑阻力 | +| `g_force` | B | `G FORCE` | G | 诱导阻 | +| `weight_lb` | B | `TOTAL WEIGHT` | lb | \(m\)、\(D_i\) | +| `ambient_density` | B | `AMBIENT DENSITY` | slug/ft³ | IAS–TAS、\(D_i\) | +| `ambient_temp_c` | B | 可选,密度退化方案 | °C | 与气压二选一补密度 | +| `wind_kts` / `wind_dir_deg` | B | 可选 | kt/deg | 解释 GS | +| `smoke_on` | B | 可选 `SMOKE ENABLE` | 0/1 | Extra 特有,次要 | +| `p_shaft_w` | C | \(\tau\omega\)(统一 SI) | W | 输出 | +| `p_prop_w` | C | \(T\cdot V_{\text{TAS}}\) | W | 输出 | +| `eta_prop` | C | `p_prop/p_shaft` | — | 0–1;异常则单位/采样问题 | +| `e_mech_j` | C | \(\tfrac12 mV^2+mgh\) | J | 机械能 | +| `di_proxy` | C | \(n^2 W^2/(\rho V^2)\) | 单位强度 | 段对比前先 /max 归一化 | +| `ds_m` | C | haversine(\(i-1\to i\)) | m | 航线微元 | +| `vg_course_kts` | C | \(\mathrm{ds}/\Delta t\) | kt | 沿迹地速 | + +**最小扩录(B)推荐 8 列:** +`tas_kts, vs_fpm, rpm, manifold_psi, prop_thrust_lbf, alpha_deg, g_force, weight_lb` +再加 `ambient_density`(或气温+气压)则诱导阻与 TAS 换算闭环。 + +### 5.3 圈汇总表(每圈一行,对比主表) + +| 列名 | 算法 | 对比用途 | +|------|------|----------| +| `lap` | 分组键 | — | +| `t_lap_s` | 圈时长 | **主验收(越小越好)** | +| `s_m` | \(\sum ds\) | 航线是否同长;异常则门线/采样问题 | +| `p_mission_w_eq` | \(S/T\)(或 kt 平均) | 任务功率代理 | +| `vg_avg_kts` | \(S/T\) | 与上同构 | +| `ias_avg` / `ias_p50` / `ias_min` | 分位数 | 能量机动是否掉速 | +| `tas_avg` / `gs_avg` | 均值 | 空速 vs 地速拆开 | +| `p_shaft_avg` / `p_prop_avg` | 时间平均 | 输出是否到位 | +| `eta_prop_avg` | 平均效率 | 桨距–RPM 匹配 | +| `e_loss_j` | \(\sum p_{\text{prop}}\Delta t-\Delta e_{\text{mech}}\) | 损失总量 | +| `e_loss_per_m` | \(E_{\text{loss}}/S\) | **单位航程耗散(越小越好)** | +| `di_proxy_avg` / `di_proxy_p90` | 均值/P90 | 转弯是否过狠 | +| `g_avg` / `g_p90` / `g_time_frac_gt_2` | G 统计 | 过载时间占比 | +| `alpha_p90` / `beta_rms` | 姿态气动 | 粗糙飞 vs 干净飞 | +| `rpm_avg` / `mp_avg` / `torque_avg` | 设定点 | 动力是否一致 | +| `health_min` | 圈内最小健康 | 损伤是否拖功率 | + +--- + +## 6. 该检测哪些列(质控清单) + +开飞 / 复盘前按表打勾: + +| 检测项 | 列 | 合格判据 | +|--------|-----|----------| +| 时间基 | `timestamp_s` | 严格递增;\(\Delta t\) 近似稳定 | +| 圈完整 | `lap` | 每圈样本数 ≈ \(T/\Delta t\);无空洞跳号 | +| 位置连续 | lat/lon | 无跳点;\(S\) 与标称赛道偏差有界 | +| 空速合理 | IAS/TAS | Extra 典型量级;TAS≥IAS(标准日近似,视密度) | +| 地速 | GS | 有风时与 TAS 差 ≈ 风投影,勿与 IAS 强行相等 | +| 动力链 | torque, rpm, thrust | 同时非 NaN;\(\eta_p\) 落在合理区间(异常先查单位) | +| 气动链 | alpha, g_force | 大 G 段 α 不应长期离谱;与 bank 同向出现 | +| 重量 | weight_lb | 圈内近似常数;跳变=读数故障 | +| 密度 | ambient_density | 随高度缓变;突变=坏点 | +| 能量闭合 | e_mech 起终点 | 同高度同速门:\(|\Delta E|\) 应 ≪ \(E_{\text{loss}}\) | +| 摇杆≠舵面 | stick_* vs 日后舵面列 | 对比输入与气动响应时勿混用 | + +**缺列时的降级:** + +- 无 TAS:可用 IAS + 密度估 TAS,或暂用 IAS 做相对圈对比(同密度日)。 +- 无 thrust:只用 \(P_{\text{shaft}}\) 做输出代理,损失改用「低速大 G 时间」等行为指标。 +- 无 α:用 pitch + VS + IAS 定性,不能定量拆 \(C_D\)。 + +--- + +## 7. 怎样呈现数据有利于对比 + +### 7.1 推荐视图(优先级) + +| 优先级 | 视图 | 横轴 / 编码 | 看什么 | +|--------|------|-------------|--------| +| 1 | **圈汇总条形/表** | lap | `t_lap_s`、`e_loss_per_m`、`p_prop_avg`、`g_p90` 并排 | +| 2 | **同圈多飞行叠加** | `timestamp_s` 归零到圈起点,或弧长 \(s\) | IAS/TAS、G、\(P_{\text{prop}}\)、\(\alpha\) 半透明叠加 | +| 3 | **航迹染色** | lat/lon | 颜色 = `di_proxy` 或 `g_force` 或 `p_prop`(找耗散热点) | +| 4 | **输出–损失散点** | 每圈一点:\(x=\bar{P}_{\text{out}},\ y=E_{\text{loss}}/S\),点大小∝\(1/T\) 或标注圈时 | 右下最好:高输出低耗散 | +| 5 | **G–V 联合** | \(x=V_{\text{TAS}},\ y=|G|\),颜色=时间或耗散率 | 是否进入「低速大 G」禁区 | +| 6 | **效率条带** | 时间 | \(\eta_p\);掉效率段对照 RPM/桨距 | + +### 7.2 对比协议(避免假差异) + +1. **只比同任务、同赛道、同机模**(Extra 300S)。 +2. **密度日分组**:`ambient_density` 或密度高度分桶后再比圈时。 +3. **主指标唯一:** 圈时 \(T\)(或 \(S/T\));其余为解释变量。 +4. **双轴拆开:** 空速族(IAS/TAS)与地速族(GS)分图,不叠同一 Y 轴误导。 +5. **段对齐优先用弧长 \(s\)**,其次用圈内相对时间;绝对墙钟时间不利对比。 +6. **多圈箱线图:** 同一飞行员多圈的 `t_lap`、`e_loss_per_m`、`g_p90` — 看稳定性。 +7. **差值条:** 最佳圈 vs 当前圈,在 \(s\) 上画 \(\Delta V\)、\(\Delta G\)、\(\Delta P_{\text{prop}}\)。 + +### 7.3 一页纸仪表板建议布局 + +``` +┌──────────────┬──────────────┬──────────────┐ +│ 圈时 T 排行 │ E_loss/S 排行 │ P_prop 均值 │ +├──────────────┴──────────────┴──────────────┤ +│ 航迹图(颜色: di_proxy 或 G) │ +├───────────────────┬────────────────────────┤ +│ 圈内: TAS / GS │ 圈内: G + α │ +├───────────────────┼────────────────────────┤ +│ 圈内: P_prop │ 散点: P_out vs E_loss/S │ +└───────────────────┴────────────────────────┘ +``` + +### 7.4 判读口诀 + +- \(T\)↓ 且 \(E_{\text{loss}}/S\)↓ → **飞干净了**(主攻方向正确)。 +- \(T\)↓ 但 \(E_{\text{loss}}/S\)↑、\(\bar{P}_{\text{out}}\)↑ → **堆功率硬推**,有风险/不稳。 +- \(\bar{P}_{\text{out}}\)↑ 但 \(T\) 不动 → 查风、航迹变长、或垂直能量没收回。 +- `g_p90`↑ 且 `tas`↓ 同时出现 → 诱导阻主导,先改转弯半径/入弯速度。 + +--- + +## 8. 与项目现状的衔接 + +| 项目位置 | 关系 | +|----------|------| +| `services/simulator.py` | 已订 `AIRSPEED`、`GROUND_VELOCITY`、姿态、位置、`ENG_TORQUE`、损伤等 | +| `models/data_bridge.py` → `read_aq_snapshot` / `ROW_KEYS` | 当前快照与采样列;扩录时在此对齐表头 | +| `models/track_fields.py` | 航迹列开关 / Auth 记录字段 | +| `models/track_store.py` | 航迹写入 Auth `user_defined_3` | +| `Docs/README.md` 附件 | B 级列的官方 SimVar 名与单位 | +| 航迹记录 meta | `aircraft_title`、`laps_s` 可与圈汇总交叉验证 | + +**实现顺序建议(不改行为亦可先离线算):** + +1. 离线:用已有航迹记录(或调试 CSV)+ 若有原始 AQ 日志,按 §5.3 出圈汇总。 +2. 扩录 B 最小 8 列:先扩 `track_fields` / 采样,再进 `track_store`。 +3. 分析线程写旁路列 C,UI 按 §7 挂圈对比与航迹染色。 + +--- + +## 9. 符号与单位注意 + +- SimConnect 姿态角多为 **弧度**,入库前转 deg(项目已有 `rad_to_deg`)。 +- `PLANE ALTITUDE` 常为英尺;能量用 SI 时统一 \(h\) 为 m、\(V\) 为 m/s、\(m\) 为 kg。 +- `ENG TORQUE` 与 `PROP THRUST` 单位以清单为准;算 \(\eta_p\) 前必须统一到 SI,否则效率无量纲会飞出 0–1。 +- `stick_*` 是硬件轴,**不是** \(T\) 或舵偏角本身。 + +--- + +## 10. 参考 + +- `Docs/README.md` 附件 — 可订阅读取的仿真变量全表 +- `Docs/Simulation Variables.pdf` — 官方变量说明 +- `Docs/项目原理.md` — 系统数据采集链路 diff --git "a/Docs/\351\241\271\347\233\256\345\216\237\347\220\206.md" "b/Docs/\351\241\271\347\233\256\345\216\237\347\220\206.md" new file mode 100644 index 00000000..c520db8d --- /dev/null +++ "b/Docs/\351\241\271\347\233\256\345\216\237\347\220\206.md" @@ -0,0 +1,41 @@ +# 项目原理 + +## 架构概览 + +- **核心库 `SimConnect/`**:vendored 绑定,用 `ctypes` 调用官方 `SimConnect.dll`,将 C 接口映射为 Python 枚举、结构体与函数,完成事件发送与数据订阅。 +- **请求/事件封装**:`RequestList.py` 定义数据请求与缓存策略;`EventList.py` 提供可调用的 `Event`(以及按分类封装的 `AircraftEvents`,本应用未走包入口导出);`FacilitiesList.py` 提供机场等设施列表 API(本应用未使用,文件保留)。 +- **本仓库主程序**:`app_run.py` → `AppController` 编排自检 / 登录 / 训练;UI 与业务见下文「应用层」。 + +## 开发与交互流程(SimConnect 库) + +1. **加载 DLL**:`SimConnect.SimConnectDll` 使用 `windll.LoadLibrary` 载入 `SimConnect.dll`,并为每个 SDK 函数声明 `argtypes`/`restype`。 +2. **建立连接**:`SimConnect.connect()` 调用 `SimConnect_Open` 创建句柄,订阅系统事件,并启动后台线程轮询 `CallDispatch`。 +3. **消息分发**:按 `dwID` 将收到的数据分发到对应处理(系统状态、异常、对象数据等)。 +4. **数据请求/写入**:经 `AircraftRequests` / 底层 `request_data`、`get_data`、`set_data` 读写仿真变量;本应用监视路径另见 `services/sim_batch.py` 批量读。 +5. **事件发送**:`Event(b"PAUSE_ON", sm)` 等触发模拟器事件(见 `app_controller` / `services/simulator.py`)。 + +## 部署要点 + +- **平台**:SimConnect 仅支持 Windows;需可访问 `SimConnect.dll`。 +- **Python**:64 位;32 位会触发 `WinError 193`。 +- **依赖**:`pip install -r requirements.txt`。 +- **远程连接(可选)**:脚本目录 `SimConnect.cfg`,模拟器端 `SimConnect.xml` 中 `Address` 允许网络访问。 + +## 扩展与调试建议 + +- 用 `logging` 排查连接或事件映射问题。 +- 新增变量/事件:在 `RequestList.py` / `EventList.py` 添加对应项;变量中文清单见 `Docs/README.md` 附件。 + +--- + +## Flight Assistant 应用层(本仓库主程序) + +- **入口**:`app_run.py` → `Frame_Shell_AppMain` + `AppController`(自检 → 登录 → 训练)。 +- **页面**:`pages/page_sim_check.py`、`page_auth.py`、`page_training_assist.py`;冒烟另可注册 `page_mission_stub.py`。 +- **状态权威**:`models/state_bus`(分组 Store + 主线程 Hub 节流订阅);三板块时间态见根目录 `Debug.md` / `Docs/status.md`。 +- **采集**:摇杆由 `AcquireWriter` 写入 `stick`;SimConnect 读数因句柄亲和仍在主线程 poll,commit 到 `live` / `runtime` / `chart`/`track` seq。 +- **分析**:云端航迹对比经 `AnalyzeWorker` → `load_track_compare_record`(Auth `user_defined_3`),结果写入 `analyze` 组。 +- **持久化**:`track_store` / `user_settings_store` + `db_operate`;禁止成品写本地 `track_data`/`config`。 +- **UI 四层**:`components/Frame|Container|widget|overlay`;图表在 `charts/`(`mask/` 蒙版,渲染为 pyqtgraph)。 + +更细的图表与字段导读见 `Docs/README.md`;状态机实现见 `Docs/status.md`;主题令牌见根目录 `DESIGN.md`。 diff --git a/ESP32_Client_[WIP].py b/ESP32_Client_[WIP].py deleted file mode 100644 index 25ee059e..00000000 --- a/ESP32_Client_[WIP].py +++ /dev/null @@ -1,87 +0,0 @@ -# This file is executed on every boot (including wake-boot from deepsleep) -import esp -import webrepl -import network -import socket -import json -import machine -from machine import Pin, ADC -from time import sleep - -esp.osdebug(None) - -server_address = 'http://' -server_port = 5000 - - -def do_connect(): - wlan = network.WLAN(network.STA_IF) - wlan.active(True) - if not wlan.isconnected(): - print('connecting to network...') - wlan.connect('', '') - while not wlan.isconnected(): - pass - print('network config:', wlan.ifconfig()) - - -def http_get(url): - _, _, host, path = url.split('/', 3) - addr = socket.getaddrinfo(host, server_port)[0][-1] - s = socket.socket() - s.connect(addr) - s.send(bytes('GET /%s HTTP/1.0\r\nHost: %s\r\n\r\n' % (path, host), 'utf8')) - while True: - data = s.recv(100) - if data: - print(str(data, 'utf8'), end='') - else: - break - s.close() - - -def http_post(url, body): - _, _, host, path = url.split('/', 3) - addr = socket.getaddrinfo(host, server_port)[0][-1] - s = socket.socket() - s.connect(addr) - s.send(bytes('POST /%s HTTP/1.0\r\nHost: %s\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n%s' % ( - path, host, len(body), body), 'utf8') - ) - while True: - data = s.recv(100) - if not data: - break - s.close() - - -def scale(val, src, dst): - """ - Scale the given value from the scale of src to the scale of dst. - """ - return ((val - src[0]) / (src[1] - src[0])) * (dst[1] - dst[0]) + dst[0] - - -def set_throttle_lever(value, index=1): - http_post(server_address + '/datapoint/GENERAL_ENG_THROTTLE_LEVER_POSITION:index/set', '{"index":%d, "value_to_use": %d}' % (index, value)) - - -def get_scaled_adc(_pin): - adc = ADC(Pin(_pin)) # create ADC object on ADC pin - adc.atten(ADC.ATTN_11DB) # set 11dB input attenuation (voltage range roughly 0.0v - 3.6v) - adc.width(ADC.WIDTH_11BIT) # set 9 bit return values (returned range 0-511) - return scale(adc.read(), (0, 520), (0, 100)) # read value using the newly configured attenuation and width - - -do_connect() -webrepl.start() - -buff = 2 -o_te = 0 -pin_to_read = 32 -while True: - c_te = int(get_scaled_adc(pin_to_read)) - if c_te > (o_te + buff) or c_te < (o_te - buff): - set_throttle_lever(c_te) - o_te = c_te - sleep(1) diff --git a/Flight_Assistant.spec b/Flight_Assistant.spec new file mode 100644 index 00000000..72b5a741 --- /dev/null +++ b/Flight_Assistant.spec @@ -0,0 +1,128 @@ +# -*- mode: python ; coding: utf-8 -*- +""" +Flight_Assistant PyInstaller spec: single-file exe (win32 / sim_connect). +Entry: app_run.py. Bundles .env, resources/, Missions/, SimConnect.dll. +Avoid collect_all(PySide2/pygame/pyqtgraph) — pulls WebEngine/examples/tests. +""" + +from pathlib import Path + +from PyInstaller.utils.hooks import collect_data_files, collect_dynamic_libs + +block_cipher = None + +_EXE_ICON = Path("resources/icons/icon.ico") + +# Qt plugins + binaries (platforms, styles, imageformats) without full Qt module tree +pyside2_datas = collect_data_files("PySide2", includes=["plugins/**", "translations/**"]) +pyside2_bins = collect_dynamic_libs("PySide2") +pygame_bins = collect_dynamic_libs("pygame") +pygame_datas = collect_data_files("pygame", excludes=["examples/**", "docs/**", "tests/**"]) + +a = Analysis( + ["app_run.py"], + pathex=[], + binaries=pyside2_bins + + pygame_bins + + [("SimConnect/SimConnect.dll", "SimConnect")], + datas=[ + (".env", "."), + ("resources", "resources"), + ("Missions", "Missions"), + ] + + pyside2_datas + + pygame_datas, + hiddenimports=[ + "pages.page_auth", + "pages.page_sim_check", + "pages.page_training_assist", + "pages.page_mission_stub", + "SimConnect", + "SimConnect.SimConnect", + "SimConnect.RequestList", + "SimConnect.EventList", + "SimConnect.Enum", + "SimConnect.Constants", + "SimConnect.Attributes", + "pymysql", + "dbutils.pooled_db", + "dotenv", + "passlib.context", + "passlib.handlers.pbkdf2", + "numpy", + "pandas", + "pyqtgraph", + "pygame", + "PySide2.QtCore", + "PySide2.QtGui", + "PySide2.QtWidgets", + "PySide2.QtNetwork", + "PySide2.QtSvg", + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[ + "tkinter", + "matplotlib", + "IPython", + "notebook", + "pytest", + "PySide2.QtWebEngine", + "PySide2.QtWebEngineCore", + "PySide2.QtWebEngineWidgets", + "PySide2.Qt3DAnimation", + "PySide2.Qt3DCore", + "PySide2.Qt3DExtras", + "PySide2.Qt3DInput", + "PySide2.Qt3DLogic", + "PySide2.Qt3DRender", + "PySide2.QtQuick", + "PySide2.QtQuickWidgets", + "PySide2.QtQml", + "PySide2.QtMultimedia", + "PySide2.QtMultimediaWidgets", + "PySide2.QtLocation", + "PySide2.QtPositioning", + "PySide2.QtSensors", + "PySide2.QtSerialPort", + "PySide2.QtSql", + "PySide2.QtTest", + "PySide2.QtBluetooth", + "PySide2.QtNfc", + "pyqtgraph.examples", + "pyqtgraph.opengl", + "pygame.examples", + "pygame.tests", + "passlib.tests", + ], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name="Flight_Assistant", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=str(_EXE_ICON) if _EXE_ICON.is_file() else None, +) diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 0ad25db4..00000000 --- a/LICENSE +++ /dev/null @@ -1,661 +0,0 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. diff --git a/Makefile b/Makefile deleted file mode 100644 index 7234f381..00000000 --- a/Makefile +++ /dev/null @@ -1,25 +0,0 @@ -build: - python setup.py build - -install: - pip install -r requirements.txt - -install-dev: - pip install -r requirements_dev.txt - -test-sim: - python example.py - -test: install install-dev - python -m pytest --cov=SimConnect --cov-report xml - -update-dependencies: - pipenv update - pipenv lock -r > requirements.txt - pipenv lock -r -d > requirements_dev.txt - -sdist: - python setup.py sdist - -clean: - rm -r dist src/*.egg-info build .coverage coverage.xml .pytest_cache \ No newline at end of file diff --git a/Missions/RB_SP_Istanbul_Intermediate/RB_SP_Istanbul_Intermediate_CRASH.csv b/Missions/RB_SP_Istanbul_Intermediate/RB_SP_Istanbul_Intermediate_CRASH.csv new file mode 100644 index 00000000..222aa157 --- /dev/null +++ b/Missions/RB_SP_Istanbul_Intermediate/RB_SP_Istanbul_Intermediate_CRASH.csv @@ -0,0 +1,26 @@ +id,library_name,resolved_mdln,instance_name,category,latitude,longitude,lat_dms,lon_dms,heading_deg,scale,has_cras,has_volume,volume_source,box_size_x_m,box_size_y_m,box_size_z_m,box_height_m,horiz_radius_m,cras_min_x,cras_min_y,cras_min_z,cras_nnodes,source_bgl +1,gen_airshow_smalltent2_1,gen_airshow_smalltent2_1,gen_airshow_smalltent2_1,tent,41.03458295,28.95297496,"N41° 02' 04.50""","E28° 57' 10.71""",108.90,1.0000,1,1,cras,22.18,7.62,15.108,7.62,13.419,-11.065,-0.022,-7.515,51,fillerobjects.bgl +2,Gen_Crowd_Istanbul_01,Gen_Crowd_Istanbul_01,Gen_Crowd_Istanbul_01,props,41.02409078,28.96499997,"N41° 01' 26.73""","E28° 57' 54.00""",148.20,1.0000,1,1,cras,477.991,10.019,28.875,10.019,239.431,-239.042,0.0,-14.356,47,FSXP_People.bgl +3,Gen_Crowd_Istanbul_02,Gen_Crowd_Istanbul_02,Gen_Crowd_Istanbul_02,props,41.02409078,28.96499159,"N41° 01' 26.73""","E28° 57' 53.97""",148.20,1.0000,1,1,cras,195.944,5.52,26.597,5.52,98.871,-92.185,-0.047,-12.825,30,FSXP_People.bgl +4,Gen_Crowd_Istanbul_03,Gen_Crowd_Istanbul_03,Gen_Crowd_Istanbul_03,props,41.02333492,28.96181107,"N41° 01' 24.01""","E28° 57' 42.52""",32.40,1.0000,1,1,cras,126.902,2.458,15.766,2.458,63.939,-63.968,-0.226,-6.071,31,FSXP_People.bgl +5,Gen_Crowd_Istanbul_04,Gen_Crowd_Istanbul_04,Gen_Crowd_Istanbul_04,props,41.02458570,28.96031382,"N41° 01' 28.51""","E28° 57' 37.13""",178.20,1.0000,1,1,cras,154.276,2.458,226.515,2.458,137.031,-72.297,-0.24,-110.855,27,FSXP_People.bgl +6,Gen_Crowd_Istanbul_05,Gen_Crowd_Istanbul_05,Gen_Crowd_Istanbul_05,props,41.02594446,28.95948887,"N41° 01' 33.40""","E28° 57' 34.16""",359.10,1.0000,1,1,cras,23.92,2.458,74.652,2.458,39.195,-10.273,-0.174,-41.567,27,FSXP_People.bgl +7,Gen_Crowd_Istanbul_06,Gen_Crowd_Istanbul_06,Gen_Crowd_Istanbul_06,props,41.02706926,28.95838606,"N41° 01' 37.45""","E28° 57' 30.19""",315.00,1.0000,1,1,cras,44.802,2.458,281.199,2.458,142.373,-30.873,-0.276,-141.558,33,FSXP_People.bgl +8,Gen_Crowd_Istanbul_07,Gen_Crowd_Istanbul_07,Gen_Crowd_Istanbul_07,props,41.02829305,28.95721662,"N41° 01' 41.85""","E28° 57' 25.98""",0.00,1.0000,1,1,cras,47.984,2.458,58.512,2.458,37.835,-25.572,-0.274,-29.631,33,FSXP_People.bgl +9,Gen_Crowd_Istanbul_08,Gen_Crowd_Istanbul_08,Gen_Crowd_Istanbul_08,props,41.02873397,28.95508049,"N41° 01' 43.44""","E28° 57' 18.29""",1.80,1.0000,1,1,cras,322.218,2.458,78.684,2.458,165.843,-159.184,-0.215,-39.7,31,FSXP_People.bgl +10,Gen_Crowd_Istanbul_09,Gen_Crowd_Istanbul_09,Gen_Crowd_Istanbul_09,props,41.02941785,28.95285551,"N41° 01' 45.90""","E28° 57' 10.28""",5.40,1.0000,1,1,cras,72.38,2.458,77.088,2.458,52.871,-35.485,-0.215,-38.366,33,FSXP_People.bgl +11,Gen_Crowd_Istanbul_10,Gen_Crowd_Istanbul_10,Gen_Crowd_Istanbul_10,props,41.03006574,28.95272216,"N41° 01' 48.24""","E28° 57' 09.80""",27.90,1.0000,1,1,cras,19.883,2.458,76.591,2.458,39.565,-11.369,-0.215,-38.933,23,FSXP_People.bgl +12,Gen_Crowd_Istanbul_11,Gen_Crowd_Istanbul_11,Gen_Crowd_Istanbul_11,props,41.03064164,28.95258050,"N41° 01' 50.31""","E28° 57' 09.29""",0.00,1.0000,1,1,cras,66.425,2.458,81.201,2.458,52.455,-34.553,-0.215,-39.727,33,FSXP_People.bgl +13,Gen_Crowd_Istanbul_12,Gen_Crowd_Istanbul_12,Gen_Crowd_Istanbul_12,props,41.03114555,28.95142221,"N41° 01' 52.12""","E28° 57' 05.12""",295.20,1.0000,1,1,cras,25.699,2.458,141.069,2.458,71.695,-21.238,-0.215,-70.452,28,FSXP_People.bgl +14,Gen_Crowd_Istanbul_13,Gen_Crowd_Istanbul_13,Gen_Crowd_Istanbul_13,props,41.03265728,28.94973326,"N41° 01' 57.57""","E28° 56' 59.04""",357.30,1.0000,1,1,cras,18.41,2.458,94.697,2.458,48.235,-9.557,-0.215,-48.015,24,FSXP_People.bgl +15,Gen_Crowd_Istanbul_14,Gen_Crowd_Istanbul_14,Gen_Crowd_Istanbul_14,props,41.03319719,28.94986938,"N41° 01' 59.51""","E28° 56' 59.53""",5.40,1.0000,1,1,cras,37.823,2.458,26.083,2.458,22.972,-19.136,-0.215,-12.98,38,FSXP_People.bgl +16,Gen_Crowd_Istanbul_14b,Gen_Crowd_Istanbul_14,Gen_Crowd_Istanbul_14b,props,41.03500587,28.94911107,"N41° 02' 06.02""","E28° 56' 56.80""",307.80,1.0000,1,1,cras,37.823,2.458,26.083,2.458,22.972,-19.136,-0.215,-12.98,38,FSXP_People.bgl +17,Gen_Crowd_Istanbul_14c,Gen_Crowd_Istanbul_14,Gen_Crowd_Istanbul_14c,props,41.03442098,28.95299440,"N41° 02' 03.92""","E28° 57' 10.78""",55.80,1.0000,1,1,cras,37.823,2.458,26.083,2.458,22.972,-19.136,-0.215,-12.98,38,FSXP_People.bgl +18,Gen_Crowd_Istanbul_14d,Gen_Crowd_Istanbul_14,Gen_Crowd_Istanbul_14d,props,41.03458295,28.95256382,"N41° 02' 04.50""","E28° 57' 09.23""",58.50,1.0000,1,1,cras,37.823,2.458,26.083,2.458,22.972,-19.136,-0.215,-12.98,38,FSXP_People.bgl +19,Gen_Crowd_Istanbul_15,Gen_Crowd_Istanbul_15,Gen_Crowd_Istanbul_15,props,41.03374609,28.94950275,"N41° 02' 01.49""","E28° 56' 58.21""",310.50,1.0000,1,1,cras,41.68,2.458,142.931,2.458,74.442,-13.412,-0.215,-74.194,17,FSXP_People.bgl +20,Gen_Crowd_Istanbul_16,Gen_Crowd_Istanbul_16,Gen_Crowd_Istanbul_16,props,41.03445697,28.94903881,"N41° 02' 04.05""","E28° 56' 56.54""",351.90,1.0000,1,1,cras,29.035,2.458,72.87,2.458,39.221,-15.075,-0.215,-36.386,34,FSXP_People.bgl +21,Gen_Crowd_Istanbul_17,Gen_Crowd_Istanbul_17,Gen_Crowd_Istanbul_17,props,41.03560877,28.94869164,"N41° 02' 08.19""","E28° 56' 55.29""",317.70,1.0000,1,1,cras,25.095,2.458,126.659,2.458,64.561,-8.969,-0.215,-63.25,37,FSXP_People.bgl +22,Gen_Crowd_Istanbul_18,Gen_Crowd_Istanbul_18,Gen_Crowd_Istanbul_18,props,41.03613968,28.94771104,"N41° 02' 10.10""","E28° 56' 51.76""",295.20,1.0000,1,1,cras,18.374,2.458,84.002,2.458,42.994,-11.851,-0.215,-41.45,30,FSXP_People.bgl +23,Gen_Crowd_Istanbul_19,Gen_Crowd_Istanbul_19,Gen_Crowd_Istanbul_19,props,41.03690454,28.94683605,"N41° 02' 12.86""","E28° 56' 48.61""",332.10,1.0000,1,1,cras,27.259,2.458,162.904,2.458,82.585,-13.629,-0.215,-81.452,34,FSXP_People.bgl +24,MIS_RedBull_Powerstation,MIS_RedBull_Powerstation,MIS_RedBull_Powerstation,building,41.03468193,28.95253608,"N41° 02' 04.85""","E28° 57' 09.13""",206.10,1.2500,1,1,cras,17.088,11.498,12.629,11.498,10.624,-8.544,-0.709,-6.839,57,fsxp_missionobjects.bgl +25,MIS_RedBull_Tower,MIS_RedBull_Tower,MIS_RedBull_Tower,building,41.03446597,28.95312214,"N41° 02' 04.08""","E28° 57' 11.24""",110.50,1.0000,1,1,cras,12.733,34.784,20.997,34.784,12.278,-6.323,-6.704,-9.579,36,fsxp_missionobjects.bgl diff --git a/Missions/RB_SP_Istanbul_Intermediate/RB_SP_Istanbul_Intermediate_POI.csv b/Missions/RB_SP_Istanbul_Intermediate/RB_SP_Istanbul_Intermediate_POI.csv new file mode 100644 index 00000000..27cc7920 --- /dev/null +++ b/Missions/RB_SP_Istanbul_Intermediate/RB_SP_Istanbul_Intermediate_POI.csv @@ -0,0 +1,25 @@ +poi_name,gate,role,latitude,longitude,lat_dms,lon_dms,gate_width_m +POI_Gate01,1,center,41.02856300,28.96406388,"N41° 01' 42.83""","E28° 57' 50.63""",40.90 +Pylon01-L,1,left,41.02860799,28.96382776,"N41° 01' 42.99""","E28° 57' 49.78""",40.90 +Pylon01-R,1,right,41.02851801,28.96430000,"N41° 01' 42.66""","E28° 57' 51.48""",40.90 +POI_Gate02,2,center,41.03038518,28.96035828,"N41° 01' 49.39""","E28° 57' 37.29""",41.12 +Pylon02-L,2,left,41.03028170,28.96015548,"N41° 01' 49.01""","E28° 57' 36.56""",41.12 +Pylon02-R,2,right,41.03048866,28.96056108,"N41° 01' 49.76""","E28° 57' 38.02""",41.12 +POI_Gate03,3,center,41.03214887,28.95828468,"N41° 01' 55.74""","E28° 57' 29.82""",40.57 +Pylon03-L,3,left,41.03197340,28.95821943,"N41° 01' 55.10""","E28° 57' 29.59""",40.57 +Pylon03-R,3,right,41.03232434,28.95834994,"N41° 01' 56.37""","E28° 57' 30.06""",40.57 +POI_Gate04,4,center,41.03315220,28.95453747,"N41° 01' 59.35""","E28° 57' 16.33""",41.54 +Pylon04-L,4,left,41.03297223,28.95447222,"N41° 01' 58.70""","E28° 57' 16.10""",41.54 +Pylon04-R,4,right,41.03333217,28.95460272,"N41° 01' 60.00""","E28° 57' 16.57""",41.54 +POI_Gate05,5,center,41.03344915,28.95168884,"N41° 02' 00.42""","E28° 57' 06.08""",41.03 +Pylon05-L,5,left,41.03336816,28.95190828,"N41° 02' 00.13""","E28° 57' 06.87""",41.03 +Pylon05-R,5,right,41.03353013,28.95146940,"N41° 02' 00.71""","E28° 57' 05.29""",41.03 +POI_Gate06,6,center,41.03131652,28.95392634,"N41° 01' 52.74""","E28° 57' 14.13""",41.54 +Pylon06-L,6,left,41.03149649,28.95399160,"N41° 01' 53.39""","E28° 57' 14.37""",41.54 +Pylon06-R,6,right,41.03113655,28.95386109,"N41° 01' 52.09""","E28° 57' 13.90""",41.54 +POI_Gate07,7,center,41.03016022,28.95989858,"N41° 01' 48.58""","E28° 57' 35.63""",41.31 +Pylon07-L,7,left,41.03026370,28.96010276,"N41° 01' 48.95""","E28° 57' 36.37""",41.31 +Pylon07-R,7,right,41.03005674,28.95969439,"N41° 01' 48.20""","E28° 57' 34.90""",41.31 +POI_Gate08,8,center,41.02695228,28.96085969,"N41° 01' 37.03""","E28° 57' 39.09""",41.54 +Pylon08-L,8,left,41.02713225,28.96092494,"N41° 01' 37.68""","E28° 57' 39.33""",41.54 +Pylon08-R,8,right,41.02677231,28.96079443,"N41° 01' 36.38""","E28° 57' 38.86""",41.54 diff --git a/Missions/RB_SP_Istanbul_Intermediate/config_RB_SP_Istanbul_Intermediate.csv b/Missions/RB_SP_Istanbul_Intermediate/config_RB_SP_Istanbul_Intermediate.csv new file mode 100644 index 00000000..f90276ba --- /dev/null +++ b/Missions/RB_SP_Istanbul_Intermediate/config_RB_SP_Istanbul_Intermediate.csv @@ -0,0 +1,2 @@ +display_name,min_sec,max_sec,flt_rel +伊斯坦布尔之旅(海上),80,300,Racing/RB_SP_Istanbul_Intermediate/RB_SP_Istanbul_Intermediate.FLT diff --git a/Missions/RB_SP_Longleat_Intermediate/RB_SP_Longleat_Intermediate_CRASH.csv b/Missions/RB_SP_Longleat_Intermediate/RB_SP_Longleat_Intermediate_CRASH.csv new file mode 100644 index 00000000..f7ef609e --- /dev/null +++ b/Missions/RB_SP_Longleat_Intermediate/RB_SP_Longleat_Intermediate_CRASH.csv @@ -0,0 +1,818 @@ +id,library_name,resolved_mdln,instance_name,category,latitude,longitude,lat_dms,lon_dms,heading_deg,scale,has_cras,has_volume,volume_source,box_size_x_m,box_size_y_m,box_size_z_m,box_height_m,horiz_radius_m,cras_min_x,cras_min_y,cras_min_z,cras_nnodes,source_bgl +1,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17929062,-2.27213060,"N51° 10' 45.45""","W2° 16' 19.67""",18.00,1.1500,1,1,cras,3.259,4.587,3.117,4.587,2.255,-1.68,-0.007,-1.558,52,fsxp_missionobjects.bgl +2,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17937161,-2.27216950,"N51° 10' 45.74""","W2° 16' 19.81""",333.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +3,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17946159,-2.27220286,"N51° 10' 46.06""","W2° 16' 19.93""",9.00,0.9500,1,1,cras,2.692,3.789,2.575,3.789,1.863,-1.388,-0.006,-1.287,52,fsxp_missionobjects.bgl +4,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17955158,-2.27224728,"N51° 10' 46.39""","W2° 16' 20.09""",108.00,0.9800,1,1,cras,2.777,3.909,2.657,3.909,1.922,-1.432,-0.006,-1.328,52,fsxp_missionobjects.bgl +5,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18023546,-2.27255833,"N51° 10' 48.85""","W2° 16' 21.21""",342.00,0.8500,1,1,cras,2.409,3.39,2.304,3.39,1.667,-1.242,-0.005,-1.152,52,fsxp_missionobjects.bgl +6,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17964156,-2.27228894,"N51° 10' 46.71""","W2° 16' 20.24""",81.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +7,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18033444,-2.27260008,"N51° 10' 49.20""","W2° 16' 21.36""",342.00,0.4500,1,1,cras,1.275,1.795,1.22,1.795,0.882,-0.658,-0.003,-0.61,52,fsxp_missionobjects.bgl +8,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17973155,-2.27233613,"N51° 10' 47.03""","W2° 16' 20.41""",54.00,1.1000,1,1,cras,3.117,4.387,2.982,4.387,2.157,-1.607,-0.007,-1.491,52,fsxp_missionobjects.bgl +9,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18040643,-2.27263335,"N51° 10' 49.46""","W2° 16' 21.48""",324.00,0.5500,1,1,cras,1.559,2.194,1.491,2.194,1.078,-0.804,-0.003,-0.745,52,fsxp_missionobjects.bgl +10,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17981253,-2.27236396,"N51° 10' 47.33""","W2° 16' 20.51""",9.00,1.2500,1,1,cras,3.543,4.985,3.388,4.985,2.451,-1.827,-0.008,-1.694,52,fsxp_missionobjects.bgl +11,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18049641,-2.27267224,"N51° 10' 49.79""","W2° 16' 21.62""",306.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +12,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17989352,-2.27239723,"N51° 10' 47.62""","W2° 16' 20.63""",9.00,0.6500,1,1,cras,1.842,2.592,1.762,2.592,1.275,-0.95,-0.004,-0.881,52,fsxp_missionobjects.bgl +13,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18058640,-2.27271390,"N51° 10' 50.11""","W2° 16' 21.77""",288.00,1.2500,1,1,cras,3.543,4.985,3.388,4.985,2.451,-1.827,-0.008,-1.694,52,fsxp_missionobjects.bgl +14,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17999250,-2.27244174,"N51° 10' 47.97""","W2° 16' 20.79""",9.00,0.7500,1,1,cras,2.126,2.991,2.033,2.991,1.471,-1.096,-0.005,-1.016,52,fsxp_missionobjects.bgl +15,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18067638,-2.27275279,"N51° 10' 50.43""","W2° 16' 21.91""",288.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +16,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18007349,-2.27247778,"N51° 10' 48.26""","W2° 16' 20.92""",324.00,0.9500,1,1,cras,2.692,3.789,2.575,3.789,1.863,-1.388,-0.006,-1.287,52,fsxp_missionobjects.bgl +17,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18076637,-2.27279445,"N51° 10' 50.76""","W2° 16' 22.06""",288.00,1.1000,1,1,cras,3.117,4.387,2.982,4.387,2.157,-1.607,-0.007,-1.491,52,fsxp_missionobjects.bgl +18,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18015447,-2.27251391,"N51° 10' 48.56""","W2° 16' 21.05""",324.00,0.8500,1,1,cras,2.409,3.39,2.304,3.39,1.667,-1.242,-0.005,-1.152,52,fsxp_missionobjects.bgl +19,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18084735,-2.27284449,"N51° 10' 51.05""","W2° 16' 22.24""",288.00,1.2000,1,1,cras,3.401,4.786,3.253,4.786,2.353,-1.753,-0.008,-1.626,52,fsxp_missionobjects.bgl +20,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18091934,-2.27288062,"N51° 10' 51.31""","W2° 16' 22.37""",243.00,1.1400,1,1,cras,3.231,4.547,3.09,4.547,2.235,-1.666,-0.007,-1.545,52,fsxp_missionobjects.bgl +21,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18100032,-2.27291951,"N51° 10' 51.60""","W2° 16' 22.51""",243.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +22,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18106331,-2.27295564,"N51° 10' 51.83""","W2° 16' 22.64""",216.00,0.7500,1,1,cras,2.126,2.991,2.033,2.991,1.471,-1.096,-0.005,-1.016,52,fsxp_missionobjects.bgl +23,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18116230,-2.27300559,"N51° 10' 52.18""","W2° 16' 22.82""",216.00,0.9000,1,1,cras,2.551,3.589,2.44,3.589,1.765,-1.315,-0.006,-1.22,52,fsxp_missionobjects.bgl +24,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17752693,-2.27127783,"N51° 10' 39.10""","W2° 16' 16.60""",315.00,1.2500,1,1,cras,3.543,4.985,3.388,4.985,2.451,-1.827,-0.008,-1.694,52,fsxp_missionobjects.bgl +25,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18120729,-2.27303057,"N51° 10' 52.35""","W2° 16' 22.91""",198.00,0.6000,1,1,cras,1.7,2.393,1.626,2.393,1.177,-0.877,-0.004,-0.813,52,fsxp_missionobjects.bgl +26,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17760792,-2.27132225,"N51° 10' 39.39""","W2° 16' 16.76""",234.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +27,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18126128,-2.27304725,"N51° 10' 52.54""","W2° 16' 22.97""",198.00,0.4500,1,1,cras,1.275,1.795,1.22,1.795,0.882,-0.658,-0.003,-0.61,52,fsxp_missionobjects.bgl +28,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17768890,-2.27136391,"N51° 10' 39.68""","W2° 16' 16.91""",207.00,1.2500,1,1,cras,3.543,4.985,3.388,4.985,2.451,-1.827,-0.008,-1.694,52,fsxp_missionobjects.bgl +29,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18134227,-2.27307508,"N51° 10' 52.83""","W2° 16' 23.07""",180.00,0.7000,1,1,cras,1.984,2.792,1.898,2.792,1.373,-1.023,-0.004,-0.949,52,fsxp_missionobjects.bgl +30,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17776989,-2.27139727,"N51° 10' 39.97""","W2° 16' 17.03""",207.00,1.1500,1,1,cras,3.259,4.587,3.117,4.587,2.255,-1.68,-0.007,-1.558,52,fsxp_missionobjects.bgl +31,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18142325,-2.27310835,"N51° 10' 53.12""","W2° 16' 23.19""",126.00,0.3500,1,1,cras,0.992,1.396,0.949,1.396,0.686,-0.511,-0.002,-0.474,52,fsxp_missionobjects.bgl +32,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17785987,-2.27144723,"N51° 10' 40.30""","W2° 16' 17.21""",171.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +33,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18151324,-2.27315278,"N51° 10' 53.45""","W2° 16' 23.35""",126.00,0.8000,1,1,cras,2.267,3.191,2.169,3.191,1.569,-1.169,-0.005,-1.084,52,fsxp_missionobjects.bgl +34,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17794986,-2.27148335,"N51° 10' 40.62""","W2° 16' 17.34""",171.00,1.1500,1,1,cras,3.259,4.587,3.117,4.587,2.255,-1.68,-0.007,-1.558,52,fsxp_missionobjects.bgl +35,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18161222,-2.27319729,"N51° 10' 53.80""","W2° 16' 23.51""",90.00,0.5500,1,1,cras,1.559,2.194,1.491,2.194,1.078,-0.804,-0.003,-0.745,52,fsxp_missionobjects.bgl +36,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17798585,-2.27151671,"N51° 10' 40.75""","W2° 16' 17.46""",171.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +37,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18168421,-2.27323618,"N51° 10' 54.06""","W2° 16' 23.65""",90.00,0.7500,1,1,cras,2.126,2.991,2.033,2.991,1.471,-1.096,-0.005,-1.016,52,fsxp_missionobjects.bgl +38,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17803084,-2.27152778,"N51° 10' 40.91""","W2° 16' 17.50""",126.00,0.3500,1,1,cras,0.992,1.396,0.949,1.396,0.686,-0.511,-0.002,-0.474,52,fsxp_missionobjects.bgl +39,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18176519,-2.27327230,"N51° 10' 54.35""","W2° 16' 23.78""",63.00,0.6000,1,1,cras,1.7,2.393,1.626,2.393,1.177,-0.877,-0.004,-0.813,52,fsxp_missionobjects.bgl +40,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17812083,-2.27156667,"N51° 10' 41.23""","W2° 16' 17.64""",126.00,0.8800,1,1,cras,2.494,3.51,2.385,3.51,1.726,-1.286,-0.006,-1.193,52,fsxp_missionobjects.bgl +41,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18183718,-2.27330835,"N51° 10' 54.61""","W2° 16' 23.91""",99.00,0.6500,1,1,cras,1.842,2.592,1.762,2.592,1.275,-0.95,-0.004,-0.881,52,fsxp_missionobjects.bgl +42,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17820181,-2.27160003,"N51° 10' 41.53""","W2° 16' 17.76""",81.00,1.1500,1,1,cras,3.259,4.587,3.117,4.587,2.255,-1.68,-0.007,-1.558,52,fsxp_missionobjects.bgl +43,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18193616,-2.27335000,"N51° 10' 54.97""","W2° 16' 24.06""",135.00,0.7000,1,1,cras,1.984,2.792,1.898,2.792,1.373,-1.023,-0.004,-0.949,52,fsxp_missionobjects.bgl +44,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17828280,-2.27163339,"N51° 10' 41.82""","W2° 16' 17.88""",81.00,0.9500,1,1,cras,2.692,3.789,2.575,3.789,1.863,-1.388,-0.006,-1.287,52,fsxp_missionobjects.bgl +45,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18202615,-2.27338890,"N51° 10' 55.29""","W2° 16' 24.20""",135.00,0.8500,1,1,cras,2.409,3.39,2.304,3.39,1.667,-1.242,-0.005,-1.152,52,fsxp_missionobjects.bgl +46,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17836379,-2.27168058,"N51° 10' 42.11""","W2° 16' 18.05""",81.00,0.8000,1,1,cras,2.267,3.191,2.169,3.191,1.569,-1.169,-0.005,-1.084,52,fsxp_missionobjects.bgl +47,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18206214,-2.27344170,"N51° 10' 55.42""","W2° 16' 24.39""",99.00,0.9000,1,1,cras,2.551,3.589,2.44,3.589,1.765,-1.315,-0.006,-1.22,52,fsxp_missionobjects.bgl +48,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17844477,-2.27172785,"N51° 10' 42.40""","W2° 16' 18.22""",81.00,1.1000,1,1,cras,3.117,4.387,2.982,4.387,2.157,-1.607,-0.007,-1.491,52,fsxp_missionobjects.bgl +49,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18212513,-2.27343340,"N51° 10' 55.65""","W2° 16' 24.36""",54.00,0.7000,1,1,cras,1.984,2.792,1.898,2.792,1.373,-1.023,-0.004,-0.949,52,fsxp_missionobjects.bgl +50,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17848976,-2.27175560,"N51° 10' 42.56""","W2° 16' 18.32""",81.00,1.1000,1,1,cras,3.117,4.387,2.982,4.387,2.157,-1.607,-0.007,-1.491,52,fsxp_missionobjects.bgl +51,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18219712,-2.27346391,"N51° 10' 55.91""","W2° 16' 24.47""",90.00,0.6500,1,1,cras,1.842,2.592,1.762,2.592,1.275,-0.95,-0.004,-0.881,52,fsxp_missionobjects.bgl +52,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17855275,-2.27176675,"N51° 10' 42.79""","W2° 16' 18.36""",27.00,0.9500,1,1,cras,2.692,3.789,2.575,3.789,1.863,-1.388,-0.006,-1.287,52,fsxp_missionobjects.bgl +53,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18226910,-2.27351119,"N51° 10' 56.17""","W2° 16' 24.64""",90.00,0.8500,1,1,cras,2.409,3.39,2.304,3.39,1.667,-1.242,-0.005,-1.152,52,fsxp_missionobjects.bgl +54,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17871472,-2.27185006,"N51° 10' 43.37""","W2° 16' 18.66""",333.00,0.9500,1,1,cras,2.692,3.789,2.575,3.789,1.863,-1.388,-0.006,-1.287,52,fsxp_missionobjects.bgl +55,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18235909,-2.27354723,"N51° 10' 56.49""","W2° 16' 24.77""",36.00,0.9000,1,1,cras,2.551,3.589,2.44,3.589,1.765,-1.315,-0.006,-1.22,52,fsxp_missionobjects.bgl +56,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17879571,-2.27188057,"N51° 10' 43.66""","W2° 16' 18.77""",333.00,0.7000,1,1,cras,1.984,2.792,1.898,2.792,1.373,-1.023,-0.004,-0.949,52,fsxp_missionobjects.bgl +57,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18244907,-2.27358897,"N51° 10' 56.82""","W2° 16' 24.92""",351.00,0.9500,1,1,cras,2.692,3.789,2.575,3.789,1.863,-1.388,-0.006,-1.287,52,fsxp_missionobjects.bgl +58,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17887670,-2.27192508,"N51° 10' 43.96""","W2° 16' 18.93""",333.00,0.5000,1,1,cras,1.417,1.994,1.355,1.994,0.98,-0.731,-0.003,-0.678,52,fsxp_missionobjects.bgl +59,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18253006,-2.27362225,"N51° 10' 57.11""","W2° 16' 25.04""",351.00,0.8000,1,1,cras,2.267,3.191,2.169,3.191,1.569,-1.169,-0.005,-1.084,52,fsxp_missionobjects.bgl +60,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17894868,-2.27196674,"N51° 10' 44.22""","W2° 16' 19.08""",288.00,0.8800,1,1,cras,2.494,3.51,2.385,3.51,1.726,-1.286,-0.006,-1.193,52,fsxp_missionobjects.bgl +61,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18262004,-2.27366667,"N51° 10' 57.43""","W2° 16' 25.20""",18.00,0.9000,1,1,cras,2.551,3.589,2.44,3.589,1.765,-1.315,-0.006,-1.22,52,fsxp_missionobjects.bgl +62,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17902967,-2.27201116,"N51° 10' 44.51""","W2° 16' 19.24""",288.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +63,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17877771,-2.27085840,"N51° 10' 43.60""","W2° 16' 15.09""",261.00,0.7000,1,1,cras,1.984,2.792,1.898,2.792,1.373,-1.023,-0.004,-0.949,52,fsxp_missionobjects.bgl +64,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17818382,-2.27057224,"N51° 10' 41.46""","W2° 16' 14.06""",351.00,0.8000,1,1,cras,2.267,3.191,2.169,3.191,1.569,-1.169,-0.005,-1.084,52,fsxp_missionobjects.bgl +65,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18271902,-2.27371395,"N51° 10' 57.79""","W2° 16' 25.37""",18.00,1.1500,1,1,cras,3.259,4.587,3.117,4.587,2.255,-1.68,-0.007,-1.558,52,fsxp_missionobjects.bgl +66,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17911965,-2.27205282,"N51° 10' 44.83""","W2° 16' 19.39""",288.00,1.1300,1,1,cras,3.203,4.507,3.063,4.507,2.216,-1.651,-0.007,-1.531,52,fsxp_missionobjects.bgl +67,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17884970,-2.27088891,"N51° 10' 43.86""","W2° 16' 15.20""",270.00,0.7000,1,1,cras,1.984,2.792,1.898,2.792,1.373,-1.023,-0.004,-0.949,52,fsxp_missionobjects.bgl +68,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17833679,-2.27064726,"N51° 10' 42.01""","W2° 16' 14.33""",351.00,0.8000,1,1,cras,2.267,3.191,2.169,3.191,1.569,-1.169,-0.005,-1.084,52,fsxp_missionobjects.bgl +69,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18279101,-2.27374722,"N51° 10' 58.05""","W2° 16' 25.49""",18.00,0.9500,1,1,cras,2.692,3.789,2.575,3.789,1.863,-1.388,-0.006,-1.287,52,fsxp_missionobjects.bgl +70,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17914665,-2.27208056,"N51° 10' 44.93""","W2° 16' 19.49""",288.00,1.2500,1,1,cras,3.543,4.985,3.388,4.985,2.451,-1.827,-0.008,-1.694,52,fsxp_missionobjects.bgl +71,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18166621,-2.27220839,"N51° 10' 54.00""","W2° 16' 19.95""",225.00,0.9500,1,1,cras,2.692,3.789,2.575,3.789,1.863,-1.388,-0.006,-1.287,52,fsxp_missionobjects.bgl +72,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17894868,-2.27092504,"N51° 10' 44.22""","W2° 16' 15.33""",315.00,0.7000,1,1,cras,1.984,2.792,1.898,2.792,1.373,-1.023,-0.004,-0.949,52,fsxp_missionobjects.bgl +73,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17851676,-2.27073058,"N51° 10' 42.66""","W2° 16' 14.63""",351.00,0.6500,1,1,cras,1.842,2.592,1.762,2.592,1.275,-0.95,-0.004,-0.881,52,fsxp_missionobjects.bgl +74,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18321394,-2.27393615,"N51° 10' 59.57""","W2° 16' 26.17""",252.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +75,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18288100,-2.27379450,"N51° 10' 58.37""","W2° 16' 25.66""",18.00,0.8800,1,1,cras,2.494,3.51,2.385,3.51,1.726,-1.286,-0.006,-1.193,52,fsxp_missionobjects.bgl +76,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17920964,-2.27209448,"N51° 10' 45.15""","W2° 16' 19.54""",288.00,0.9500,1,1,cras,2.692,3.789,2.575,3.789,1.863,-1.388,-0.006,-1.287,52,fsxp_missionobjects.bgl +77,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18175619,-2.27224728,"N51° 10' 54.32""","W2° 16' 20.09""",171.00,0.9000,1,1,cras,2.551,3.589,2.44,3.589,1.765,-1.315,-0.006,-1.22,52,fsxp_missionobjects.bgl +78,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17902967,-2.27098061,"N51° 10' 44.51""","W2° 16' 15.53""",270.00,0.7500,1,1,cras,2.126,2.991,2.033,2.991,1.471,-1.096,-0.005,-1.016,52,fsxp_missionobjects.bgl +79,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17858875,-2.27076947,"N51° 10' 42.92""","W2° 16' 14.77""",351.00,0.8500,1,1,cras,2.409,3.39,2.304,3.39,1.667,-1.242,-0.005,-1.152,52,fsxp_missionobjects.bgl +80,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18329492,-2.27398057,"N51° 10' 59.86""","W2° 16' 26.33""",252.00,0.8000,1,1,cras,2.267,3.191,2.169,3.191,1.569,-1.169,-0.005,-1.084,52,fsxp_missionobjects.bgl +81,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18295298,-2.27382786,"N51° 10' 58.63""","W2° 16' 25.78""",18.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +82,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18183718,-2.27229171,"N51° 10' 54.61""","W2° 16' 20.25""",117.00,0.9500,1,1,cras,2.692,3.789,2.575,3.789,1.863,-1.388,-0.006,-1.287,52,fsxp_missionobjects.bgl +83,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17911066,-2.27101950,"N51° 10' 44.80""","W2° 16' 15.67""",315.00,0.8500,1,1,cras,2.409,3.39,2.304,3.39,1.667,-1.242,-0.005,-1.152,52,fsxp_missionobjects.bgl +84,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18338491,-2.27403338,"N51° 11' 00.19""","W2° 16' 26.52""",252.00,0.8000,1,1,cras,2.267,3.191,2.169,3.191,1.569,-1.169,-0.005,-1.084,52,fsxp_missionobjects.bgl +85,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18304297,-2.27386675,"N51° 10' 58.95""","W2° 16' 25.92""",351.00,0.9500,1,1,cras,2.692,3.789,2.575,3.789,1.863,-1.388,-0.006,-1.287,52,fsxp_missionobjects.bgl +86,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18192716,-2.27233060,"N51° 10' 54.94""","W2° 16' 20.39""",63.00,1.1000,1,1,cras,3.117,4.387,2.982,4.387,2.157,-1.607,-0.007,-1.491,52,fsxp_missionobjects.bgl +87,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17918264,-2.27105001,"N51° 10' 45.06""","W2° 16' 15.78""",279.00,0.9500,1,1,cras,2.692,3.789,2.575,3.789,1.863,-1.388,-0.006,-1.287,52,fsxp_missionobjects.bgl +88,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18346589,-2.27406389,"N51° 11' 00.48""","W2° 16' 26.63""",252.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +89,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18312395,-2.27390556,"N51° 10' 59.25""","W2° 16' 26.06""",315.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +90,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18217912,-2.27245281,"N51° 10' 55.84""","W2° 16' 20.83""",27.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +91,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18199915,-2.27236949,"N51° 10' 55.20""","W2° 16' 20.53""",63.00,0.9000,1,1,cras,2.551,3.589,2.44,3.589,1.765,-1.315,-0.006,-1.22,52,fsxp_missionobjects.bgl +92,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17927263,-2.27109728,"N51° 10' 45.38""","W2° 16' 15.95""",252.00,0.9000,1,1,cras,2.551,3.589,2.44,3.589,1.765,-1.315,-0.006,-1.22,52,fsxp_missionobjects.bgl +93,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18355588,-2.27410002,"N51° 11' 00.80""","W2° 16' 26.76""",288.00,1.1000,1,1,cras,3.117,4.387,2.982,4.387,2.157,-1.607,-0.007,-1.491,52,fsxp_missionobjects.bgl +94,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18226910,-2.27248893,"N51° 10' 56.17""","W2° 16' 20.96""",9.00,1.2500,1,1,cras,3.543,4.985,3.388,4.985,2.451,-1.827,-0.008,-1.694,52,fsxp_missionobjects.bgl +95,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18208913,-2.27241668,"N51° 10' 55.52""","W2° 16' 20.70""",9.00,0.8000,1,1,cras,2.267,3.191,2.169,3.191,1.569,-1.169,-0.005,-1.084,52,fsxp_missionobjects.bgl +96,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17934461,-2.27114171,"N51° 10' 45.64""","W2° 16' 16.11""",225.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +97,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18364586,-2.27414452,"N51° 11' 01.13""","W2° 16' 26.92""",288.00,0.8000,1,1,cras,2.267,3.191,2.169,3.191,1.569,-1.169,-0.005,-1.084,52,fsxp_missionobjects.bgl +98,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18242208,-2.27256672,"N51° 10' 56.72""","W2° 16' 21.24""",9.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +99,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17952458,-2.27121673,"N51° 10' 46.29""","W2° 16' 16.38""",225.00,1.1000,1,1,cras,3.117,4.387,2.982,4.387,2.157,-1.607,-0.007,-1.491,52,fsxp_missionobjects.bgl +100,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18371785,-2.27417780,"N51° 11' 01.38""","W2° 16' 27.04""",288.00,0.8800,1,1,cras,2.494,3.51,2.385,3.51,1.726,-1.286,-0.006,-1.193,52,fsxp_missionobjects.bgl +101,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18251206,-2.27260837,"N51° 10' 57.04""","W2° 16' 21.39""",342.00,0.8500,1,1,cras,2.409,3.39,2.304,3.39,1.667,-1.242,-0.005,-1.152,52,fsxp_missionobjects.bgl +102,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17960557,-2.27126392,"N51° 10' 46.58""","W2° 16' 16.55""",225.00,0.9000,1,1,cras,2.551,3.589,2.44,3.589,1.765,-1.315,-0.006,-1.22,52,fsxp_missionobjects.bgl +103,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18381683,-2.27422507,"N51° 11' 01.74""","W2° 16' 27.21""",315.00,0.9000,1,1,cras,2.551,3.589,2.44,3.589,1.765,-1.315,-0.006,-1.22,52,fsxp_missionobjects.bgl +104,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18260204,-2.27264727,"N51° 10' 57.37""","W2° 16' 21.53""",288.00,0.7500,1,1,cras,2.126,2.991,2.033,2.991,1.471,-1.096,-0.005,-1.016,52,fsxp_missionobjects.bgl +105,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17976754,-2.27134170,"N51° 10' 47.16""","W2° 16' 16.83""",225.00,0.9500,1,1,cras,2.692,3.789,2.575,3.789,1.863,-1.388,-0.006,-1.287,52,fsxp_missionobjects.bgl +106,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18388882,-2.27425282,"N51° 11' 02.00""","W2° 16' 27.31""",288.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +107,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18277301,-2.27272228,"N51° 10' 57.98""","W2° 16' 21.80""",288.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +108,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17984853,-2.27137783,"N51° 10' 47.45""","W2° 16' 16.96""",189.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +109,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17681606,-2.26993061,"N51° 10' 36.54""","W2° 16' 11.75""",288.00,0.2500,1,1,cras,0.709,0.997,0.678,0.997,0.49,-0.365,-0.002,-0.339,52,fsxp_missionobjects.bgl +110,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18286300,-2.27276947,"N51° 10' 58.31""","W2° 16' 21.97""",288.00,0.8500,1,1,cras,2.409,3.39,2.304,3.39,1.667,-1.242,-0.005,-1.152,52,fsxp_missionobjects.bgl +111,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17993851,-2.27141948,"N51° 10' 47.78""","W2° 16' 17.11""",252.00,1.1500,1,1,cras,3.259,4.587,3.117,4.587,2.255,-1.68,-0.007,-1.558,52,fsxp_missionobjects.bgl +112,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17698703,-2.27001392,"N51° 10' 37.15""","W2° 16' 12.05""",288.00,0.2500,1,1,cras,0.709,0.997,0.678,0.997,0.49,-0.365,-0.002,-0.339,52,fsxp_missionobjects.bgl +113,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17690604,-2.26998056,"N51° 10' 36.86""","W2° 16' 11.93""",288.00,0.4000,1,1,cras,1.134,1.595,1.084,1.595,0.784,-0.584,-0.003,-0.542,52,fsxp_missionobjects.bgl +114,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18294398,-2.27280283,"N51° 10' 58.60""","W2° 16' 22.09""",288.00,0.7500,1,1,cras,2.126,2.991,2.033,2.991,1.471,-1.096,-0.005,-1.016,52,fsxp_missionobjects.bgl +115,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18007349,-2.27145284,"N51° 10' 48.26""","W2° 16' 17.23""",252.00,1.1500,1,1,cras,3.259,4.587,3.117,4.587,2.255,-1.68,-0.007,-1.558,52,fsxp_missionobjects.bgl +116,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17706801,-2.27007503,"N51° 10' 37.44""","W2° 16' 12.27""",288.00,0.3000,1,1,cras,0.85,1.196,0.813,1.196,0.588,-0.438,-0.002,-0.407,52,fsxp_missionobjects.bgl +117,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18302497,-2.27284173,"N51° 10' 58.89""","W2° 16' 22.23""",234.00,0.7500,1,1,cras,2.126,2.991,2.033,2.991,1.471,-1.096,-0.005,-1.016,52,fsxp_missionobjects.bgl +118,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18010048,-2.27148612,"N51° 10' 48.36""","W2° 16' 17.35""",252.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +119,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17715800,-2.27009171,"N51° 10' 37.77""","W2° 16' 12.33""",288.00,0.4500,1,1,cras,1.275,1.795,1.22,1.795,0.882,-0.658,-0.003,-0.61,52,fsxp_missionobjects.bgl +120,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01, Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18065838,-2.26563890,"N51° 10' 50.37""","W2° 15' 56.30""",27.90,0.7500,1,1,cras,17.344,25.189,16.629,25.189,12.014,-8.649,-2.37,-8.314,7566,fsxp_missionobjects.bgl +121,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01, Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18055040,-2.26565282,"N51° 10' 49.98""","W2° 15' 56.35""",288.90,0.8500,1,1,cras,19.657,28.547,18.846,28.547,13.616,-9.803,-2.686,-9.423,7566,fsxp_missionobjects.bgl +122,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18312395,-2.27288892,"N51° 10' 59.25""","W2° 16' 22.40""",234.00,1.7000,1,1,cras,4.818,6.78,4.608,6.78,3.333,-2.484,-0.011,-2.304,52,fsxp_missionobjects.bgl +123,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18015447,-2.27150003,"N51° 10' 48.56""","W2° 16' 17.40""",252.00,0.9500,1,1,cras,2.692,3.789,2.575,3.789,1.863,-1.388,-0.006,-1.287,52,fsxp_missionobjects.bgl +124,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,py - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17725698,-2.27013060,"N51° 10' 38.13""","W2° 16' 12.47""",279.00,0.3500,1,1,cras,0.992,1.396,0.949,1.396,0.686,-0.511,-0.002,-0.474,52,fsxp_missionobjects.bgl +125,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18044242,-2.26555282,"N51° 10' 49.59""","W2° 15' 55.99""",333.90,1.0000,1,1,cras,23.126,33.585,22.172,33.585,16.019,-11.533,-3.16,-11.086,7566,fsxp_missionobjects.bgl +126,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18329492,-2.27295564,"N51° 10' 59.86""","W2° 16' 22.64""",189.00,1.5000,1,1,cras,4.251,5.982,4.066,5.982,2.941,-2.192,-0.01,-2.033,52,fsxp_missionobjects.bgl +127,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18028045,-2.27157505,"N51° 10' 49.01""","W2° 16' 17.67""",252.00,0.8000,1,1,cras,2.267,3.191,2.169,3.191,1.569,-1.169,-0.005,-1.084,52,fsxp_missionobjects.bgl +128,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17733797,-2.27016672,"N51° 10' 38.42""","W2° 16' 12.60""",288.00,0.5000,1,1,cras,1.417,1.994,1.355,1.994,0.98,-0.731,-0.003,-0.678,52,fsxp_missionobjects.bgl +129,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18482466,-2.27331396,"N51° 11' 05.37""","W2° 16' 23.93""",324.90,0.3500,1,1,cras,8.094,11.755,7.76,11.755,5.607,-4.036,-1.106,-3.88,7566,fsxp_missionobjects.bgl +130,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18414078,-2.27457502,"N51° 11' 02.91""","W2° 16' 28.47""",306.90,0.6500,1,1,cras,15.032,21.83,14.412,21.83,10.412,-7.496,-2.054,-7.206,7566,fsxp_missionobjects.bgl +131,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18208014,-2.27359174,"N51° 10' 55.49""","W2° 16' 24.93""",315.90,0.5500,1,1,cras,12.719,18.472,12.194,18.472,8.81,-6.343,-1.738,-6.097,7566,fsxp_missionobjects.bgl +132,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18531957,-2.25900002,"N51° 11' 07.15""","W2° 15' 32.40""",198.00,0.3500,1,1,cras,6.161,8.023,5.559,8.023,4.149,-3.07,-0.89,-2.774,1787,fsxp_missionobjects.bgl +133,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18338491,-2.27300559,"N51° 11' 00.19""","W2° 16' 22.82""",189.00,1.2000,1,1,cras,3.401,4.786,3.253,4.786,2.353,-1.753,-0.008,-1.626,52,fsxp_missionobjects.bgl +134,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18037044,-2.27160841,"N51° 10' 49.33""","W2° 16' 17.79""",234.00,0.9000,1,1,cras,2.551,3.589,2.44,3.589,1.765,-1.315,-0.006,-1.22,52,fsxp_missionobjects.bgl +135,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17741895,-2.27020562,"N51° 10' 38.71""","W2° 16' 12.74""",288.00,0.9000,1,1,cras,2.551,3.589,2.44,3.589,1.765,-1.315,-0.006,-1.22,52,fsxp_missionobjects.bgl +136,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18415877,-2.27314448,"N51° 11' 02.97""","W2° 16' 23.32""",279.90,0.3500,1,1,cras,8.094,11.755,7.76,11.755,5.607,-4.036,-1.106,-3.88,7566,fsxp_missionobjects.bgl +137,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18432074,-2.27323056,"N51° 11' 03.55""","W2° 16' 23.63""",279.90,0.3000,1,1,cras,6.938,10.075,6.652,10.075,4.806,-3.46,-0.948,-3.326,7566,fsxp_missionobjects.bgl +138,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18267403,-2.27387228,"N51° 10' 57.63""","W2° 16' 25.94""",315.90,0.7000,1,1,cras,16.188,23.509,15.52,23.509,11.213,-8.073,-2.212,-7.76,7566,fsxp_missionobjects.bgl +139,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.17891269,-2.26548057,"N51° 10' 44.09""","W2° 15' 55.73""",117.00,1.2500,1,1,cras,28.907,41.981,27.715,41.981,20.023,-14.416,-3.951,-13.857,7566,fsxp_missionobjects.bgl +140,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18526558,-2.25915835,"N51° 11' 06.96""","W2° 15' 32.97""",198.00,0.3500,1,1,cras,6.161,8.023,5.559,8.023,4.149,-3.07,-0.89,-2.774,1787,fsxp_missionobjects.bgl +141,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18346589,-2.26000836,"N51° 11' 00.48""","W2° 15' 36.03""",90.00,0.9000,0,1,bbox,7.052,3.379,7.024,3.379,4.976,-3.979,-0.083,-3.574,0,vegetation.bgl +142,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18345690,-2.27303619,"N51° 11' 00.44""","W2° 16' 22.93""",117.00,1.1500,1,1,cras,3.259,4.587,3.117,4.587,2.255,-1.68,-0.007,-1.558,52,fsxp_missionobjects.bgl +143,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18046942,-2.27165560,"N51° 10' 49.69""","W2° 16' 17.96""",234.00,0.8500,1,1,cras,2.409,3.39,2.304,3.39,1.667,-1.242,-0.005,-1.152,52,fsxp_missionobjects.bgl +144,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17750894,-2.27025004,"N51° 10' 39.03""","W2° 16' 12.90""",288.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +145,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18365486,-2.27292228,"N51° 11' 01.16""","W2° 16' 22.52""",279.90,0.4300,1,1,cras,9.944,14.442,9.534,14.442,6.888,-4.959,-1.359,-4.767,7566,fsxp_missionobjects.bgl +146,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18396081,-2.27449170,"N51° 11' 02.26""","W2° 16' 28.17""",315.90,0.5000,1,1,cras,11.563,16.792,11.086,16.792,8.009,-5.766,-1.58,-5.543,7566,fsxp_missionobjects.bgl +147,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18180118,-2.27346115,"N51° 10' 54.48""","W2° 16' 24.46""",351.90,0.5000,1,1,cras,11.563,16.792,11.086,16.792,8.009,-5.766,-1.58,-5.543,7566,fsxp_missionobjects.bgl +148,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18131527,-2.27322226,"N51° 10' 52.73""","W2° 16' 23.60""",9.90,0.5000,1,1,cras,11.563,16.792,11.086,16.792,8.009,-5.766,-1.58,-5.543,7566,fsxp_missionobjects.bgl +149,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.17993851,-2.26858615,"N51° 10' 47.78""","W2° 16' 06.91""",108.00,0.8500,1,1,cras,19.657,28.547,18.846,28.547,13.616,-9.803,-2.686,-9.423,7566,fsxp_missionobjects.bgl +150,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.17904767,-2.26577503,"N51° 10' 44.57""","W2° 15' 56.79""",72.00,1.0000,1,1,cras,23.126,33.585,22.172,33.585,16.019,-11.533,-3.16,-11.086,7566,fsxp_missionobjects.bgl +151,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18522059,-2.25905559,"N51° 11' 06.79""","W2° 15' 32.60""",198.00,0.5000,1,1,cras,8.801,11.462,7.942,11.462,5.927,-4.385,-1.271,-3.963,1787,fsxp_missionobjects.bgl +152,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.17986652,-2.27255833,"N51° 10' 47.52""","W2° 16' 21.21""",126.00,0.7500,1,1,cras,13.906,18.845,12.982,18.845,9.512,-6.855,-2.368,-6.491,1828,fsxp_missionobjects.bgl +153,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - NEW - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18136926,-2.25702507,"N51° 10' 52.93""","W2° 15' 25.29""",351.00,1.5000,0,1,bbox,11.753,5.632,11.706,5.632,8.294,-6.632,-0.138,-5.956,0,vegetation.bgl +154,veg_tb_bush_Bayberry_3_5ft,veg_tb_bush_Bayberry_3_5ft,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_3_5ft,bush,51.17763491,-2.26105006,"N51° 10' 39.49""","W2° 15' 39.78""",342.00,1.0000,0,1,bbox,4.932,1.589,4.703,1.589,3.407,-2.688,-0.055,-2.276,0,vegetation.bgl +155,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18365486,-2.25985003,"N51° 11' 01.16""","W2° 15' 35.46""",18.00,1.0000,0,1,bbox,7.835,3.755,7.804,3.755,5.529,-4.421,-0.092,-3.971,0,vegetation.bgl +156,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18347489,-2.25993058,"N51° 11' 00.51""","W2° 15' 35.75""",90.00,1.0000,0,1,bbox,7.835,3.755,7.804,3.755,5.529,-4.421,-0.092,-3.971,0,vegetation.bgl +157,Longleat_Tree_BlackPoplar_02,Longleat_Tree_BlackPoplar_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_02,tree,51.18115330,-2.26376396,"N51° 10' 52.15""","W2° 15' 49.55""",342.00,0.2000,1,1,cras,3.989,4.263,4.678,4.263,3.074,-1.994,-0.26,-2.339,1715,fsxp_missionobjects.bgl +158,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18355588,-2.27308061,"N51° 11' 00.80""","W2° 16' 23.09""",117.00,0.9000,1,1,cras,2.551,3.589,2.44,3.589,1.765,-1.315,-0.006,-1.22,52,fsxp_missionobjects.bgl +159,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18055040,-2.27169726,"N51° 10' 49.98""","W2° 16' 18.11""",234.00,0.9500,1,1,cras,2.692,3.789,2.575,3.789,1.863,-1.388,-0.006,-1.287,52,fsxp_missionobjects.bgl +160,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17747294,-2.27021115,"N51° 10' 38.90""","W2° 16' 12.76""",315.00,1.2000,1,1,cras,3.401,4.786,3.253,4.786,2.353,-1.753,-0.008,-1.626,52,fsxp_missionobjects.bgl +161,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18227810,-2.27226673,"N51° 10' 56.20""","W2° 16' 20.16""",27.90,0.5000,1,1,cras,11.563,16.792,11.086,16.792,8.009,-5.766,-1.58,-5.543,7566,fsxp_missionobjects.bgl +162,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18269203,-2.27247502,"N51° 10' 57.69""","W2° 16' 20.91""",351.90,0.6500,1,1,cras,15.032,21.83,14.412,21.83,10.412,-7.496,-2.054,-7.206,7566,fsxp_missionobjects.bgl +163,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18287200,-2.27255280,"N51° 10' 58.34""","W2° 16' 21.19""",342.90,0.4000,1,1,cras,9.25,13.434,8.869,13.434,6.407,-4.613,-1.264,-4.434,7566,fsxp_missionobjects.bgl +164,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18001050,-2.26833612,"N51° 10' 48.04""","W2° 16' 06.01""",72.00,1.2500,1,1,cras,28.907,41.981,27.715,41.981,20.023,-14.416,-3.951,-13.857,7566,fsxp_missionobjects.bgl +165,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18514860,-2.25887781,"N51° 11' 06.53""","W2° 15' 31.96""",180.00,0.4500,1,1,cras,7.921,10.316,7.148,10.316,5.335,-3.947,-1.144,-3.567,1787,fsxp_missionobjects.bgl +166,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18257505,-2.27381671,"N51° 10' 57.27""","W2° 16' 25.74""",180.00,0.7500,1,1,cras,13.202,17.193,11.913,17.193,8.891,-6.578,-1.906,-5.945,1787,fsxp_missionobjects.bgl +167,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18121629,-2.27317507,"N51° 10' 52.38""","W2° 16' 23.43""",180.00,0.7500,1,1,cras,13.202,17.193,11.913,17.193,8.891,-6.578,-1.906,-5.945,1787,fsxp_missionobjects.bgl +168,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.18138726,-2.27326669,"N51° 10' 52.99""","W2° 16' 23.76""",126.00,0.5500,1,1,cras,10.198,13.819,9.52,13.819,6.975,-5.027,-1.736,-4.76,1828,fsxp_missionobjects.bgl +169,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.17791387,-2.27166675,"N51° 10' 40.49""","W2° 16' 18.00""",306.00,0.7500,1,1,cras,13.906,18.845,12.982,18.845,9.512,-6.855,-2.368,-6.491,1828,fsxp_missionobjects.bgl +170,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.17800385,-2.27169449,"N51° 10' 40.81""","W2° 16' 18.10""",270.00,0.6000,1,1,cras,11.125,15.076,10.386,15.076,7.61,-5.484,-1.894,-5.193,1828,fsxp_missionobjects.bgl +171,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.18454570,-2.27493896,"N51° 11' 04.36""","W2° 16' 29.78""",63.00,0.5000,1,1,cras,9.271,12.563,8.655,12.563,6.341,-4.57,-1.578,-4.327,1828,fsxp_missionobjects.bgl +172,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17962357,-2.26763061,"N51° 10' 46.64""","W2° 16' 03.47""",243.00,1.0000,1,1,cras,28.603,31.371,27.571,31.371,19.864,-14.352,-1.956,-13.768,2035,fsxp_missionobjects.bgl +173,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18098233,-2.25716396,"N51° 10' 51.54""","W2° 15' 25.79""",135.00,1.0000,1,1,cras,28.603,31.371,27.571,31.371,19.864,-14.352,-1.956,-13.768,2035,fsxp_missionobjects.bgl +174,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18043342,-2.25751667,"N51° 10' 49.56""","W2° 15' 27.06""",135.00,0.8000,1,1,cras,22.882,25.096,22.057,25.096,15.891,-11.482,-1.565,-11.015,2035,fsxp_missionobjects.bgl +175,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17997450,-2.26344729,"N51° 10' 47.91""","W2° 15' 48.41""",162.00,0.7500,1,1,cras,15.306,18.639,16.099,18.639,11.107,-7.653,-0.993,-8.049,1990,fsxp_missionobjects.bgl +176,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.18010948,-2.26328619,"N51° 10' 48.39""","W2° 15' 47.83""",162.00,1.0000,1,1,cras,20.408,24.853,21.466,24.853,14.809,-10.204,-1.324,-10.732,1990,fsxp_missionobjects.bgl +177,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17936261,-2.26517782,"N51° 10' 45.71""","W2° 15' 54.64""",180.00,1.1000,1,1,cras,22.449,27.338,23.612,27.338,16.29,-11.224,-1.456,-11.806,1990,fsxp_missionobjects.bgl +178,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.18090134,-2.26201675,"N51° 10' 51.24""","W2° 15' 43.26""",243.00,1.0000,1,1,cras,20.408,24.853,21.466,24.853,14.809,-10.204,-1.324,-10.732,1990,fsxp_missionobjects.bgl +179,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17795886,-2.25952783,"N51° 10' 40.65""","W2° 15' 34.30""",153.00,1.0000,1,1,cras,20.408,24.853,21.466,24.853,14.809,-10.204,-1.324,-10.732,1990,fsxp_missionobjects.bgl +180,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17913765,-2.25805563,"N51° 10' 44.90""","W2° 15' 29.00""",63.00,0.8000,1,1,cras,16.326,19.882,17.172,19.882,11.847,-8.163,-1.059,-8.586,1990,fsxp_missionobjects.bgl +181,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18074837,-2.26876393,"N51° 10' 50.69""","W2° 16' 07.55""",148.50,1.0000,1,1,cras,30.896,24.74,30.149,24.74,21.584,-15.448,-0.063,-15.074,1806,fsxp_missionobjects.bgl +182,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17668108,-2.26600838,"N51° 10' 36.05""","W2° 15' 57.63""",319.50,0.5000,1,1,cras,15.448,12.37,15.074,12.37,10.792,-7.724,-0.032,-7.537,1806,fsxp_missionobjects.bgl +183,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - NEW - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18146824,-2.25716111,"N51° 10' 53.29""","W2° 15' 25.78""",351.00,1.5000,0,1,bbox,11.753,5.632,11.706,5.632,8.294,-6.632,-0.138,-5.956,0,vegetation.bgl +184,veg_tb_bush_Bayberry_3_5ft,veg_tb_bush_Bayberry_3_5ft,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_3_5ft,bush,51.17753593,-2.26111670,"N51° 10' 39.13""","W2° 15' 40.02""",342.00,1.5000,0,1,bbox,7.398,2.383,7.054,2.383,5.111,-4.032,-0.083,-3.413,0,vegetation.bgl +185,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18351089,-2.25993896,"N51° 11' 00.64""","W2° 15' 35.78""",18.00,1.0000,0,1,bbox,7.835,3.755,7.804,3.755,5.529,-4.421,-0.092,-3.971,0,vegetation.bgl +186,Longleat_Tree_BlackPoplar_02,Longleat_Tree_BlackPoplar_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_02,tree,51.18118029,-2.26390284,"N51° 10' 52.25""","W2° 15' 50.05""",27.00,0.2520,1,1,cras,5.026,5.372,5.895,5.372,3.873,-2.513,-0.327,-2.947,1715,fsxp_missionobjects.bgl +187,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18361887,-2.27311112,"N51° 11' 01.03""","W2° 16' 23.20""",99.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +188,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18059540,-2.27171117,"N51° 10' 50.14""","W2° 16' 18.16""",180.00,1.1000,1,1,cras,3.117,4.387,2.982,4.387,2.157,-1.607,-0.007,-1.491,52,fsxp_missionobjects.bgl +189,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17758992,-2.27029723,"N51° 10' 39.32""","W2° 16' 13.07""",342.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +190,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18200815,-2.27215282,"N51° 10' 55.23""","W2° 16' 19.75""",342.90,0.5000,1,1,cras,11.563,16.792,11.086,16.792,8.009,-5.766,-1.58,-5.543,7566,fsxp_missionobjects.bgl +191,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18219712,-2.27224175,"N51° 10' 55.91""","W2° 16' 20.07""",342.90,0.6000,1,1,cras,13.875,20.151,13.303,20.151,9.611,-6.92,-1.896,-6.651,7566,fsxp_missionobjects.bgl +192,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18045142,-2.27281113,"N51° 10' 49.63""","W2° 16' 22.12""",27.00,0.6000,1,1,cras,13.875,20.151,13.303,20.151,9.611,-6.92,-1.896,-6.651,7566,fsxp_missionobjects.bgl +193,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18075737,-2.26847501,"N51° 10' 50.73""","W2° 16' 06.51""",54.00,1.1000,1,1,cras,25.438,36.943,24.389,36.943,17.621,-12.686,-3.476,-12.194,7566,fsxp_missionobjects.bgl +194,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18520259,-2.25874730,"N51° 11' 06.73""","W2° 15' 31.49""",180.00,0.4000,1,1,cras,7.041,9.169,6.354,9.169,4.742,-3.508,-1.017,-3.171,1787,fsxp_missionobjects.bgl +195,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18527458,-2.25879449,"N51° 11' 06.99""","W2° 15' 31.66""",180.00,0.4500,1,1,cras,7.921,10.316,7.148,10.316,5.335,-3.947,-1.144,-3.567,1787,fsxp_missionobjects.bgl +196,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18208913,-2.27218618,"N51° 10' 55.52""","W2° 16' 19.87""",198.00,0.4000,1,1,cras,7.041,9.169,6.354,9.169,4.742,-3.508,-1.017,-3.171,1787,fsxp_missionobjects.bgl +197,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18279101,-2.27252782,"N51° 10' 58.05""","W2° 16' 21.10""",180.00,0.7000,1,1,cras,12.321,16.046,11.119,16.046,8.298,-6.14,-1.779,-5.549,1787,fsxp_missionobjects.bgl +198,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18405079,-2.27310559,"N51° 11' 02.58""","W2° 16' 23.18""",153.00,0.2500,1,1,cras,4.401,5.731,3.971,5.731,2.964,-2.193,-0.635,-1.982,1787,fsxp_missionobjects.bgl +199,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18335791,-2.27418057,"N51° 11' 00.09""","W2° 16' 27.05""",162.00,0.6000,1,1,cras,10.561,13.754,9.53,13.754,7.113,-5.262,-1.525,-4.756,1787,fsxp_missionobjects.bgl +200,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.18199015,-2.27353340,"N51° 10' 55.16""","W2° 16' 24.72""",171.00,0.7500,1,1,cras,13.906,18.845,12.982,18.845,9.512,-6.855,-2.368,-6.491,1828,fsxp_missionobjects.bgl +201,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.18188217,-2.27350834,"N51° 10' 54.78""","W2° 16' 24.63""",126.00,0.5500,1,1,cras,10.198,13.819,9.52,13.819,6.975,-5.027,-1.736,-4.76,1828,fsxp_missionobjects.bgl +202,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.17860674,-2.27197780,"N51° 10' 42.98""","W2° 16' 19.12""",234.00,0.6000,1,1,cras,11.125,15.076,10.386,15.076,7.61,-5.484,-1.894,-5.193,1828,fsxp_missionobjects.bgl +203,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.17868773,-2.27200840,"N51° 10' 43.28""","W2° 16' 19.23""",306.00,0.8000,1,1,cras,14.833,20.101,13.848,20.101,10.146,-7.312,-2.525,-6.924,1828,fsxp_missionobjects.bgl +204,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.18446472,-2.27503619,"N51° 11' 04.07""","W2° 16' 30.13""",36.00,0.5000,1,1,cras,9.271,12.563,8.655,12.563,6.341,-4.57,-1.578,-4.327,1828,fsxp_missionobjects.bgl +205,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18502262,-2.27271114,"N51° 11' 06.08""","W2° 16' 21.76""",45.00,0.5000,1,1,cras,14.301,15.685,13.785,15.685,9.932,-7.176,-0.978,-6.884,2035,fsxp_missionobjects.bgl +206,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18023546,-2.26715284,"N51° 10' 48.85""","W2° 16' 01.75""",288.00,1.0000,1,1,cras,28.603,31.371,27.571,31.371,19.864,-14.352,-1.956,-13.768,2035,fsxp_missionobjects.bgl +207,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18093734,-2.26771116,"N51° 10' 51.37""","W2° 16' 03.76""",36.00,1.1000,1,1,cras,31.463,34.508,30.328,34.508,21.85,-15.788,-2.152,-15.145,2035,fsxp_missionobjects.bgl +208,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17948859,-2.26791392,"N51° 10' 46.16""","W2° 16' 04.49""",27.00,0.7000,1,1,cras,20.022,21.959,19.3,21.959,13.905,-10.047,-1.369,-9.638,2035,fsxp_missionobjects.bgl +209,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17749994,-2.26272779,"N51° 10' 39.00""","W2° 15' 45.82""",342.00,0.8500,1,1,cras,24.312,26.665,23.435,26.665,16.884,-12.2,-1.663,-11.703,2035,fsxp_missionobjects.bgl +210,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17896668,-2.25975556,"N51° 10' 44.28""","W2° 15' 35.12""",34.20,1.0000,1,1,cras,28.603,31.371,27.571,31.371,19.864,-14.352,-1.956,-13.768,2035,fsxp_missionobjects.bgl +211,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18015447,-2.25751391,"N51° 10' 48.56""","W2° 15' 27.05""",216.00,1.2500,1,1,cras,35.753,39.213,34.464,39.213,24.83,-17.94,-2.445,-17.21,2035,fsxp_missionobjects.bgl +212,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18036144,-2.25761667,"N51° 10' 49.30""","W2° 15' 27.42""",135.00,0.8000,1,1,cras,22.882,25.096,22.057,25.096,15.891,-11.482,-1.565,-11.015,2035,fsxp_missionobjects.bgl +213,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17879571,-2.26442504,"N51° 10' 43.66""","W2° 15' 51.93""",27.00,1.0000,1,1,cras,20.408,24.853,21.466,24.853,14.809,-10.204,-1.324,-10.732,1990,fsxp_missionobjects.bgl +214,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17924563,-2.26580562,"N51° 10' 45.28""","W2° 15' 56.90""",153.00,1.1000,1,1,cras,22.449,27.338,23.612,27.338,16.29,-11.224,-1.456,-11.806,1990,fsxp_missionobjects.bgl +215,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17927263,-2.26358894,"N51° 10' 45.38""","W2° 15' 48.92""",135.00,1.2500,1,1,cras,25.51,31.066,26.832,31.066,18.511,-12.755,-1.655,-13.416,1990,fsxp_missionobjects.bgl +216,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17938061,-2.26378064,"N51° 10' 45.77""","W2° 15' 49.61""",162.00,1.0000,1,1,cras,20.408,24.853,21.466,24.853,14.809,-10.204,-1.324,-10.732,1990,fsxp_missionobjects.bgl +217,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17919164,-2.26446116,"N51° 10' 45.09""","W2° 15' 52.06""",126.00,1.2500,1,1,cras,25.51,31.066,26.832,31.066,18.511,-12.755,-1.655,-13.416,1990,fsxp_missionobjects.bgl +218,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17893969,-2.26508897,"N51° 10' 44.18""","W2° 15' 54.32""",180.00,1.1000,1,1,cras,22.449,27.338,23.612,27.338,16.29,-11.224,-1.456,-11.806,1990,fsxp_missionobjects.bgl +219,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17933562,-2.26484447,"N51° 10' 45.61""","W2° 15' 53.44""",90.00,1.2000,1,1,cras,24.489,29.823,25.759,29.823,17.771,-12.244,-1.588,-12.879,1990,fsxp_missionobjects.bgl +220,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17841778,-2.26310279,"N51° 10' 42.30""","W2° 15' 47.17""",189.00,1.0000,1,1,cras,20.408,24.853,21.466,24.853,14.809,-10.204,-1.324,-10.732,1990,fsxp_missionobjects.bgl +221,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17871472,-2.26046392,"N51° 10' 43.37""","W2° 15' 37.67""",207.00,1.2500,1,1,cras,25.51,31.066,26.832,31.066,18.511,-12.755,-1.655,-13.416,1990,fsxp_missionobjects.bgl +222,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17919164,-2.25779168,"N51° 10' 45.09""","W2° 15' 28.05""",126.00,1.0000,1,1,cras,20.408,24.853,21.466,24.853,14.809,-10.204,-1.324,-10.732,1990,fsxp_missionobjects.bgl +223,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17941660,-2.25920839,"N51° 10' 45.90""","W2° 15' 33.15""",81.00,1.3000,1,1,cras,26.53,32.308,27.905,32.308,19.252,-13.265,-1.721,-13.952,1990,fsxp_missionobjects.bgl +224,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17618617,-2.26790562,"N51° 10' 34.27""","W2° 16' 04.46""",193.50,1.1200,1,1,cras,34.604,27.709,33.767,27.709,24.175,-17.302,-0.071,-16.883,1806,fsxp_missionobjects.bgl +225,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17713100,-2.26760563,"N51° 10' 37.67""","W2° 16' 03.38""",40.50,0.8500,1,1,cras,26.262,21.029,25.627,21.029,18.347,-13.131,-0.054,-12.813,1806,fsxp_missionobjects.bgl +226,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17670808,-2.26631390,"N51° 10' 36.15""","W2° 15' 58.73""",292.50,0.9500,1,1,cras,29.352,23.503,28.641,23.503,20.505,-14.676,-0.06,-14.321,1806,fsxp_missionobjects.bgl +227,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18019947,-2.26862781,"N51° 10' 48.72""","W2° 16' 07.06""",121.50,1.0000,1,1,cras,30.896,24.74,30.149,24.74,21.584,-15.448,-0.063,-15.074,1806,fsxp_missionobjects.bgl +228,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18052341,-2.26878061,"N51° 10' 49.88""","W2° 16' 07.61""",49.50,0.7800,1,1,cras,24.099,19.297,23.516,19.297,16.836,-12.049,-0.049,-11.758,1806,fsxp_missionobjects.bgl +229,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17886770,-2.26340840,"N51° 10' 43.92""","W2° 15' 48.27""",130.50,0.6500,1,1,cras,20.083,16.081,19.597,16.081,14.03,-10.041,-0.041,-9.798,1806,fsxp_missionobjects.bgl +230,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17866073,-2.26378617,"N51° 10' 43.18""","W2° 15' 49.63""",355.50,0.4000,1,1,cras,12.359,9.896,12.06,9.896,8.634,-6.179,-0.025,-6.03,1806,fsxp_missionobjects.bgl +231,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17841778,-2.26124167,"N51° 10' 42.30""","W2° 15' 40.47""",247.50,1.0000,1,1,cras,30.896,24.74,30.149,24.74,21.584,-15.448,-0.063,-15.074,1806,fsxp_missionobjects.bgl +232,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18054141,-2.25848336,"N51° 10' 49.95""","W2° 15' 30.54""",18.00,1.0000,1,1,cras,30.896,24.74,30.149,24.74,21.584,-15.448,-0.063,-15.074,1806,fsxp_missionobjects.bgl +233,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17948859,-2.25780560,"N51° 10' 46.16""","W2° 15' 28.10""",351.00,1.3000,1,1,cras,40.165,32.162,39.194,32.162,28.06,-20.082,-0.082,-19.596,1806,fsxp_missionobjects.bgl +234,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17915565,-2.25862778,"N51° 10' 44.96""","W2° 15' 31.06""",0.00,1.1000,1,1,cras,33.986,27.214,33.164,27.214,23.743,-16.993,-0.069,-16.582,1806,fsxp_missionobjects.bgl +235,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Copy - Copy - NEW - Copy - Longleat_Tree_Oak_01,tree,51.18669633,-2.26805557,"N51° 11' 12.11""","W2° 16' 05.00""",72.00,1.2500,1,1,cras,35.753,39.213,34.464,39.213,24.83,-17.94,-2.445,-17.21,2035,fsxp_missionobjects.bgl +236,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - Copy - Copy - Copy - NEW - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18138726,-2.25713060,"N51° 10' 52.99""","W2° 15' 25.67""",315.00,1.5000,0,1,bbox,11.753,5.632,11.706,5.632,8.294,-6.632,-0.138,-5.956,0,vegetation.bgl +237,veg_tb_bush_Bayberry_3_5ft,veg_tb_bush_Bayberry_3_5ft,Copy - Copy - Copy - Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_3_5ft,bush,51.17791387,-2.26133337,"N51° 10' 40.49""","W2° 15' 40.80""",351.00,1.5000,0,1,bbox,7.398,2.383,7.054,2.383,5.111,-4.032,-0.083,-3.413,0,vegetation.bgl +238,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18388882,-2.25982781,"N51° 11' 02.00""","W2° 15' 35.38""",18.00,1.0000,0,1,bbox,7.835,3.755,7.804,3.755,5.529,-4.421,-0.092,-3.971,0,vegetation.bgl +239,Longleat_Tree_BlackPoplar_02,Longleat_Tree_BlackPoplar_02,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_02,tree,51.18099133,-2.26389723,"N51° 10' 51.57""","W2° 15' 50.03""",27.00,0.2000,1,1,cras,3.989,4.263,4.678,4.263,3.074,-1.994,-0.26,-2.339,1715,fsxp_missionobjects.bgl +240,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18370885,-2.27315563,"N51° 11' 01.35""","W2° 16' 23.36""",45.00,1.3500,1,1,cras,3.826,5.384,3.66,5.384,2.647,-1.973,-0.009,-1.829,52,fsxp_missionobjects.bgl +241,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18100932,-2.27189172,"N51° 10' 51.63""","W2° 16' 18.81""",18.00,0.9000,1,1,cras,2.551,3.589,2.44,3.589,1.765,-1.315,-0.006,-1.22,52,fsxp_missionobjects.bgl +242,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18064939,-2.27173339,"N51° 10' 50.34""","W2° 16' 18.24""",180.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +243,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17767091,-2.27034174,"N51° 10' 39.62""","W2° 16' 13.23""",9.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +244,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18064039,-2.27148897,"N51° 10' 50.31""","W2° 16' 17.36""",342.90,0.6500,1,1,cras,15.032,21.83,14.412,21.83,10.412,-7.496,-2.054,-7.206,7566,fsxp_missionobjects.bgl +245,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18073937,-2.27156390,"N51° 10' 50.66""","W2° 16' 17.63""",27.00,0.4500,1,1,cras,10.407,15.113,9.977,15.113,7.208,-5.19,-1.422,-4.989,7566,fsxp_missionobjects.bgl +246,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.17924563,-2.27227503,"N51° 10' 45.28""","W2° 16' 20.19""",63.90,0.4500,1,1,cras,10.407,15.113,9.977,15.113,7.208,-5.19,-1.422,-4.989,7566,fsxp_missionobjects.bgl +247,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.17727498,-2.27131672,"N51° 10' 38.19""","W2° 16' 16.74""",27.00,0.5000,1,1,cras,11.563,16.792,11.086,16.792,8.009,-5.766,-1.58,-5.543,7566,fsxp_missionobjects.bgl +248,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18080236,-2.26808340,"N51° 10' 50.89""","W2° 16' 05.10""",27.00,1.0000,1,1,cras,23.126,33.585,22.172,33.585,16.019,-11.533,-3.16,-11.086,7566,fsxp_missionobjects.bgl +249,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18523858,-2.25893338,"N51° 11' 06.86""","W2° 15' 32.16""",180.00,0.4500,1,1,cras,7.921,10.316,7.148,10.316,5.335,-3.947,-1.144,-3.567,1787,fsxp_missionobjects.bgl +250,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18142325,-2.27188057,"N51° 10' 53.12""","W2° 16' 18.77""",180.00,0.3000,1,1,cras,5.281,6.877,4.765,6.877,3.556,-2.631,-0.762,-2.378,1787,fsxp_missionobjects.bgl +251,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18295298,-2.27260008,"N51° 10' 58.63""","W2° 16' 21.36""",216.00,0.5000,1,1,cras,8.801,11.462,7.942,11.462,5.927,-4.385,-1.271,-3.963,1787,fsxp_missionobjects.bgl +252,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18356488,-2.27285841,"N51° 11' 00.83""","W2° 16' 22.29""",153.00,0.4000,1,1,cras,7.041,9.169,6.354,9.169,4.742,-3.508,-1.017,-3.171,1787,fsxp_missionobjects.bgl +253,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18404179,-2.27451115,"N51° 11' 02.55""","W2° 16' 28.24""",135.00,0.6500,1,1,cras,11.441,14.9,10.324,14.9,7.706,-5.701,-1.652,-5.152,1787,fsxp_missionobjects.bgl +254,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18054141,-2.27284726,"N51° 10' 49.95""","W2° 16' 22.25""",198.00,0.7000,1,1,cras,12.321,16.046,11.119,16.046,8.298,-6.14,-1.779,-5.549,1787,fsxp_missionobjects.bgl +255,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.18416777,-2.27438341,"N51° 11' 03.00""","W2° 16' 27.78""",0.00,0.5600,1,1,cras,10.383,14.071,9.693,14.071,7.102,-5.119,-1.768,-4.847,1828,fsxp_missionobjects.bgl +256,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.18275502,-2.27393615,"N51° 10' 57.92""","W2° 16' 26.17""",126.00,0.5500,1,1,cras,10.198,13.819,9.52,13.819,6.975,-5.027,-1.736,-4.76,1828,fsxp_missionobjects.bgl +257,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.17978554,-2.27252229,"N51° 10' 47.23""","W2° 16' 21.08""",333.00,0.6500,1,1,cras,12.052,16.332,11.251,16.332,8.244,-5.941,-2.052,-5.625,1828,fsxp_missionobjects.bgl +258,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.17908366,-2.27218894,"N51° 10' 44.70""","W2° 16' 19.88""",306.00,0.9000,1,1,cras,16.687,22.614,15.579,22.614,11.414,-8.226,-2.841,-7.789,1828,fsxp_missionobjects.bgl +259,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.18456370,-2.27507508,"N51° 11' 04.43""","W2° 16' 30.27""",108.00,0.5000,1,1,cras,9.271,12.563,8.655,12.563,6.341,-4.57,-1.578,-4.327,1828,fsxp_missionobjects.bgl +260,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18504062,-2.27253897,"N51° 11' 06.15""","W2° 16' 21.14""",45.00,0.6500,1,1,cras,18.592,20.391,17.921,20.391,12.911,-9.329,-1.271,-8.949,2035,fsxp_missionobjects.bgl +261,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18137826,-2.26816119,"N51° 10' 52.96""","W2° 16' 05.38""",351.00,1.1000,1,1,cras,31.463,34.508,30.328,34.508,21.85,-15.788,-2.152,-15.145,2035,fsxp_missionobjects.bgl +262,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18007349,-2.26239167,"N51° 10' 48.26""","W2° 15' 44.61""",288.00,0.9500,1,1,cras,27.173,29.802,26.192,29.802,18.871,-13.635,-1.858,-13.08,2035,fsxp_missionobjects.bgl +263,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17895768,-2.26462780,"N51° 10' 44.25""","W2° 15' 52.66""",349.20,1.0000,1,1,cras,28.603,31.371,27.571,31.371,19.864,-14.352,-1.956,-13.768,2035,fsxp_missionobjects.bgl +264,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17861574,-2.26339172,"N51° 10' 43.02""","W2° 15' 48.21""",270.00,0.8000,1,1,cras,22.882,25.096,22.057,25.096,15.891,-11.482,-1.565,-11.015,2035,fsxp_missionobjects.bgl +265,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18100032,-2.26172506,"N51° 10' 51.60""","W2° 15' 42.21""",306.00,0.3000,1,1,cras,8.581,9.411,8.271,9.411,5.959,-4.306,-0.587,-4.13,2035,fsxp_missionobjects.bgl +266,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18088335,-2.26169446,"N51° 10' 51.18""","W2° 15' 42.10""",297.00,0.3000,1,1,cras,8.581,9.411,8.271,9.411,5.959,-4.306,-0.587,-4.13,2035,fsxp_missionobjects.bgl +267,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18038843,-2.26025839,"N51° 10' 49.40""","W2° 15' 36.93""",306.00,1.0000,1,1,cras,28.603,31.371,27.571,31.371,19.864,-14.352,-1.956,-13.768,2035,fsxp_missionobjects.bgl +268,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18080236,-2.25999168,"N51° 10' 50.89""","W2° 15' 35.97""",351.00,0.7500,1,1,cras,21.452,23.528,20.678,23.528,14.898,-10.764,-1.467,-10.326,2035,fsxp_missionobjects.bgl +269,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18031644,-2.25934728,"N51° 10' 49.14""","W2° 15' 33.65""",171.00,1.2500,1,1,cras,35.753,39.213,34.464,39.213,24.83,-17.94,-2.445,-17.21,2035,fsxp_missionobjects.bgl +270,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17837278,-2.26649168,"N51° 10' 42.14""","W2° 15' 59.37""",171.00,1.2500,1,1,cras,25.51,31.066,26.832,31.066,18.511,-12.755,-1.655,-13.416,1990,fsxp_missionobjects.bgl +271,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17866973,-2.26546674,"N51° 10' 43.21""","W2° 15' 55.68""",90.00,1.2000,1,1,cras,24.489,29.823,25.759,29.823,17.771,-12.244,-1.588,-12.879,1990,fsxp_missionobjects.bgl +272,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17856175,-2.26489451,"N51° 10' 42.82""","W2° 15' 53.62""",180.00,1.1000,1,1,cras,22.449,27.338,23.612,27.338,16.29,-11.224,-1.456,-11.806,1990,fsxp_missionobjects.bgl +273,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17876872,-2.26413896,"N51° 10' 43.57""","W2° 15' 50.90""",135.00,1.0000,1,1,cras,20.408,24.853,21.466,24.853,14.809,-10.204,-1.324,-10.732,1990,fsxp_missionobjects.bgl +274,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17953358,-2.26371392,"N51° 10' 46.32""","W2° 15' 49.37""",207.00,0.8000,1,1,cras,16.326,19.882,17.172,19.882,11.847,-8.163,-1.059,-8.586,1990,fsxp_missionobjects.bgl +275,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.18023546,-2.26318619,"N51° 10' 48.85""","W2° 15' 47.47""",207.00,0.7000,1,1,cras,14.285,17.397,15.026,17.397,10.366,-7.143,-0.927,-7.513,1990,fsxp_missionobjects.bgl +276,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17998350,-2.25782781,"N51° 10' 47.94""","W2° 15' 28.18""",126.00,0.9000,1,1,cras,18.367,22.367,19.319,22.367,13.328,-9.183,-1.191,-9.659,1990,fsxp_missionobjects.bgl +277,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17956958,-2.25818060,"N51° 10' 46.45""","W2° 15' 29.45""",324.00,0.9000,1,1,cras,18.367,22.367,19.319,22.367,13.328,-9.183,-1.191,-9.659,1990,fsxp_missionobjects.bgl +278,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17975854,-2.25833894,"N51° 10' 47.13""","W2° 15' 30.02""",81.00,1.1000,1,1,cras,22.449,27.338,23.612,27.338,16.29,-11.224,-1.456,-11.806,1990,fsxp_missionobjects.bgl +279,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17614118,-2.26852505,"N51° 10' 34.11""","W2° 16' 06.69""",166.50,0.9500,1,1,cras,29.352,23.503,28.641,23.503,20.505,-14.676,-0.06,-14.321,1806,fsxp_missionobjects.bgl +280,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18276402,-2.27191393,"N51° 10' 57.95""","W2° 16' 18.89""",328.50,0.6500,1,1,cras,20.083,16.081,19.597,16.081,14.03,-10.041,-0.041,-9.798,1806,fsxp_missionobjects.bgl +281,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18093734,-2.26903894,"N51° 10' 51.37""","W2° 16' 08.54""",121.50,1.0000,1,1,cras,30.896,24.74,30.149,24.74,21.584,-15.448,-0.063,-15.074,1806,fsxp_missionobjects.bgl +282,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17888570,-2.26355835,"N51° 10' 43.99""","W2° 15' 48.81""",31.50,0.6500,1,1,cras,20.083,16.081,19.597,16.081,14.03,-10.041,-0.041,-9.798,1806,fsxp_missionobjects.bgl +283,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17851676,-2.26370562,"N51° 10' 42.66""","W2° 15' 49.34""",319.50,0.6000,1,1,cras,18.538,14.844,18.089,14.844,12.951,-9.269,-0.038,-9.045,1806,fsxp_missionobjects.bgl +284,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17898468,-2.26304445,"N51° 10' 44.34""","W2° 15' 46.96""",274.50,0.7000,1,1,cras,21.628,17.318,21.104,17.318,15.109,-10.814,-0.044,-10.552,1806,fsxp_missionobjects.bgl +285,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17724798,-2.26210560,"N51° 10' 38.09""","W2° 15' 43.58""",247.50,1.0000,1,1,cras,30.896,24.74,30.149,24.74,21.584,-15.448,-0.063,-15.074,1806,fsxp_missionobjects.bgl +286,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17783288,-2.26060557,"N51° 10' 40.20""","W2° 15' 38.18""",238.50,1.0000,1,1,cras,30.896,24.74,30.149,24.74,21.584,-15.448,-0.063,-15.074,1806,fsxp_missionobjects.bgl +287,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17901167,-2.26083616,"N51° 10' 44.44""","W2° 15' 39.01""",274.50,1.2000,1,1,cras,37.076,29.688,36.179,29.688,25.901,-18.538,-0.076,-18.089,1806,fsxp_missionobjects.bgl +288,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18101832,-2.25925558,"N51° 10' 51.67""","W2° 15' 33.32""",337.50,0.5000,1,1,cras,15.448,12.37,15.074,12.37,10.792,-7.724,-0.032,-7.537,1806,fsxp_missionobjects.bgl +289,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18017247,-2.25883892,"N51° 10' 48.62""","W2° 15' 31.82""",351.00,1.3000,1,1,cras,40.165,32.162,39.194,32.162,28.06,-20.082,-0.082,-19.596,1806,fsxp_missionobjects.bgl +290,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Copy - NEW - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18186417,-2.25635561,"N51° 10' 54.71""","W2° 15' 22.88""",310.50,0.8000,1,1,cras,24.717,19.792,24.119,19.792,17.268,-12.358,-0.05,-12.059,1806,fsxp_missionobjects.bgl +291,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Copy - NEW - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18219712,-2.25589729,"N51° 10' 55.91""","W2° 15' 21.23""",328.50,0.6500,1,1,cras,20.083,16.081,19.597,16.081,14.03,-10.041,-0.041,-9.798,1806,fsxp_missionobjects.bgl +292,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Copy - NEW - Copy - Longleat_Tree_Oak_01,tree,51.18626440,-2.26690005,"N51° 11' 10.55""","W2° 16' 00.84""",45.00,1.0000,1,1,cras,28.603,31.371,27.571,31.371,19.864,-14.352,-1.956,-13.768,2035,fsxp_missionobjects.bgl +293,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Copy - NEW - Copy - Longleat_Tree_Oak_01,tree,51.18088335,-2.25615562,"N51° 10' 51.18""","W2° 15' 22.16""",63.00,0.6000,1,1,cras,17.162,18.822,16.543,18.822,11.918,-8.611,-1.174,-8.261,2035,fsxp_missionobjects.bgl +294,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - Copy - Copy - NEW - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18127028,-2.25715005,"N51° 10' 52.57""","W2° 15' 25.74""",315.00,1.5000,0,1,bbox,11.753,5.632,11.706,5.632,8.294,-6.632,-0.138,-5.956,0,vegetation.bgl +295,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Copy - NEW- Copy - Longleat_Tree_Oak_01,tree,51.18091934,-2.25590835,"N51° 10' 51.31""","W2° 15' 21.27""",153.00,0.5000,1,1,cras,14.301,15.685,13.785,15.685,9.932,-7.176,-0.978,-6.884,2035,fsxp_missionobjects.bgl +296,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Copy - NEW- Copy - Longleat_Tree_Oak_01,tree,51.18090134,-2.25562228,"N51° 10' 51.24""","W2° 15' 20.24""",216.00,0.6000,1,1,cras,17.162,18.822,16.543,18.822,11.918,-8.611,-1.174,-8.261,2035,fsxp_missionobjects.bgl +297,veg_tb_bush_Bayberry_3_5ft,veg_tb_bush_Bayberry_3_5ft,Copy - Copy - Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_3_5ft,bush,51.17781488,-2.26125835,"N51° 10' 40.13""","W2° 15' 40.53""",333.00,1.5000,0,1,bbox,7.398,2.383,7.054,2.383,5.111,-4.032,-0.083,-3.413,0,vegetation.bgl +298,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18529257,-2.26446670,"N51° 11' 07.05""","W2° 15' 52.08""",171.00,0.7500,0,1,bbox,5.877,2.816,5.853,2.816,4.147,-3.316,-0.069,-2.978,0,vegetation.bgl +299,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18396981,-2.25985556,"N51° 11' 02.29""","W2° 15' 35.48""",18.00,1.0000,0,1,bbox,7.835,3.755,7.804,3.755,5.529,-4.421,-0.092,-3.971,0,vegetation.bgl +300,Longleat_Tree_BlackPoplar_02,Longleat_Tree_BlackPoplar_02,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_02,tree,51.18109931,-2.26405841,"N51° 10' 51.96""","W2° 15' 50.61""",342.00,0.2000,1,1,cras,3.989,4.263,4.678,4.263,3.074,-1.994,-0.26,-2.339,1715,fsxp_missionobjects.bgl +301,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18379884,-2.27319729,"N51° 11' 01.68""","W2° 16' 23.51""",108.00,1.3500,1,1,cras,3.826,5.384,3.66,5.384,2.647,-1.973,-0.009,-1.829,52,fsxp_missionobjects.bgl +302,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18118029,-2.27197227,"N51° 10' 52.25""","W2° 16' 19.10""",324.00,0.9000,1,1,cras,2.551,3.589,2.44,3.589,1.765,-1.315,-0.006,-1.22,52,fsxp_missionobjects.bgl +303,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18073937,-2.27176951,"N51° 10' 50.66""","W2° 16' 18.37""",162.00,0.9000,1,1,cras,2.551,3.589,2.44,3.589,1.765,-1.315,-0.006,-1.22,52,fsxp_missionobjects.bgl +304,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17786887,-2.27035557,"N51° 10' 40.33""","W2° 16' 13.28""",9.00,0.9500,1,1,cras,2.692,3.789,2.575,3.789,1.863,-1.388,-0.006,-1.287,52,fsxp_missionobjects.bgl +305,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18014547,-2.27126668,"N51° 10' 48.52""","W2° 16' 16.56""",18.90,0.8000,1,1,cras,18.501,26.868,17.737,26.868,12.815,-9.226,-2.528,-8.869,7566,fsxp_missionobjects.bgl +306,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18023546,-2.27132779,"N51° 10' 48.85""","W2° 16' 16.78""",27.00,0.5500,1,1,cras,12.719,18.472,12.194,18.472,8.81,-6.343,-1.738,-6.097,7566,fsxp_missionobjects.bgl +307,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18114430,-2.27313057,"N51° 10' 52.12""","W2° 16' 23.27""",342.00,0.7500,1,1,cras,17.344,25.189,16.629,25.189,12.014,-8.649,-2.37,-8.314,7566,fsxp_missionobjects.bgl +308,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.17716700,-2.27125838,"N51° 10' 37.80""","W2° 16' 16.53""",27.00,0.6500,1,1,cras,15.032,21.83,14.412,21.83,10.412,-7.496,-2.054,-7.206,7566,fsxp_missionobjects.bgl +309,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18513060,-2.25875007,"N51° 11' 06.47""","W2° 15' 31.50""",180.00,0.5000,1,1,cras,8.801,11.462,7.942,11.462,5.927,-4.385,-1.271,-3.963,1787,fsxp_missionobjects.bgl +310,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18003749,-2.27122779,"N51° 10' 48.13""","W2° 16' 16.42""",135.00,0.7000,1,1,cras,12.321,16.046,11.119,16.046,8.298,-6.14,-1.779,-5.549,1787,fsxp_missionobjects.bgl +311,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18153123,-2.27192508,"N51° 10' 53.51""","W2° 16' 18.93""",180.00,0.4500,1,1,cras,7.921,10.316,7.148,10.316,5.335,-3.947,-1.144,-3.567,1787,fsxp_missionobjects.bgl +312,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18336691,-2.27280560,"N51° 11' 00.12""","W2° 16' 22.10""",135.00,0.4500,1,1,cras,7.921,10.316,7.148,10.316,5.335,-3.947,-1.144,-3.567,1787,fsxp_missionobjects.bgl +313,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.17916465,-2.27225281,"N51° 10' 44.99""","W2° 16' 20.11""",171.00,0.5500,1,1,cras,9.681,12.608,8.736,12.608,6.52,-4.824,-1.398,-4.36,1787,fsxp_missionobjects.bgl +314,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.17932662,-2.27232506,"N51° 10' 45.58""","W2° 16' 20.37""",135.00,0.4500,1,1,cras,7.921,10.316,7.148,10.316,5.335,-3.947,-1.144,-3.567,1787,fsxp_missionobjects.bgl +315,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.17852576,-2.27193061,"N51° 10' 42.69""","W2° 16' 18.95""",135.00,0.6500,1,1,cras,11.441,14.9,10.324,14.9,7.706,-5.701,-1.652,-5.152,1787,fsxp_missionobjects.bgl +316,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.18160322,-2.27195282,"N51° 10' 53.77""","W2° 16' 19.03""",18.00,0.3000,1,1,cras,5.562,7.538,5.193,7.538,3.805,-2.742,-0.947,-2.596,1828,fsxp_missionobjects.bgl +317,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.18248507,-2.27378611,"N51° 10' 56.95""","W2° 16' 25.63""",126.00,0.7500,1,1,cras,13.906,18.845,12.982,18.845,9.512,-6.855,-2.368,-6.491,1828,fsxp_missionobjects.bgl +318,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.17994751,-2.27259446,"N51° 10' 47.81""","W2° 16' 21.34""",333.00,0.6000,1,1,cras,11.125,15.076,10.386,15.076,7.61,-5.484,-1.894,-5.193,1828,fsxp_missionobjects.bgl +319,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.18477966,-2.27521950,"N51° 11' 05.21""","W2° 16' 30.79""",108.00,1.1000,1,1,cras,20.395,27.639,19.041,27.639,13.951,-10.054,-3.472,-9.52,1828,fsxp_missionobjects.bgl +320,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18543655,-2.26640283,"N51° 11' 07.57""","W2° 15' 59.05""",315.00,0.2500,1,1,cras,7.151,7.843,6.893,7.843,4.966,-3.588,-0.489,-3.442,2035,fsxp_missionobjects.bgl +321,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18545455,-2.26664725,"N51° 11' 07.64""","W2° 15' 59.93""",234.00,0.2500,1,1,cras,7.151,7.843,6.893,7.843,4.966,-3.588,-0.489,-3.442,2035,fsxp_missionobjects.bgl +322,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17624916,-2.26711395,"N51° 10' 34.50""","W2° 16' 01.61""",324.00,0.9800,1,1,cras,28.031,30.743,27.019,30.743,19.466,-14.065,-1.917,-13.493,2035,fsxp_missionobjects.bgl +323,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18261104,-2.27186389,"N51° 10' 57.40""","W2° 16' 18.71""",306.00,0.6500,1,1,cras,18.592,20.391,17.921,20.391,12.911,-9.329,-1.271,-8.949,2035,fsxp_missionobjects.bgl +324,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18190017,-2.26796119,"N51° 10' 54.84""","W2° 16' 04.66""",333.00,0.9000,1,1,cras,25.742,28.233,24.814,28.233,17.877,-12.917,-1.76,-12.391,2035,fsxp_missionobjects.bgl +325,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18509461,-2.27209171,"N51° 11' 06.34""","W2° 16' 19.53""",18.00,0.6500,1,1,cras,18.592,20.391,17.921,20.391,12.911,-9.329,-1.271,-8.949,2035,fsxp_missionobjects.bgl +326,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18163021,-2.26851113,"N51° 10' 53.87""","W2° 16' 06.64""",351.00,0.6500,1,1,cras,18.592,20.391,17.921,20.391,12.911,-9.329,-1.271,-8.949,2035,fsxp_missionobjects.bgl +327,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18114430,-2.26767227,"N51° 10' 52.12""","W2° 16' 03.62""",279.00,0.8500,1,1,cras,24.312,26.665,23.435,26.665,16.884,-12.2,-1.663,-11.703,2035,fsxp_missionobjects.bgl +328,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18011848,-2.26885278,"N51° 10' 48.43""","W2° 16' 07.87""",18.00,0.6500,1,1,cras,18.592,20.391,17.921,20.391,12.911,-9.329,-1.271,-8.949,2035,fsxp_missionobjects.bgl +329,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17868773,-2.26515284,"N51° 10' 43.28""","W2° 15' 54.55""",234.00,1.0000,1,1,cras,28.603,31.371,27.571,31.371,19.864,-14.352,-1.956,-13.768,2035,fsxp_missionobjects.bgl +330,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17870573,-2.26606948,"N51° 10' 43.34""","W2° 15' 57.85""",349.20,1.0000,1,1,cras,28.603,31.371,27.571,31.371,19.864,-14.352,-1.956,-13.768,2035,fsxp_missionobjects.bgl +331,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17920064,-2.26398063,"N51° 10' 45.12""","W2° 15' 50.33""",270.00,1.0000,1,1,cras,28.603,31.371,27.571,31.371,19.864,-14.352,-1.956,-13.768,2035,fsxp_missionobjects.bgl +332,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17914665,-2.26497782,"N51° 10' 44.93""","W2° 15' 53.92""",315.00,1.2000,1,1,cras,34.323,37.645,33.085,37.645,23.836,-17.223,-2.347,-16.522,2035,fsxp_missionobjects.bgl +333,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18097333,-2.26185557,"N51° 10' 51.50""","W2° 15' 42.68""",306.00,0.4000,1,1,cras,11.441,12.548,11.028,12.548,7.945,-5.741,-0.782,-5.507,2035,fsxp_missionobjects.bgl +334,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18095533,-2.26165281,"N51° 10' 51.44""","W2° 15' 41.95""",243.00,0.2500,1,1,cras,7.151,7.843,6.893,7.843,4.966,-3.588,-0.489,-3.442,2035,fsxp_missionobjects.bgl +335,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17803984,-2.25983611,"N51° 10' 40.94""","W2° 15' 35.41""",261.00,1.0000,1,1,cras,28.603,31.371,27.571,31.371,19.864,-14.352,-1.956,-13.768,2035,fsxp_missionobjects.bgl +336,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17862474,-2.26084169,"N51° 10' 43.05""","W2° 15' 39.03""",234.00,1.5000,1,1,cras,42.904,47.056,41.356,47.056,29.796,-21.529,-2.934,-20.652,2035,fsxp_missionobjects.bgl +337,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17981253,-2.25977501,"N51° 10' 47.33""","W2° 15' 35.19""",261.00,1.3000,1,1,cras,37.183,40.782,35.842,40.782,25.823,-18.658,-2.543,-17.899,2035,fsxp_missionobjects.bgl +338,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18008249,-2.25986394,"N51° 10' 48.30""","W2° 15' 35.51""",207.00,1.2500,1,1,cras,35.753,39.213,34.464,39.213,24.83,-17.94,-2.445,-17.21,2035,fsxp_missionobjects.bgl +339,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17928163,-2.25971114,"N51° 10' 45.41""","W2° 15' 34.96""",261.00,1.5000,1,1,cras,42.904,47.056,41.356,47.056,29.796,-21.529,-2.934,-20.652,2035,fsxp_missionobjects.bgl +340,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.18520259,-2.26050281,"N51° 11' 06.73""","W2° 15' 37.81""",27.00,0.4500,1,1,cras,9.183,11.184,9.659,11.184,6.664,-4.592,-0.596,-4.83,1990,fsxp_missionobjects.bgl +341,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.18532857,-2.26050004,"N51° 11' 07.18""","W2° 15' 37.80""",288.00,0.4000,1,1,cras,8.163,9.941,8.586,9.941,5.924,-4.081,-0.529,-4.293,1990,fsxp_missionobjects.bgl +342,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17948859,-2.26625280,"N51° 10' 46.16""","W2° 15' 58.51""",171.00,1.0000,1,1,cras,20.408,24.853,21.466,24.853,14.809,-10.204,-1.324,-10.732,1990,fsxp_missionobjects.bgl +343,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17910166,-2.26536951,"N51° 10' 44.77""","W2° 15' 55.33""",117.00,1.2300,1,1,cras,25.102,30.569,26.403,30.569,18.215,-12.55,-1.628,-13.201,1990,fsxp_missionobjects.bgl +344,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17917364,-2.26606395,"N51° 10' 45.03""","W2° 15' 57.83""",207.00,0.8800,1,1,cras,17.959,21.87,18.89,21.87,13.032,-8.979,-1.165,-9.445,1990,fsxp_missionobjects.bgl +345,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.18049641,-2.25751114,"N51° 10' 49.79""","W2° 15' 27.04""",99.00,0.9000,1,1,cras,18.367,22.367,19.319,22.367,13.328,-9.183,-1.191,-9.659,1990,fsxp_missionobjects.bgl +346,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.18019947,-2.25788615,"N51° 10' 48.72""","W2° 15' 28.39""",81.00,1.1000,1,1,cras,22.449,27.338,23.612,27.338,16.29,-11.224,-1.456,-11.806,1990,fsxp_missionobjects.bgl +347,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17967756,-2.25802504,"N51° 10' 46.84""","W2° 15' 28.89""",342.00,0.9000,1,1,cras,18.367,22.367,19.319,22.367,13.328,-9.183,-1.191,-9.659,1990,fsxp_missionobjects.bgl +348,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17618617,-2.26641390,"N51° 10' 34.27""","W2° 15' 59.09""",139.50,0.9500,1,1,cras,29.352,23.503,28.641,23.503,20.505,-14.676,-0.06,-14.321,1806,fsxp_missionobjects.bgl +349,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18141425,-2.27103341,"N51° 10' 53.09""","W2° 16' 15.72""",40.50,0.4500,1,1,cras,13.903,11.133,13.567,11.133,9.713,-6.952,-0.028,-6.783,1806,fsxp_missionobjects.bgl +350,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18253006,-2.27172785,"N51° 10' 57.11""","W2° 16' 18.22""",337.50,0.5000,1,1,cras,15.448,12.37,15.074,12.37,10.792,-7.724,-0.032,-7.537,1806,fsxp_missionobjects.bgl +351,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18300697,-2.27103618,"N51° 10' 58.83""","W2° 16' 15.73""",22.50,0.7000,1,1,cras,21.628,17.318,21.104,17.318,15.109,-10.814,-0.044,-10.552,1806,fsxp_missionobjects.bgl +352,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18192716,-2.26744445,"N51° 10' 54.94""","W2° 16' 02.80""",187.20,1.1000,1,1,cras,33.986,27.214,33.164,27.214,23.743,-16.993,-0.069,-16.582,1806,fsxp_missionobjects.bgl +353,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18173820,-2.26777227,"N51° 10' 54.26""","W2° 16' 03.98""",121.50,0.8500,1,1,cras,26.262,21.029,25.627,21.029,18.347,-13.131,-0.054,-12.813,1806,fsxp_missionobjects.bgl +354,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18490564,-2.27268339,"N51° 11' 05.66""","W2° 16' 21.66""",283.50,0.7000,1,1,cras,21.628,17.318,21.104,17.318,15.109,-10.814,-0.044,-10.552,1806,fsxp_missionobjects.bgl +355,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18142325,-2.26870006,"N51° 10' 53.12""","W2° 16' 07.32""",61.20,1.5000,1,1,cras,46.345,37.11,45.223,37.11,32.377,-23.172,-0.095,-22.611,1806,fsxp_missionobjects.bgl +356,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18178319,-2.26889452,"N51° 10' 54.42""","W2° 16' 08.02""",355.50,1.1000,1,1,cras,33.986,27.214,33.164,27.214,23.743,-16.993,-0.069,-16.582,1806,fsxp_missionobjects.bgl +357,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18055940,-2.26843058,"N51° 10' 50.01""","W2° 16' 06.35""",121.50,1.0000,1,1,cras,30.896,24.74,30.149,24.74,21.584,-15.448,-0.063,-15.074,1806,fsxp_missionobjects.bgl +358,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17822881,-2.26265562,"N51° 10' 41.62""","W2° 15' 45.56""",274.50,1.0000,1,1,cras,30.896,24.74,30.149,24.74,21.584,-15.448,-0.063,-15.074,1806,fsxp_missionobjects.bgl +359,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17841778,-2.26493063,"N51° 10' 42.30""","W2° 15' 53.75""",346.50,0.5300,1,1,cras,16.375,13.112,15.979,13.112,11.44,-8.187,-0.033,-7.989,1806,fsxp_missionobjects.bgl +360,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17749094,-2.26306113,"N51° 10' 38.97""","W2° 15' 47.02""",256.50,0.9000,1,1,cras,27.807,22.266,27.134,22.266,19.426,-13.903,-0.057,-13.567,1806,fsxp_missionobjects.bgl +361,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18103632,-2.25977224,"N51° 10' 51.73""","W2° 15' 35.18""",310.50,0.5000,1,1,cras,15.448,12.37,15.074,12.37,10.792,-7.724,-0.032,-7.537,1806,fsxp_missionobjects.bgl +362,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18073037,-2.25819167,"N51° 10' 50.63""","W2° 15' 29.49""",324.00,1.0000,1,1,cras,30.896,24.74,30.149,24.74,21.584,-15.448,-0.063,-15.074,1806,fsxp_missionobjects.bgl +363,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Copy - NEW - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18171120,-2.25569445,"N51° 10' 54.16""","W2° 15' 20.50""",328.50,0.8000,1,1,cras,24.717,19.792,24.119,19.792,17.268,-12.358,-0.05,-12.059,1806,fsxp_missionobjects.bgl +364,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - NEW - Copy - Longleat_Tree_Oak_01,tree,51.18646237,-2.26400007,"N51° 11' 11.26""","W2° 15' 50.40""",81.00,0.7500,1,1,cras,21.452,23.528,20.678,23.528,14.898,-10.764,-1.467,-10.326,2035,fsxp_missionobjects.bgl +365,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - NEW - Copy - Longleat_Tree_Oak_01,tree,51.18062239,-2.25652501,"N51° 10' 50.24""","W2° 15' 23.49""",36.00,0.8000,1,1,cras,22.882,25.096,22.057,25.096,15.891,-11.482,-1.565,-11.015,2035,fsxp_missionobjects.bgl +366,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - Copy - NEW - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18134227,-2.25727226,"N51° 10' 52.83""","W2° 15' 26.18""",279.00,1.2500,0,1,bbox,9.794,4.693,9.755,4.693,6.912,-5.526,-0.115,-4.964,0,vegetation.bgl +367,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - NEW- Copy - Longleat_Tree_Oak_01,tree,51.18712825,-2.26032503,"N51° 11' 13.66""","W2° 15' 37.17""",153.00,1.0000,1,1,cras,28.603,31.371,27.571,31.371,19.864,-14.352,-1.956,-13.768,2035,fsxp_missionobjects.bgl +368,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Copy - NEW- Copy - Longleat_Tree_Oak_01,tree,51.18232309,-2.25762505,"N51° 10' 56.36""","W2° 15' 27.45""",153.00,0.5000,1,1,cras,14.301,15.685,13.785,15.685,9.932,-7.176,-0.978,-6.884,2035,fsxp_missionobjects.bgl +369,veg_tb_bush_Bayberry_3_5ft,veg_tb_bush_Bayberry_3_5ft,Copy - Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_3_5ft,bush,51.17779689,-2.26114729,"N51° 10' 40.07""","W2° 15' 40.13""",333.00,1.5000,0,1,bbox,7.398,2.383,7.054,2.383,5.111,-4.032,-0.083,-3.413,0,vegetation.bgl +370,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18504062,-2.26959726,"N51° 11' 06.15""","W2° 16' 10.55""",198.00,0.6500,0,1,bbox,5.093,2.441,5.073,2.441,3.594,-2.874,-0.06,-2.581,0,vegetation.bgl +371,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18534656,-2.26453057,"N51° 11' 07.25""","W2° 15' 52.31""",99.00,0.6500,0,1,bbox,5.093,2.441,5.073,2.441,3.594,-2.874,-0.06,-2.581,0,vegetation.bgl +372,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18392481,-2.25992781,"N51° 11' 02.13""","W2° 15' 35.74""",18.00,1.0000,0,1,bbox,7.835,3.755,7.804,3.755,5.529,-4.421,-0.092,-3.971,0,vegetation.bgl +373,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18091034,-2.26961947,"N51° 10' 51.28""","W2° 16' 10.63""",90.00,1.5000,0,1,bbox,11.753,5.632,11.706,5.632,8.294,-6.632,-0.138,-5.956,0,vegetation.bgl +374,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18052341,-2.26894448,"N51° 10' 49.88""","W2° 16' 08.20""",36.00,1.2000,0,1,bbox,9.402,4.506,9.365,4.506,6.635,-5.305,-0.11,-4.765,0,vegetation.bgl +375,Longleat_Tree_BlackPoplar_02,Longleat_Tree_BlackPoplar_02,Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_02,tree,51.18097333,-2.26410007,"N51° 10' 51.50""","W2° 15' 50.76""",45.00,0.3000,1,1,cras,5.983,6.395,7.018,6.395,4.611,-2.992,-0.389,-3.509,1715,fsxp_missionobjects.bgl +376,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18388882,-2.27323618,"N51° 11' 02.00""","W2° 16' 23.65""",108.00,1.1500,1,1,cras,3.259,4.587,3.117,4.587,2.255,-1.68,-0.007,-1.558,52,fsxp_missionobjects.bgl +377,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18127028,-2.27201393,"N51° 10' 52.57""","W2° 16' 19.25""",324.00,0.8500,1,1,cras,2.409,3.39,2.304,3.39,1.667,-1.242,-0.005,-1.152,52,fsxp_missionobjects.bgl +378,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18082036,-2.27180840,"N51° 10' 50.95""","W2° 16' 18.51""",117.00,0.8500,1,1,cras,2.409,3.39,2.304,3.39,1.667,-1.242,-0.005,-1.152,52,fsxp_missionobjects.bgl +379,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17776989,-2.27031115,"N51° 10' 39.97""","W2° 16' 13.12""",9.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +380,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18432974,-2.26337227,"N51° 11' 03.59""","W2° 15' 48.14""",315.00,0.8000,1,1,cras,2.267,3.191,2.169,3.191,1.569,-1.169,-0.005,-1.084,52,fsxp_missionobjects.bgl +381,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.17953358,-2.27103341,"N51° 10' 46.32""","W2° 16' 15.72""",27.00,0.5500,1,1,cras,12.719,18.472,12.194,18.472,8.81,-6.343,-1.738,-6.097,7566,fsxp_missionobjects.bgl +382,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.17784188,-2.27160841,"N51° 10' 40.23""","W2° 16' 17.79""",306.00,0.5000,1,1,cras,11.563,16.792,11.086,16.792,8.009,-5.766,-1.58,-5.543,7566,fsxp_missionobjects.bgl +383,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.17740095,-2.27119175,"N51° 10' 38.64""","W2° 16' 16.29""",27.00,0.5500,1,1,cras,12.719,18.472,12.194,18.472,8.81,-6.343,-1.738,-6.097,7566,fsxp_missionobjects.bgl +384,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.17856175,-2.26668337,"N51° 10' 42.82""","W2° 16' 00.06""",324.00,0.7500,1,1,cras,17.344,25.189,16.629,25.189,12.014,-8.649,-2.37,-8.314,7566,fsxp_missionobjects.bgl +385,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18537356,-2.26058059,"N51° 11' 07.34""","W2° 15' 38.09""",45.00,0.4000,1,1,cras,7.041,9.169,6.354,9.169,4.742,-3.508,-1.017,-3.171,1787,fsxp_missionobjects.bgl +386,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18523858,-2.25861395,"N51° 11' 06.86""","W2° 15' 31.01""",180.00,0.4000,1,1,cras,7.041,9.169,6.354,9.169,4.742,-3.508,-1.017,-3.171,1787,fsxp_missionobjects.bgl +387,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.17945260,-2.27098061,"N51° 10' 46.03""","W2° 16' 15.53""",135.00,0.5500,1,1,cras,9.681,12.608,8.736,12.608,6.52,-4.824,-1.398,-4.36,1787,fsxp_missionobjects.bgl +388,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.17938061,-2.27091950,"N51° 10' 45.77""","W2° 16' 15.31""",189.00,0.4500,1,1,cras,7.921,10.316,7.148,10.316,5.335,-3.947,-1.144,-3.567,1787,fsxp_missionobjects.bgl +389,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18092834,-2.27165837,"N51° 10' 51.34""","W2° 16' 17.97""",180.00,0.6500,1,1,cras,11.441,14.9,10.324,14.9,7.706,-5.701,-1.652,-5.152,1787,fsxp_missionobjects.bgl +390,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.17714000,-2.27108614,"N51° 10' 37.70""","W2° 16' 15.91""",315.00,0.6500,1,1,cras,11.441,14.9,10.324,14.9,7.706,-5.701,-1.652,-5.152,1787,fsxp_missionobjects.bgl +391,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18351089,-2.26126397,"N51° 11' 00.64""","W2° 15' 40.55""",0.00,0.3500,1,1,cras,6.161,8.023,5.559,8.023,4.149,-3.07,-0.89,-2.774,1787,fsxp_missionobjects.bgl +392,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.18134227,-2.27184445,"N51° 10' 52.83""","W2° 16' 18.64""",18.00,0.4500,1,1,cras,8.343,11.307,7.789,11.307,5.707,-4.113,-1.421,-3.895,1828,fsxp_missionobjects.bgl +393,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.18343890,-2.27423614,"N51° 11' 00.38""","W2° 16' 27.25""",108.00,0.7500,1,1,cras,13.906,18.845,12.982,18.845,9.512,-6.855,-2.368,-6.491,1828,fsxp_missionobjects.bgl +394,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.18064939,-2.27289445,"N51° 10' 50.34""","W2° 16' 22.42""",18.00,0.6500,1,1,cras,12.052,16.332,11.251,16.332,8.244,-5.941,-2.052,-5.625,1828,fsxp_missionobjects.bgl +395,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18525658,-2.26626394,"N51° 11' 06.92""","W2° 15' 58.55""",234.00,0.2500,1,1,cras,7.151,7.843,6.893,7.843,4.966,-3.588,-0.489,-3.442,2035,fsxp_missionobjects.bgl +396,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17623116,-2.26908060,"N51° 10' 34.43""","W2° 16' 08.69""",306.00,0.9800,1,1,cras,28.031,30.743,27.019,30.743,19.466,-14.065,-1.917,-13.493,2035,fsxp_missionobjects.bgl +397,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17803084,-2.26707229,"N51° 10' 40.91""","W2° 16' 01.46""",7.20,0.8500,1,1,cras,24.312,26.665,23.435,26.665,16.884,-12.2,-1.663,-11.703,2035,fsxp_missionobjects.bgl +398,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18025346,-2.27038616,"N51° 10' 48.91""","W2° 16' 13.39""",18.00,0.6500,1,1,cras,18.592,20.391,17.921,20.391,12.911,-9.329,-1.271,-8.949,2035,fsxp_missionobjects.bgl +399,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18167521,-2.26928897,"N51° 10' 54.03""","W2° 16' 09.44""",333.00,0.6500,1,1,cras,18.592,20.391,17.921,20.391,12.911,-9.329,-1.271,-8.949,2035,fsxp_missionobjects.bgl +400,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18167521,-2.26751117,"N51° 10' 54.03""","W2° 16' 03.04""",189.00,1.3000,1,1,cras,37.183,40.782,35.842,40.782,25.823,-18.658,-2.543,-17.899,2035,fsxp_missionobjects.bgl +401,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18144125,-2.26756389,"N51° 10' 53.19""","W2° 16' 03.23""",315.00,0.8500,1,1,cras,24.312,26.665,23.435,26.665,16.884,-12.2,-1.663,-11.703,2035,fsxp_missionobjects.bgl +402,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18123428,-2.26932501,"N51° 10' 52.44""","W2° 16' 09.57""",27.00,1.1650,1,1,cras,33.322,36.547,32.12,36.547,23.141,-16.72,-2.279,-16.04,2035,fsxp_missionobjects.bgl +403,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17978554,-2.26838063,"N51° 10' 47.23""","W2° 16' 06.17""",333.00,0.7500,1,1,cras,21.452,23.528,20.678,23.528,14.898,-10.764,-1.467,-10.326,2035,fsxp_missionobjects.bgl +404,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17869673,-2.26467784,"N51° 10' 43.31""","W2° 15' 52.84""",270.00,0.9560,1,1,cras,27.344,29.99,26.358,29.99,18.99,-13.721,-1.87,-13.162,2035,fsxp_missionobjects.bgl +405,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17893069,-2.26626671,"N51° 10' 44.15""","W2° 15' 58.56""",315.00,0.9560,1,1,cras,27.344,29.99,26.358,29.99,18.99,-13.721,-1.87,-13.162,2035,fsxp_missionobjects.bgl +406,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17863374,-2.26293893,"N51° 10' 43.08""","W2° 15' 46.58""",261.00,0.8000,1,1,cras,22.882,25.096,22.057,25.096,15.891,-11.482,-1.565,-11.015,2035,fsxp_missionobjects.bgl +407,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17763491,-2.26169723,"N51° 10' 39.49""","W2° 15' 42.11""",234.00,1.5000,1,1,cras,42.904,47.056,41.356,47.056,29.796,-21.529,-2.934,-20.652,2035,fsxp_missionobjects.bgl +408,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18091034,-2.26180561,"N51° 10' 51.28""","W2° 15' 42.50""",261.00,0.3530,1,1,cras,10.097,11.074,9.733,11.074,7.012,-5.066,-0.69,-4.86,2035,fsxp_missionobjects.bgl +409,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17897568,-2.26024171,"N51° 10' 44.31""","W2° 15' 36.87""",261.00,1.5500,1,1,cras,44.334,48.624,42.735,48.624,30.789,-22.246,-3.032,-21.341,2035,fsxp_missionobjects.bgl +410,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17870573,-2.25983611,"N51° 10' 43.34""","W2° 15' 35.41""",261.00,1.3000,1,1,cras,37.183,40.782,35.842,40.782,25.823,-18.658,-2.543,-17.899,2035,fsxp_missionobjects.bgl +411,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.18526558,-2.26051949,"N51° 11' 06.96""","W2° 15' 37.87""",351.00,0.4000,1,1,cras,8.163,9.941,8.586,9.941,5.924,-4.081,-0.529,-4.293,1990,fsxp_missionobjects.bgl +412,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.18048741,-2.26708612,"N51° 10' 49.75""","W2° 16' 01.51""",171.00,1.1000,1,1,cras,22.449,27.338,23.612,27.338,16.29,-11.224,-1.456,-11.806,1990,fsxp_missionobjects.bgl +413,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.18073037,-2.26726390,"N51° 10' 50.63""","W2° 16' 02.15""",225.00,1.1000,1,1,cras,22.449,27.338,23.612,27.338,16.29,-11.224,-1.456,-11.806,1990,fsxp_missionobjects.bgl +414,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.18089234,-2.26737781,"N51° 10' 51.21""","W2° 16' 02.56""",27.00,0.8500,1,1,cras,17.347,21.125,18.246,21.125,12.588,-8.673,-1.125,-9.123,1990,fsxp_missionobjects.bgl +415,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17998350,-2.26790286,"N51° 10' 47.94""","W2° 16' 04.45""",18.00,1.5000,1,1,cras,30.612,37.279,32.198,37.279,22.214,-15.305,-1.986,-16.099,1990,fsxp_missionobjects.bgl +416,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.18028945,-2.26895562,"N51° 10' 49.04""","W2° 16' 08.24""",36.00,0.7000,1,1,cras,14.285,17.397,15.026,17.397,10.366,-7.143,-0.927,-7.513,1990,fsxp_missionobjects.bgl +417,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17886770,-2.26580839,"N51° 10' 43.92""","W2° 15' 56.91""",63.00,1.5000,1,1,cras,30.612,37.279,32.198,37.279,22.214,-15.305,-1.986,-16.099,1990,fsxp_missionobjects.bgl +418,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17867873,-2.26677784,"N51° 10' 43.24""","W2° 16' 00.40""",225.00,0.8800,1,1,cras,17.959,21.87,18.89,21.87,13.032,-8.979,-1.165,-9.445,1990,fsxp_missionobjects.bgl +419,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.18057740,-2.25884168,"N51° 10' 50.08""","W2° 15' 31.83""",306.00,1.2000,1,1,cras,24.489,29.823,25.759,29.823,17.771,-12.244,-1.588,-12.879,1990,fsxp_missionobjects.bgl +420,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.18064939,-2.25741668,"N51° 10' 50.34""","W2° 15' 26.70""",81.00,1.1000,1,1,cras,22.449,27.338,23.612,27.338,16.29,-11.224,-1.456,-11.806,1990,fsxp_missionobjects.bgl +421,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18190017,-2.26275285,"N51° 10' 54.84""","W2° 15' 45.91""",355.50,0.4500,1,1,cras,13.903,11.133,13.567,11.133,9.713,-6.952,-0.028,-6.783,1806,fsxp_missionobjects.bgl +422,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18198115,-2.26526951,"N51° 10' 55.13""","W2° 15' 54.97""",229.50,0.7000,1,1,cras,21.628,17.318,21.104,17.318,15.109,-10.814,-0.044,-10.552,1806,fsxp_missionobjects.bgl +423,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17643812,-2.26828893,"N51° 10' 35.18""","W2° 16' 05.84""",202.50,0.8500,1,1,cras,26.262,21.029,25.627,21.029,18.347,-13.131,-0.054,-12.813,1806,fsxp_missionobjects.bgl +424,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18152223,-2.26932501,"N51° 10' 53.48""","W2° 16' 09.57""",121.50,0.6500,1,1,cras,20.083,16.081,19.597,16.081,14.03,-10.041,-0.041,-9.798,1806,fsxp_missionobjects.bgl +425,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18150424,-2.27109728,"N51° 10' 53.42""","W2° 16' 15.95""",337.50,0.5000,1,1,cras,15.448,12.37,15.074,12.37,10.792,-7.724,-0.032,-7.537,1806,fsxp_missionobjects.bgl +426,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18302497,-2.27194453,"N51° 10' 58.89""","W2° 16' 19.00""",310.50,0.7000,1,1,cras,21.628,17.318,21.104,17.318,15.109,-10.814,-0.044,-10.552,1806,fsxp_missionobjects.bgl +427,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18282701,-2.27096946,"N51° 10' 58.18""","W2° 16' 15.49""",337.50,0.5000,1,1,cras,15.448,12.37,15.074,12.37,10.792,-7.724,-0.032,-7.537,1806,fsxp_missionobjects.bgl +428,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18143225,-2.26715561,"N51° 10' 53.16""","W2° 16' 01.76""",160.20,1.1000,1,1,cras,33.986,27.214,33.164,27.214,23.743,-16.993,-0.069,-16.582,1806,fsxp_missionobjects.bgl +429,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18068538,-2.26776950,"N51° 10' 50.47""","W2° 16' 03.97""",13.50,1.1000,1,1,cras,33.986,27.214,33.164,27.214,23.743,-16.993,-0.069,-16.582,1806,fsxp_missionobjects.bgl +430,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17849876,-2.26620007,"N51° 10' 42.60""","W2° 15' 58.32""",337.50,0.7500,1,1,cras,23.172,18.555,22.612,18.555,16.188,-11.586,-0.047,-11.306,1806,fsxp_missionobjects.bgl +431,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17850776,-2.26432781,"N51° 10' 42.63""","W2° 15' 51.58""",346.50,0.7500,1,1,cras,23.172,18.555,22.612,18.555,16.188,-11.586,-0.047,-11.306,1806,fsxp_missionobjects.bgl +432,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18092834,-2.25985841,"N51° 10' 51.34""","W2° 15' 35.49""",310.50,0.6000,1,1,cras,18.538,14.844,18.089,14.844,12.951,-9.269,-0.038,-9.045,1806,fsxp_missionobjects.bgl +433,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18088335,-2.25939724,"N51° 10' 51.18""","W2° 15' 33.83""",324.00,1.0000,1,1,cras,30.896,24.74,30.149,24.74,21.584,-15.448,-0.063,-15.074,1806,fsxp_missionobjects.bgl +434,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17963256,-2.25873062,"N51° 10' 46.68""","W2° 15' 31.43""",333.00,1.3000,1,1,cras,40.165,32.162,39.194,32.162,28.06,-20.082,-0.082,-19.596,1806,fsxp_missionobjects.bgl +435,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Copy - NEW - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18150424,-2.25580836,"N51° 10' 53.42""","W2° 15' 20.91""",292.50,0.7500,1,1,cras,23.172,18.555,22.612,18.555,16.188,-11.586,-0.047,-11.306,1806,fsxp_missionobjects.bgl +436,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - NEW - Copy - Longleat_Tree_Oak_01,tree,51.18097333,-2.25533059,"N51° 10' 51.50""","W2° 15' 19.19""",36.00,0.7000,1,1,cras,20.022,21.959,19.3,21.959,13.905,-10.047,-1.369,-9.638,2035,fsxp_missionobjects.bgl +437,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - NEW - Copy - Longleat_Tree_Oak_01,tree,51.18124328,-2.25528063,"N51° 10' 52.48""","W2° 15' 19.01""",324.00,0.6500,1,1,cras,18.592,20.391,17.921,20.391,12.911,-9.329,-1.271,-8.949,2035,fsxp_missionobjects.bgl +438,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - NEW - Copy - Longleat_Tree_Oak_01,tree,51.18729022,-2.27041391,"N51° 11' 14.24""","W2° 16' 13.49""",45.00,1.0000,1,1,cras,28.603,31.371,27.571,31.371,19.864,-14.352,-1.956,-13.768,2035,fsxp_missionobjects.bgl +439,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - NEW - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18136926,-2.25742229,"N51° 10' 52.93""","W2° 15' 26.72""",234.00,1.2500,0,1,bbox,9.794,4.693,9.755,4.693,6.912,-5.526,-0.115,-4.964,0,vegetation.bgl +440,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - NEW copy - Copy - Longleat_Tree_Oak_01,tree,51.18684030,-2.26904171,"N51° 11' 12.63""","W2° 16' 08.55""",225.00,0.8500,1,1,cras,24.312,26.665,23.435,26.665,16.884,-12.2,-1.663,-11.703,2035,fsxp_missionobjects.bgl +441,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - NEW- Copy - Longleat_Tree_Oak_01,tree,51.18724523,-2.25854723,"N51° 11' 14.08""","W2° 15' 30.77""",153.00,0.5000,1,1,cras,14.301,15.685,13.785,15.685,9.932,-7.176,-0.978,-6.884,2035,fsxp_missionobjects.bgl +442,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - NEW- Copy - Longleat_Tree_Oak_01,tree,51.18732622,-2.25894168,"N51° 11' 14.37""","W2° 15' 32.19""",153.00,0.7000,1,1,cras,20.022,21.959,19.3,21.959,13.905,-10.047,-1.369,-9.638,2035,fsxp_missionobjects.bgl +443,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - NEW- Copy - Longleat_Tree_Oak_01,tree,51.18728123,-2.25932230,"N51° 11' 14.21""","W2° 15' 33.56""",153.00,0.5000,1,1,cras,14.301,15.685,13.785,15.685,9.932,-7.176,-0.978,-6.884,2035,fsxp_missionobjects.bgl +444,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - NEW- Copy - Longleat_Tree_Oak_01,tree,51.18694828,-2.26013057,"N51° 11' 13.01""","W2° 15' 36.47""",153.00,0.5000,1,1,cras,14.301,15.685,13.785,15.685,9.932,-7.176,-0.978,-6.884,2035,fsxp_missionobjects.bgl +445,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Copy - NEW- Copy - Longleat_Tree_Oak_01,tree,51.18709226,-2.26142784,"N51° 11' 13.53""","W2° 15' 41.14""",153.00,0.7000,1,1,cras,20.022,21.959,19.3,21.959,13.905,-10.047,-1.369,-9.638,2035,fsxp_missionobjects.bgl +446,RedBull_Hangar_01,MIS_RedBull_Hangars,Copy - Copy - Copy - Copy - RedBull_Hangar_01,building,51.18358287,-2.27334724,"N51° 11' 00.90""","W2° 16' 24.05""",254.70,1.0000,1,1,cras,13.129,8.122,16.582,8.122,10.575,-6.581,-0.085,-9.272,57,fsxp_missionobjects.bgl +447,veg_tb_bush_Bayberry_3_5ft,veg_tb_bush_Bayberry_3_5ft,Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_3_5ft,bush,51.17774289,-2.26112785,"N51° 10' 39.87""","W2° 15' 40.06""",0.00,1.5000,0,1,bbox,7.398,2.383,7.054,2.383,5.111,-4.032,-0.083,-3.413,0,vegetation.bgl +448,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18425775,-2.27053897,"N51° 11' 03.33""","W2° 16' 13.94""",144.00,0.5000,0,1,bbox,3.918,1.877,3.902,1.877,2.765,-2.211,-0.046,-1.985,0,vegetation.bgl +449,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18540055,-2.26446670,"N51° 11' 07.44""","W2° 15' 52.08""",99.00,0.6500,0,1,bbox,5.093,2.441,5.073,2.441,3.594,-2.874,-0.06,-2.581,0,vegetation.bgl +450,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18590447,-2.26642504,"N51° 11' 09.26""","W2° 15' 59.13""",90.00,0.6500,0,1,bbox,5.093,2.441,5.073,2.441,3.594,-2.874,-0.06,-2.581,0,vegetation.bgl +451,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18396981,-2.25996947,"N51° 11' 02.29""","W2° 15' 35.89""",108.00,1.0000,0,1,bbox,7.835,3.755,7.804,3.755,5.529,-4.421,-0.092,-3.971,0,vegetation.bgl +452,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.17731997,-2.26705838,"N51° 10' 38.35""","W2° 16' 01.41""",108.00,1.0000,0,1,bbox,7.835,3.755,7.804,3.755,5.529,-4.421,-0.092,-3.971,0,vegetation.bgl +453,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.17879571,-2.26774452,"N51° 10' 43.66""","W2° 16' 03.88""",18.00,1.2500,0,1,bbox,9.794,4.693,9.755,4.693,6.912,-5.526,-0.115,-4.964,0,vegetation.bgl +454,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18091934,-2.26971393,"N51° 10' 51.31""","W2° 16' 10.97""",90.00,1.5000,0,1,bbox,11.753,5.632,11.706,5.632,8.294,-6.632,-0.138,-5.956,0,vegetation.bgl +455,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.17758092,-2.26976112,"N51° 10' 39.29""","W2° 16' 11.14""",234.00,1.0000,0,1,bbox,7.835,3.755,7.804,3.755,5.529,-4.421,-0.092,-3.971,0,vegetation.bgl +456,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18061339,-2.26915285,"N51° 10' 50.21""","W2° 16' 08.95""",18.00,1.2560,0,1,bbox,9.841,4.716,9.802,4.716,6.945,-5.553,-0.115,-4.987,0,vegetation.bgl +457,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18560752,-2.26593889,"N51° 11' 08.19""","W2° 15' 57.38""",0.00,0.4500,0,1,bbox,3.526,1.69,3.512,1.69,2.488,-1.989,-0.041,-1.787,0,vegetation.bgl +458,Longleat_Tree_BlackPoplar_02,Longleat_Tree_BlackPoplar_02,Copy - Copy - Copy - Longleat_Tree_BlackPoplar_02,tree,51.18102732,-2.26399169,"N51° 10' 51.70""","W2° 15' 50.37""",45.00,0.2500,1,1,cras,4.986,5.329,5.848,5.329,3.843,-2.493,-0.324,-2.924,1715,fsxp_missionobjects.bgl +459,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18396981,-2.27327507,"N51° 11' 02.29""","W2° 16' 23.79""",135.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +460,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18134227,-2.27205282,"N51° 10' 52.83""","W2° 16' 19.39""",18.00,0.8500,1,1,cras,2.409,3.39,2.304,3.39,1.667,-1.242,-0.005,-1.152,52,fsxp_missionobjects.bgl +461,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18087435,-2.27181394,"N51° 10' 51.15""","W2° 16' 18.53""",72.00,0.9000,1,1,cras,2.551,3.589,2.44,3.589,1.765,-1.315,-0.006,-1.22,52,fsxp_missionobjects.bgl +462,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17776089,-2.27036395,"N51° 10' 39.94""","W2° 16' 13.31""",36.00,1.1500,1,1,cras,3.259,4.587,3.117,4.587,2.255,-1.68,-0.007,-1.558,52,fsxp_missionobjects.bgl +463,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18436574,-2.26325836,"N51° 11' 03.72""","W2° 15' 47.73""",315.00,0.9800,1,1,cras,2.777,3.909,2.657,3.909,1.922,-1.432,-0.006,-1.328,52,fsxp_missionobjects.bgl +464,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17805784,-2.26204726,"N51° 10' 41.01""","W2° 15' 43.37""",0.00,0.5000,1,1,cras,1.417,1.994,1.355,1.994,0.98,-0.731,-0.003,-0.678,52,fsxp_missionobjects.bgl +465,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17881371,-2.26275561,"N51° 10' 43.73""","W2° 15' 45.92""",324.00,1.2000,1,1,cras,3.401,4.786,3.253,4.786,2.353,-1.753,-0.008,-1.626,52,fsxp_missionobjects.bgl +466,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.17811183,-2.27034727,"N51° 10' 41.20""","W2° 16' 13.25""",27.00,0.5500,1,1,cras,12.719,18.472,12.194,18.472,8.81,-6.343,-1.738,-6.097,7566,fsxp_missionobjects.bgl +467,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.17841778,-2.27190002,"N51° 10' 42.30""","W2° 16' 18.84""",306.00,0.7500,1,1,cras,17.344,25.189,16.629,25.189,12.014,-8.649,-2.37,-8.314,7566,fsxp_missionobjects.bgl +468,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18048741,-2.25977501,"N51° 10' 49.75""","W2° 15' 35.19""",81.00,1.3000,1,1,cras,30.064,43.66,28.823,43.66,20.824,-14.992,-4.109,-14.411,7566,fsxp_missionobjects.bgl +469,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18525658,-2.26065838,"N51° 11' 06.92""","W2° 15' 38.37""",90.00,0.4000,1,1,cras,7.041,9.169,6.354,9.169,4.742,-3.508,-1.017,-3.171,1787,fsxp_missionobjects.bgl +470,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18522059,-2.26043056,"N51° 11' 06.79""","W2° 15' 37.55""",72.00,0.6000,1,1,cras,10.561,13.754,9.53,13.754,7.113,-5.262,-1.525,-4.756,1787,fsxp_missionobjects.bgl +471,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18516660,-2.25856391,"N51° 11' 06.60""","W2° 15' 30.83""",135.00,0.3500,1,1,cras,6.161,8.023,5.559,8.023,4.149,-3.07,-0.89,-2.774,1787,fsxp_missionobjects.bgl +472,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.17882271,-2.26812783,"N51° 10' 43.76""","W2° 16' 05.26""",0.00,0.9000,1,1,cras,15.842,20.631,14.295,20.631,10.669,-7.894,-2.287,-7.134,1787,fsxp_missionobjects.bgl +473,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.17996551,-2.27118890,"N51° 10' 47.88""","W2° 16' 16.28""",135.00,0.6000,1,1,cras,10.561,13.754,9.53,13.754,7.113,-5.262,-1.525,-4.756,1787,fsxp_missionobjects.bgl +474,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.17821081,-2.27040008,"N51° 10' 41.56""","W2° 16' 13.44""",333.00,0.5500,1,1,cras,9.681,12.608,8.736,12.608,6.52,-4.824,-1.398,-4.36,1787,fsxp_missionobjects.bgl +475,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.17860674,-2.27059169,"N51° 10' 42.98""","W2° 16' 14.13""",135.00,0.3500,1,1,cras,6.161,8.023,5.559,8.023,4.149,-3.07,-0.89,-2.774,1787,fsxp_missionobjects.bgl +476,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.17877771,-2.27065841,"N51° 10' 43.60""","W2° 16' 14.37""",135.00,0.2500,1,1,cras,4.401,5.731,3.971,5.731,2.964,-2.193,-0.635,-1.982,1787,fsxp_missionobjects.bgl +477,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.17775189,-2.27154169,"N51° 10' 39.91""","W2° 16' 17.55""",315.00,0.9500,1,1,cras,16.722,21.777,15.09,21.777,11.262,-8.332,-2.415,-7.53,1787,fsxp_missionobjects.bgl +478,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.17832779,-2.26340840,"N51° 10' 41.98""","W2° 15' 48.27""",0.00,0.3500,1,1,cras,6.161,8.023,5.559,8.023,4.149,-3.07,-0.89,-2.774,1787,fsxp_missionobjects.bgl +479,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.18378084,-2.25973897,"N51° 11' 01.61""","W2° 15' 35.06""",18.00,1.0000,1,1,cras,18.541,25.126,17.31,25.126,12.683,-9.14,-3.157,-8.655,1828,fsxp_missionobjects.bgl +480,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.18293499,-2.27159173,"N51° 10' 58.57""","W2° 16' 17.73""",63.00,0.8500,1,1,cras,15.76,21.357,14.713,21.357,10.78,-7.769,-2.683,-7.356,1828,fsxp_missionobjects.bgl +481,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.18273702,-2.27113618,"N51° 10' 57.85""","W2° 16' 16.09""",36.00,0.7500,1,1,cras,13.906,18.845,12.982,18.845,9.512,-6.855,-2.368,-6.491,1828,fsxp_missionobjects.bgl +482,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.18082036,-2.27160003,"N51° 10' 50.95""","W2° 16' 17.76""",18.00,0.3000,1,1,cras,5.562,7.538,5.193,7.538,3.805,-2.742,-0.947,-2.596,1828,fsxp_missionobjects.bgl +483,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.18479766,-2.27309729,"N51° 11' 05.27""","W2° 16' 23.15""",63.00,0.6000,1,1,cras,11.125,15.076,10.386,15.076,7.61,-5.484,-1.894,-5.193,1828,fsxp_missionobjects.bgl +484,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.18386182,-2.27441392,"N51° 11' 01.90""","W2° 16' 27.89""",63.00,0.7500,1,1,cras,13.906,18.845,12.982,18.845,9.512,-6.855,-2.368,-6.491,1828,fsxp_missionobjects.bgl +485,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18181918,-2.26516667,"N51° 10' 54.55""","W2° 15' 54.60""",252.00,1.0000,1,1,cras,28.603,31.371,27.571,31.371,19.864,-14.352,-1.956,-13.768,2035,fsxp_missionobjects.bgl +486,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18540055,-2.26654449,"N51° 11' 07.44""","W2° 15' 59.56""",234.00,0.2500,1,1,cras,7.151,7.843,6.893,7.843,4.966,-3.588,-0.489,-3.442,2035,fsxp_missionobjects.bgl +487,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17627615,-2.27074449,"N51° 10' 34.59""","W2° 16' 14.68""",0.00,1.1500,1,1,cras,32.893,36.076,31.707,36.076,22.843,-16.505,-2.249,-15.834,2035,fsxp_missionobjects.bgl +488,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17800385,-2.26829723,"N51° 10' 40.81""","W2° 16' 05.87""",340.20,1.3000,1,1,cras,37.183,40.782,35.842,40.782,25.823,-18.658,-2.543,-17.899,2035,fsxp_missionobjects.bgl +489,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18016347,-2.27033059,"N51° 10' 48.59""","W2° 16' 13.19""",306.00,0.5000,1,1,cras,14.301,15.685,13.785,15.685,9.932,-7.176,-0.978,-6.884,2035,fsxp_missionobjects.bgl +490,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18091934,-2.27003613,"N51° 10' 51.31""","W2° 16' 12.13""",0.00,0.8500,1,1,cras,24.312,26.665,23.435,26.665,16.884,-12.2,-1.663,-11.703,2035,fsxp_missionobjects.bgl +491,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.18280901,-2.27169726,"N51° 10' 58.11""","W2° 16' 18.11""",54.00,1.1000,1,1,cras,31.463,34.508,30.328,34.508,21.85,-15.788,-2.152,-15.145,2035,fsxp_missionobjects.bgl +492,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17961457,-2.26265285,"N51° 10' 46.61""","W2° 15' 45.55""",261.00,1.5500,1,1,cras,44.334,48.624,42.735,48.624,30.789,-22.246,-3.032,-21.341,2035,fsxp_missionobjects.bgl +493,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - Longleat_Tree_Oak_01,tree,51.17851676,-2.26583890,"N51° 10' 42.66""","W2° 15' 57.02""",315.00,0.9560,1,1,cras,27.344,29.99,26.358,29.99,18.99,-13.721,-1.87,-13.162,2035,fsxp_missionobjects.bgl +494,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.18525658,-2.26428615,"N51° 11' 06.92""","W2° 15' 51.43""",54.00,0.5000,1,1,cras,10.204,12.426,10.733,12.426,7.405,-5.102,-0.662,-5.366,1990,fsxp_missionobjects.bgl +495,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.18580548,-2.26083892,"N51° 11' 08.90""","W2° 15' 39.02""",351.00,0.7500,1,1,cras,15.306,18.639,16.099,18.639,11.107,-7.653,-0.993,-8.049,1990,fsxp_missionobjects.bgl +496,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.18534656,-2.25783619,"N51° 11' 07.25""","W2° 15' 28.21""",351.00,0.9000,1,1,cras,18.367,22.367,19.319,22.367,13.328,-9.183,-1.191,-9.659,1990,fsxp_missionobjects.bgl +497,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.18508561,-2.25787223,"N51° 11' 06.31""","W2° 15' 28.34""",63.00,0.7500,1,1,cras,15.306,18.639,16.099,18.639,11.107,-7.653,-0.993,-8.049,1990,fsxp_missionobjects.bgl +498,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.18470768,-2.25772781,"N51° 11' 04.95""","W2° 15' 27.82""",351.00,0.7500,1,1,cras,15.306,18.639,16.099,18.639,11.107,-7.653,-0.993,-8.049,1990,fsxp_missionobjects.bgl +499,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.18463569,-2.25751952,"N51° 11' 04.69""","W2° 15' 27.07""",18.00,0.6500,1,1,cras,13.265,16.154,13.953,16.154,9.626,-6.632,-0.86,-6.976,1990,fsxp_missionobjects.bgl +500,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17772490,-2.26938335,"N51° 10' 39.81""","W2° 16' 09.78""",135.00,0.8500,1,1,cras,17.347,21.125,18.246,21.125,12.588,-8.673,-1.125,-9.123,1990,fsxp_missionobjects.bgl +501,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17788687,-2.26704447,"N51° 10' 40.39""","W2° 16' 01.36""",207.00,1.0000,1,1,cras,20.408,24.853,21.466,24.853,14.809,-10.204,-1.324,-10.732,1990,fsxp_missionobjects.bgl +502,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17823781,-2.26688613,"N51° 10' 41.66""","W2° 16' 00.79""",171.00,1.2000,1,1,cras,24.489,29.823,25.759,29.823,17.771,-12.244,-1.588,-12.879,1990,fsxp_missionobjects.bgl +503,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17896668,-2.26791115,"N51° 10' 44.28""","W2° 16' 04.48""",27.00,0.8500,1,1,cras,17.347,21.125,18.246,21.125,12.588,-8.673,-1.125,-9.123,1990,fsxp_missionobjects.bgl +504,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17882271,-2.26990278,"N51° 10' 43.76""","W2° 16' 11.65""",81.00,0.8500,1,1,cras,17.347,21.125,18.246,21.125,12.588,-8.673,-1.125,-9.123,1990,fsxp_missionobjects.bgl +505,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.18006449,-2.27028063,"N51° 10' 48.23""","W2° 16' 13.01""",9.00,0.6500,1,1,cras,13.265,16.154,13.953,16.154,9.626,-6.632,-0.86,-6.976,1990,fsxp_missionobjects.bgl +506,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17743695,-2.27001945,"N51° 10' 38.77""","W2° 16' 12.07""",243.00,0.5500,1,1,cras,11.224,13.669,11.806,13.669,8.145,-5.612,-0.728,-5.903,1990,fsxp_missionobjects.bgl +507,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17935361,-2.26770840,"N51° 10' 45.67""","W2° 16' 03.75""",171.00,1.4500,1,1,cras,29.591,36.036,31.125,36.036,21.473,-14.795,-1.919,-15.562,1990,fsxp_missionobjects.bgl +508,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.18027145,-2.26816119,"N51° 10' 48.98""","W2° 16' 05.38""",63.00,1.5000,1,1,cras,30.612,37.279,32.198,37.279,22.214,-15.305,-1.986,-16.099,1990,fsxp_missionobjects.bgl +509,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.18106331,-2.25945281,"N51° 10' 51.83""","W2° 15' 34.03""",36.00,0.7500,1,1,cras,15.306,18.639,16.099,18.639,11.107,-7.653,-0.993,-8.049,1990,fsxp_missionobjects.bgl +510,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.18095533,-2.25894445,"N51° 10' 51.44""","W2° 15' 32.20""",342.00,1.0000,1,1,cras,20.408,24.853,21.466,24.853,14.809,-10.204,-1.324,-10.732,1990,fsxp_missionobjects.bgl +511,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.18069438,-2.25691116,"N51° 10' 50.50""","W2° 15' 24.88""",45.00,1.3000,1,1,cras,26.53,32.308,27.905,32.308,19.252,-13.265,-1.721,-13.952,1990,fsxp_missionobjects.bgl +512,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17982153,-2.25782781,"N51° 10' 47.36""","W2° 15' 28.18""",63.00,0.8500,1,1,cras,17.347,21.125,18.246,21.125,12.588,-8.673,-1.125,-9.123,1990,fsxp_missionobjects.bgl +513,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.17941660,-2.25819452,"N51° 10' 45.90""","W2° 15' 29.50""",72.00,0.7000,1,1,cras,14.285,17.397,15.026,17.397,10.366,-7.143,-0.927,-7.513,1990,fsxp_missionobjects.bgl +514,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18201715,-2.26428062,"N51° 10' 55.26""","W2° 15' 51.41""",355.50,0.5500,1,1,cras,16.993,13.607,16.582,13.607,11.871,-8.496,-0.035,-8.291,1806,fsxp_missionobjects.bgl +515,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18184618,-2.26410837,"N51° 10' 54.65""","W2° 15' 50.79""",292.50,0.5600,1,1,cras,17.302,13.854,16.883,13.854,12.087,-8.651,-0.035,-8.442,1806,fsxp_missionobjects.bgl +516,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18192716,-2.26468060,"N51° 10' 54.94""","W2° 15' 52.85""",292.50,1.0000,1,1,cras,30.896,24.74,30.149,24.74,21.584,-15.448,-0.063,-15.074,1806,fsxp_missionobjects.bgl +517,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18522958,-2.26296390,"N51° 11' 06.83""","W2° 15' 46.67""",274.50,1.2000,1,1,cras,37.076,29.688,36.179,29.688,25.901,-18.538,-0.076,-18.089,1806,fsxp_missionobjects.bgl +518,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18338491,-2.26005564,"N51° 11' 00.19""","W2° 15' 36.20""",274.50,0.3500,1,1,cras,10.814,8.659,10.552,8.659,7.555,-5.407,-0.022,-5.276,1806,fsxp_missionobjects.bgl +519,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18137826,-2.25963612,"N51° 10' 52.96""","W2° 15' 34.69""",283.50,0.3500,1,1,cras,10.814,8.659,10.552,8.659,7.555,-5.407,-0.022,-5.276,1806,fsxp_missionobjects.bgl +520,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17607819,-2.27052229,"N51° 10' 33.88""","W2° 16' 13.88""",130.50,0.8500,1,1,cras,26.262,21.029,25.627,21.029,18.347,-13.131,-0.054,-12.813,1806,fsxp_missionobjects.bgl +521,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18041543,-2.27039170,"N51° 10' 49.50""","W2° 16' 13.41""",121.50,0.7500,1,1,cras,23.172,18.555,22.612,18.555,16.188,-11.586,-0.047,-11.306,1806,fsxp_missionobjects.bgl +522,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18159422,-2.27117507,"N51° 10' 53.74""","W2° 16' 16.23""",337.50,0.6500,1,1,cras,20.083,16.081,19.597,16.081,14.03,-10.041,-0.041,-9.798,1806,fsxp_missionobjects.bgl +523,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18315995,-2.27178896,"N51° 10' 59.38""","W2° 16' 18.44""",50.40,1.0000,1,1,cras,30.896,24.74,30.149,24.74,21.584,-15.448,-0.063,-15.074,1806,fsxp_missionobjects.bgl +524,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18302497,-2.27133893,"N51° 10' 58.89""","W2° 16' 16.82""",49.50,1.2500,1,1,cras,38.621,30.925,37.686,30.925,26.981,-19.31,-0.079,-18.843,1806,fsxp_missionobjects.bgl +525,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18172020,-2.26708059,"N51° 10' 54.19""","W2° 16' 01.49""",97.20,1.2500,1,1,cras,38.621,30.925,37.686,30.925,26.981,-19.31,-0.079,-18.843,1806,fsxp_missionobjects.bgl +526,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18155823,-2.26780563,"N51° 10' 53.61""","W2° 16' 04.10""",355.50,0.7500,1,1,cras,23.172,18.555,22.612,18.555,16.188,-11.586,-0.047,-11.306,1806,fsxp_missionobjects.bgl +527,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18001050,-2.26688613,"N51° 10' 48.04""","W2° 16' 00.79""",355.50,1.0000,1,1,cras,30.896,24.74,30.149,24.74,21.584,-15.448,-0.063,-15.074,1806,fsxp_missionobjects.bgl +528,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18108131,-2.26802506,"N51° 10' 51.89""","W2° 16' 04.89""",337.50,1.0000,1,1,cras,30.896,24.74,30.149,24.74,21.584,-15.448,-0.063,-15.074,1806,fsxp_missionobjects.bgl +529,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17875972,-2.26642504,"N51° 10' 43.53""","W2° 15' 59.13""",319.50,0.7500,1,1,cras,23.172,18.555,22.612,18.555,16.188,-11.586,-0.047,-11.306,1806,fsxp_missionobjects.bgl +530,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17837278,-2.26559171,"N51° 10' 42.14""","W2° 15' 56.13""",319.50,1.0000,1,1,cras,30.896,24.74,30.149,24.74,21.584,-15.448,-0.063,-15.074,1806,fsxp_missionobjects.bgl +531,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18026245,-2.26053340,"N51° 10' 48.94""","W2° 15' 37.92""",324.00,0.8500,1,1,cras,26.262,21.029,25.627,21.029,18.347,-13.131,-0.054,-12.813,1806,fsxp_missionobjects.bgl +532,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - Longleat_Tree_Oak_03,tree,51.17929962,-2.26004172,"N51° 10' 45.48""","W2° 15' 36.15""",333.00,1.3000,1,1,cras,40.165,32.162,39.194,32.162,28.06,-20.082,-0.082,-19.596,1806,fsxp_missionobjects.bgl +533,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Copy - NEW - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18315095,-2.25773896,"N51° 10' 59.34""","W2° 15' 27.86""",292.50,0.8500,1,1,cras,26.262,21.029,25.627,21.029,18.347,-13.131,-0.054,-12.813,1806,fsxp_missionobjects.bgl +534,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - NEW - Copy - Longleat_Tree_Oak_01,tree,51.18324993,-2.25819167,"N51° 10' 59.70""","W2° 15' 29.49""",324.00,0.7500,1,1,cras,21.452,23.528,20.678,23.528,14.898,-10.764,-1.467,-10.326,2035,fsxp_missionobjects.bgl +535,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - NEW - Copy - Longleat_Tree_Oak_01,tree,51.18372685,-2.25789168,"N51° 11' 01.42""","W2° 15' 28.41""",72.00,0.8000,1,1,cras,22.882,25.096,22.057,25.096,15.891,-11.482,-1.565,-11.015,2035,fsxp_missionobjects.bgl +536,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - NEW - Copy - Longleat_Tree_Oak_01,tree,51.18403279,-2.25786117,"N51° 11' 02.52""","W2° 15' 28.30""",36.00,0.7000,1,1,cras,20.022,21.959,19.3,21.959,13.905,-10.047,-1.369,-9.638,2035,fsxp_missionobjects.bgl +537,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - NEW - Copy - Longleat_Tree_Oak_01,tree,51.18271902,-2.25774449,"N51° 10' 57.79""","W2° 15' 27.88""",9.00,1.0000,1,1,cras,28.603,31.371,27.571,31.371,19.864,-14.352,-1.956,-13.768,2035,fsxp_missionobjects.bgl +538,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - NEW - Copy - Longleat_Tree_Oak_01,tree,51.18719124,-2.25781113,"N51° 11' 13.89""","W2° 15' 28.12""",36.00,0.6000,1,1,cras,17.162,18.822,16.543,18.822,11.918,-8.611,-1.174,-8.261,2035,fsxp_missionobjects.bgl +539,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - NEW - Copy - Longleat_Tree_Oak_01,tree,51.18594046,-2.26485008,"N51° 11' 09.39""","W2° 15' 53.46""",72.00,0.8500,1,1,cras,24.312,26.665,23.435,26.665,16.884,-12.2,-1.663,-11.703,2035,fsxp_missionobjects.bgl +540,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - NEW - Copy - Longleat_Tree_Oak_01,tree,51.18626440,-2.26559448,"N51° 11' 10.55""","W2° 15' 56.14""",45.00,1.0000,1,1,cras,28.603,31.371,27.571,31.371,19.864,-14.352,-1.956,-13.768,2035,fsxp_missionobjects.bgl +541,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - NEW - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18166621,-2.25716111,"N51° 10' 54.00""","W2° 15' 25.78""",270.00,1.5000,0,1,bbox,11.753,5.632,11.706,5.632,8.294,-6.632,-0.138,-5.956,0,vegetation.bgl +542,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - NEW copy - Copy - Longleat_Tree_Oak_01,tree,51.18684930,-2.26869453,"N51° 11' 12.66""","W2° 16' 07.30""",171.00,0.7000,1,1,cras,20.022,21.959,19.3,21.959,13.905,-10.047,-1.369,-9.638,2035,fsxp_missionobjects.bgl +543,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Copy - NEW- Copy - Longleat_Tree_Oak_01,tree,51.18674132,-2.26081948,"N51° 11' 12.27""","W2° 15' 38.95""",153.00,0.5000,1,1,cras,14.301,15.685,13.785,15.685,9.932,-7.176,-0.978,-6.884,2035,fsxp_missionobjects.bgl +544,VEH_Land_car_truck3_white,VEH_Land_car_truck3_white,Copy - Copy - Copy - NEW Copy - VEH_Land_car_truck3_white,vehicle,51.18507661,-2.26236393,"N51° 11' 06.28""","W2° 15' 44.51""",87.30,1.0000,1,1,cras,4.253,4.44,13.93,4.44,7.282,-2.117,-0.11,-10.565,59,vehicles_land.bgl +545,RedBull_Hangar_01,MIS_RedBull_Hangars,Copy - Copy - Copy - RedBull_Hangar_01,building,51.18372685,-2.27341672,"N51° 11' 01.42""","W2° 16' 24.30""",254.70,1.0000,1,1,cras,13.129,8.122,16.582,8.122,10.575,-6.581,-0.085,-9.272,57,fsxp_missionobjects.bgl +546,veg_tb_bush_Bayberry_3_5ft,veg_tb_bush_Bayberry_3_5ft,Copy - Copy - Copy - veg_tb_bush_Bayberry_3_5ft,bush,51.17918264,-2.26205279,"N51° 10' 45.06""","W2° 15' 43.39""",0.00,1.5000,0,1,bbox,7.398,2.383,7.054,2.383,5.111,-4.032,-0.083,-3.413,0,vegetation.bgl +547,veg_tb_bush_Bayberry_4ft,veg_tb_bush_Bayberry_4ft,Copy - Copy - Copy - veg_tb_bush_Bayberry_4ft,bush,51.18570650,-2.26676116,"N51° 11' 08.54""","W2° 16' 00.34""",36.00,1.2500,0,1,bbox,3.737,2.273,3.548,2.273,2.577,-1.964,-0.04,-1.893,0,vegetation.bgl +548,veg_tb_bush_Bayberry_4ft,veg_tb_bush_Bayberry_4ft,Copy - Copy - Copy - veg_tb_bush_Bayberry_4ft,bush,51.18341190,-2.26016116,"N51° 11' 00.28""","W2° 15' 36.58""",315.00,1.2000,0,1,bbox,3.588,2.182,3.407,2.182,2.474,-1.885,-0.039,-1.818,0,vegetation.bgl +549,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18367286,-2.26800562,"N51° 11' 01.22""","W2° 16' 04.82""",108.00,0.5000,0,1,bbox,3.918,1.877,3.902,1.877,2.765,-2.211,-0.046,-1.985,0,vegetation.bgl +550,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18542755,-2.26453895,"N51° 11' 07.54""","W2° 15' 52.34""",63.00,1.0000,0,1,bbox,7.835,3.755,7.804,3.755,5.529,-4.421,-0.092,-3.971,0,vegetation.bgl +551,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18577849,-2.26636394,"N51° 11' 08.80""","W2° 15' 58.91""",90.00,0.8500,0,1,bbox,6.66,3.191,6.633,3.191,4.7,-3.758,-0.078,-3.375,0,vegetation.bgl +552,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18332192,-2.25996947,"N51° 10' 59.96""","W2° 15' 35.89""",63.00,1.0000,0,1,bbox,7.835,3.755,7.804,3.755,5.529,-4.421,-0.092,-3.971,0,vegetation.bgl +553,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18138726,-2.26111947,"N51° 10' 52.99""","W2° 15' 40.03""",0.00,0.8500,0,1,bbox,6.66,3.191,6.633,3.191,4.7,-3.758,-0.078,-3.375,0,vegetation.bgl +554,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.17748194,-2.26684447,"N51° 10' 38.93""","W2° 16' 00.64""",333.00,1.3500,0,1,bbox,10.578,5.069,10.535,5.069,7.465,-5.968,-0.124,-5.361,0,vegetation.bgl +555,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.17733797,-2.26710834,"N51° 10' 38.42""","W2° 16' 01.59""",90.00,1.2500,0,1,bbox,9.794,4.693,9.755,4.693,6.912,-5.526,-0.115,-4.964,0,vegetation.bgl +556,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.17869673,-2.26779448,"N51° 10' 43.31""","W2° 16' 04.06""",324.00,1.2500,0,1,bbox,9.794,4.693,9.755,4.693,6.912,-5.526,-0.115,-4.964,0,vegetation.bgl +557,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18092834,-2.26979448,"N51° 10' 51.34""","W2° 16' 11.26""",36.00,1.5000,0,1,bbox,11.753,5.632,11.706,5.632,8.294,-6.632,-0.138,-5.956,0,vegetation.bgl +558,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.17757192,-2.26981946,"N51° 10' 39.26""","W2° 16' 11.35""",189.00,1.0000,0,1,bbox,7.835,3.755,7.804,3.755,5.529,-4.421,-0.092,-3.971,0,vegetation.bgl +559,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18501362,-2.27325839,"N51° 11' 06.05""","W2° 16' 23.73""",135.00,0.9000,0,1,bbox,7.052,3.379,7.024,3.379,4.976,-3.979,-0.083,-3.574,0,vegetation.bgl +560,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18079336,-2.26917506,"N51° 10' 50.86""","W2° 16' 09.03""",45.00,1.2560,0,1,bbox,9.841,4.716,9.802,4.716,6.945,-5.553,-0.115,-4.987,0,vegetation.bgl +561,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18563451,-2.26589170,"N51° 11' 08.28""","W2° 15' 57.21""",0.00,0.6500,0,1,bbox,5.093,2.441,5.073,2.441,3.594,-2.874,-0.06,-2.581,0,vegetation.bgl +562,Gen_Crowd_Restroom02,Gen_Crowd_Restroom02,Copy - Copy - Gen_Crowd_Restroom02,props,51.18533757,-2.26354452,"N51° 11' 07.22""","W2° 15' 48.76""",180.00,1.0000,1,1,cras,1.658,2.239,8.341,2.239,4.252,-0.829,-0.179,-4.17,37,FSXP_People.bgl +563,Longleat_Tree_BlackPoplar_02,Longleat_Tree_BlackPoplar_02,Copy - Copy - Longleat_Tree_BlackPoplar_02,tree,51.18110831,-2.26394174,"N51° 10' 51.99""","W2° 15' 50.19""",18.00,0.2000,1,1,cras,3.989,4.263,4.678,4.263,3.074,-1.994,-0.26,-2.339,1715,fsxp_missionobjects.bgl +564,Longleat_Tree_BlackPoplar_03,Longleat_Tree_BlackPoplar_03,Copy - Copy - Longleat_Tree_BlackPoplar_03,tree,51.18100032,-2.26372230,"N51° 10' 51.60""","W2° 15' 49.40""",342.00,0.3000,1,1,cras,6.975,8.152,7.242,8.152,5.027,-3.488,-0.682,-3.621,1769,fsxp_missionobjects.bgl +565,Longleat_Tree_BlackPoplar_03,Longleat_Tree_BlackPoplar_03,Copy - Copy - Longleat_Tree_BlackPoplar_03,tree,51.17967756,-2.26340001,"N51° 10' 46.84""","W2° 15' 48.24""",72.00,0.9500,1,1,cras,22.089,25.815,22.932,25.815,15.92,-11.044,-2.16,-11.466,1769,fsxp_missionobjects.bgl +566,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18414977,-2.27336115,"N51° 11' 02.94""","W2° 16' 24.10""",108.00,1.2500,1,1,cras,3.543,4.985,3.388,4.985,2.451,-1.827,-0.008,-1.694,52,fsxp_missionobjects.bgl +567,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18142325,-2.27209171,"N51° 10' 53.12""","W2° 16' 19.53""",18.00,0.9000,1,1,cras,2.551,3.589,2.44,3.589,1.765,-1.315,-0.006,-1.22,52,fsxp_missionobjects.bgl +568,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18091934,-2.27185559,"N51° 10' 51.31""","W2° 16' 18.68""",45.00,0.8000,1,1,cras,2.267,3.191,2.169,3.191,1.569,-1.169,-0.005,-1.084,52,fsxp_missionobjects.bgl +569,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17784188,-2.27039723,"N51° 10' 40.23""","W2° 16' 13.43""",0.00,1.2500,1,1,cras,3.543,4.985,3.388,4.985,2.451,-1.827,-0.008,-1.694,52,fsxp_missionobjects.bgl +570,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17888570,-2.26180837,"N51° 10' 43.99""","W2° 15' 42.51""",0.00,0.8500,1,1,cras,2.409,3.39,2.304,3.39,1.667,-1.242,-0.005,-1.152,52,fsxp_missionobjects.bgl +571,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.17896668,-2.26270557,"N51° 10' 44.28""","W2° 15' 45.74""",0.00,1.2000,1,1,cras,3.401,4.786,3.253,4.786,2.353,-1.753,-0.008,-1.626,52,fsxp_missionobjects.bgl +572,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Copy - Longleat_Tree_BlackPoplar_04,tree,51.18619242,-2.26976389,"N51° 11' 10.29""","W2° 16' 11.15""",324.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +573,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.17794986,-2.27023613,"N51° 10' 40.62""","W2° 16' 12.85""",306.00,0.6500,1,1,cras,15.032,21.83,14.412,21.83,10.412,-7.496,-2.054,-7.206,7566,fsxp_missionobjects.bgl +574,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18004649,-2.27266671,"N51° 10' 48.17""","W2° 16' 21.60""",0.00,0.5000,1,1,cras,11.563,16.792,11.086,16.792,8.009,-5.766,-1.58,-5.543,7566,fsxp_missionobjects.bgl +575,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18072137,-2.25968893,"N51° 10' 50.60""","W2° 15' 34.88""",27.00,1.2000,1,1,cras,27.751,40.302,26.606,40.302,19.222,-13.839,-3.792,-13.303,7566,fsxp_missionobjects.bgl +576,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Copy - Longleat_Tree_Chestnut_01,tree,51.18323193,-2.26428062,"N51° 10' 59.63""","W2° 15' 51.41""",0.00,0.1500,1,1,cras,3.469,5.038,3.326,5.038,2.403,-1.73,-0.474,-1.663,7566,fsxp_missionobjects.bgl +577,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18176519,-2.26480281,"N51° 10' 54.35""","W2° 15' 53.29""",36.00,1.0000,1,1,cras,17.602,22.923,15.884,22.923,11.855,-8.771,-2.542,-7.927,1787,fsxp_missionobjects.bgl +578,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18526558,-2.26075837,"N51° 11' 06.96""","W2° 15' 38.73""",135.00,0.6000,1,1,cras,10.561,13.754,9.53,13.754,7.113,-5.262,-1.525,-4.756,1787,fsxp_missionobjects.bgl +579,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18540055,-2.26065838,"N51° 11' 07.44""","W2° 15' 38.37""",90.00,0.4500,1,1,cras,7.921,10.316,7.148,10.316,5.335,-3.947,-1.144,-3.567,1787,fsxp_missionobjects.bgl +580,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.18130627,-2.25957225,"N51° 10' 52.70""","W2° 15' 34.46""",0.00,0.3500,1,1,cras,6.161,8.023,5.559,8.023,4.149,-3.07,-0.89,-2.774,1787,fsxp_missionobjects.bgl +581,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.17584423,-2.26986397,"N51° 10' 33.04""","W2° 16' 11.51""",288.00,1.3500,1,1,cras,23.763,30.947,21.443,30.947,16.004,-11.841,-3.431,-10.701,1787,fsxp_missionobjects.bgl +582,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.17873272,-2.26761946,"N51° 10' 43.44""","W2° 16' 03.43""",297.00,1.1500,1,1,cras,20.242,26.362,18.266,26.362,13.633,-10.086,-2.923,-9.116,1787,fsxp_missionobjects.bgl +583,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Copy - Longleat_Tree_Chestnut_02,tree,51.17678006,-2.26969449,"N51° 10' 36.41""","W2° 16' 10.90""",0.00,0.5500,1,1,cras,9.681,12.608,8.736,12.608,6.52,-4.824,-1.398,-4.36,1787,fsxp_missionobjects.bgl +584,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.18530157,-2.26039175,"N51° 11' 07.09""","W2° 15' 37.41""",18.00,0.5000,1,1,cras,9.271,12.563,8.655,12.563,6.341,-4.57,-1.578,-4.327,1828,fsxp_missionobjects.bgl +585,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.18280901,-2.27135838,"N51° 10' 58.11""","W2° 16' 16.89""",18.00,1.0000,1,1,cras,18.541,25.126,17.31,25.126,12.683,-9.14,-3.157,-8.655,1828,fsxp_missionobjects.bgl +586,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.17581723,-2.27010285,"N51° 10' 32.94""","W2° 16' 12.37""",36.00,1.1000,1,1,cras,20.395,27.639,19.041,27.639,13.951,-10.054,-3.472,-9.52,1828,fsxp_missionobjects.bgl +587,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.17803984,-2.27026949,"N51° 10' 40.94""","W2° 16' 12.97""",27.00,0.6500,1,1,cras,12.052,16.332,11.251,16.332,8.244,-5.941,-2.052,-5.625,1828,fsxp_missionobjects.bgl +588,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.18426675,-2.27318890,"N51° 11' 03.36""","W2° 16' 23.48""",63.00,0.6000,1,1,cras,11.125,15.076,10.386,15.076,7.61,-5.484,-1.894,-5.193,1828,fsxp_missionobjects.bgl +589,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Copy - Longleat_Tree_Chestnut_03,tree,51.17722998,-2.27111673,"N51° 10' 38.03""","W2° 16' 16.02""",0.00,0.6000,1,1,cras,11.125,15.076,10.386,15.076,7.61,-5.484,-1.894,-5.193,1828,fsxp_missionobjects.bgl +590,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Longleat_Tree_Oak_01,tree,51.18260204,-2.26347780,"N51° 10' 57.37""","W2° 15' 48.52""",351.00,1.2500,1,1,cras,35.753,39.213,34.464,39.213,24.83,-17.94,-2.445,-17.21,2035,fsxp_missionobjects.bgl +591,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Longleat_Tree_Oak_01,tree,51.18527458,-2.26662504,"N51° 11' 06.99""","W2° 15' 59.85""",234.00,0.3000,1,1,cras,8.581,9.411,8.271,9.411,5.959,-4.306,-0.587,-4.13,2035,fsxp_missionobjects.bgl +592,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Longleat_Tree_Oak_01,tree,51.18591347,-2.26563614,"N51° 11' 09.29""","W2° 15' 56.29""",162.00,0.5000,1,1,cras,14.301,15.685,13.785,15.685,9.932,-7.176,-0.978,-6.884,2035,fsxp_missionobjects.bgl +593,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Longleat_Tree_Oak_01,tree,51.18415877,-2.26061395,"N51° 11' 02.97""","W2° 15' 38.21""",9.00,1.0000,1,1,cras,28.603,31.371,27.571,31.371,19.864,-14.352,-1.956,-13.768,2035,fsxp_missionobjects.bgl +594,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Longleat_Tree_Oak_01,tree,51.18525658,-2.25822503,"N51° 11' 06.92""","W2° 15' 29.61""",0.00,0.7000,1,1,cras,20.022,21.959,19.3,21.959,13.905,-10.047,-1.369,-9.638,2035,fsxp_missionobjects.bgl +595,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Longleat_Tree_Oak_01,tree,51.17696903,-2.27108890,"N51° 10' 37.09""","W2° 16' 15.92""",0.00,1.1500,1,1,cras,32.893,36.076,31.707,36.076,22.843,-16.505,-2.249,-15.834,2035,fsxp_missionobjects.bgl +596,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Longleat_Tree_Oak_01,tree,51.17625815,-2.26961394,"N51° 10' 34.53""","W2° 16' 10.61""",0.00,0.5500,1,1,cras,15.731,17.254,15.164,17.254,10.925,-7.894,-1.076,-7.573,2035,fsxp_missionobjects.bgl +597,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Longleat_Tree_Oak_01,tree,51.17942560,-2.27013060,"N51° 10' 45.93""","W2° 16' 12.47""",0.00,0.7000,1,1,cras,20.022,21.959,19.3,21.959,13.905,-10.047,-1.369,-9.638,2035,fsxp_missionobjects.bgl +598,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Longleat_Tree_Oak_01,tree,51.18155823,-2.27068339,"N51° 10' 53.61""","W2° 16' 14.46""",27.00,1.2500,1,1,cras,35.753,39.213,34.464,39.213,24.83,-17.94,-2.445,-17.21,2035,fsxp_missionobjects.bgl +599,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Longleat_Tree_Oak_01,tree,51.17982153,-2.26733616,"N51° 10' 47.36""","W2° 16' 02.41""",0.00,1.3000,1,1,cras,37.183,40.782,35.842,40.782,25.823,-18.658,-2.543,-17.899,2035,fsxp_missionobjects.bgl +600,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - Longleat_Tree_Oak_01,tree,51.17842678,-2.26516952,"N51° 10' 42.34""","W2° 15' 54.61""",9.00,0.9560,1,1,cras,27.344,29.99,26.358,29.99,18.99,-13.721,-1.87,-13.162,2035,fsxp_missionobjects.bgl +601,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Longleat_Tree_Oak_02,tree,51.18271902,-2.26331946,"N51° 10' 57.79""","W2° 15' 47.95""",315.00,0.8000,1,1,cras,16.326,19.882,17.172,19.882,11.847,-8.163,-1.059,-8.586,1990,fsxp_missionobjects.bgl +602,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Longleat_Tree_Oak_02,tree,51.18215212,-2.26528896,"N51° 10' 55.75""","W2° 15' 55.04""",171.00,1.2500,1,1,cras,25.51,31.066,26.832,31.066,18.511,-12.755,-1.655,-13.416,1990,fsxp_missionobjects.bgl +603,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Longleat_Tree_Oak_02,tree,51.18530157,-2.26423896,"N51° 11' 07.09""","W2° 15' 51.26""",117.00,0.4500,1,1,cras,9.183,11.184,9.659,11.184,6.664,-4.592,-0.596,-4.83,1990,fsxp_missionobjects.bgl +604,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Longleat_Tree_Oak_02,tree,51.18452771,-2.26772784,"N51° 11' 04.30""","W2° 16' 03.82""",315.00,1.2000,1,1,cras,24.489,29.823,25.759,29.823,17.771,-12.244,-1.588,-12.879,1990,fsxp_missionobjects.bgl +605,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Longleat_Tree_Oak_02,tree,51.18585048,-2.26063893,"N51° 11' 09.06""","W2° 15' 38.30""",63.00,0.7500,1,1,cras,15.306,18.639,16.099,18.639,11.107,-7.653,-0.993,-8.049,1990,fsxp_missionobjects.bgl +606,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Longleat_Tree_Oak_02,tree,51.18504962,-2.25813341,"N51° 11' 06.18""","W2° 15' 29.28""",351.00,0.8500,1,1,cras,17.347,21.125,18.246,21.125,12.588,-8.673,-1.125,-9.123,1990,fsxp_missionobjects.bgl +607,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Longleat_Tree_Oak_02,tree,51.18135126,-2.25951944,"N51° 10' 52.86""","W2° 15' 34.27""",36.00,0.5000,1,1,cras,10.204,12.426,10.733,12.426,7.405,-5.102,-0.662,-5.366,1990,fsxp_missionobjects.bgl +608,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Longleat_Tree_Oak_02,tree,51.17776089,-2.26911949,"N51° 10' 39.94""","W2° 16' 08.83""",180.00,0.7500,1,1,cras,15.306,18.639,16.099,18.639,11.107,-7.653,-0.993,-8.049,1990,fsxp_missionobjects.bgl +609,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Longleat_Tree_Oak_02,tree,51.17734696,-2.26723339,"N51° 10' 38.45""","W2° 16' 02.04""",315.00,1.0000,1,1,cras,20.408,24.853,21.466,24.853,14.809,-10.204,-1.324,-10.732,1990,fsxp_missionobjects.bgl +610,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Longleat_Tree_Oak_02,tree,51.17820181,-2.26709451,"N51° 10' 41.53""","W2° 16' 01.54""",171.00,1.0000,1,1,cras,20.408,24.853,21.466,24.853,14.809,-10.204,-1.324,-10.732,1990,fsxp_missionobjects.bgl +611,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Longleat_Tree_Oak_02,tree,51.17821081,-2.26785558,"N51° 10' 41.56""","W2° 16' 04.28""",144.00,1.3500,1,1,cras,27.55,33.551,28.978,33.551,19.992,-13.775,-1.787,-14.489,1990,fsxp_missionobjects.bgl +612,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Longleat_Tree_Oak_02,tree,51.17862474,-2.26764453,"N51° 10' 43.05""","W2° 16' 03.52""",333.00,0.7500,1,1,cras,15.306,18.639,16.099,18.639,11.107,-7.653,-0.993,-8.049,1990,fsxp_missionobjects.bgl +613,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Longleat_Tree_Oak_02,tree,51.17899368,-2.26988618,"N51° 10' 44.38""","W2° 16' 11.59""",333.00,0.8500,1,1,cras,17.347,21.125,18.246,21.125,12.588,-8.673,-1.125,-9.123,1990,fsxp_missionobjects.bgl +614,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Longleat_Tree_Oak_02,tree,51.18465369,-2.27205005,"N51° 11' 04.75""","W2° 16' 19.38""",126.00,1.2000,1,1,cras,24.489,29.823,25.759,29.823,17.771,-12.244,-1.588,-12.879,1990,fsxp_missionobjects.bgl +615,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Longleat_Tree_Oak_02,tree,51.18421276,-2.27497785,"N51° 11' 03.17""","W2° 16' 29.92""",216.00,0.6000,1,1,cras,12.245,14.912,12.879,14.912,8.885,-6.122,-0.794,-6.439,1990,fsxp_missionobjects.bgl +616,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Longleat_Tree_Oak_02,tree,51.18098233,-2.26855002,"N51° 10' 51.54""","W2° 16' 06.78""",63.00,1.5000,1,1,cras,30.612,37.279,32.198,37.279,22.214,-15.305,-1.986,-16.099,1990,fsxp_missionobjects.bgl +617,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Longleat_Tree_Oak_02,tree,51.17789587,-2.26035562,"N51° 10' 40.43""","W2° 15' 37.28""",306.00,1.0000,1,1,cras,20.408,24.853,21.466,24.853,14.809,-10.204,-1.324,-10.732,1990,fsxp_missionobjects.bgl +618,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Longleat_Tree_Oak_02,tree,51.17860674,-2.26021112,"N51° 10' 42.98""","W2° 15' 36.76""",333.00,1.0000,1,1,cras,20.408,24.853,21.466,24.853,14.809,-10.204,-1.324,-10.732,1990,fsxp_missionobjects.bgl +619,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Longleat_Tree_Oak_02,tree,51.18111730,-2.25970561,"N51° 10' 52.02""","W2° 15' 34.94""",45.00,0.7500,1,1,cras,15.306,18.639,16.099,18.639,11.107,-7.653,-0.993,-8.049,1990,fsxp_missionobjects.bgl +620,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Longleat_Tree_Oak_02,tree,51.18190017,-2.25817507,"N51° 10' 54.84""","W2° 15' 29.43""",18.00,1.0000,1,1,cras,20.408,24.853,21.466,24.853,14.809,-10.204,-1.324,-10.732,1990,fsxp_missionobjects.bgl +621,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Longleat_Tree_Oak_02,tree,51.18086535,-2.25764450,"N51° 10' 51.12""","W2° 15' 27.52""",45.00,1.5000,1,1,cras,30.612,37.279,32.198,37.279,22.214,-15.305,-1.986,-16.099,1990,fsxp_missionobjects.bgl +622,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Longleat_Tree_Oak_02,tree,51.18091934,-2.25801950,"N51° 10' 51.31""","W2° 15' 28.87""",315.00,0.9000,1,1,cras,18.367,22.367,19.319,22.367,13.328,-9.183,-1.191,-9.659,1990,fsxp_missionobjects.bgl +623,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Longleat_Tree_Oak_02,tree,51.17961457,-2.26005564,"N51° 10' 46.61""","W2° 15' 36.20""",45.00,1.5000,1,1,cras,30.612,37.279,32.198,37.279,22.214,-15.305,-1.986,-16.099,1990,fsxp_missionobjects.bgl +624,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Longleat_Tree_Oak_02,tree,51.18073037,-2.25901117,"N51° 10' 50.63""","W2° 15' 32.44""",54.00,0.9000,1,1,cras,18.367,22.367,19.319,22.367,13.328,-9.183,-1.191,-9.659,1990,fsxp_missionobjects.bgl +625,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Copy - Longleat_Tree_Oak_02,tree,51.17939861,-2.25850004,"N51° 10' 45.83""","W2° 15' 30.60""",45.00,0.7000,1,1,cras,14.285,17.397,15.026,17.397,10.366,-7.143,-0.927,-7.513,1990,fsxp_missionobjects.bgl +626,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Longleat_Tree_Oak_03,tree,51.18320494,-2.26166119,"N51° 10' 59.54""","W2° 15' 41.98""",274.50,1.2000,1,1,cras,37.076,29.688,36.179,29.688,25.901,-18.538,-0.076,-18.089,1806,fsxp_missionobjects.bgl +627,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Longleat_Tree_Oak_03,tree,51.18211613,-2.26467784,"N51° 10' 55.62""","W2° 15' 52.84""",52.20,1.2500,1,1,cras,38.621,30.925,37.686,30.925,26.981,-19.31,-0.079,-18.843,1806,fsxp_missionobjects.bgl +628,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Longleat_Tree_Oak_03,tree,51.18414977,-2.26015563,"N51° 11' 02.94""","W2° 15' 36.56""",22.50,0.8500,1,1,cras,26.262,21.029,25.627,21.029,18.347,-13.131,-0.054,-12.813,1806,fsxp_missionobjects.bgl +629,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Longleat_Tree_Oak_03,tree,51.18329492,-2.26013895,"N51° 10' 59.86""","W2° 15' 36.50""",49.50,0.4000,1,1,cras,12.359,9.896,12.06,9.896,8.634,-6.179,-0.025,-6.03,1806,fsxp_missionobjects.bgl +630,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Longleat_Tree_Oak_03,tree,51.18141425,-2.25959170,"N51° 10' 53.09""","W2° 15' 34.53""",49.50,0.4000,1,1,cras,12.359,9.896,12.06,9.896,8.634,-6.179,-0.025,-6.03,1806,fsxp_missionobjects.bgl +631,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Longleat_Tree_Oak_03,tree,51.17624916,-2.26986397,"N51° 10' 34.50""","W2° 16' 11.51""",193.50,0.7500,1,1,cras,23.172,18.555,22.612,18.555,16.188,-11.586,-0.047,-11.306,1806,fsxp_missionobjects.bgl +632,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Longleat_Tree_Oak_03,tree,51.17602420,-2.26966389,"N51° 10' 33.69""","W2° 16' 10.79""",211.50,1.0000,1,1,cras,30.896,24.74,30.149,24.74,21.584,-15.448,-0.063,-15.074,1806,fsxp_missionobjects.bgl +633,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Longleat_Tree_Oak_03,tree,51.17599720,-2.27020562,"N51° 10' 33.59""","W2° 16' 12.74""",139.50,0.9000,1,1,cras,27.807,22.266,27.134,22.266,19.426,-13.903,-0.057,-13.567,1806,fsxp_missionobjects.bgl +634,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Longleat_Tree_Oak_03,tree,51.17739196,-2.26649730,"N51° 10' 38.61""","W2° 15' 59.39""",319.50,0.7500,1,1,cras,23.172,18.555,22.612,18.555,16.188,-11.586,-0.047,-11.306,1806,fsxp_missionobjects.bgl +635,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Longleat_Tree_Oak_03,tree,51.17883170,-2.26790839,"N51° 10' 43.79""","W2° 16' 04.47""",337.50,0.8500,1,1,cras,26.262,21.029,25.627,21.029,18.347,-13.131,-0.054,-12.813,1806,fsxp_missionobjects.bgl +636,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Longleat_Tree_Oak_03,tree,51.17879571,-2.26917783,"N51° 10' 43.66""","W2° 16' 09.04""",301.50,0.6580,1,1,cras,20.33,16.279,19.838,16.279,14.203,-10.165,-0.042,-9.919,1806,fsxp_missionobjects.bgl +637,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Longleat_Tree_Oak_03,tree,51.17926363,-2.27005005,"N51° 10' 45.35""","W2° 16' 12.18""",121.50,0.8500,1,1,cras,26.262,21.029,25.627,21.029,18.347,-13.131,-0.054,-12.813,1806,fsxp_missionobjects.bgl +638,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Longleat_Tree_Oak_03,tree,51.18144125,-2.26966674,"N51° 10' 53.19""","W2° 16' 10.80""",355.50,0.7500,1,1,cras,23.172,18.555,22.612,18.555,16.188,-11.586,-0.047,-11.306,1806,fsxp_missionobjects.bgl +639,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Longleat_Tree_Oak_03,tree,51.18222411,-2.27164445,"N51° 10' 56.01""","W2° 16' 17.92""",337.50,1.0000,1,1,cras,30.896,24.74,30.149,24.74,21.584,-15.448,-0.063,-15.074,1806,fsxp_missionobjects.bgl +640,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Longleat_Tree_Oak_03,tree,51.17956058,-2.26052502,"N51° 10' 46.42""","W2° 15' 37.89""",288.00,1.5000,1,1,cras,46.345,37.11,45.223,37.11,32.377,-23.172,-0.095,-22.611,1806,fsxp_missionobjects.bgl +641,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - Longleat_Tree_Oak_03,tree,51.17883170,-2.25835285,"N51° 10' 43.79""","W2° 15' 30.07""",270.00,1.5000,1,1,cras,46.345,37.11,45.223,37.11,32.377,-23.172,-0.095,-22.611,1806,fsxp_missionobjects.bgl +642,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Copy - NEW - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18181918,-2.25672500,"N51° 10' 54.55""","W2° 15' 24.21""",292.50,1.0000,1,1,cras,30.896,24.74,30.149,24.74,21.584,-15.448,-0.063,-15.074,1806,fsxp_missionobjects.bgl +643,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - NEW - Copy - Longleat_Tree_Oak_01,tree,51.18436574,-2.25732783,"N51° 11' 03.72""","W2° 15' 26.38""",36.00,1.0000,1,1,cras,28.603,31.371,27.571,31.371,19.864,-14.352,-1.956,-13.768,2035,fsxp_missionobjects.bgl +644,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - NEW - Copy - Longleat_Tree_Oak_01,tree,51.18667833,-2.26114168,"N51° 11' 12.04""","W2° 15' 40.11""",36.00,0.6000,1,1,cras,17.162,18.822,16.543,18.822,11.918,-8.611,-1.174,-8.261,2035,fsxp_missionobjects.bgl +645,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - NEW - Copy - Longleat_Tree_Oak_01,tree,51.18723623,-2.25815839,"N51° 11' 14.05""","W2° 15' 29.37""",36.00,0.7000,1,1,cras,20.022,21.959,19.3,21.959,13.905,-10.047,-1.369,-9.638,2035,fsxp_missionobjects.bgl +646,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - NEW - Copy - Longleat_Tree_Oak_01,tree,51.18720024,-2.25968893,"N51° 11' 13.92""","W2° 15' 34.88""",324.00,1.0000,1,1,cras,28.603,31.371,27.571,31.371,19.864,-14.352,-1.956,-13.768,2035,fsxp_missionobjects.bgl +647,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - NEW - Copy - Longleat_Tree_Oak_01,tree,51.18738921,-2.26145281,"N51° 11' 14.60""","W2° 15' 41.23""",36.00,1.2500,1,1,cras,35.753,39.213,34.464,39.213,24.83,-17.94,-2.445,-17.21,2035,fsxp_missionobjects.bgl +648,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - NEW - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18163021,-2.25723613,"N51° 10' 53.87""","W2° 15' 26.05""",324.00,1.5000,0,1,bbox,11.753,5.632,11.706,5.632,8.294,-6.632,-0.138,-5.956,0,vegetation.bgl +649,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - NEW copy - Copy - Longleat_Tree_Oak_01,tree,51.18624641,-2.26436117,"N51° 11' 10.49""","W2° 15' 51.70""",171.00,0.5000,1,1,cras,14.301,15.685,13.785,15.685,9.932,-7.176,-0.978,-6.884,2035,fsxp_missionobjects.bgl +650,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Copy - NEW- Copy - Longleat_Tree_Oak_01,tree,51.18689429,-2.26082501,"N51° 11' 12.82""","W2° 15' 38.97""",189.00,0.8000,1,1,cras,22.882,25.096,22.057,25.096,15.891,-11.482,-1.565,-11.015,2035,fsxp_missionobjects.bgl +651,VEH_Land_car_truck3_white,VEH_Land_car_truck3_white,Copy - Copy - NEW Copy - VEH_Land_car_truck3_white,vehicle,51.18512160,-2.26236393,"N51° 11' 06.44""","W2° 15' 44.51""",87.30,1.0000,1,1,cras,4.253,4.44,13.93,4.44,7.282,-2.117,-0.11,-10.565,59,vehicles_land.bgl +652,RedBull_Hangar_01,MIS_RedBull_Hangars,Copy - Copy - RedBull_Hangar_01,building,51.18386182,-2.27347506,"N51° 11' 01.90""","W2° 16' 24.51""",254.70,1.0000,1,1,cras,13.129,8.122,16.582,8.122,10.575,-6.581,-0.085,-9.272,57,fsxp_missionobjects.bgl +653,veg_tb_bush_Bayberry_3_5ft,veg_tb_bush_Bayberry_3_5ft,Copy - Copy - veg_tb_bush_Bayberry_3_5ft,bush,51.18575149,-2.26641951,"N51° 11' 08.71""","W2° 15' 59.11""",18.00,1.0000,0,1,bbox,4.932,1.589,4.703,1.589,3.407,-2.688,-0.055,-2.276,0,vegetation.bgl +654,veg_tb_bush_Bayberry_3_5ft,veg_tb_bush_Bayberry_3_5ft,Copy - Copy - veg_tb_bush_Bayberry_3_5ft,bush,51.17722099,-2.26726667,"N51° 10' 38.00""","W2° 16' 02.16""",0.00,1.5000,0,1,bbox,7.398,2.383,7.054,2.383,5.111,-4.032,-0.083,-3.413,0,vegetation.bgl +655,veg_tb_bush_Bayberry_3_5ft,veg_tb_bush_Bayberry_3_5ft,Copy - Copy - veg_tb_bush_Bayberry_3_5ft,bush,51.18098233,-2.26237223,"N51° 10' 51.54""","W2° 15' 44.54""",0.00,1.5000,0,1,bbox,7.398,2.383,7.054,2.383,5.111,-4.032,-0.083,-3.413,0,vegetation.bgl +656,veg_tb_bush_Bayberry_4ft,veg_tb_bush_Bayberry_4ft,Copy - Copy - veg_tb_bush_Bayberry_4ft,bush,51.18519359,-2.26653334,"N51° 11' 06.70""","W2° 15' 59.52""",36.00,1.2500,0,1,bbox,3.737,2.273,3.548,2.273,2.577,-1.964,-0.04,-1.893,0,vegetation.bgl +657,veg_tb_bush_Bayberry_4ft,veg_tb_bush_Bayberry_4ft,Copy - Copy - veg_tb_bush_Bayberry_4ft,bush,51.18360987,-2.25988339,"N51° 11' 01.00""","W2° 15' 35.58""",315.00,1.2000,0,1,bbox,3.588,2.182,3.407,2.182,2.474,-1.885,-0.039,-1.818,0,vegetation.bgl +658,veg_tb_bush_Bayberry_4ft,veg_tb_bush_Bayberry_4ft,Copy - Copy - veg_tb_bush_Bayberry_4ft,bush,51.18556253,-2.26858062,"N51° 11' 08.03""","W2° 16' 06.89""",0.00,1.0000,0,1,bbox,2.99,1.818,2.839,1.818,2.061,-1.571,-0.032,-1.515,0,vegetation.bgl +659,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18376284,-2.26801392,"N51° 11' 01.55""","W2° 16' 04.85""",45.00,0.7500,0,1,bbox,5.877,2.816,5.853,2.816,4.147,-3.316,-0.069,-2.978,0,vegetation.bgl +660,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18306996,-2.26520003,"N51° 10' 59.05""","W2° 15' 54.72""",36.00,1.0000,0,1,bbox,7.835,3.755,7.804,3.755,5.529,-4.421,-0.092,-3.971,0,vegetation.bgl +661,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18536456,-2.26438338,"N51° 11' 07.31""","W2° 15' 51.78""",63.00,1.0000,0,1,bbox,7.835,3.755,7.804,3.755,5.529,-4.421,-0.092,-3.971,0,vegetation.bgl +662,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18280001,-2.26756951,"N51° 10' 58.08""","W2° 16' 03.25""",0.00,1.0000,0,1,bbox,7.835,3.755,7.804,3.755,5.529,-4.421,-0.092,-3.971,0,vegetation.bgl +663,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18579649,-2.26628062,"N51° 11' 08.87""","W2° 15' 58.61""",0.00,0.9000,0,1,bbox,7.052,3.379,7.024,3.379,4.976,-3.979,-0.083,-3.574,0,vegetation.bgl +664,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18182818,-2.26160285,"N51° 10' 54.58""","W2° 15' 41.77""",342.00,1.1000,0,1,bbox,8.619,4.13,8.584,4.13,6.082,-4.863,-0.101,-4.368,0,vegetation.bgl +665,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.17745495,-2.26685286,"N51° 10' 38.84""","W2° 16' 00.67""",324.00,1.2500,0,1,bbox,9.794,4.693,9.755,4.693,6.912,-5.526,-0.115,-4.964,0,vegetation.bgl +666,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.17730197,-2.26710834,"N51° 10' 38.29""","W2° 16' 01.59""",36.00,1.2500,0,1,bbox,9.794,4.693,9.755,4.693,6.912,-5.526,-0.115,-4.964,0,vegetation.bgl +667,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.17814782,-2.26739726,"N51° 10' 41.33""","W2° 16' 02.63""",36.00,1.5000,0,1,bbox,11.753,5.632,11.706,5.632,8.294,-6.632,-0.138,-5.956,0,vegetation.bgl +668,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.17757192,-2.26987227,"N51° 10' 39.26""","W2° 16' 11.54""",270.00,0.8500,0,1,bbox,6.66,3.191,6.633,3.191,4.7,-3.758,-0.078,-3.375,0,vegetation.bgl +669,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18534656,-2.27338890,"N51° 11' 07.25""","W2° 16' 24.20""",0.00,0.8500,0,1,bbox,6.66,3.191,6.633,3.191,4.7,-3.758,-0.078,-3.375,0,vegetation.bgl +670,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18488764,-2.27322503,"N51° 11' 05.60""","W2° 16' 23.61""",54.00,1.1000,0,1,bbox,8.619,4.13,8.584,4.13,6.082,-4.863,-0.101,-4.368,0,vegetation.bgl +671,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18083835,-2.26919174,"N51° 10' 51.02""","W2° 16' 09.09""",18.00,1.2560,0,1,bbox,9.841,4.716,9.802,4.716,6.945,-5.553,-0.115,-4.987,0,vegetation.bgl +672,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18569750,-2.26589723,"N51° 11' 08.51""","W2° 15' 57.23""",0.00,0.8000,0,1,bbox,6.268,3.004,6.243,3.004,4.423,-3.537,-0.073,-3.177,0,vegetation.bgl +673,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18141425,-2.25846391,"N51° 10' 53.09""","W2° 15' 30.47""",0.00,1.0000,0,1,bbox,7.835,3.755,7.804,3.755,5.529,-4.421,-0.092,-3.971,0,vegetation.bgl +674,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18151324,-2.26033618,"N51° 10' 53.45""","W2° 15' 37.21""",0.00,0.5000,0,1,bbox,3.918,1.877,3.902,1.877,2.765,-2.211,-0.046,-1.985,0,vegetation.bgl +675,gen_airshow_smalltent1_2,gen_airshow_smalltent1_2,Copy - gen_airshow_smalltent1_2,tent,51.18373585,-2.27430001,"N51° 11' 01.45""","W2° 16' 27.48""",72.00,1.0000,1,1,cras,12.28,4.522,9.26,4.522,7.69,-6.147,-0.0,-4.629,65,fillerobjects.bgl +676,Gen_Crowd_Berlin04,Gen_Crowd_Berlin04,Copy - Gen_Crowd_Berlin04,props,51.18616542,-2.27093333,"N51° 11' 10.20""","W2° 16' 15.36""",4.50,1.0000,1,1,cras,186.828,2.458,55.573,2.458,97.459,-90.217,-0.215,-22.1,7,FSXP_People.bgl +677,Gen_Crowd_Restroom02,Gen_Crowd_Restroom02,Copy - Gen_Crowd_Restroom02,props,51.18534656,-2.26360286,"N51° 11' 07.25""","W2° 15' 48.97""",180.00,1.0000,1,1,cras,1.658,2.239,8.341,2.239,4.252,-0.829,-0.179,-4.17,37,FSXP_People.bgl +678,Longleat_Tree_BlackPoplar_02,Longleat_Tree_BlackPoplar_02,Copy - Longleat_Tree_BlackPoplar_02,tree,51.18106331,-2.26385280,"N51° 10' 51.83""","W2° 15' 49.87""",18.00,0.3000,1,1,cras,5.983,6.395,7.018,6.395,4.611,-2.992,-0.389,-3.509,1715,fsxp_missionobjects.bgl +679,Longleat_Tree_BlackPoplar_03,Longleat_Tree_BlackPoplar_03,Copy - Longleat_Tree_BlackPoplar_03,tree,51.18540955,-2.26621114,"N51° 11' 07.47""","W2° 15' 58.36""",0.00,0.2500,1,1,cras,5.813,6.793,6.035,6.793,4.189,-2.906,-0.568,-3.017,1769,fsxp_missionobjects.bgl +680,Longleat_Tree_BlackPoplar_03,Longleat_Tree_BlackPoplar_03,Copy - Longleat_Tree_BlackPoplar_03,tree,51.18091934,-2.26383889,"N51° 10' 51.31""","W2° 15' 49.82""",54.00,0.4500,1,1,cras,10.463,12.228,10.862,12.228,7.541,-5.231,-1.023,-5.431,1769,fsxp_missionobjects.bgl +681,Longleat_Tree_BlackPoplar_03,Longleat_Tree_BlackPoplar_03,Copy - Longleat_Tree_BlackPoplar_03,tree,51.18110831,-2.26369170,"N51° 10' 51.99""","W2° 15' 49.29""",0.00,0.2000,1,1,cras,4.65,5.435,4.828,5.435,3.352,-2.325,-0.455,-2.414,1769,fsxp_missionobjects.bgl +682,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Longleat_Tree_BlackPoplar_04,tree,51.18374484,-2.26905562,"N51° 11' 01.48""","W2° 16' 08.60""",0.00,1.2000,1,1,cras,3.401,4.786,3.253,4.786,2.353,-1.753,-0.008,-1.626,52,fsxp_missionobjects.bgl +683,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Longleat_Tree_BlackPoplar_04,tree,51.17801285,-2.27048893,"N51° 10' 40.85""","W2° 16' 13.76""",0.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +684,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Longleat_Tree_BlackPoplar_04,tree,51.18423076,-2.27339728,"N51° 11' 03.23""","W2° 16' 24.23""",108.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +685,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Longleat_Tree_BlackPoplar_04,tree,51.18151324,-2.27213614,"N51° 10' 53.45""","W2° 16' 19.69""",18.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +686,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Copy - Longleat_Tree_BlackPoplar_04,tree,51.18568850,-2.26913894,"N51° 11' 08.48""","W2° 16' 08.90""",0.00,1.0000,1,1,cras,2.834,3.988,2.711,3.988,1.961,-1.461,-0.006,-1.355,52,fsxp_missionobjects.bgl +687,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Longleat_Tree_Chestnut_01,tree,51.18213413,-2.26500557,"N51° 10' 55.68""","W2° 15' 54.02""",45.00,1.0000,1,1,cras,23.126,33.585,22.172,33.585,16.019,-11.533,-3.16,-11.086,7566,fsxp_missionobjects.bgl +688,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Longleat_Tree_Chestnut_01,tree,51.17660909,-2.26961670,"N51° 10' 35.79""","W2° 16' 10.62""",0.00,0.5000,1,1,cras,11.563,16.792,11.086,16.792,8.009,-5.766,-1.58,-5.543,7566,fsxp_missionobjects.bgl +689,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Longleat_Tree_Chestnut_01,tree,51.18079336,-2.25866952,"N51° 10' 50.86""","W2° 15' 31.21""",27.00,1.2000,1,1,cras,27.751,40.302,26.606,40.302,19.222,-13.839,-3.792,-13.303,7566,fsxp_missionobjects.bgl +690,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Copy - Longleat_Tree_Chestnut_01,tree,51.18329492,-2.26426394,"N51° 10' 59.86""","W2° 15' 51.35""",0.00,0.1500,1,1,cras,3.469,5.038,3.326,5.038,2.403,-1.73,-0.474,-1.663,7566,fsxp_missionobjects.bgl +691,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Longleat_Tree_Chestnut_02,tree,51.18406879,-2.26492502,"N51° 11' 02.65""","W2° 15' 53.73""",0.00,1.0000,1,1,cras,17.602,22.923,15.884,22.923,11.855,-8.771,-2.542,-7.927,1787,fsxp_missionobjects.bgl +692,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Longleat_Tree_Chestnut_02,tree,51.18190917,-2.26443057,"N51° 10' 54.87""","W2° 15' 51.95""",36.00,1.0000,1,1,cras,17.602,22.923,15.884,22.923,11.855,-8.771,-2.542,-7.927,1787,fsxp_missionobjects.bgl +693,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Longleat_Tree_Chestnut_02,tree,51.18532857,-2.26066114,"N51° 11' 07.18""","W2° 15' 38.38""",36.00,0.5000,1,1,cras,8.801,11.462,7.942,11.462,5.927,-4.385,-1.271,-3.963,1787,fsxp_missionobjects.bgl +694,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Longleat_Tree_Chestnut_02,tree,51.17678006,-2.27106669,"N51° 10' 36.41""","W2° 16' 15.84""",0.00,1.0000,1,1,cras,17.602,22.923,15.884,22.923,11.855,-8.771,-2.542,-7.927,1787,fsxp_missionobjects.bgl +695,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Longleat_Tree_Chestnut_02,tree,51.17616817,-2.26937229,"N51° 10' 34.21""","W2° 16' 09.74""",288.00,1.0000,1,1,cras,17.602,22.923,15.884,22.923,11.855,-8.771,-2.542,-7.927,1787,fsxp_missionobjects.bgl +696,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Longleat_Tree_Chestnut_02,tree,51.17603319,-2.26940556,"N51° 10' 33.72""","W2° 16' 09.86""",297.00,1.1500,1,1,cras,20.242,26.362,18.266,26.362,13.633,-10.086,-2.923,-9.116,1787,fsxp_missionobjects.bgl +697,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Longleat_Tree_Chestnut_02,tree,51.18072137,-2.27000286,"N51° 10' 50.60""","W2° 16' 12.01""",0.00,1.0000,1,1,cras,17.602,22.923,15.884,22.923,11.855,-8.771,-2.542,-7.927,1787,fsxp_missionobjects.bgl +698,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Longleat_Tree_Chestnut_02,tree,51.17667208,-2.26964445,"N51° 10' 36.02""","W2° 16' 10.72""",0.00,0.7500,1,1,cras,13.202,17.193,11.913,17.193,8.891,-6.578,-1.906,-5.945,1787,fsxp_missionobjects.bgl +699,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Longleat_Tree_Chestnut_02,tree,51.17730197,-2.27113618,"N51° 10' 38.29""","W2° 16' 16.09""",351.00,0.7500,1,1,cras,13.202,17.193,11.913,17.193,8.891,-6.578,-1.906,-5.945,1787,fsxp_missionobjects.bgl +700,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Copy - Longleat_Tree_Chestnut_02,tree,51.18050541,-2.26576673,"N51° 10' 49.82""","W2° 15' 56.76""",315.00,1.0000,1,1,cras,17.602,22.923,15.884,22.923,11.855,-8.771,-2.542,-7.927,1787,fsxp_missionobjects.bgl +701,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Longleat_Tree_Chestnut_03,tree,51.18535556,-2.26075284,"N51° 11' 07.28""","W2° 15' 38.71""",18.00,0.5000,1,1,cras,9.271,12.563,8.655,12.563,6.341,-4.57,-1.578,-4.327,1828,fsxp_missionobjects.bgl +702,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Longleat_Tree_Chestnut_03,tree,51.17686105,-2.26975006,"N51° 10' 36.70""","W2° 16' 11.10""",0.00,0.7500,1,1,cras,13.906,18.845,12.982,18.845,9.512,-6.855,-2.368,-6.491,1828,fsxp_missionobjects.bgl +703,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - Longleat_Tree_Chestnut_03,tree,51.18346589,-2.27283058,"N51° 11' 00.48""","W2° 16' 22.19""",0.00,0.6000,1,1,cras,11.125,15.076,10.386,15.076,7.61,-5.484,-1.894,-5.193,1828,fsxp_missionobjects.bgl +704,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Longleat_Tree_Oak_01,tree,51.18346589,-2.26723893,"N51° 11' 00.48""","W2° 16' 02.06""",207.00,0.7000,1,1,cras,20.022,21.959,19.3,21.959,13.905,-10.047,-1.369,-9.638,2035,fsxp_missionobjects.bgl +705,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Longleat_Tree_Oak_01,tree,51.18358287,-2.26925838,"N51° 11' 00.90""","W2° 16' 09.33""",252.00,0.3500,1,1,cras,10.011,10.98,9.65,10.98,6.952,-5.023,-0.685,-4.819,2035,fsxp_missionobjects.bgl +706,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Longleat_Tree_Oak_01,tree,51.18504062,-2.26324445,"N51° 11' 06.15""","W2° 15' 47.68""",45.00,1.0000,1,1,cras,28.603,31.371,27.571,31.371,19.864,-14.352,-1.956,-13.768,2035,fsxp_missionobjects.bgl +707,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Longleat_Tree_Oak_01,tree,51.18517559,-2.26320279,"N51° 11' 06.63""","W2° 15' 47.53""",0.00,0.7000,1,1,cras,20.022,21.959,19.3,21.959,13.905,-10.047,-1.369,-9.638,2035,fsxp_missionobjects.bgl +708,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Longleat_Tree_Oak_01,tree,51.18523858,-2.26639445,"N51° 11' 06.86""","W2° 15' 59.02""",207.00,0.3500,1,1,cras,10.011,10.98,9.65,10.98,6.952,-5.023,-0.685,-4.819,2035,fsxp_missionobjects.bgl +709,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Longleat_Tree_Oak_01,tree,51.18588647,-2.26663618,"N51° 11' 09.19""","W2° 15' 59.89""",207.00,0.5000,1,1,cras,14.301,15.685,13.785,15.685,9.932,-7.176,-0.978,-6.884,2035,fsxp_missionobjects.bgl +710,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Longleat_Tree_Oak_01,tree,51.18516660,-2.25749446,"N51° 11' 06.60""","W2° 15' 26.98""",0.00,1.2000,1,1,cras,34.323,37.645,33.085,37.645,23.836,-17.223,-2.347,-16.522,2035,fsxp_missionobjects.bgl +711,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Longleat_Tree_Oak_01,tree,51.17633914,-2.26855279,"N51° 10' 34.82""","W2° 16' 06.79""",0.00,1.2500,1,1,cras,35.753,39.213,34.464,39.213,24.83,-17.94,-2.445,-17.21,2035,fsxp_missionobjects.bgl +712,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Longleat_Tree_Oak_01,tree,51.17576324,-2.27040008,"N51° 10' 32.75""","W2° 16' 13.44""",306.00,1.1000,1,1,cras,31.463,34.508,30.328,34.508,21.85,-15.788,-2.152,-15.145,2035,fsxp_missionobjects.bgl +713,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Longleat_Tree_Oak_01,tree,51.18113530,-2.26977227,"N51° 10' 52.09""","W2° 16' 11.18""",0.00,1.3000,1,1,cras,37.183,40.782,35.842,40.782,25.823,-18.658,-2.543,-17.899,2035,fsxp_missionobjects.bgl +714,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Longleat_Tree_Oak_01,tree,51.18493264,-2.27212784,"N51° 11' 05.76""","W2° 16' 19.66""",36.00,1.1000,1,1,cras,31.463,34.508,30.328,34.508,21.85,-15.788,-2.152,-15.145,2035,fsxp_missionobjects.bgl +715,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - Longleat_Tree_Oak_01,tree,51.17962357,-2.26540002,"N51° 10' 46.64""","W2° 15' 55.44""",54.00,1.0000,1,1,cras,28.603,31.371,27.571,31.371,19.864,-14.352,-1.956,-13.768,2035,fsxp_missionobjects.bgl +716,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Longleat_Tree_Oak_02,tree,51.18421276,-2.27086117,"N51° 11' 03.17""","W2° 16' 15.10""",180.00,1.2000,1,1,cras,24.489,29.823,25.759,29.823,17.771,-12.244,-1.588,-12.879,1990,fsxp_missionobjects.bgl +717,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Longleat_Tree_Oak_02,tree,51.18355588,-2.26908060,"N51° 11' 00.80""","W2° 16' 08.69""",252.00,0.5500,1,1,cras,11.224,13.669,11.806,13.669,8.145,-5.612,-0.728,-5.903,1990,fsxp_missionobjects.bgl +718,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Longleat_Tree_Oak_02,tree,51.18404179,-2.26467784,"N51° 11' 02.55""","W2° 15' 52.84""",63.00,0.6000,1,1,cras,12.245,14.912,12.879,14.912,8.885,-6.122,-0.794,-6.439,1990,fsxp_missionobjects.bgl +719,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Longleat_Tree_Oak_02,tree,51.18525658,-2.26335282,"N51° 11' 06.92""","W2° 15' 48.07""",45.00,0.6500,1,1,cras,13.265,16.154,13.953,16.154,9.626,-6.632,-0.86,-6.976,1990,fsxp_missionobjects.bgl +720,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Longleat_Tree_Oak_02,tree,51.18501362,-2.26299726,"N51° 11' 06.05""","W2° 15' 46.79""",27.00,1.0000,1,1,cras,20.408,24.853,21.466,24.853,14.809,-10.204,-1.324,-10.732,1990,fsxp_missionobjects.bgl +721,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Longleat_Tree_Oak_02,tree,51.18539156,-2.26421390,"N51° 11' 07.41""","W2° 15' 51.17""",63.00,0.6500,1,1,cras,13.265,16.154,13.953,16.154,9.626,-6.632,-0.86,-6.976,1990,fsxp_missionobjects.bgl +722,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Longleat_Tree_Oak_02,tree,51.18595846,-2.26661950,"N51° 11' 09.45""","W2° 15' 59.83""",27.00,0.8500,1,1,cras,17.347,21.125,18.246,21.125,12.588,-8.673,-1.125,-9.123,1990,fsxp_missionobjects.bgl +723,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Longleat_Tree_Oak_02,tree,51.18352888,-2.26037230,"N51° 11' 00.70""","W2° 15' 37.34""",324.00,1.1000,1,1,cras,22.449,27.338,23.612,27.338,16.29,-11.224,-1.456,-11.806,1990,fsxp_missionobjects.bgl +724,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Longleat_Tree_Oak_02,tree,51.18463569,-2.25849727,"N51° 11' 04.69""","W2° 15' 30.59""",81.00,1.0000,1,1,cras,20.408,24.853,21.466,24.853,14.809,-10.204,-1.324,-10.732,1990,fsxp_missionobjects.bgl +725,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Longleat_Tree_Oak_02,tree,51.18138726,-2.25924167,"N51° 10' 52.99""","W2° 15' 33.27""",18.00,0.9000,1,1,cras,18.367,22.367,19.319,22.367,13.328,-9.183,-1.191,-9.659,1990,fsxp_missionobjects.bgl +726,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Longleat_Tree_Oak_02,tree,51.17670808,-2.27080006,"N51° 10' 36.15""","W2° 16' 14.88""",144.00,1.0000,1,1,cras,20.408,24.853,21.466,24.853,14.809,-10.204,-1.324,-10.732,1990,fsxp_missionobjects.bgl +727,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Longleat_Tree_Oak_02,tree,51.18433874,-2.27467502,"N51° 11' 03.62""","W2° 16' 28.83""",252.00,0.6000,1,1,cras,12.245,14.912,12.879,14.912,8.885,-6.122,-0.794,-6.439,1990,fsxp_missionobjects.bgl +728,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Longleat_Tree_Oak_02,tree,51.18038843,-2.26750279,"N51° 10' 49.40""","W2° 16' 03.01""",63.00,1.5000,1,1,cras,30.612,37.279,32.198,37.279,22.214,-15.305,-1.986,-16.099,1990,fsxp_missionobjects.bgl +729,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Longleat_Tree_Oak_02,tree,51.17805784,-2.26160000,"N51° 10' 41.01""","W2° 15' 41.76""",306.00,1.2000,1,1,cras,24.489,29.823,25.759,29.823,17.771,-12.244,-1.588,-12.879,1990,fsxp_missionobjects.bgl +730,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Longleat_Tree_Oak_02,tree,51.18007349,-2.26032503,"N51° 10' 48.26""","W2° 15' 37.17""",45.00,1.3000,1,1,cras,26.53,32.308,27.905,32.308,19.252,-13.265,-1.721,-13.952,1990,fsxp_missionobjects.bgl +731,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Copy - Longleat_Tree_Oak_02,tree,51.18063139,-2.25929724,"N51° 10' 50.27""","W2° 15' 33.47""",0.00,1.4000,1,1,cras,28.571,34.794,30.052,34.794,20.733,-14.285,-1.853,-15.025,1990,fsxp_missionobjects.bgl +732,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Longleat_Tree_Oak_03,tree,51.18365486,-2.26900281,"N51° 11' 01.16""","W2° 16' 08.41""",22.50,0.3500,1,1,cras,10.814,8.659,10.552,8.659,7.555,-5.407,-0.022,-5.276,1806,fsxp_missionobjects.bgl +733,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Longleat_Tree_Oak_03,tree,51.18241308,-2.26811668,"N51° 10' 56.69""","W2° 16' 05.22""",337.50,0.7500,1,1,cras,23.172,18.555,22.612,18.555,16.188,-11.586,-0.047,-11.306,1806,fsxp_missionobjects.bgl +734,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Longleat_Tree_Oak_03,tree,51.18391581,-2.26482779,"N51° 11' 02.10""","W2° 15' 53.38""",0.00,0.8000,1,1,cras,24.717,19.792,24.119,19.792,17.268,-12.358,-0.05,-12.059,1806,fsxp_missionobjects.bgl +735,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Longleat_Tree_Oak_03,tree,51.18400580,-2.26026946,"N51° 11' 02.42""","W2° 15' 36.97""",49.50,0.7500,1,1,cras,23.172,18.555,22.612,18.555,16.188,-11.586,-0.047,-11.306,1806,fsxp_missionobjects.bgl +736,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Longleat_Tree_Oak_03,tree,51.17651911,-2.27068892,"N51° 10' 35.47""","W2° 16' 14.48""",256.50,0.9000,1,1,cras,27.807,22.266,27.134,22.266,19.426,-13.903,-0.057,-13.567,1806,fsxp_missionobjects.bgl +737,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Longleat_Tree_Oak_03,tree,51.17786887,-2.26924723,"N51° 10' 40.33""","W2° 16' 09.29""",337.50,0.8500,1,1,cras,26.262,21.029,25.627,21.029,18.347,-13.131,-0.054,-12.813,1806,fsxp_missionobjects.bgl +738,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Longleat_Tree_Oak_03,tree,51.18524758,-2.27224451,"N51° 11' 06.89""","W2° 16' 20.08""",189.00,1.0000,1,1,cras,30.896,24.74,30.149,24.74,21.584,-15.448,-0.063,-15.074,1806,fsxp_missionobjects.bgl +739,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Longleat_Tree_Oak_03,tree,51.18429375,-2.27485841,"N51° 11' 03.46""","W2° 16' 29.49""",0.00,0.5500,1,1,cras,16.993,13.607,16.582,13.607,11.871,-8.496,-0.035,-8.291,1806,fsxp_missionobjects.bgl +740,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - Longleat_Tree_Oak_03,tree,51.18443772,-2.27551673,"N51° 11' 03.98""","W2° 16' 31.86""",315.00,1.0000,1,1,cras,30.896,24.74,30.149,24.74,21.584,-15.448,-0.063,-15.074,1806,fsxp_missionobjects.bgl +741,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - NEW - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18215212,-2.25711392,"N51° 10' 55.75""","W2° 15' 25.61""",274.50,1.2000,1,1,cras,37.076,29.688,36.179,29.688,25.901,-18.538,-0.076,-18.089,1806,fsxp_missionobjects.bgl +742,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Copy - NEW - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18231410,-2.25898895,"N51° 10' 56.33""","W2° 15' 32.36""",274.50,0.6500,1,1,cras,20.083,16.081,19.597,16.081,14.03,-10.041,-0.041,-9.798,1806,fsxp_missionobjects.bgl +743,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Copy - NEW - Copy - Longleat_Tree_Chestnut_03,tree,51.18332192,-2.25852778,"N51° 10' 59.96""","W2° 15' 30.70""",18.00,1.2500,1,1,cras,23.176,31.408,21.637,31.408,15.853,-11.425,-3.946,-10.818,1828,fsxp_missionobjects.bgl +744,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - NEW - Copy - Longleat_Tree_Oak_01,tree,51.18693029,-2.26169723,"N51° 11' 12.95""","W2° 15' 42.11""",18.00,0.6500,1,1,cras,18.592,20.391,17.921,20.391,12.911,-9.329,-1.271,-8.949,2035,fsxp_missionobjects.bgl +745,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - NEW - Copy - Longleat_Tree_Oak_01,tree,51.18747919,-2.25855561,"N51° 11' 14.93""","W2° 15' 30.80""",36.00,1.2500,1,1,cras,35.753,39.213,34.464,39.213,24.83,-17.94,-2.445,-17.21,2035,fsxp_missionobjects.bgl +746,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - NEW - Copy - Longleat_Tree_Oak_01,tree,51.18783013,-2.25958063,"N51° 11' 16.19""","W2° 15' 34.49""",36.00,1.5000,1,1,cras,42.904,47.056,41.356,47.056,29.796,-21.529,-2.934,-20.652,2035,fsxp_missionobjects.bgl +747,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - NEW - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18181018,-2.25718056,"N51° 10' 54.52""","W2° 15' 25.85""",324.00,1.5000,0,1,bbox,11.753,5.632,11.706,5.632,8.294,-6.632,-0.138,-5.956,0,vegetation.bgl +748,gen_airshow_smalltent2_1,gen_airshow_smalltent2_1,Copy - NEW - gen_airshow_smalltent2_1,tent,51.18535556,-2.26247231,"N51° 11' 07.28""","W2° 15' 44.90""",180.00,1.0000,1,1,cras,22.18,7.62,15.108,7.62,13.419,-11.065,-0.022,-7.515,51,fillerobjects.bgl +749,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - NEW copy - Copy - Longleat_Tree_Oak_01,tree,51.18650736,-2.26365005,"N51° 11' 11.43""","W2° 15' 49.14""",198.00,0.6500,1,1,cras,18.592,20.391,17.921,20.391,12.911,-9.329,-1.271,-8.949,2035,fsxp_missionobjects.bgl +750,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Copy - NEW- Copy - Longleat_Tree_Oak_01,tree,51.18659735,-2.26914723,"N51° 11' 11.75""","W2° 16' 08.93""",189.00,1.2500,1,1,cras,35.753,39.213,34.464,39.213,24.83,-17.94,-2.445,-17.21,2035,fsxp_missionobjects.bgl +751,VEH_Land_car_truck3_white,VEH_Land_car_truck3_white,Copy - NEW Copy - VEH_Land_car_truck3_white,vehicle,51.18385283,-2.27476948,"N51° 11' 01.87""","W2° 16' 29.17""",162.90,1.0000,1,1,cras,4.253,4.44,13.93,4.44,7.282,-2.117,-0.11,-10.565,59,vehicles_land.bgl +752,VEH_land_DeliveryVan,VEH_land_DeliveryVan,Copy - NEW_VEH_land_DeliveryVan,vehicle,51.18645337,-2.27040838,"N51° 11' 11.23""","W2° 16' 13.47""",90.90,1.0000,1,1,cras,3.38,2.857,6.431,2.857,3.632,-1.681,-0.014,-3.22,9,vehicles_land.bgl +753,RedBull_Hangar_01,MIS_RedBull_Hangars,Copy - RedBull_Hangar_01,building,51.18399680,-2.27354170,"N51° 11' 02.39""","W2° 16' 24.75""",254.70,1.0000,1,1,cras,13.129,8.122,16.582,8.122,10.575,-6.581,-0.085,-9.272,57,fsxp_missionobjects.bgl +754,veg_tb_bush_Bayberry_3_5ft,veg_tb_bush_Bayberry_3_5ft,Copy - veg_tb_bush_Bayberry_3_5ft,bush,51.18347489,-2.26800000,"N51° 11' 00.51""","W2° 16' 04.80""",0.00,1.0000,0,1,bbox,4.932,1.589,4.703,1.589,3.407,-2.688,-0.055,-2.276,0,vegetation.bgl +755,veg_tb_bush_Bayberry_3_5ft,veg_tb_bush_Bayberry_3_5ft,Copy - veg_tb_bush_Bayberry_3_5ft,bush,51.18297098,-2.26754168,"N51° 10' 58.70""","W2° 16' 03.15""",0.00,1.0000,0,1,bbox,4.932,1.589,4.703,1.589,3.407,-2.688,-0.055,-2.276,0,vegetation.bgl +756,veg_tb_bush_Bayberry_3_5ft,veg_tb_bush_Bayberry_3_5ft,Copy - veg_tb_bush_Bayberry_3_5ft,bush,51.18576949,-2.26646947,"N51° 11' 08.77""","W2° 15' 59.29""",333.00,1.0000,0,1,bbox,4.932,1.589,4.703,1.589,3.407,-2.688,-0.055,-2.276,0,vegetation.bgl +757,veg_tb_bush_Bayberry_3_5ft,veg_tb_bush_Bayberry_3_5ft,Copy - veg_tb_bush_Bayberry_3_5ft,bush,51.18513060,-2.25906950,"N51° 11' 06.47""","W2° 15' 32.65""",0.00,1.0000,0,1,bbox,4.932,1.589,4.703,1.589,3.407,-2.688,-0.055,-2.276,0,vegetation.bgl +758,veg_tb_bush_Bayberry_3_5ft,veg_tb_bush_Bayberry_3_5ft,Copy - veg_tb_bush_Bayberry_3_5ft,bush,51.18223311,-2.26361669,"N51° 10' 56.04""","W2° 15' 49.02""",0.00,1.5000,0,1,bbox,7.398,2.383,7.054,2.383,5.111,-4.032,-0.083,-3.413,0,vegetation.bgl +759,veg_tb_bush_Bayberry_4ft,veg_tb_bush_Bayberry_4ft,Copy - veg_tb_bush_Bayberry_4ft,bush,51.18533757,-2.26670559,"N51° 11' 07.22""","W2° 16' 00.14""",0.00,1.2500,0,1,bbox,3.737,2.273,3.548,2.273,2.577,-1.964,-0.04,-1.893,0,vegetation.bgl +760,veg_tb_bush_Bayberry_4ft,veg_tb_bush_Bayberry_4ft,Copy - veg_tb_bush_Bayberry_4ft,bush,51.18360087,-2.25978339,"N51° 11' 00.96""","W2° 15' 35.22""",0.00,1.0000,0,1,bbox,2.99,1.818,2.839,1.818,2.061,-1.571,-0.032,-1.515,0,vegetation.bgl +761,veg_tb_bush_Bayberry_4ft,veg_tb_bush_Bayberry_4ft,Copy - veg_tb_bush_Bayberry_4ft,bush,51.18538256,-2.26880835,"N51° 11' 07.38""","W2° 16' 07.71""",0.00,1.0000,0,1,bbox,2.99,1.818,2.839,1.818,2.061,-1.571,-0.032,-1.515,0,vegetation.bgl +762,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - veg_tb_bush_Bayberry_8ft,bush,51.18340290,-2.26793060,"N51° 11' 00.25""","W2° 16' 04.55""",0.00,0.9000,0,1,bbox,7.052,3.379,7.024,3.379,4.976,-3.979,-0.083,-3.574,0,vegetation.bgl +763,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - veg_tb_bush_Bayberry_8ft,bush,51.18311495,-2.26516390,"N51° 10' 59.21""","W2° 15' 54.59""",315.00,1.0000,0,1,bbox,7.835,3.755,7.804,3.755,5.529,-4.421,-0.092,-3.971,0,vegetation.bgl +764,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - veg_tb_bush_Bayberry_8ft,bush,51.18218812,-2.26242227,"N51° 10' 55.88""","W2° 15' 44.72""",0.00,1.0000,0,1,bbox,7.835,3.755,7.804,3.755,5.529,-4.421,-0.092,-3.971,0,vegetation.bgl +765,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - veg_tb_bush_Bayberry_8ft,bush,51.18541855,-2.26435002,"N51° 11' 07.51""","W2° 15' 51.66""",0.00,1.0000,0,1,bbox,7.835,3.755,7.804,3.755,5.529,-4.421,-0.092,-3.971,0,vegetation.bgl +766,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - veg_tb_bush_Bayberry_8ft,bush,51.17751793,-2.26686115,"N51° 10' 39.06""","W2° 16' 00.70""",36.00,1.2500,0,1,bbox,9.794,4.693,9.755,4.693,6.912,-5.526,-0.115,-4.964,0,vegetation.bgl +767,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - veg_tb_bush_Bayberry_8ft,bush,51.17756293,-2.26993061,"N51° 10' 39.23""","W2° 16' 11.75""",297.00,1.0000,0,1,bbox,7.835,3.755,7.804,3.755,5.529,-4.421,-0.092,-3.971,0,vegetation.bgl +768,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - veg_tb_bush_Bayberry_8ft,bush,51.18539156,-2.27327784,"N51° 11' 07.41""","W2° 16' 23.80""",54.00,0.6000,0,1,bbox,4.701,2.253,4.682,2.253,3.318,-2.653,-0.055,-2.382,0,vegetation.bgl +769,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - veg_tb_bush_Bayberry_8ft,bush,51.18067638,-2.26890558,"N51° 10' 50.43""","W2° 16' 08.06""",18.00,1.2560,0,1,bbox,9.841,4.716,9.802,4.716,6.945,-5.553,-0.115,-4.987,0,vegetation.bgl +770,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,Copy - veg_tb_bush_Bayberry_8ft,bush,51.18302497,-2.25930562,"N51° 10' 58.89""","W2° 15' 33.50""",0.00,1.0000,0,1,bbox,7.835,3.755,7.804,3.755,5.529,-4.421,-0.092,-3.971,0,vegetation.bgl +771,gen_airshow_smalltent1_2,gen_airshow_smalltent1_2,gen_airshow_smalltent1_2,tent,51.18405079,-2.27331396,"N51° 11' 02.58""","W2° 16' 23.93""",72.00,1.0000,1,1,cras,12.28,4.522,9.26,4.522,7.69,-6.147,-0.0,-4.629,65,fillerobjects.bgl +772,gen_airshow_smalltent2_1,gen_airshow_smalltent2_1,gen_airshow_smalltent2_1,tent,51.18403279,-2.27479722,"N51° 11' 02.52""","W2° 16' 29.27""",72.00,1.0000,1,1,cras,22.18,7.62,15.108,7.62,13.419,-11.065,-0.022,-7.515,51,fillerobjects.bgl +773,gen_airshow_smalltent2_1,gen_airshow_smalltent2_1,gen_airshow_smalltent2_1,tent,51.18514860,-2.26348895,"N51° 11' 06.53""","W2° 15' 48.56""",90.00,1.0000,1,1,cras,22.18,7.62,15.108,7.62,13.419,-11.065,-0.022,-7.515,51,fillerobjects.bgl +774,Gen_Crowd_Berlin04,Gen_Crowd_Berlin04,Gen_Crowd_Berlin04,props,51.18603944,-2.26824727,"N51° 11' 09.74""","W2° 16' 05.69""",3.60,1.0000,1,1,cras,186.828,2.458,55.573,2.458,97.459,-90.217,-0.215,-22.1,7,FSXP_People.bgl +775,Gen_Crowd_Camera,Gen_Crowd_Camera,Gen_Crowd_Camera,props,51.17700502,-2.27047501,"N51° 10' 37.22""","W2° 16' 13.71""",69.30,1.0000,1,1,cras,0.884,2.239,0.884,2.239,0.625,-0.442,-0.077,-0.442,1,FSXP_People.bgl +776,Gen_Crowd_Concession,Gen_Crowd_Concession,Gen_Crowd_Concession,props,51.18517559,-2.26358894,"N51° 11' 06.63""","W2° 15' 48.92""",244.80,1.0000,1,1,cras,22.373,2.406,10.335,2.406,12.322,-11.186,-0.21,-5.167,34,FSXP_People.bgl +777,Gen_Crowd_Restroom02,Gen_Crowd_Restroom02,Gen_Crowd_Restroom02,props,51.18534656,-2.26362783,"N51° 11' 07.25""","W2° 15' 49.06""",0.00,1.0000,1,1,cras,1.658,2.239,8.341,2.239,4.252,-0.829,-0.179,-4.17,37,FSXP_People.bgl +778,Gen_Crowd04,Gen_Crowd04,Gen_Crowd04,props,51.18541855,-2.27603615,"N51° 11' 07.51""","W2° 16' 33.73""",74.70,1.0000,1,1,cras,118.646,7.813,162.442,7.813,100.579,-55.128,-0.215,-68.798,25,FSXP_People.bgl +779,Gen_Plane_Extra300_Blue,,Gen_Plane_Extra300_Blue,other,51.18356488,-2.27341119,"N51° 11' 00.83""","W2° 16' 24.28""",254.70,1.0000,0,0,,,,,,,,,,, +780,Longleat_Tree_BlackPoplar_03,Longleat_Tree_BlackPoplar_03,Longleat_Tree_BlackPoplar_03,tree,51.18398780,-2.26893618,"N51° 11' 02.36""","W2° 16' 08.17""",0.00,0.6000,1,1,cras,13.951,16.304,14.483,16.304,10.055,-6.975,-1.364,-7.241,1769,fsxp_missionobjects.bgl +781,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,Longleat_Tree_BlackPoplar_04,tree,51.18369985,-2.26916115,"N51° 11' 01.32""","W2° 16' 08.98""",0.00,1.2000,1,1,cras,3.401,4.786,3.253,4.786,2.353,-1.753,-0.008,-1.626,52,fsxp_missionobjects.bgl +782,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,Longleat_Tree_Chestnut_01,tree,51.18198115,-2.26497229,"N51° 10' 55.13""","W2° 15' 53.90""",342.00,1.2000,1,1,cras,27.751,40.302,26.606,40.302,19.222,-13.839,-3.792,-13.303,7566,fsxp_missionobjects.bgl +783,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,Longleat_Tree_Chestnut_02,tree,51.18543655,-2.26841952,"N51° 11' 07.57""","W2° 16' 06.31""",0.00,0.8500,1,1,cras,14.962,19.485,13.501,19.485,10.076,-7.455,-2.16,-6.738,1787,fsxp_missionobjects.bgl +784,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,tree,51.18176519,-2.26451112,"N51° 10' 54.35""","W2° 15' 52.24""",0.00,1.2000,1,1,cras,22.249,30.151,20.772,30.151,15.219,-10.968,-3.788,-10.385,1828,fsxp_missionobjects.bgl +785,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,tree,51.18488764,-2.27003337,"N51° 11' 05.60""","W2° 16' 12.12""",0.00,1.1000,1,1,cras,31.463,34.508,30.328,34.508,21.85,-15.788,-2.152,-15.145,2035,fsxp_missionobjects.bgl +786,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,tree,51.18400580,-2.26829446,"N51° 11' 02.42""","W2° 16' 05.86""",207.00,0.5000,1,1,cras,14.301,15.685,13.785,15.685,9.932,-7.176,-0.978,-6.884,2035,fsxp_missionobjects.bgl +787,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,tree,51.18537356,-2.26318058,"N51° 11' 07.34""","W2° 15' 47.45""",0.00,1.0000,1,1,cras,28.603,31.371,27.571,31.371,19.864,-14.352,-1.956,-13.768,2035,fsxp_missionobjects.bgl +788,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,tree,51.18489664,-2.27239170,"N51° 11' 05.63""","W2° 16' 20.61""",180.00,1.0000,1,1,cras,20.408,24.853,21.466,24.853,14.809,-10.204,-1.324,-10.732,1990,fsxp_missionobjects.bgl +789,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,tree,51.18379884,-2.26822229,"N51° 11' 01.68""","W2° 16' 05.60""",279.00,0.7000,1,1,cras,14.285,17.397,15.026,17.397,10.366,-7.143,-0.927,-7.513,1990,fsxp_missionobjects.bgl +790,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,tree,51.18333992,-2.26515284,"N51° 11' 00.02""","W2° 15' 54.55""",27.00,0.8500,1,1,cras,17.347,21.125,18.246,21.125,12.588,-8.673,-1.125,-9.123,1990,fsxp_missionobjects.bgl +791,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,tree,51.18163021,-2.26231112,"N51° 10' 53.87""","W2° 15' 44.32""",0.00,1.0000,1,1,cras,20.408,24.853,21.466,24.853,14.809,-10.204,-1.324,-10.732,1990,fsxp_missionobjects.bgl +792,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,tree,51.18540055,-2.26290280,"N51° 11' 07.44""","W2° 15' 46.45""",0.00,1.0000,1,1,cras,20.408,24.853,21.466,24.853,14.809,-10.204,-1.324,-10.732,1990,fsxp_missionobjects.bgl +793,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,tree,51.18548154,-2.26425002,"N51° 11' 07.73""","W2° 15' 51.30""",0.00,0.5000,1,1,cras,10.204,12.426,10.733,12.426,7.405,-5.102,-0.662,-5.366,1990,fsxp_missionobjects.bgl +794,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,tree,51.18507661,-2.27298061,"N51° 11' 06.28""","W2° 16' 22.73""",0.00,1.0000,1,1,cras,30.896,24.74,30.149,24.74,21.584,-15.448,-0.063,-15.074,1806,fsxp_missionobjects.bgl +795,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,tree,51.18360987,-2.26773061,"N51° 11' 01.00""","W2° 16' 03.83""",337.50,0.7500,1,1,cras,23.172,18.555,22.612,18.555,16.188,-11.586,-0.047,-11.306,1806,fsxp_missionobjects.bgl +796,MIS_RedBull_Powerstation,MIS_RedBull_Powerstation,MIS_RedBull_Powerstation,building,51.18639938,-2.26839731,"N51° 11' 11.04""","W2° 16' 06.23""",180.00,1.0000,1,1,cras,13.671,9.199,10.103,9.199,8.5,-6.835,-0.567,-5.471,57,fsxp_missionobjects.bgl +797,MIS_RedBull_Tower,MIS_RedBull_Tower,MIS_RedBull_Tower,building,51.18534656,-2.26354728,"N51° 11' 07.25""","W2° 15' 48.77""",270.00,1.0000,1,1,cras,12.733,34.784,20.997,34.784,12.278,-6.323,-6.704,-9.579,36,fsxp_missionobjects.bgl +798,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,NEW - Copy - Copy - Copy - Longleat_Tree_Oak_02,tree,51.18715525,-2.26176672,"N51° 11' 13.76""","W2° 15' 42.36""",18.00,1.5000,1,1,cras,30.612,37.279,32.198,37.279,22.214,-15.305,-1.986,-16.099,1990,fsxp_missionobjects.bgl +799,Longleat_Tree_Oak_03,Longleat_Tree_Oak_03,NEW - Copy - Copy - Longleat_Tree_Oak_03,tree,51.18229610,-2.25828613,"N51° 10' 56.27""","W2° 15' 29.83""",274.50,1.2000,1,1,cras,37.076,29.688,36.179,29.688,25.901,-18.538,-0.076,-18.089,1806,fsxp_missionobjects.bgl +800,Longleat_Tree_Chestnut_03,Longleat_Tree_Chestnut_03,NEW - Copy - Longleat_Tree_Chestnut_03,tree,51.18271902,-2.25870556,"N51° 10' 57.79""","W2° 15' 31.34""",18.00,0.9000,1,1,cras,16.687,22.614,15.579,22.614,11.414,-8.226,-2.841,-7.789,1828,fsxp_missionobjects.bgl +801,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,NEW - Copy - Longleat_Tree_Oak_01,tree,51.18713725,-2.26110002,"N51° 11' 13.69""","W2° 15' 39.96""",36.00,1.2500,1,1,cras,35.753,39.213,34.464,39.213,24.83,-17.94,-2.445,-17.21,2035,fsxp_missionobjects.bgl +802,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,NEW - Copy - Longleat_Tree_Oak_02,tree,51.18238608,-2.25874730,"N51° 10' 56.59""","W2° 15' 31.49""",18.00,0.9000,1,1,cras,18.367,22.367,19.319,22.367,13.328,-9.183,-1.191,-9.659,1990,fsxp_missionobjects.bgl +803,veg_tb_bush_Bayberry_8ft,veg_tb_bush_Bayberry_8ft,NEW - Copy - veg_tb_bush_Bayberry_8ft,bush,51.18178319,-2.25704167,"N51° 10' 54.42""","W2° 15' 25.35""",324.00,1.5000,0,1,bbox,11.753,5.632,11.706,5.632,8.294,-6.632,-0.138,-5.956,0,vegetation.bgl +804,gen_airshow_smalltent2_1,gen_airshow_smalltent2_1,NEW - gen_airshow_smalltent2_1,tent,51.18522958,-2.26248060,"N51° 11' 06.83""","W2° 15' 44.93""",180.00,1.0000,1,1,cras,22.18,7.62,15.108,7.62,13.419,-11.065,-0.022,-7.515,51,fillerobjects.bgl +805,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,NEW copy - Copy - Longleat_Tree_Oak_01,tree,51.18670533,-2.26318334,"N51° 11' 12.14""","W2° 15' 47.46""",162.00,0.5000,1,1,cras,14.301,15.685,13.785,15.685,9.932,-7.176,-0.978,-6.884,2035,fsxp_missionobjects.bgl +806,Longleat_Tree_Oak_02,Longleat_Tree_Oak_02,NEW Copy - Copy - Longleat_Tree_Oak_02,tree,51.18664234,-2.26974444,"N51° 11' 11.91""","W2° 16' 11.08""",279.00,1.5000,1,1,cras,30.612,37.279,32.198,37.279,22.214,-15.305,-1.986,-16.099,1990,fsxp_missionobjects.bgl +807,Longleat_Tree_Oak_01,Longleat_Tree_Oak_01,NEW- Copy - Longleat_Tree_Oak_01,tree,51.18661534,-2.27047225,"N51° 11' 11.82""","W2° 16' 13.70""",171.00,0.7000,1,1,cras,20.022,21.959,19.3,21.959,13.905,-10.047,-1.369,-9.638,2035,fsxp_missionobjects.bgl +808,VEH_Land_car_truck3_white,VEH_Land_car_truck3_white,NEW Copy - VEH_Land_car_truck3_white,vehicle,51.18385283,-2.27471114,"N51° 11' 01.87""","W2° 16' 28.96""",162.90,1.0000,1,1,cras,4.253,4.44,13.93,4.44,7.282,-2.117,-0.11,-10.565,59,vehicles_land.bgl +809,gen_airshow_smalltent2_1,gen_airshow_smalltent2_1,New_gen_airshow_smalltent2_1,tent,51.18639938,-2.26972223,"N51° 11' 11.04""","W2° 16' 11.00""",0.00,1.0000,1,1,cras,22.18,7.62,15.108,7.62,13.419,-11.065,-0.022,-7.515,51,fillerobjects.bgl +810,gen_airshow_smalltent3_2,gen_airshow_smalltent3_2,NEW_gen_airshow_smalltent3_2,tent,51.18639038,-2.26998341,"N51° 11' 11.01""","W2° 16' 11.94""",0.90,1.0000,1,1,cras,5.636,4.113,4.227,4.113,3.522,-2.818,-0.0,-2.113,5,fillerobjects.bgl +811,VEH_land_DeliveryVan,VEH_land_DeliveryVan,NEW_VEH_land_DeliveryVan,vehicle,51.18645337,-2.27028893,"N51° 11' 11.23""","W2° 16' 13.04""",90.90,1.0000,1,1,cras,3.38,2.857,6.431,2.857,3.632,-1.681,-0.014,-3.22,9,vehicles_land.bgl +812,RedBull_Hangar_01,MIS_RedBull_Hangars,RedBull_Hangar_01,building,51.18414977,-2.27360557,"N51° 11' 02.94""","W2° 16' 24.98""",254.70,1.0000,1,1,cras,13.129,8.122,16.582,8.122,10.575,-6.581,-0.085,-9.272,57,fsxp_missionobjects.bgl +813,VEH_Air_FireTruck_US_Green,VEH_Air_FireTruck_US_Green,VEH_Air_FireTruck_US_Green,vehicle,51.17752693,-2.27138059,"N51° 10' 39.10""","W2° 16' 16.97""",72.00,1.0000,1,1,cras,3.799,3.436,9.478,3.436,5.106,-2.012,-0.002,-4.739,61,vehicles_airport.bgl +814,Veh_Air_Fueltruck_2,Veh_Air_Fueltruck_2,Veh_Air_Fueltruck_2,vehicle,51.18364586,-2.27420563,"N51° 11' 01.13""","W2° 16' 27.14""",162.00,1.0000,1,1,cras,2.448,2.844,6.799,2.844,3.613,-1.204,-0.242,-3.399,3,vehicles_airport.bgl +815,VEH_Air_PickupEuro_White,VEH_Air_PickupEuro_White,VEH_Air_PickupEuro_White,vehicle,51.18353788,-2.27417780,"N51° 11' 00.74""","W2° 16' 27.04""",126.00,1.0000,1,1,cras,2.323,2.218,5.292,2.218,2.89,-1.181,-0.108,-2.425,3,vehicles_airport.bgl +816,VEH_Air_PickupEuro_White,VEH_Air_PickupEuro_White,VEH_Air_PickupEuro_White,vehicle,51.18370885,-2.27442507,"N51° 11' 01.35""","W2° 16' 27.93""",0.00,1.0000,1,1,cras,2.323,2.218,5.292,2.218,2.89,-1.181,-0.108,-2.425,3,vehicles_airport.bgl +817,VEH_Land_car_truck3_white,VEH_Land_car_truck3_white,VEH_Land_car_truck3_white,vehicle,51.18386182,-2.27466395,"N51° 11' 01.90""","W2° 16' 28.79""",162.90,1.0000,1,1,cras,4.253,4.44,13.93,4.44,7.282,-2.117,-0.11,-10.565,59,vehicles_land.bgl diff --git a/Missions/RB_SP_Longleat_Intermediate/RB_SP_Longleat_Intermediate_POI.csv b/Missions/RB_SP_Longleat_Intermediate/RB_SP_Longleat_Intermediate_POI.csv new file mode 100644 index 00000000..9500c0b7 --- /dev/null +++ b/Missions/RB_SP_Longleat_Intermediate/RB_SP_Longleat_Intermediate_POI.csv @@ -0,0 +1,22 @@ +poi_name,gate,role,latitude,longitude,lat_dms,lon_dms,gate_width_m +POI_Gate01,1,center,51.18134676,-2.27257501,"N51° 10' 52.85""","W2° 16' 21.27""",41.26 +Pylon01-L,1,left,51.18140525,-2.27229447,"N51° 10' 53.06""","W2° 16' 20.26""",41.26 +Pylon01-R,1,right,51.18128827,-2.27285556,"N51° 10' 52.64""","W2° 16' 22.28""",41.26 +POI_Gate02,2,center,51.17716700,-2.27029312,"N51° 10' 37.80""","W2° 16' 13.06""",40.49 +Pylon02-L,2,left,51.17732897,-2.27016119,"N51° 10' 38.38""","W2° 16' 12.58""",40.49 +Pylon02-R,2,right,51.17700502,-2.27042506,"N51° 10' 37.22""","W2° 16' 13.53""",40.49 +POI_Gate03,3,center,51.17790037,-2.26236116,"N51° 10' 40.44""","W2° 15' 44.50""",40.87 +Pylon03-L,3,left,51.17804884,-2.26253341,"N51° 10' 40.98""","W2° 15' 45.12""",40.87 +Pylon03-R,3,right,51.17775189,-2.26218891,"N51° 10' 39.91""","W2° 15' 43.88""",40.87 +POI_Gate04,4,center,51.18375384,-2.25925143,"N51° 11' 01.51""","W2° 15' 33.31""",40.51 +Pylon04-L,4,left,51.18375384,-2.25954174,"N51° 11' 01.51""","W2° 15' 34.35""",40.51 +Pylon04-R,4,right,51.18375384,-2.25896113,"N51° 11' 01.51""","W2° 15' 32.26""",40.51 +POI_Gate05,5,center,51.18570200,-2.26302228,"N51° 11' 08.53""","W2° 15' 46.88""",41.30 +Pylon05-L,5,left,51.18553553,-2.26315283,"N51° 11' 07.93""","W2° 15' 47.35""",41.30 +Pylon05-R,5,right,51.18586847,-2.26289174,"N51° 11' 09.13""","W2° 15' 46.41""",41.30 +POI_Gate06,6,center,51.18554453,-2.26729450,"N51° 11' 07.96""","W2° 16' 02.26""",40.07 +Pylon06-L,6,left,51.18536456,-2.26729450,"N51° 11' 07.31""","W2° 16' 02.26""",40.07 +Pylon06-R,6,right,51.18572450,-2.26729450,"N51° 11' 08.61""","W2° 16' 02.26""",40.07 +POI_Gate07,7,center,51.18540505,-2.27352091,"N51° 11' 07.46""","W2° 16' 24.68""",40.96 +Pylon07-L,7,left,51.18527458,-2.27331396,"N51° 11' 06.99""","W2° 16' 23.93""",40.96 +Pylon07-R,7,right,51.18553553,-2.27372786,"N51° 11' 07.93""","W2° 16' 25.42""",40.96 diff --git a/Missions/RB_SP_Longleat_Intermediate/config_RB_SP_Longleat_Intermediate.csv b/Missions/RB_SP_Longleat_Intermediate/config_RB_SP_Longleat_Intermediate.csv new file mode 100644 index 00000000..e065659a --- /dev/null +++ b/Missions/RB_SP_Longleat_Intermediate/config_RB_SP_Longleat_Intermediate.csv @@ -0,0 +1,2 @@ +display_name,min_sec,max_sec,flt_rel +趣味竞速飞行(红牛朗利特),100,240,Racing/RB_SP_Longleat_Intermediate/RB_SP_Longleat_Intermediate.FLT diff --git a/Missions/RB_SP_Tempelhof_Intermediate/RB_SP_Templehof_Intermediate_CRASH.csv b/Missions/RB_SP_Tempelhof_Intermediate/RB_SP_Templehof_Intermediate_CRASH.csv new file mode 100644 index 00000000..f1e8e9c1 --- /dev/null +++ b/Missions/RB_SP_Tempelhof_Intermediate/RB_SP_Templehof_Intermediate_CRASH.csv @@ -0,0 +1,118 @@ +id,library_name,resolved_mdln,instance_name,category,latitude,longitude,lat_dms,lon_dms,heading_deg,scale,has_cras,has_volume,volume_source,box_size_x_m,box_size_y_m,box_size_z_m,box_height_m,horiz_radius_m,cras_min_x,cras_min_y,cras_min_z,cras_nnodes,source_bgl +1,gen_airshow_5potties_01,gen_airshow_5potties_01,Copy - Copy - Copy - Copy - Copy - gen_airshow_5potties_01,other,52.47517184,13.38801110,"N52° 28' 30.62""","E13° 23' 16.84""",270.00,1.0000,1,1,cras,6.312,2.737,3.642,2.737,3.644,-3.047,-0.0,-2.431,5,fillerobjects.bgl +2,gen_airshow_5potties_01,gen_airshow_5potties_01,Copy - Copy - Copy - Copy - gen_airshow_5potties_01,other,52.47523483,13.38801663,"N52° 28' 30.85""","E13° 23' 16.86""",270.00,1.0000,1,1,cras,6.312,2.737,3.642,2.737,3.644,-3.047,-0.0,-2.431,5,fillerobjects.bgl +3,Gen_Crowd_Restroom01,Gen_Crowd_Restroom01,Copy - Copy - Copy - Copy - Gen_Crowd_Restroom01,props,52.47357912,13.38730551,"N52° 28' 24.88""","E13° 23' 14.30""",2.70,1.0000,1,1,cras,1.658,2.239,6.636,2.239,3.42,-0.829,-0.179,-3.318,6,FSXP_People.bgl +4,gen_airshow_5potties_01,gen_airshow_5potties_01,Copy - Copy - Copy - gen_airshow_5potties_01,other,52.47528882,13.38801387,"N52° 28' 31.04""","E13° 23' 16.85""",270.00,1.0000,1,1,cras,6.312,2.737,3.642,2.737,3.644,-3.047,-0.0,-2.431,5,fillerobjects.bgl +5,gen_airshow_smalltent1_2,gen_airshow_smalltent1_2,Copy - Copy - Copy - gen_airshow_smalltent1_2,tent,52.47547779,13.38796659,"N52° 28' 31.72""","E13° 23' 16.68""",271.80,1.0000,1,1,cras,12.28,4.522,9.26,4.522,7.69,-6.147,-0.0,-4.629,65,fillerobjects.bgl +6,Gen_Crowd_AircraftCrew,Gen_Crowd_AircraftCrew,Copy - Copy - Copy - Gen_Crowd_AircraftCrew,props,52.47341715,13.38846666,"N52° 28' 24.30""","E13° 23' 18.48""",45.00,1.0000,1,1,cras,0.716,1.918,0.716,1.918,0.506,-0.358,-0.08,-0.358,1,FSXP_People.bgl +7,Gen_Crowd_Concession,Gen_Crowd_Concession,Copy - Copy - Copy - Gen_Crowd_Concession,props,52.47263429,13.38774716,"N52° 28' 21.48""","E13° 23' 15.89""",255.60,1.0000,1,1,cras,22.373,2.406,10.335,2.406,12.322,-11.186,-0.21,-5.167,34,FSXP_People.bgl +8,Gen_Crowd_Restroom01,Gen_Crowd_Restroom01,Copy - Copy - Copy - Gen_Crowd_Restroom01,props,52.47357912,13.38728607,"N52° 28' 24.88""","E13° 23' 14.23""",182.70,1.0000,1,1,cras,1.658,2.239,6.636,2.239,3.42,-0.829,-0.179,-3.318,6,FSXP_People.bgl +9,Gen_Crowd_Restroom01,Gen_Crowd_Restroom01,Copy - Copy - Copy - Gen_Crowd_Restroom01,props,52.47898718,13.39926381,"N52° 28' 44.35""","E13° 23' 57.35""",194.40,1.0000,1,1,cras,1.658,2.239,6.636,2.239,3.42,-0.829,-0.179,-3.318,6,FSXP_People.bgl +10,Gen_Crowd_Restroom01,Gen_Crowd_Restroom01,Copy - Copy - Copy - Gen_Crowd_Restroom01,props,52.47899618,13.39919717,"N52° 28' 44.39""","E13° 23' 57.11""",15.30,1.0000,1,1,cras,1.658,2.239,6.636,2.239,3.42,-0.829,-0.179,-3.318,6,FSXP_People.bgl +11,Gen_Crowd_Restroom01,Gen_Crowd_Restroom01,Copy - Copy - Copy - Gen_Crowd_Restroom01,props,52.47901417,13.39923053,"N52° 28' 44.45""","E13° 23' 57.23""",195.30,1.0000,1,1,cras,1.658,2.239,6.636,2.239,3.42,-0.829,-0.179,-3.318,6,FSXP_People.bgl +12,veh_air_pushbackblue,veh_air_pushbackblue,Copy - Copy - Copy - NEW_veh_air_pushbackblue,vehicle,52.48225361,13.39311107,"N52° 28' 56.11""","E13° 23' 35.20""",134.10,1.0000,1,1,cras,2.589,1.917,9.168,1.917,4.763,-1.284,-0.004,-8.799,21,vehicles_airport.bgl +13,VEH_land_DeliveryVan,VEH_land_DeliveryVan,Copy - Copy - Copy - VEH_land_DeliveryVan,vehicle,52.47491089,13.38796944,"N52° 28' 29.68""","E13° 23' 16.69""",7.20,1.0000,1,1,cras,3.38,2.857,6.431,2.857,3.632,-1.681,-0.014,-3.22,9,vehicles_land.bgl +14,gen_airshow_5potties_01,gen_airshow_5potties_01,Copy - Copy - gen_airshow_5potties_01,other,52.47364211,13.38724441,"N52° 28' 25.11""","E13° 23' 14.08""",270.00,1.0000,1,1,cras,6.312,2.737,3.642,2.737,3.644,-3.047,-0.0,-2.431,5,fillerobjects.bgl +15,gen_airshow_5potties_01,gen_airshow_5potties_01,Copy - Copy - gen_airshow_5potties_01,other,52.47905017,13.39915828,"N52° 28' 44.58""","E13° 23' 56.97""",15.30,1.0000,1,1,cras,6.312,2.737,3.642,2.737,3.644,-3.047,-0.0,-2.431,5,fillerobjects.bgl +16,gen_airshow_concession2_01,gen_airshow_concession2_01,Copy - Copy - gen_airshow_concession2_01,props,52.47932912,13.39756387,"N52° 28' 45.58""","E13° 23' 51.23""",354.60,1.0000,1,1,cras,5.914,3.886,4.855,3.886,3.826,-2.352,0.0,-3.044,7,fillerobjects.bgl +17,gen_airshow_smalltent1_2,gen_airshow_smalltent1_2,Copy - Copy - gen_airshow_smalltent1_2,tent,52.47559477,13.38796659,"N52° 28' 32.14""","E13° 23' 16.68""",271.80,1.0000,1,1,cras,12.28,4.522,9.26,4.522,7.69,-6.147,-0.0,-4.629,65,fillerobjects.bgl +18,gen_airshow_smalltent2_1,gen_airshow_smalltent2_1,Copy - Copy - gen_airshow_smalltent2_1,tent,52.47895118,13.39945827,"N52° 28' 44.22""","E13° 23' 58.05""",283.50,1.5000,1,1,cras,33.271,11.43,22.663,11.43,20.128,-16.598,-0.033,-11.272,51,fillerobjects.bgl +19,Gen_Crowd_AircraftCrew,Gen_Crowd_AircraftCrew,Copy - Copy - Gen_Crowd_AircraftCrew,props,52.47343515,13.38845551,"N52° 28' 24.37""","E13° 23' 18.44""",45.00,1.0000,1,1,cras,0.716,1.918,0.716,1.918,0.506,-0.358,-0.08,-0.358,1,FSXP_People.bgl +20,Gen_Crowd_Concession,Gen_Crowd_Concession,Copy - Copy - Gen_Crowd_Concession,props,52.47239133,13.38778052,"N52° 28' 20.61""","E13° 23' 16.01""",255.60,1.0000,1,1,cras,22.373,2.406,10.335,2.406,12.322,-11.186,-0.21,-5.167,34,FSXP_People.bgl +21,Gen_Crowd_Restroom01,Gen_Crowd_Restroom01,Copy - Copy - Gen_Crowd_Restroom01,props,52.47353413,13.38731943,"N52° 28' 24.72""","E13° 23' 14.35""",94.50,1.0000,1,1,cras,1.658,2.239,6.636,2.239,3.42,-0.829,-0.179,-3.318,6,FSXP_People.bgl +22,Gen_Crowd_Restroom01,Gen_Crowd_Restroom01,Copy - Copy - Gen_Crowd_Restroom01,props,52.47356113,13.38735270,"N52° 28' 24.82""","E13° 23' 14.47""",274.50,1.0000,1,1,cras,1.658,2.239,6.636,2.239,3.42,-0.829,-0.179,-3.318,6,FSXP_People.bgl +23,Gen_Crowd_Restroom01,Gen_Crowd_Restroom01,Copy - Copy - Gen_Crowd_Restroom01,props,52.47349814,13.38731381,"N52° 28' 24.59""","E13° 23' 14.33""",274.50,1.0000,1,1,cras,1.658,2.239,6.636,2.239,3.42,-0.829,-0.179,-3.318,6,FSXP_People.bgl +24,Gen_Crowd_Restroom01,Gen_Crowd_Restroom01,Copy - Copy - Gen_Crowd_Restroom01,props,52.47901417,13.39925551,"N52° 28' 44.45""","E13° 23' 57.32""",14.40,1.0000,1,1,cras,1.658,2.239,6.636,2.239,3.42,-0.829,-0.179,-3.318,6,FSXP_People.bgl +25,Gen_Crowd_Restroom01,Gen_Crowd_Restroom01,Copy - Copy - Gen_Crowd_Restroom01,props,52.47902317,13.39911939,"N52° 28' 44.48""","E13° 23' 56.83""",13.50,1.0000,1,1,cras,1.658,2.239,6.636,2.239,3.42,-0.829,-0.179,-3.318,6,FSXP_People.bgl +26,veh_air_bagcart2,veh_air_bagcart2,Copy - Copy - NEW_veh_air_bagcart2,vehicle,52.48212763,13.39229719,"N52° 28' 55.66""","E13° 23' 32.27""",72.00,1.0000,1,1,cras,2.389,2.288,13.431,2.288,6.821,-1.201,-0.037,-9.345,41,vehicles_airport.bgl +27,veh_air_pushbackblue,veh_air_pushbackblue,Copy - Copy - NEW_veh_air_pushbackblue,vehicle,52.48262254,13.39734158,"N52° 28' 57.44""","E13° 23' 50.43""",126.00,1.0000,1,1,cras,2.589,1.917,9.168,1.917,4.763,-1.284,-0.004,-8.799,21,vehicles_airport.bgl +28,VEH_Land_car_stationwagon3_white,VEH_Land_car_stationwagon3_white,Copy - Copy - VEH_Land_car_stationwagon3_white,vehicle,52.47413703,13.38861108,"N52° 28' 26.89""","E13° 23' 19.00""",126.00,1.0000,1,1,cras,2.338,1.472,4.792,1.472,2.666,-1.16,0.005,-2.181,9,vehicles_land.bgl +29,VEH_land_DeliveryVan,VEH_land_DeliveryVan,Copy - Copy - VEH_land_DeliveryVan,vehicle,52.47498288,13.38798327,"N52° 28' 29.94""","E13° 23' 16.74""",7.20,1.0000,1,1,cras,3.38,2.857,6.431,2.857,3.632,-1.681,-0.014,-3.22,9,vehicles_land.bgl +30,gen_airshow_5potties_01,gen_airshow_5potties_01,Copy - gen_airshow_5potties_01,other,52.47357912,13.38724441,"N52° 28' 24.88""","E13° 23' 14.08""",270.00,1.0000,1,1,cras,6.312,2.737,3.642,2.737,3.644,-3.047,-0.0,-2.431,5,fillerobjects.bgl +31,gen_airshow_5potties_01,gen_airshow_5potties_01,Copy - gen_airshow_5potties_01,other,52.47904117,13.39924721,"N52° 28' 44.55""","E13° 23' 57.29""",15.30,1.0000,1,1,cras,6.312,2.737,3.642,2.737,3.644,-3.047,-0.0,-2.431,5,fillerobjects.bgl +32,gen_airshow_concession1_01,gen_airshow_concession1_01,Copy - gen_airshow_concession1_01,props,52.47930212,13.39712500,"N52° 28' 45.49""","E13° 23' 49.65""",355.50,1.0000,1,1,cras,9.759,4.564,6.201,4.564,5.781,-4.596,0.0,-4.155,61,fillerobjects.bgl +33,gen_airshow_concession1_02,gen_airshow_concession1_02,Copy - gen_airshow_concession1_02,props,52.47932912,13.39745549,"N52° 28' 45.58""","E13° 23' 50.84""",356.40,1.0000,1,1,cras,9.127,4.564,6.195,4.564,5.515,-4.596,-0.0,-4.155,61,fillerobjects.bgl +34,gen_airshow_concession2_01,gen_airshow_concession2_01,Copy - gen_airshow_concession2_01,props,52.47265229,13.38760827,"N52° 28' 21.55""","E13° 23' 15.39""",264.60,1.0000,1,1,cras,5.914,3.886,4.855,3.886,3.826,-2.352,0.0,-3.044,7,fillerobjects.bgl +35,gen_airshow_concession2_01,gen_airshow_concession2_01,Copy - gen_airshow_concession2_01,props,52.47930212,13.39724720,"N52° 28' 45.49""","E13° 23' 50.09""",355.50,1.0000,1,1,cras,5.914,3.886,4.855,3.886,3.826,-2.352,0.0,-3.044,7,fillerobjects.bgl +36,gen_airshow_concession2_02,gen_airshow_concession2_02,Copy - gen_airshow_concession2_02,props,52.47931112,13.39734443,"N52° 28' 45.52""","E13° 23' 50.44""",356.40,1.0000,1,1,cras,4.712,3.886,5.711,3.886,3.702,-2.352,0.0,-3.044,5,fillerobjects.bgl +37,gen_airshow_smalltent2_1,gen_airshow_smalltent2_1,Copy - gen_airshow_smalltent2_1,tent,52.47570275,13.38866942,"N52° 28' 32.53""","E13° 23' 19.21""",84.60,1.0000,1,1,cras,22.18,7.62,15.108,7.62,13.419,-11.065,-0.022,-7.515,51,fillerobjects.bgl +38,Gen_Crowd_AircraftCrew,Gen_Crowd_AircraftCrew,Copy - Gen_Crowd_AircraftCrew,props,52.47302122,13.38904442,"N52° 28' 22.88""","E13° 23' 20.56""",45.00,1.0000,1,1,cras,0.716,1.918,0.716,1.918,0.506,-0.358,-0.08,-0.358,1,FSXP_People.bgl +39,Gen_Crowd_Berlin05,Gen_Crowd_Berlin05,Copy - Gen_Crowd_Berlin05,props,52.47593671,13.38923603,"N52° 28' 33.37""","E13° 23' 21.25""",355.50,1.0000,1,1,cras,117.405,2.458,118.836,2.458,83.525,-58.702,-0.215,-59.417,37,FSXP_People.bgl +40,Gen_Crowd_Berlin06,Gen_Crowd_Berlin06,Copy - Gen_Crowd_Berlin06,props,52.47450596,13.38831386,"N52° 28' 28.22""","E13° 23' 17.93""",0.00,1.0000,1,1,cras,105.249,2.458,335.85,2.458,175.978,-52.624,-0.215,-167.925,33,FSXP_People.bgl +41,Gen_Crowd_Concession,Gen_Crowd_Concession,Copy - Gen_Crowd_Concession,props,52.47233734,13.38788881,"N52° 28' 20.41""","E13° 23' 16.40""",351.00,1.0000,1,1,cras,22.373,2.406,10.335,2.406,12.322,-11.186,-0.21,-5.167,34,FSXP_People.bgl +42,Gen_Crowd_Concession,Gen_Crowd_Concession,Copy - Gen_Crowd_Concession,props,52.47254430,13.38784992,"N52° 28' 21.16""","E13° 23' 16.26""",351.00,1.0000,1,1,cras,22.373,2.406,10.335,2.406,12.322,-11.186,-0.21,-5.167,34,FSXP_People.bgl +43,Gen_Crowd_Restroom01,Gen_Crowd_Restroom01,Copy - Gen_Crowd_Restroom01,props,52.47351613,13.38731666,"N52° 28' 24.66""","E13° 23' 14.34""",94.50,1.0000,1,1,cras,1.658,2.239,6.636,2.239,3.42,-0.829,-0.179,-3.318,6,FSXP_People.bgl +44,Gen_Crowd_Restroom01,Gen_Crowd_Restroom01,Copy - Gen_Crowd_Restroom01,props,52.47358812,13.38735270,"N52° 28' 24.92""","E13° 23' 14.47""",94.50,1.0000,1,1,cras,1.658,2.239,6.636,2.239,3.42,-0.829,-0.179,-3.318,6,FSXP_People.bgl +45,Gen_Crowd_Restroom01,Gen_Crowd_Restroom01,Copy - Gen_Crowd_Restroom01,props,52.47902317,13.39914721,"N52° 28' 44.48""","E13° 23' 56.93""",13.50,1.0000,1,1,cras,1.658,2.239,6.636,2.239,3.42,-0.829,-0.179,-3.318,6,FSXP_People.bgl +46,Gen_Crowd_Restroom02,Gen_Crowd_Restroom02,Copy - Gen_Crowd_Restroom02,props,52.47901417,13.39916666,"N52° 28' 44.45""","E13° 23' 57.00""",193.50,1.0000,1,1,cras,1.658,2.239,8.341,2.239,4.252,-0.829,-0.179,-4.17,37,FSXP_People.bgl +47,Gen_Crowd_Security,Gen_Crowd_Security,Copy - Gen_Crowd_Security,props,52.47415502,13.38862214,"N52° 28' 26.96""","E13° 23' 19.04""",324.00,1.0000,1,1,cras,0.716,1.779,0.716,1.779,0.506,-0.358,-0.08,-0.271,1,FSXP_People.bgl +48,Gen_Crowd_Security,Gen_Crowd_Security,Copy - Gen_Crowd_Security,props,52.47277826,13.38914165,"N52° 28' 22.00""","E13° 23' 20.91""",0.00,1.0000,1,1,cras,0.716,1.779,0.716,1.779,0.506,-0.358,-0.08,-0.271,1,FSXP_People.bgl +49,veh_air_bagcart2,veh_air_bagcart2,Copy - NEW_veh_air_bagcart2,vehicle,52.47917614,13.38815552,"N52° 28' 45.03""","E13° 23' 17.36""",108.00,1.0000,1,1,cras,2.389,2.288,13.431,2.288,6.821,-1.201,-0.037,-9.345,41,vehicles_airport.bgl +50,veh_air_bagloaderblue,veh_air_bagloaderblue,Copy - NEW_veh_air_bagloaderblue,vehicle,52.47923913,13.38809995,"N52° 28' 45.26""","E13° 23' 17.16""",106.20,1.0000,1,1,cras,1.937,1.937,6.014,1.937,3.159,-1.05,-0.005,-2.845,4,vehicles_airport.bgl +51,veh_air_bagloaderblue,veh_air_bagloaderblue,Copy - NEW_veh_air_bagloaderblue,vehicle,52.48202864,13.39198882,"N52° 28' 55.30""","E13° 23' 31.16""",160.20,1.0000,1,1,cras,1.937,1.937,6.014,1.937,3.159,-1.05,-0.005,-2.845,4,vehicles_airport.bgl +52,veh_air_cratesgrey,veh_air_cratesgrey,Copy - NEW_veh_air_cratesgrey,vehicle,52.48007599,13.38874997,"N52° 28' 48.27""","E13° 23' 19.50""",27.00,1.0000,1,1,cras,4.832,2.009,5.058,2.009,3.498,-2.732,-0.005,-2.554,4,vehicles_airport.bgl +53,VEH_Air_Forklift_Blue,VEH_Air_Forklift_Blue,Copy - NEW_VEH_Air_Forklift_Blue,vehicle,52.48002200,13.38869163,"N52° 28' 48.08""","E13° 23' 19.29""",117.00,1.0000,1,1,cras,2.03,2.383,5.603,2.383,2.979,-1.017,-0.035,-1.827,5,vehicles_airport.bgl +54,veh_air_pushbackblue,veh_air_pushbackblue,Copy - NEW_veh_air_pushbackblue,vehicle,52.48044492,13.38917769,"N52° 28' 49.60""","E13° 23' 21.04""",126.00,1.0000,1,1,cras,2.589,1.917,9.168,1.917,4.763,-1.284,-0.004,-8.799,21,vehicles_airport.bgl +55,RedBull_Powerstation,MIS_RedBull_Powerstation,Copy - RedBull_Powerstation,building,52.47875322,13.39968332,"N52° 28' 43.51""","E13° 23' 58.86""",191.70,1.5000,1,1,cras,20.506,13.798,15.155,13.798,12.749,-10.253,-0.85,-8.207,57,fsxp_missionobjects.bgl +56,VEH_Land_AmbulanceEur,VEH_Land_AmbulanceEur,Copy - VEH_Land_AmbulanceEur,vehicle,52.47394806,13.38833330,"N52° 28' 26.21""","E13° 23' 18.00""",84.60,1.0000,1,1,cras,2.481,2.677,4.827,2.677,2.713,-1.231,-0.049,-2.44,9,vehicles_land.bgl +57,VEH_Land_car_stationwagon3_white,VEH_Land_car_stationwagon3_white,Copy - VEH_Land_car_stationwagon3_white,vehicle,52.47418202,13.38858887,"N52° 28' 27.06""","E13° 23' 18.92""",82.80,1.0000,1,1,cras,2.338,1.472,4.792,1.472,2.666,-1.16,0.005,-2.181,9,vehicles_land.bgl +58,VEH_Land_car_truck3_white,VEH_Land_car_truck3_white,Copy - VEH_Land_car_truck3_white,vehicle,52.47896918,13.39992221,"N52° 28' 44.29""","E13° 23' 59.72""",13.50,1.0000,1,1,cras,4.253,4.44,13.93,4.44,7.282,-2.117,-0.11,-10.565,59,vehicles_land.bgl +59,VEH_Land_car_truck3_white,VEH_Land_car_truck3_white,Copy - VEH_Land_car_truck3_white,vehicle,52.47396606,13.38817773,"N52° 28' 26.28""","E13° 23' 17.44""",171.90,1.0000,1,1,cras,4.253,4.44,13.93,4.44,7.282,-2.117,-0.11,-10.565,59,vehicles_land.bgl +60,VEH_land_DeliveryVan,VEH_land_DeliveryVan,Copy - VEH_land_DeliveryVan,vehicle,52.47506386,13.38800272,"N52° 28' 30.23""","E13° 23' 16.81""",7.20,1.0000,1,1,cras,3.38,2.857,6.431,2.857,3.632,-1.681,-0.014,-3.22,9,vehicles_land.bgl +61,gen_airshow_5potties_01,gen_airshow_5potties_01,gen_airshow_5potties_01,other,52.47403804,13.38835551,"N52° 28' 26.54""","E13° 23' 18.08""",171.00,1.0000,1,1,cras,6.312,2.737,3.642,2.737,3.644,-3.047,-0.0,-2.431,5,fillerobjects.bgl +62,gen_airshow_5potties_01,gen_airshow_5potties_01,gen_airshow_5potties_01,other,52.47351613,13.38724441,"N52° 28' 24.66""","E13° 23' 14.08""",270.00,1.0000,1,1,cras,6.312,2.737,3.642,2.737,3.644,-3.047,-0.0,-2.431,5,fillerobjects.bgl +63,gen_airshow_concession1_01,gen_airshow_concession1_01,gen_airshow_concession1_01,props,52.47233734,13.38764439,"N52° 28' 20.41""","E13° 23' 15.52""",263.70,1.0000,1,1,cras,9.759,4.564,6.201,4.564,5.781,-4.596,0.0,-4.155,61,fillerobjects.bgl +64,gen_airshow_concession1_02,gen_airshow_concession1_02,gen_airshow_concession1_02,props,52.47258030,13.38760274,"N52° 28' 21.29""","E13° 23' 15.37""",263.70,1.0000,1,1,cras,9.127,4.564,6.195,4.564,5.515,-4.596,-0.0,-4.155,61,fillerobjects.bgl +65,gen_airshow_concession2_01,gen_airshow_concession2_01,gen_airshow_concession2_01,props,52.47242733,13.38764439,"N52° 28' 20.74""","E13° 23' 15.52""",264.60,1.0000,1,1,cras,5.914,3.886,4.855,3.886,3.826,-2.352,0.0,-3.044,7,fillerobjects.bgl +66,gen_airshow_concession2_02,gen_airshow_concession2_02,gen_airshow_concession2_02,props,52.47249931,13.38763333,"N52° 28' 21.00""","E13° 23' 15.48""",262.80,1.0000,1,1,cras,4.712,3.886,5.711,3.886,3.702,-2.352,0.0,-3.044,5,fillerobjects.bgl +67,gen_airshow_smalltent1_1,gen_airshow_smalltent1_1,gen_airshow_smalltent1_1,tent,52.47379509,13.38791664,"N52° 28' 25.66""","E13° 23' 16.50""",85.50,1.0000,1,1,cras,12.28,4.522,9.26,4.522,7.69,-6.147,-0.0,-4.629,62,fillerobjects.bgl +68,gen_airshow_smalltent1_2,gen_airshow_smalltent1_2,gen_airshow_smalltent1_2,tent,52.47222036,13.38766661,"N52° 28' 19.99""","E13° 23' 15.60""",352.80,1.0000,1,1,cras,12.28,4.522,9.26,4.522,7.69,-6.147,-0.0,-4.629,65,fillerobjects.bgl +69,gen_airshow_smalltent1_2,gen_airshow_smalltent1_2,gen_airshow_smalltent1_2,tent,52.47299423,13.38824722,"N52° 28' 22.78""","E13° 23' 17.69""",315.00,0.7500,1,1,cras,9.21,3.391,6.945,3.391,5.768,-4.61,-0.0,-3.472,65,fillerobjects.bgl +70,gen_airshow_smalltent2_1,gen_airshow_smalltent2_1,gen_airshow_smalltent2_1,tent,52.47420901,13.38807774,"N52° 28' 27.15""","E13° 23' 17.08""",174.60,1.0000,1,1,cras,22.18,7.62,15.108,7.62,13.419,-11.065,-0.022,-7.515,51,fillerobjects.bgl +71,gen_airshow_smalltent2_2,gen_airshow_smalltent2_2,gen_airshow_smalltent2_2,tent,52.47314720,13.38798881,"N52° 28' 23.33""","E13° 23' 16.76""",84.60,1.0000,1,1,cras,22.18,7.62,15.108,7.62,13.419,-11.065,-0.022,-7.515,51,fillerobjects.bgl +72,gen_airshow_smalltent3_1,gen_airshow_smalltent3_1,gen_airshow_smalltent3_1,tent,52.47344415,13.38797221,"N52° 28' 24.40""","E13° 23' 16.70""",265.50,1.0000,1,1,cras,5.636,4.113,4.375,4.113,3.567,-2.818,-0.0,-2.262,6,fillerobjects.bgl +73,gen_airshow_smalltent3_2,gen_airshow_smalltent3_2,gen_airshow_smalltent3_2,tent,52.47361512,13.38794715,"N52° 28' 25.01""","E13° 23' 16.61""",265.50,1.2000,1,1,cras,6.763,4.936,5.072,4.936,4.227,-3.381,-0.0,-2.536,5,fillerobjects.bgl +74,Gen_Crowd_AircraftCrew,Gen_Crowd_AircraftCrew,Gen_Crowd_AircraftCrew,props,52.47303022,13.38897494,"N52° 28' 22.91""","E13° 23' 20.31""",0.00,1.0000,1,1,cras,0.716,1.918,0.716,1.918,0.506,-0.358,-0.08,-0.358,1,FSXP_People.bgl +75,Gen_Crowd_Berlin01,Gen_Crowd_Berlin01,Gen_Crowd_Berlin01,props,52.47873522,13.39964996,"N52° 28' 43.45""","E13° 23' 58.74""",2.70,1.0000,1,1,cras,253.457,2.519,110.449,2.519,138.238,-126.728,-0.277,-55.224,28,FSXP_People.bgl +76,Gen_Crowd_Berlin02,Gen_Crowd_Berlin02,Gen_Crowd_Berlin02,props,52.47870823,13.39566386,"N52° 28' 43.35""","E13° 23' 44.39""",1.80,1.0000,1,1,cras,263.511,2.519,153.992,2.519,152.604,-137.604,-0.277,-82.254,32,FSXP_People.bgl +77,Gen_Crowd_Berlin03,Gen_Crowd_Berlin03,Gen_Crowd_Berlin03,props,52.47795236,13.39311660,"N52° 28' 40.63""","E13° 23' 35.22""",9.00,1.0000,1,1,cras,156.187,2.458,143.014,2.458,105.886,-73.315,-0.215,-77.548,33,FSXP_People.bgl +78,Gen_Crowd_Berlin04,Gen_Crowd_Berlin04,Gen_Crowd_Berlin04,props,52.47742145,13.39138331,"N52° 28' 38.72""","E13° 23' 28.98""",355.50,1.0000,1,1,cras,186.828,2.458,55.573,2.458,97.459,-90.217,-0.215,-22.1,7,FSXP_People.bgl +79,Gen_Crowd_Berlin05,Gen_Crowd_Berlin05,Gen_Crowd_Berlin05,props,52.47682756,13.39007498,"N52° 28' 36.58""","E13° 23' 24.27""",355.50,1.0000,1,1,cras,117.405,2.458,118.836,2.458,83.525,-58.702,-0.215,-59.417,37,FSXP_People.bgl +80,Gen_Crowd_Berlin06,Gen_Crowd_Berlin06,Gen_Crowd_Berlin06,props,52.47443397,13.38821663,"N52° 28' 27.96""","E13° 23' 17.58""",0.00,1.0000,1,1,cras,105.249,2.458,335.85,2.458,175.978,-52.624,-0.215,-167.925,33,FSXP_People.bgl +81,Gen_Crowd_Berlin07,Gen_Crowd_Berlin07,Gen_Crowd_Berlin07,props,52.47195941,13.38877218,"N52° 28' 19.05""","E13° 23' 19.58""",0.00,1.0000,1,1,cras,101.929,2.458,128.041,2.458,81.829,-44.783,-0.215,-55.802,23,FSXP_People.bgl +82,Gen_Crowd_Berlin08,Gen_Crowd_Berlin08,Gen_Crowd_Berlin08,props,52.47062764,13.39034437,"N52° 28' 14.26""","E13° 23' 25.24""",0.00,1.0000,1,1,cras,228.966,2.458,178.754,2.458,145.24,-111.601,-0.215,-85.594,31,FSXP_People.bgl +83,Gen_Crowd_Berlin09,Gen_Crowd_Berlin09,Gen_Crowd_Berlin09,props,52.46799110,13.39688050,"N52° 28' 04.77""","E13° 23' 48.77""",29.70,1.0000,1,1,cras,794.807,2.458,59.89,2.458,398.53,-397.403,-0.215,-29.945,17,FSXP_People.bgl +84,Gen_Crowd_Concession,Gen_Crowd_Concession,Gen_Crowd_Concession,props,52.47229235,13.38790826,"N52° 28' 20.25""","E13° 23' 16.47""",351.00,1.0000,1,1,cras,22.373,2.406,10.335,2.406,12.322,-11.186,-0.21,-5.167,34,FSXP_People.bgl +85,Gen_Crowd_Restroom01,Gen_Crowd_Restroom01,Gen_Crowd_Restroom01,props,52.47362412,13.38734164,"N52° 28' 25.05""","E13° 23' 14.43""",94.50,1.0000,1,1,cras,1.658,2.239,6.636,2.239,3.42,-0.829,-0.179,-3.318,6,FSXP_People.bgl +86,Gen_Crowd_Restroom02,Gen_Crowd_Restroom02,Gen_Crowd_Restroom02,props,52.47364211,13.38734164,"N52° 28' 25.11""","E13° 23' 14.43""",271.80,1.0000,1,1,cras,1.658,2.239,8.341,2.239,4.252,-0.829,-0.179,-4.17,37,FSXP_People.bgl +87,Gen_Crowd_Security,Gen_Crowd_Security,Gen_Crowd_Security,props,52.47415502,13.38861108,"N52° 28' 26.96""","E13° 23' 19.00""",0.00,1.0000,1,1,cras,0.716,1.779,0.716,1.779,0.506,-0.358,-0.08,-0.271,1,FSXP_People.bgl +88,GEN_Plane_DC3,GEN_Plane_DC3,GEN_Plane_DC3,other,52.47762842,13.40204995,"N52° 28' 39.46""","E13° 24' 07.38""",279.90,1.0000,1,1,cras,28.108,4.713,19.475,4.713,17.098,-14.054,-0.033,-13.645,39,vehicles_aircraft.bgl +89,Gen_Plane_Extra300_Blue,,Gen_Plane_Extra300_Blue,other,52.47302122,13.38900000,"N52° 28' 22.88""","E13° 23' 20.40""",45.00,1.0000,0,0,,,,,,,,,,, +90,Gen_Plane_Extra300_Dragon,,Gen_Plane_Extra300_Dragon,other,52.47383108,13.38843883,"N52° 28' 25.79""","E13° 23' 18.38""",103.50,1.0000,0,0,,,,,,,,,,, +91,Gen_Plane_Extra300_Green,,Gen_Plane_Extra300_Green,other,52.47290424,13.38871384,"N52° 28' 22.46""","E13° 23' 19.37""",0.00,1.0000,0,0,,,,,,,,,,, +92,Gen_Plane_Extra300_Shark,,Gen_Plane_Extra300_Shark,other,52.47305721,13.38857772,"N52° 28' 23.01""","E13° 23' 18.88""",55.80,1.0000,0,0,,,,,,,,,,, +93,Gen_Plane_Extra300_Zebra,,Gen_Plane_Extra300_Zebra,other,52.47342615,13.38847496,"N52° 28' 24.33""","E13° 23' 18.51""",81.00,1.0000,0,0,,,,,,,,,,, +94,MIS_RedBull_Tower,MIS_RedBull_Tower,MIS_RedBull_Tower,building,52.47528882,13.38905272,"N52° 28' 31.04""","E13° 23' 20.59""",85.50,1.0000,1,1,cras,12.733,34.784,20.997,34.784,12.278,-6.323,-6.704,-9.579,36,fsxp_missionobjects.bgl +95,GEN_Plane_MD83,GEN_Plane_MD83,NEW_Copy_GEN_Plane_MD83,other,52.48077786,13.39318047,"N52° 28' 50.80""","E13° 23' 35.45""",27.90,1.0000,1,1,cras,32.984,8.405,45.491,8.405,28.095,-16.515,-0.151,-23.012,37,vehicles_aircraft.bgl +96,Gen_Plane_L-39_02,,NEW_Gen_Plane_L-39_02,other,52.47743945,13.38946939,"N52° 28' 38.78""","E13° 23' 22.09""",269.10,1.0000,0,0,,,,,,,,,,, +97,GEN_Plane_MD83,GEN_Plane_MD83,NEW_GEN_Plane_MD83,other,52.47874422,13.38944164,"N52° 28' 43.48""","E13° 23' 21.99""",282.60,1.0000,1,1,cras,32.984,8.405,45.491,8.405,28.095,-16.515,-0.151,-23.012,37,vehicles_aircraft.bgl +98,veh_air_bagcart2,veh_air_bagcart2,NEW_veh_air_bagcart2,vehicle,52.47830330,13.38775277,"N52° 28' 41.89""","E13° 23' 15.91""",7.20,1.0000,1,1,cras,2.389,2.288,13.431,2.288,6.821,-1.201,-0.037,-9.345,41,vehicles_airport.bgl +99,veh_air_bagloaderblue,veh_air_bagloaderblue,NEW_veh_air_bagloaderblue,vehicle,52.47818632,13.38774439,"N52° 28' 41.47""","E13° 23' 15.88""",88.20,1.0000,1,1,cras,1.937,1.937,6.014,1.937,3.159,-1.05,-0.005,-2.845,4,vehicles_airport.bgl +100,veh_air_cratesgrey,veh_air_cratesgrey,NEW_veh_air_cratesgrey,vehicle,52.47779039,13.38771941,"N52° 28' 40.05""","E13° 23' 15.79""",0.00,1.0000,1,1,cras,4.832,2.009,5.058,2.009,3.498,-2.732,-0.005,-2.554,4,vehicles_airport.bgl +101,VEH_Air_Forklift_Blue,VEH_Air_Forklift_Blue,NEW_VEH_Air_Forklift_Blue,vehicle,52.47774539,13.38772771,"N52° 28' 39.88""","E13° 23' 15.82""",90.00,1.0000,1,1,cras,2.03,2.383,5.603,2.383,2.979,-1.017,-0.035,-1.827,5,vehicles_airport.bgl +102,veh_air_pushbackblue,veh_air_pushbackblue,NEW_veh_air_pushbackblue,vehicle,52.47800635,13.38781941,"N52° 28' 40.82""","E13° 23' 16.15""",90.00,1.0000,1,1,cras,2.589,1.917,9.168,1.917,4.763,-1.284,-0.004,-8.799,21,vehicles_airport.bgl +103,RedBull_Hangars_01,MIS_RedBull_Hangars,RedBull_Hangars_01,building,52.47287725,13.38898055,"N52° 28' 22.36""","E13° 23' 20.33""",0.00,1.0000,1,1,cras,13.129,8.122,16.582,8.122,10.575,-6.581,-0.085,-9.272,57,fsxp_missionobjects.bgl +104,RedBull_Hangars_02,MIS_RedBull_Hangars,RedBull_Hangars_02,building,52.47287725,13.38871937,"N52° 28' 22.36""","E13° 23' 19.39""",0.00,1.0000,1,1,cras,13.129,8.122,16.582,8.122,10.575,-6.581,-0.085,-9.272,57,fsxp_missionobjects.bgl +105,RedBull_Hangars_03,MIS_RedBull_Hangars,RedBull_Hangars_03,building,52.47297623,13.38844160,"N52° 28' 22.71""","E13° 23' 18.39""",45.00,1.0000,1,1,cras,13.129,8.122,16.582,8.122,10.575,-6.581,-0.085,-9.272,57,fsxp_missionobjects.bgl +106,RedBull_Hangars_04,MIS_RedBull_Hangars,RedBull_Hangars_04,building,52.47322819,13.38830547,"N52° 28' 23.62""","E13° 23' 17.90""",84.60,1.0000,1,1,cras,13.129,8.122,16.582,8.122,10.575,-6.581,-0.085,-9.272,57,fsxp_missionobjects.bgl +107,RedBull_Hangars_05,MIS_RedBull_Hangars,RedBull_Hangars_05,building,52.47340815,13.38827773,"N52° 28' 24.27""","E13° 23' 17.80""",84.60,1.0000,1,1,cras,13.129,8.122,16.582,8.122,10.575,-6.581,-0.085,-9.272,57,fsxp_missionobjects.bgl +108,RedBull_Hangars_06,MIS_RedBull_Hangars,RedBull_Hangars_06,building,52.47361512,13.38824437,"N52° 28' 25.01""","E13° 23' 17.68""",84.60,1.0000,1,1,cras,13.129,8.122,16.582,8.122,10.575,-6.581,-0.085,-9.272,57,fsxp_missionobjects.bgl +109,RedBull_Hangars_07,MIS_RedBull_Hangars,RedBull_Hangars_07,building,52.47381308,13.38821663,"N52° 28' 25.73""","E13° 23' 17.58""",84.60,1.0000,1,1,cras,13.129,8.122,16.582,8.122,10.575,-6.581,-0.085,-9.272,57,fsxp_missionobjects.bgl +110,RedBull_Powerstation,MIS_RedBull_Powerstation,RedBull_Powerstation,building,52.47407404,13.38834160,"N52° 28' 26.67""","E13° 23' 18.03""",83.70,1.5000,1,1,cras,20.506,13.798,15.155,13.798,12.749,-10.253,-0.85,-8.207,57,fsxp_missionobjects.bgl +111,VEH_Air_TransBus_Euro_Silver,VEH_Air_TransBus_Euro_Silver,VEH_Air_TransBus_Euro_Silver,vehicle,52.47250831,13.38726385,"N52° 28' 21.03""","E13° 23' 14.15""",0.00,1.0000,1,1,cras,2.191,2.365,5.994,2.365,3.191,-1.098,-0.002,-2.997,9,vehicles_airport.bgl +112,VEH_Land_AmbulanceEur,VEH_Land_AmbulanceEur,VEH_Land_AmbulanceEur,vehicle,52.47215737,13.38737777,"N52° 28' 19.77""","E13° 23' 14.56""",160.85,1.0000,1,1,cras,2.481,2.677,4.827,2.677,2.713,-1.231,-0.049,-2.44,9,vehicles_land.bgl +113,VEH_Land_car_minivan1_white,VEH_Land_car_minivan1_white,VEH_Land_car_minivan1_white,vehicle,52.47234634,13.38726662,"N52° 28' 20.45""","E13° 23' 14.16""",0.00,1.0000,1,1,cras,2.317,2.005,4.339,2.005,2.459,-1.149,-0.031,-1.945,7,vehicles_land.bgl +114,VEH_Land_car_stationwagon3_white,VEH_Land_car_stationwagon3_white,VEH_Land_car_stationwagon3_white,vehicle,52.47296723,13.38917769,"N52° 28' 22.68""","E13° 23' 21.04""",0.00,1.0000,1,1,cras,2.338,1.472,4.792,1.472,2.666,-1.16,0.005,-2.181,9,vehicles_land.bgl +115,VEH_Land_car_truck3_white,VEH_Land_car_truck3_white,VEH_Land_car_truck3_white,vehicle,52.47313820,13.38723611,"N52° 28' 23.30""","E13° 23' 14.05""",9.00,1.0000,1,1,cras,4.253,4.44,13.93,4.44,7.282,-2.117,-0.11,-10.565,59,vehicles_land.bgl +116,VEH_land_DeliveryVan,VEH_land_DeliveryVan,VEH_land_DeliveryVan,vehicle,52.47220236,13.38736385,"N52° 28' 19.93""","E13° 23' 14.51""",269.10,1.0000,1,1,cras,3.38,2.857,6.431,2.857,3.632,-1.681,-0.014,-3.22,9,vehicles_land.bgl +117,VEH_land_DeliveryVan,VEH_land_DeliveryVan,VEH_land_DeliveryVan,vehicle,52.47242733,13.38726109,"N52° 28' 20.74""","E13° 23' 14.14""",0.00,1.0000,1,1,cras,3.38,2.857,6.431,2.857,3.632,-1.681,-0.014,-3.22,9,vehicles_land.bgl diff --git a/Missions/RB_SP_Tempelhof_Intermediate/RB_SP_Templehof_Intermediate_POI.csv b/Missions/RB_SP_Tempelhof_Intermediate/RB_SP_Templehof_Intermediate_POI.csv new file mode 100644 index 00000000..8bac4d08 --- /dev/null +++ b/Missions/RB_SP_Tempelhof_Intermediate/RB_SP_Templehof_Intermediate_POI.csv @@ -0,0 +1,31 @@ +poi_name,gate,role,latitude,longitude,lat_dms,lon_dms,gate_width_m +POI_Gate01,1,center,52.46962881,13.41252775,"N52° 28' 10.66""","E13° 24' 45.10""",40.68 +Pylon01-L,1,left,52.46946684,13.41266664,"N52° 28' 10.08""","E13° 24' 45.60""",40.68 +Pylon01-R,1,right,52.46979079,13.41238886,"N52° 28' 11.25""","E13° 24' 44.60""",40.68 +POI_Gate02,2,center,52.46733422,13.40568468,"N52° 28' 02.40""","E13° 24' 20.46""",40.34 +Pylon02-L,2,left,52.46715425,13.40571938,"N52° 28' 01.76""","E13° 24' 20.59""",40.34 +Pylon02-R,2,right,52.46751419,13.40564998,"N52° 28' 03.05""","E13° 24' 20.34""",40.34 +POI_Gate03,3,center,52.46812158,13.39884023,"N52° 28' 05.24""","E13° 23' 55.82""",40.52 +Pylon03-L,3,left,52.46795511,13.39871940,"N52° 28' 04.64""","E13° 23' 55.39""",40.52 +Pylon03-R,3,right,52.46828805,13.39896105,"N52° 28' 05.84""","E13° 23' 56.26""",40.52 +POI_Gate04,4,center,52.47136101,13.39291942,"N52° 28' 16.90""","E13° 23' 34.51""",41.20 +Pylon04-L,4,left,52.47127553,13.39264998,"N52° 28' 16.59""","E13° 23' 33.54""",41.20 +Pylon04-R,4,right,52.47144650,13.39318885,"N52° 28' 17.21""","E13° 23' 35.48""",41.20 +POI_Gate05,5,center,52.47383558,13.39605551,"N52° 28' 25.81""","E13° 23' 45.80""",41.18 +Pylon05-L,5,left,52.47402005,13.39603325,"N52° 28' 26.47""","E13° 23' 45.72""",41.18 +Pylon05-R,5,right,52.47365111,13.39607776,"N52° 28' 25.14""","E13° 23' 45.88""",41.18 +POI_Gate06,6,center,52.47035319,13.40250827,"N52° 28' 13.27""","E13° 24' 09.03""",41.15 +Pylon06-L,6,left,52.47053766,13.40248883,"N52° 28' 13.94""","E13° 24' 08.96""",41.15 +Pylon06-R,6,right,52.47016872,13.40252772,"N52° 28' 12.61""","E13° 24' 09.10""",41.15 +POI_Gate07,7,center,52.47308421,13.40642635,"N52° 28' 23.10""","E13° 24' 23.13""",40.87 +Pylon07-L,7,left,52.47308421,13.40612498,"N52° 28' 23.10""","E13° 24' 22.05""",40.87 +Pylon07-R,7,right,52.47308421,13.40672772,"N52° 28' 23.10""","E13° 24' 24.22""",40.87 +POI_Gate08,8,center,52.47596821,13.40127358,"N52° 28' 33.49""","E13° 24' 04.58""",40.80 +Pylon08-L,8,left,52.47581973,13.40109718,"N52° 28' 32.95""","E13° 24' 03.95""",40.80 +Pylon08-R,8,right,52.47611668,13.40144997,"N52° 28' 34.02""","E13° 24' 05.22""",40.80 +POI_Gate09,9,center,52.47791187,13.40650414,"N52° 28' 40.48""","E13° 24' 23.41""",41.14 +Pylon09-L,9,left,52.47809633,13.40648607,"N52° 28' 41.15""","E13° 24' 23.35""",41.14 +Pylon09-R,9,right,52.47772740,13.40652220,"N52° 28' 39.82""","E13° 24' 23.48""",41.14 +POI_Gate10,10,center,52.47486590,13.41395276,"N52° 28' 29.52""","E13° 24' 50.23""",40.75 +Pylon10-L,10,left,52.47489289,13.41424998,"N52° 28' 29.61""","E13° 24' 51.30""",40.75 +Pylon10-R,10,right,52.47483890,13.41365553,"N52° 28' 29.42""","E13° 24' 49.16""",40.75 diff --git a/Missions/RB_SP_Tempelhof_Intermediate/config_RB_SP_Tempelhof_Intermediate.csv b/Missions/RB_SP_Tempelhof_Intermediate/config_RB_SP_Tempelhof_Intermediate.csv new file mode 100644 index 00000000..72b0aafe --- /dev/null +++ b/Missions/RB_SP_Tempelhof_Intermediate/config_RB_SP_Tempelhof_Intermediate.csv @@ -0,0 +1,3 @@ +display_name,min_sec,max_sec,flt_rel +滕伯尔霍夫之旅(陆地),150,360,Racing/RB_SP_Tempelhof_Intermediate/RB_SP_Templehof_Intermediate.FLT +,,,Racing/RB_SP_Tempelhof_Intermediate/RB_SP_Tempelhof_Intermediate.FLT diff --git a/Missions/RedBullSalzburg2/config_RedBullSalzburg2.csv b/Missions/RedBullSalzburg2/config_RedBullSalzburg2.csv new file mode 100644 index 00000000..07fc239c --- /dev/null +++ b/Missions/RedBullSalzburg2/config_RedBullSalzburg2.csv @@ -0,0 +1,2 @@ +display_name,min_sec,max_sec,flt_rel +Y-特技竞速,100,240,Challenges/RedBullSalzburg2/RedBullSalzburg2.FLT diff --git a/Pipfile b/Pipfile deleted file mode 100644 index d04858d7..00000000 --- a/Pipfile +++ /dev/null @@ -1,18 +0,0 @@ -[[source]] -name = "pypi" -url = "https://pypi.org/simple" -verify_ssl = true - -[dev-packages] -pytest-cov = "*" -pylint = "*" -black = "*" - -[packages] -flask = "*" - -[requires] -python_version = "3.8" - -[pipenv] -allow_prereleases = true diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index 967c24e6..00000000 --- a/Pipfile.lock +++ /dev/null @@ -1,404 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "75da094b7fd42cf27c1385797b0df42d11341a540f6defb40d93eba46794181a" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.8" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "click": { - "hashes": [ - "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", - "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==7.1.2" - }, - "flask": { - "hashes": [ - "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060", - "sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557" - ], - "index": "pypi", - "version": "==1.1.2" - }, - "itsdangerous": { - "hashes": [ - "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", - "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.1.0" - }, - "jinja2": { - "hashes": [ - "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", - "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==2.11.2" - }, - "markupsafe": { - "hashes": [ - "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", - "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", - "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", - "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", - "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", - "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", - "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", - "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", - "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", - "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", - "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", - "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", - "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", - "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", - "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", - "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", - "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", - "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", - "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", - "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", - "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", - "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", - "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", - "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", - "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", - "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", - "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", - "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", - "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", - "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", - "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", - "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", - "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.1.1" - }, - "werkzeug": { - "hashes": [ - "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43", - "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==1.0.1" - } - }, - "develop": { - "appdirs": { - "hashes": [ - "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", - "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" - ], - "version": "==1.4.4" - }, - "astroid": { - "hashes": [ - "sha256:2f4078c2a41bf377eea06d71c9d2ba4eb8f6b1af2135bec27bbbb7d8f12bb703", - "sha256:bc58d83eb610252fd8de6363e39d4f1d0619c894b0ed24603b881c02e64c7386" - ], - "markers": "python_version >= '3.5'", - "version": "==2.4.2" - }, - "atomicwrites": { - "hashes": [ - "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197", - "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a" - ], - "markers": "sys_platform == 'win32'", - "version": "==1.4.0" - }, - "attrs": { - "hashes": [ - "sha256:0ef97238856430dcf9228e07f316aefc17e8939fc8507e18c6501b761ef1a42a", - "sha256:2867b7b9f8326499ab5b0e2d12801fa5c98842d2cbd22b35112ae04bf85b4dff" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.1.0" - }, - "black": { - "hashes": [ - "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea", - "sha256:70b62ef1527c950db59062cda342ea224d772abdf6adc58b86a45421bab20a6b" - ], - "index": "pypi", - "version": "==20.8b1" - }, - "click": { - "hashes": [ - "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", - "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==7.1.2" - }, - "colorama": { - "hashes": [ - "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff", - "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1" - ], - "markers": "sys_platform == 'win32' and sys_platform == 'win32'", - "version": "==0.4.3" - }, - "coverage": { - "hashes": [ - "sha256:098a703d913be6fbd146a8c50cc76513d726b022d170e5e98dc56d958fd592fb", - "sha256:16042dc7f8e632e0dcd5206a5095ebd18cb1d005f4c89694f7f8aafd96dd43a3", - "sha256:1adb6be0dcef0cf9434619d3b892772fdb48e793300f9d762e480e043bd8e716", - "sha256:27ca5a2bc04d68f0776f2cdcb8bbd508bbe430a7bf9c02315cd05fb1d86d0034", - "sha256:28f42dc5172ebdc32622a2c3f7ead1b836cdbf253569ae5673f499e35db0bac3", - "sha256:2fcc8b58953d74d199a1a4d633df8146f0ac36c4e720b4a1997e9b6327af43a8", - "sha256:304fbe451698373dc6653772c72c5d5e883a4aadaf20343592a7abb2e643dae0", - "sha256:30bc103587e0d3df9e52cd9da1dd915265a22fad0b72afe54daf840c984b564f", - "sha256:40f70f81be4d34f8d491e55936904db5c527b0711b2a46513641a5729783c2e4", - "sha256:4186fc95c9febeab5681bc3248553d5ec8c2999b8424d4fc3a39c9cba5796962", - "sha256:46794c815e56f1431c66d81943fa90721bb858375fb36e5903697d5eef88627d", - "sha256:4869ab1c1ed33953bb2433ce7b894a28d724b7aa76c19b11e2878034a4e4680b", - "sha256:4f6428b55d2916a69f8d6453e48a505c07b2245653b0aa9f0dee38785939f5e4", - "sha256:52f185ffd3291196dc1aae506b42e178a592b0b60a8610b108e6ad892cfc1bb3", - "sha256:538f2fd5eb64366f37c97fdb3077d665fa946d2b6d95447622292f38407f9258", - "sha256:64c4f340338c68c463f1b56e3f2f0423f7b17ba6c3febae80b81f0e093077f59", - "sha256:675192fca634f0df69af3493a48224f211f8db4e84452b08d5fcebb9167adb01", - "sha256:700997b77cfab016533b3e7dbc03b71d33ee4df1d79f2463a318ca0263fc29dd", - "sha256:8505e614c983834239f865da2dd336dcf9d72776b951d5dfa5ac36b987726e1b", - "sha256:962c44070c281d86398aeb8f64e1bf37816a4dfc6f4c0f114756b14fc575621d", - "sha256:9e536783a5acee79a9b308be97d3952b662748c4037b6a24cbb339dc7ed8eb89", - "sha256:9ea749fd447ce7fb1ac71f7616371f04054d969d412d37611716721931e36efd", - "sha256:a34cb28e0747ea15e82d13e14de606747e9e484fb28d63c999483f5d5188e89b", - "sha256:a3ee9c793ffefe2944d3a2bd928a0e436cd0ac2d9e3723152d6fd5398838ce7d", - "sha256:aab75d99f3f2874733946a7648ce87a50019eb90baef931698f96b76b6769a46", - "sha256:b1ed2bdb27b4c9fc87058a1cb751c4df8752002143ed393899edb82b131e0546", - "sha256:b360d8fd88d2bad01cb953d81fd2edd4be539df7bfec41e8753fe9f4456a5082", - "sha256:b8f58c7db64d8f27078cbf2a4391af6aa4e4767cc08b37555c4ae064b8558d9b", - "sha256:c1bbb628ed5192124889b51204de27c575b3ffc05a5a91307e7640eff1d48da4", - "sha256:c2ff24df02a125b7b346c4c9078c8936da06964cc2d276292c357d64378158f8", - "sha256:c890728a93fffd0407d7d37c1e6083ff3f9f211c83b4316fae3778417eab9811", - "sha256:c96472b8ca5dc135fb0aa62f79b033f02aa434fb03a8b190600a5ae4102df1fd", - "sha256:ce7866f29d3025b5b34c2e944e66ebef0d92e4a4f2463f7266daa03a1332a651", - "sha256:e26c993bd4b220429d4ec8c1468eca445a4064a61c74ca08da7429af9bc53bb0" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", - "version": "==5.2.1" - }, - "iniconfig": { - "hashes": [ - "sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437", - "sha256:e5f92f89355a67de0595932a6c6c02ab4afddc6fcdc0bfc5becd0d60884d3f69" - ], - "version": "==1.0.1" - }, - "isort": { - "hashes": [ - "sha256:92533892058de0306e51c88f22ece002a209dc8e80288aa3cec6d443060d584f", - "sha256:a200d47b7ee8b7f7d0a9646650160c4a51b6a91a9413fd31b1da2c4de789f5d3" - ], - "markers": "python_version >= '3.6' and python_version < '4.0'", - "version": "==5.5.1" - }, - "lazy-object-proxy": { - "hashes": [ - "sha256:0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d", - "sha256:194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449", - "sha256:1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08", - "sha256:4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a", - "sha256:48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50", - "sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd", - "sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239", - "sha256:8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb", - "sha256:9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea", - "sha256:9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e", - "sha256:97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156", - "sha256:9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142", - "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442", - "sha256:a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62", - "sha256:ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db", - "sha256:cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531", - "sha256:d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383", - "sha256:d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a", - "sha256:eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357", - "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4", - "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.4.3" - }, - "mccabe": { - "hashes": [ - "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", - "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" - ], - "version": "==0.6.1" - }, - "more-itertools": { - "hashes": [ - "sha256:6f83822ae94818eae2612063a5101a7311e68ae8002005b5e05f03fd74a86a20", - "sha256:9b30f12df9393f0d28af9210ff8efe48d10c94f73e5daf886f10c4b0b0b4f03c" - ], - "markers": "python_version >= '3.5'", - "version": "==8.5.0" - }, - "mypy-extensions": { - "hashes": [ - "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", - "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8" - ], - "version": "==0.4.3" - }, - "packaging": { - "hashes": [ - "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", - "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.4" - }, - "pathspec": { - "hashes": [ - "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0", - "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061" - ], - "version": "==0.8.0" - }, - "pluggy": { - "hashes": [ - "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", - "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.13.1" - }, - "py": { - "hashes": [ - "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", - "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.9.0" - }, - "pylint": { - "hashes": [ - "sha256:bb4a908c9dadbc3aac18860550e870f58e1a02c9f2c204fdf5693d73be061210", - "sha256:bfe68f020f8a0fece830a22dd4d5dddb4ecc6137db04face4c3420a46a52239f" - ], - "index": "pypi", - "version": "==2.6.0" - }, - "pyparsing": { - "hashes": [ - "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", - "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" - ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.4.7" - }, - "pytest": { - "hashes": [ - "sha256:85228d75db9f45e06e57ef9bf4429267f81ac7c0d742cc9ed63d09886a9fe6f4", - "sha256:8b6007800c53fdacd5a5c192203f4e531eb2a1540ad9c752e052ec0f7143dbad" - ], - "markers": "python_version >= '3.5'", - "version": "==6.0.1" - }, - "pytest-cov": { - "hashes": [ - "sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191", - "sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e" - ], - "index": "pypi", - "version": "==2.10.1" - }, - "regex": { - "hashes": [ - "sha256:0dc64ee3f33cd7899f79a8d788abfbec168410be356ed9bd30bbd3f0a23a7204", - "sha256:1269fef3167bb52631ad4fa7dd27bf635d5a0790b8e6222065d42e91bede4162", - "sha256:14a53646369157baa0499513f96091eb70382eb50b2c82393d17d7ec81b7b85f", - "sha256:3a3af27a8d23143c49a3420efe5b3f8cf1a48c6fc8bc6856b03f638abc1833bb", - "sha256:46bac5ca10fb748d6c55843a931855e2727a7a22584f302dd9bb1506e69f83f6", - "sha256:4c037fd14c5f4e308b8370b447b469ca10e69427966527edcab07f52d88388f7", - "sha256:51178c738d559a2d1071ce0b0f56e57eb315bcf8f7d4cf127674b533e3101f88", - "sha256:5ea81ea3dbd6767873c611687141ec7b06ed8bab43f68fad5b7be184a920dc99", - "sha256:6961548bba529cac7c07af2fd4d527c5b91bb8fe18995fed6044ac22b3d14644", - "sha256:75aaa27aa521a182824d89e5ab0a1d16ca207318a6b65042b046053cfc8ed07a", - "sha256:7a2dd66d2d4df34fa82c9dc85657c5e019b87932019947faece7983f2089a840", - "sha256:8a51f2c6d1f884e98846a0a9021ff6861bdb98457879f412fdc2b42d14494067", - "sha256:9c568495e35599625f7b999774e29e8d6b01a6fb684d77dee1f56d41b11b40cd", - "sha256:9eddaafb3c48e0900690c1727fba226c4804b8e6127ea409689c3bb492d06de4", - "sha256:bbb332d45b32df41200380fff14712cb6093b61bd142272a10b16778c418e98e", - "sha256:bc3d98f621898b4a9bc7fecc00513eec8f40b5b83913d74ccb445f037d58cd89", - "sha256:c11d6033115dc4887c456565303f540c44197f4fc1a2bfb192224a301534888e", - "sha256:c50a724d136ec10d920661f1442e4a8b010a4fe5aebd65e0c2241ea41dbe93dc", - "sha256:d0a5095d52b90ff38592bbdc2644f17c6d495762edf47d876049cfd2968fbccf", - "sha256:d6cff2276e502b86a25fd10c2a96973fdb45c7a977dca2138d661417f3728341", - "sha256:e46d13f38cfcbb79bfdb2964b0fe12561fe633caf964a77a5f8d4e45fe5d2ef7" - ], - "version": "==2020.7.14" - }, - "six": { - "hashes": [ - "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", - "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.15.0" - }, - "toml": { - "hashes": [ - "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f", - "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88" - ], - "version": "==0.10.1" - }, - "typed-ast": { - "hashes": [ - "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", - "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", - "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", - "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", - "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", - "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", - "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", - "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", - "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", - "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", - "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", - "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", - "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", - "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", - "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", - "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", - "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", - "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", - "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", - "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", - "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" - ], - "version": "==1.4.1" - }, - "typing-extensions": { - "hashes": [ - "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918", - "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c", - "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f" - ], - "version": "==3.7.4.3" - }, - "wrapt": { - "hashes": [ - "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7" - ], - "version": "==1.12.1" - } - } -} diff --git a/README.md b/README.md index 6b566fa6..7b0fb132 100644 --- a/README.md +++ b/README.md @@ -1,194 +1,354 @@ -[![PyPI version](https://badge.fury.io/py/SimConnect.svg)](https://badge.fury.io/py/SimConnect) -# Python-SimConnect +# Flight Assistant(TALENT ACADEMY) + +模拟飞行 **数据收集、实时监测与航迹分析** 桌面应用。 +面向 FSX / Prepar3D 等支持 **SimConnect** 的仿真环境,在训练辅助页呈现性能图、航迹图与摇杆图,并将航迹与用户设置持久化到 Auth 云端字段。 + +> 产品基调:深蓝航空 HUD。UI / 主题令牌真相源见 `setting.py` 与 `DESIGN.md`;Agent 编码约束见根目录 `AGENTS.md`。 + +--- + +## 目录 + +1. [能做什么](#1-能做什么) +2. [技术栈与运行环境](#2-技术栈与运行环境) +3. [快速开始](#3-快速开始) +4. [架构总览](#4-架构总览) +5. [目录结构](#5-目录结构) +6. [数据业务与状态机](#6-数据业务与状态机) +7. [SimConnect 与仿真变量](#7-simconnect-与仿真变量) +8. [图表子系统](#8-图表子系统) +9. [任务功率与气动分析口径](#9-任务功率与气动分析口径) +10. [持久化规则](#10-持久化规则) +11. [UI 分层与设计系统](#11-ui-分层与设计系统) +12. [文档地图](#12-文档地图) + +--- + +## 1. 能做什么 + +| 能力 | 说明 | +|------|------| +| 仿真自检 | 检测模拟器 / SimConnect 连通与关键读数 | +| 账号认证 | 登录后启用航迹与设置的云端读写 | +| 训练监测 | 开始 / 暂停 / 停止监测;任务切换;圈次与会话信息 | +| 实时读数 | CHT、扭矩、健康度、姿态、空速 / 地速、位置等 | +| 三图联动 | **性能图**(趋势)、**航迹图**(经纬度)、**摇杆图**(本地外设) | +| 航迹对比 | 从 Auth 加载历史航迹,与当前飞行叠加对比 | +| 任务完成 | 完赛结果对话框;保存流程与置顶窗口协调 | +| 硬件设定 | 摇杆轴映射等用户设置(`user_defined_4`) | +| 模拟器根目录 | 自检「模拟器」项内解析(缺路径弹窗;已启动才通过);同步 UD4 `fsx_root` | + +典型主流程:`app_run.py` → 主壳 `Frame_Shell_AppMain` + `AppController` 编排 **自检 → 登录 → 训练辅助**。 + +--- + +## 2. 技术栈与运行环境 + +| 层 | 技术 | +|----|------| +| UI | Qt / **PySide2**,主题 QSS(`UITheme`) | +| 图表 | **pyqtgraph**(须 `PYQTGRAPH_QT_LIB=PySide2`) | +| 仿真 | **ctypes** 调用官方 `SimConnect.dll`(vendored 包 `SimConnect/`) | +| 外设 | **pygame**(摇杆枚举与读轴) | +| 数据 | **Pandas / Numpy**;**Pymysql + DBUtils** 访问 MySQL Auth | +| 配置 | `.env`(部署级)、`setting.py`(业务与 UI 常量) | + +**平台约束(务必对齐):** + +- **仅 Windows**(SimConnect)。 +- 本仓库 `requirements.txt` 约定:`SimConnect.dll` 为 **32 位**,需 **32 位 Python**(如 win32 cp310)与同架构的 PySide2;勿混用 64 位解释器(易触发 `WinError 193`)。 +- 依赖安装:`pip install -r requirements.txt`(文件内已含镜像与版本钉)。 +- 可选远程连接:本机 `SimConnect.cfg` + 模拟器端 `SimConnect.xml` 允许网络 `Address`。 + +--- + +## 3. 快速开始 + +```bat +cd /d F:\Flight_Assistant +REM 确认当前为项目默认 32 位开发环境后再安装 +set PYQTGRAPH_QT_LIB=PySide2 +pip install -r requirements.txt +python app_run.py +``` -Python interface for Microsoft Flight Simulator 2020 (MSFS2020) using SimConnect +### 打包(32 位 `sim_connect` 环境) -This library allows Python scripts to read and set variables within MSFS2020 and trigger events within the simulation. +```bat +cd /d F:\Flight_Assistant +build_exe.bat +``` -It also includes, as an example, "Cockpit Companion", a flask mini http server which runs locally. It provides a web UI with a moving map and simulation variables. It also provides simulation data in JSON format in response to REST API requests. +产物:`dist\Flight_Assistant.exe`(onefile、无控制台;含 `.env` / `resources/` / `Missions/` / `SimConnect.dll`)。 + +启动前请: + +1. 模拟器已运行且 SimConnect 可用; +2. `.env` 中数据库等部署参数已配置; +3. 需要云端航迹 / 设置时先完成登录。 + +**不要**编辑或依赖 `dist/`(构建产物)。 + +--- + +## 4. 架构总览 + +```text +┌─────────────────────────────────────────────────────────────┐ +│ pages/ + components/(Frame / Container / widget / overlay) │ +│ charts/(TrendChart facade + pyqtgraph) │ +└──────────────────────────▲──────────────────────────────────┘ + │ 订阅(节流) +┌──────────────────────────┴──────────────────────────────────┐ +│ models/state_bus(唯一总线:session / runtime / live / …) │ +└──────────▲───────────────────────────▲──────────────────────┘ + │ commit │ 分析冷路径 +┌──────────┴──────────┐ ┌───────────┴──────────┐ +│ 采集:Sim 读数+摇杆 │ │ AnalyzeWorker │ +│ live / stick / chart │ │ 航迹对比 → analyze │ +└──────────▲──────────┘ └───────────▲──────────┘ + │ │ +┌──────────┴──────────┐ ┌───────────┴──────────┐ +│ services/sim* │ │ track_store / │ +│ SimConnect 绑定 │ │ user_settings_store │ +└─────────────────────┘ │ + db_operate → Auth │ + └──────────────────────┘ +``` -Full documentation for this example can be found at [https://msfs2020.cc](https://msfs2020.cc) and it is included in a standalone repo here on Github as [MSFS2020-cockpit-companion](https://github.com/hankhank10/MSFS2020-cockpit-companion). +- **入口**:`app_run.py` / `app_bootstrap.py`(DPI:`PassThrough` + HighDpi,须在 `QApplication` 之前)。 +- **编排**:`app_controller.py`(命令、监测生命周期、与页面解耦)。 +- **权威状态**:仅 `models/state_bus`;**UI 只消费状态,不定义状态**。 + +更细的库级 SimConnect 流程见 `Docs/项目原理.md`。 + +--- + +## 5. 目录结构 + +```text +app_run.py / app_bootstrap.py / app_controller.py +setting.py # 业务与 UI 常量(真相源) +pages/ # page_{业务名}.py — 一页一内容根 +components/ + Frame/ # 主壳 / 并发对话框 + Container/ # 版式分割 + widget/ # 可复用控件 + overlay/ # 全局 / 本地蒙版 +charts/ # 图表子系统(非 UI 四层) +models/ + state_bus/ # 分组 Store / 订阅 / 采集写 / 分析 + track_store.py # 航迹 → Auth user_defined_3 + user_settings_store.py # 设置 → Auth user_defined_4 + data_bridge.py / chart.py / track_fields.py … +services/ # 仿真、批量读、业务服务 +SimConnect/ # vendored ctypes 绑定 + RequestList / EventList +Missions/ # 任务资源(只读部署) +Docs/ # 原理、状态机、SimVar 清单、气动分析 +AGENTS.md / DESIGN.md # Agent 规则 / 设计令牌文档 +``` +命名约定摘要: -## Mobiflight Simconnect events: +- 主进程入口:`app_` 前缀。 +- 页面:`pages/page_{业务名}.py`。 +- UI 组件:`{层名}_{功能名}_{业务名}`;评审写法 `{中文名} - {英文名}`。 -Yes this supports the new [SimConnect commands that DocMoebiuz](https://forums.flightsimulator.com/t/full-g1000-control-now-with-mobiflight/348509) of [MobiFlight](https://www.mobiflight.com/en/index.html) developed. -A full list of [commands and install instructions](https://pastebin.com/fMdB7at2) +--- -At this time MobiFlight SimConnect commands are not include in the AircraftEvents class and as so the AircraftEvents.find() and AircraftEvents.get() will not work. You will need to pass the Event ID to a new Event class as the Example below shows. +## 6. 数据业务与状态机 +全局时间状态由三大板块定义(唯一总线维护布尔态): -```py -from SimConnect import * -# Create SimConnect link -sm = SimConnect() -# Creat a function to call the MobiFlight AS1000_MFD_SOFTKEYS_3 event. -Sk3 = Event(b'MobiFlight.AS1000_MFD_SOFTKEYS_3', sm) -# Call the Event. -Sk3() -sm.exit() -quit() -``` +| 板块 | 时间态 | 要点 | +|------|--------|------| +| **1 数据获取** | start / paused / stop | 最高优先级;Sim 读数 + 摇杆采集 | +| **2 数据保存** | start / stop | 获取一旦有数据必须同步保存至冲刷完毕;暂停时可异步收尾 | +| **3 数据消费** | start / paused / stop | 非紧急读已保存数据;紧急仅从唯一副本截取,禁止另开副本 | -## Python interface example - -```py -from SimConnect import * - -# Create SimConnect link -sm = SimConnect() -# Note the default _time is 2000 to be refreshed every 2 seconds -aq = AircraftRequests(sm, _time=2000) -# Use _time=ms where ms is the time in milliseconds to cache the data. -# Setting ms to 0 will disable data caching and always pull new data from the sim. -# There is still a timeout of 4 tries with a 10ms delay between checks. -# If no data is received in 40ms the value will be set to None -# Each request can be fine tuned by setting the time param. - -# To find and set timeout of cached data to 200ms: -altitude = aq.find("PLANE_ALTITUDE") -altitude.time = 200 - -# Get the aircraft's current altitude -altitude = aq.get("PLANE_ALTITUDE") -altitude = altitude + 1000 - -# Set the aircraft's current altitude -aq.set("PLANE_ALTITUDE", altitude) - -ae = AircraftEvents(sm) -# Trigger a simple event -event_to_trigger = ae.find("AP_MASTER") # Toggles autopilot on or off -event_to_trigger() - -# Trigger an event while passing a variable -target_altitude = 15000 -event_to_trigger = ae.find("AP_ALT_VAR_SET_ENGLISH") # Sets AP autopilot hold level -event_to_trigger(target_altitude) -sm.exit() -quit() -``` +协同:多板块同时工作时 **开始对齐**,停止可异步;缓存 **禁止跨板块共享**。 + +状态组(节流周期见 `setting.STATE_GROUP_*_MS`): + +| 组 | 典型内容 | 消费者 | +|----|----------|--------| +| `session` | 任务 / 模式 / 联机 | 圈板 | +| `runtime` | 监测阶段 / 暂停 / 闩锁 | 监测钮、状态栏 | +| `live` | CHT / 扭矩 / 健康 | 指标条 | +| `stick` | 摇杆轴 | 摇杆图 | +| `track` / `chart` | 轨迹 / 趋势 seq | 航迹图、性能图 | +| `analyze` | 对比载荷 | 对比面板 | + +实现细节:`Docs/status.md`、`models/state_bus/lifecycle.py`。 + +--- + +## 7. SimConnect 与仿真变量 + +### 7.1 连接与读写(库层) + +1. `ctypes` 加载 `SimConnect.dll`,声明 SDK `argtypes` / `restype`。 +2. `SimConnect_Open` 建连,后台线程 `CallDispatch` 分发。 +3. 变量:`AircraftRequests` / `services/sim_batch` 批量读;写入走 `set_data`(仅标记可写的变量)。 +4. 交互:只读开关类量需发 **SimConnect 事件**(`EventList.py`,如 `PAUSE_ON`),不能当普通变量写。 -## HTTP interface example +本应用监视路径以 `services/simulator.py` + 批量读为主;事件发送见 `app_controller` / `services/simulator.py`。 -Run `glass_server.py` using Python 3. +### 7.2 官方变量表(PDF → 项目清单) -#### `http://localhost:5000` -Method: GET +官方说明来源:[`Docs/Simulation Variables.pdf`](Docs/Simulation%20Variables.pdf)(约 75 页,FSX / SimConnect **Aircraft** 与 **Global** 仿真变量)。 -Variables: None +项目内中文对齐表(与 `SimConnect/RequestList.py` 对照): -Output: Web interface with moving map and aircraft information +- 详表:[`Docs/README.md`](Docs/README.md) 附件《模拟器 Simulation Variables 参数清单》 +- **变量总数约 830**(可写 ≈ 191,只读 ≈ 639) +- 分类示例:发动机、燃油、灯光、位置与速度、仪表、航电、操纵、自动驾驶、起落架、环境、直升机、吊索、杂项系统、字符串、AI、航母、竞速、全局环境等 -#### `http://localhost:5000/dataset/` -Method: GET +| 读写标记 | 含义 | 本项目用法 | +|----------|------|------------| +| 只读 (N) | 仅 get / request | `AircraftRequests.get` / `sim_batch` | +| 可写 (Y) | 可 set_data | `AircraftRequests.set` / `simulator` | -Arguments to pass: +注意: -|Argument|Location|Description| -|---|---|---| -|dataset_name|in path|can be navigation, airspeed compass, vertical_speed, fuel, flaps, throttle, gear, trim, autopilot, cabin| +- 带 `:index` 的变量需指定系统索引(发动机常从 0,航电通讯常从 1)。 +- PDF 另有 Program Data(如 `SIMULATION RATE`)与单位表;清单主体为 `A:` 类变量。 +- 姿态角多为 **弧度**,入库前转度(项目 `rad_to_deg`)。 -Description: Returns set of variables from simulator in JSON format +扩变量时:先查 PDF / `Docs/README.md` 附件 → 写入 `RequestList.py` → 在 `data_bridge` / `track_fields` 对齐列名。 +--- -#### `http://localhost:5000/datapoint//get` -Method: GET +## 8. 图表子系统 -Arguments to pass: +标准名称(全文统一): -|Argument|Location|Description| -|---|---|---| -|datapoint_name|in path|any variable name from MS SimConnect documentation| +| 名称 | 含义 | +|------|------| +| **性能图** | 左右 Y 轴共用一个 X 轴(时间趋势) | +| **航迹图** | 经纬度平面航迹 | +| **摇杆图** | 本地操纵杆信号与参数 | -Description: Returns individual variable from simulator in JSON format +分层: +```text +TrendChart(facade) + ├── ChartToolbar + ├── ChartDisplayArea + │ ├── ChartSlot(LineChart + masks) ← 性能图 + │ └── TrackMapPane + StickInputPane ← 航迹 / 摇杆 + └── TimelineScrubber +``` -#### `http://localhost:5000/datapoint//set` -Method: POST +数据流摘要: -Arguments to pass: +`read_aq_snapshot` → Store(`live`) + `ChartHistoryBuffer` + 轨迹录制 → 订阅 `chart`/`track` → `update`。 -|Argument|Location|Description| -|---|---|---| -|datapoint_name|in path|any variable name from MS SimConnect documentation| -|index (optional)|form or json|the relevant index if required (eg engine number) - if not passed defaults to None| -|value_to_use (optional)|value to set variable to - if not passed defaults to 0| +| 要改的内容 | 优先位置 | +|------------|----------| +| Y 轴范围 / 默认视窗 | `models/chart.py`、`TRAINING_CHART` | +| 健康阈值 / 色 / 透明度 | `setting.py` → `charts.theme` / `UITheme` | +| 采样列 / 字段 | `models/data_bridge.py`、`models/track_fields.py` | +| 折线交互 / 平移缩放 | `charts/slot.py`、`charts/base/line_chart.py` | -Description: Sets datapoint in the simulator +当前交互约定(实现侧): -#### `http://localhost:5000/event//trigger` -Method: POST +- 性能图时间窗:**最小缩放约 10 s**,最大为任务 `max_sec`;放大后可横向平移。 +- 航迹视窗:非监测默认 **自由**;监测中可 **锁定跟随**;停止后退出跟随。 +- 当前数据透明度约 90%,历史约 70%;当前黄 / 历史蓝语义色见设计令牌。 -Arguments to pass: +图表模块长文见 `Docs/README.md` 前半部分。 -|Argument|Location|Description| -|---|---|---| -|event_name|in path|any event name from MS SimConnect documentation| -|value_to_use (optional)|value to pass to the event| +--- -Description: Triggers an event in the simulator +## 9. 任务功率与气动分析口径 -## Running SimConnect on a separate system. +适用机型口径:**Extra 300 / Extra 300S**(活塞螺旋桨特技)。 +详细公式、扩录列与对比协议见 [`Docs/空气动力学原理及分析.md`](Docs/空气动力学原理及分析.md)。 -#### Note: At this time SimConnect can only run on Windows hosts. +**目标:** 任务功率最大化 ≈ 抬高有效输出 + 压低航线能量损失,用 **完成时间 \(T\)**(或 \(S/T\))验收。 -Create a file called SimConnect.cfg in the same folder as your script. -#### Sample SimConnect.cfg: -```ini -; Example SimConnect client configurations -[SimConnect] -Protocol=IPv4 -Address= -Port=500 +```text +油门 / RPM / 桨距 / 歧管压 / 扭矩 ──→ 推力 ──→ P_out ≈ T · V_TAS +α、G、重量、侧滑 ──────────────────→ 阻力 ──→ P_loss ≈ D · V_TAS +密度 / 高度 / 气温;风 ──→ IAS↔TAS、GS 投影(必须分列,禁止混比) +结果:IAS / TAS / GS / VS / G → 圈时 T ``` -To enable the host running the sim to share over network, - -add \0.0.0.0\ - -under the \500\ in SimConnect.xml - -SimConnect.xml can be located at -#### `%AppData%\Microsoft Flight Simulator\SimConnect.xml` - -#### Sample SimConnect.xml: -```xml - - - - SimConnect Server Configuration - SimConnect.xml - - Static IP4 port - IPv4 - local - 500 -
0.0.0.0
- 64 - 41088 -
-... + +| 目标 | 飞行含义 | +|------|----------| +| 抬高 \(\bar{P}_{\text{out}}\) | 动力在高效区 | +| 压低 \(\bar{P}_{\text{loss}}\) | 少无用 α、多余 G、侧滑 | +| 验收 \(P_{\text{mission}}\) | 圈时最短 / \(S/T\) 最大 | + +派生量示例(分析侧):轴功率 \(\tau\omega\)、推进功率 \(T\cdot V_{\text{TAS}}\)、\(\eta_p\)、机械能、\(E_{\text{loss}}\)、诱导阻相对强度 `di_proxy` ∝ \(n^2 W^2/(\rho V^2)\)。 + +**列分级:** A 已采样 / B 建议扩录(如 TAS、RPM、推力、α、G、密度等)/ C 事后派生。 +对比时:同任务、同赛道、同机模;主指标唯一为圈时;空速族与地速族分轴。 + +与代码衔接:`services/simulator.py`、`models/data_bridge.py`、`models/track_fields.py`、`models/track_store.py`;B 级 SimVar 名以 `Docs/README.md` 附件为准。 + +--- + +## 10. 持久化规则 + +| 级别 | 落点 | 说明 | +|------|------|------| +| 部署 / 应用 | `.env`、`setting.py`、只读资源(如 `Missions/`、字体) | 成品运行期禁止当用户库写盘 | +| 用户航迹 | Auth **`user_defined_3`** | `track_store` + `db_operate` | +| 用户设置 | Auth **`user_defined_4`** | 摇杆映射、模拟器根目录 `fsx_root` 等可扩展字典 | +| 禁止 | `user_defined_1/2/5`;本地 `track_data/*.csv`、`config/*.json` 作成品出口 | 未登录仅内存并提示登录 | + +正式对比:`AnalyzeWorker` → `load_track_compare_record` → Store `analyze`。 +任务身份以规范 **`display_name` / `mission_key`**(`Missions/` 目录名)对齐 SP–MP,避免仅靠显示名误匹配。 + +--- + +## 11. UI 分层与设计系统 + +```text +UI = Layers + Tokens + ComponentMap + Pipeline + Scale/DPI + Constraints ``` -## Notes: -Python 64-bit is needed. You may see this Error if running 32-bit python: +| 层 | 职责 | +|----|------| +| Frame | 唯一主业务窗 + 可并发对话框 | +| Container | 版式分割(可嵌套) | +| Widget | 最小交互单位(不互相遮挡) | +| Overlay | 全局 / 本地半透明蒙版 | +| Chart | 图表子系统(可叠控件;冲突须人工决策) | +| Page | 一页一内容根,挂入主窗页面栈 | + +管线:`setting.py` → `UITheme`(QSS)→ `charts.theme`(委托 UITheme,不另开色票)→ 组件。 -```OSError: [WinError 193] %1 is not a valid Win32 application``` +硬约束摘要:禁止散落硬编码色值与随意 `objectName`;布局用 `UITheme.scaled`;表面嵌套 `BG` → `SURFACE` → `CHART_WELL`;DPI 与设计稿缩放双轨分离。完整令牌表见 `AGENTS.md` §6 / `DESIGN.md`。 -Per mracko on COM_RADIO_SET: +--- - MSFS uses the European COM frequency spacing of 8.33kHz for all default aircraft. - This means that in practice, you increment the frequency by 0.005 MHz and - skip x.x20, x.x45, x.x70, and x.x95 MHz frequencies. - Have a look here http://g3asr.co.uk/calculators/833kHz.htm +## 12. 文档地图 +| 文档 | 内容 | +|------|------| +| **本文件 `README.md`** | 项目总览与入口导航 | +| [`AGENTS.md`](AGENTS.md) | Cursor Agent / 编码与 UI 唯一规则入口 | +| [`DESIGN.md`](DESIGN.md) | 设计令牌与组件清单(与 setting 同步) | +| [`Docs/项目原理.md`](Docs/项目原理.md) | SimConnect 库流程 + 应用层摘要 | +| [`Docs/README.md`](Docs/README.md) | 图表模块详解 + **Simulation Variables 中文全表** | +| [`Docs/Simulation Variables.pdf`](Docs/Simulation%20Variables.pdf) | 官方仿真变量原文(75 页) | +| [`Docs/空气动力学原理及分析.md`](Docs/空气动力学原理及分析.md) | Extra 任务功率 / 损失链 / 扩录与对比 | +| [`Docs/status.md`](Docs/status.md) | 状态机与生命周期实现说明 | +| [`Docs/window_config.md`](Docs/window_config.md) | 窗口几何与高 DPI 约定 | -## Events and Variables +--- -Below are links to the Microsoft documentation +## 参考来源 -[Function](https://docs.microsoft.com/en-us/previous-versions/microsoft-esp/cc526983(v=msdn.10)) +- `Docs/项目原理.md` — SimConnect 与应用层架构 +- `Docs/README.md` — 图表架构与仿真变量中文清单 +- `Docs/Simulation Variables.pdf` — FSX / SimConnect Simulation Variables +- `Docs/空气动力学原理及分析.md` — 任务功率与气动分析口径 +- `AGENTS.md` — 项目结构、状态机、UI / 持久化约束 +- `requirements.txt` — 依赖与架构(win32)钉选 -[Event IDs](https://docs.microsoft.com/en-us/previous-versions/microsoft-esp/cc526980(v=msdn.10)) +--- -[Simulation Variables](https://docs.flightsimulator.com/html/Programming_Tools/SimVars/Simulation_Variables.htm) +*维护说明:改业务常量改 `setting.py`;改主题同步 `DESIGN.md` / `AGENTS.md` 令牌表;改可订变量同步 `RequestList.py` 与 `Docs/README.md` 附件,勿以本地 CSV/JSON 作为成品持久化出口。* diff --git a/SimConnect/Attributes.py b/SimConnect/Attributes.py index 4e084aff..4ff948a7 100644 --- a/SimConnect/Attributes.py +++ b/SimConnect/Attributes.py @@ -3,6 +3,9 @@ from ctypes import * from ctypes.wintypes import * +# 本文件负责从 SimConnect DLL 暴露的 C 函数中创建 Python 端的接口定义, +# 通过 ctypes 声明参数与返回值,便于上层调用。 + class SimConnectDll(object): diff --git a/SimConnect/Constants.py b/SimConnect/Constants.py index d41bb1b3..d988be8d 100644 --- a/SimConnect/Constants.py +++ b/SimConnect/Constants.py @@ -4,6 +4,8 @@ LOGGER = logging.getLogger(__name__) +# 常量定义文件:对 SimConnect SDK 的关键数值进行 Python 端映射,方便重用。 + # //---------------------------------------------------------------------------- # // Constants diff --git a/SimConnect/Enum.py b/SimConnect/Enum.py index 8b42cad9..78637a99 100644 --- a/SimConnect/Enum.py +++ b/SimConnect/Enum.py @@ -3,6 +3,8 @@ from ctypes import * from .Constants import * +# 定义 SimConnect 需要的枚举与结构体,确保 ctypes 与 SDK 一致。 + import logging LOGGER = logging.getLogger(__name__) @@ -323,6 +325,14 @@ class SIMCONNECT_CLIENT_EVENT_ID(AutoName): # client-defined client event ID EVENT_SIM_STOP = auto() EVENT_SIM_PAUSED = auto() EVENT_SIM_UNPAUSED = auto() + EVENT_RACE_LAP = auto() + EVENT_RACE_END = auto() + EVENT_CUSTOM_MISSION_ACTION = auto() + EVENT_FLIGHT_LOADED = auto() + EVENT_MISSION_COMPLETED = auto() + EVENT_MP_SERVER_STARTED = auto() + EVENT_MP_CLIENT_STARTED = auto() + EVENT_MP_SESSION_ENDED = auto() pass @@ -425,39 +435,40 @@ class SIMCONNECT_RECV_EVENT_MULTIPLAYER_SESSION_ENDED(SIMCONNECT_RECV_EVENT): pass -# SIMCONNECT_DATA_RACE_RESULT +# SIMCONNECT_DATA_RACE_RESULT(SDK 使用 1 字节对齐) class SIMCONNECT_DATA_RACE_RESULT(Structure): + _pack_ = 1 _fields_ = [ - ("dwNumberOfRacers", DWORD), # The total number of racers - ("szPlayerName", c_char * MAX_PATH), # The name of the player - ( - "szSessionType", - c_char * MAX_PATH, - ), # The type of the multiplayer session: "LAN", "GAMESPY") - ("szAircraft", c_char * MAX_PATH), # The aircraft type - ("szPlayerRole", c_char * MAX_PATH), # The player role in the mission - ("fTotalTime", c_double), # Total time in seconds, 0 means DNF - ("fPenaltyTime", c_double), # Total penalty time in seconds - ( - "MissionGUID", - DWORD, - ), # The name of the mission to execute, NULL if no mission - ("dwIsDisqualified", c_double), # non 0 - disqualified, 0 - not disqualified + ("dwNumberOfRacers", DWORD), + ("MissionGUID", c_ubyte * 16), + ("szPlayerName", c_char * MAX_PATH), + ("szSessionType", c_char * MAX_PATH), + ("szAircraft", c_char * MAX_PATH), + ("szPlayerRole", c_char * MAX_PATH), + ("fTotalTime", c_double), + ("fPenaltyTime", c_double), + ("dwIsDisqualified", DWORD), ] class SIMCONNECT_RECV_EVENT_RACE_END( SIMCONNECT_RECV_EVENT ): # when dwID == SIMCONNECT_RECV_ID_EVENT_RACE_END - RacerData = SIMCONNECT_DATA_RACE_RESULT - _fields_ = [("dwRacerNumber", DWORD)] # The index of the racer the results are for + _pack_ = 1 + _fields_ = [ + ("dwRacerNumber", DWORD), + ("RacerData", SIMCONNECT_DATA_RACE_RESULT), + ] class SIMCONNECT_RECV_EVENT_RACE_LAP( SIMCONNECT_RECV_EVENT ): # when dwID == SIMCONNECT_RECV_ID_EVENT_RACE_LAP - RacerData = SIMCONNECT_DATA_RACE_RESULT - _fields_ = [("dwLapIndex", DWORD)] # The index of the lap the results are for + _pack_ = 1 + _fields_ = [ + ("dwLapIndex", DWORD), + ("RacerData", SIMCONNECT_DATA_RACE_RESULT), + ] class SIMCONNECT_RECV_SIMOBJECT_DATA(SIMCONNECT_RECV): @@ -535,12 +546,9 @@ class SIMCONNECT_RECV_SYSTEM_STATE( class SIMCONNECT_RECV_CUSTOM_ACTION(SIMCONNECT_RECV_EVENT): # _fields_ = [ - ("guidInstanceId", DWORD), # Instance id of the action that executed - ("dwWaitForCompletion", DWORD), # Wait for completion flag on the action - ( - "szPayLoad", - c_char, - ), # Variable length string payload associated with the mission action. + ("guidInstanceId", c_ubyte * 16), + ("dwWaitForCompletion", DWORD), + # 其后为变长 PayloadString,通过原始指针偏移读取 ] diff --git a/SimConnect/EventList.py b/SimConnect/EventList.py index bec37d14..dbe53719 100644 --- a/SimConnect/EventList.py +++ b/SimConnect/EventList.py @@ -1,5 +1,7 @@ from SimConnect import * +# 本文件列举大量可触发的 SimConnect 事件,并提供 EventHelper 以动态创建事件实例。 + class Event(object): diff --git a/SimConnect/FacilitiesList.py b/SimConnect/FacilitiesList.py index bbc0d918..1ce799f0 100644 --- a/SimConnect/FacilitiesList.py +++ b/SimConnect/FacilitiesList.py @@ -2,6 +2,8 @@ from .Enum import * from .Constants import * +# 设施请求工具:封装机场、导航点等设施列表的订阅与打印。 + class Facilitie(object): def __init__(self): diff --git a/SimConnect/RequestList.py b/SimConnect/RequestList.py index ccf16f59..90324965 100644 --- a/SimConnect/RequestList.py +++ b/SimConnect/RequestList.py @@ -2,6 +2,8 @@ from .Enum import * from .Constants import * +# 数据请求工具:封装航空器变量的定义、缓存与写入逻辑。 + class Request(object): diff --git a/SimConnect/SimConnect.dll b/SimConnect/SimConnect.dll index da40b739..a2bf93ed 100644 Binary files a/SimConnect/SimConnect.dll and b/SimConnect/SimConnect.dll differ diff --git a/SimConnect/SimConnect.py b/SimConnect/SimConnect.py index 5bb44652..6e8ed4aa 100644 --- a/SimConnect/SimConnect.py +++ b/SimConnect/SimConnect.py @@ -8,6 +8,8 @@ import os import threading +# 该模块封装与 SimConnect DLL 的交互,负责建立连接、事件分发、数据读写与模拟器状态管理。 + _library_path = os.path.splitext(os.path.abspath(__file__))[0] + '.dll' LOGGER = logging.getLogger(__name__) @@ -24,23 +26,39 @@ def IsHR(self, hr, value): return ctypes.c_ulong(_hr.value).value == value def handle_id_event(self, event): - uEventID = event.uEventID - if uEventID == self.dll.EventID.EVENT_SIM_START: + # 处理模拟器启动/停止/暂停类事件并同步内部状态 + uEventID = int(event.uEventID) + event_id = self.dll.EventID + if uEventID == int(event_id.EVENT_SIM_START.value): LOGGER.info("SIM START") self.running = True - if uEventID == self.dll.EventID.EVENT_SIM_STOP: + if uEventID == int(event_id.EVENT_SIM_STOP.value): LOGGER.info("SIM Stop") self.running = False - # Unknow whay not reciving - if uEventID == self.dll.EventID.EVENT_SIM_PAUSED: + if uEventID == int(event_id.EVENT_SIM_PAUSED.value): LOGGER.info("SIM Paused") self.paused = True - if uEventID == self.dll.EventID.EVENT_SIM_UNPAUSED: + if uEventID == int(event_id.EVENT_SIM_UNPAUSED.value): LOGGER.info("SIM Unpaused") self.paused = False def handle_simobject_event(self, ObjData): + # 处理对象数据回调,将结果写回已注册的请求 dwRequestID = ObjData.dwRequestID + dwObjectID = getattr(ObjData, "dwObjectID", None) + enum_state = getattr(self, "_aircraft_enum", None) + if enum_state and int(dwRequestID) == int(enum_state["request_id"]): + if dwObjectID is not None: + oid = int(dwObjectID) + if oid not in enum_state["ids"]: + enum_state["ids"].append(oid) + # dwoutof 表示本批总数;收齐后标记完成 + try: + if int(ObjData.dwentrynumber) >= int(ObjData.dwoutof): + enum_state["done"].set() + except Exception: + enum_state["done"].set() + if dwRequestID in self.Requests: _request = self.Requests[dwRequestID] rtype = _request.definitions[0][1].decode() @@ -48,13 +66,18 @@ def handle_simobject_event(self, ObjData): pS = cast(ObjData.dwData, c_char_p) _request.outData = pS.value else: - _request.outData = cast( - ObjData.dwData, POINTER(c_double * len(_request.definitions)) - ).contents[0] + n = len(_request.definitions) + arr = cast(ObjData.dwData, POINTER(c_double * n)).contents + if n == 1: + _request.outData = arr[0] + else: + _request.outData = [float(arr[i]) for i in range(n)] else: - LOGGER.warn("Event ID: %d Not Handled." % (dwRequestID)) + if not (enum_state and int(dwRequestID) == int(enum_state["request_id"])): + LOGGER.warn("Event ID: %d Not Handled." % (dwRequestID)) def handle_exception_event(self, exc): + # 捕获并打印 SimConnect 抛出的异常,便于定位请求问题 _exception = SIMCONNECT_EXCEPTION(exc.dwException).name _unsendid = exc.UNKNOWN_SENDID _sendid = exc.dwSendID @@ -65,25 +88,146 @@ def handle_exception_event(self, exc): for _reqin in self.Requests: _request = self.Requests[_reqin] if _request.LastID == _unsendid: - LOGGER.warn("%s: in %s" % (_exception, _request.definitions[0])) + var_name = _request.definitions[0] + if _exception == "SIMCONNECT_EXCEPTION_DATA_ERROR": + self._log_exception( + "%s: 变量 %s 数据不可写入或值无效(Program Data 请用事件而非 SetData)" + % (_exception, var_name) + ) + else: + self._log_exception("%s: in %s" % (_exception, var_name)) return - LOGGER.warn(_exception) + op = self._consume_send_op(_unsendid) or self._consume_send_op(_sendid) + if op: + if _exception == "SIMCONNECT_EXCEPTION_INVALID_DATA_SIZE" and op == "AI_WAYPOINT_LIST": + self.waypoint_broken = True + self._log_exception("%s: in %s" % (_exception, op)) + return + + if _exception == "SIMCONNECT_EXCEPTION_DATA_ERROR": + self._log_exception("%s: SimConnect 数据写入被拒绝" % (_exception,)) + else: + self._log_exception(_exception) + + def _log_exception(self, message: str) -> None: + """同类异常限流,避免回放线程刷屏。""" + now = time.monotonic() + last = self._exc_log_ts.get(message, 0.0) + if now - last < 2.0: + self._exc_suppressed += 1 + return + extra = "" + if self._exc_suppressed: + extra = " (已抑制 %d 条重复)" % self._exc_suppressed + self._exc_suppressed = 0 + self._exc_log_ts[message] = now + LOGGER.warning("%s%s", message, extra) + + def _note_send_op(self, label: str) -> None: + temp = DWORD(0) + try: + self.dll.GetLastSentPacketID(self.hSimConnect, temp) + except Exception: + return + self._send_ops[int(temp.value)] = label + + def _consume_send_op(self, send_id): + try: + key = int(send_id) + except (TypeError, ValueError): + return None + return self._send_ops.pop(key, None) def handle_state_event(self, pData): - print("I:", pData.dwInteger, "F:", pData.fFloat, "S:", pData.szString) + req_id = int(pData.dwRequestID) + if req_id == int(self.dll.EventID.EVENT_SIM_PAUSED.value): + paused = int(pData.dwInteger) != 0 + LOGGER.debug("SIM pause state: %s", paused) + self.paused = paused + waiter = getattr(self, "_pause_state_waiter", None) + if waiter is not None: + try: + waiter(paused) + except Exception: + LOGGER.exception("pause state waiter failed") + return + LOGGER.debug( + "System state req=%s I=%s F=%s S=%s", + req_id, + pData.dwInteger, + pData.fFloat, + pData.szString, + ) # TODO: update callbackfunction to expand functions. def my_dispatch_proc(self, pData, cbData, pContext): - # print("my_dispatch_proc") + # 统一的分发函数,被 DLL 回调以分流不同类型的消息 dwID = pData.contents.dwID if dwID == SIMCONNECT_RECV_ID.SIMCONNECT_RECV_ID_EVENT: evt = cast(pData, POINTER(SIMCONNECT_RECV_EVENT)).contents - self.handle_id_event(evt) + if evt.uEventID == self.dll.EventID.EVENT_MISSION_COMPLETED.value: + result = int(evt.dwData) + LOGGER.info("MISSION COMPLETED: result=%s", result) + for handler in list(self._mission_completed_handlers): + try: + handler(result) + except Exception: + LOGGER.exception("mission completed handler failed") + else: + self.handle_id_event(evt) + + elif dwID == SIMCONNECT_RECV_ID.SIMCONNECT_RECV_ID_EVENT_FILENAME: + # ctypes 子类会追加字段而非覆盖 dwData,此处按 SDK 布局手动解析 + base = cast(pData, c_void_p).value + if base: + u_event_id = cast(base + 16, POINTER(DWORD)).contents.value + if u_event_id == self.dll.EventID.EVENT_FLIGHT_LOADED.value: + path = string_at(base + 20, MAX_PATH).decode("utf-8", errors="ignore").strip("\x00") + LOGGER.info("FLIGHT LOADED: %s", path) + for handler in list(self._flight_loaded_handlers): + try: + handler(path) + except Exception: + LOGGER.exception("flight loaded handler failed") elif dwID == SIMCONNECT_RECV_ID.SIMCONNECT_RECV_ID_SYSTEM_STATE: state = cast(pData, POINTER(SIMCONNECT_RECV_SYSTEM_STATE)).contents - self.handle_state_event(state) + if state.dwRequestID == self.dll.EventID.EVENT_FLIGHT_LOADED.value: + path = state.szString.decode("utf-8", errors="ignore").strip("\x00") + if path: + LOGGER.info("FLIGHT LOADED (state): %s", path) + for handler in list(self._flight_loaded_handlers): + try: + handler(path) + except Exception: + LOGGER.exception("flight loaded handler failed") + else: + self.handle_state_event(state) + + elif dwID == SIMCONNECT_RECV_ID.SIMCONNECT_RECV_ID_EVENT_MULTIPLAYER_SERVER_STARTED: + LOGGER.info("MULTIPLAYER SERVER STARTED") + for handler in list(self._mp_server_started_handlers): + try: + handler() + except Exception: + LOGGER.exception("mp server started handler failed") + + elif dwID == SIMCONNECT_RECV_ID.SIMCONNECT_RECV_ID_EVENT_MULTIPLAYER_CLIENT_STARTED: + LOGGER.info("MULTIPLAYER CLIENT STARTED") + for handler in list(self._mp_client_started_handlers): + try: + handler() + except Exception: + LOGGER.exception("mp client started handler failed") + + elif dwID == SIMCONNECT_RECV_ID.SIMCONNECT_RECV_ID_EVENT_MULTIPLAYER_SESSION_ENDED: + LOGGER.info("MULTIPLAYER SESSION ENDED") + for handler in list(self._mp_session_ended_handlers): + try: + handler() + except Exception: + LOGGER.exception("mp session ended handler failed") elif dwID == SIMCONNECT_RECV_ID.SIMCONNECT_RECV_ID_SIMOBJECT_DATA_BYTYPE: pObjData = cast( @@ -91,6 +235,12 @@ def my_dispatch_proc(self, pData, cbData, pContext): ).contents self.handle_simobject_event(pObjData) + elif dwID == SIMCONNECT_RECV_ID.SIMCONNECT_RECV_ID_SIMOBJECT_DATA: + pObjData = cast( + pData, POINTER(SIMCONNECT_RECV_SIMOBJECT_DATA) + ).contents + self.handle_simobject_event(pObjData) + elif dwID == SIMCONNECT_RECV_ID.SIMCONNECT_RECV_ID_OPEN: LOGGER.info("SIM OPEN") self.ok = True @@ -104,7 +254,14 @@ def my_dispatch_proc(self, pData, cbData, pContext): pData, POINTER(SIMCONNECT_RECV_ASSIGNED_OBJECT_ID) ).contents objectId = pObjData.dwObjectID + requestId = pObjData.dwRequestID os.environ["SIMCONNECT_OBJECT_ID"] = str(objectId) + waiter = self._object_id_waiters.pop(int(requestId), None) + if waiter is not None: + try: + waiter(int(requestId), int(objectId)) + except Exception: + LOGGER.exception("assigned object handler failed") elif (dwID == SIMCONNECT_RECV_ID.SIMCONNECT_RECV_ID_AIRPORT_LIST) or ( dwID == SIMCONNECT_RECV_ID.SIMCONNECT_RECV_ID_WAYPOINT_LIST) or ( @@ -119,6 +276,67 @@ def my_dispatch_proc(self, pData, cbData, pContext): _facilitie.parent.dump(pData) _facilitie.dump(pData) + elif dwID == SIMCONNECT_RECV_ID.SIMCONNECT_RECV_ID_EVENT_RACE_LAP: + evt = cast(pData, POINTER(SIMCONNECT_RECV_EVENT_RACE_LAP)).contents + lap_index = int(evt.dwLapIndex) + lap_time_s = float(evt.RacerData.fTotalTime) + penalty_s = float(evt.RacerData.fPenaltyTime) + LOGGER.info( + "RACE LAP: %s time=%.3fs penalty=%.3fs", + lap_index, + lap_time_s, + penalty_s, + ) + for handler in list(self._race_lap_handlers): + try: + handler(lap_index, lap_time_s, penalty_s) + except TypeError: + # 兼容旧签名 handler(lap_index) + try: + handler(lap_index) + except Exception: + LOGGER.exception("race lap handler failed") + except Exception: + LOGGER.exception("race lap handler failed") + + elif dwID == SIMCONNECT_RECV_ID.SIMCONNECT_RECV_ID_EVENT_RACE_END: + evt = cast(pData, POINTER(SIMCONNECT_RECV_EVENT_RACE_END)).contents + racer_number = int(evt.dwRacerNumber) + total_time_s = float(evt.RacerData.fTotalTime) + penalty_s = float(evt.RacerData.fPenaltyTime) + LOGGER.info( + "RACE END: racer %s total=%.3fs penalty=%.3fs", + racer_number, + total_time_s, + penalty_s, + ) + for handler in list(self._race_end_handlers): + try: + handler(racer_number, total_time_s, penalty_s) + except TypeError: + try: + handler(racer_number) + except Exception: + LOGGER.exception("race end handler failed") + except Exception: + LOGGER.exception("race end handler failed") + + elif dwID == SIMCONNECT_RECV_ID.SIMCONNECT_RECV_ID_CUSTOM_ACTION: + # RECV(12) + EVENT(12) + GUID(16) + Wait(4) = 44 + base = cast(pData, c_void_p).value + payload = "" + if base and cbData > 44: + try: + payload = string_at(base + 44).decode("utf-8", errors="ignore").strip() + except Exception: + payload = "" + LOGGER.info("CUSTOM ACTION payload: %r", payload) + for handler in list(self._custom_action_handlers): + try: + handler(payload) + except Exception: + LOGGER.exception("custom action handler failed") + elif dwID == SIMCONNECT_RECV_ID.SIMCONNECT_RECV_ID_QUIT: self.quit = 1 else: @@ -137,40 +355,379 @@ def __init__(self, auto_connect=True, library_path=_library_path): self.paused = False self.DEFINITION_POS = None self.DEFINITION_WAYPOINT = None + self.waypoint_broken = False + self._send_ops = {} + self._exc_log_ts = {} + self._exc_suppressed = 0 + self._race_lap_handlers = [] + self._race_end_handlers = [] + self._custom_action_handlers = [] + self._flight_loaded_handlers = [] + self._mission_completed_handlers = [] + self._mp_server_started_handlers = [] + self._mp_client_started_handlers = [] + self._mp_session_ended_handlers = [] + self._object_id_waiters = {} + self._aircraft_enum = None + self._pause_state_waiter = None + self._api_lock = threading.RLock() self.my_dispatch_proc_rd = self.dll.DispatchProc(self.my_dispatch_proc) if auto_connect: self.connect() - def connect(self): - try: - err = self.dll.Open( - byref(self.hSimConnect), LPCSTR(b"Request Data"), None, 0, 0, 0 + def add_race_lap_handler(self, handler): + """注册 Acceleration RaceLap 回调:handler(lap_index, lap_time_s, penalty_s)。""" + self._race_lap_handlers.append(handler) + + def add_race_end_handler(self, handler): + """注册 Acceleration RaceEnd 回调:handler(racer_number, total_time_s, penalty_s)。""" + self._race_end_handlers.append(handler) + + def add_custom_action_handler(self, handler): + """注册 Mission CustomAction 回调:handler(payload:str)。""" + self._custom_action_handlers.append(handler) + + def add_flight_loaded_handler(self, handler): + """注册 FlightLoaded 回调:handler(path:str)。""" + self._flight_loaded_handlers.append(handler) + + def add_mission_completed_handler(self, handler): + """注册 MissionCompleted 回调:handler(result_code:int)。""" + self._mission_completed_handlers.append(handler) + + def add_mp_server_started_handler(self, handler): + self._mp_server_started_handlers.append(handler) + + def add_mp_client_started_handler(self, handler): + self._mp_client_started_handlers.append(handler) + + def add_mp_session_ended_handler(self, handler): + self._mp_session_ended_handlers.append(handler) + + def request_flight_loaded(self) -> None: + """查询当前已加载的 .flt 路径。""" + with self._api_lock: + self.dll.RequestSystemState( + self.hSimConnect, + self.dll.EventID.EVENT_FLIGHT_LOADED, + b"FlightLoaded", ) - if self.IsHR(err, 0): - LOGGER.debug("Connected to Flight Simulator!") - # Request an event when the simulation starts - # The user is in control of the aircraft + def register_object_id_waiter(self, request_id, callback): + """注册一次性回调:AI 创建完成后 callback(request_id, object_id)。""" + key = int(request_id.value if hasattr(request_id, "value") else request_id) + self._object_id_waiters[key] = callback + + def remove_sim_object(self, object_id): + """移除模拟器中的 AI / 物体。""" + with self._api_lock: + req = self.new_request_id() + err = self.dll.AIRemoveObject( + self.hSimConnect, + DWORD(int(object_id)), + req.value, + ) + return self.IsHR(err, 0) + + def release_ai_control(self, object_id): + """释放 AI 自动控制,便于客户端接管航路点/位置。""" + with self._api_lock: + req = self.new_request_id() + err = self.dll.AIReleaseControl( + self.hSimConnect, + DWORD(int(object_id)), + req.value, + ) + return self.IsHR(err, 0) + + def set_init_position_on_object( + self, + object_id, + lat, + lon, + alt, + hdg=0, + pitch=0, + bank=0, + gnd=0, + speed=0, + ): + """对指定 ObjectID 写入 Initial Position。""" + with self._api_lock: + init_pos = SIMCONNECT_DATA_INITPOSITION() + init_pos.Altitude = float(alt) + init_pos.Latitude = float(lat) + init_pos.Longitude = float(lon) + init_pos.Pitch = float(pitch) + init_pos.Bank = float(bank) + init_pos.Heading = float(hdg) + init_pos.OnGround = 1 if gnd else 0 + init_pos.Airspeed = int(max(0, speed)) + if self.DEFINITION_POS is None: + self.DEFINITION_POS = self.new_def_id() + err = self.dll.AddToDataDefinition( + self.hSimConnect, + self.DEFINITION_POS.value, + b"Initial Position", + b"", + SIMCONNECT_DATATYPE.SIMCONNECT_DATATYPE_INITPOSITION, + 0, + SIMCONNECT_UNUSED, + ) + if not self.IsHR(err, 0): + self.DEFINITION_POS = None + return False + hr = self.dll.SetDataOnSimObject( + self.hSimConnect, + self.DEFINITION_POS.value, + DWORD(int(object_id)), + 0, + 0, + sizeof(SIMCONNECT_DATA_INITPOSITION), + byref(init_pos), + ) + if self.IsHR(hr, 0): + self._note_send_op("Initial Position") + return True + return False + + def set_double_on_object(self, object_id, simvar_name, unit_name, value): + """向指定 ObjectID 写入单个 FLOAT64 仿真变量。""" + with self._api_lock: + name = simvar_name if isinstance(simvar_name, (bytes, bytearray)) else str(simvar_name).encode() + unit = unit_name if isinstance(unit_name, (bytes, bytearray)) else str(unit_name).encode() + cache_key = (name, unit) + if not hasattr(self, "_object_var_defs"): + self._object_var_defs = {} + def_id = self._object_var_defs.get(cache_key) + if def_id is None: + def_id = self.new_def_id() + err = self.dll.AddToDataDefinition( + self.hSimConnect, + def_id.value, + name, + unit, + SIMCONNECT_DATATYPE.SIMCONNECT_DATATYPE_FLOAT64, + 0, + SIMCONNECT_UNUSED, + ) + if not self.IsHR(err, 0): + return False + self._object_var_defs[cache_key] = def_id + arr = (ctypes.c_double * 1)(float(value)) + hr = self.dll.SetDataOnSimObject( + self.hSimConnect, + def_id.value, + DWORD(int(object_id)), + 0, + 0, + sizeof(ctypes.c_double), + cast(arr, c_void_p), + ) + if self.IsHR(hr, 0): + self._note_send_op(name.decode(errors="ignore") if isinstance(name, (bytes, bytearray)) else str(name)) + return True + return False + + def create_non_atc_aircraft( + self, + container_title, + lat, + lon, + rqst, + tail_number="GHOST1", + hdg=0, + gnd=0, + alt=0, + pitch=0, + bank=0, + speed=0, + ): + """创建非 ATC 控制的 AI 飞机(适合航路点跟飞)。""" + with self._api_lock: + init_pos = SIMCONNECT_DATA_INITPOSITION() + init_pos.Altitude = float(alt) + init_pos.Latitude = float(lat) + init_pos.Longitude = float(lon) + init_pos.Pitch = float(pitch) + init_pos.Bank = float(bank) + init_pos.Heading = float(hdg) + init_pos.OnGround = 1 if gnd else 0 + init_pos.Airspeed = int(max(0, speed)) + title = container_title.encode() if isinstance(container_title, str) else container_title + tail = tail_number.encode() if isinstance(tail_number, str) else tail_number + err = self.dll.AICreateNonATCAircraft( + self.hSimConnect, + title, + tail, + init_pos, + rqst.value, + ) + return self.IsHR(err, 0) + + def set_ai_waypoints(self, object_id, waypoint_list): + """为指定 AI 对象下发航路点列表。""" + if not waypoint_list: + return False + if getattr(self, "waypoint_broken", False): + return False + with self._api_lock: + if self.DEFINITION_WAYPOINT is None: + self.DEFINITION_WAYPOINT = self.new_def_id() + err = self.dll.AddToDataDefinition( + self.hSimConnect, + self.DEFINITION_WAYPOINT.value, + b'AI WAYPOINT LIST', + b'', + SIMCONNECT_DATATYPE.SIMCONNECT_DATATYPE_WAYPOINT, + 0, + SIMCONNECT_UNUSED, + ) + if not self.IsHR(err, 0): + self.DEFINITION_WAYPOINT = None + return False + count = len(waypoint_list) + array_type = SIMCONNECT_DATA_WAYPOINT * count + arr = array_type() + for i, wp in enumerate(waypoint_list): + arr[i] = wp + hr = self.dll.SetDataOnSimObject( + self.hSimConnect, + self.DEFINITION_WAYPOINT.value, + DWORD(int(object_id)), + 0, + count, + sizeof(SIMCONNECT_DATA_WAYPOINT), + byref(arr), + ) + if self.IsHR(hr, 0): + self._note_send_op("AI_WAYPOINT_LIST") + return True + return False + + def enumerate_aircraft_ids(self, radius_m=200000, timeout=4.0): + """枚举当前范围内飞机 ObjectID(含用户机)。""" + collected = [] + done = threading.Event() + with self._api_lock: + def_id = self.new_def_id() + req_id = self.new_request_id() + err = self.dll.AddToDataDefinition( + self.hSimConnect, + def_id.value, + b"PLANE LATITUDE", + b"degrees", + SIMCONNECT_DATATYPE.SIMCONNECT_DATATYPE_FLOAT64, + 0, + SIMCONNECT_UNUSED, + ) + if not self.IsHR(err, 0): + return [] + + class _EnumReq: + def __init__(self): + self.DATA_REQUEST_ID = req_id + self.DATA_DEFINITION_ID = def_id + self.definitions = [(b"PLANE LATITUDE", b"degrees")] + self.outData = None + self.LastID = 0 + self.attemps = 1 + + def note_object(self, object_id): + oid = int(object_id) + if oid not in collected: + collected.append(oid) + + enum_req = _EnumReq() + self.Requests[req_id.value] = enum_req + self._aircraft_enum = { + "request_id": int(req_id.value), + "ids": collected, + "done": done, + } + self.dll.RequestDataOnSimObjectType( + self.hSimConnect, + req_id.value, + def_id.value, + int(radius_m), + SIMCONNECT_SIMOBJECT_TYPE.SIMCONNECT_SIMOBJECT_TYPE_AIRCRAFT, + ) + deadline = time.time() + timeout + while time.time() < deadline: + if done.is_set() and collected: + break + time.sleep(0.05) + with self._api_lock: + self.Requests.pop(req_id.value, None) + self._aircraft_enum = None + return list(collected) + + def connect(self, timeout=5.0): + try: + with self._api_lock: + err = self.dll.Open( + byref(self.hSimConnect), LPCSTR(b"Request Data"), None, 0, 0, 0 + ) + if not self.IsHR(err, 0): + raise ConnectionError("Did not find Flight Simulator running.") + LOGGER.debug("Connected to Flight Simulator!") self.dll.SubscribeToSystemEvent( self.hSimConnect, self.dll.EventID.EVENT_SIM_START, b"SimStart" ) - # The user is navigating the UI. self.dll.SubscribeToSystemEvent( self.hSimConnect, self.dll.EventID.EVENT_SIM_STOP, b"SimStop" ) - # Request a notification when the flight is paused self.dll.SubscribeToSystemEvent( self.hSimConnect, self.dll.EventID.EVENT_SIM_PAUSED, b"Paused" ) - # Request a notification when the flight is un-paused. self.dll.SubscribeToSystemEvent( self.hSimConnect, self.dll.EventID.EVENT_SIM_UNPAUSED, b"Unpaused" ) + self.dll.SubscribeToSystemEvent( + self.hSimConnect, self.dll.EventID.EVENT_RACE_LAP, b"RaceLap" + ) + self.dll.SubscribeToSystemEvent( + self.hSimConnect, self.dll.EventID.EVENT_RACE_END, b"RaceEnd" + ) + self.dll.SubscribeToSystemEvent( + self.hSimConnect, + self.dll.EventID.EVENT_CUSTOM_MISSION_ACTION, + b"CustomMissionActionExecuted", + ) + self.dll.SubscribeToSystemEvent( + self.hSimConnect, + self.dll.EventID.EVENT_FLIGHT_LOADED, + b"FlightLoaded", + ) + self.dll.SubscribeToSystemEvent( + self.hSimConnect, + self.dll.EventID.EVENT_MISSION_COMPLETED, + b"MissionCompleted", + ) + self.dll.SubscribeToSystemEvent( + self.hSimConnect, + self.dll.EventID.EVENT_MP_SERVER_STARTED, + b"MultiplayerServerStarted", + ) + self.dll.SubscribeToSystemEvent( + self.hSimConnect, + self.dll.EventID.EVENT_MP_CLIENT_STARTED, + b"MultiplayerClientStarted", + ) + self.dll.SubscribeToSystemEvent( + self.hSimConnect, + self.dll.EventID.EVENT_MP_SESSION_ENDED, + b"MultiplayerSessionEnded", + ) self.timerThread = threading.Thread(target=self._run) self.timerThread.daemon = True self.timerThread.start() - while self.ok is False: - pass + deadline = time.time() + timeout + while self.ok is False: + if time.time() > deadline: + self.exit() + raise ConnectionError("SimConnect connection timed out.") + time.sleep(0.01) except OSError: LOGGER.debug("Did not find Flight Simulator running.") raise ConnectionError("Did not find Flight Simulator running.") @@ -181,80 +738,95 @@ def _run(self): self.dll.CallDispatch(self.hSimConnect, self.my_dispatch_proc_rd, None) time.sleep(.002) except OSError as err: - print("OS error: {0}".format(err)) + LOGGER.debug("SimConnect dispatch error: %s", err) + self.quit = 1 def exit(self): self.quit = 1 - self.timerThread.join() - self.dll.Close(self.hSimConnect) + if hasattr(self, "timerThread") and self.timerThread.is_alive(): + self.timerThread.join(timeout=2.0) + with self._api_lock: + try: + self.dll.Close(self.hSimConnect) + except Exception: + pass def map_to_sim_event(self, name): - for m in self.dll.EventID: - if name.decode() == m.name: - LOGGER.debug("Already have event: ", m) - return m - - names = [m.name for m in self.dll.EventID] + [name.decode()] - self.dll.EventID = Enum(self.dll.EventID.__name__, names) - evnt = list(self.dll.EventID)[-1] - err = self.dll.MapClientEventToSimEvent(self.hSimConnect, evnt.value, name) - if self.IsHR(err, 0): - return evnt - else: + with self._api_lock: + for m in self.dll.EventID: + if name.decode() == m.name: + LOGGER.debug("Already have event: ", m) + return m + + names = [m.name for m in self.dll.EventID] + [name.decode()] + self.dll.EventID = Enum(self.dll.EventID.__name__, names) + evnt = list(self.dll.EventID)[-1] + err = self.dll.MapClientEventToSimEvent(self.hSimConnect, evnt.value, name) + if self.IsHR(err, 0): + return evnt LOGGER.error("Error: MapToSimEvent") return None def add_to_notification_group(self, group, evnt, bMaskable=False): - self.dll.AddClientEventToNotificationGroup( - self.hSimConnect, group, evnt, bMaskable - ) + with self._api_lock: + self.dll.AddClientEventToNotificationGroup( + self.hSimConnect, group, evnt, bMaskable + ) def request_data(self, _Request): - _Request.outData = None - self.dll.RequestDataOnSimObjectType( - self.hSimConnect, - _Request.DATA_REQUEST_ID.value, - _Request.DATA_DEFINITION_ID.value, - 0, - SIMCONNECT_SIMOBJECT_TYPE.SIMCONNECT_SIMOBJECT_TYPE_USER, - ) - temp = DWORD(0) - self.dll.GetLastSentPacketID(self.hSimConnect, temp) - _Request.LastID = temp.value + try: + with self._api_lock: + if self.quit: + return False + _Request.outData = None + self.dll.RequestDataOnSimObjectType( + self.hSimConnect, + _Request.DATA_REQUEST_ID.value, + _Request.DATA_DEFINITION_ID.value, + 0, + SIMCONNECT_SIMOBJECT_TYPE.SIMCONNECT_SIMOBJECT_TYPE_USER, + ) + temp = DWORD(0) + self.dll.GetLastSentPacketID(self.hSimConnect, temp) + _Request.LastID = temp.value + return True + except OSError: + self.quit = 1 + return False def set_data(self, _Request): - rtype = _Request.definitions[0][1].decode() - if 'string' in rtype.lower(): - pyarr = bytearray(_Request.outData) - dataarray = (ctypes.c_char * len(pyarr))(*pyarr) - else: - pyarr = list([_Request.outData]) - dataarray = (ctypes.c_double * len(pyarr))(*pyarr) + with self._api_lock: + rtype = _Request.definitions[0][1].decode() + if 'string' in rtype.lower(): + pyarr = bytearray(_Request.outData) + dataarray = (ctypes.c_char * len(pyarr))(*pyarr) + else: + pyarr = list([_Request.outData]) + dataarray = (ctypes.c_double * len(pyarr))(*pyarr) - pObjData = cast( - dataarray, c_void_p - ) - err = self.dll.SetDataOnSimObject( - self.hSimConnect, - _Request.DATA_DEFINITION_ID.value, - SIMCONNECT_SIMOBJECT_TYPE.SIMCONNECT_SIMOBJECT_TYPE_USER, - 0, - 0, - sizeof(ctypes.c_double) * len(pyarr), - pObjData - ) - if self.IsHR(err, 0): - # LOGGER.debug("Request Sent") - return True - else: + pObjData = cast( + dataarray, c_void_p + ) + err = self.dll.SetDataOnSimObject( + self.hSimConnect, + _Request.DATA_DEFINITION_ID.value, + SIMCONNECT_SIMOBJECT_TYPE.SIMCONNECT_SIMOBJECT_TYPE_USER, + 0, + 0, + sizeof(ctypes.c_double) * len(pyarr), + pObjData + ) + if self.IsHR(err, 0): + return True return False def get_data(self, _Request): - self.request_data(_Request) - # self.run() + if not self.request_data(_Request): + return False attemps = 0 while _Request.outData is None and attemps < _Request.attemps: - # self.run() + if self.quit: + return False time.sleep(.01) attemps += 1 if _Request.outData is None: @@ -263,71 +835,88 @@ def get_data(self, _Request): return True def send_event(self, evnt, data=DWORD(0)): - err = self.dll.TransmitClientEvent( - self.hSimConnect, - SIMCONNECT_OBJECT_ID_USER, - evnt.value, - data, - SIMCONNECT_GROUP_PRIORITY_HIGHEST, - DWORD(16), - ) + with self._api_lock: + err = self.dll.TransmitClientEvent( + self.hSimConnect, + SIMCONNECT_OBJECT_ID_USER, + evnt.value, + data, + SIMCONNECT_GROUP_PRIORITY_HIGHEST, + DWORD(16), + ) - if self.IsHR(err, 0): - # LOGGER.debug("Event Sent") - return True - else: + if self.IsHR(err, 0): + return True return False + def send_event_to_object(self, object_id, event_name, data=0): + """向指定 ObjectID 发送客户端事件(如 FREEZE_*)。""" + with self._api_lock: + name = event_name if isinstance(event_name, (bytes, bytearray)) else str(event_name).encode() + evnt = self.map_to_sim_event(name) + if evnt is None: + return False + err = self.dll.TransmitClientEvent( + self.hSimConnect, + DWORD(int(object_id)), + evnt.value, + DWORD(int(data)), + SIMCONNECT_GROUP_PRIORITY_HIGHEST, + DWORD(16), + ) + return self.IsHR(err, 0) + def new_def_id(self): - _name = "Definition" + str(len(list(self.dll.DATA_DEFINITION_ID))) - names = [m.name for m in self.dll.DATA_DEFINITION_ID] + [_name] + with self._api_lock: + _name = "Definition" + str(len(list(self.dll.DATA_DEFINITION_ID))) + names = [m.name for m in self.dll.DATA_DEFINITION_ID] + [_name] - self.dll.DATA_DEFINITION_ID = Enum(self.dll.DATA_DEFINITION_ID.__name__, names) - DEFINITION_ID = list(self.dll.DATA_DEFINITION_ID)[-1] - return DEFINITION_ID + self.dll.DATA_DEFINITION_ID = Enum(self.dll.DATA_DEFINITION_ID.__name__, names) + DEFINITION_ID = list(self.dll.DATA_DEFINITION_ID)[-1] + return DEFINITION_ID def new_request_id(self): - name = "Request" + str(len(self.dll.DATA_REQUEST_ID)) - names = [m.name for m in self.dll.DATA_REQUEST_ID] + [name] - self.dll.DATA_REQUEST_ID = Enum(self.dll.DATA_REQUEST_ID.__name__, names) - REQUEST_ID = list(self.dll.DATA_REQUEST_ID)[-1] + with self._api_lock: + name = "Request" + str(len(self.dll.DATA_REQUEST_ID)) + names = [m.name for m in self.dll.DATA_REQUEST_ID] + [name] + self.dll.DATA_REQUEST_ID = Enum(self.dll.DATA_REQUEST_ID.__name__, names) + REQUEST_ID = list(self.dll.DATA_REQUEST_ID)[-1] - return REQUEST_ID + return REQUEST_ID def add_waypoints(self, _waypointlist): - if self.DEFINITION_WAYPOINT is None: - self.DEFINITION_WAYPOINT = self.new_def_id() - err = self.dll.AddToDataDefinition( + with self._api_lock: + if self.DEFINITION_WAYPOINT is None: + self.DEFINITION_WAYPOINT = self.new_def_id() + err = self.dll.AddToDataDefinition( + self.hSimConnect, + self.DEFINITION_WAYPOINT.value, + b'AI WAYPOINT LIST', + b'number', + SIMCONNECT_DATATYPE.SIMCONNECT_DATATYPE_WAYPOINT, + 0, + SIMCONNECT_UNUSED, + ) + pyarr = [] + for waypt in _waypointlist: + for e in waypt._fields_: + pyarr.append(getattr(waypt, e[0])) + dataarray = (ctypes.c_double * len(pyarr))(*pyarr) + pObjData = cast( + dataarray, c_void_p + ) + sx = int(sizeof(ctypes.c_double) * (len(pyarr) / len(_waypointlist))) + hr = self.dll.SetDataOnSimObject( self.hSimConnect, self.DEFINITION_WAYPOINT.value, - b'AI WAYPOINT LIST', - b'number', - SIMCONNECT_DATATYPE.SIMCONNECT_DATATYPE_WAYPOINT, + SIMCONNECT_OBJECT_ID_USER, 0, - SIMCONNECT_UNUSED, + len(_waypointlist), + sx, + pObjData ) - pyarr = [] - for waypt in _waypointlist: - for e in waypt._fields_: - pyarr.append(getattr(waypt, e[0])) - dataarray = (ctypes.c_double * len(pyarr))(*pyarr) - pObjData = cast( - dataarray, c_void_p - ) - sx = int(sizeof(ctypes.c_double) * (len(pyarr) / len(_waypointlist))) - return - hr = self.dll.SetDataOnSimObject( - self.hSimConnect, - self.DEFINITION_WAYPOINT.value, - SIMCONNECT_OBJECT_ID_USER, - 0, - len(_waypointlist), - sx, - pObjData - ) - if self.IsHR(err, 0): - return True - else: + if self.IsHR(err, 0): + return True return False def set_pos( @@ -341,54 +930,54 @@ def set_pos( _Heading=0, _OnGround=0, ): - Init = SIMCONNECT_DATA_INITPOSITION() - Init.Altitude = _Altitude - Init.Latitude = _Latitude - Init.Longitude = _Longitude - Init.Pitch = _Pitch - Init.Bank = _Bank - Init.Heading = _Heading - Init.OnGround = _OnGround - Init.Airspeed = _Airspeed - - if self.DEFINITION_POS is None: - self.DEFINITION_POS = self.new_def_id() - err = self.dll.AddToDataDefinition( + with self._api_lock: + Init = SIMCONNECT_DATA_INITPOSITION() + Init.Altitude = _Altitude + Init.Latitude = _Latitude + Init.Longitude = _Longitude + Init.Pitch = _Pitch + Init.Bank = _Bank + Init.Heading = _Heading + Init.OnGround = _OnGround + Init.Airspeed = _Airspeed + + if self.DEFINITION_POS is None: + self.DEFINITION_POS = self.new_def_id() + err = self.dll.AddToDataDefinition( + self.hSimConnect, + self.DEFINITION_POS.value, + b'Initial Position', + b'', + SIMCONNECT_DATATYPE.SIMCONNECT_DATATYPE_INITPOSITION, + 0, + SIMCONNECT_UNUSED, + ) + + hr = self.dll.SetDataOnSimObject( self.hSimConnect, self.DEFINITION_POS.value, - b'Initial Position', - b'', - SIMCONNECT_DATATYPE.SIMCONNECT_DATATYPE_INITPOSITION, + SIMCONNECT_OBJECT_ID_USER, 0, - SIMCONNECT_UNUSED, + 0, + sizeof(Init), + pointer(Init) ) - - hr = self.dll.SetDataOnSimObject( - self.hSimConnect, - self.DEFINITION_POS.value, - SIMCONNECT_OBJECT_ID_USER, - 0, - 0, - sizeof(Init), - pointer(Init) - ) - if self.IsHR(hr, 0): - return True - else: + if self.IsHR(hr, 0): + return True return False def load_flight(self, flt_path): - hr = self.dll.FlightLoad(self.hSimConnect, flt_path.encode()) - if self.IsHR(hr, 0): - return True - else: + with self._api_lock: + hr = self.dll.FlightLoad(self.hSimConnect, flt_path.encode()) + if self.IsHR(hr, 0): + return True return False def load_flight_plan(self, pln_path): - hr = self.dll.FlightPlanLoad(self.hSimConnect, pln_path.encode()) - if self.IsHR(hr, 0): - return True - else: + with self._api_lock: + hr = self.dll.FlightPlanLoad(self.hSimConnect, pln_path.encode()) + if self.IsHR(hr, 0): + return True return False def save_flight( @@ -400,10 +989,10 @@ def save_flight( flt_mission_location='Custom departure', flt_original_flight='', flt_flight_type='NORMAL'): - hr = self.dll.FlightSave(self.hSimConnect, flt_path.encode(), flt_title.encode(), flt_description.encode(), 0) - if not self.IsHR(hr, 0): - return False - + with self._api_lock: + hr = self.dll.FlightSave(self.hSimConnect, flt_path.encode(), flt_title.encode(), flt_description.encode(), 0) + if not self.IsHR(hr, 0): + return False dicp = self.flight_to_dic(flt_path) if 'MissionType' not in dicp['Main']: dicp['Main']['MissionType'] = flt_mission_type @@ -420,12 +1009,42 @@ def save_flight( return False + def refresh_pause_state(self, timeout: float = 0.15): + """向模拟器查询当前暂停状态,并同步 ``self.paused``。""" + if self.quit: + return None + done = threading.Event() + result = {"paused": None} + + def _on_result(paused: bool) -> None: + result["paused"] = bool(paused) + done.set() + + with self._api_lock: + self._pause_state_waiter = _on_result + try: + hr = self.dll.RequestSystemState( + self.hSimConnect, + self.dll.EventID.EVENT_SIM_PAUSED.value, + b"Sim", + ) + except Exception: + self._pause_state_waiter = None + return None + if not self.IsHR(hr, 0): + self._pause_state_waiter = None + return None + if done.wait(max(0.05, float(timeout))): + self._pause_state_waiter = None + paused = result["paused"] + if paused is not None: + self.paused = paused + return paused + self._pause_state_waiter = None + return None + def get_paused(self): - hr = self.dll.RequestSystemState( - self.hSimConnect, - self.dll.EventID.EVENT_SIM_PAUSED, - b"Sim" - ) + return self.refresh_pause_state() def dic_to_flight(self, dic, fpath): with open(fpath, "w") as tempfile: @@ -452,31 +1071,33 @@ def flight_to_dic(self, fpath): return dic def sendText(self, text, timeSeconds=5, TEXT_TYPE=SIMCONNECT_TEXT_TYPE.SIMCONNECT_TEXT_TYPE_PRINT_WHITE): - pyarr = bytearray(text.encode()) - dataarray = (ctypes.c_char * len(pyarr))(*pyarr) - pObjData = cast(dataarray, c_void_p) - self.dll.Text( - self.hSimConnect, - TEXT_TYPE, - timeSeconds, - 0, - sizeof(ctypes.c_double) * len(pyarr), - pObjData - ) + with self._api_lock: + pyarr = bytearray(text.encode()) + dataarray = (ctypes.c_char * len(pyarr))(*pyarr) + pObjData = cast(dataarray, c_void_p) + self.dll.Text( + self.hSimConnect, + TEXT_TYPE, + timeSeconds, + 0, + sizeof(ctypes.c_double) * len(pyarr), + pObjData + ) def createSimulatedObject(self, name, lat, lon, rqst, hdg=0, gnd=1, alt=0, pitch=0, bank=0, speed=0): - simInitPos = SIMCONNECT_DATA_INITPOSITION() - simInitPos.Altitude = alt - simInitPos.Latitude = lat - simInitPos.Longitude = lon - simInitPos.Pitch = pitch - simInitPos.Bank = bank - simInitPos.Heading = hdg - simInitPos.OnGround = gnd - simInitPos.Airspeed = speed - self.dll.AICreateSimulatedObject( - self.hSimConnect, - name.encode(), - simInitPos, - rqst.value - ) + with self._api_lock: + simInitPos = SIMCONNECT_DATA_INITPOSITION() + simInitPos.Altitude = alt + simInitPos.Latitude = lat + simInitPos.Longitude = lon + simInitPos.Pitch = pitch + simInitPos.Bank = bank + simInitPos.Heading = hdg + simInitPos.OnGround = gnd + simInitPos.Airspeed = speed + self.dll.AICreateSimulatedObject( + self.hSimConnect, + name.encode(), + simInitPos, + rqst.value + ) diff --git a/SimConnect/__init__.py b/SimConnect/__init__.py index ea8fd2ac..a0f8e922 100644 --- a/SimConnect/__init__.py +++ b/SimConnect/__init__.py @@ -1,7 +1,9 @@ from .SimConnect import SimConnect, millis, DWORD from .RequestList import AircraftRequests, Request -from .EventList import AircraftEvents, Event -from .FacilitiesList import FacilitiesRequests, Facilitie +from .EventList import Event + +# 对外暴露本应用使用的接口。AircraftEvents / FacilitiesRequests 仍在子模块内, +# 需要时用:from SimConnect.EventList import AircraftEvents 等。 def int_or_str(value): @@ -14,4 +16,4 @@ def int_or_str(value): __version__ = "0.4.26" VERSION = tuple(map(int_or_str, __version__.split("."))) -__all__ = ["SimConnect", "Request", "Event", "millis", "DWORD", "AircraftRequests", "AircraftEvents", "FacilitiesRequests"] +__all__ = ["SimConnect", "Request", "Event", "millis", "DWORD", "AircraftRequests"] diff --git a/_make_app_icon_ico.py b/_make_app_icon_ico.py new file mode 100644 index 00000000..f266dda4 --- /dev/null +++ b/_make_app_icon_ico.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +"""Generate multi-size icon.ico from icon.png for Windows exe shell.""" +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +PNG = ROOT / "resources" / "icons" / "icon.png" +ICO = ROOT / "resources" / "icons" / "icon.ico" +SIZES = (256, 128, 64, 48, 32, 24, 16) + + +def main() -> int: + if not PNG.is_file(): + print("[ERROR] missing", PNG) + return 1 + try: + from PIL import Image + except ImportError: + print("[ERROR] Pillow not installed in this env") + return 1 + try: + im = Image.open(PNG).convert("RGBA") + resample = getattr(getattr(Image, "Resampling", Image), "LANCZOS", Image.LANCZOS) + icons = [im.resize((s, s), resample) for s in SIZES] + icons[0].save( + ICO, + format="ICO", + sizes=[(s, s) for s in SIZES], + append_images=icons[1:], + ) + except Exception as exc: + print("[ERROR]", exc) + return 1 + print("OK", ICO, "bytes", ICO.stat().st_size) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/aero_quiz_base_user_info.sql b/aero_quiz_base_user_info.sql new file mode 100644 index 00000000..78539b26 --- /dev/null +++ b/aero_quiz_base_user_info.sql @@ -0,0 +1,52 @@ +/* + Navicat Premium Data Transfer + + Source Server : 阿里云ECS - Prod_Env - root权限 + Source Server Type : MySQL + Source Server Version : 80041 + Source Host : 47.109.72.25:3306 + Source Schema : talent_auth_info_schema + + Target Server Type : MySQL + Target Server Version : 80041 + File Encoding : 65001 + + Date: 11/09/2026 19:45:51 +*/ + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- ---------------------------- +-- Table structure for aero_quiz_base_user_info +-- ---------------------------- +DROP TABLE IF EXISTS `aero_quiz_base_user_info`; +CREATE TABLE `aero_quiz_base_user_info` ( + `user_id` char(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL, + `uuid` char(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL, + `user_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL, + `email` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL, + `phone` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL, + `password_hash` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL, + `status` enum('active','disabled') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'disabled', + `premium` enum('yes','no') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'no', + `premium_at` datetime NULL DEFAULT NULL, + `premium_expires_at` datetime NULL DEFAULT NULL, + `activation_token` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL, + `activation_token_expires_at` datetime NULL DEFAULT NULL, + `password_reset_payload` json NULL, + `user_defined_1` json NULL, + `user_defined_2` json NULL, + `user_defined_3` json NULL, + `user_defined_4` json NULL, + `user_defined_5` json NULL, + `last_login_at` datetime NULL DEFAULT NULL, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`user_id`) USING BTREE, + UNIQUE INDEX `user_id`(`user_id` ASC) USING BTREE, + UNIQUE INDEX `uuid`(`uuid` ASC) USING BTREE, + UNIQUE INDEX `user_name`(`user_name` ASC) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic; + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/app_bootstrap.py b/app_bootstrap.py new file mode 100644 index 00000000..6c1bc536 --- /dev/null +++ b/app_bootstrap.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +""" +模块:app_bootstrap +职责:进程级启动引导;必须在 QApplication 实例化之前完成高 DPI 声明 +依赖:PySide2(仅 QtCore/QtGui 属性 API,不创建 QApplication) + +顺序(不可打乱): + 1. HighDpiScaleFactorRoundingPolicy.PassThrough — 支持 125%/150% 等非整数缩放,避免取整导致布局错位 + 2. AA_EnableHighDpiScaling — 让 Qt 按系统 DPI 做逻辑像素缩放(勿在 QApplication 之后再设) + 3. AA_UseHighDpiPixmaps — 图标/位图按 devicePixelRatio 选高清资源,减轻糊边 + +说明:本模块不替代 UITheme.apply_scale(窗口相对设计稿的适配倍率);二者职责分离。 +""" + +from __future__ import annotations + +_BOOTSTRAPPED = False + + +def apply_qt_high_dpi() -> None: + """ + 在首次创建 QApplication 之前调用;重复调用安全(幂等)。 + 此处需人工验证:100%/125%/150%/200% 下主窗与对话框是否清晰、点击命中是否正常。 + """ + global _BOOTSTRAPPED + if _BOOTSTRAPPED: + return + + from PySide2.QtCore import Qt, QCoreApplication + from PySide2.QtGui import QGuiApplication + + # 1) 舍入策略:必须优先于 AA_*,否则非整数缩放过仍可能被 Round 截断 + policy = getattr(Qt, "HighDpiScaleFactorRoundingPolicy", None) + if policy is not None and hasattr(QGuiApplication, "setHighDpiScaleFactorRoundingPolicy"): + QGuiApplication.setHighDpiScaleFactorRoundingPolicy(policy.PassThrough) + + # 2) 启用高 DPI 缩放(PySide2 5.15 仍依赖该属性;单独启用而不配 PassThrough 会在 125%/150% 下失真) + QCoreApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) + + # 3) 高 DPI 像素图:与缩放配套,避免工具栏/窗口图标被系统拉伸发糊 + QCoreApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) + + _BOOTSTRAPPED = True + + +def apply_windows_dpi_aware_for_pygame() -> None: + """ + Windows 专属:在 import/初始化 pygame 之前声明进程 DPI 感知,减轻 SDL 被系统位图拉伸。 + 当前工程使用 SDL_VIDEODRIVER=dummy(无真实 SDL 窗),仍按白名单规范补齐调用时机。 + 若 Qt 已通过 AA_EnableHighDpiScaling 设置过感知级别,本调用可能无效——属预期,不视为错误。 + """ + import sys + + if sys.platform != "win32": + return + try: + import ctypes + + # Windows-only:SetProcessDPIAware(旧 API,兼容 Win7+;不引入 pywin32) + ctypes.windll.user32.SetProcessDPIAware() + except Exception: + pass diff --git a/app_controller.py b/app_controller.py new file mode 100644 index 00000000..44cc049f --- /dev/null +++ b/app_controller.py @@ -0,0 +1,2306 @@ +# -*- coding: utf-8 -*- +"""应用编排:自检 → 登录 → 训练助手(SimConnect / 鉴权 / 轮询)。""" + +from __future__ import annotations + +import threading +import time +from typing import Optional + +from PySide2.QtCore import QEvent, QObject, QTimer, Signal +from PySide2.QtWidgets import QApplication, QWidget +from SimConnect import Event + +from components.Frame.Frame_Dialog_MissionSwitch import show_mission_switch +from components.Frame.Frame_Dialog_RaceSuccess import show_race_success_result +from components.Frame.Frame_Dialog_SimNotFound import show_sim_not_found +from components.Frame.Frame_Dialog_SimulatorRoot import show_simulator_root_setup +from components.Frame.Frame_Dialog_StartMonitoring import show_start_monitoring +from components.Frame.Frame_Dialog_ThemeAlert import show_theme_alert +from components.Frame.Frame_Dialog_TrackStorePick import ( + Frame_Dialog_TrackStoreWait, + show_track_store_make_space, +) +from components.Frame.Frame_Dialog_TrajectorySave import show_trajectory_save_result +from components.Frame.Frame_Theme_UI import format_lap_time +from setting import ( + LAP_TABLE_ROWS, + MSG_FSX_ROOT_INVALID, + MSG_FSX_ROOT_SAVE_FAIL, + MSG_MISSION_SWITCH_FAIL, + MSG_MISSION_SWITCH_NO_FSX_ROOT, + MSG_MISSION_SWITCH_NO_SIM, + MSG_TRACK_STORE_NEED_LOGIN, + MSG_TRACK_STORE_SAVE_FAIL, + MSG_TRACK_STORE_SAVE_OK_ID, + MSG_TRACK_STORE_SAVING, + TRACK_STORE_MAX_RECORDS, +) +from models.runtime_watch import aircraft_has_crashed +from models.shadow_plane import ( + SHADOW_LEAD_DEFAULT_S, + TrajectoryRecorder, + launch_shadow_follow, +) +from models.track_store import ( + TrackStoreFull, + build_pending_trajectory, + format_track_id_short, + list_track_metas, + load_trajectory_record, + save_trajectory_record, + track_result_from_score, +) +from models.track_acquire_worker import TrackAcquireWorker +from models.chart import TRAINING_CHART +from models.consume_cache import SessionReadCache +from models.hardware_monitor import poll_stick, reload_stick, stick_record_fields +from models.lap_reference import LAP_COUNT, lap_penalties_from_session, laps_from_session +from models.mission_switch import ( + resolve_canonical_mission_display, + resolve_canonical_mission_key, +) +from models.state_bus import ( + GROUP_CHART, + GROUP_LIVE, + GROUP_RUNTIME, + GROUP_SESSION, + GROUP_STICK, + GROUP_TRACK, + KEY_ACQUIRE_START, + KEY_BANK, + KEY_CHT, + KEY_GROUNDSPEED, + KEY_HEALTH, + KEY_MISSION_TITLE, + KEY_MP_ACTIVE, + KEY_PITCH, + KEY_SEQ, + KEY_SESSION_MODE, + KEY_STICK_AXES, + KEY_AIRSPEED, + KEY_TORQUE, + StateBus, +) +from services.auth import ( + AuthFailed, + AuthUser, + check_auth_server_online, + check_network_online, + try_auth_login, +) +from models.data_bridge import derive_prev_from_snap, extract, read_aq_snapshot +from services.simulator import ( + CHT_VAR, + CHECK_ITEM_MIN_S, + COLD_CABIN_DELAY_MS, + DAMAGE_VAR, + DATA_POLL_MS, + WAITING_POLL_MS, + health_ratio_from_damage, + is_plane_moved, + read_simulation_rate, + chart_elapsed_time_s, + SIM_RATE_MAX_STEPS, + SIM_RATE_STEP_MS, + TARGET_CHT_F, + TARGET_SIM_RATE, + TORQUE_VAR, + c_to_f, + f_to_c, + normalize_flt_path, + parse_flt_mission_type, + parse_mission_title, + safe_disconnect_simulator, + try_connect_simulator, +) +from pages.page_auth import AuthPage +from pages.page_sim_check import SimCheckPage +from pages.page_training_assist import TrainingAssistPage + + +class _UiBridge(QObject): + check_item = Signal(int, str, str) + check_action = Signal(str, bool) + check_step_failed = Signal(int, str) + check_all_passed = Signal(object) + check_fsx_root_gate = Signal() + login_finished = Signal(object) + sim_connected = Signal(object, object, object, object) + sim_not_found = Signal() + shadow_follow_done = Signal(bool, str) + race_lap = Signal(int, float, float) + race_end = Signal(int, float, float) + flight_loaded = Signal(str) + mission_completed = Signal(int) + mp_server_started = Signal() + mp_client_started = Signal() + mp_session_ended = Signal() + custom_action = Signal(str) + + +class _SaveTrackBridge(QObject): + """航迹云存后台线程 → UI:meta / err / need_space_metas。""" + + finished = Signal(object, object, object) + + +class AppController(QObject): + """驱动 Frame_Shell_AppMain 三阶段流程与训练业务逻辑。""" + + def __init__(self, shell): + super().__init__() + self.shell = shell + self._bridge = _UiBridge() + self._closing = False + self._phase = "sim_check" + + self._sim_connection = None + self._detecting_check = False + self._check_ready = False + self._check_closing = False + + self._logging_in = False + self._login_result = None + self._auth_user: Optional[AuthUser] = None + + self.sm = None + self.aq = None + self.sim_rate_incr_event = None + self.sim_rate_decr_event = None + self.pause_on_event = None + self.situation_reset_event = None + + self._consume = SessionReadCache(TRAINING_CHART) + self._derive_prev_snap: Optional[dict] = None + self._bus = StateBus(parent=self) + self._bus.start() + self._chart_seq = 0 + self._track_seq = 0 + self._training_wired = False + self._bus_ui_bound = False + self._sim_dialog_shown = False + self._detecting_sim = False + self._preconnected = False + self._runtime = self._bus.lifecycle + self._baseline_lat = None + self._baseline_lon = None + self._lap_times = [] + self._lap_count = 0 + self._race_finished = False + self._race_events_bound = False + self._session_events_bound = False + self._session_mode = "freeflight" + self._mp_active = False + self._mission_title = "" + self._mission_display_name = "" + self._mission_key = "" + self._last_flt_path = "" + self._live_readouts_enabled = False + self._trajectory_recorder = TrajectoryRecorder() + self._acquire_worker = TrackAcquireWorker(queue_size=8) + self._track_save_busy = False + self._shadow_controller = None + self._shadow_follow_busy = False + self._shadow_lead_s = SHADOW_LEAD_DEFAULT_S + self._shadow_smoke_enabled = True + self._shadow_trajectory_record_id = "" + self._shadow_trajectory_label = "" + self._shadow_cold_cabin_enabled = False + self._monitor_cold_cabin_enabled = False + self._monitor_shadow_follow_enabled = False + self._suspend_sim_poll = False + self._chart_time_origin = None + + self._sim_rate_target = None + self._sim_rate_steps_left = 0 + + self._poll_timer = QTimer(self) + self._poll_timer.timeout.connect(self._poll) + self._sim_rate_timer = QTimer(self) + self._sim_rate_timer.timeout.connect(self._sim_rate_step) + self._login_poll_timer = QTimer(self) + self._login_poll_timer.timeout.connect(self._poll_login) + + self._wire_bridge() + self._wire_pages() + self._bus.hub.subscribe(GROUP_RUNTIME, self._on_bus_runtime, period_ms=0) + self._bus_commit_session() + self._bus_commit_runtime() + + def _on_bus_runtime(self, _group, snap, _ver) -> None: + """业务对齐获取态:影子机回放仅在 acquire_start 时推进。""" + ctrl = self._shadow_controller + if ctrl is None or getattr(ctrl, "object_id", None) is None: + return + paused = not bool(snap.get(KEY_ACQUIRE_START)) + try: + ctrl.set_replay_paused(paused) + except Exception: + pass + + def _wire_bridge(self) -> None: + b = self._bridge + b.check_item.connect(self._on_check_item) + b.check_action.connect(self._on_check_action) + b.check_step_failed.connect(self._on_check_step_failed) + b.check_all_passed.connect(self._on_check_all_passed) + b.check_fsx_root_gate.connect(self._on_check_fsx_root_gate) + b.login_finished.connect(self._on_login_finished) + b.sim_connected.connect(self._on_sim_connected) + b.sim_not_found.connect(self._on_sim_not_found) + b.shadow_follow_done.connect(self._on_shadow_follow_done) + b.race_lap.connect(self._apply_race_lap) + b.race_end.connect(self._apply_race_end) + b.flight_loaded.connect(self._apply_flight_loaded) + b.mission_completed.connect(self._apply_mission_completed) + b.mp_server_started.connect(self._apply_mp_server_started) + b.mp_client_started.connect(self._apply_mp_client_started) + b.mp_session_ended.connect(self._apply_mp_session_ended) + b.custom_action.connect(self._apply_custom_action) + + def _wire_pages(self) -> None: + sim = self._sim_page() + if sim is not None: + sim.actionClicked.connect(self._on_sim_check_action) + auth = self._auth_page() + if auth is not None: + auth.loginRequested.connect(self._on_login_requested) + self._wire_training_page() + self.shell.installEventFilter(self) + + def _wire_training_page(self) -> None: + if self._training_wired: + return + train = self._train_page() + if train is None: + return + train.startMonitorClicked.connect(self._on_start_monitor) + train.rateClicked.connect(self._on_sim_rate) + train.monitorClicked.connect(self._on_monitor_action) + train.switchMissionClicked.connect(self._on_switch_mission) + self._bind_train_state_bus(train) + self._training_wired = True + self._sync_mission_switch_button() + + def _bind_train_state_bus(self, train) -> None: + if self._bus_ui_bound: + return + train.bind_state_bus( + self._bus, + history_provider=lambda: self._consume.history, + track_provider=lambda: list(self._consume.track_points), + consume_provider=lambda: self._consume, + ) + self._bus_ui_bound = True + + @property + def history(self): + return self._consume.history + + def _bus_commit_session(self) -> None: + self._bus.commit( + GROUP_SESSION, + { + KEY_SESSION_MODE: self._session_mode, + KEY_MISSION_TITLE: self._mission_title, + KEY_MP_ACTIVE: bool(self._mp_active), + }, + ) + + def _bus_commit_runtime(self, paused: Optional[bool] = None) -> None: + """将获取暂停传感器结果同步进生命周期(权威在 DataLifecycle)。""" + if paused is None: + paused = bool(self._runtime.pause_detector.is_paused) + if self._runtime.is_active(): + self._runtime.apply_acquire_paused(bool(paused)) + else: + self._runtime.publish() + + def _sync_save_pending(self) -> None: + self._runtime.mark_save_pending(self._trajectory_recorder.has_pending_samples) + + def _bus_commit_live(self, metrics: dict) -> None: + payload = { + KEY_CHT: metrics.get("cht"), + KEY_TORQUE: metrics.get("torque"), + KEY_HEALTH: metrics.get("health"), + KEY_AIRSPEED: metrics.get("airspeed"), + KEY_GROUNDSPEED: metrics.get("groundspeed"), + KEY_PITCH: metrics.get("pitch"), + KEY_BANK: metrics.get("bank"), + } + for fid in ("eta_prop", "di_proxy", "vg_course"): + if fid in metrics: + payload[fid] = metrics.get(fid) + self._bus.commit(GROUP_LIVE, payload) + + def _bus_bump_chart_track(self) -> None: + self._chart_seq += 1 + self._track_seq += 1 + self._bus.commit(GROUP_CHART, {KEY_SEQ: self._chart_seq}) + self._bus.commit(GROUP_TRACK, {KEY_SEQ: self._track_seq}) + + def _ensure_training_page(self) -> None: + """自检窗已显示后再构建训练页,避免 Charts/pygame 抢先闪顶层窗。""" + from pages import register_training_feature + + register_training_feature(self.shell) + self._wire_training_page() + + def eventFilter(self, obj, event): + if obj is self.shell and event.type() == QEvent.Close: + self._on_close() + return super().eventFilter(obj, event) + + def _sim_page(self) -> Optional[SimCheckPage]: + page = self.shell.registry.get("flow.sim_check") + return page if isinstance(page, SimCheckPage) else None + + def _auth_page(self) -> Optional[AuthPage]: + page = self.shell.registry.get("flow.auth") + return page if isinstance(page, AuthPage) else None + + def _train_page(self) -> Optional[TrainingAssistPage]: + page = self.shell.registry.get("training_assist") + return page if isinstance(page, TrainingAssistPage) else None + + def start(self) -> None: + self._phase = "sim_check" + self.shell.show_feature("flow.sim_check") + self.shell.show() + # 训练页(Charts/pygame)不要在自检阶段预热,否则会在初始化页再闪一窗 + QTimer.singleShot(180, self._start_check) + + # ---- SimCheck ---- + + def _start_check(self) -> None: + if self._detecting_check or self._check_closing or self._closing: + return + self._detecting_check = True + self._check_ready = False + if self._sim_connection: + safe_disconnect_simulator(self._sim_connection[0]) + self._sim_connection = None + page = self._sim_page() + if page is not None: + page.set_action("START", False) + page.set_busy(True) + for i in range(3): + page.set_item(i, "idle", "等待") + threading.Thread(target=self._check_worker, daemon=True).start() + + def _check_worker(self) -> None: + """网络 → 鉴权;随后主线程合并检测「模拟器」(根目录 + 进程连接)。""" + steps = ( + (0, "检测中", "OK", "无法连接外网", check_network_online), + (1, "检测中", "OK", "服务器不可达", check_auth_server_online), + ) + for index, running, ok_text, fail_text, probe in steps: + if self._check_closing: + return + self._bridge.check_item.emit(index, "run", running) + started = time.monotonic() + ok = False + try: + ok = bool(probe()) + except Exception: + ok = False + remain = CHECK_ITEM_MIN_S - (time.monotonic() - started) + if remain > 0: + time.sleep(remain) + if self._check_closing: + return + if not ok: + self._bridge.check_step_failed.emit(index, fail_text) + return + self._bridge.check_item.emit(index, "ok", ok_text) + + if self._check_closing: + return + # 第 3 项「模拟器」:主线程先确认根目录,再连进程 + self._bridge.check_fsx_root_gate.emit() + + def _on_check_fsx_root_gate(self) -> None: + """「模拟器」合并项:路径缺失弹窗;路径有效后再检测是否已启动。""" + if self._check_closing or self._closing: + self._detecting_check = False + return + page = self._sim_page() + if page is not None: + page.set_item(2, "run", "检测中") + if not self._ensure_fsx_root_pre_sim(): + if self._check_closing or self._closing: + self._detecting_check = False + return + self._on_check_step_failed(2, MSG_MISSION_SWITCH_NO_FSX_ROOT) + return + if self._check_closing or self._closing: + self._detecting_check = False + return + # 路径已就绪:同一行继续检测运行中的 FSX + threading.Thread(target=self._check_sim_worker, daemon=True).start() + + def _check_sim_worker(self) -> None: + conn = None + if self._check_closing: + return + self._bridge.check_item.emit(2, "run", "检测中") + started = time.monotonic() + try: + conn = try_connect_simulator() + ok = True + except Exception: + ok = False + remain = CHECK_ITEM_MIN_S - (time.monotonic() - started) + if remain > 0: + time.sleep(remain) + if self._check_closing: + if conn: + safe_disconnect_simulator(conn[0]) + return + if not ok: + self._bridge.check_step_failed.emit(2, "未侦测到运行中的FSX程序") + return + self._bridge.check_all_passed.emit(conn) + + def _on_check_item(self, index: int, state: str, detail: str) -> None: + page = self._sim_page() + if page is not None: + page.set_item(index, state, detail) + + def _on_check_action(self, text: str, enabled: bool) -> None: + page = self._sim_page() + if page is not None: + page.set_action(text, enabled) + page.set_busy(False) + + def _on_check_step_failed(self, index: int, detail: str) -> None: + self._detecting_check = False + if self._check_closing: + return + page = self._sim_page() + if page is not None: + page.set_item(index, "fail", detail) + for later in range(index + 1, 3): + page.set_item(later, "idle", "未检测") + page.set_action("重试", True) + page.set_busy(False) + + def _on_check_all_passed(self, conn) -> None: + self._detecting_check = False + if self._check_closing: + safe_disconnect_simulator(conn[0]) + return + self._sim_connection = conn + self._check_ready = True + page = self._sim_page() + if page is not None: + page.set_item(2, "ok", "OK") + page.set_action("START", True) + page.set_busy(False) + + def _on_sim_check_action(self) -> None: + if self._detecting_check: + return + if self._check_ready: + self._on_sim_check_start() + return + self._start_check() + + def _on_sim_check_start(self) -> None: + if not self._check_ready or not self._sim_connection: + return + self._phase = "auth" + self.shell.show_feature("flow.auth") + + # ---- Auth ---- + + def _on_login_requested(self, username: str, password: str) -> None: + if self._logging_in: + return + auth = self._auth_page() + username = (username or "").strip() + if not username: + if auth is not None: + auth.set_status("请输入用户名", "error") + return + if not password: + if auth is not None: + auth.set_status("请输入密码", "error") + return + self._logging_in = True + self._login_result = None + if auth is not None: + auth.set_login_enabled(False) + auth.set_status("正在登录…") + threading.Thread(target=self._login_worker, args=(username, password), daemon=True).start() + self._login_poll_timer.start(50) + + def _login_worker(self, username: str, password: str) -> None: + try: + user = try_auth_login(username, password) + self._login_result = ("ok", user) + except Exception as exc: + self._login_result = ("fail", exc) + + def _poll_login(self) -> None: + if self._login_result is None: + return + self._login_poll_timer.stop() + status, payload = self._login_result + self._login_result = None + self._bridge.login_finished.emit((status, payload)) + + def _on_login_finished(self, result) -> None: + status, payload = result + if status == "ok": + self._on_login_ok(payload if isinstance(payload, AuthUser) else None) + return + self._on_login_failed(payload) + + def _format_login_error(self, exc) -> str: + if isinstance(exc, AuthFailed): + from setting import MSG_AUTH_LOGIN_FAILED + return MSG_AUTH_LOGIN_FAILED + msg = str(exc).strip() + low = msg.lower() + if "1045" in msg or "access denied" in low: + return "鉴权服务账号无效" + if "1049" in msg or "unknown database" in low: + return "鉴权库不存在" + if "1146" in msg or "doesn't exist" in low: + return "鉴权用户表不存在" + if "1054" in msg or "unknown column" in low: + return "鉴权表字段不匹配" + if "2003" in msg or "can't connect" in low or "timed out" in low or "timeout" in low: + return "无法连接鉴权服务器" + if "passlib" in low: + return "缺少 passlib,请先安装" + if "pymysql" in low or "dbutils" in low or "dotenv" in low: + return "缺少数据库依赖,请先安装 pymysql / DBUtils / python-dotenv" + if "caching_sha2_password" in low or "auth plugin" in low: + return "鉴权库认证插件不兼容" + return msg or f"登录失败:{type(exc).__name__}" + + def _on_login_failed(self, exc) -> None: + self._logging_in = False + auth = self._auth_page() + if auth is not None: + auth.set_login_enabled(True) + auth.set_status(self._format_login_error(exc), "error") + + def _on_login_ok(self, user: Optional[AuthUser] = None) -> None: + self._logging_in = False + self._auth_user = user + conn = self._sim_connection + self._sim_connection = None + if conn: + self.sm, self.aq, self.sim_rate_incr_event, self.sim_rate_decr_event = conn + self._preconnected = True + # 先拉 UD4 / 绑定设备,再建训练页(避免空 device 配置覆盖自动选杆) + from models.hardware_monitor import ( + bind_session_device, + load_config_from_cloud, + set_settings_user_id, + ) + from models.mission_switch import ( + fsx_root_configured, + resolve_fsx_root_auto, + ) + + if user is not None: + set_settings_user_id(user.user_id) + try: + load_config_from_cloud(user.user_id) + except Exception: + pass + bind_session_device(persist_if_logged_in=True) + # 根目录已在自检阶段解析;登录后仅把会话路径同步进 UD4(或补拉云端) + self._persist_fsx_root_after_login(user.user_id) + if not fsx_root_configured(): + resolve_fsx_root_auto(user.user_id, persist=True) + else: + set_settings_user_id(None) + bind_session_device(persist_if_logged_in=False) + self._phase = "training" + self._ensure_training_page() + train = self._train_page() + if train is not None and user is not None: + train.set_auth_user_id(user.user_id) + self.shell.show_feature("training_assist") + self._start_training() + + def _ensure_fsx_root_pre_sim(self) -> bool: + """ + 模拟器连接前:会话已有 → OK;否则自动侦测; + 仍失败才弹窗「请设定模拟器根目录路径」(此时尚未登录,只写会话)。 + """ + from models.mission_switch import ( + fsx_root_configured, + is_valid_fsx_root, + resolve_fsx_root_auto, + set_session_fsx_root, + ) + + if fsx_root_configured(): + return True + if resolve_fsx_root_auto(None, persist=False): + return True + initial = "" + while True: + chosen = show_simulator_root_setup(self.shell, initial_path=initial) + if chosen is None: + return False + if not is_valid_fsx_root(chosen): + show_theme_alert( + self.shell, + title="提示", + message=MSG_FSX_ROOT_INVALID, + kind="warn", + ) + initial = chosen + continue + set_session_fsx_root(chosen) + return True + + def _persist_fsx_root_after_login(self, user_id: str) -> None: + """登录后:若会话根目录有效,写回 UD4(与云端不一致时覆盖)。""" + from models.mission_switch import ( + fsx_root_configured, + get_session_fsx_root, + is_valid_fsx_root, + normalize_fsx_root, + ) + from models.user_settings_store import get_fsx_root, set_fsx_root + + uid = str(user_id or "").strip() + if not uid or not fsx_root_configured(): + return + path_text = str(get_session_fsx_root()) + try: + stored = get_fsx_root(uid) or "" + except Exception: + stored = "" + if is_valid_fsx_root(stored) and normalize_fsx_root(stored) == normalize_fsx_root( + path_text + ): + return + try: + set_fsx_root(uid, path_text) + except Exception: + pass + + def _ensure_fsx_root_for_mission(self) -> bool: + """任务切换时补齐根目录:自动 → 弹窗;已登录则写 UD4。""" + from models.mission_switch import ( + fsx_root_configured, + is_valid_fsx_root, + resolve_fsx_root_auto, + set_session_fsx_root, + ) + from models.user_settings_store import UserSettingsError, set_fsx_root + + if fsx_root_configured(): + return True + uid = self._auth_user_id() + if resolve_fsx_root_auto(uid, persist=bool(uid)): + return True + initial = "" + while True: + chosen = show_simulator_root_setup(self.shell, initial_path=initial) + if chosen is None: + return False + if not is_valid_fsx_root(chosen): + show_theme_alert( + self.shell, + title="提示", + message=MSG_FSX_ROOT_INVALID, + kind="warn", + ) + initial = chosen + continue + if uid: + try: + set_fsx_root(uid, chosen) + except UserSettingsError: + show_theme_alert( + self.shell, + title="提示", + message=MSG_FSX_ROOT_SAVE_FAIL, + kind="error", + ) + initial = chosen + continue + except Exception: + show_theme_alert( + self.shell, + title="提示", + message=MSG_FSX_ROOT_SAVE_FAIL, + kind="error", + ) + initial = chosen + continue + set_session_fsx_root(chosen) + return True + + def _auth_user_id(self) -> Optional[str]: + user = self._auth_user + if user is None: + return None + uid = str(user.user_id or "").strip() + return uid or None + + def _start_training(self) -> None: + train = self._train_page() + if self.sm: + self._init_sim_events() + self._bind_race_events(self.sm) + self._bind_session_events(self.sm) + reload_stick() + self._bus.acquire.set_ui_preview(True) + if self._preconnected: + QTimer.singleShot(100, self._on_preconnected) + else: + QTimer.singleShot(100, self._detect_simulator) + + # ---- Training ---- + + def _on_preconnected(self) -> None: + if self._closing: + return + if self.sm and self.sm.quit: + self._handle_sim_lost() + return + train = self._train_page() + if train is None: + return + self._sync_rate_button() + train.set_status("已连接模拟器", "ok") + self._sync_session_mode_ui() + self._sync_start_monitor_button() + + def _detect_simulator(self) -> None: + if self._detecting_sim or self._closing: + return + self._detecting_sim = True + train = self._train_page() + if train is not None: + train.set_status("正在检测模拟器...", "muted") + threading.Thread(target=self._detect_simulator_worker, daemon=True).start() + + def _detect_simulator_worker(self) -> None: + try: + conn = try_connect_simulator() + except (ConnectionError, OSError): + self._bridge.sim_not_found.emit() + return + self._bridge.sim_connected.emit(*conn) + + def _on_sim_connected(self, sm, aq, sim_rate_incr, sim_rate_decr) -> None: + self._detecting_sim = False + if self._closing: + safe_disconnect_simulator(sm) + return + self.sm = sm + self.aq = aq + self.sim_rate_incr_event = sim_rate_incr + self.sim_rate_decr_event = sim_rate_decr + self._init_sim_events() + self._bind_race_events(sm) + self._bind_session_events(sm) + train = self._train_page() + if train is None: + return + self._sync_rate_button() + train.set_status("已连接模拟器", "ok") + self._sync_session_mode_ui() + self._sync_start_monitor_button() + + def _on_sim_not_found(self) -> None: + self._detecting_sim = False + if self._closing: + return + train = self._train_page() + if train is not None: + train.set_status("未连接模拟器", "error") + self._show_sim_not_found_dialog() + + def _show_sim_not_found_dialog(self) -> None: + if self._sim_dialog_shown or self._closing: + return + self._sim_dialog_shown = True + show_sim_not_found(self.shell) + self._quit_app() + + def _chart_has_data(self) -> bool: + return len(self.history) > 0 + + def _is_shadow_active(self) -> bool: + ctrl = self._shadow_controller + return ctrl is not None and ctrl.object_id is not None + + def _refresh_monitor_button(self) -> None: + train = self._train_page() + if train is None: + return + if self._runtime.is_recording(): + train.set_monitor_button("stop") + elif self._runtime.is_waiting(): + train.set_monitor_button("pending") + elif ( + not self._runtime.is_active() + and self._chart_has_data() + and self._trajectory_recorder.has_pending_samples + ): + train.set_monitor_button("save") + else: + train.set_monitor_button(None) + self._sync_start_monitor_button() + + def _sync_start_monitor_button(self) -> None: + train = self._train_page() + if train is None: + return + ready = bool((self._mission_title or "").strip()) + train.set_start_monitor_mission_ready(ready) + self._sync_mission_switch_button() + + def _sync_mission_switch_button(self) -> None: + train = self._train_page() + if train is None: + return + # 监测中禁止切换 + train.set_mission_switch_enabled(not self._runtime.is_active()) + + def _on_switch_mission(self) -> None: + from models.mission_switch import fsx_root_configured + + train = self._train_page() + # 监测中按钮已禁用,此处不再弹 BUSY 提示 + if not self.sm or not self.aq: + show_theme_alert( + self.shell, + title="提示", + message=MSG_MISSION_SWITCH_NO_SIM, + kind="error", + ) + if train is not None: + train.set_status(MSG_MISSION_SWITCH_NO_SIM, "error") + return + if not fsx_root_configured(): + if not self._ensure_fsx_root_for_mission(): + show_theme_alert( + self.shell, + title="提示", + message=MSG_MISSION_SWITCH_NO_FSX_ROOT, + kind="warn", + ) + if train is not None: + train.set_status(MSG_MISSION_SWITCH_NO_FSX_ROOT, "warn") + return + entry = show_mission_switch(self.shell) + if entry is None: + return + # 列表已禁用缺失项,selected 必为 exists + path = str(entry.flt_path) + ok = False + try: + ok = bool(self.sm.load_flight(path)) + except Exception: + ok = False + if not ok: + show_theme_alert( + self.shell, + title="提示", + message=MSG_MISSION_SWITCH_FAIL, + kind="error", + ) + if train is not None: + train.set_status(MSG_MISSION_SWITCH_FAIL, "error") + return + # 不等 FlightLoaded:以所选 FLT 立刻刷新任务名/模式 + self._apply_flight_loaded(path) + # 切换列表的 label 就是 config.display_name;目录名即为 mission_key + self._mission_display_name = (entry.label or "").strip() + self._mission_key = resolve_canonical_mission_key( + flt_path=path, + mission_title=self._mission_title, + display_hint=self._mission_display_name, + ) + if train is not None: + train.set_status(f"已切换任务:{entry.label}", "ok") + + def _on_monitor_action(self) -> None: + train = self._train_page() + if self._runtime.is_active(): + had_shadow = ( + self._shadow_controller is not None + and self._shadow_controller.object_id is not None + ) + self._stop_monitoring(save=False) + if train is not None: + if had_shadow: + train.set_status("监测已停止,跟飞已结束", "warn") + else: + train.set_status("监测已停止", "warn") + self._refresh_monitor_button() + return + if self._trajectory_recorder.has_pending_samples: + self._save_pending_trajectory() + + def _race_yield_topmost(self, race_dlg) -> None: + fn = getattr(race_dlg, "yield_topmost", None) + if callable(fn): + fn() + + def _race_restore_topmost(self, race_dlg) -> None: + fn = getattr(race_dlg, "restore_topmost", None) + if callable(fn): + fn() + + def _save_pending_trajectory(self, dialog_parent=None) -> bool: + """ + 监测结束后点击保存:内存采样 → 后台增量写入 user_defined_3;成功后再清空缓冲。 + 返回 True 表示已受理(异步进行中;成绩框可禁用保存按钮); + False 表示未启动(未登录 / 无数据 / 已在保存)。 + """ + # 等待/结果框挂主壳,避免成绩弹窗先关导致子对话框被销毁 + parent = self.shell + race_dlg = dialog_parent if isinstance(dialog_parent, QWidget) else None + if self._track_save_busy: + return False + uid = self._auth_user_id() + if not uid: + self._race_yield_topmost(race_dlg) + show_theme_alert( + parent, + title="提示", + message=MSG_TRACK_STORE_NEED_LOGIN, + kind="warn", + show_title=True, + stays_on_top=True, + ) + self._race_restore_topmost(race_dlg) + train = self._train_page() + if train is not None: + train.set_status(MSG_TRACK_STORE_NEED_LOGIN, "error") + self._refresh_monitor_button() + return False + + samples = self._trajectory_recorder.samples_snapshot() + if not samples: + self._runtime.mark_save_flushed() + self._race_yield_topmost(race_dlg) + show_trajectory_save_result( + parent, + ok=False, + path=None, + message="无有效轨迹数据,未写入云端", + ) + self._race_restore_topmost(race_dlg) + train = self._train_page() + if train is not None: + train.set_status("轨迹保存失败:无有效数据", "error") + self._refresh_monitor_button() + return False + + net_s = self._total_flight_time_s() + penalty_s = self._total_penalty_s() + gross_s = None + if net_s is not None: + gross_s = float(net_s) + float(penalty_s or 0.0) + train = self._train_page() + score = train.displayed_score() if train is not None else None + mission_display = resolve_canonical_mission_display( + flt_path=self._last_flt_path, + mission_title=self._mission_title, + display_hint=self._mission_display_name, + mission_key=self._mission_key, + ) + mission_key = resolve_canonical_mission_key( + flt_path=self._last_flt_path, + mission_title=self._mission_title, + display_hint=mission_display or self._mission_display_name, + mission_key=self._mission_key, + ) + if mission_key: + self._mission_key = mission_key + status = track_result_from_score(score) + traj = build_pending_trajectory( + samples, + aircraft_title=getattr(self._trajectory_recorder, "_aircraft_title", "") or "", + mission_title=self._mission_title, + laps_s=laps_from_session(self._lap_times), + ) + laps_penalty_s = lap_penalties_from_session(self._lap_times) + save_kw = dict( + net_s=net_s, + total_s=net_s, + penalty_s=penalty_s, + gross_s=gross_s, + laps_penalty_s=laps_penalty_s, + session_mode=self._session_mode, + score=score, + mission_display_name=mission_display, + mission_key=mission_key, + status=status, + ) + + self._track_save_busy = True + if train is not None: + train.set_status(MSG_TRACK_STORE_SAVING, "muted") + + # 成绩框让出 TOPMOST,后续等待/结果/腾位框自行置顶压模拟器 + self._race_yield_topmost(race_dlg) + + wait = Frame_Dialog_TrackStoreWait(parent, message=MSG_TRACK_STORE_SAVING) + wait.show() + wait.raise_() + wait.activateWindow() + + bridge = _SaveTrackBridge(wait) + wait._save_bridge = bridge # 防 GC + + def _reenable_race_save() -> None: + fn = getattr(race_dlg, "_set_save_enabled", None) + if callable(fn): + fn(True) + + def _fail(msg: str) -> None: + self._track_save_busy = False + show_trajectory_save_result(parent, ok=False, message=msg) + train_page = self._train_page() + if train_page is not None: + train_page.set_status(msg, "error") + _reenable_race_save() + self._race_restore_topmost(race_dlg) + self._refresh_monitor_button() + + def _succeed(meta) -> None: + self._trajectory_recorder.discard() + self._runtime.mark_save_flushed() + self._track_save_busy = False + short_id = format_track_id_short(meta.record_id) + ok_msg = MSG_TRACK_STORE_SAVE_OK_ID.format(track_id=short_id) + show_trajectory_save_result( + parent, + ok=True, + path=None, + message=ok_msg, + ) + train_page = self._train_page() + if train_page is not None: + train_page.set_status(ok_msg.replace("\n", " "), "ok") + self._race_restore_topmost(race_dlg) + self._refresh_monitor_button() + + def _run_save(replace_ids=None) -> None: + try: + meta = save_trajectory_record( + uid, traj, replace_ids=replace_ids, **save_kw + ) + bridge.finished.emit(meta, None, None) + except TrackStoreFull: + try: + metas = list_track_metas(uid) + bridge.finished.emit(None, None, list(metas)) + except Exception as exc: + bridge.finished.emit(None, exc, None) + except Exception as exc: + bridge.finished.emit(None, exc, None) + + def _done(meta_obj, err_obj, need_space_obj) -> None: + if need_space_obj is not None: + wait.hide() + picked = show_track_store_make_space( + parent, + list(need_space_obj), + need_slots=1, + message=( + f"最多保存 {TRACK_STORE_MAX_RECORDS} 条。" + "请选择要覆盖的记录后继续保存。" + ), + ) + if not picked: + wait.close() + wait.deleteLater() + self._track_save_busy = False + show_trajectory_save_result( + parent, ok=False, message="已取消保存(云端航迹已满)" + ) + train_page = self._train_page() + if train_page is not None: + train_page.set_status("轨迹保存已取消", "warn") + _reenable_race_save() + self._race_restore_topmost(race_dlg) + self._refresh_monitor_button() + return + wait.show() + wait.raise_() + threading.Thread( + target=_run_save, args=(picked,), daemon=True + ).start() + return + + wait.close() + wait.deleteLater() + if err_obj is not None: + _fail(f"{MSG_TRACK_STORE_SAVE_FAIL}:{err_obj}") + return + _succeed(meta_obj) + + bridge.finished.connect(_done) + threading.Thread(target=_run_save, daemon=True).start() + return True + + def _handle_sim_lost(self) -> None: + if self._closing: + return + self._runtime.reset() + self._poll_timer.stop() + self._baseline_lat = None + self._baseline_lon = None + self._chart_time_origin = None + try: + self._trajectory_recorder.stop() + except Exception: + pass + self._mp_active = False + self._session_mode = "freeflight" + self._mission_title = "" + self._mission_display_name = "" + self._mission_key = "" + self._last_flt_path = "" + self._live_readouts_enabled = False + self._race_events_bound = False + self._session_events_bound = False + self._bus_commit_session() + safe_disconnect_simulator(self.sm) + self.sm = None + self.aq = None + train = self._train_page() + if train is not None: + train.set_monitor_button(None) + self._clear_live_readouts() + train.set_start_monitor_mission_ready(False) + self._show_sim_not_found_dialog() + + def _start_monitor_waiting(self) -> None: + if not self.aq or self._closing: + return + self._trajectory_recorder.discard() + self._runtime.enter_waiting() + self._baseline_lat = None + self._baseline_lon = None + self._chart_time_origin = None + self._reset_mission_tracking(update_labels=True) + self._clear_monitor_charts() + self._request_sim_pause() + _, self._baseline_lat, self._baseline_lon = is_plane_moved( + self.aq, None, None + ) + train = self._train_page() + if train is not None: + train.set_score_crashed(False) + self._set_run_status("等待解除暂停状态后开始监测...", "warn") + self._refresh_monitor_button() + self._refresh_session_info() + self._poll() + self._refresh_runtime_timers() + + def _set_run_status(self, text: str, kind: str = "muted") -> None: + """状态栏文案有变化才写,避免等待态每拍刷 UI。""" + key = (text, kind) + if getattr(self, "_last_run_status", None) == key: + return + self._last_run_status = key + train = self._train_page() + if train is not None: + train.set_status(text, kind) + + def _clear_monitor_charts(self) -> None: + """进入 WAITING 即清空图表/航迹/圈标,不等到正式录制。""" + self._consume.clear() + self._derive_prev_snap = None + self._chart_seq = 0 + self._track_seq = 0 + self._bus_bump_chart_track() + train = self._train_page() + if train is not None: + train.clear_lap_markers() + train.clear_track() + train.reset_track_filters() + train.resume_live_follow() + + def _stop_monitoring(self, save: bool = False) -> None: + was_recording = self._runtime.is_recording() + self._runtime.mark_save_pending(self._trajectory_recorder.has_pending_samples) + self._runtime.return_to_idle() + self._baseline_lat = None + self._baseline_lon = None + self._chart_time_origin = None + self._poll_timer.stop() + self._stop_acquire_worker() + if was_recording: + self._trajectory_recorder.stop() + if save and was_recording: + # 自动落库路径:与手动保存同一套云端写入 + self._save_pending_trajectory() + elif not self._trajectory_recorder.has_pending_samples: + self._runtime.mark_save_flushed() + self._disable_live_readouts() + self._stop_shadow_soft() + train = self._train_page() + if train is not None: + self._refresh_monitor_button() + train.leave_live_follow() + self._refresh_runtime_timers() + + def _refresh_runtime_timers(self) -> None: + if self._closing or not self.aq: + self._poll_timer.stop() + return + if self._runtime.should_poll(self._is_shadow_active(), self._live_readouts_enabled): + self._poll_timer.start(self._data_poll_interval_ms()) + else: + self._poll_timer.stop() + + def _data_poll_interval_ms(self) -> int: + """主线程调度周期;录制重读已在采集线程,主线程保持 DATA_POLL_MS 即可。""" + return int(DATA_POLL_MS) + + def _live_metrics_from_snap( + self, + snap: dict, + *, + cht_f: Optional[float], + torque: Optional[float], + health_pct: Optional[float], + ) -> dict: + def _finite(v): + try: + f = float(v) + except (TypeError, ValueError): + return None + return f if f == f else None + + return { + "cht": cht_f, + "torque": torque, + "health": health_pct, + "airspeed": _finite(snap.get("airspeed_kts")), + "groundspeed": _finite(snap.get("groundspeed_kts")), + "pitch": _finite(snap.get("pitch_deg")), + "bank": _finite(snap.get("bank_deg")), + } + + def _read_monitor_tick(self, flight_t: Optional[float] = None) -> tuple: + """一拍一次批量 RequestData:CHT/扭矩/损伤 + 快照字段。""" + from services.sim_batch import batch_get_sim_vars, monitor_snapshot_keys + + raw = batch_get_sim_vars(self.aq, monitor_snapshot_keys()) if self.aq else {} + cht_c = raw.get(CHT_VAR) + cht_f = c_to_f(cht_c) if cht_c is not None else None + torque_raw = raw.get(TORQUE_VAR) + torque = float(torque_raw) if torque_raw is not None else None + health_ratio = None + damage = raw.get(DAMAGE_VAR) + if damage is not None: + health_ratio = health_ratio_from_damage(damage) + health_pct = (float(health_ratio) * 100.0) if health_ratio is not None else None + snap = read_aq_snapshot( + self.aq, + torque=torque, + health_ratio=health_ratio, + timestamp_s=flight_t, + prev=self._derive_prev_snap if flight_t is not None else None, + raw=raw, + ) + metrics = self._live_metrics_from_snap( + snap, cht_f=cht_f, torque=torque, health_pct=health_pct + ) + return snap, metrics, torque, health_ratio + + def _refresh_live_metrics_once(self) -> None: + """暂停等轻量路径:一次批量只读仪表必要量。""" + if not self._live_readouts_enabled or not self.aq: + return + try: + from services.sim_batch import LIVE_GAUGE_KEYS, batch_get_sim_vars + from services.simulator import ( + AIRSPEED_VAR, + BANK_VAR, + GROUND_SPEED_VAR, + PITCH_VAR, + optional_float, + rad_to_deg, + ) + + raw = batch_get_sim_vars(self.aq, LIVE_GAUGE_KEYS) + cht_c = raw.get(CHT_VAR) + cht_f = c_to_f(cht_c) if cht_c is not None else None + torque_raw = raw.get(TORQUE_VAR) + torque = float(torque_raw) if torque_raw is not None else None + health_pct = None + damage = raw.get(DAMAGE_VAR) + if damage is not None: + hr = health_ratio_from_damage(damage) + health_pct = (float(hr) * 100.0) if hr is not None else None + ias_v = optional_float(raw.get(AIRSPEED_VAR)) + gs_v = optional_float(raw.get(GROUND_SPEED_VAR)) + pitch = rad_to_deg(raw.get(PITCH_VAR), None) + bank = rad_to_deg(raw.get(BANK_VAR), None) + self._bus_commit_live( + { + "cht": cht_f, + "torque": torque, + "health": health_pct, + "airspeed": float(ias_v) if ias_v is not None else None, + "groundspeed": float(gs_v) if gs_v is not None else None, + "pitch": float(pitch) if pitch is not None and pitch == pitch else None, + "bank": float(bank) if bank is not None and bank == bank else None, + } + ) + except Exception: + pass + + def _reset_mission_tracking(self, update_labels=True) -> None: + self._lap_count = 0 + self._lap_times = [] + self._race_finished = False + if update_labels: + self._refresh_mission_labels() + + def _lap_entry_by_number(self, lap_number): + for item in self._lap_times: + if int(item["lap"]) == int(lap_number): + return item + return None + + def _lap_time_by_number(self, lap_number): + entry = self._lap_entry_by_number(lap_number) + return entry["time_s"] if entry else None + + def _lap_penalty_delta_by_number(self, lap_number): + entry = self._lap_entry_by_number(lap_number) + if entry is None: + return None + if "penalty_delta_s" in entry: + delta = float(entry.get("penalty_delta_s") or 0.0) + return delta if delta > 0 else None + cumulative = float(entry.get("penalty_s") or 0.0) + prev_cumulative = 0.0 + for item in self._lap_times: + if int(item["lap"]) >= int(lap_number): + continue + prev_cumulative = float(item.get("penalty_s") or 0.0) + delta = cumulative - prev_cumulative + return delta if delta > 0 else None + + def _total_penalty_s(self): + if not self._lap_times: + return None + latest = max(self._lap_times, key=lambda x: int(x["lap"])) + penalty = float(latest.get("penalty_s") or 0.0) + return penalty if penalty > 0 else None + + def _total_flight_time_s(self): + total = 0.0 + has_any = False + for item in self._lap_times: + t = item.get("time_s") + if t is None: + continue + try: + value = float(t) + except (TypeError, ValueError): + continue + if value > 0 and value == value: + total += value + has_any = True + return total if has_any else None + + def _refresh_mission_labels(self) -> None: + train = self._train_page() + if train is None: + return + times = [] + penalties = [] + for i in range(LAP_TABLE_ROWS): + lap_no = i + 1 + times.append(self._lap_time_by_number(lap_no)) + penalties.append(self._lap_penalty_delta_by_number(lap_no)) + train.set_laps( + times, + self._total_flight_time_s(), + lap_penalties=penalties, + total_penalty=self._total_penalty_s(), + ) + self._sync_session_mode_ui() + + def _bind_race_events(self, sm) -> None: + if not sm or self._race_events_bound: + return + sm.add_race_lap_handler(self._on_race_lap_event) + sm.add_race_end_handler(self._on_race_end_event) + self._race_events_bound = True + + def _bind_session_events(self, sm) -> None: + if not sm or self._session_events_bound: + return + sm.add_flight_loaded_handler(self._on_flight_loaded_event) + sm.add_mission_completed_handler(self._on_mission_completed_event) + sm.add_mp_server_started_handler(self._on_mp_server_started_event) + sm.add_mp_client_started_handler(self._on_mp_client_started_event) + sm.add_mp_session_ended_handler(self._on_mp_session_ended_event) + sm.add_custom_action_handler(self._on_custom_action_event) + sm.request_flight_loaded() + self._session_events_bound = True + + def _set_session_mode(self, mode: str) -> None: + if mode not in ("freeflight", "mission_sp", "mission_mp"): + return + self._session_mode = mode + self._bus_commit_session() + + def _set_mission_title(self, title: Optional[str]) -> None: + text = (title or "").strip() + self._mission_title = text + # 统一成 Mission 列表用的 config.display_name + Missions 目录 mission_key + self._mission_key = resolve_canonical_mission_key( + flt_path=self._last_flt_path, + mission_title=text, + display_hint=self._mission_display_name or None, + ) + self._mission_display_name = resolve_canonical_mission_display( + flt_path=self._last_flt_path, + mission_title=text, + mission_key=self._mission_key or None, + ) + self._bus_commit_session() + # 未监测不读 Sim:选任务只更新标题,不开 live / poll + if not text and not self._runtime.is_active(): + self._disable_live_readouts() + self._sync_start_monitor_button() + + def _clear_live_readouts(self) -> None: + self._bus_commit_live( + { + "cht": None, + "torque": None, + "health": None, + "airspeed": None, + "groundspeed": None, + "pitch": None, + "bank": None, + } + ) + + def _enable_live_readouts(self) -> None: + if self._live_readouts_enabled or self._closing or not self.aq: + return + self._live_readouts_enabled = True + self._refresh_live_metrics_once() + self._refresh_runtime_timers() + + def _disable_live_readouts(self) -> None: + self._live_readouts_enabled = False + self._refresh_runtime_timers() + self._clear_live_readouts() + + def _sync_session_mode_ui(self) -> None: + self._bus_commit_session() + + def _mode_from_flt_path(self, path: str) -> str: + mission_type = parse_flt_mission_type(path) + if mission_type.lower() == "freeflight": + return "freeflight" + return "mission_mp" if self._mp_active else "mission_sp" + + def _update_session_info_from_flt(self, path: str) -> None: + if not path: + return + path = normalize_flt_path(path) or path + mode = self._mode_from_flt_path(path) + # MP 激活时仍刷新标题;勿把会话模式降回 freeflight + if mode == "freeflight" and self._mp_active: + mode = "mission_mp" + elif mode == "freeflight" and self._runtime.is_active(): + mode = "mission_mp" if self._mp_active else "mission_sp" + title = parse_mission_title(path) + if title: + self._set_mission_title(title) + elif mode == "freeflight": + self._set_mission_title(None) + self._set_session_mode(mode) + + def _refresh_session_info(self) -> None: + if self.sm: + try: + self.sm.request_flight_loaded() + except Exception: + pass + path = (self._last_flt_path or "").strip() + if path: + self._update_session_info_from_flt(path) + elif self._runtime.is_active(): + self._set_session_mode("mission_mp" if self._mp_active else "mission_sp") + + def _on_flight_loaded_event(self, path: str) -> None: + if self._closing: + return + self._bridge.flight_loaded.emit(path) + + def _on_mission_completed_event(self, result_code: int) -> None: + if self._closing: + return + self._bridge.mission_completed.emit(int(result_code)) + + def _on_mp_server_started_event(self) -> None: + if self._closing: + return + self._bridge.mp_server_started.emit() + + def _on_mp_client_started_event(self) -> None: + if self._closing: + return + self._bridge.mp_client_started.emit() + + def _on_mp_session_ended_event(self) -> None: + if self._closing: + return + self._bridge.mp_session_ended.emit() + + def _on_custom_action_event(self, payload: str) -> None: + if self._closing: + return + self._bridge.custom_action.emit(payload or "") + + def _apply_flight_loaded(self, path: str) -> None: + if not path: + return + resolved = normalize_flt_path(path) or path + self._last_flt_path = resolved + self._update_session_info_from_flt(resolved) + + def _apply_mission_completed(self, _result_code: int) -> None: + if self._mp_active: + return + self._set_mission_title(None) + self._set_session_mode("freeflight") + + def _apply_mp_server_started(self) -> None: + self._mp_active = True + self._bus_commit_session() + if self._session_mode == "mission_sp": + self._set_session_mode("mission_mp") + + def _apply_mp_client_started(self) -> None: + self._mp_active = True + self._bus_commit_session() + if self._session_mode in ("mission_sp", "mission_mp"): + self._set_session_mode("mission_mp") + + def _apply_mp_session_ended(self) -> None: + self._mp_active = False + self._bus_commit_session() + if self._session_mode == "mission_mp": + self._set_session_mode("mission_sp") + + def _apply_custom_action(self, _payload: str) -> None: + self._apply_mission_session_mode() + + def _apply_mission_session_mode(self) -> None: + """按是否多人会话切换 mission_mp / mission_sp。""" + self._set_session_mode("mission_mp" if self._mp_active else "mission_sp") + + def _on_race_lap_event(self, lap_index, lap_time_s=0.0, penalty_s=0.0): + if self._closing: + return + self._bridge.race_lap.emit(int(lap_index), float(lap_time_s), float(penalty_s)) + + def _on_race_end_event(self, racer_number, total_time_s=0.0, penalty_s=0.0): + if self._closing: + return + self._bridge.race_end.emit(int(racer_number), float(total_time_s), float(penalty_s)) + + def _apply_race_lap(self, lap_index, lap_time_s=0.0, penalty_s=0.0) -> None: + self._apply_mission_session_mode() + completed_lap = int(lap_index) + 1 + time_s = float(lap_time_s) + train = self._train_page() + if time_s <= 0 or time_s != time_s: + if train is not None: + train.set_status(f"第 {completed_lap} 圈完成,但未读到有效圈时", "warn") + self._lap_count = completed_lap + 1 + self._refresh_mission_labels() + return + self._lap_times = [x for x in self._lap_times if int(x["lap"]) != completed_lap] + prev_cumulative = max( + (float(x.get("penalty_s") or 0.0) for x in self._lap_times if int(x["lap"]) < completed_lap), + default=0.0, + ) + penalty_cumulative = float(penalty_s or 0.0) + penalty_delta = max(0.0, penalty_cumulative - prev_cumulative) + self._lap_times.append( + { + "lap": completed_lap, + "time_s": time_s, + "penalty_s": penalty_cumulative, + "penalty_delta_s": penalty_delta, + } + ) + self._lap_times.sort(key=lambda x: int(x["lap"])) + self._lap_count = completed_lap + 1 + self._race_finished = False + self._refresh_mission_labels() + four_done = self._four_laps_complete() + hint = f"第 {completed_lap} 圈 {format_lap_time(time_s)}" + if penalty_delta > 0: + hint += f"(本圈罚时 {format_lap_time(penalty_delta)})" + elif penalty_cumulative > 0: + hint += f"(累计罚时 {format_lap_time(penalty_cumulative)})" + if train is not None and self._runtime.is_recording(): + chart_t = self._current_chart_time_s() + if chart_t is not None: + train.add_lap_marker(chart_t, completed_lap) + self._push_aux_charts(train) + train.set_status(hint, "ok") + elif train is not None: + train.set_status(hint, "ok") + if four_done: + self._pause_all_monitoring_after_four_laps(hint) + + def _pause_all_monitoring_after_four_laps(self, last_hint: str = "") -> None: + """四圈成绩齐全:暂停模拟器、停止监测,并弹出成绩确认。""" + if not self._runtime.is_recording() and not self._runtime.is_waiting(): + return + times = [] + penalties = [] + for i in range(LAP_TABLE_ROWS): + lap_no = i + 1 + times.append(self._lap_time_by_number(lap_no)) + penalties.append(self._lap_penalty_delta_by_number(lap_no)) + total_net = self._total_flight_time_s() + total_penalty = self._total_penalty_s() + self._request_sim_pause() + self._stop_monitoring(save=False) + train = self._train_page() + if train is not None: + text = "四圈完成,监测已全部暂停" + if last_hint: + text = f"{last_hint}|{text}" + train.set_status(text, "ok") + self._refresh_monitor_button() + show_race_success_result( + self.shell, + lap_times=times, + lap_penalties=penalties, + total_net_s=total_net, + total_penalty_s=total_penalty, + on_save=self._save_pending_trajectory, + ) + + def _apply_race_end(self, racer_number, total_time_s=0.0, penalty_s=0.0) -> None: + self._apply_mission_session_mode() + self._race_finished = True + self._refresh_mission_labels() + train = self._train_page() + if train is None: + return + flight = self._total_flight_time_s() + text = f"竞赛结束(选手 #{racer_number + 1})" + if flight is not None: + text += f" 总飞行 {format_lap_time(flight)}" + elif total_time_s and total_time_s == total_time_s and total_time_s > 0: + text += f" 接口总时 {format_lap_time(total_time_s)}" + if penalty_s and penalty_s == penalty_s and penalty_s > 0: + text += f" 罚时 {format_lap_time(penalty_s)}" + train.set_status(text, "ok") + + def _begin_race_tracking(self) -> None: + self._lap_count = 1 + self._lap_times = [] + self._race_finished = False + self._refresh_mission_labels() + train = self._train_page() + if train is not None: + self._push_aux_charts(train) + + def _four_laps_complete(self) -> bool: + have = {int(x["lap"]) for x in self._lap_times if float(x.get("time_s") or 0) > 0} + return all(i in have for i in range(1, LAP_COUNT + 1)) + + def _current_chart_time_s(self) -> Optional[float]: + """图表 X 轴:模拟器 ABSOLUTE TIME 相对监测起点的飞行时间(暂停时不推进)。""" + if not self.aq: + return None + elapsed, origin = chart_elapsed_time_s(self.aq, self._chart_time_origin) + if origin is not None: + self._chart_time_origin = origin + return elapsed + + def _push_aux_charts(self, train=None) -> None: + self._bus_bump_chart_track() + + def _start_acquire_worker(self) -> None: + """录制期:Sim 重读进独占线程;主线程只 drain 队列刷 UI。""" + self._acquire_worker.stop() + + def _get_stick_kwargs() -> dict: + stick_axes = self._bus.store.get(GROUP_STICK, KEY_STICK_AXES) + return stick_record_fields( + stick_axes if stick_axes is not None else poll_stick() + ) + + self._acquire_worker.start( + aq=self.aq, + read_tick=self._read_monitor_tick, + get_flight_t=self._current_chart_time_s, + sample_fn=self._trajectory_recorder.sample_from_aq, + get_lap=lambda: max( + 1, min(LAP_COUNT, int(self._lap_count) if self._lap_count else 1) + ), + get_stick_kwargs=_get_stick_kwargs, + is_paused=lambda: bool(self._runtime.pause_detector.is_paused) + or bool(self._trajectory_recorder.paused), + should_run=lambda: bool(self._runtime.is_recording()) + and not self._closing + and not self._four_laps_complete(), + ) + + def _stop_acquire_worker(self) -> None: + self._acquire_worker.stop() + + def _consume_acquire_ticks(self) -> None: + """主线程:消化采集线程产出(总线 + 有新样本时刷图)。""" + fault = self._acquire_worker.fault + if fault: + self._trajectory_recorder.stop() + self._stop_acquire_worker() + train = self._train_page() + if train is not None: + self._set_run_status(f"采样中断:{fault}", "error") + return + ticks = self._acquire_worker.drain() + if not ticks: + return + any_wrote = False + for tick in ticks: + series = extract(tick.snap) + self._derive_prev_snap = derive_prev_from_snap(tick.snap) + metrics = dict(tick.metrics or {}) + for fid in ("eta_prop", "di_proxy", "vg_course"): + val = series.get(fid) + if val is not None and val == val: + metrics[fid] = float(val) + self._bus_commit_live(metrics) + if tick.wrote_sample: + any_wrote = True + if any_wrote: + self._sync_save_pending() + rec = self._trajectory_recorder + synced = self._consume.synced_len + total = rec.replica_len() + if total < synced: + added = self._consume.pull_replica(rec.replica_snapshot()) + else: + tail = rec.replica_slice(synced) + if not tail: + added = 0 + else: + # pull_replica 按「完整序列长度」推进 synced;拼前缀长度占位 + added = self._consume.pull_replica_tail(tail, total) + train = self._train_page() + if train is not None and added > 0: + train.pull_live_frame() + + def _start_recording(self) -> None: + self._refresh_session_info() + self._runtime.sync_start_recording() + self._chart_time_origin = None + # 连续监测兜底:再清一次图,避免 WAITING→录制间隙残留几何/游标幽灵 + self._clear_monitor_charts() + self._enable_live_readouts() + train = self._train_page() + if train is not None: + train.resume_live_follow() + title = self.aq.get("TITLE") if self.aq else None + if isinstance(title, bytes): + title = title.decode(errors="ignore") + self._trajectory_recorder.start(title) + self._begin_race_tracking() + self._start_acquire_worker() + if train is not None: + self._set_run_status("监测中...", "ok") + self._refresh_monitor_button() + train.set_health_beep_armed(True) + + def _poll(self) -> None: + if self._closing or not self.aq: + return + if self._handle_detected_crash(): + return + paused = self._runtime.pause_detector.refresh(self.aq, self.sm) + self._bus_commit_runtime(paused=paused) + if not self._runtime.is_active(): + if not self._suspend_sim_poll: + if self.sm and self.sm.quit: + self._handle_sim_lost() + return + self._refresh_runtime_timers() + return + interval = self._data_poll_interval_ms() + if self._suspend_sim_poll: + self._poll_timer.start(interval) + return + if self.sm and self.sm.quit: + self._handle_sim_lost() + return + + if self._runtime.is_waiting(): + # 等待态:只做暂停检测,不刷全量 Sim 快照(此前每 100ms 二十余次 aq.get) + if paused: + self._set_run_status("等待解除暂停状态后开始监测...", "warn") + self._poll_timer.start(WAITING_POLL_MS) + return + self._set_run_status("请移动飞机以开始监测", "warn") + self._start_recording() + # 开录后本拍继续走录制路径;按当前倍速加密 poll + interval = self._data_poll_interval_ms() + + if paused: + if self._trajectory_recorder.active and not self._trajectory_recorder.paused: + self._trajectory_recorder.set_paused(True) + self._sync_save_pending() + self._set_run_status("模拟器已暂停,监测同步暂停", "warn") + train = self._train_page() + if train is not None: + train.set_health_beep_armed(False) + # 录制暂停:仪表降频,不跑全量扩录字段 + self._refresh_live_metrics_once() + self._poll_timer.start(WAITING_POLL_MS) + return + + if self._trajectory_recorder.paused: + self._trajectory_recorder.set_paused(False) + self._set_run_status("监测中...", "ok") + train = self._train_page() + if train is not None: + train.set_health_beep_armed(True) + + # 四圈已齐则不再抓取/写入/刷图(兜底,正常应在过圈时已停) + if self._four_laps_complete(): + self._pause_all_monitoring_after_four_laps() + return + + if self._runtime.is_recording(): + # 重读在采集线程;主线程只消费队列 + 轻量调度 + if not self._acquire_worker.is_running(): + self._start_acquire_worker() + self._consume_acquire_ticks() + if self._runtime.is_active(): + self._refresh_runtime_timers() + + def _apply_live_readouts(self, metrics: Optional[dict] = None) -> None: + data = dict(metrics or {}) + self._bus_commit_live(data) + + def _stop_shadow_soft(self, ctrl=None, stop_recorder: bool = False) -> None: + if ctrl is None: + ctrl = self._shadow_controller + self._shadow_controller = None + if ctrl is not None: + try: + ctrl.signal_stop() + except Exception: + pass + + def _bg_join(): + try: + ctrl.stop_replay(join_timeout=0.3) + except Exception: + pass + + threading.Thread(target=_bg_join, daemon=True).start() + if stop_recorder: + try: + self._trajectory_recorder.stop_record_thread() + except Exception: + pass + self._refresh_runtime_timers() + + def _handle_detected_crash(self) -> bool: + """检测到坠毁时停止监测/跟飞;返回是否已处理。""" + if self._runtime.crash_latched or not aircraft_has_crashed(self.aq): + return False + session_active = self._runtime.is_active() + ctrl = self._shadow_controller + had_shadow = ctrl is not None and ctrl.object_id is not None + if not session_active and not had_shadow: + return False + self._runtime.crash_latched = True + self._runtime.publish() + if session_active: + self._stop_monitoring(save=False) + elif had_shadow: + self._shadow_controller = None + self._stop_shadow_soft(ctrl, stop_recorder=True) + train = self._train_page() + if train is not None: + train.set_score_crashed(True) + if had_shadow and session_active: + train.set_status("检测到坠毁,已停止跟飞及监测", "warn") + elif had_shadow: + train.set_status("检测到坠毁,已停止跟飞", "warn") + else: + train.set_status("检测到坠毁,已停止监测", "warn") + self._refresh_runtime_timers() + return True + + def _init_sim_events(self) -> None: + if not self.sm: + return + self.pause_on_event = Event(b"PAUSE_ON", self.sm) + self.situation_reset_event = Event(b"SITUATION_RESET", self.sm) + if not self.sim_rate_incr_event: + self.sim_rate_incr_event = Event(b"SIM_RATE_INCR", self.sm) + if not self.sim_rate_decr_event: + self.sim_rate_decr_event = Event(b"SIM_RATE_DECR", self.sm) + + def _request_sim_pause(self) -> None: + self._init_sim_events() + if self.pause_on_event: + try: + self.pause_on_event() + except Exception: + pass + + def _read_sim_rate(self): + return read_simulation_rate(self.aq) + + def _sync_rate_button(self, rate=None) -> None: + train = self._train_page() + if train is None: + return + if rate is None: + rate = self._read_sim_rate() + if rate is not None and rate >= TARGET_SIM_RATE - 0.05: + train.set_rate_button("关闭倍速") + else: + train.set_rate_button("开启倍速") + + def _reset_task_and_request_pause(self) -> None: + if self.situation_reset_event: + self.situation_reset_event() + self._request_sim_pause() + + def _apply_cold_cabin(self) -> bool: + target_c = f_to_c(TARGET_CHT_F) + return bool(self.aq.set(CHT_VAR, target_c)) + + def _on_start_monitor(self) -> None: + if not self.aq: + train = self._train_page() + if train is not None: + train.set_status("未连接模拟器", "error") + return + if not (self._mission_title or "").strip(): + train = self._train_page() + if train is not None: + train.set_status("请先选择飞行任务", "warn") + return + self._refresh_session_info() + settings = show_start_monitoring( + self.shell, + session_mode=self._session_mode, + cold_cabin_enabled=self._monitor_cold_cabin_enabled, + shadow_follow_enabled=self._monitor_shadow_follow_enabled, + lead_s=self._shadow_lead_s, + smoke_enabled=self._shadow_smoke_enabled, + trajectory_record_id=self._shadow_trajectory_record_id or None, + trajectory_label=self._shadow_trajectory_label or None, + user_id=self._auth_user_id(), + ) + if settings is None: + return + reload_stick() + self._monitor_cold_cabin_enabled = bool(settings.get("cold_cabin")) + self._monitor_shadow_follow_enabled = bool(settings.get("shadow_follow")) + self._shadow_lead_s = float(settings.get("lead", self._shadow_lead_s)) + self._shadow_smoke_enabled = bool(settings.get("smoke", self._shadow_smoke_enabled)) + rid = str(settings.get("trajectory_record_id") or "").strip() + if rid: + self._shadow_trajectory_record_id = rid + self._shadow_trajectory_label = str(settings.get("trajectory_label") or rid) + task = settings.get("task", "current") + if settings.get("shadow_follow"): + self._begin_shadow_follow_from_monitor(task, settings) + return + if task == "reset": + if self._monitor_cold_cabin_enabled: + self._cold_cabin_reset_task() + else: + self._start_monitor_reset_task() + elif self._monitor_cold_cabin_enabled: + self._cold_cabin_current_task() + else: + self._request_sim_pause() + train = self._train_page() + if train is not None: + train.set_status("准备开始监测...", "muted") + self._start_monitor_waiting() + + def _begin_shadow_follow_from_monitor(self, task: str, settings: dict) -> None: + if not self.aq or not self.sm or self._shadow_follow_busy: + train = self._train_page() + if train is not None: + train.set_status("未连接模拟器", "error") + return + rid = str(settings.get("trajectory_record_id") or "").strip() + if not rid: + train = self._train_page() + if train is not None: + train.set_status("请选择跟飞云端航迹", "warn") + return + + self._stop_shadow_soft() + if self._runtime.is_active(): + self._stop_monitoring() + reset_applied = False + if task == "reset": + try: + self._reset_task_and_request_pause() + reset_applied = True + except Exception as exc: + train = self._train_page() + if train is not None: + train.set_status(f"任务重置失败:{exc}", "error") + return + else: + self._request_sim_pause() + + lead = float(settings.get("lead", self._shadow_lead_s)) + enable_smoke = bool(settings.get("smoke", self._shadow_smoke_enabled)) + enable_cold_cabin = bool(settings.get("cold_cabin")) + self._shadow_follow_busy = True + train = self._train_page() + if train is not None: + train.set_status("跟飞准备中...", "muted") + threading.Thread( + target=self._shadow_follow_worker, + args=(rid, lead, enable_smoke, enable_cold_cabin, task == "current", reset_applied), + daemon=True, + ).start() + + def _start_monitor_reset_task(self) -> None: + self._reset_task_and_request_pause() + train = self._train_page() + if train is not None: + train.set_status("任务已重置,准备开始监测...", "muted") + self._start_monitor_waiting() + + def _cold_cabin_current_task(self) -> None: + QTimer.singleShot(COLD_CABIN_DELAY_MS, self._finish_cold_cabin_current) + + def _finish_cold_cabin_current(self) -> None: + if self._closing or not self.aq: + return + train = self._train_page() + if self._apply_cold_cabin(): + if train is not None: + train.set_status("冷舱启动成功", "ok") + self._start_monitor_waiting() + elif train is not None: + train.set_status("冷舱启动失败", "error") + + def _cold_cabin_reset_task(self) -> None: + self._reset_task_and_request_pause() + QTimer.singleShot(COLD_CABIN_DELAY_MS, self._finish_cold_cabin_reset) + + def _finish_cold_cabin_reset(self) -> None: + if self._closing or not self.aq: + return + ok = self._apply_cold_cabin() + self._request_sim_pause() + train = self._train_page() + if ok: + if train is not None: + train.set_status("冷舱启动成功", "ok") + self._start_monitor_waiting() + elif train is not None: + train.set_status("冷舱启动失败", "error") + + def _on_sim_rate(self) -> None: + if not self.aq or not self.sm: + train = self._train_page() + if train is not None: + train.set_rate_status("未连接模拟器", "error") + return + self._init_sim_events() + current = self._read_sim_rate() + if current is None: + current = 1.0 + if current >= TARGET_SIM_RATE - 0.05: + self._sim_rate_target = 1.0 + action = "关闭" + else: + self._sim_rate_target = TARGET_SIM_RATE + action = "开启" + self._sim_rate_steps_left = SIM_RATE_MAX_STEPS + self._sim_rate_timer.stop() + train = self._train_page() + if train is not None: + train.set_rate_status(f"倍速{action}中…", "muted") + self._sim_rate_step() + + def _sim_rate_step(self) -> None: + if self._closing or not self.aq: + return + current = self._read_sim_rate() + train = self._train_page() + if current is None: + if train is not None: + train.set_rate_status("倍速调节失败:无法读取 SIMULATION_RATE", "error") + return + target = self._sim_rate_target + if target is None: + self._sync_rate_button(current) + return + if abs(current - target) <= 0.05: + self._sync_rate_button(current) + if train is not None: + if target >= TARGET_SIM_RATE - 0.05: + train.set_rate_status(f"开启倍速:{current:.1f}x", "ok") + else: + train.set_rate_status(f"关闭倍速:{current:.1f}x", "ok") + return + if self._sim_rate_steps_left <= 0: + self._sync_rate_button(current) + if train is not None: + train.set_rate_status(f"倍速调节未到位,当前 {current:.1f}x", "warn") + return + self._sim_rate_steps_left -= 1 + if current < target - 0.05: + self.sim_rate_incr_event() + else: + self.sim_rate_decr_event() + self._sim_rate_timer.start(SIM_RATE_STEP_MS) + + def _shadow_follow_worker( + self, + record_id, + lead_s=SHADOW_LEAD_DEFAULT_S, + enable_smoke=True, + enable_cold_cabin=False, + use_user_reference=False, + reset_applied=False, + ) -> None: + err_text = None + try: + old = self._shadow_controller + self._shadow_controller = None + if old is not None: + try: + old.signal_stop() + old.stop_replay(join_timeout=0.3) + except Exception: + pass + if not self.sm or getattr(self.sm, "quit", 0) or not getattr(self.sm, "ok", False): + raise RuntimeError("模拟器连接异常,请重新连接后再试") + uid = self._auth_user_id() + if not uid: + raise RuntimeError(MSG_TRACK_STORE_NEED_LOGIN) + traj = load_trajectory_record(uid, str(record_id)) + if enable_cold_cabin: + time.sleep(COLD_CABIN_DELAY_MS / 1000.0) + if not self._apply_cold_cabin(): + raise RuntimeError("冷舱启动失败") + self._request_sim_pause() + result = launch_shadow_follow( + self.sm, + self.aq, + traj, + situation_reset_event=None, + reset_already_applied=reset_applied, + replace_mission_ai=False, + lead_s=lead_s, + enable_smoke=enable_smoke, + use_user_reference=use_user_reference, + ) + self._shadow_controller = result["controller"] + duration = float(result.get("duration_s") or 0.0) + lead = float(result.get("lead_s") or 0.0) + msg = ( + f"影子机已启动:{result['aircraft_title']}," + f"{result['sample_count']} 点 / {duration:.1f}s" + f"(Shadow Plane 偏移 {lead:.1f}s,{int(result.get('replay_hz') or 60)}Hz" + f"{',拉烟' if result.get('smoke') else ''}" + f"{',冷舱' if enable_cold_cabin else ''})" + ) + self._request_sim_pause() + self._bridge.shadow_follow_done.emit(True, msg) + except Exception as exc: + err_text = str(exc) or exc.__class__.__name__ + self._bridge.shadow_follow_done.emit(False, err_text) + + def _on_shadow_follow_done(self, ok: bool, message: str) -> None: + self._shadow_follow_busy = False + if self._closing: + return + train = self._train_page() + if train is not None: + if ok: + self._bus_commit_runtime( + paused=self._runtime.pause_detector.refresh(self.aq, self.sm) + ) + self._start_monitor_waiting() + else: + train.set_status(f"跟飞失败:{message}", "error") + + def _on_close(self) -> None: + if self._closing: + return + self._closing = True + self._check_closing = True + self._poll_timer.stop() + self._sim_rate_timer.stop() + self._login_poll_timer.stop() + self._stop_acquire_worker() + self._bus.acquire.set_ui_preview(False) + self._bus.stop() + self._runtime.reset() + try: + self._trajectory_recorder.stop() + except Exception: + pass + self._trajectory_recorder.stop_and_save( + total_s=self._total_flight_time_s(), + laps_s=laps_from_session(self._lap_times), + mission_title=self._mission_title, + ) + if self._shadow_controller is not None: + try: + self._shadow_controller.signal_stop() + except Exception: + pass + self._shadow_controller = None + train = self._train_page() + if train is not None: + train.metrics.stop_health_flash() + conn_sm = None + if self.sm: + conn_sm = self.sm + elif self._sim_connection: + conn_sm = self._sim_connection[0] + safe_disconnect_simulator(conn_sm) + self.sm = None + self.aq = None + self._sim_connection = None + + def _quit_app(self) -> None: + self._on_close() + app = QApplication.instance() + if app is not None: + app.quit() diff --git a/app_run.py b/app_run.py new file mode 100644 index 00000000..171d2de6 --- /dev/null +++ b/app_run.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +""" +模块:app_run +职责:应用入口;启动 Qt 壳与 AppController +依赖:Frame_Shell_AppMain、pages、app_controller +""" + +from __future__ import annotations + +import sys + + +def main(argv=None) -> int: + """ + 说明:正常启动或 --smoke 冒烟 + 参数: + argv — 命令行参数;默认 sys.argv + 返回: + 进程退出码 + """ + # 尽早声明高 DPI(在任何 QApplication 实例化之前;create_app 内也会幂等再调一次) + from app_bootstrap import apply_qt_high_dpi + + apply_qt_high_dpi() + + args = list(argv if argv is not None else sys.argv) + if "--smoke" in args: + from components.Frame.Frame_Shell_AppMain import _run_smoke + + return _run_smoke() + + from components.Frame.Frame_Shell_AppMain import Frame_Shell_AppMain + from components.Frame.Frame_Theme_UI import create_app + from pages import register_flow_features + from app_controller import AppController + + app = create_app(args) + shell = Frame_Shell_AppMain() + # 先只挂自检/登录;训练页(Charts + pygame)推迟到自检窗显示后再建,避免闪窗 + register_flow_features(shell) + controller = AppController(shell) + controller.start() + return app.exec_() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/build_exe.bat b/build_exe.bat new file mode 100644 index 00000000..eda1801b --- /dev/null +++ b/build_exe.bat @@ -0,0 +1,49 @@ +@echo off +REM Build Flight_Assistant.exe with conda env sim_connect (Python 3.10 win32) +setlocal EnableExtensions +cd /d "%~dp0" + +set "CONDA_BAT=%USERPROFILE%\miniconda3\Scripts\activate.bat" +if not exist "%CONDA_BAT%" ( + echo [ERROR] miniconda activate not found: %CONDA_BAT% + exit /b 1 +) +call "%CONDA_BAT%" sim_connect +if errorlevel 1 ( + echo [ERROR] failed to activate conda env sim_connect + exit /b 1 +) + +set PYQTGRAPH_QT_LIB=PySide2 + +python -c "import sys; assert not (sys.maxsize > 2**32), 'need 32-bit Python (sim_connect)'; print(sys.executable); print(sys.version)" +if errorlevel 1 exit /b 1 + +if not exist ".env" ( + echo [ERROR] missing .env + exit /b 1 +) +if not exist "SimConnect\SimConnect.dll" ( + echo [ERROR] missing SimConnect\SimConnect.dll + exit /b 1 +) + +echo. +echo === Generate resources\icons\icon.ico === +python _make_app_icon_ico.py +if errorlevel 1 ( + echo [WARN] icon.ico generate failed; continue without custom exe icon +) + +echo. +echo === PyInstaller: Flight_Assistant (onefile / windowed) === +python -m PyInstaller --noconfirm --clean Flight_Assistant.spec +if errorlevel 1 ( + echo [ERROR] build failed + exit /b 1 +) + +echo. +echo [OK] output: %CD%\dist\Flight_Assistant.exe +dir "%CD%\dist\Flight_Assistant.exe" +endlocal diff --git a/charts/__init__.py b/charts/__init__.py new file mode 100644 index 00000000..8d014adc --- /dev/null +++ b/charts/__init__.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +""" +模块:charts +职责:图表包公开入口;页面侧优先使用 TrendChart +""" + +from charts.pg_bind import pg # noqa: F401 — 锁定 PySide2 绑定 + +from .facade.trend_chart import TrendChart + +__all__ = ["TrendChart"] \ No newline at end of file diff --git a/charts/axis_proxy.py b/charts/axis_proxy.py new file mode 100644 index 00000000..899ee784 --- /dev/null +++ b/charts/axis_proxy.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +""" +模块:charts.axis_proxy +职责:把 pyqtgraph AxisItem/ViewBox 包成与旧 QValueAxis 相近的量程接口 +依赖:pyqtgraph +""" + +from __future__ import annotations + +from typing import Optional, Tuple + +from charts.pg_bind import pg +from charts.theme import axis_label_color + + +class AxisProxy: + """Y/X 轴代理:setRange / min / max / 标题 / 可见性。""" + + def __init__( + self, + axis: "pg.AxisItem", + viewbox: "pg.ViewBox", + *, + is_x: bool = False, + ) -> None: + self._axis = axis + self._vb = viewbox + self._is_x = bool(is_x) + self._title = "" + self._visible = True + + @property + def axis_item(self) -> "pg.AxisItem": + return self._axis + + @property + def viewbox(self) -> "pg.ViewBox": + return self._vb + + def setRange(self, a: float, b: float) -> None: + lo, hi = float(a), float(b) + if hi <= lo: + hi = lo + 1.0 + if self._is_x: + self._vb.setXRange(lo, hi, padding=0.0) + else: + self._vb.setYRange(lo, hi, padding=0.0) + + def min(self) -> float: + xr, yr = self._vb.viewRange() + return float(xr[0] if self._is_x else yr[0]) + + def max(self) -> float: + xr, yr = self._vb.viewRange() + return float(xr[1] if self._is_x else yr[1]) + + def range(self) -> Tuple[float, float]: + return self.min(), self.max() + + def isVisible(self) -> bool: + return bool(self._visible) + + def setVisible(self, visible: bool) -> None: + self._visible = bool(visible) + try: + self._axis.setVisible(self._visible) + except Exception: + pass + + def setTitleText(self, title: str) -> None: + self._title = str(title or "") + try: + # 空标题仍占位,避免左右轴显隐导致绘图区跳动 + label = self._title if self._title else " " + c = axis_label_color() + self._axis.setLabel(label, color=c.name()) + except Exception: + pass + + def titleText(self) -> str: + return self._title + + def setLabelFormat(self, _fmt: str) -> None: + # AxisItem 用 tickStrings;整数秒由外部 setTickSpacing 控制 + return diff --git a/charts/base/__init__.py b/charts/base/__init__.py new file mode 100644 index 00000000..52d8423b --- /dev/null +++ b/charts/base/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""包占位:charts.base(请从具体子模块导入)。""" diff --git a/charts/base/base_chart.py b/charts/base/base_chart.py new file mode 100644 index 00000000..d03b9d2b --- /dev/null +++ b/charts/base/base_chart.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +""" +模块:charts.base.base_chart +职责:基底图表抽象接口(系列、轴、清空);宿主为 pyqtgraph PlotWidget +依赖:charts.context、models.chart(TYPE_CHECKING) +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from charts.axis_proxy import AxisProxy + from charts.context import ChartRenderContext + from charts.pg_bind import pg + from models.chart import ChartConfig, ChartFieldSpec +else: + AxisProxy = Any + pg = Any + + +class BaseChart(ABC): + """基底图表:挂到 PlotWidget 上,由 ChartSlot 驱动 render。""" + + chart_type: str = "base" + + def __init__(self) -> None: + self._plot_widget: Optional[Any] = None + self._config: Any = None + + @property + def plot_widget(self) -> Optional[Any]: + return self._plot_widget + + @property + def qchart(self) -> Optional[Any]: + """与 plot_widget 同义(蒙版 clear 等沿用此名)。""" + return self._plot_widget + + def create_plot_widget(self) -> Any: + """默认笛卡尔 PlotWidget。""" + from charts.pg_bind import pg + + w = pg.PlotWidget() + w.setMenuEnabled(False) + w.showGrid(x=False, y=False) + pi = w.plotItem + pi.hideButtons() + pi.setClipToView(True) + pi.setDownsampling(auto=True, mode="peak") + pi.setMouseEnabled(x=False, y=False) + pi.enableAutoRange(enable=False) + pi.getViewBox().setMouseEnabled(x=False, y=False) + return w + + @abstractmethod + def attach_to_plot(self, plot_widget: Any) -> None: + """创建系列与轴并挂到 PlotWidget。""" + ... + + @abstractmethod + def set_config(self, config: "ChartConfig") -> None: + ... + + @abstractmethod + def update_series(self, ctx: "ChartRenderContext") -> None: + ... + + @abstractmethod + def apply_axis_ranges(self, ctx: "ChartRenderContext") -> None: + ... + + @abstractmethod + def primary_series(self) -> Optional[Any]: + """平移/圈标用的主曲线(PlotDataItem)。""" + ... + + @abstractmethod + def field_axis(self, field: "ChartFieldSpec") -> Optional["AxisProxy"]: + ... + + @abstractmethod + def axis_x(self) -> Optional["AxisProxy"]: + ... + + @abstractmethod + def clear(self) -> None: + ... + + @property + def config(self): + return self._config diff --git a/charts/base/line_chart.py b/charts/base/line_chart.py new file mode 100644 index 00000000..b4f5280b --- /dev/null +++ b/charts/base/line_chart.py @@ -0,0 +1,764 @@ +# -*- coding: utf-8 -*- +""" +模块:charts.base.line_chart +职责:双 Y 轴折线基底(pyqtgraph PlotDataItem + 右轴 ViewBox) +依赖:BaseChart、AxisProxy、theme、models.chart +""" + +from __future__ import annotations + +import math +from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple + +from PySide2.QtCore import Qt +from PySide2.QtGui import QColor, QFont + +from charts.axis_proxy import AxisProxy +from charts.base.base_chart import BaseChart +from charts.context import ChartRenderContext +from charts.pg_bind import pg +from charts.theme import ( + axis_border_color, + axis_label_color, + axis_muted_color, + compare_field_qcolor, + compare_pen_style, + field_line_color, + field_pen_style, +) +from components.Frame.Frame_Theme_UI import UITheme +from models.chart import ( + AXIS_IDLE_SPEC, + AxisSide, + ChartConfig, + ChartFieldSpec, + ChartHistoryBuffer, + SERIES_SLOT_SIDES, + axis_idle_series_id, + is_axis_idle_id, + series_points, +) +from models.track_compare import CompareStyle +from setting import ( + CHART_FLOAT_BOTTOM_GAP_PX, + CHART_HISTORY_OPACITY, + CHART_LIVE_OPACITY, +) + + +def _curve_len(curve: Optional["pg.PlotDataItem"]) -> int: + if curve is None: + return 0 + try: + data = curve.getData() + if data is None or data[0] is None: + return 0 + return int(len(data[0])) + except Exception: + return 0 + + +def _mk_pen( + color: QColor, + width: float, + style=None, + *, + opacity: float = 1.0, +) -> "pg.QtGui.QPen": + c = QColor(color) + op = max(0.0, min(1.0, float(opacity))) + if op < 0.999: + c.setAlphaF(op) + pen = pg.mkPen(color=c, width=float(width)) + if style is not None: + pen.setStyle(style) + return pen + + +class LineChart(BaseChart): + """可配置多字段折线;工具栏左右槽指定显示字段与所挂 Y 轴。""" + + chart_type = "line" + + def __init__(self, config: Optional[ChartConfig] = None) -> None: + super().__init__() + from models.chart import DEFAULT_SERIES_SLOTS, DEFAULT_SERIES_VISIBLE, TRAINING_CHART + + self._config = config or TRAINING_CHART + self._series: Dict[str, pg.PlotDataItem] = {} + self._compare_series: Dict[str, pg.PlotDataItem] = {} + self._idle_series: Dict[str, pg.PlotDataItem] = {} + self._axes_y: Dict[str, AxisProxy] = {} + self._axis_x: Optional[AxisProxy] = None + self._vb_left: Optional[pg.ViewBox] = None + self._vb_right: Optional[pg.ViewBox] = None + self._plot_item: Optional[pg.PlotItem] = None + self._primary_field = self._config.primary_field() + self._primary_series: Optional[pg.PlotDataItem] = None + self._compare_history: Optional[ChartHistoryBuffer] = None + from models.track_compare import default_compare_style + + self._compare_style = default_compare_style() + self._field_axis_side: Dict[str, AxisSide] = { + f.id: f.side for f in self._config.fields + } + self._side_field: Dict[str, Optional[str]] = { + AxisSide.LEFT.value: None, + AxisSide.RIGHT.value: None, + } + self._side_active: Dict[str, Optional[str]] = { + AxisSide.LEFT.value: None, + AxisSide.RIGHT.value: None, + } + self._visible_ids: Set[str] = { + str(fid) + for fid, vis in zip(DEFAULT_SERIES_SLOTS, DEFAULT_SERIES_VISIBLE) + if vis and fid + } + for i, fid in enumerate(DEFAULT_SERIES_SLOTS): + if i < len(SERIES_SLOT_SIDES) and fid: + side = SERIES_SLOT_SIDES[i] + self._field_axis_side[fid] = side + self._side_field[side.value] = fid + self._cursor_t: Optional[float] = None + self._cursor_lines: list = [] + self._cursor_line: Optional[pg.InfiniteLine] = None + self._cursor_label: Optional[pg.TextItem] = None + self._series_host: Dict[str, str] = {} # field_id -> left|right + self._last_compare_view: Optional[Tuple[float, float]] = None + + def attach_to_plot(self, plot_widget) -> None: + self._plot_widget = plot_widget + pi = plot_widget.plotItem + self._plot_item = pi + self._vb_left = pi.vb + self._vb_left.enableAutoRange(enable=False) + # 官方 ViewBox:左键横向平移、滚轮缩放;Y 仍由字段量程锁定 + self._vb_left.setMouseEnabled(x=True, y=False) + try: + self._vb_left.setMouseMode(pg.ViewBox.PanMode) + except Exception: + pass + + # 右轴独立 ViewBox(官方 dual-axis 模式);X 与左联动,勿再接鼠标 + self._vb_right = pg.ViewBox() + self._vb_right.enableAutoRange(enable=False) + self._vb_right.setMouseEnabled(x=False, y=False) + # 不接收鼠标:避免盖住左 VB 导致缩放/拖动失效 + self._vb_right.setAcceptedMouseButtons(Qt.NoButton) + # 透明:只叠曲线,背景仍由下层 PlotWidget 井区承担,禁止再铺一层底色 + self._vb_right.setBackgroundColor(None) + # 主 VB 已透明:右 VB 置后绘制,鼠标落到左 VB;右轴曲线仍可透过显示 + self._vb_right.setZValue(-10) + pi.showAxis("right") + pi.scene().addItem(self._vb_right) + pi.getAxis("right").linkToView(self._vb_right) + self._vb_right.setXLink(pi) + + def _sync_right_geometry() -> None: + try: + self._vb_right.setGeometry(self._vb_left.sceneBoundingRect()) + self._vb_right.linkedViewChanged(self._vb_left, self._vb_right.XAxis) + except Exception: + pass + + _sync_right_geometry() + self._vb_left.sigResized.connect(_sync_right_geometry) + + muted = axis_muted_color() + border = axis_border_color() + label_c = axis_label_color() + for name in ("bottom", "left", "right"): + ax = pi.getAxis(name) + ax.setPen(pg.mkPen(border)) + ax.setTextPen(pg.mkPen(muted)) + try: + ax.setStyle(tickFont=QFont(UITheme.FONT_FAMILY, 8)) + except Exception: + pass + + pi.setLabel("bottom", self._config.x_axis_title or "Time (s)", color=label_c.name()) + pi.getAxis("bottom").setTickSpacing( + major=float(self._config.x_axis_major_sec), + minor=1.0, + ) + + self._axis_x = AxisProxy(pi.getAxis("bottom"), self._vb_left, is_x=True) + self._axes_y[AxisSide.LEFT.value] = AxisProxy(pi.getAxis("left"), self._vb_left) + self._axes_y[AxisSide.RIGHT.value] = AxisProxy(pi.getAxis("right"), self._vb_right) + + for field in self._config.fields: + curve = pg.PlotDataItem( + [], + [], + name=field.id, + pen=_mk_pen( + field_line_color(field.color_key, getattr(field, "color", "")), + float(field.line_width) if field.line_width else 1.8, + field_pen_style(getattr(field, "line_style", "") or ""), + opacity=float(CHART_LIVE_OPACITY), + ), + antialias=True, + ) + curve.setVisible(False) + self._series[field.id] = curve + self._vb_left.addItem(curve) + self._series_host[field.id] = AxisSide.LEFT.value + if field.id == self._primary_field.id: + self._primary_series = curve + + ref = pg.PlotDataItem( + [], + [], + name=f"{field.id}_ref", + pen=self._compare_pen_for_field(field), + antialias=True, + ) + ref.setVisible(False) + self._compare_series[field.id] = ref + self._vb_left.addItem(ref) + self._series_host[f"{field.id}_ref"] = AxisSide.LEFT.value + + for side in (AxisSide.LEFT, AxisSide.RIGHT): + idle = pg.PlotDataItem([], [], name=axis_idle_series_id(side), pen=None) + idle.setVisible(False) + self._idle_series[side.value] = idle + self._series[axis_idle_series_id(side)] = idle + vb = self._vb_left if side == AxisSide.LEFT else self._vb_right + vb.addItem(idle) + self._series_host[axis_idle_series_id(side)] = side.value + self._side_active[side.value] = axis_idle_series_id(side) + + self._refresh_axis_meta() + + def set_config(self, config: ChartConfig) -> None: + self._config = config + self._primary_field = config.primary_field() + self._primary_series = self._series.get(self._primary_field.id) + self._refresh_axis_meta() + + def _host_vb(self, side: AxisSide) -> Optional[pg.ViewBox]: + return self._vb_left if side == AxisSide.LEFT else self._vb_right + + def _move_curve_to_side(self, curve: Optional[pg.PlotDataItem], field_key: str, side: AxisSide) -> None: + if curve is None: + return + target = self._host_vb(side) + if target is None: + return + prev = self._series_host.get(field_key) + if prev == side.value: + return + # 只从当前宿主卸下,避免对另一侧 scene 误 removeItem + prev_vb = None + if prev == AxisSide.LEFT.value: + prev_vb = self._vb_left + elif prev == AxisSide.RIGHT.value: + prev_vb = self._vb_right + if prev_vb is not None: + try: + if curve.scene() is not None: + prev_vb.removeItem(curve) + except Exception: + pass + try: + target.addItem(curve) + except Exception: + pass + self._series_host[field_key] = side.value + + def _park_catalog_on_side( + self, + field_id: str, + side: AxisSide, + *, + keep_alive: Optional[Set[str]] = None, + ) -> None: + if not field_id or is_axis_idle_id(field_id): + return + # 对侧仍要显示时只解除本侧占用,禁止清数据/隐曲线 + if keep_alive and field_id in keep_alive: + return + for key, series in ( + (field_id, self._series.get(field_id)), + (f"{field_id}_ref", self._compare_series.get(field_id)), + ): + if series is None: + continue + try: + series.setData([], []) + series.setVisible(False) + except Exception: + pass + + def _activate_idle_on_side( + self, + side: AxisSide, + *, + keep_alive: Optional[Set[str]] = None, + ) -> None: + cur = self._side_active.get(side.value) + if cur and is_axis_idle_id(str(cur)): + self._side_active[side.value] = axis_idle_series_id(side) + return + if cur and not is_axis_idle_id(str(cur)): + self._park_catalog_on_side(str(cur), side, keep_alive=keep_alive) + idle = self._idle_series.get(side.value) + if idle is not None: + try: + idle.setData([], []) + idle.setVisible(False) + except Exception: + pass + self._side_active[side.value] = axis_idle_series_id(side) + + def _activate_catalog_on_side( + self, + field_id: str, + side: AxisSide, + *, + keep_alive: Optional[Set[str]] = None, + ) -> None: + cur = self._side_active.get(side.value) + if cur == field_id: + self._bind_series_to_side(field_id, side) + series = self._series.get(field_id) + try: + if series is not None: + series.setVisible(True) + except Exception: + pass + return + if cur and not is_axis_idle_id(str(cur)): + self._park_catalog_on_side(str(cur), side, keep_alive=keep_alive) + self._bind_series_to_side(field_id, side) + idle = self._idle_series.get(side.value) + if idle is not None: + try: + idle.setData([], []) + idle.setVisible(False) + except Exception: + pass + series = self._series.get(field_id) + try: + if series is not None: + series.setVisible(True) + except Exception: + pass + self._side_active[side.value] = field_id + + def _bind_series_to_side(self, field_id: str, side: AxisSide) -> None: + if field_id not in self._series or is_axis_idle_id(field_id): + return + self._field_axis_side[field_id] = side + self._move_curve_to_side(self._series.get(field_id), field_id, side) + self._move_curve_to_side(self._compare_series.get(field_id), f"{field_id}_ref", side) + + def set_slot_selection(self, slots: Sequence[tuple]) -> None: + self._clear_cursor_graphics() + side_field: Dict[str, Optional[str]] = { + AxisSide.LEFT.value: None, + AxisSide.RIGHT.value: None, + } + side_show: Dict[str, bool] = { + AxisSide.LEFT.value: False, + AxisSide.RIGHT.value: False, + } + used: Set[str] = set() + for i, item in enumerate(slots): + if i >= len(SERIES_SLOT_SIDES): + break + fid, vis = item[0], bool(item[1]) + fid = str(fid or "") + side = SERIES_SLOT_SIDES[i] + if not fid or fid not in self._series or is_axis_idle_id(fid): + side_field[side.value] = None + continue + side_field[side.value] = fid + if (not vis) or (fid in used): + continue + used.add(fid) + side_show[side.value] = True + + visible_ids = {fid for fid, on in ( + (side_field[AxisSide.LEFT.value], side_show[AxisSide.LEFT.value]), + (side_field[AxisSide.RIGHT.value], side_show[AxisSide.RIGHT.value]), + ) if on and fid} + # 先结算两侧目标,再激活;停靠时保留仍要显示的字段 + for side in SERIES_SLOT_SIDES: + fid = side_field.get(side.value) + if fid and side_show.get(side.value): + self._activate_catalog_on_side(fid, side, keep_alive=visible_ids) + else: + self._activate_idle_on_side(side, keep_alive=visible_ids) + + self._side_field = side_field + self._visible_ids = visible_ids + self._sync_compare_visibility() + primary = None + left_id = side_field.get(AxisSide.LEFT.value) + if left_id and left_id in visible_ids: + primary = self._config.field_by_id(left_id) + if primary is None: + right_id = side_field.get(AxisSide.RIGHT.value) + if right_id and right_id in visible_ids: + primary = self._config.field_by_id(right_id) + if primary is not None: + self._primary_field = primary + self._primary_series = self._series.get(primary.id) + else: + self._primary_field = None + self._primary_series = None + + def set_visible_fields(self, field_ids: Iterable[str]) -> None: + wanted = {str(x) for x in field_ids if x} + slots = [] + for side in SERIES_SLOT_SIDES: + fid = self._side_field.get(side.value) or "" + slots.append((fid, bool(fid) and fid in wanted)) + self.set_slot_selection(slots) + + def set_compare_history(self, history: Optional[ChartHistoryBuffer]) -> None: + self._compare_history = history + if history is None: + self._clear_compare_points() + + def set_compare_style(self, style: CompareStyle) -> None: + """对比样式:显隐仅切 Visible;显示时按上次视窗刷新切片,不整表重绘。""" + self._compare_style = style + self._apply_compare_pens() + if ( + bool(self._compare_style.visible) + and self._compare_history is not None + and self._last_compare_view is not None + ): + v0, v1 = self._last_compare_view + self._update_compare_in_view(float(v0), float(v1)) + return + self._sync_compare_visibility() + + def _sync_compare_visibility(self) -> None: + """仅 setVisible;隐藏时保留已切片数据,避免再勾选时重灌。""" + show_ref = bool(self._compare_style.visible) and self._compare_history is not None + for field_id, series in self._compare_series.items(): + show = show_ref and field_id in self._visible_ids + try: + series.setVisible(show) + except Exception: + pass + + def is_field_visible(self, field_id: str) -> bool: + fid = str(field_id) + if is_axis_idle_id(fid): + return False + return fid in self._visible_ids + + def _refresh_axis_meta(self) -> None: + for side in (AxisSide.LEFT, AxisSide.RIGHT): + axis = self._axes_y.get(side.value) + if axis is None: + continue + active = self._side_active.get(side.value) + fid = self._side_field.get(side.value) + use_idle = ( + active is None + or is_axis_idle_id(str(active)) + or not fid + or fid not in self._visible_ids + ) + if use_idle: + ymin, ymax = AXIS_IDLE_SPEC.ylim() + title = AXIS_IDLE_SPEC.axis_title + else: + field = self._config.field_by_id(fid) + if field is None: + ymin, ymax = AXIS_IDLE_SPEC.ylim() + title = AXIS_IDLE_SPEC.axis_title + else: + ymin, ymax = field.ylim() + title = field.axis_title or "" + if not math.isfinite(ymin) or not math.isfinite(ymax) or ymax <= ymin: + ymin, ymax = AXIS_IDLE_SPEC.ylim() + title = AXIS_IDLE_SPEC.axis_title + try: + cur_min = float(axis.min()) + cur_max = float(axis.max()) + need_range = abs(cur_min - float(ymin)) > 1e-9 or abs(cur_max - float(ymax)) > 1e-9 + if need_range: + axis.setRange(float(ymin), float(ymax)) + if axis.titleText() != title: + axis.setTitleText(title) + if not axis.isVisible(): + axis.setVisible(True) + except Exception: + pass + + def field_axis(self, field: ChartFieldSpec) -> Optional[AxisProxy]: + side = self._field_axis_side.get(field.id, field.side) + return self._axes_y.get(side.value) + + def _compare_pen_for_field(self, field: ChartFieldSpec): + return _mk_pen( + compare_field_qcolor( + field.color_key, + getattr(field, "color", ""), + opacity=float(getattr(self._compare_style, "opacity", CHART_HISTORY_OPACITY)), + ), + float(self._compare_style.line_width), + compare_pen_style(self._compare_style), + opacity=1.0, # alpha 已在 compare_field_qcolor 内 + ) + + def _apply_compare_pens(self) -> None: + fields_by_id = {f.id: f for f in self._config.fields} + for field_id, series in self._compare_series.items(): + field = fields_by_id.get(field_id) + if field is None: + continue + series.setPen(self._compare_pen_for_field(field)) + + def _replace_series(self, series: pg.PlotDataItem, xs, ys) -> None: + try: + pts = series_points(xs, ys) + if not pts: + series.setData([], []) + return + x_arr = [p[0] for p in pts] + y_arr = [p[1] for p in pts] + series.setData(x_arr, y_arr) + except Exception: + pass + + def append_live_point(self, t_rel: float, field_data: dict) -> bool: + t = float(t_rel) + if not math.isfinite(t): + return False + for field in self._config.fields: + if field.id not in self._visible_ids: + continue + series = self._series.get(field.id) + if series is None: + continue + raw = field_data.get(field.id, float("nan")) + try: + y = float(raw) + except (TypeError, ValueError): + y = float("nan") + if not math.isfinite(y): + continue + try: + xd, yd = series.getData() + if xd is None or len(xd) == 0: + series.setData([t], [y]) + else: + series.setData(list(xd) + [t], list(yd) + [y]) + except Exception: + pass + if self._axis_x is None: + return False + try: + if t > float(self._axis_x.max()): + return True + except Exception: + return True + return False + + def update_series(self, ctx: ChartRenderContext) -> None: + if not ctx.xs: + for field_id, series in self._series.items(): + if field_id in self._visible_ids: + series.setData([], []) + else: + for field in self._config.fields: + series = self._series.get(field.id) + if series is None: + continue + if field.id not in self._visible_ids: + try: + series.setData([], []) + series.setVisible(False) + except Exception: + pass + continue + try: + series.setVisible(True) + except Exception: + pass + self._replace_series(series, ctx.xs, ctx.field_data.get(field.id, [])) + self._update_compare_in_view(float(ctx.view_start), float(ctx.view_end)) + + def _update_compare_in_view(self, view_start: float, view_end: float) -> None: + self._last_compare_view = (float(view_start), float(view_end)) + if self._compare_history is None: + self._clear_compare_points() + return + if not self._compare_style.visible: + # 隐藏:保留点列,只切显隐(视窗变化时也不清数据) + self._sync_compare_visibility() + return + xs, field_data = self._compare_history.slice_view(view_start, view_end) + for field in self._config.fields: + series = self._compare_series.get(field.id) + if series is None: + continue + if field.id not in self._visible_ids: + try: + series.setVisible(False) + except Exception: + pass + continue + try: + series.setVisible(True) + except Exception: + pass + self._replace_series(series, xs, field_data.get(field.id, [])) + + def _clear_compare_points(self) -> None: + for series in self._compare_series.values(): + series.setData([], []) + try: + series.setVisible(False) + except Exception: + pass + + def apply_axis_ranges(self, ctx: ChartRenderContext) -> None: + if self._axis_x is not None: + start = float(ctx.view_start) + end = float(ctx.view_end) + if not math.isfinite(start) or not math.isfinite(end) or end <= start: + window = self._config.view_window_s if self._config else 150.0 + start, end = 0.0, float(window) + try: + self._axis_x.setRange(start, end) + except Exception: + pass + self._refresh_axis_meta() + + def apply_empty_axis_ranges(self) -> None: + window = self._config.view_window_s + if self._axis_x is not None: + try: + self._axis_x.setRange(0.0, window) + except Exception: + pass + self._refresh_axis_meta() + self._update_compare_in_view(0.0, float(window)) + + def side_field_ids(self) -> Dict[str, Optional[str]]: + return dict(self._side_field) + + def set_cursor(self, t_rel: Optional[float], lines: Optional[Sequence[str]] = None) -> None: + self._cursor_t = None if t_rel is None else float(t_rel) + self._cursor_lines = list(lines or []) + + def clear_cursor(self) -> None: + self._cursor_t = None + self._cursor_lines = [] + self._clear_cursor_graphics() + + def _clear_cursor_graphics(self) -> None: + pi = self._plot_item + if self._cursor_line is not None and pi is not None: + try: + pi.removeItem(self._cursor_line) + except Exception: + pass + self._cursor_line = None + if self._cursor_label is not None and pi is not None: + try: + pi.removeItem(self._cursor_label) + except Exception: + pass + self._cursor_label = None + + def compare_history(self) -> Optional[ChartHistoryBuffer]: + return self._compare_history + + def primary_series(self) -> Optional[pg.PlotDataItem]: + if self._primary_series is None: + return None + try: + if not self._primary_series.isVisible() or _curve_len(self._primary_series) <= 0: + return None + except Exception: + return None + return self._primary_series + + def _cursor_anchor_series(self) -> Optional[pg.PlotDataItem]: + primary = self.primary_series() + if primary is not None: + return primary + if self._primary_field is None: + return None + ref = self._compare_series.get(self._primary_field.id) + if ref is None: + return None + try: + if not ref.isVisible() or _curve_len(ref) <= 0: + return None + except Exception: + return None + return ref + + def update_cursor(self, ctx: Optional[ChartRenderContext]) -> None: + self._clear_cursor_graphics() + if ctx is None or self._plot_item is None or self._cursor_t is None: + return + t = float(self._cursor_t) + if not math.isfinite(t): + return + if t < float(ctx.view_start) - 1e-6 or t > float(ctx.view_end) + 1e-6: + return + if self._primary_field is None or self._primary_field.id not in self._visible_ids: + return + axis = self.field_axis(self._primary_field) + if axis is None: + return + try: + if not axis.isVisible(): + return + except Exception: + return + ymin, _ymax = self._primary_field.ylim() + line = pg.InfiniteLine( + pos=t, + angle=90, + pen=pg.mkPen(QColor(Qt.white), width=1.2), + movable=False, + ) + self._plot_item.addItem(line) + self._cursor_line = line + + if not self._cursor_lines: + return + from charts.theme import apply_chart_float_font, chart_float_html + + # 浮窗在游标最下方:底边锚在 Y 下沿,文字向上展开;再上移数像素,避开 X 轴刻度 + label = pg.TextItem(html=chart_float_html(self._cursor_lines), anchor=(0, 1)) + apply_chart_float_font(label) + y_pad = 0.0 + try: + vb = self._plot_item.getViewBox() + _, dy = vb.viewPixelSize() + gap_px = UITheme.scaled(CHART_FLOAT_BOTTOM_GAP_PX, 4) + y_pad = abs(float(dy)) * float(gap_px) + except Exception: + y_pad = 0.0 + label.setPos(t, float(ymin) + y_pad) + self._plot_item.addItem(label) + self._cursor_label = label + + def axis_x(self) -> Optional[AxisProxy]: + return self._axis_x + + def clear(self) -> None: + for series in self._series.values(): + try: + series.setData([], []) + except Exception: + pass + + # 供 pan / 蒙版:曲线点数 + @staticmethod + def curve_count(series) -> int: + return _curve_len(series) diff --git a/charts/context.py b/charts/context.py new file mode 100644 index 00000000..4e61163b --- /dev/null +++ b/charts/context.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +""" +模块:charts.context +职责:一次 render 周期的共享上下文(视窗、切片、Plot 引用) +依赖:无硬 QtCharts +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +@dataclass +class ChartRenderContext: + """一次 render 周期内的共享上下文。""" + + history: Any = None + view_start: float = 0.0 + view_end: float = 150.0 + view_window_s: float = 150.0 + xs: List[float] = field(default_factory=list) + field_data: Dict[str, List[float]] = field(default_factory=dict) + # plot_widget:PlotWidget;qchart 为同义属性(蒙版/门面沿用) + plot_widget: Optional[Any] = None + axis_x: Optional[Any] = None + live_mode: bool = True + config: Any = None + + @property + def qchart(self) -> Optional[Any]: + return self.plot_widget diff --git a/charts/facade/__init__.py b/charts/facade/__init__.py new file mode 100644 index 00000000..8451c210 --- /dev/null +++ b/charts/facade/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""包占位:charts.facade(请从 charts 或 trend_chart 导入)。""" diff --git a/charts/facade/trend_chart.py b/charts/facade/trend_chart.py new file mode 100644 index 00000000..5f3eebae --- /dev/null +++ b/charts/facade/trend_chart.py @@ -0,0 +1,1081 @@ +# -*- coding: utf-8 -*- +""" +模块:charts.facade.trend_chart +职责:训练页成品趋势图 facade(主折线 + 航迹图 + 操纵输入 + 全局游标 + 对比) +依赖:ChartDisplayArea、LineChart、TrackMapPane、StickInputPane、顶栏下拉、timeline +""" + +from __future__ import annotations + +from typing import Callable, List, Optional, Sequence, Tuple + +from PySide2.QtCore import Signal, QTimer, Qt +from PySide2.QtGui import QColor, QPalette +from PySide2.QtWidgets import ( + QHBoxLayout, + QLabel, + QVBoxLayout, + QWidget, +) + +from components.Frame.Frame_Theme_UI import UITheme +from charts.base.line_chart import LineChart +from charts.slot import ChartDisplayArea, ChartSlot +from charts.mask.registry import create_masks +from charts.widgets.chart_toolbar import ChartToolbar +from charts.widgets.stick_input import StickInputPane +from charts.widgets.timeline_scrubber import TimelineScrubber +from charts.widgets.track_color_toolbar import TrackColorToolbar +from charts.widgets.track_map_pane import TrackMapPane +from models.chart import ( + ChartConfig, + ChartHistoryBuffer, + TRAINING_CHART, + relative_times, + series_option, +) +from models.cursor_model import CursorModel, CursorState +from models.data_bridge import load_overlay_config +from setting import ( + CHART_CANVAS_GAP_PX, + MSG_COMPARE_MISSION_MISMATCH, + SPACE_NONE, + SPACE_WIDGET_GAP, + SPACE_WIDGET_PAD, + TRACK_VIEW_LOCK_OFF, + TRACK_VIEW_LOCK_ON, + UI_TIMELINE_BG, +) + +from models.hardware_monitor import StickAxes +from models.track_compare import ( + CompareStyle, + TrackComparePayload, + lap_finish_markers_from_samples, +) +from models.track_map import TrackPoint, points_from_trajectory_samples +from models.lap_reference import LAP_COUNT + + +class TrendChart(QWidget): + """可配置趋势图;底部全局游标同步折线 / 航迹 / 摇杆。""" + + compareActiveChanged = Signal(bool, str) # active, label + compareHintChanged = Signal(str) + colorFieldChanged = Signal(str) + + def __init__(self, parent=None, config: Optional[ChartConfig] = None): + super().__init__(parent) + self.setObjectName("TrendChart") + self.setAttribute(Qt.WA_StyledBackground, True) + self._config = config or TRAINING_CHART + overlay_config = load_overlay_config() + + self.main_slot = ChartSlot( + LineChart(self._config), + create_masks(overlay_config), + ) + self._track_pane = TrackMapPane() + self._track_chrome = TrackColorToolbar() + self._track_chrome.colorFieldChanged.connect(self._on_color_field_changed) + self._track_chrome.overlayLapsChanged.connect(self._on_overlay_laps_changed) + self._stick_pane = StickInputPane() + self._toolbar = ChartToolbar() + self._toolbar.seriesSelectionChanged.connect(self._on_series_selection) + + # 全局时间轴:Canvas 下方整行;下拉在 Canvas 顶栏与列等宽 + self._timeline = self._build_timeline_pane() + self._display = ChartDisplayArea( + self._track_pane, + self.main_slot, + self._stick_pane, + self._track_chrome, + self._toolbar, + ) + self._toolbar.set_save_target(self._display.grab) + self._compare_style = CompareStyle() + self._compare_payload: Optional[TrackComparePayload] = None + self._compare_loader: Optional[Callable[[str], None]] = None + self._track_user_id: Optional[str] = None + self._pending_slots = None + self._history: Optional[ChartHistoryBuffer] = None + self._syncing_scrub = False + self._live_stick: Optional[StickAxes] = None + self._mission_title: str = "" + self._cursor = CursorModel(self) + self._cursor.changed.connect(self._on_cursor_changed) + self._applying_cursor = False + + gap = int(CHART_CANVAS_GAP_PX) + layout = QVBoxLayout(self) + layout.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + layout.setSpacing(gap) + layout.addWidget(self._display, 1) + layout.addWidget(self._timeline, 0) + self.main_slot.live_mode = True + self.main_slot.view_end = None + + self._track_pane.set_color_field(self._track_chrome.color_field()) + self._apply_series_selection(self._toolbar.selection()) + self.set_mission_title(None) + + def set_mission_title(self, title: Optional[str] = None) -> None: + """按圈时板当前任务名加载 time_rule,刷新性能图 X 轴限位。""" + from models.mission_time_rule import load_mission_time_rule + + self._mission_title = (title or "").strip() + rule = load_mission_time_rule(self._mission_title or None) + self.main_slot.set_time_rule(rule) + + @property + def _follow_live(self) -> bool: + return self._cursor.follow_live + + @_follow_live.setter + def _follow_live(self, value: bool) -> None: + if self._applying_cursor or self._syncing_scrub: + self._cursor.assign(follow_live=bool(value), emit=False) + return + self._cursor.set_follow_live(bool(value)) + + def _build_timeline_pane(self) -> QWidget: + """略深于 SURFACE 的时间轴条;滑块为向右飞行的剪影飞机。""" + pane_bg = UI_TIMELINE_BG + + pane = QWidget(self) + pane.setObjectName("TimelinePane") + pane.setAttribute(Qt.WA_StyledBackground, True) + pane.setAutoFillBackground(True) + pal = pane.palette() + pal.setColor(QPalette.Window, QColor(pane_bg)) + pal.setColor(QPalette.Base, QColor(pane_bg)) + pane.setPalette(pal) + pane.setStyleSheet( + f""" + QWidget#TimelinePane {{ + background-color: {pane_bg}; + border: {UITheme.border_css()}; + border-radius: {UITheme.scaled(UITheme.BORDER_RADIUS, 3)}px; + }} + QWidget#TimelinePane QLabel {{ + color: {UITheme.FG_MUTED}; + background: transparent; + border: none; + }} + """ + ) + + row = QHBoxLayout(pane) + row.setContentsMargins( + SPACE_WIDGET_GAP, + SPACE_WIDGET_PAD, + SPACE_WIDGET_GAP, + SPACE_WIDGET_PAD, + ) + row.setSpacing(SPACE_WIDGET_GAP) + scrub_lbl = QLabel("时间轴") + self._scrub = TimelineScrubber(track_color=pane_bg) + self._scrub.setObjectName("TimelineScrubber") + self._scrub.setMinimum(0) + self._scrub.setMaximum(0) + self._scrub.setValue(0) + self._scrub.setEnabled(False) + self._scrub.valueChanged.connect(self._on_scrub) + self._scrub_pos = QLabel("0.0%") + self._scrub_pos.setMinimumWidth(UITheme.scaled(48, 40)) + self._scrub_pos.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + row.addWidget(scrub_lbl, 0) + row.addWidget(self._scrub, 1) + row.addWidget(self._scrub_pos, 0) + return pane + + def _on_series_selection(self, slots) -> None: + """工具栏变更:推迟到下一事件循环,避免与轮询 render 重入崩 Qt Charts。""" + self._pending_slots = list(slots) + QTimer.singleShot(0, self._flush_series_selection) + + def _flush_series_selection(self) -> None: + slots = self._pending_slots + if slots is None: + return + self._pending_slots = None + # render() 内已 update_cursor;勿再 paint 触发二次全量/色带重建 + self._apply_series_selection(slots) + + def _apply_series_selection(self, slots) -> None: + # 必须先卸健康色带/圈标,再改显隐与轴量程 + slot = self.main_slot + for ov in slot.overlays: + ov.clear(slot.qchart) + line = slot.base_chart + if isinstance(line, LineChart): + line._clear_cursor_graphics() + line.set_slot_selection(slots) + slot.render() + + def _on_cursor_changed(self, state: CursorState) -> None: + """游标订阅:同步 scrub / 航迹 / 折线 / 摇杆。""" + if self._applying_cursor: + return + self._applying_cursor = True + try: + self._apply_cursor_state(state) + finally: + self._applying_cursor = False + + def _apply_cursor_state(self, state: CursorState) -> None: + self._ensure_stick_envelope_series() + n = int(state.length) + idx = int(state.index) + self._syncing_scrub = True + if n <= 1: + self._scrub.setEnabled(False) + self._scrub.setMaximum(0) + self._scrub.setValue(0) + else: + self._scrub.setEnabled(True) + self._scrub.setMaximum(n - 1) + self._scrub.setPageStep(max(10, n // 50)) + self._scrub.setValue(idx) + self._syncing_scrub = False + if self._track_pane.scrub_point_count() > 0: + self._track_pane.set_cursor_index(idx) + self._track_pane.sync_focus_lap_from_cursor() + self._sync_track_chrome_focus_lap() + self._update_scrub_label() + self._paint_cursor_views(state) + + def _paint_cursor_views(self, state: CursorState) -> None: + """按 CursorState 刷新折线竖线与摇杆(不再由子图互调)。""" + pt = self._track_pane.cursor_point() + line = self.main_slot.base_chart + idx = int(state.index) + follow = bool(state.follow_live) + + if pt is None: + hist = self._history_for_cursor() + if hist is None or len(hist) <= 0 or not isinstance(line, LineChart): + if isinstance(line, LineChart): + line.clear_cursor() + self._sync_session_stick(follow=follow, session_pt=None) + self._sync_compare_stick(None, scrub_pt=None) + self._sync_stick_envelopes_at(None, follow=follow) + return + t = hist.relative_time_at(idx) + if t is None: + t = float(state.t_rel) + self._cursor.assign(t_rel=float(t), emit=False) + self._sync_line_view_to_cursor(float(t), follow=follow) + vals = hist.values_at_relative(float(t)) + cmp_vals = ( + self._compare_vals_at(float(t)) + if isinstance(self._history, ChartHistoryBuffer) and len(self._history) > 0 + else None + ) + middle = self._cursor_selected_field_lines(line, vals, hist_vals=cmp_vals) + speed, alt = self._cursor_speed_alt(None, vals) + line.set_cursor( + float(t), + self._cursor_float_lines(float(t), middle, speed_kts=speed, altitude_ft=alt), + ) + self._sync_session_stick(follow=follow, session_pt=None) + self._sync_compare_stick(float(t), scrub_pt=None) + self._sync_stick_envelopes_at(float(t), follow=follow) + if follow: + self.main_slot.refresh_cursor_only() + else: + self.main_slot.render() + return + + cursor_t: Optional[float] = None + session_pt = self._track_pane.session_point_at_cursor() + self._sync_session_stick(follow=follow, session_pt=session_pt) + + if isinstance(line, LineChart): + t = self._history_cursor_time(pt, idx) + cursor_t = t + self._cursor.assign(t_rel=float(t), emit=False) + self._sync_line_view_to_cursor(t, follow=follow) + middle: List[str] = [] + vals: Optional[dict] = None + hist = self._history_for_cursor() + if hist is not None: + vals = hist.values_at_relative(t) + cmp_vals = ( + self._compare_vals_at(t) + if isinstance(self._history, ChartHistoryBuffer) + and len(self._history) > 0 + and hist is self._history + else None + ) + middle = self._cursor_selected_field_lines( + line, vals, hist_vals=cmp_vals + ) + speed, alt = self._cursor_speed_alt(pt, vals) + line.set_cursor( + t, + self._cursor_float_lines(t, middle, speed_kts=speed, altitude_ft=alt), + ) + if follow: + self.main_slot.refresh_cursor_only() + else: + self.main_slot.render() + elif pt is not None: + cursor_t = self._history_cursor_time(pt, idx) + + self._sync_compare_stick(cursor_t, scrub_pt=pt) + self._sync_stick_envelopes_at(cursor_t, follow=follow) + + @property + def live_mode(self) -> bool: + return self.main_slot.live_mode + + @live_mode.setter + def live_mode(self, value: bool) -> None: + self.main_slot.live_mode = value + + @property + def view_end(self) -> Optional[float]: + return self.main_slot.view_end + + @view_end.setter + def view_end(self, value: Optional[float]) -> None: + self.main_slot.view_end = value + + def set_config(self, config: ChartConfig) -> None: + self._config = config + self.main_slot.set_config(config) + self.main_slot.render() + + def set_save_caption_provider(self, provider: Optional[Callable]) -> None: + self._toolbar.set_save_caption_provider(provider) + + def set_save_enabled(self, enabled: bool) -> None: + self._toolbar.set_save_enabled(enabled) + + def save_chart(self) -> None: + self._toolbar.save_chart() + + def update(self, history) -> None: + self._history = history if isinstance(history, ChartHistoryBuffer) else history + if self.live_mode: + self.view_end = None + self._display.update_all(history) + n = self._timeline_length() + self._cursor.set_length(n, prefer_end=bool(self._follow_live)) + + def append_live_frame(self, history, track_points=None) -> None: + """监测热路径:从消费缓存 pull 后的增量刷新。""" + self._history = history if isinstance(history, ChartHistoryBuffer) else history + self.live_mode = True + self.view_end = None + self.main_slot.append_live(history) + batch = None + if track_points is not None: + if isinstance(track_points, TrackPoint): + batch = [track_points] + else: + batch = list(track_points) + if batch: + was_following = self._follow_live + self._track_pane.append_track_points(batch) + # 包络只跟航迹 CSV 采样点走(约 DATA_POLL_HZ),不跟摇杆总线刷新 + self._stick_pane.append_envelope_from_track_points( + batch, invert_pitch=True, t0=self._track_envelope_t0() + ) + self._sync_track_chrome_available_laps() + self._sync_track_chrome_focus_lap() + self._sync_timeline_lap_bands() + n = self._timeline_length() + self._cursor.set_length(n, prefer_end=was_following or n <= 1) + # set_length 可能早退;按当前 follow/cursor 刷新前缀包络 + self._sync_stick_envelopes_at( + float(self._cursor.t_rel), follow=bool(self._follow_live) + ) + elif self._follow_live: + n = self._timeline_length() + self._cursor.set_length(n, prefer_end=True) + + def live_track_count(self) -> int: + return int(self._track_pane.live_track_count()) + + def resume_live_follow(self) -> None: + """重新开始监测等场景:恢复时间轴跟随与实时摇杆刷新。""" + self.main_slot.clear_user_view() + self.live_mode = True + self.view_end = None + self.main_slot.set_time_base_override(None) + # 监测态默认航迹视窗锁定 + self._track_pane.set_view_lock_mode(TRACK_VIEW_LOCK_ON) + n = self._timeline_length() + self._cursor.set_length(n, prefer_end=True) + if n <= 0 and self._live_stick is not None: + self._stick_pane.set_stick(self._live_stick) + self._stick_pane.clear_compare() + self.main_slot.render() + + def leave_live_follow(self) -> None: + """非监测:航迹视窗优先自由,便于回看拖动。""" + self._track_pane.set_view_lock_mode(TRACK_VIEW_LOCK_OFF) + + def set_track_points(self, points: Sequence[TrackPoint]) -> None: + was_following = self._follow_live + self._track_pane.set_track_points(points) + self._stick_pane.set_envelope_from_track_points(points, invert_pitch=True) + self._sync_track_chrome_available_laps() + self._sync_track_chrome_focus_lap() + self._sync_timeline_lap_bands() + n = self._timeline_length() + self._cursor.set_length(n, prefer_end=was_following or n <= 1) + self._sync_stick_envelopes_at( + float(self._cursor.t_rel), follow=bool(self._follow_live) + ) + + def is_compare_visible(self) -> bool: + return bool(getattr(self._compare_style, "visible", True)) + + def _timeline_length(self) -> int: + """时间轴长度:本场航迹优先;历史显隐关闭时不用对比缓冲。""" + n_scrub = self._track_pane.scrub_point_count() + if n_scrub > 1: + return n_scrub + if isinstance(self._history, ChartHistoryBuffer) and len(self._history) > 1: + return len(self._history) + if ( + self.is_compare_visible() + and self._compare_payload is not None + and isinstance(self._compare_payload.history, ChartHistoryBuffer) + and len(self._compare_payload.history) > 1 + ): + return len(self._compare_payload.history) + return max(n_scrub, 0) + + def _history_for_cursor(self) -> Optional[ChartHistoryBuffer]: + """折线取值缓冲:与时间轴驱动源对齐;关闭「显示历史数据」时不用对比历史。""" + n_live = self._track_pane.point_count() + n_cmp = self._track_pane.compare_point_count() + use_compare = ( + self.is_compare_visible() + and n_live <= 1 + and n_cmp > 1 + and self._compare_payload is not None + and isinstance(self._compare_payload.history, ChartHistoryBuffer) + and len(self._compare_payload.history) > 0 + ) + if use_compare: + return self._compare_payload.history + if isinstance(self._history, ChartHistoryBuffer) and len(self._history) > 0: + return self._history + if ( + self.is_compare_visible() + and self._compare_payload is not None + and isinstance(self._compare_payload.history, ChartHistoryBuffer) + and len(self._compare_payload.history) > 0 + ): + return self._compare_payload.history + return None + + def _refresh_timeline_range(self, *, prefer_end: bool) -> None: + """按当前数据源刷新时间轴可用范围(含对比-only 场景)。""" + n = self._timeline_length() + self._cursor.set_length(n, prefer_end=prefer_end) + + def _sync_timeline_lap_bands(self) -> None: + """时间轴加亮:当前圈 ∪ 叠加显示圈;与航迹 visible_laps 一致。""" + pts = self._track_pane.scrub_points() + if not pts: + self._scrub.clear_lap_bands() + return + laps = [int(getattr(p, "lap", 0) or 0) for p in pts] + flags = list(self._track_pane.visible_laps()) + while len(flags) < LAP_COUNT: + flags.append(False) + self._scrub.set_lap_bands(laps, flags[:LAP_COUNT], alpha=0.60) + + def reset_track_filters(self) -> None: + self._track_pane.reset_filters() + self._track_chrome.set_focus_lap(1) + self._track_chrome.set_overlay_laps([]) + self._track_pane.set_overlay_laps([]) + self._sync_timeline_lap_bands() + + def color_field(self) -> str: + return self._track_chrome.color_field() + + def set_color_field(self, field_id: str) -> None: + self._track_chrome.set_color_field(field_id) + self._track_pane.set_color_field(field_id) + + def _on_color_field_changed(self, field_id: str) -> None: + self._track_pane.set_color_field(field_id) + self.colorFieldChanged.emit(str(field_id)) + + def _on_overlay_laps_changed(self, laps) -> None: + self._track_pane.set_overlay_laps(list(laps or [])) + self._sync_timeline_lap_bands() + + def _sync_track_chrome_available_laps(self) -> None: + """按航迹实际圈号裁剪叠加下拉;选项被踢掉时回写 canvas。""" + prev = list(self._track_chrome.overlay_laps()) + avail = self._track_pane.available_laps() + self._track_chrome.set_available_laps(avail) + cur = list(self._track_chrome.overlay_laps()) + if cur != prev: + self._track_pane.set_overlay_laps(cur) + self._sync_timeline_lap_bands() + + def _sync_track_chrome_focus_lap(self) -> None: + """仅在当前圈变化时刷新叠加下拉;避免游标热路径整图重建。""" + focus = int(self._track_pane.focus_lap()) + prev_focus = int(self._track_chrome.focus_lap()) + if focus == prev_focus: + return + prev_overlay = list(self._track_chrome.overlay_laps()) + self._track_chrome.set_focus_lap(focus) + cur_overlay = list(self._track_chrome.overlay_laps()) + if cur_overlay != prev_overlay: + self._track_pane.set_overlay_laps(cur_overlay) + self._sync_timeline_lap_bands() + + def set_stick_axes( + self, + pitch: float = 0.0, + roll: float = 0.0, + yaw: float = 0.0, + throttle: float = 0.0, + connected: bool = True, + ) -> None: + axes = StickAxes( + pitch=pitch, + roll=roll, + yaw=yaw, + throttle=throttle, + connected=connected, + ) + self._live_stick = axes + if self._follow_live: + self._stick_pane.set_stick(axes) + self._sync_compare_stick(self._live_cursor_time()) + + def set_stick(self, axes: StickAxes) -> None: + self._live_stick = axes + if self._follow_live: + self._stick_pane.set_stick(axes) + self._sync_compare_stick(self._live_cursor_time()) + + def clear_stick(self) -> None: + self._live_stick = None + self._stick_pane.clear() + self._stick_pane.clear_envelope() + + def _live_cursor_time(self) -> Optional[float]: + """当前游标相对时间(供实时摇杆刷新时对齐历史层)。""" + pt = self._track_pane.cursor_point() + if pt is None: + return None + return self._history_cursor_time(pt, self._track_pane.cursor_index()) + + def _on_scrub(self, value: int) -> None: + if self._syncing_scrub or self._applying_cursor: + return + n = self._timeline_length() + self._cursor.assign(length=n, emit=False) + if n <= 0: + self._cursor.set_follow_live(True) + return + idx = max(0, min(n - 1, int(value))) + self._cursor.set_index(idx) + + def _set_scrub_index(self, index: int, *, apply: bool) -> None: + n = self._timeline_length() + if n <= 0: + self._cursor.set_length(0, prefer_end=True) + return + idx = max(0, min(n - 1, int(index))) + self._cursor.assign(length=n, index=idx, follow_live=(idx >= n - 1), emit=apply) + if not apply: + # 仍同步 scrub 外观 + self._syncing_scrub = True + self._scrub.setMaximum(max(0, n - 1)) + self._scrub.setEnabled(n > 1) + self._scrub.setValue(idx) + self._syncing_scrub = False + self._update_scrub_label() + + def _update_scrub_label(self) -> None: + n = self._timeline_length() + if n <= 0: + self._scrub_pos.setText("0.0%") + else: + pct = (float(self._cursor.index + 1) / float(n)) * 100.0 + self._scrub_pos.setText(f"{pct:.1f}%") + + def _history_cursor_time(self, pt: TrackPoint, idx: int) -> float: + """航迹时间 → 折线图相对 X(与 relative_times 对齐)。""" + hist = self._history_for_cursor() + if hist is not None and len(hist) > 0: + n_hist = len(hist) + n_track = self._track_pane.scrub_point_count() + if n_hist == n_track: + mapped = hist.relative_time_at(idx) + if mapped is not None: + return float(mapped) + t0 = float(hist.timestamps[0]) + return float(pt.timestamp_s) - t0 + return float(pt.timestamp_s) + + def _sync_line_view_to_cursor(self, t_rel: float, *, follow: Optional[bool] = None) -> None: + """回放时把折线视窗钉到游标;跟随直播则回到最新端。""" + hist = self._history_for_cursor() + live = self._history if isinstance(self._history, ChartHistoryBuffer) else None + if hist is not None and live is not None and hist is not live: + self.main_slot.set_time_base_override(hist) + else: + self.main_slot.set_time_base_override(None) + + use_follow = self._follow_live if follow is None else bool(follow) + # 用户手动缩放/拖动中:禁止把视窗抢回直播端 + if self.main_slot.has_user_x_view(): + return + if use_follow: + self.live_mode = True + self.view_end = None + return + self.live_mode = False + if hist is None or len(hist) == 0: + return + times = relative_times(list(hist.timestamps)) + if not times: + return + t_max = float(times[-1]) + self.view_end = max(0.0, min(float(t_rel), t_max)) + + def _stick_from_point(self, pt: Optional[TrackPoint]) -> Optional[StickAxes]: + if pt is None or not pt.stick_connected(): + return None + return StickAxes( + pitch=0.0 if pt.stick_pitch != pt.stick_pitch else float(pt.stick_pitch), + roll=0.0 if pt.stick_roll != pt.stick_roll else float(pt.stick_roll), + yaw=0.0 if pt.stick_yaw != pt.stick_yaw else float(pt.stick_yaw), + throttle=0.0 if pt.stick_throttle != pt.stick_throttle else float(pt.stick_throttle), + connected=True, + ) + + def _sync_session_stick( + self, + *, + follow: bool, + session_pt: Optional[TrackPoint], + ) -> None: + """黄层=本场:跟随直播用硬件杆;scrub 用本场航迹游标点(绝不用对比点)。""" + if follow and self._live_stick is not None and self._live_stick.connected: + self._stick_pane.set_stick(self._live_stick) + return + axes = self._stick_from_point(session_pt) + if axes is not None: + self._stick_pane.set_session_axes( + axes.pitch, + axes.roll, + axes.yaw, + axes.throttle, + connected=True, + ) + return + self._stick_pane.set_session_axes(0.0, 0.0, 0.0, 0.0, connected=False) + + def _sync_compare_stick( + self, + t_rel: Optional[float], + *, + scrub_pt: Optional[TrackPoint] = None, + ) -> None: + """蓝层=历史对比;不得清包络;不得写入黄层。""" + has_hist_data = self._track_pane.compare_point_count() > 1 + if not has_hist_data: + self._stick_pane.clear_compare() + return + if not self.is_compare_visible(): + self._stick_pane.set_compare_visible(False) + return + self._stick_pane.set_compare_visible(True) + session_n = self._track_pane.point_count() + cmp_pt: Optional[TrackPoint] = None + if session_n > 1 and t_rel is not None: + cmp_pt = self._track_pane.compare_point_at_time(float(t_rel)) + elif session_n <= 1 and scrub_pt is not None: + cmp_pt = scrub_pt + elif t_rel is not None: + cmp_pt = self._track_pane.compare_point_at_time(float(t_rel)) + axes = self._stick_from_point(cmp_pt) + if axes is None: + self._stick_pane.clear_compare() + else: + self._stick_pane.set_compare_stick(axes) + + def _track_envelope_t0(self) -> Optional[float]: + """本场包络相对时间零点:航迹首个有效 timestamp(与时间轴一致)。""" + for pt in self._track_pane.all_points() or (): + if pt is None: + continue + try: + ts = float(pt.timestamp_s) + except (TypeError, ValueError, AttributeError): + continue + if ts == ts: + return ts + return None + + def _ensure_stick_envelope_series(self) -> None: + """时序样本缺失时从航迹点恢复(仅 CSV;不按游标截取)。""" + if self._track_pane.point_count() > 0 and not self._stick_pane.has_envelope(): + self._stick_pane.set_envelope_from_track_points( + self._track_pane.all_points(), invert_pitch=True + ) + if ( + self._track_pane.compare_point_count() > 0 + and not self._stick_pane.has_compare_envelope() + ): + self._stick_pane.set_compare_envelope_from_track_points( + self._track_pane.compare_points(), invert_pitch=True + ) + + def _sync_stick_envelopes_at( + self, t_rel: Optional[float], *, follow: bool + ) -> None: + """跟随直播显示全量;scrub 时按相对时间前缀生长包络。""" + self._ensure_stick_envelope_series() + if follow: + self._stick_pane.show_envelope_until(None) + self._stick_pane.show_compare_envelope_until(None) + return + t = 0.0 if t_rel is None else float(t_rel) + self._stick_pane.show_envelope_until(t) + self._stick_pane.show_compare_envelope_until(t) + + def _cursor_selected_field_lines( + self, + line: LineChart, + vals: dict, + hist_vals: Optional[dict] = None, + ) -> list: + """工具栏下拉已选且勾选「显示」的字段:当前黄 / 历史蓝;空速由固定行覆盖则跳过。""" + middle: list = [] + sides = line.side_field_ids() + show_hist = bool(hist_vals) and self.is_compare_visible() + for side_key, title in (("left", "L"), ("right", "R")): + fid = sides.get(side_key) + if not fid or not line.is_field_visible(fid): + continue + if fid == "airspeed": + continue + opt = series_option(fid) + name = opt.label if opt is not None else fid + v = vals.get(fid, float("nan")) if vals else float("nan") + cur_txt = "--" if v != v else f"{float(v):.2f}" + parts = [ + (f"{title} {name}: ", "default"), + (cur_txt, "current"), + ] + if show_hist: + hv = hist_vals.get(fid, float("nan")) if hist_vals else float("nan") + hist_txt = "--" if hv != hv else f"{float(hv):.2f}" + parts.extend([(" / ", "default"), (hist_txt, "history")]) + middle.append({"parts": parts}) + return middle + + @staticmethod + def _fmt_cursor_value(v: float, pattern: str) -> str: + if v != v: + return "--" + return format(float(v), pattern) + + def _cursor_float_lines( + self, + t_rel: float, + middle: Optional[list] = None, + *, + speed_kts: float = float("nan"), + altitude_ft: float = float("nan"), + ) -> list: + """浮窗:时间/空速/高度固定(default 色)+ 下拉字段(当前黄 / 历史蓝)。""" + lines: list = [ + (f"时间 {float(t_rel):.2f}s", "default"), + (f"空速 {self._fmt_cursor_value(speed_kts, '.1f')} kt", "default"), + (f"高度 {self._fmt_cursor_value(altitude_ft, '.0f')} ft", "default"), + ] + for item in middle or []: + if isinstance(item, dict) and "parts" in item: + lines.append(item) + continue + if isinstance(item, (tuple, list)) and len(item) >= 2: + s = str(item[0]).strip() + role = item[1] + else: + s = str(item).strip() + role = "current" + if not s: + continue + low = s.lower() + if low.startswith("t ") and s.rstrip().endswith("s"): + continue + if s.startswith("时间 ") or s.startswith("空速 ") or s.startswith("高度 "): + continue + if low.startswith("lap"): + continue + lines.append((s, role)) + return lines + + def _compare_vals_at(self, t_rel: float) -> Optional[dict]: + """对比历史在相对时刻的字段值;不可见或无载荷则 None。""" + if not self.is_compare_visible() or self._compare_payload is None: + return None + buf = self._compare_payload.history + if not isinstance(buf, ChartHistoryBuffer) or len(buf) <= 0: + return None + return buf.values_at_relative(float(t_rel)) + + def _cursor_speed_alt( + self, + pt: Optional[TrackPoint], + vals: Optional[dict], + ) -> Tuple[float, float]: + """固定行取值:空速优先缓冲,高度取航迹点。""" + speed = float("nan") + alt = float("nan") + if vals: + v = vals.get("airspeed", float("nan")) + if v == v: + speed = float(v) + if pt is not None: + if speed != speed and pt.airspeed_kts == pt.airspeed_kts: + speed = float(pt.airspeed_kts) + if pt.altitude_ft == pt.altitude_ft: + alt = float(pt.altitude_ft) + return speed, alt + + def _apply_global_cursor(self) -> None: + """兼容入口:转发到 CursorModel 订阅路径。""" + n = self._timeline_length() + self._cursor.assign(length=n, emit=False) + self._on_cursor_changed(self._cursor.state()) + + def is_compare_active(self) -> bool: + return self._compare_payload is not None + + def compare_style(self) -> CompareStyle: + return self._compare_style + + def compare_label(self) -> str: + if self._compare_payload is None: + return "" + return self._compare_payload.label or "" + + def set_compare_loader(self, loader: Optional[Callable[[str], None]]) -> None: + """可选:异步对比加载(分析线程);参数为云端 record_id。""" + self._compare_loader = loader + + def set_track_user_id(self, user_id: Optional[str]) -> None: + """登录用户 id:对比列表从云端 user_defined_3 读取。""" + self._track_user_id = str(user_id or "").strip() or None + + def load_compare_dialog(self) -> None: + from components.Frame.Frame_Dialog_ThemeAlert import show_theme_alert + from components.Frame.Frame_Dialog_TrackStorePick import fetch_and_pick_track + from setting import MSG_TRACK_STORE_NEED_LOGIN + + uid = getattr(self, "_track_user_id", None) + if not uid: + show_theme_alert( + self, + title="提示", + message=MSG_TRACK_STORE_NEED_LOGIN, + kind="warn", + show_title=True, + ) + return + + def _after_pick(record_id, picked) -> None: + if not record_id: + return + if picked and self._mission_title: + from models.mission_switch import ( + resolve_canonical_mission_display, + resolve_canonical_mission_key, + ) + from models.shadow_plane import mission_identities_match + + # 优先 mission_key;显示名 / FLT Title 作旧数据兼容 + current_key = resolve_canonical_mission_key( + mission_title=self._mission_title, + display_hint=resolve_canonical_mission_display( + mission_title=self._mission_title, + ) + or None, + ) + current_display = resolve_canonical_mission_display( + mission_title=self._mission_title, + mission_key=current_key or None, + ) + file_key = (getattr(picked, "mission_key", None) or "").strip() + if not file_key: + file_key = resolve_canonical_mission_key( + mission_title=getattr(picked, "mission_flt_title", None) + or getattr(picked, "mission_title", None), + display_hint=getattr(picked, "mission_title", None), + ) + file_display = (getattr(picked, "mission_title", None) or "").strip() + file_flt = (getattr(picked, "mission_flt_title", None) or "").strip() + if current_key and file_key and current_key.lower() == file_key.lower(): + pass # 同 mission_key,直接放行 + elif not mission_identities_match( + [self._mission_title, current_display, current_key], + [file_display, file_flt, file_key], + ): + show_theme_alert( + self, + title="提示", + message=MSG_COMPARE_MISSION_MISMATCH, + kind="warn", + show_title=True, + ) + self.compareHintChanged.emit(MSG_COMPARE_MISSION_MISMATCH) + return + if self._compare_loader is not None: + self._compare_loader(record_id) + return + try: + from models.track_compare import load_track_compare_record + + payload = load_track_compare_record(uid, record_id) + except Exception as exc: + self.compareHintChanged.emit(f"对比加载失败: {exc}") + return + self.apply_compare(payload) + + fetch_and_pick_track( + self, + uid, + title="选择对比航迹", + message="请选择一条云端航迹用于对比", + on_finished=_after_pick, + ) + + def apply_compare(self, payload: TrackComparePayload) -> None: + self._compare_payload = payload + line = self.main_slot.base_chart + if isinstance(line, LineChart): + line.set_compare_history(payload.history) + line.set_compare_style(self._compare_style) + cmp_points: List[TrackPoint] = [] + try: + cmp_points = points_from_trajectory_samples(payload.trajectory.samples) + except Exception: + cmp_points = [] + self._track_pane.set_compare_track_points(cmp_points) + self._stick_pane.set_compare_envelope_from_track_points( + cmp_points, invert_pitch=True + ) + self.apply_compare_style(self._compare_style) + # 单独对比时灌入对比轨迹的 LAP 红线 + self._sync_compare_lap_markers(payload) + hints = [] + if not payload.has_trend: + hints.append("无趋势字段") + if not payload.has_laps: + hints.append("无圈时") + self.compareHintChanged.emit(";".join(hints) if hints else "") + # 对比载入后也要刷新时间轴(本场为空时可拖对比轨迹) + self._refresh_timeline_range(prefer_end=self._follow_live) + self._sync_timeline_lap_bands() + self._apply_global_cursor() + self.main_slot.render() + self.compareActiveChanged.emit(True, payload.label or "") + + def _live_history_empty(self) -> bool: + hist = self._history + if hist is None: + return True + try: + return len(hist) <= 0 + except Exception: + return True + + def _sync_compare_lap_markers(self, payload: Optional[TrackComparePayload]) -> None: + """本场无数据时,用对比轨迹圈切换点填充 LAP 竖线。""" + lap_ov = self.main_slot.lap_mask() + if lap_ov is None: + return + if payload is None or not self._live_history_empty(): + return + try: + markers = lap_finish_markers_from_samples(payload.trajectory.samples) + except Exception: + markers = [] + lap_ov.set_markers(markers) + + def clear_compare(self) -> None: + self._compare_payload = None + line = self.main_slot.base_chart + if isinstance(line, LineChart): + line.set_compare_history(None) + self._track_pane.set_compare_track_points(None) + self._stick_pane.clear_compare() + self._stick_pane.clear_compare_envelope() + if self._live_history_empty(): + self.clear_lap_markers() + self.compareHintChanged.emit("") + self.main_slot.set_time_base_override(None) + self._refresh_timeline_range(prefer_end=self._follow_live) + self._sync_timeline_lap_bands() + self._apply_global_cursor() + self.main_slot.render() + self.compareActiveChanged.emit(False, "") + + def clear_track(self) -> None: + """清空本场航迹与起终点标记(多轮监测前调用,避免幽灵起点)。""" + self._track_pane.clear() + self.clear_stick() + self.main_slot.clear_user_view() + self._cursor.set_length(0, prefer_end=True) + self.main_slot.render() + + def apply_compare_style(self, style: CompareStyle) -> None: + """「显示历史数据」:仅显隐,不整图 render / 不重建航迹。""" + self._compare_style = style + line = self.main_slot.base_chart + if isinstance(line, LineChart): + line.set_compare_style(style) + self._track_pane.set_compare_style(style) + visible = self.is_compare_visible() + self._stick_pane.set_compare_visible(visible) + if visible: + self._sync_compare_stick(self._live_cursor_time()) + # 仅「无本场、只靠历史」时时间轴源会变,才刷新长度;有本场则不动 + if self._track_pane.point_count() <= 1: + if not visible: + self.main_slot.set_time_base_override(None) + if isinstance(line, LineChart): + line.clear_cursor() + self._refresh_timeline_range(prefer_end=self._follow_live) + self._sync_timeline_lap_bands() + self._apply_global_cursor() + self.main_slot.render() + + def add_lap_marker(self, timestamp=None, lap_number=None) -> None: + import time + + lap_ov = self.main_slot.lap_mask() + if lap_ov is None: + return + ts = time.time() if timestamp is None else float(timestamp) + lap = int(lap_number) if lap_number is not None else len(lap_ov._lap_markers) + 1 + lap_ov.add_marker(lap, ts) + self.main_slot.render() + + def clear_lap_markers(self) -> None: + lap_ov = self.main_slot.lap_mask() + if lap_ov is None: + return + lap_ov.clear_markers() + lap_ov.clear(self.main_slot.qchart) + self.main_slot.render() diff --git a/charts/mask/__init__.py b/charts/mask/__init__.py new file mode 100644 index 00000000..45f6b8d7 --- /dev/null +++ b/charts/mask/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""包占位:charts.mask(请从具体子模块导入)。""" diff --git a/charts/mask/base_mask.py b/charts/mask/base_mask.py new file mode 100644 index 00000000..1dbfbbcb --- /dev/null +++ b/charts/mask/base_mask.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +""" +模块:charts.mask.base_mask +职责:蒙版图层抽象接口(宿主为 PlotWidget) +依赖:BaseChart、ChartRenderContext(TYPE_CHECKING) +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from charts.base.base_chart import BaseChart + from charts.context import ChartRenderContext + + +class ChartMask(ABC): + """挂在基底上的蒙版层;由 ChartSlot 按 z_order 调用 update/clear。""" + + overlay_id: str = "mask" + z_order: int = 0 + + def __init__(self) -> None: + self._base: Optional["BaseChart"] = None + + def bind_base(self, base: "BaseChart") -> None: + self._base = base + + @abstractmethod + def update(self, ctx: "ChartRenderContext") -> None: + ... + + @abstractmethod + def clear(self, plot_widget: Any) -> None: + """从 PlotWidget / PlotItem 移除本层图元。""" + ... diff --git a/charts/mask/health_span_mask.py b/charts/mask/health_span_mask.py new file mode 100644 index 00000000..b9a02587 --- /dev/null +++ b/charts/mask/health_span_mask.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +""" +模块:charts.mask.health_span_mask +职责:健康度阈值色带(pyqtgraph FillBetweenItem) +依赖:ChartMask、ThresholdSpanOverlaySpec、kind_resolver +""" + +from __future__ import annotations + +from typing import List, Optional, Tuple + +from PySide2.QtGui import QColor + +from charts.mask.base_mask import ChartMask +from charts.pg_bind import pg +from models.chart import ChartFieldSpec +from models.data_bridge import ThresholdSpanOverlaySpec, kind_resolver + + +class HealthSpanMask(ChartMask): + """按 health_span_kind 在视窗内绘制半透明色带(官方 FillBetweenItem)。""" + + def __init__(self, spec: ThresholdSpanOverlaySpec) -> None: + super().__init__() + self.overlay_id = spec.overlay_id + self.z_order = spec.z_order + self._spec = spec + self._kind_fn = kind_resolver(spec.kind_resolver_name) + # (lo_curve, hi_curve, fill) + self._span_items: List[Tuple[pg.PlotDataItem, pg.PlotDataItem, pg.FillBetweenItem]] = [] + + def update(self, ctx) -> None: + plot = ctx.plot_widget if ctx is not None else None + if self._base is None or plot is None: + return + self.clear(plot) + if not self._spec.enabled or self._kind_fn is None: + return + if hasattr(self._base, "is_field_visible") and not self._base.is_field_visible( + self._spec.source_field_id + ): + return + config = ctx.config + if config is None: + return + span_field = config.field_by_id(self._spec.axis_field_id) + if span_field is None: + return + xs = ctx.xs + span_values = ctx.field_data.get(self._spec.source_field_id, []) + if not xs or not span_values or len(xs) != len(span_values): + return + span_axis = self._base.field_axis(span_field) + if span_axis is None: + return + try: + if not span_axis.isVisible(): + return + except Exception: + pass + if self._spec.direction == "x": + self._update_x_spans(xs, span_values, span_field, span_axis, ctx.view_window_s) + + def _span_brush(self, kind: str): + hex_map = self._spec.colors + c = QColor(hex_map.get(kind, "#e74c3c")) + c.setAlphaF(self._spec.alpha) + return pg.mkBrush(c) + + def _add_x_span( + self, + x0: float, + x1: float, + kind: str, + span_field: ChartFieldSpec, + span_axis, + window: float, + ) -> None: + if kind is None: + return + if x1 < x0: + x0, x1 = x1, x0 + if x1 == x0: + x1 = x0 + max(0.05, window * 0.002) + ymin, ymax = span_field.ylim() + lo = pg.PlotDataItem([float(x0), float(x1)], [float(ymin), float(ymin)]) + hi = pg.PlotDataItem([float(x0), float(x1)], [float(ymax), float(ymax)]) + fill = pg.FillBetweenItem(lo, hi, brush=self._span_brush(kind)) + # 色带在折线下 + try: + fill.setZValue(-10) + lo.setZValue(-11) + hi.setZValue(-11) + except Exception: + pass + vb = getattr(span_axis, "viewbox", None) + if vb is None: + return + vb.addItem(lo) + vb.addItem(hi) + vb.addItem(fill) + self._span_items.append((lo, hi, fill)) + + def _update_x_spans(self, xs, span_values, span_field, span_axis, window: float) -> None: + n = len(xs) + stride = max(1, n // 400) if n > 400 else 1 + active_kind = None + start_x = None + prev_x = xs[0] + span_budget = 80 + for i in range(0, n, stride): + x = xs[i] + value = span_values[i] + kind = self._kind_fn(value) + if kind != active_kind: + if active_kind is not None and start_x is not None: + if span_budget <= 0: + active_kind = kind + start_x = x if kind is not None else None + prev_x = x + continue + self._add_x_span( + start_x, + prev_x if prev_x > start_x else x, + active_kind, + span_field, + span_axis, + window, + ) + span_budget -= 1 + active_kind = kind + start_x = x if kind is not None else None + prev_x = x + if active_kind is not None and start_x is not None and span_budget > 0: + end_x = xs[-1] + if end_x <= start_x and len(xs) == 1: + end_x = start_x + max(0.05, window * 0.002) + self._add_x_span(start_x, end_x, active_kind, span_field, span_axis, window) + + def clear(self, plot_widget) -> None: + for lo, hi, fill in self._span_items: + for item in (fill, hi, lo): + try: + sc = item.scene() + if sc is not None: + sc.removeItem(item) + except Exception: + pass + self._span_items.clear() diff --git a/charts/mask/lap_marker_mask.py b/charts/mask/lap_marker_mask.py new file mode 100644 index 00000000..e6b5acee --- /dev/null +++ b/charts/mask/lap_marker_mask.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +""" +模块:charts.mask.lap_marker_mask +职责:圈完成竖线 + 顶部标签(InfiniteLine + TextItem) +依赖:ChartMask、LapMarkerOverlaySpec +""" + +from __future__ import annotations + +import math +from typing import List, Optional, Tuple + +from PySide2.QtGui import QFont + +from charts.context import ChartRenderContext +from charts.mask.base_mask import ChartMask +from charts.pg_bind import pg +from charts.theme import error_color +from models.data_bridge import LapMarkerOverlaySpec + + +class LapMarkerMask(ChartMask): + """在相对时间轴上标注 LAP 完成时刻。""" + + def __init__(self, spec: Optional[LapMarkerOverlaySpec] = None) -> None: + super().__init__() + self._spec = spec or LapMarkerOverlaySpec() + self.overlay_id = self._spec.overlay_id + self.z_order = self._spec.z_order + self._lap_markers: List[Tuple[int, float]] = [] + self._lap_lines: List[pg.InfiniteLine] = [] + self._lap_labels: List[pg.TextItem] = [] + + def set_markers(self, markers: List[Tuple[int, float]]) -> None: + self._lap_markers = list(markers) + + def add_marker(self, lap_number: int, timestamp: float) -> None: + self._lap_markers.append((lap_number, timestamp)) + + def clear_markers(self) -> None: + self._lap_markers = [] + + def update(self, ctx: ChartRenderContext) -> None: + plot = ctx.plot_widget if ctx is not None else None + if self._base is None or plot is None: + return + self.clear(plot) + if not self._spec.enabled: + return + if not self._lap_markers: + return + history = ctx.history + if history is None or len(list(getattr(history, "timestamps", []) or [])) <= 0: + history = getattr(self._base, "compare_history", lambda: None)() + if history is None: + return + ts_list = list(history.timestamps) + if not ts_list: + return + if ctx.config is None: + return + primary_field = getattr(self._base, "_primary_field", None) or ctx.config.primary_field() + if primary_field is None: + return + left_axis = self._base.field_axis(primary_field) + pi = plot.plotItem if hasattr(plot, "plotItem") else None + if pi is None: + return + try: + if left_axis is not None and not left_axis.isVisible(): + return + except Exception: + pass + _ymin, ymax = primary_field.ylim() + t0 = float(ts_list[0]) + lo = float(ctx.view_start) - 1e-6 + hi = float(ctx.view_end) + 1e-6 + pen = pg.mkPen(error_color(), width=0.9) + from components.Frame.Frame_Theme_UI import UITheme + + lab_font = QFont(UITheme.FONT_FAMILY) + lab_font.setPointSize(8) + for lap_number, ts in self._lap_markers: + x = float(ts) - t0 + if not math.isfinite(x) or x < lo or x > hi: + continue + line = pg.InfiniteLine(pos=x, angle=90, pen=pen, movable=False) + pi.addItem(line) + self._lap_lines.append(line) + label = pg.TextItem( + text=self._spec.label_template.format(lap=lap_number), + color=error_color(), + anchor=(0.5, 1.0), + ) + label.setFont(lab_font) + label.setPos(x, float(ymax)) + pi.addItem(label) + self._lap_labels.append(label) + + def clear(self, plot_widget) -> None: + pi = getattr(plot_widget, "plotItem", None) if plot_widget is not None else None + for line in self._lap_lines: + if pi is not None: + try: + pi.removeItem(line) + except Exception: + pass + self._lap_lines.clear() + for label in self._lap_labels: + if pi is not None: + try: + pi.removeItem(label) + except Exception: + pass + self._lap_labels.clear() diff --git a/charts/mask/registry.py b/charts/mask/registry.py new file mode 100644 index 00000000..e71e22e2 --- /dev/null +++ b/charts/mask/registry.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +""" +模块:charts.mask.registry +职责:按配置实例化蒙版列表并按 z_order 排序 +依赖:HealthSpanMask、LapMarkerMask、models.data_bridge +""" + +from __future__ import annotations + +from typing import Dict, List, Type + +from charts.mask.base_mask import ChartMask +from charts.mask.health_span_mask import HealthSpanMask +from charts.mask.lap_marker_mask import LapMarkerMask +from models.data_bridge import ( + ChartOverlayConfig, + LapMarkerOverlaySpec, + ThresholdSpanOverlaySpec, + load_overlay_config, +) + + +_MASK_REGISTRY: Dict[str, Type[ChartMask]] = { + "threshold_span": HealthSpanMask, + "lap_marker": LapMarkerMask, +} + + +def register_mask(overlay_type: str, cls: Type[ChartMask]) -> None: + """注册自定义蒙版类型。""" + _MASK_REGISTRY[overlay_type] = cls + + +def create_masks( + config: ChartOverlayConfig | None = None, +) -> List[ChartMask]: + """ + 说明:由 ChartOverlayConfig 生成已启用的蒙版实例 + 参数: + config — 缺省则 load_overlay_config() + 返回: + 按 z_order 升序的蒙版列表 + """ + cfg = config or load_overlay_config() + overlays: List[ChartMask] = [] + for spec in cfg.overlays: + if isinstance(spec, ThresholdSpanOverlaySpec): + if not spec.enabled: + continue + cls = _MASK_REGISTRY.get("threshold_span", HealthSpanMask) + overlays.append(cls(spec)) + elif isinstance(spec, LapMarkerOverlaySpec): + if not spec.enabled: + continue + cls = _MASK_REGISTRY.get("lap_marker", LapMarkerMask) + overlays.append(cls(spec)) + overlays.sort(key=lambda o: o.z_order) + return overlays diff --git a/charts/pg_bind.py b/charts/pg_bind.py new file mode 100644 index 00000000..c19ce6c6 --- /dev/null +++ b/charts/pg_bind.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +""" +模块:charts.pg_bind +职责:在导入 pyqtgraph 前锁定 PySide2 绑定;统一导出 pg +依赖:环境变量 PYQTGRAPH_QT_LIB、pyqtgraph +""" + +from __future__ import annotations + +import os + +# 必须在 import pyqtgraph 之前设置(本项目固定 PySide2 / win32) +os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide2") + +import pyqtgraph as pg # noqa: E402 + +pg.setConfigOptions(antialias=True, foreground="#aab4c0", background=None) + +__all__ = ["pg"] diff --git a/charts/slot.py b/charts/slot.py new file mode 100644 index 00000000..1c95452a --- /dev/null +++ b/charts/slot.py @@ -0,0 +1,595 @@ +# -*- coding: utf-8 -*- +""" +模块:charts.slot +职责:图表槽位 / 二级布局 / 页面级显示区(pyqtgraph PlotWidget) +依赖:BaseChart、蒙版、models.chart +""" + +from __future__ import annotations + +import math +from typing import List, Optional + +from PySide2.QtCore import Qt, QTimer +from PySide2.QtWidgets import QHBoxLayout, QSizePolicy, QVBoxLayout, QWidget + +from components.Frame.Frame_Theme_UI import UITheme +from charts.base.base_chart import BaseChart +from charts.base.line_chart import LineChart +from charts.context import ChartRenderContext +from charts.mask.base_mask import ChartMask +from charts.mask.registry import create_masks +from charts.pg_bind import pg +from charts.theme import chart_plot_bg_color, chart_plot_bg_css +from charts.widgets.chart_hud_overlay import ChartEmptyHud, attach_huds +from models.chart import ( + ChartConfig, + ChartHistoryBuffer, + relative_times, + view_bounds, +) +from models.data_bridge import ChartOverlayConfig +from setting import ( + CHART_EMPTY_HINT_PERF, + CHART_GRID_ALPHA, + CHART_MARGIN_BOTTOM, + CHART_MARGIN_TOP, + CHART_MARGIN_Y_LABEL, + CHART_TIME_RULE_DEFAULT_MAX_SEC, + CHART_TIME_ZOOM_MIN_SEC, + SPACE_NONE, +) + + +class ChartSlot(QWidget): + """单槽:一个 PlotWidget + 基底图表 + 蒙版列表。""" + + def __init__( + self, + base_chart: BaseChart, + masks: Optional[List[ChartMask]] = None, + mask_config: Optional[ChartOverlayConfig] = None, + enable_pan: bool = True, + parent=None, + ): + super().__init__(parent) + self.base_chart = base_chart + self.overlays: List[ChartMask] = masks if masks is not None else create_masks(mask_config) + for ov in self.overlays: + ov.bind_base(base_chart) + + self.live_mode = True + self.view_end: Optional[float] = None + self._history = None + self._time_base_override = None + self._enable_pan = enable_pan + self._rendering = False + self._render_pending = False + self._rendered_len = 0 + self._append_since_full = 0 + # 官方 ViewBox 用户缩放:覆盖默认视窗宽度 + self._view_window_override: Optional[float] = None + self._applying_axis = False + + well = chart_plot_bg_color() + well_css = chart_plot_bg_css() + + # 本槽不透明井区:上层 ViewBox 透明时只能露出本层,不得打穿到窗体 BG + # 边框仅画在与外层容器交界处(本槽外框),不画绘图区 ViewBox + self.setObjectName("ChartSlot") + self.setAttribute(Qt.WA_StyledBackground, True) + self.setAutoFillBackground(True) + self.setStyleSheet( + f"QWidget#ChartSlot {{" + f" background-color: {well_css};" + f" border: {UITheme.border_css()};" + f" border-radius: {UITheme.BORDER_RADIUS}px;" + f" }}" + ) + + # 随父容器伸缩(高 DPI / 窗口适配均靠 Layout + Expanding,禁止对 PlotWidget 绝对定位) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.view = pg.PlotWidget(parent=self) + self.plot_widget = self.view + self.qchart = self.view + self.view.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.view.setMenuEnabled(False) + self.view.showGrid(x=True, y=True, alpha=float(CHART_GRID_ALPHA)) + + # 井区色画在 PlotWidget(不透明);主 VB 透明仅露出本层井区(右轴曲线可见) + self.view.setBackground(well) + pi = self.view.plotItem + pi.hideButtons() + pi.setClipToView(True) + pi.getViewBox().setBackgroundColor(None) + try: + pi.layout.setContentsMargins( + UITheme.scaled(CHART_MARGIN_Y_LABEL, 0), + UITheme.scaled(CHART_MARGIN_TOP, 4), + UITheme.scaled(CHART_MARGIN_Y_LABEL, 0), + UITheme.scaled(CHART_MARGIN_BOTTOM, 20), + ) + except Exception: + pass + # QSS 必须与 setBackground 同色且不透明;绘图区本身无边框 + self.view.setStyleSheet(f"background-color: {well_css}; border: none;") + self.view.setAutoFillBackground(True) + base_chart.attach_to_plot(self.view) + + # 性能图空态 HUD(视口像素层,不随缩放/拖动) + self._empty_hud = ChartEmptyHud(CHART_EMPTY_HINT_PERF, self.view) + self._hud_filter = attach_huds(self.view, self._empty_hud) + + # 官方拖动 / 滚轮缩放(仅 X);Y 由字段量程锁定 + # 必须先建 timer,再连 sigRangeChanged:setLimits/几何同步会立刻发信号 + self._user_range_timer = QTimer(self) + self._user_range_timer.setSingleShot(True) + self._user_range_timer.setInterval(40) + self._user_range_timer.timeout.connect(self._commit_user_x_range) + + vb = pi.getViewBox() + if enable_pan: + # 左键拖 = 横向平移;滚轮缩放(放大后可拖查看) + vb.setMouseEnabled(x=True, y=False) + try: + vb.setMouseMode(pg.ViewBox.PanMode) + except Exception: + pass + vb.sigRangeChanged.connect(self._on_vb_range_changed) + else: + vb.setMouseEnabled(x=False, y=False) + # 默认时间规则限位;任务切换时由 TrendChart 刷新 + from models.mission_time_rule import default_time_rule + + self._time_rule = default_time_rule() + self._applying_axis = True + try: + self._apply_x_limits() + finally: + self._applying_axis = False + self.view.viewport().installEventFilter(self) + + # 内边距 = 边框宽,避免子控件盖住与容器交界处的外框 + bw = int(UITheme.BORDER_WIDTH) + layout = QVBoxLayout(self) + layout.setContentsMargins(bw, bw, bw, bw) + layout.setSpacing(SPACE_NONE) + layout.addWidget(self.view, 1) + + def eventFilter(self, obj, event): + from PySide2.QtCore import QEvent + + if obj is self.view.viewport() and event.type() == QEvent.MouseButtonDblClick: + if event.button() == Qt.LeftButton: + self.clear_user_view() + self.render() + return True + return super().eventFilter(obj, event) + + def clear_user_view(self) -> None: + """恢复直播跟随:清用户缩放窗与 view_end。""" + self.live_mode = True + self.view_end = None + self._view_window_override = None + + def has_user_x_view(self) -> bool: + """用户已手动缩放/拖动 X 轴(双击 clear_user_view 前有效)。""" + return (not self.live_mode) and self._view_window_override is not None + + def set_time_rule(self, rule) -> None: + """按任务 time_rule 更新 X 轴硬边界 [0, max_sec];缩放窗 [ZOOM_MIN, max_sec]。""" + self._time_rule = rule + self._apply_x_limits() + # 用户窗宽:最小可放到 ZOOM_MIN,最大不超过任务 max_sec + x_min_span = self._x_zoom_min_span() + x_max_span = self._x_zoom_max_span() + if self._view_window_override is not None: + self._view_window_override = max( + x_min_span, + min(x_max_span, float(self._view_window_override)), + ) + if self.view_end is not None: + self.view_end = max(0.0, min(x_max_span, float(self.view_end))) + self.render() + + def _x_zoom_min_span(self) -> float: + """性能图可放大到的最小时间窗(秒)。""" + return float(CHART_TIME_ZOOM_MIN_SEC) + + def _x_zoom_max_span(self) -> float: + """性能图可缩小到的最大时间窗 = 任务 max_sec。""" + rule = getattr(self, "_time_rule", None) + if rule is not None: + return float(getattr(rule, "max_sec", CHART_TIME_RULE_DEFAULT_MAX_SEC)) + return float(CHART_TIME_RULE_DEFAULT_MAX_SEC) + + def _apply_x_limits(self) -> None: + rule = getattr(self, "_time_rule", None) + if rule is None: + from models.mission_time_rule import default_time_rule + + rule = default_time_rule() + self._time_rule = rule + vb = self.plot_widget.plotItem.vb + x_max = self._x_zoom_max_span() + x_min_span = self._x_zoom_min_span() + try: + vb.setLimits( + xMin=0.0, + xMax=x_max, + minXRange=x_min_span, + maxXRange=x_max, + ) + except Exception: + pass + + def _on_vb_range_changed(self, _vb, _ranges) -> None: + if self._applying_axis: + return + timer = getattr(self, "_user_range_timer", None) + if timer is None: + return + self.live_mode = False + timer.start() + + def _commit_user_x_range(self) -> None: + if self._applying_axis: + return + try: + (x0, x1), _yr = self.plot_widget.plotItem.vb.viewRange() + except Exception: + return + if not math.isfinite(x0) or not math.isfinite(x1) or x1 <= x0: + return + x_max = self._x_zoom_max_span() + x_min_span = self._x_zoom_min_span() + x0 = max(0.0, float(x0)) + x1 = min(x_max, float(x1)) + if x1 - x0 < x_min_span: + x1 = min(x_max, x0 + x_min_span) + x0 = max(0.0, x1 - x_min_span) + self.view_end = float(x1) + self._view_window_override = max(x_min_span, float(x1 - x0)) + self.render() + + def lap_mask(self): + from charts.mask.lap_marker_mask import LapMarkerMask + + for ov in self.overlays: + if isinstance(ov, LapMarkerMask): + return ov + return None + + def bind_history(self, history) -> None: + self._history = history + + def set_time_base_override(self, history) -> None: + self._time_base_override = history + + def set_config(self, config: ChartConfig) -> None: + self.base_chart.set_config(config) + + def _view_window_s(self) -> float: + min_span = self._x_zoom_min_span() + max_span = self._x_zoom_max_span() + if self._view_window_override is not None and self._view_window_override > 1e-6: + return max(min_span, min(max_span, float(self._view_window_override))) + cfg = self.base_chart.config + base = cfg.view_window_s if cfg else 150.0 + return max(min_span, min(max_span, float(base))) + + def _clamped_view_bounds(self, t_max: float) -> tuple: + rule = getattr(self, "_time_rule", None) + x_cap = ( + float(getattr(rule, "max_sec", CHART_TIME_RULE_DEFAULT_MAX_SEC)) + if rule is not None + else float(CHART_TIME_RULE_DEFAULT_MAX_SEC) + ) + view_start, view_end = view_bounds(t_max, self.view_end, self._view_window_s()) + view_start = max(0.0, min(x_cap, float(view_start))) + view_end = max(view_start, min(x_cap, float(view_end))) + return view_start, view_end + + def _live_history_len(self) -> int: + if self._history is None: + return 0 + try: + return len(list(self._history.timestamps)) + except Exception: + return 0 + + def _render_history(self): + override = getattr(self, "_time_base_override", None) + if override is not None: + try: + if len(list(override.timestamps)) > 0: + return override + except Exception: + pass + if self._live_history_len() > 0: + return self._history + if isinstance(self.base_chart, LineChart): + style = getattr(self.base_chart, "_compare_style", None) + if style is not None and not bool(getattr(style, "visible", True)): + return None + cmp_hist = self.base_chart.compare_history() + if cmp_hist is not None and len(cmp_hist) > 0: + return cmp_hist + return None + + def _history_slice(self, view_start: float, view_end: float, history=None): + buf = self._history if history is None else history + config = self.base_chart.config + if buf is None: + return [], {field.id: [] for field in config.fields} + if not isinstance(buf, ChartHistoryBuffer): + raise TypeError(f"history 须为 ChartHistoryBuffer,收到 {type(buf)!r}") + return buf.slice_view(view_start, view_end) + + def _build_context( + self, + view_start: float, + view_end: float, + xs, + field_data, + history=None, + ) -> ChartRenderContext: + return ChartRenderContext( + history=self._history if history is None else history, + view_start=view_start, + view_end=view_end, + view_window_s=self._view_window_s(), + xs=xs, + field_data=field_data, + plot_widget=self.plot_widget, + axis_x=self.base_chart.axis_x(), + live_mode=self.live_mode, + config=self.base_chart.config, + ) + + def render(self) -> None: + if getattr(self, "_rendering", False): + self._render_pending = True + return + self._rendering = True + self._render_pending = False + try: + self._render_impl() + finally: + self._rendering = False + if self._render_pending: + self._render_pending = False + self.render() + + def _render_impl(self) -> None: + for ov in self.overlays: + ov.clear(self.plot_widget) + if isinstance(self.base_chart, LineChart): + self.base_chart._clear_cursor_graphics() + + history = self._render_history() + hist_empty = True + if history is not None: + try: + hist_empty = len(list(history.timestamps)) <= 0 + except Exception: + hist_empty = True + if history is None or hist_empty: + self.base_chart.clear() + if isinstance(self.base_chart, LineChart): + self._applying_axis = True + try: + self.base_chart.apply_empty_axis_ranges() + finally: + self._applying_axis = False + window = self._view_window_s() + ctx = self._build_context(0.0, float(window), [], {}) + self.base_chart.update_cursor(ctx) + self._empty_hud.set_empty(True) + return + + self._empty_hud.set_empty(False) + times = relative_times(list(history.timestamps)) + t_max = times[-1] if times else 0.0 + if not math.isfinite(t_max): + t_max = 0.0 + view_start, view_end = self._clamped_view_bounds(t_max) + + if self._live_history_len() > 0 and history is self._history: + xs, field_data = self._history_slice(view_start, view_end, history=self._history) + else: + xs, field_data = [], {} + + ctx = self._build_context(view_start, view_end, xs, field_data, history=history) + self._applying_axis = True + try: + self.base_chart.update_series(ctx) + self.base_chart.apply_axis_ranges(ctx) + finally: + self._applying_axis = False + show_compare = False + if isinstance(self.base_chart, LineChart): + style = getattr(self.base_chart, "_compare_style", None) + cmp_hist = self.base_chart.compare_history() + show_compare = ( + cmp_hist is not None + and len(cmp_hist) > 0 + and bool(getattr(style, "visible", True)) + ) + if xs or show_compare: + for ov in self.overlays: + ov.update(ctx) + if isinstance(self.base_chart, LineChart): + self.base_chart.update_cursor(ctx) + hist = self._history + self._rendered_len = len(hist) if isinstance(hist, ChartHistoryBuffer) else 0 + self._append_since_full = 0 + + def update_history(self, history, reset_live: bool = False) -> None: + self._history = history + if reset_live or self.live_mode: + self.view_end = None + if self.live_mode: + self._view_window_override = None + self.render() + + def append_live(self, history) -> None: + self._history = history + if self.live_mode: + self.view_end = None + if not self.live_mode or not isinstance(self.base_chart, LineChart): + self.render() + return + if history is None or not isinstance(history, ChartHistoryBuffer): + self.render() + return + n = len(history) + if n <= 0 or n != self._rendered_len + 1 or self._rendered_len <= 0: + self.render() + return + times = relative_times(list(history.timestamps)) + t_rel = float(times[-1]) if times else 0.0 + field_data = history.values_at_relative(t_rel) + need_scroll = bool(self.base_chart.append_live_point(t_rel, field_data)) + self._rendered_len = n + self._append_since_full += 1 + if need_scroll or self._append_since_full >= 10: + self._append_since_full = 0 + self.render() + return + self.refresh_cursor_only() + + def refresh_cursor_only(self) -> None: + if not isinstance(self.base_chart, LineChart): + return + history = self._render_history() + if history is None: + self.base_chart.clear_cursor() + return + times = relative_times(list(history.timestamps)) + t_max = times[-1] if times else 0.0 + if not math.isfinite(t_max): + t_max = 0.0 + view_start, view_end = self._clamped_view_bounds(t_max) + ctx = self._build_context(view_start, view_end, [], {}, history=history) + self.base_chart.update_cursor(ctx) + + +class ChartPane(QWidget): + """二级容器:single / horizontal / vertical 布局。""" + + def __init__(self, layout_mode: str = "single", parent=None): + super().__init__(parent) + self.layout_mode = layout_mode + self._slots: List[ChartSlot] = [] + from setting import CHART_CANVAS_GAP_PX + + gap = int(CHART_CANVAS_GAP_PX) + if layout_mode == "horizontal": + self._layout = QHBoxLayout(self) + else: + self._layout = QVBoxLayout(self) + self._layout.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + self._layout.setSpacing(gap) + + def add_slot(self, slot: ChartSlot, stretch: int = 1) -> None: + self._slots.append(slot) + self._layout.addWidget(slot, stretch) + + def add_widget(self, widget: QWidget, stretch: int = 1) -> None: + self._layout.addWidget(widget, stretch) + + +class _TrackAspectColumn(QWidget): + """左列航迹:高度填满,宽度 = 高度 × 宽高比。""" + + def __init__(self, child: QWidget, aspect_wh: float, parent=None): + super().__init__(parent) + self._aspect_wh = float(aspect_wh) if aspect_wh > 0 else 1.2 + self._child = child + lay = QVBoxLayout(self) + lay.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + lay.setSpacing(SPACE_NONE) + child.setVisible(True) + lay.addWidget(child, 1) + self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) + + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + h = max(1, int(self.height())) + w = max(1, int(round(h * self._aspect_wh))) + if self.minimumWidth() != w or self.maximumWidth() != w: + self.setFixedWidth(w) + + +class ChartDisplayArea(QWidget): + """图表 Canvas:顶栏下拉(与列等宽)+ 两列图区。""" + + def __init__( + self, + track_widget: QWidget, + perf_slot: ChartSlot, + stick_widget: QWidget, + track_chrome: QWidget, + perf_chrome: QWidget, + parent=None, + ): + super().__init__(parent) + self.setObjectName("ChartDisplayArea") + self.setAttribute(Qt.WA_StyledBackground, True) + from setting import CHART_CANVAS_GAP_PX, CHART_TRACK_ASPECT_WH + + self.main_slot = perf_slot + self.track_widget = track_widget + self.stick_widget = stick_widget + self.track_chrome = track_chrome + self.perf_chrome = perf_chrome + + gap = int(CHART_CANVAS_GAP_PX) + root = QVBoxLayout(self) + root.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + root.setSpacing(gap) + + # 顶栏:左=航迹下拉,右=性能一行两列下拉;宽度与下方列对齐 + chrome = QHBoxLayout() + chrome.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + chrome.setSpacing(gap) + self._track_chrome_host = QWidget(self) + self._track_chrome_host.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + ch_lay = QVBoxLayout(self._track_chrome_host) + ch_lay.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + ch_lay.setSpacing(SPACE_NONE) + track_chrome.setVisible(True) + ch_lay.addWidget(track_chrome) + chrome.addWidget(self._track_chrome_host, 0) + perf_chrome.setVisible(True) + chrome.addWidget(perf_chrome, 1) + root.addLayout(chrome, 0) + + # 图区:左航迹 | 右(性能+摇杆) + body = QHBoxLayout() + body.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + body.setSpacing(gap) + self._track_col = _TrackAspectColumn( + track_widget, float(CHART_TRACK_ASPECT_WH), self + ) + body.addWidget(self._track_col, 0) + + right = ChartPane("vertical") + perf_slot.setVisible(True) + stick_widget.setVisible(True) + right.add_widget(perf_slot, 3) + right.add_widget(stick_widget, 2) + body.addWidget(right, 1) + root.addLayout(body, 1) + + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + # 顶栏左列宽 = 航迹列宽 + w = max(1, int(self._track_col.width())) + host = self._track_chrome_host + if host.minimumWidth() != w or host.maximumWidth() != w: + host.setFixedWidth(w) + + def update_all(self, history) -> None: + self.main_slot.update_history(history, reset_live=False) diff --git a/charts/theme.py b/charts/theme.py new file mode 100644 index 00000000..5596becf --- /dev/null +++ b/charts/theme.py @@ -0,0 +1,227 @@ +# -*- coding: utf-8 -*- +""" +模块:charts.theme +职责:图表配色适配(委托 UITheme 预置色号) +依赖:components.Frame.Frame_Theme_UI.UITheme +""" + +from __future__ import annotations + +from PySide2.QtGui import QColor + +from components.Frame.Frame_Theme_UI import UITheme + + +def field_line_color(color_key: str, color: str = "") -> QColor: + """优先用字段显式 color,否则按 color_key 取主题色。""" + if color: + return QColor(color) + color_map = { + "torque": UITheme.CHART_TORQUE, + "health": UITheme.CHART_HEALTH, + "airspeed": UITheme.CHART_AIRSPEED, + "groundspeed": UITheme.CHART_GROUNDSPEED, + "speed_rate": UITheme.CHART_SPEED_RATE, + "accel_rate": UITheme.CHART_ACCEL_RATE, + "pitch": UITheme.CHART_PITCH, + "bank": UITheme.CHART_BANK, + "eta_prop": UITheme.CHART_ETA_PROP, + "di_proxy": UITheme.CHART_DI_PROXY, + "vg_course": UITheme.CHART_VG_COURSE, + } + return QColor(color_map.get(color_key, UITheme.FG)) + + +def desaturate_color(color, factor: float = 0.45) -> QColor: + """按 HSV 降低饱和度;factor 为保留比例(0.45 ≈ 降饱和约 55%)。""" + c = QColor(color) if not isinstance(color, QColor) else QColor(color) + h, s, v, a = c.getHsvF() + out = QColor() + out.setHsvF(h, max(0.0, min(1.0, s * float(factor))), v, a) + return out + + +def field_pen_style(line_style: str = "") -> "Qt.PenStyle": + """字段线型;空则实线。""" + from PySide2.QtCore import Qt + + return { + "solid": Qt.SolidLine, + "dash": Qt.DashLine, + "dot": Qt.DotLine, + "dashdot": Qt.DashDotLine, + }.get(line_style or "solid", Qt.SolidLine) + + +def reference_line_color() -> QColor: + """历史对比默认参考线颜色。""" + return QColor(UITheme.CHART_REFERENCE) + + +def compare_field_qcolor( + color_key: str, + color: str = "", + opacity: float = 0.7, + sat_scale: float = 0.45, +) -> QColor: + """历史对比折线色:原字段色降饱和 + 固定透明度。""" + c = desaturate_color(field_line_color(color_key, color), sat_scale) + c.setAlphaF(max(0.0, min(1.0, float(opacity)))) + return c + + +def compare_lap_qcolor( + lap_number: int, + opacity: float = 0.7, + sat_scale: float = 0.45, +) -> QColor: + """历史对比速度带色:原 LAP 色降饱和 + 透明度。""" + c = desaturate_color(lap_color(lap_number), sat_scale) + c.setAlphaF(max(0.0, min(1.0, float(opacity)))) + return c + + +def compare_pen_style(style) -> "Qt.PenStyle": + """从 CompareStyle.line_style 映射 Qt 笔型。""" + from PySide2.QtCore import Qt + + return { + "solid": Qt.SolidLine, + "dash": Qt.DashLine, + "dot": Qt.DotLine, + "dashdot": Qt.DashDotLine, + }.get(getattr(style, "line_style", "dash"), Qt.DashLine) + + +def lap_color(lap_number: int) -> QColor: + """LAP1–LAP4 固定色;越界回退 FG。""" + idx = int(lap_number) - 1 + colors = UITheme.CHART_LAP_COLORS + if 0 <= idx < len(colors): + return QColor(colors[idx]) + return QColor(UITheme.FG) + + +def span_fill_color(kind: str, alpha: float | None = None) -> QColor: + hex_map = { + "orange": UITheme.CHART_HEALTH_ORANGE_FILL, + "blue": UITheme.CHART_HEALTH_BLUE_FILL, + "red": UITheme.CHART_HEALTH_RED_FILL, + } + c = QColor(hex_map.get(kind, UITheme.CHART_HEALTH_RED_FILL)) + c.setAlphaF(alpha if alpha is not None else UITheme.CHART_HEALTH_SPAN_ALPHA) + return c + + +def chart_surface_color() -> QColor: + """图表容器/表面填充色(UITheme.SURFACE);强制不透明。""" + c = QColor(UITheme.SURFACE) + c.setAlpha(255) + return c + + +def chart_well_color(_container=None) -> QColor: + """嵌套井区填充(预置 UITheme.CHART_WELL);强制不透明。""" + c = QColor(UITheme.CHART_WELL) + c.setAlpha(255) + return c + + +def chart_plot_bg_color() -> QColor: + """所有图表绘图区统一背景(不透明 CHART_WELL);禁止透明以免窗体 alpha 透出跳变。""" + return chart_well_color() + + +def chart_plot_bg_css() -> str: + """绘图区背景 CSS 色值(与 chart_plot_bg_color 同源)。""" + return str(UITheme.CHART_WELL) + + +def chart_well_border_color() -> QColor: + """井区边框色(统一边框色)。""" + c = QColor(UITheme.BORDER) + c.setAlpha(255) + return c + + +def chart_border_width() -> float: + """统一边框线宽(px)。""" + return float(UITheme.BORDER_WIDTH) + + +def axis_muted_color() -> QColor: + """坐标轴刻度数字弱化色。""" + return QColor(UITheme.FG_MUTED) + + +def axis_label_color() -> QColor: + """坐标轴标签文字色(与摇杆图标签同色:CHART_PITCH)。""" + return QColor(UITheme.CHART_PITCH) + + +def axis_border_color() -> QColor: + """坐标轴线与网格色。""" + return QColor(UITheme.BORDER) + + +def error_color() -> QColor: + """错误/圈标强调色。""" + return QColor(UITheme.ERROR) + + +def chart_float_role_color(role: str = "default") -> str: + """浮窗字段色:default=固定行;current=当前(黄);history=历史(蓝)。""" + key = str(role or "default").lower() + if key == "current": + return str(UITheme.DATA_CURRENT) + if key == "history": + return str(UITheme.DATA_HISTORY) + return str(UITheme.CHART_FLOAT_DEFAULT) + + +def chart_float_html(lines) -> str: + """性能图 / 航迹图游标浮窗共用 HTML。 + + lines 元素可为: + - str:整行 default 色 + - (text, role):整行着色 + - {"parts": [(text, role), ...]}:一行内多色拼接 + """ + from html import escape + + rows: list[str] = [] + for item in lines or []: + if isinstance(item, dict) and "parts" in item: + spans = [] + for part in item.get("parts") or []: + if isinstance(part, (tuple, list)) and len(part) >= 2: + text, role = part[0], part[1] + else: + text, role = part, "default" + spans.append( + f"" + f"{escape(str(text))}" + ) + rows.append("".join(spans)) + continue + if isinstance(item, (tuple, list)) and len(item) >= 2: + text, role = item[0], item[1] + else: + text, role = item, "default" + rows.append( + f"" + f"{escape(str(text))}" + ) + body = "
".join(rows) + bg = "rgba(20,24,36,210)" + return ( + f"
{body}
" + ) + + +def apply_chart_float_font(text_item) -> None: + """浮窗字体:Orbitron 10pt,与性能图游标一致。""" + from PySide2.QtGui import QFont + + text_item.setFont(QFont(UITheme.FONT_FAMILY_TECH, 10)) diff --git a/charts/widgets/__init__.py b/charts/widgets/__init__.py new file mode 100644 index 00000000..6775d0bc --- /dev/null +++ b/charts/widgets/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""包占位:charts.widgets(请从具体子模块导入)。""" diff --git a/charts/widgets/chart_hud_overlay.py b/charts/widgets/chart_hud_overlay.py new file mode 100644 index 00000000..1c4a3a8d --- /dev/null +++ b/charts/widgets/chart_hud_overlay.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +""" +模块:charts.widgets.chart_hud_overlay +职责:图表视口固定 HUD 蒙版(不随 ViewBox 缩放/拖动) +依赖:UITheme、setting 透明度/文案常量 +""" + +from __future__ import annotations + +from typing import Iterable, List + +from PySide2.QtCore import QEvent, QObject, QRectF, Qt +from PySide2.QtGui import QColor, QFont, QPainter, QPen +from PySide2.QtWidgets import QLabel, QVBoxLayout, QWidget + +from components.Frame.Frame_Theme_UI import UITheme +from charts.theme import chart_plot_bg_css +from setting import ( + CHART_EMPTY_HINT_PERF, + CHART_EMPTY_HINT_TRACK, + CHART_LAP_HUD_TEMPLATE, + CHART_VIEWPORT_HUD_ALPHA, + SPACE_OVERLAY_PAD, +) + + +def _hud_alpha_byte() -> int: + return max(0, min(255, int(round(float(CHART_VIEWPORT_HUD_ALPHA) * 255.0)))) + + +class ChartEmptyHud(QWidget): + """全幅空态蒙版:居中提示;背景与绘图区同色且不透明,避免显隐时背景明暗跳变。""" + + def __init__(self, text: str, parent: QWidget = None): + super().__init__(parent) + self.setObjectName("ChartEmptyHud") + self.setAttribute(Qt.WA_TransparentForMouseEvents, True) + self.setAttribute(Qt.WA_StyledBackground, True) + well_css = chart_plot_bg_css() + fg = QColor(UITheme.FG_MUTED) + self.setStyleSheet( + f""" + QWidget#ChartEmptyHud {{ + background-color: {well_css}; + }} + QLabel#ChartEmptyHudLabel {{ + color: {fg.name()}; + background: transparent; + border: none; + }} + """ + ) + lay = QVBoxLayout(self) + lay.setContentsMargins( + SPACE_OVERLAY_PAD, + SPACE_OVERLAY_PAD, + SPACE_OVERLAY_PAD, + SPACE_OVERLAY_PAD, + ) + self._label = QLabel(text) + self._label.setObjectName("ChartEmptyHudLabel") + self._label.setAlignment(Qt.AlignCenter) + font = QFont(UITheme.FONT_FAMILY_TECH) + font.setPixelSize(max(12, UITheme.scaled(14, 12))) + self._label.setFont(font) + lay.addWidget(self._label) + self.hide() + + def set_hint(self, text: str) -> None: + self._label.setText(str(text or "")) + + def set_empty(self, empty: bool) -> None: + self.setVisible(bool(empty)) + if empty: + self.raise_() + + +class ChartLapHud(QWidget): + """右上角圈号徽标;鼠标穿透,不随图变换。""" + + def __init__(self, parent: QWidget = None): + super().__init__(parent) + self.setObjectName("ChartLapHud") + self.setAttribute(Qt.WA_TransparentForMouseEvents, True) + self.setAttribute(Qt.WA_StyledBackground, True) + self.setStyleSheet("background: transparent;") + self._text = CHART_LAP_HUD_TEMPLATE.format(lap=1) + self.show() + + def set_lap(self, lap_number: int) -> None: + lap = max(1, int(lap_number)) + self._text = CHART_LAP_HUD_TEMPLATE.format(lap=lap) + self.update() + self.raise_() + + def paintEvent(self, _event) -> None: + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing, True) + a = _hud_alpha_byte() + # 徽标底色与绘图井区同源,仅徽标本身半透明(非整幅背景) + bg = QColor(UITheme.CHART_WELL) + bg.setAlpha(a) + fg = QColor(UITheme.FG) + fg.setAlpha(a) + border = QColor(UITheme.BORDER) + border.setAlpha(a) + + font = QFont(UITheme.FONT_FAMILY_TECH) + font.setPixelSize(max(11, UITheme.scaled(13, 11))) + p.setFont(font) + fm = p.fontMetrics() + pad_x = UITheme.scaled(10, 8) + pad_y = UITheme.scaled(6, 4) + tw = fm.width(self._text) + th = fm.height() + box_w = tw + pad_x * 2 + box_h = th + pad_y * 2 + margin = UITheme.scaled(8, 6) + x = max(0.0, float(self.width()) - box_w - margin) + y = float(margin) + box = QRectF(x, y, box_w, box_h) + p.setBrush(bg) + p.setPen(QPen(border, float(UITheme.BORDER_WIDTH))) + p.drawRoundedRect(box, float(UITheme.BORDER_RADIUS), float(UITheme.BORDER_RADIUS)) + p.setPen(QPen(fg)) + p.drawText(box, Qt.AlignCenter, self._text) + + +class _HudHostFilter(QObject): + """同步多个 HUD 到 host 客户区。""" + + def __init__(self, host: QWidget, huds: Iterable[QWidget], parent=None): + super().__init__(parent) + self._host = host + self._huds: List[QWidget] = list(huds) + + def eventFilter(self, obj, event) -> bool: + if obj is self._host and event.type() == QEvent.Resize: + self.sync() + return False + + def sync(self) -> None: + rect = self._host.rect() + for hud in self._huds: + hud.setGeometry(rect) + hud.raise_() + + +def attach_huds(host: QWidget, *huds: QWidget) -> _HudHostFilter: + """把 HUD 挂到 host 上并随 host 尺寸对齐(视口像素坐标,不进 ViewBox)。""" + items = [h for h in huds if h is not None] + for hud in items: + hud.setParent(host) + filt = _HudHostFilter(host, items, parent=host) + host.installEventFilter(filt) + filt.sync() + return filt + + +EMPTY_HINT_TRACK = CHART_EMPTY_HINT_TRACK +EMPTY_HINT_PERF = CHART_EMPTY_HINT_PERF diff --git a/charts/widgets/chart_toolbar.py b/charts/widgets/chart_toolbar.py new file mode 100644 index 00000000..b5055e94 --- /dev/null +++ b/charts/widgets/chart_toolbar.py @@ -0,0 +1,214 @@ +# -*- coding: utf-8 -*- +""" +模块:charts.widgets.chart_toolbar +职责:左右 Y 轴系列选择(下拉+显隐)与图表保存截图 +依赖:models.chart 选项库、UITheme +""" + +from __future__ import annotations + +import time +from typing import Callable, List, Optional, Sequence, Tuple + +from PySide2.QtCore import Qt, Signal +from PySide2.QtGui import QColor, QPainter, QPixmap +from PySide2.QtWidgets import ( + QCheckBox, + QComboBox, + QFileDialog, + QHBoxLayout, + QLabel, + QSizePolicy, + QWidget, +) + +from components.Frame.Frame_Theme_UI import UITheme + +from setting import SPACE_FRAME_GAP, SPACE_NONE, SPACE_WIDGET_GAP_TIGHT, SPACE_WIDGET_PAD +from models.chart import ( + CHART_SERIES_CATALOG, + DEFAULT_SERIES_SLOTS, + DEFAULT_SERIES_VISIBLE, + SERIES_SLOT_COUNT, + SERIES_SLOT_LABELS, +) + + +class ChartToolbar(QWidget): + """ + 一行两列:Y轴(L)/ Y轴(R)各「下拉选字段 + 显隐复选」。 + 置于 Canvas 顶栏右列,宽与性能图列对齐。 + """ + + # [(field_id, visible), ...] 长度 2:索引 0=左轴,1=右轴 + seriesSelectionChanged = Signal(object) + + def __init__( + self, + parent=None, + catalog=None, + default_slots: Optional[Sequence[str]] = None, + default_visible: Optional[Sequence[bool]] = None, + ): + super().__init__(parent) + self._catalog = dict(catalog or CHART_SERIES_CATALOG) + self._get_save_caption: Optional[Callable] = None + self._save_target: Optional[Callable[[], QPixmap]] = None + self._save_enabled = True + self._updating = False + + slots = list(default_slots or DEFAULT_SERIES_SLOTS) + vis = list(default_visible or DEFAULT_SERIES_VISIBLE) + catalog_ids = list(self._catalog.keys()) + while len(slots) < SERIES_SLOT_COUNT: + slots.append(catalog_ids[len(slots) % len(catalog_ids)]) + while len(vis) < SERIES_SLOT_COUNT: + vis.append(True) + slots = slots[:SERIES_SLOT_COUNT] + vis = vis[:SERIES_SLOT_COUNT] + + row = QHBoxLayout(self) + row.setContentsMargins( + SPACE_WIDGET_PAD, + SPACE_WIDGET_PAD, + SPACE_WIDGET_PAD, + SPACE_WIDGET_PAD, + ) + row.setSpacing(SPACE_FRAME_GAP) + self.setObjectName("ChartToolbar") + self.setAttribute(Qt.WA_StyledBackground, True) + + self._combos: List[QComboBox] = [] + self._checks: List[QCheckBox] = [] + for i in range(SERIES_SLOT_COUNT): + cell = QWidget(self) + cell.setObjectName("ChartToolbarCell") + cell.setAttribute(Qt.WA_StyledBackground, True) + cell_l = QHBoxLayout(cell) + cell_l.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + cell_l.setSpacing(SPACE_WIDGET_GAP_TIGHT) + + label = QLabel(SERIES_SLOT_LABELS[i]) + label.setObjectName("ChartHint") + + combo = QComboBox() + combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self._fill_combo(combo, slots[i]) + combo.currentIndexChanged.connect(self._emit_selection) + + check = QCheckBox("显示") + check.setChecked(bool(vis[i])) + check.toggled.connect(self._emit_selection) + + cell_l.addWidget(label, 0) + cell_l.addWidget(combo, 1) + cell_l.addWidget(check, 0) + row.addWidget(cell, 1) + self._combos.append(combo) + self._checks.append(check) + + def _fill_combo(self, combo: QComboBox, selected_id: str) -> None: + """两个下拉共用完整选项池。""" + combo.blockSignals(True) + combo.clear() + for opt in self._catalog.values(): + combo.addItem(opt.label, opt.id) + idx = combo.findData(selected_id) + combo.setCurrentIndex(idx if idx >= 0 else 0) + combo.blockSignals(False) + + def selection(self) -> List[Tuple[str, bool]]: + """当前左右槽 (field_id, visible)。""" + out: List[Tuple[str, bool]] = [] + for combo, check in zip(self._combos, self._checks): + field_id = str(combo.currentData() or "") + out.append((field_id, check.isChecked())) + return out + + def set_selection( + self, + slots: Sequence[Tuple[str, bool]], + ) -> None: + """回写左右槽选择。""" + self._updating = True + for i, combo in enumerate(self._combos): + if i >= len(slots): + break + field_id, visible = slots[i] + idx = combo.findData(field_id) + if idx >= 0: + combo.setCurrentIndex(idx) + self._checks[i].setChecked(bool(visible)) + self._updating = False + self._emit_selection() + + def set_catalog(self, catalog: dict) -> None: + """热更新选项库并重建两个下拉(共用全库)。""" + prev = self.selection() + self._catalog = dict(catalog) + self._updating = True + for i, combo in enumerate(self._combos): + cur = prev[i][0] if i < len(prev) else "" + self._fill_combo(combo, cur) + if i < len(prev): + self._checks[i].setChecked(prev[i][1]) + self._updating = False + self._emit_selection() + + def _emit_selection(self, *_args) -> None: + if self._updating: + return + self.seriesSelectionChanged.emit(self.selection()) + + def set_save_caption_provider(self, provider: Optional[Callable]) -> None: + """设置截图页眉圈时文案回调 → (rows, total_text)。""" + self._get_save_caption = provider + + def set_save_enabled(self, enabled: bool) -> None: + """能力开关:关闭后 save_chart 直接返回。""" + self._save_enabled = bool(enabled) + + def set_save_target(self, target: Callable[[], QPixmap]) -> None: + """设置截图目标(通常为 ChartDisplayArea.grab)。""" + self._save_target = target + + def save_chart(self) -> None: + """弹出另存对话框,合成页眉后写盘。""" + if not self._save_enabled or self._save_target is None: + return + default_name = time.strftime("chart_%Y%m%d_%H%M%S.png") + path, _ = QFileDialog.getSaveFileName( + self, + "保存图表", + default_name, + "PNG 图片 (*.png);;JPEG 图片 (*.jpg *.jpeg);;所有文件 (*.*)", + ) + if not path: + return + chart_pix = self._save_target() + lap_rows, total_text = [], "--" + if self._get_save_caption: + try: + lap_rows, total_text = self._get_save_caption() + except Exception: + lap_rows, total_text = [], "--" + header_h = 72 if lap_rows else 40 + out = QPixmap(chart_pix.width(), chart_pix.height() + header_h) + out.fill(QColor(UITheme.SURFACE)) + painter = QPainter(out) + painter.setPen(QColor(UITheme.FG)) + painter.setFont(UITheme.font_tech(UITheme.FONT_SM)) + y = 16 + if lap_rows: + for name, value in lap_rows: + painter.drawText(24, y, f"{name} {value}") + y += 16 + painter.setPen(QColor(UITheme.FG_TITLE)) + painter.setFont(UITheme.font(UITheme.FONT_SM)) + painter.drawText(out.width() - 160, 20, "NET(PENALTY)TOTAL") + painter.setPen(QColor(UITheme.FG)) + painter.setFont(UITheme.font_tech(UITheme.FONT_LG, bold=True)) + painter.drawText(out.width() - 160, 44, total_text or "--") + painter.drawPixmap(0, header_h, chart_pix) + painter.end() + out.save(path) diff --git a/charts/widgets/compare_style_panel.py b/charts/widgets/compare_style_panel.py new file mode 100644 index 00000000..c9ae3d5d --- /dev/null +++ b/charts/widgets/compare_style_panel.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +""" +模块:charts.widgets.compare_style_panel +职责:历史对比文件名显示 + 显隐复选(样式固定,无颜色/线型控件) +依赖:models.track_compare.CompareStyle、UITheme +""" + +from __future__ import annotations + +from PySide2.QtCore import Qt, Signal +from PySide2.QtWidgets import QCheckBox, QHBoxLayout, QLabel, QSizePolicy, QWidget + +from components.Frame.Frame_Theme_UI import UITheme + +from setting import SPACE_WIDGET_GAP, SPACE_WIDGET_PAD +from models.track_compare import CompareStyle + + +class CompareStylePanel(QWidget): + """单行:完整文件名 +「显示历史数据」复选(同速度带 LAP 样式)。""" + + styleChanged = Signal(object) # CompareStyle + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("CompareStylePanel") + self._style = CompareStyle() + self._updating = False + + self.label = QLabel("") + self.label.setObjectName("ChartHint") + self.label.setStyleSheet("background: transparent;") + self.label.setWordWrap(False) + self.label.setAlignment(Qt.AlignVCenter | Qt.AlignLeft) + self.label.setTextInteractionFlags(Qt.TextSelectableByMouse) + self.label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + + self.visible_cb = QCheckBox("显示历史数据") + self.visible_cb.setChecked(True) + self.visible_cb.setStyleSheet( + f"QCheckBox {{ color: {UITheme.FG}; background: transparent; }}" + ) + self.visible_cb.setToolTip("历史对比曲线显隐") + self.visible_cb.toggled.connect(self._emit_style) + + row = QHBoxLayout(self) + row.setContentsMargins( + SPACE_WIDGET_PAD, + SPACE_WIDGET_PAD, + SPACE_WIDGET_PAD, + SPACE_WIDGET_PAD, + ) + row.setSpacing(SPACE_WIDGET_GAP) + row.addWidget(self.label, 1) + row.addWidget(self.visible_cb, 0) + + self.setVisible(False) + + def style(self) -> CompareStyle: + """当前显隐 + 固定对比样式。""" + return CompareStyle(visible=self.visible_cb.isChecked()) + + def set_label(self, text: str) -> None: + """设置对比文件名(完整文件名)。""" + name = text or "" + self.label.setText(name) + self.label.setToolTip(name) + + def set_style(self, style: CompareStyle) -> None: + """回写显隐(不触发 styleChanged)。""" + self._updating = True + self._style = CompareStyle(visible=bool(style.visible)) + self.visible_cb.setChecked(bool(style.visible)) + self._updating = False + + def _emit_style(self, *_args) -> None: + if self._updating: + return + self.styleChanged.emit(self.style()) diff --git a/charts/widgets/stick_input.py b/charts/widgets/stick_input.py new file mode 100644 index 00000000..9a9cc5aa --- /dev/null +++ b/charts/widgets/stick_input.py @@ -0,0 +1,1055 @@ +# -*- coding: utf-8 -*- +""" +模块:charts.widgets.stick_input +职责:俯仰/滚转田字格 + 油门/偏航填充 + 右侧设备下拉与数值列;田字格摇杆包络(凸包) +依赖:models.hardware_monitor、UITheme、services.simulator.DATA_POLL_MS +""" + +from __future__ import annotations + +import time +from typing import Iterable, List, Optional, Sequence, Tuple + +from PySide2.QtCore import QPointF, QRectF, Qt, Signal +from PySide2.QtGui import QColor, QFont, QPainter, QPen, QPolygonF +from PySide2.QtWidgets import ( + QHBoxLayout, + QLabel, + QPushButton, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from components.Frame.Frame_Theme_UI import UITheme +from charts.theme import chart_border_width, chart_plot_bg_color, chart_plot_bg_css, chart_well_border_color +from setting import ( + SPACE_NONE, + SPACE_WIDGET_GAP_TIGHT, + STICK_ENVELOPE_EDGE_OPACITY, + STICK_ENVELOPE_HISTORY_OPACITY, + STICK_ENVELOPE_LIVE_OPACITY, +) +from models.hardware_monitor import ( + DeviceIdentity, + JoystickInfo, + StickAxes, + list_joysticks, + load_config, + save_config, +) + +_LAYOUT_SCALE = 0.8 +# 设备列表轻量刷新间隔(不 quit/init);完整重扫仅在无设备时触发 +_DEVICE_SOFT_SCAN_S = 2.0 +# 包络点量化:略细于显示十字抖动阈值,便于包络随摇杆移动逐步生长 +_ENVELOPE_QUANT = 200 + + +def _clamp(v: float, lo: float = -1.0, hi: float = 1.0) -> float: + return max(lo, min(hi, float(v))) + + +def _axes_key( + pitch: float, + roll: float, + yaw: float, + throttle: float, + connected: bool, +) -> Tuple[float, float, float, float, bool]: + # 量化到显示精度,避免浮点抖动触发无意义重绘 + return ( + round(float(pitch), 2), + round(float(roll), 2), + round(float(yaw), 2), + round(float(throttle), 2), + bool(connected), + ) + + +def _quantize_stick_xy(roll: float, pitch_disp: float) -> Tuple[float, float]: + q = float(_ENVELOPE_QUANT) + return (round(_clamp(roll) * q) / q, round(_clamp(pitch_disp) * q) / q) + + +def _convex_hull(points: Sequence[Tuple[float, float]]) -> List[Tuple[float, float]]: + """Andrew 单调链凸包;结果含边上所有输入极值点,任意原点均在内部或边上。""" + pts = sorted(set((float(x), float(y)) for x, y in points)) + n = len(pts) + if n <= 1: + return list(pts) + + def cross(o, a, b) -> float: + return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]) + + lower: List[Tuple[float, float]] = [] + for p in pts: + while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0.0: + lower.pop() + lower.append(p) + upper: List[Tuple[float, float]] = [] + for p in reversed(pts): + while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0.0: + upper.pop() + upper.append(p) + return lower[:-1] + upper[:-1] + + +# 时序包络样本:(t_rel, roll, pitch_disp);t_rel 相对本段航迹首点 +EnvelopeSample = Tuple[float, float, float] + + +def _track_time_base(points: Sequence) -> Optional[float]: + """航迹相对时间零点:与 scrub / compare 对齐,取首个有效 timestamp_s。""" + for pt in points or (): + if pt is None: + continue + try: + ts = float(pt.timestamp_s) + except (TypeError, ValueError, AttributeError): + continue + if ts == ts: + return ts + return None + + +def _envelope_samples_from_track( + points: Sequence, + *, + invert_pitch: bool, + t0: Optional[float] = None, +) -> List[EnvelopeSample]: + """ + 仅用「会写入航迹 CSV」的摇杆点(stick_pitch/stick_roll 均启用且样本有效)。 + 不含实时摇杆总线轮询。按时间保留样本,供游标前缀截取后算凸包。 + t0:相对时间零点;None 时取航迹首点 timestamp_s(与时间轴一致)。 + """ + from models.track_fields import is_field_enabled + + if not (is_field_enabled("stick_pitch") and is_field_enabled("stick_roll")): + return [] + base = t0 if t0 is not None else _track_time_base(points) + if base is None: + return [] + out: List[EnvelopeSample] = [] + for pt in points or (): + if pt is None: + continue + try: + if not pt.stick_connected(): + continue + roll = float(pt.stick_roll) + pitch = float(pt.stick_pitch) + ts = float(pt.timestamp_s) + except (TypeError, ValueError, AttributeError): + continue + if roll != roll or pitch != pitch or ts != ts: + continue + pitch_disp = -pitch if invert_pitch else pitch + xy = _quantize_stick_xy(roll, pitch_disp) + out.append((float(ts) - float(base), xy[0], xy[1])) + return out + + +def _prefix_count_until(series: Sequence[EnvelopeSample], t_rel: float) -> int: + """二分:series[i].t <= t_rel 的最大前缀长度。""" + lo, hi = 0, len(series) + t = float(t_rel) + while lo < hi: + mid = (lo + hi) // 2 + if series[mid][0] <= t: + lo = mid + 1 + else: + hi = mid + return lo + + +def _hull_from_prefix(series: Sequence[EnvelopeSample], n: int) -> List[Tuple[float, float]]: + if n <= 0: + return [] + pts = [(s[1], s[2]) for s in series[:n]] + return _convex_hull(pts) + + +class StickPlotWidget(QWidget): + """左侧操纵示意:油门 / 田字格 / 偏航(可叠画历史层与摇杆包络)。""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.setMinimumSize(UITheme.scaled(96, 72), UITheme.scaled(96, 72)) + self._pitch = 0.0 + self._roll = 0.0 + self._yaw = 0.0 + self._throttle = 0.0 + self._connected = False + self._state_key = _axes_key(0, 0, 0, 0, False) + self._cmp_pitch = 0.0 + self._cmp_roll = 0.0 + self._cmp_yaw = 0.0 + self._cmp_throttle = 0.0 + self._cmp_connected = False + self._cmp_key = _axes_key(0, 0, 0, 0, False) + self._cmp_visible = True + # 全量时序样本;绘制用 _hull_cache = 前缀凸包 + self._envelope_series: List[EnvelopeSample] = [] + self._cmp_envelope_series: List[EnvelopeSample] = [] + self._envelope_t0: Optional[float] = None + self._cmp_envelope_t0: Optional[float] = None + self._envelope_until_fp: Tuple[int, object] = (0, None) + self._cmp_envelope_until_fp: Tuple[int, object] = (0, None) + self._hull_cache: List[Tuple[float, float]] = [] + self._cmp_hull_cache: List[Tuple[float, float]] = [] + + def set_axes( + self, + pitch: float = 0.0, + roll: float = 0.0, + yaw: float = 0.0, + throttle: float = 0.0, + connected: bool = True, + ) -> None: + pitch = _clamp(pitch) + roll = _clamp(roll) + yaw = _clamp(yaw) + throttle = _clamp(throttle) + connected = bool(connected) + key = _axes_key(pitch, roll, yaw, throttle, connected) + if key == self._state_key: + return + self._pitch = pitch + self._roll = roll + self._yaw = yaw + self._throttle = throttle + self._connected = connected + self._state_key = key + self.update() + + def set_compare_axes( + self, + pitch: float = 0.0, + roll: float = 0.0, + yaw: float = 0.0, + throttle: float = 0.0, + connected: bool = True, + ) -> None: + pitch = _clamp(pitch) + roll = _clamp(roll) + yaw = _clamp(yaw) + throttle = _clamp(throttle) + connected = bool(connected) + key = _axes_key(pitch, roll, yaw, throttle, connected) + if key == self._cmp_key: + return + self._cmp_pitch = pitch + self._cmp_roll = roll + self._cmp_yaw = yaw + self._cmp_throttle = throttle + self._cmp_connected = connected + self._cmp_key = key + self.update() + + def set_compare_visible(self, visible: bool) -> None: + vis = bool(visible) + if vis == self._cmp_visible: + return + self._cmp_visible = vis + self.update() + + def envelope_t0(self) -> Optional[float]: + return self._envelope_t0 + + def compare_envelope_t0(self) -> Optional[float]: + return self._cmp_envelope_t0 + + def set_envelope_series( + self, + samples: Sequence[EnvelopeSample], + *, + t0: Optional[float] = None, + ) -> None: + """整表写入本场时序样本;默认显示全量凸包(随后由 show_envelope_until 截取)。""" + series = list(samples or ()) + self._envelope_series = series + self._envelope_t0 = t0 + self._envelope_until_fp = (-1, None) + self.show_envelope_until(None) + + def append_envelope_series(self, samples: Iterable[EnvelopeSample]) -> None: + """监测热路径:追加时序样本;若当前已显示到末尾则同步生长凸包。""" + batch = list(samples or ()) + if not batch: + return + was_at_end = ( + self._envelope_until_fp[0] < 0 + or self._envelope_until_fp[0] >= len(self._envelope_series) + ) + self._envelope_series.extend(batch) + if was_at_end: + self._envelope_until_fp = (-1, None) + self.show_envelope_until(None) + # scrub 中:只扩样本,凸包等下次 show_envelope_until / _sync_stick_envelopes_at + + def show_envelope_until(self, t_rel: Optional[float]) -> None: + """按相对时间截取前缀重算凸包;t_rel=None 表示全量。""" + series = self._envelope_series + if not series: + if self._hull_cache or self._envelope_until_fp != (0, None): + self._hull_cache = [] + self._envelope_until_fp = (0, None) + self.update() + return + if t_rel is None: + n = len(series) + fp = (-1, len(series)) + else: + n = _prefix_count_until(series, float(t_rel)) + fp = (n, len(series)) + if fp == self._envelope_until_fp: + return + self._envelope_until_fp = fp + self._hull_cache = _hull_from_prefix(series, n) + self.update() + + def clear_envelope(self) -> None: + if ( + not self._envelope_series + and not self._hull_cache + and self._envelope_t0 is None + ): + return + self._envelope_series = [] + self._envelope_t0 = None + self._hull_cache = [] + self._envelope_until_fp = (0, None) + self.update() + + def set_compare_envelope_series( + self, + samples: Sequence[EnvelopeSample], + *, + t0: Optional[float] = None, + ) -> None: + series = list(samples or ()) + self._cmp_envelope_series = series + self._cmp_envelope_t0 = t0 + self._cmp_envelope_until_fp = (-1, None) + self.show_compare_envelope_until(None) + + def show_compare_envelope_until(self, t_rel: Optional[float]) -> None: + series = self._cmp_envelope_series + if not series: + if self._cmp_hull_cache or self._cmp_envelope_until_fp != (0, None): + self._cmp_hull_cache = [] + self._cmp_envelope_until_fp = (0, None) + self.update() + return + if t_rel is None: + n = len(series) + fp = (-1, len(series)) + else: + n = _prefix_count_until(series, float(t_rel)) + fp = (n, len(series)) + if fp == self._cmp_envelope_until_fp: + return + self._cmp_envelope_until_fp = fp + self._cmp_hull_cache = _hull_from_prefix(series, n) + self.update() + + def clear_compare_envelope(self) -> None: + if ( + not self._cmp_envelope_series + and not self._cmp_hull_cache + and self._cmp_envelope_t0 is None + ): + return + self._cmp_envelope_series = [] + self._cmp_envelope_t0 = None + self._cmp_hull_cache = [] + self._cmp_envelope_until_fp = (0, None) + self.update() + + def clear(self) -> None: + self.set_axes(0.0, 0.0, 0.0, 0.0, connected=False) + + def clear_compare(self) -> None: + """只清对比十字/填充,保留对比包络(拖时间轴时仍要画)。""" + self.set_compare_axes(0.0, 0.0, 0.0, 0.0, connected=False) + + def has_envelope(self) -> bool: + """是否已有本场时序样本(与当前前缀凸包是否为空无关)。""" + return bool(self._envelope_series) + + def has_compare_envelope(self) -> bool: + return bool(self._cmp_envelope_series) + + def paintEvent(self, _event) -> None: + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing, True) + bg = chart_plot_bg_color() + # 先铺满整个控件,避免上下/外圈露出父级 BG 黑框 + p.fillRect(self.rect(), bg) + outer = QRectF(self.rect()).adjusted(2, 2, -2, -2) + if outer.width() < 28 or outer.height() < 28: + return + + border = chart_well_border_color() + muted = QColor(UITheme.FG_MUTED) + accent = QColor(UITheme.DATA_CURRENT) + hist = QColor(UITheme.DATA_HISTORY) + hist.setAlphaF(0.70) + # 参数井区与所在容器同色(CHART_WELL),仅靠边框区分 + well = bg + + gap = max(3.0, min(outer.width(), outer.height()) * 0.035) + rail = max(8.0, min(outer.width() * 0.14, outer.height() * 0.14)) + avail_w = outer.width() - rail - gap + avail_h = outer.height() - rail - gap + side = min(avail_w, avail_h) * _LAYOUT_SCALE + rail_s = rail * _LAYOUT_SCALE + gap_s = gap * _LAYOUT_SCALE + + block_w = rail_s + gap_s + side + block_h = side + gap_s + rail_s + ox = outer.left() + max(0.0, (outer.width() - block_w) * 0.5) + oy = outer.top() + max(0.0, (outer.height() - block_h) * 0.5) + + throttle_rect = QRectF(ox, oy, rail_s, side) + grid_rect = QRectF(ox + rail_s + gap_s, oy, side, side) + yaw_rect = QRectF(grid_rect.left(), grid_rect.bottom() + gap_s, side, rail_s) + + # 底板只画一次;包络与十字:历史在下、当前在上 + self._draw_throttle_frame(p, throttle_rect, well, border) + self._draw_cross_frame(p, grid_rect, well, border, muted) + self._draw_yaw_frame(p, yaw_rect, well, border, accent) + show_hist = bool(self._cmp_visible) + if show_hist and self._cmp_hull_cache: + self._draw_envelope( + p, + grid_rect, + self._cmp_hull_cache, + UITheme.DATA_HISTORY, + float(STICK_ENVELOPE_HISTORY_OPACITY), + ) + if self._cmp_connected and show_hist: + self._draw_throttle_fill( + p, throttle_rect, self._cmp_throttle, hist, draw_frame=False + ) + # 与本场一致:俯仰显示取反(推杆方向),避免同数据云端加载后相对 X 轴镜像 + self._draw_cross_mark( + p, grid_rect, self._cmp_roll, -self._cmp_pitch, hist + ) + self._draw_yaw_fill(p, yaw_rect, self._cmp_yaw, hist, draw_frame=False) + if self._hull_cache: + self._draw_envelope( + p, + grid_rect, + self._hull_cache, + UITheme.DATA_CURRENT, + float(STICK_ENVELOPE_LIVE_OPACITY), + ) + if self._connected: + self._draw_throttle_fill( + p, throttle_rect, self._throttle, accent, draw_frame=False + ) + # 当前监测俯仰相对硬件读数取反,使推杆方向与示意一致 + self._draw_cross_mark(p, grid_rect, self._roll, -self._pitch, accent) + self._draw_yaw_fill(p, yaw_rect, self._yaw, accent, draw_frame=False) + + def _draw_envelope( + self, + p: QPainter, + r: QRectF, + hull: Sequence[Tuple[float, float]], + color_name: str, + fill_alpha: float, + ) -> None: + if not hull: + return + cx, cy = r.center().x(), r.center().y() + pad = max(4.0, min(r.width(), r.height()) * 0.06) + rx = r.width() * 0.5 - pad + ry = r.height() * 0.5 - pad + + def _map_xy(roll: float, pitch_disp: float) -> QPointF: + return QPointF(cx + float(roll) * rx, cy - float(pitch_disp) * ry) + + fill = QColor(color_name) + fill.setAlphaF(max(0.0, min(1.0, float(fill_alpha)))) + edge = QColor(color_name) + edge.setAlphaF(max(0.0, min(1.0, float(STICK_ENVELOPE_EDGE_OPACITY)))) + if len(hull) == 1: + pt = _map_xy(hull[0][0], hull[0][1]) + rad = max(2.5, min(r.width(), r.height()) * 0.02) + p.setPen(Qt.NoPen) + p.setBrush(fill) + p.drawEllipse(pt, rad, rad) + return + if len(hull) == 2: + a = _map_xy(hull[0][0], hull[0][1]) + b = _map_xy(hull[1][0], hull[1][1]) + p.setPen(QPen(edge, 2.0)) + p.drawLine(a, b) + return + poly = QPolygonF() + for roll, pitch_disp in hull: + poly.append(_map_xy(roll, pitch_disp)) + p.setPen(QPen(edge, 1.2)) + p.setBrush(fill) + p.drawPolygon(poly) + + def _draw_cross_frame(self, p, r, well, border, muted) -> None: + bw = chart_border_width() + p.setPen(QPen(border, bw)) + p.setBrush(well) + p.drawRect(r) + cx, cy = r.center().x(), r.center().y() + p.setPen(QPen(muted, bw, Qt.DashLine)) + p.drawLine(QPointF(cx, r.top()), QPointF(cx, r.bottom())) + p.drawLine(QPointF(r.left(), cy), QPointF(r.right(), cy)) + p.drawLine(QPointF(r.left(), cy - r.height() * 0.25), QPointF(r.right(), cy - r.height() * 0.25)) + p.drawLine(QPointF(r.left(), cy + r.height() * 0.25), QPointF(r.right(), cy + r.height() * 0.25)) + p.drawLine(QPointF(cx - r.width() * 0.25, r.top()), QPointF(cx - r.width() * 0.25, r.bottom())) + p.drawLine(QPointF(cx + r.width() * 0.25, r.top()), QPointF(cx + r.width() * 0.25, r.bottom())) + + def _draw_cross_mark(self, p, r, roll, pitch, color) -> None: + cx, cy = r.center().x(), r.center().y() + pad = max(4.0, min(r.width(), r.height()) * 0.06) + arm = max(6.0, min(r.width(), r.height()) * 0.08) + x = cx + roll * (r.width() * 0.5 - pad) + y = cy - pitch * (r.height() * 0.5 - pad) + p.setPen(QPen(color, 1.8)) + p.drawLine(QPointF(x - arm, y), QPointF(x + arm, y)) + p.drawLine(QPointF(x, y - arm), QPointF(x, y + arm)) + + def _draw_throttle_frame(self, p, r, well, border) -> None: + p.setPen(QPen(border, chart_border_width())) + p.setBrush(well) + p.drawRect(r) + + def _draw_throttle_fill(self, p, r, value, color, *, draw_frame: bool = True) -> None: + if draw_frame: + self._draw_throttle_frame(p, r, chart_plot_bg_color(), chart_well_border_color()) + ratio = _clamp((float(value) + 1.0) * 0.5, 0.0, 1.0) + inner_h = max(0.0, r.height() - 2.0) + fill_h = ratio * inner_h + if fill_h <= 0.5: + return + fill = QRectF(r.left() + 1.0, r.bottom() - 1.0 - fill_h, r.width() - 2.0, fill_h) + fill_c = QColor(color) + # 历史层已带 0.7 alpha;当前层沿用半透明填充 + if fill_c.alphaF() >= 0.99: + fill_c.setAlphaF(0.55) + p.fillRect(fill, fill_c) + + def _draw_yaw_frame(self, p, r, well, border, center_color) -> None: + p.setPen(QPen(border, chart_border_width())) + p.setBrush(well) + p.drawRect(r) + cx = r.center().x() + p.setPen(QPen(center_color, 1.5)) + p.drawLine(QPointF(cx, r.top() + 1.0), QPointF(cx, r.bottom() - 1.0)) + + def _draw_yaw_fill(self, p, r, value, color, *, draw_frame: bool = True) -> None: + if draw_frame: + self._draw_yaw_frame( + p, r, chart_plot_bg_color(), chart_well_border_color(), color + ) + cx = r.center().x() + x = cx + value * (r.width() * 0.5 - 1.0) + fill = QRectF(min(x, cx), r.top() + 1.0, max(1.0, abs(x - cx)), r.height() - 2.0) + fill_c = QColor(color) + if fill_c.alphaF() >= 0.99: + fill_c.setAlphaF(0.55) + p.fillRect(fill, fill_c) + # 中线保持可见 + p.setPen(QPen(QColor(UITheme.DATA_CURRENT), 1.5)) + p.drawLine(QPointF(cx, r.top() + 1.0), QPointF(cx, r.bottom() - 1.0)) + + +class StickValuesWidget(QWidget): + """右侧数值:标签与当前值黄色;历史列蓝色(UI 标准 DATA_CURRENT / DATA_HISTORY)。""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) + self.setMinimumWidth(UITheme.scaled(108, 88)) + self._pitch = 0.0 + self._roll = 0.0 + self._yaw = 0.0 + self._throttle = 0.0 + self._connected = False + self._state_key = _axes_key(0, 0, 0, 0, False) + self._cmp_pitch = 0.0 + self._cmp_roll = 0.0 + self._cmp_yaw = 0.0 + self._cmp_throttle = 0.0 + self._cmp_connected = False + self._cmp_key = _axes_key(0, 0, 0, 0, False) + self._cmp_visible = True + + def set_axes( + self, + pitch: float = 0.0, + roll: float = 0.0, + yaw: float = 0.0, + throttle: float = 0.0, + connected: bool = True, + ) -> None: + # 数值区显示原始读数,不做 [-1,1] 夹紧 + pitch = float(pitch) + roll = float(roll) + yaw = float(yaw) + throttle = float(throttle) + connected = bool(connected) + key = _axes_key(pitch, roll, yaw, throttle, connected) + if key == self._state_key: + return + self._pitch = pitch + self._roll = roll + self._yaw = yaw + self._throttle = throttle + self._connected = connected + self._state_key = key + self.update() + + def set_compare_axes( + self, + pitch: float = 0.0, + roll: float = 0.0, + yaw: float = 0.0, + throttle: float = 0.0, + connected: bool = True, + ) -> None: + pitch = float(pitch) + roll = float(roll) + yaw = float(yaw) + throttle = float(throttle) + connected = bool(connected) + key = _axes_key(pitch, roll, yaw, throttle, connected) + if key == self._cmp_key: + return + was_show = self._cmp_connected and self._cmp_visible + self._cmp_pitch = pitch + self._cmp_roll = roll + self._cmp_yaw = yaw + self._cmp_throttle = throttle + self._cmp_connected = connected + self._cmp_key = key + now_show = self._cmp_connected and self._cmp_visible + if was_show != now_show: + self._sync_hist_width() + self.update() + + def set_compare_visible(self, visible: bool) -> None: + vis = bool(visible) + if vis == self._cmp_visible: + return + was_show = self._cmp_connected and self._cmp_visible + self._cmp_visible = vis + now_show = self._cmp_connected and self._cmp_visible + if was_show != now_show: + self._sync_hist_width() + self.update() + + def _sync_hist_width(self) -> None: + show = self._cmp_connected and self._cmp_visible + self.setMinimumWidth( + UITheme.scaled(168, 140) if show else UITheme.scaled(108, 88) + ) + + def clear(self) -> None: + self.set_axes(0.0, 0.0, 0.0, 0.0, connected=False) + + def clear_compare(self) -> None: + self.set_compare_axes(0.0, 0.0, 0.0, 0.0, connected=False) + + def paintEvent(self, _event) -> None: + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing, True) + # 与所在容器同色,避免数值区衬底跳色 + p.fillRect(self.rect(), chart_plot_bg_color()) + r = QRectF(self.rect()).adjusted(2, 2, -2, -2) + label_c = QColor(UITheme.DATA_CURRENT) + value_c = QColor(UITheme.DATA_CURRENT) + hist_c = QColor(UITheme.DATA_HISTORY) + hist_c.setAlphaF(0.70) + muted = QColor(UITheme.FG_MUTED) + show_hist = bool(self._cmp_connected and self._cmp_visible) + rows = ( + ("Pitch", -self._pitch, -self._cmp_pitch), + ("Roll", self._roll, self._cmp_roll), + ("Yaw", self._yaw, self._cmp_yaw), + ("Throttle", self._throttle, self._cmp_throttle), + ) + font = QFont(UITheme.FONT_FAMILY_TECH) + font.setPixelSize(max(9, UITheme.scaled(10, 9))) + p.setFont(font) + + # 表头:当前 / 历史 + header_h = UITheme.scaled(14, 12) if show_hist else 0.0 + body = QRectF(r.left(), r.top() + header_h, r.width(), r.height() - header_h) + label_w = min(body.width() * (0.38 if show_hist else 0.55), UITheme.scaled(64, 52)) + val_w = (body.width() - label_w) / (2.0 if show_hist else 1.0) + if show_hist: + p.setPen(QPen(value_c)) + cur_hdr = QRectF(body.left() + label_w, r.top(), val_w, header_h) + hist_hdr = QRectF(body.left() + label_w + val_w, r.top(), val_w, header_h) + p.drawText(cur_hdr, Qt.AlignLeft | Qt.AlignVCenter, "当前") + p.setPen(QPen(hist_c)) + p.drawText(hist_hdr, Qt.AlignLeft | Qt.AlignVCenter, "历史") + + row_h = body.height() / len(rows) + for i, (name, val, cmp_val) in enumerate(rows): + cell = QRectF(body.left(), body.top() + i * row_h, body.width(), row_h) + label_rect = QRectF(cell.left(), cell.top(), label_w, cell.height()) + cur_rect = QRectF(cell.left() + label_w, cell.top(), val_w, cell.height()) + p.setPen(QPen(label_c)) + p.drawText(label_rect, Qt.AlignLeft | Qt.AlignVCenter, name) + p.setPen(QPen(value_c if self._connected else muted)) + text = f"{val:+.3f}" if self._connected else "--" + p.drawText(cur_rect, Qt.AlignLeft | Qt.AlignVCenter, text) + if show_hist: + hist_rect = QRectF( + cell.left() + label_w + val_w, cell.top(), val_w, cell.height() + ) + p.setPen(QPen(hist_c)) + p.drawText(hist_rect, Qt.AlignLeft | Qt.AlignVCenter, f"{cmp_val:+.3f}") + + +class StickInputPane(QWidget): + """操纵输入面板:左图 + 右列(控制器设定 / 设备名 / 数值 / 提示)。""" + + deviceChanged = Signal(object) # Optional[DeviceIdentity] + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("StickInputPane") + self.setAttribute(Qt.WA_StyledBackground, True) + self.setAutoFillBackground(True) + # 边框仅在与外层容器交界处;摇杆绘图区内框仍为控件自身示意 + well_css = chart_plot_bg_css() + self.setStyleSheet( + f"QWidget#StickInputPane {{" + f" background-color: {well_css};" + f" border: {UITheme.border_css()};" + f" border-radius: {UITheme.BORDER_RADIUS}px;" + f" }}" + ) + self._devices: List[JoystickInfo] = [] + self._device_fp: tuple = () + self._selected: Optional[JoystickInfo] = None + self._last_scan_t = 0.0 + self._hint_kind = "" + + self.plot = StickPlotWidget(self) + self.values = StickValuesWidget(self) + + self._btn_controller = QPushButton("控制器设定") + self._btn_controller.setObjectName("ControllerSettingsButton") + self._btn_controller.setCursor(Qt.PointingHandCursor) + self._btn_controller.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self._btn_controller.clicked.connect(self._open_controller_settings) + + self._device_name = QLabel("") + self._device_name.setObjectName("Muted") + self._device_name.setWordWrap(True) + self._device_name.setAlignment(Qt.AlignHCenter | Qt.AlignTop) + self._device_name.setStyleSheet("background: transparent;") + + self._hint = QLabel("") + self._hint.setObjectName("Muted") + self._hint.setWordWrap(True) + self._hint.setAlignment(Qt.AlignLeft | Qt.AlignTop) + self._hint.setStyleSheet("background: transparent;") + + right = QWidget(self) + right.setObjectName("StickValuesColumn") + right.setAttribute(Qt.WA_StyledBackground, True) + right.setStyleSheet( + f"QWidget#StickValuesColumn {{ background-color: {chart_plot_bg_css()}; border: none; }}" + ) + right.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) + right.setMinimumWidth(UITheme.scaled(120, 96)) + right.setMaximumWidth(UITheme.scaled(220, 180)) + right_l = QVBoxLayout(right) + right_l.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + right_l.setSpacing(SPACE_WIDGET_GAP_TIGHT) + right_l.addWidget(self._btn_controller, 0) + right_l.addWidget(self._device_name, 0) + right_l.addWidget(self.values, 1) + right_l.addWidget(self._hint, 0) + + bw = int(UITheme.BORDER_WIDTH) + root = QHBoxLayout(self) + root.setContentsMargins(bw, bw, bw, bw) + root.setSpacing(SPACE_WIDGET_GAP_TIGHT) + root.addWidget(self.plot, 3) + root.addWidget(right, 2) + + # 构造期不做 rescan(quit/init),避免与后续 HardwareMonitor 抢摇杆子系统 + self.refresh_devices(rescan=False) + self._sync_hint() + + def _open_controller_settings(self) -> None: + from models.hardware_monitor import reload_stick + from components.Frame.Frame_Dialog_HardwareSettings import show_hardware_monitor_settings + + if show_hardware_monitor_settings(self) is not None: + reload_stick() + self.refresh_devices(rescan=False) + + @staticmethod + def _fingerprint(devices: List[JoystickInfo]) -> tuple: + return tuple((d.guid or d.name, d.index, d.num_axes) for d in devices) + + def _set_device_name(self, text: str) -> None: + name = str(text or "").strip() + if self._device_name.text() == name: + return + self._device_name.setText(name) + self._device_name.setVisible(bool(name)) + + def _set_hint(self, kind: str, text: str) -> None: + if kind == self._hint_kind and self._hint.text() == text: + return + self._hint_kind = kind + self._hint.setText(text) + self._hint.setVisible(bool(text)) + + def _sync_hint(self) -> None: + if not self._devices: + self._set_hint("none", "未检测到摇杆,请连接 USB 控制器后打开控制器设定") + return + if self._selected is None: + self._set_hint("unselected", "请在控制器设定中选择要监测的摇杆") + return + self._set_hint("", "") + + def refresh_devices(self, rescan: bool = False) -> None: + """刷新当前设备显示。rescan=True 会重启 pygame 摇杆子系统,仅在无设备时使用。""" + devices = list_joysticks(rescan=rescan) + fp = self._fingerprint(devices) + self._last_scan_t = time.monotonic() + cfg = load_config() + + if fp == self._device_fp: + self._devices = list(devices) + self._apply_selection_from_config(cfg, commit_if_missing=False) + self._sync_hint() + return + + self._devices = list(devices) + self._device_fp = fp + self._apply_selection_from_config(cfg, commit_if_missing=True) + if self._selected is None: + self._apply_disconnected() + self._sync_hint() + + def _apply_selection_from_config( + self, + cfg, + *, + commit_if_missing: bool, + ) -> None: + if not self._devices: + self._selected = None + self._set_device_name(str(cfg.device.name or "").strip()) + return + + chosen: Optional[JoystickInfo] = None + for d in self._devices: + if cfg.device.matches(d.identity()): + chosen = d + break + if chosen is None: + chosen = self._devices[0] + if commit_if_missing: + self._commit_device(chosen) + self._selected = chosen + self._set_device_name(chosen.name) + + def _maybe_refresh_devices(self) -> None: + now = time.monotonic() + if now - self._last_scan_t < _DEVICE_SOFT_SCAN_S: + return + # 有设备时禁止 quit/init,避免读数闪断 + need_rescan = not self._devices + self.refresh_devices(rescan=need_rescan) + + def _current_device(self) -> Optional[JoystickInfo]: + return self._selected + + def _commit_device(self, device: JoystickInfo) -> None: + cfg = load_config() + if cfg.device.matches(device.identity()) and cfg.device.guid == device.guid: + # 已是同一设备,避免无谓 save 触发 monitor 热重载闪烁 + if cfg.device.index == device.index and cfg.device.name == device.name: + return + cfg.device = device.identity() + save_config(cfg, require_cloud=False) + self.deviceChanged.emit(device.identity()) + + def _apply_disconnected(self) -> None: + self.plot.clear() + self.values.clear() + + def _apply_axes( + self, + pitch: float, + roll: float, + yaw: float, + throttle: float, + connected: bool, + ) -> None: + self.plot.set_axes(pitch, roll, yaw, throttle, connected) + self.values.set_axes(pitch, roll, yaw, throttle, connected) + + def _apply_compare_axes( + self, + pitch: float, + roll: float, + yaw: float, + throttle: float, + connected: bool, + ) -> None: + self.plot.set_compare_axes(pitch, roll, yaw, throttle, connected) + self.values.set_compare_axes(pitch, roll, yaw, throttle, connected) + + def set_axes( + self, + pitch: float = 0.0, + roll: float = 0.0, + yaw: float = 0.0, + throttle: float = 0.0, + connected: bool = True, + ) -> None: + self._maybe_refresh_devices() + if self._selected is None: + self._apply_disconnected() + self._sync_hint() + return + self._apply_axes(pitch, roll, yaw, throttle, connected) + self._sync_hint() + + def set_session_axes( + self, + pitch: float = 0.0, + roll: float = 0.0, + yaw: float = 0.0, + throttle: float = 0.0, + connected: bool = True, + ) -> None: + """本场/游标黄层:不依赖设备选中,不清包络(拖时间轴用)。""" + self._apply_axes(pitch, roll, yaw, throttle, connected) + + def set_stick(self, axes: StickAxes) -> None: + """实时摇杆总线 → 黄层;包络不在此更新。""" + self._maybe_refresh_devices() + if self._selected is None: + self._apply_disconnected() + self._sync_hint() + return + if not axes.connected: + self._apply_disconnected() + self._set_hint("lost", "摇杆已断开或暂时无法读取,请检查连接") + return + self._apply_axes(axes.pitch, axes.roll, axes.yaw, axes.throttle, True) + self._sync_hint() + + def set_compare_axes( + self, + pitch: float = 0.0, + roll: float = 0.0, + yaw: float = 0.0, + throttle: float = 0.0, + connected: bool = True, + ) -> None: + """叠画历史对比摇杆(不依赖本机设备选中状态)。""" + self._apply_compare_axes(pitch, roll, yaw, throttle, connected) + + def set_compare_stick(self, axes: Optional[StickAxes]) -> None: + if axes is None or not axes.connected: + # 仅清对比十字,勿清包络(scrub 无杆量点时也会走到这里) + self.plot.clear_compare() + self.values.clear_compare() + return + self.set_compare_axes( + axes.pitch, axes.roll, axes.yaw, axes.throttle, connected=True + ) + + def set_compare_visible(self, visible: bool) -> None: + """历史对比层显隐(保留杆量与包络,不 clear)。""" + self.plot.set_compare_visible(visible) + self.values.set_compare_visible(visible) + + def has_envelope(self) -> bool: + return self.plot.has_envelope() + + def has_compare_envelope(self) -> bool: + return self.plot.has_compare_envelope() + + def show_envelope_until(self, t_rel: Optional[float]) -> None: + self.plot.show_envelope_until(t_rel) + + def show_compare_envelope_until(self, t_rel: Optional[float]) -> None: + self.plot.show_compare_envelope_until(t_rel) + + def set_envelope_from_track_points( + self, points: Sequence, *, invert_pitch: bool = True + ) -> None: + """用航迹 CSV 点列写入本场时序样本(非实时轮询);默认显示全量。""" + t0 = _track_time_base(points) + samples = _envelope_samples_from_track( + points, invert_pitch=invert_pitch, t0=t0 + ) + self.plot.set_envelope_series(samples, t0=t0) + + def append_envelope_from_track_points( + self, + points: Sequence, + *, + invert_pitch: bool = True, + t0: Optional[float] = None, + ) -> None: + """监测热路径:追加本批将落盘的航迹摇杆点;t0 优先用已锁定/调用方传入(与时间轴对齐)。""" + base = self.plot.envelope_t0() + if base is None: + base = t0 if t0 is not None else _track_time_base(points) + batch = _envelope_samples_from_track( + points, invert_pitch=invert_pitch, t0=base + ) + if not batch: + return + if self.plot.envelope_t0() is None and base is not None: + self.plot.set_envelope_series(batch, t0=base) + return + self.plot.append_envelope_series(batch) + + def set_compare_envelope_from_track_points( + self, points: Sequence, *, invert_pitch: bool = True + ) -> None: + """历史/云端对比包络:俯仰与本场同规则取反,避免同轨迹相对 X 轴镜像。""" + t0 = _track_time_base(points) + samples = _envelope_samples_from_track( + points, invert_pitch=invert_pitch, t0=t0 + ) + self.plot.set_compare_envelope_series(samples, t0=t0) + + def clear_envelope(self) -> None: + self.plot.clear_envelope() + + def clear_compare_envelope(self) -> None: + self.plot.clear_compare_envelope() + + def clear_compare(self) -> None: + """清对比十字;包络由 clear_compare_envelope 单独清(关闭对比时调用)。""" + self.plot.clear_compare() + self.values.clear_compare() + + def clear(self) -> None: + self._apply_disconnected() + self.clear_envelope() + self.clear_compare() + self.clear_compare_envelope() + self._sync_hint() diff --git a/charts/widgets/timeline_scrubber.py b/charts/widgets/timeline_scrubber.py new file mode 100644 index 00000000..ea590473 --- /dev/null +++ b/charts/widgets/timeline_scrubber.py @@ -0,0 +1,305 @@ +# -*- coding: utf-8 -*- +""" +模块:charts.widgets.timeline_scrubber +职责:时间轴滑条 —— 波音737顶视剪影自左向右滑行;当前圈+叠加圈区段蓝色着色 + 圈间细红线 +依赖:UITheme +""" + +from __future__ import annotations + +from typing import List, Sequence + +from PySide2.QtCore import QRectF, Qt +from PySide2.QtGui import QColor, QPainter, QPainterPath, QPen +from PySide2.QtWidgets import QAbstractSlider, QSizePolicy, QWidget + +from components.Frame.Frame_Theme_UI import UITheme + +_LAP_BAND_ALPHA = 0.60 +_LAP_BAND_BLUE = UITheme.ACCENT +_LAP_SEP_COLOR = UITheme.ERROR +_LAP_SEP_WIDTH = 1.0 + + +def _plane_path() -> QPainterPath: + """波音737 经典顶视剪影:机头朝右(飞行方向左→右)。单位盒 0..1 × 0..1。""" + p = QPainterPath() + # 自机头顺时针一圈:细长机身 + 后掠主翼 + 翼下发动机轮廓 + 平尾 + p.moveTo(0.98, 0.50) + p.cubicTo(0.96, 0.46, 0.90, 0.44, 0.84, 0.435) + p.lineTo(0.62, 0.435) + p.lineTo(0.48, 0.12) + p.lineTo(0.40, 0.10) + p.lineTo(0.38, 0.14) + p.lineTo(0.46, 0.38) + p.cubicTo(0.44, 0.40, 0.42, 0.42, 0.44, 0.445) + p.lineTo(0.28, 0.445) + p.lineTo(0.14, 0.28) + p.lineTo(0.08, 0.28) + p.lineTo(0.12, 0.445) + p.lineTo(0.04, 0.46) + p.cubicTo(0.01, 0.48, 0.01, 0.52, 0.04, 0.54) + p.lineTo(0.12, 0.555) + p.lineTo(0.08, 0.72) + p.lineTo(0.14, 0.72) + p.lineTo(0.28, 0.555) + p.lineTo(0.44, 0.555) + p.cubicTo(0.42, 0.58, 0.44, 0.60, 0.46, 0.62) + p.lineTo(0.38, 0.86) + p.lineTo(0.40, 0.90) + p.lineTo(0.48, 0.88) + p.lineTo(0.62, 0.565) + p.lineTo(0.84, 0.565) + p.cubicTo(0.90, 0.56, 0.96, 0.54, 0.98, 0.50) + p.closeSubpath() + return p + + +class TimelineScrubber(QAbstractSlider): + """横向时间轴:细航线 + 波音737顶视剪影飞机手柄(机头朝右)。""" + + def __init__(self, parent: QWidget = None, *, track_color: str = "#122536"): + super().__init__(parent) + self.setOrientation(Qt.Horizontal) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.setMinimumHeight(UITheme.scaled(30, 24)) + self.setMaximumHeight(UITheme.scaled(40, 32)) + self.setMouseTracking(True) + self.setFocusPolicy(Qt.StrongFocus) + self.setSingleStep(1) + self.setPageStep(10) + self._track_color = QColor(track_color) + self._dragging = False + self._hover = False + self._plane = _plane_path() + self._plane.setFillRule(Qt.WindingFill) + self._lap_by_index: List[int] = [] + self._visible_laps: List[bool] = [] + self._lap_band_alpha = _LAP_BAND_ALPHA + + def set_track_color(self, color: str) -> None: + self._track_color = QColor(color) + self.update() + + def set_lap_bands( + self, + lap_by_index: Sequence[int], + visible_laps: Sequence[bool], + *, + alpha: float = _LAP_BAND_ALPHA, + ) -> None: + """ + 按时间轴下标着色:与轨迹图勾选的 LAP 对应。 + lap_by_index[i] = 圈号(1..N;0 表示无圈)。 + visible_laps[k] 对应 LAP(k+1) 是否显示。 + """ + self._lap_by_index = [int(v) for v in (lap_by_index or [])] + self._visible_laps = [bool(v) for v in (visible_laps or [])] + self._lap_band_alpha = max(0.0, min(1.0, float(alpha))) + self.update() + + def clear_lap_bands(self) -> None: + self._lap_by_index = [] + self._visible_laps = [] + self.update() + + def _plane_size(self) -> tuple: + h = max(18.0, self.height() * 0.88) + w = h * 1.25 + return w, h + + def _groove_rect(self) -> QRectF: + margin_x = self._plane_size()[0] * 0.5 + cy = self.height() * 0.5 + gh = max(3.0, UITheme.scaled(4, 3)) + return QRectF( + margin_x, + cy - gh * 0.5, + max(1.0, self.width() - 2.0 * margin_x), + gh, + ) + + def _value_to_x(self, value: int) -> float: + groove = self._groove_rect() + span = max(1, self.maximum() - self.minimum()) + t = (float(value) - float(self.minimum())) / float(span) + t = max(0.0, min(1.0, t)) + return groove.left() + t * groove.width() + + def _x_to_value(self, x: float) -> int: + groove = self._groove_rect() + if groove.width() <= 1e-6: + return int(self.minimum()) + t = (float(x) - groove.left()) / groove.width() + t = max(0.0, min(1.0, t)) + span = max(0, self.maximum() - self.minimum()) + return int(round(self.minimum() + t * span)) + + def _plane_rect(self) -> QRectF: + w, h = self._plane_size() + cx = self._value_to_x(self.value()) + cy = self.height() * 0.5 + return QRectF(cx - w * 0.5, cy - h * 0.5, w, h) + + def _paint_lap_bands(self, p: QPainter) -> None: + """可见 LAP 统一蓝色着色(透明度默认 60%),圈交界细红线分隔。""" + n = len(self._lap_by_index) + if n <= 0 or self.maximum() <= self.minimum(): + return + groove = self._groove_rect() + h = float(self.height()) + band = QColor(_LAP_BAND_BLUE) + band.setAlphaF(self._lap_band_alpha) + boundaries: List[float] = [] + i = 0 + while i < n: + lap = int(self._lap_by_index[i]) + j = i + 1 + while j < n and int(self._lap_by_index[j]) == lap: + j += 1 + show = ( + lap >= 1 + and lap <= len(self._visible_laps) + and bool(self._visible_laps[lap - 1]) + ) + x0 = self._value_to_x(max(self.minimum(), i)) + if j >= n: + x1 = groove.right() + else: + x1 = self._value_to_x(min(self.maximum(), j)) + # 有效圈交界处画分隔线(两侧至少一侧为有效圈号) + next_lap = int(self._lap_by_index[j]) if j < n else 0 + if lap >= 1 and next_lap >= 1 and lap != next_lap: + boundaries.append(x1) + if show: + w = max(0.0, x1 - x0) + if w > 0.5: + p.fillRect(QRectF(x0, 0.0, w, h), band) + i = j + if boundaries: + pen = QPen(QColor(_LAP_SEP_COLOR), _LAP_SEP_WIDTH) + p.setPen(pen) + for x in boundaries: + p.drawLine(int(round(x)), 0, int(round(x)), int(h)) + + def paintEvent(self, _event) -> None: + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing, True) + bg = self._track_color + p.fillRect(self.rect(), bg) + self._paint_lap_bands(p) + + groove = self._groove_rect() + enabled = self.isEnabled() and self.maximum() > self.minimum() + line_c = QColor(UITheme.TOGGLE_TRACK_BORDER if enabled else UITheme.BLUE_MID) + fill_c = QColor(UITheme.ACCENT if enabled else UITheme.BLUE_MID) + fill_c.setAlphaF(0.35 if enabled else 0.18) + + p.setPen(QPen(line_c, float(UITheme.BORDER_WIDTH))) + p.setBrush(bg) + p.drawRoundedRect(groove, groove.height() * 0.5, groove.height() * 0.5) + + cx = self._value_to_x(self.value()) + flown = QRectF(groove.left(), groove.top(), max(0.0, cx - groove.left()), groove.height()) + if flown.width() > 0.5: + p.setPen(Qt.NoPen) + p.setBrush(fill_c) + p.drawRoundedRect(flown, groove.height() * 0.5, groove.height() * 0.5) + + plane_c = QColor(UITheme.FG_ON_ACCENT if enabled else UITheme.FG_MUTED) + focused = self.hasFocus() + if self._hover or self._dragging or focused: + plane_c = QColor(UITheme.FLASH_BLUE if enabled else UITheme.FG_MUTED) + body = QColor(UITheme.ACCENT if enabled else UITheme.BLUE_MID) + if self._hover or self._dragging or focused: + body = QColor(UITheme.ACCENT_HOVER if enabled else UITheme.BLUE_MID) + + pr = self._plane_rect() + p.save() + p.translate(pr.topLeft()) + p.scale(pr.width(), pr.height()) + p.setPen(QPen(plane_c, 0.03)) + p.setBrush(body) + p.drawPath(self._plane) + p.setPen(QPen(plane_c, 0.04)) + p.setBrush(Qt.NoBrush) + p.drawPath(self._plane) + p.restore() + + if focused: + focus_c = QColor(UITheme.FLASH_BLUE) + focus_c.setAlphaF(0.85) + p.setPen(QPen(focus_c, 1.2, Qt.DashLine)) + p.setBrush(Qt.NoBrush) + p.drawRoundedRect(QRectF(self.rect()).adjusted(1.5, 1.5, -1.5, -1.5), 3, 3) + + def focusInEvent(self, event) -> None: + super().focusInEvent(event) + self.update() + + def focusOutEvent(self, event) -> None: + super().focusOutEvent(event) + self.update() + + def enterEvent(self, _event) -> None: + self._hover = True + self.update() + + def leaveEvent(self, _event) -> None: + self._hover = False + self.update() + + def keyPressEvent(self, event) -> None: + if not self.isEnabled() or self.maximum() <= self.minimum(): + super().keyPressEvent(event) + return + key = event.key() + mods = event.modifiers() + step = max(1, int(self.singleStep())) + page = max(step, int(self.pageStep())) + if mods & Qt.ControlModifier: + step = max(step * 5, page) + if key in (Qt.Key_Left, Qt.Key_Down): + self.setValue(self.value() - step) + elif key in (Qt.Key_Right, Qt.Key_Up): + self.setValue(self.value() + step) + elif key == Qt.Key_PageDown: + self.setValue(self.value() - page) + elif key == Qt.Key_PageUp: + self.setValue(self.value() + page) + elif key == Qt.Key_Home: + self.setValue(self.minimum()) + elif key == Qt.Key_End: + self.setValue(self.maximum()) + elif key == Qt.Key_Minus: + self.setValue(self.value() - step) + elif key in (Qt.Key_Plus, Qt.Key_Equal): + self.setValue(self.value() + step) + else: + super().keyPressEvent(event) + return + event.accept() + self.update() + + def mousePressEvent(self, event) -> None: + if event.button() != Qt.LeftButton or not self.isEnabled(): + return + self.setFocus(Qt.MouseFocusReason) + if self.maximum() <= self.minimum(): + return + self._dragging = True + self.setSliderDown(True) + self.setValue(self._x_to_value(event.pos().x())) + self.update() + + def mouseMoveEvent(self, event) -> None: + if self._dragging: + self.setValue(self._x_to_value(event.pos().x())) + self.update() + + def mouseReleaseEvent(self, event) -> None: + if event.button() == Qt.LeftButton and self._dragging: + self._dragging = False + self.setSliderDown(False) + self.setValue(self._x_to_value(event.pos().x())) + self.update() diff --git a/charts/widgets/track_color_toolbar.py b/charts/widgets/track_color_toolbar.py new file mode 100644 index 00000000..c977ca7a --- /dev/null +++ b/charts/widgets/track_color_toolbar.py @@ -0,0 +1,267 @@ +# -*- coding: utf-8 -*- +""" +模块:charts.widgets.track_color_toolbar +职责:航迹顶栏一行两列——渲染数据 / 叠加(差集排除当前圈,单选 + 显隐复选) +依赖:models.chart 选项库、models.lap_reference、UITheme、setting +""" + +from __future__ import annotations + +from typing import List, Optional, Sequence + +from PySide2.QtCore import Qt, Signal +from PySide2.QtWidgets import ( + QCheckBox, + QComboBox, + QHBoxLayout, + QLabel, + QSizePolicy, + QWidget, +) + +from models.chart import CHART_SERIES_CATALOG +from models.lap_reference import LAP_COUNT +from setting import ( + CHART_LAP_HUD_TEMPLATE, + SPACE_FRAME_GAP, + SPACE_NONE, + SPACE_WIDGET_GAP_TIGHT, + SPACE_WIDGET_PAD, +) + +_DEFAULT_COLOR_FIELD = "airspeed" + + +def _lap_label(lap: int) -> str: + return CHART_LAP_HUD_TEMPLATE.format(lap=int(lap)) + + +class TrackColorToolbar(QWidget): + """ + 一行两列:渲染数据 / 叠加(下拉 +「显示」复选,对齐性能图 ChartToolbar)。 + 置于 Canvas 顶栏左列,宽与航迹列对齐;样式与 ChartToolbar 一致。 + """ + + colorFieldChanged = Signal(str) + overlayLapsChanged = Signal(object) # List[int],0 或 1 个圈号 + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("TrackColorToolbar") + self.setAttribute(Qt.WA_StyledBackground, True) + self._color_field_id = _DEFAULT_COLOR_FIELD + self._focus_lap = 1 + self._overlay_laps: List[int] = [] + self._overlay_visible = False + self._available_laps: Optional[List[int]] = None + self._updating = False + + row = QHBoxLayout(self) + row.setContentsMargins( + SPACE_WIDGET_PAD, + SPACE_WIDGET_PAD, + SPACE_WIDGET_PAD, + SPACE_WIDGET_PAD, + ) + row.setSpacing(SPACE_FRAME_GAP) + + # 列1:渲染数据 + cell_color = QWidget(self) + cell_color.setObjectName("TrackColorToolbarCell") + cell_color.setAttribute(Qt.WA_StyledBackground, True) + color_l = QHBoxLayout(cell_color) + color_l.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + color_l.setSpacing(SPACE_WIDGET_GAP_TIGHT) + lbl_color = QLabel("渲染数据") + lbl_color.setObjectName("ChartHint") + self._combo = QComboBox() + self._combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self._fill_combo(_DEFAULT_COLOR_FIELD) + self._combo.currentIndexChanged.connect(self._on_color_changed) + color_l.addWidget(lbl_color, 0) + color_l.addWidget(self._combo, 1) + row.addWidget(cell_color, 1) + + # 列2:叠加(差集排除当前圈,单选 + 显隐,对齐性能图) + cell_overlay = QWidget(self) + cell_overlay.setObjectName("TrackColorToolbarCell") + cell_overlay.setAttribute(Qt.WA_StyledBackground, True) + overlay_l = QHBoxLayout(cell_overlay) + overlay_l.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + overlay_l.setSpacing(SPACE_WIDGET_GAP_TIGHT) + lbl_overlay = QLabel("叠加") + lbl_overlay.setObjectName("ChartHint") + self._overlay_combo = QComboBox() + self._overlay_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self._overlay_combo.currentIndexChanged.connect(self._on_overlay_changed) + self._overlay_check = QCheckBox("显示") + self._overlay_check.setChecked(False) + self._overlay_check.toggled.connect(self._on_overlay_changed) + overlay_l.addWidget(lbl_overlay, 0) + overlay_l.addWidget(self._overlay_combo, 1) + overlay_l.addWidget(self._overlay_check, 0) + row.addWidget(cell_overlay, 1) + + self._rebuild_overlay_combo(keep=None, visible=False) + + def _fill_combo(self, selected_id: str) -> None: + self._updating = True + self._combo.blockSignals(True) + self._combo.clear() + for opt in CHART_SERIES_CATALOG.values(): + self._combo.addItem(opt.label, opt.id) + idx = self._combo.findData(selected_id) + self._combo.setCurrentIndex(idx if idx >= 0 else 0) + self._combo.blockSignals(False) + self._updating = False + + def _allowed_overlay_laps(self) -> List[int]: + focus = max(1, min(LAP_COUNT, int(self._focus_lap or 1))) + allowed = [i for i in range(1, LAP_COUNT + 1) if i != focus] + if self._available_laps is not None: + present = {int(x) for x in self._available_laps} + allowed = [i for i in allowed if i in present] + return allowed + + def _rebuild_overlay_combo( + self, + keep: Optional[int], + *, + visible: Optional[bool] = None, + ) -> None: + """选项 = 有数据的圈 ∩ 差集排除当前圈(无「无」项);显隐由复选控制。""" + allowed = self._allowed_overlay_laps() + if visible is None: + visible = bool(self._overlay_visible) + selected: Optional[int] = None + if keep is not None and int(keep) in allowed: + selected = int(keep) + elif allowed: + selected = int(allowed[0]) + if not allowed: + visible = False + + self._updating = True + self._overlay_combo.blockSignals(True) + self._overlay_check.blockSignals(True) + self._overlay_combo.clear() + for lap in allowed: + self._overlay_combo.addItem(_lap_label(lap), lap) + has = bool(allowed) + self._overlay_combo.setEnabled(has) + self._overlay_check.setEnabled(has) + if selected is not None: + idx = self._overlay_combo.findData(selected) + self._overlay_combo.setCurrentIndex(idx if idx >= 0 else 0) + self._overlay_check.setChecked(bool(visible) and selected is not None) + self._overlay_combo.blockSignals(False) + self._overlay_check.blockSignals(False) + self._updating = False + + self._overlay_visible = bool(self._overlay_check.isChecked()) + if self._overlay_visible and selected is not None: + self._overlay_laps = [selected] + else: + self._overlay_laps = [] + + def set_available_laps(self, laps: Optional[Sequence[int]]) -> None: + """按航迹实际出现的圈号裁剪叠加选项;None=不裁剪(1..LAP_COUNT)。""" + if laps is None: + new_set = None + else: + new_set = sorted( + { + int(x) + for x in laps + if 1 <= int(x) <= LAP_COUNT + } + ) + if new_set == self._available_laps: + return + self._available_laps = new_set + keep = self._overlay_laps[0] if self._overlay_laps else None + prev = list(self._overlay_laps) + self._rebuild_overlay_combo(keep, visible=self._overlay_visible) + if list(self._overlay_laps) != prev: + self.overlayLapsChanged.emit(list(self._overlay_laps)) + + def color_field(self) -> str: + return self._color_field_id + + def set_color_field(self, field_id: str) -> None: + fid = str(field_id or _DEFAULT_COLOR_FIELD) + if fid not in CHART_SERIES_CATALOG: + fid = _DEFAULT_COLOR_FIELD + self._color_field_id = fid + self._updating = True + idx = self._combo.findData(fid) + if idx >= 0: + self._combo.blockSignals(True) + self._combo.setCurrentIndex(idx) + self._combo.blockSignals(False) + self._updating = False + + def focus_lap(self) -> int: + return int(self._focus_lap) + + def set_focus_lap(self, lap_number: int) -> None: + """当前圈变更时重建叠加选项(差集排除当前圈)。""" + lap = int(lap_number) if lap_number is not None else 1 + if lap < 1 or lap > LAP_COUNT: + lap = 1 + if lap == self._focus_lap: + return + self._focus_lap = lap + # 差集:若原叠加圈已变成当前圈,则取消显示并改选其它圈 + keep = self._overlay_laps[0] if self._overlay_laps else None + visible = self._overlay_visible + if keep is not None and int(keep) == lap: + keep = None + visible = False + self._rebuild_overlay_combo(keep, visible=visible) + + def overlay_laps(self) -> List[int]: + return list(self._overlay_laps) + + def set_overlay_laps(self, laps: Sequence[int]) -> None: + focus = self._focus_lap + allowed = {i for i in range(1, LAP_COUNT + 1) if i != focus} + if self._available_laps is not None: + present = {int(x) for x in self._available_laps} + allowed &= present + # 单选:只取第一个合法圈;空列表 → 取消显示 + keep = None + for x in laps or []: + lap = int(x) + if lap in allowed: + keep = lap + break + self._rebuild_overlay_combo(keep, visible=keep is not None) + + def _on_color_changed(self, _index: int = 0) -> None: + if self._updating: + return + fid = str(self._combo.currentData() or _DEFAULT_COLOR_FIELD) + self._color_field_id = fid + self.colorFieldChanged.emit(fid) + + def _on_overlay_changed(self, *_args) -> None: + if self._updating: + return + idx = int(self._overlay_combo.currentIndex()) + raw = self._overlay_combo.itemData(idx) if idx >= 0 else None + if raw is None: + raw = self._overlay_combo.currentData() + try: + lap = int(raw) if raw is not None else 0 + except (TypeError, ValueError): + lap = 0 + focus = int(self._focus_lap) + visible = bool(self._overlay_check.isChecked()) + if (not visible) or lap == focus or lap < 1 or lap > LAP_COUNT: + self._overlay_visible = False + self._overlay_laps = [] + else: + self._overlay_visible = True + self._overlay_laps = [lap] + self.overlayLapsChanged.emit(list(self._overlay_laps)) diff --git a/charts/widgets/track_map_pane.py b/charts/widgets/track_map_pane.py new file mode 100644 index 00000000..162f78e9 --- /dev/null +++ b/charts/widgets/track_map_pane.py @@ -0,0 +1,1789 @@ +# -*- coding: utf-8 -*- +""" +模块:charts.widgets.track_map_pane +职责:赛道航迹(pyqtgraph)+ 进度条驱动圈过滤;着色字段由顶栏 TrackColorToolbar 管理 +依赖:models.track_map、charts.pg_bind、UITheme +""" + +from __future__ import annotations + +from typing import Dict, List, Optional, Sequence, Tuple + +from PySide2.QtCore import QEvent, QPointF, QRectF, Qt, Signal +from PySide2.QtGui import QColor, QFont, QLinearGradient, QPainter, QPen +from PySide2.QtWidgets import QSizePolicy, QVBoxLayout, QWidget + +from components.Frame.Frame_Theme_UI import UITheme +from components.widget.widget_Btn_TrackViewLock import widget_Btn_TrackViewLock +from charts.pg_bind import pg +from charts.theme import ( + apply_chart_float_font, + axis_border_color, + axis_label_color, + axis_muted_color, + chart_border_width, + chart_float_html, + chart_plot_bg_color, + chart_plot_bg_css, + chart_well_border_color, + desaturate_color, + field_pen_style, +) +from models.chart import CHART_SERIES_CATALOG, series_option +from models.lap_reference import LAP_COUNT +from models.track_compare import CompareStyle +from models.track_map import ( + FIXED_SPEED_COLOR_FIELDS, + TrackPoint, + compute_bounds, + filter_by_visible_laps, + format_metric_legend_value, + metric_range_for_field, + metric_tick_marks, + metric_to_rgb, + metric_values_for_field, + metric_legend_reversed, +) +from charts.widgets.chart_hud_overlay import ChartEmptyHud, ChartLapHud, attach_huds +from setting import ( + CHART_EMPTY_HINT_TRACK, + CHART_GRID_ALPHA, + CHART_HISTORY_OPACITY, + CHART_LIVE_OPACITY, + SPACE_NONE, + SPACE_WIDGET_PAD, + TRACK_COLOR_BUCKETS, + TRACK_OVERLAY_LINE_WIDTH, + TRACK_OVERLAY_LINE_WIDTH_BG, + TRACK_OVERLAY_LINE_STYLE, + TRACK_OVERLAY_OPACITY, + TRACK_VIEW_FIT_PAD, + TRACK_VIEW_LOCK_BTN_MARGIN, + TRACK_VIEW_LOCK_ON, + TRACK_VIEW_LOCK_OFF, +) + +_LINE_WIDTH_LIVE = 3.2 +_LINE_WIDTH_COMPARE = 5.0 +_LINE_WIDTH_BG_LIVE = 6.0 +_LINE_WIDTH_BG_COMPARE = 9.0 +_DEFAULT_COLOR_FIELD = "airspeed" + + +class _LegendStrip(QWidget): + """底部色标条(QPainter);轨迹本体在 PlotWidget。""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedHeight(UITheme.scaled(36, 30)) + self._field_id = _DEFAULT_COLOR_FIELD + self._val_lo: Optional[float] = None + self._val_hi: Optional[float] = None + + def set_metric(self, field_id: str, val_lo: Optional[float], val_hi: Optional[float]) -> None: + self._field_id = field_id + self._val_lo = val_lo + self._val_hi = val_hi + self.update() + + def paintEvent(self, _event) -> None: + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing, True) + rect = QRectF(self.rect()) + p.fillRect(rect, chart_plot_bg_color()) + legend = QRectF(rect.left() + 6, rect.top() + 1, rect.width() - 12, rect.height() - 2) + font = QFont(UITheme.FONT_FAMILY_TECH) + font.setPixelSize(max(8, UITheme.scaled(9, 8))) + p.setFont(font) + p.setPen(QPen(QColor(UITheme.FG_MUTED))) + opt = series_option(self._field_id) + field_label = opt.label if opt is not None else self._field_id + unit = (opt.axis_title if opt is not None else "") or "" + val_lo, val_hi = self._val_lo, self._val_hi + if val_lo is None or val_hi is None: + suffix = f" ({unit})" if unit else "" + p.drawText(legend, Qt.AlignVCenter | Qt.AlignLeft, f"{field_label}{suffix}") + return + + bar_w = min(UITheme.scaled(168, 130), legend.width() * 0.48) + bar_h = UITheme.scaled(10, 8) + bar = QRectF( + legend.right() - bar_w, + legend.top() + UITheme.scaled(4, 3), + bar_w, + bar_h, + ) + grad = QLinearGradient(bar.left(), 0, bar.right(), 0) + if metric_legend_reversed(self._field_id): + grad.setColorAt(0.0, QColor(0, 255, 255)) + grad.setColorAt(0.33, QColor(0, 255, 0)) + grad.setColorAt(0.66, QColor(255, 255, 0)) + grad.setColorAt(1.0, QColor(255, 0, 0)) + else: + grad.setColorAt(0.0, QColor(255, 0, 0)) + grad.setColorAt(0.33, QColor(255, 255, 0)) + grad.setColorAt(0.66, QColor(0, 255, 0)) + grad.setColorAt(1.0, QColor(0, 255, 255)) + p.fillRect(bar, grad) + p.setPen(QPen(chart_well_border_color(), chart_border_width())) + p.drawRect(bar) + + ticks = metric_tick_marks(val_lo, val_hi, self._field_id) + span = max(1e-6, val_hi - val_lo) + tick_pen = QPen(QColor("#ffffff")) + tick_pen.setWidthF(1.0) + label_font = QFont(UITheme.FONT_FAMILY_TECH) + label_font.setPixelSize(max(7, UITheme.scaled(8, 7))) + p.setFont(label_font) + for v in ticks: + t = (v - val_lo) / span + x = bar.left() + t * bar.width() + p.setPen(tick_pen) + p.drawLine(QPointF(x, bar.top()), QPointF(x, bar.bottom())) + + p.setPen(QPen(QColor(UITheme.FG_MUTED))) + lo_txt = format_metric_legend_value(self._field_id, val_lo) + hi_txt = format_metric_legend_value(self._field_id, val_hi) + p.drawText( + QRectF(bar.left() - 4, bar.bottom() + 1, 48, legend.bottom() - bar.bottom()), + Qt.AlignLeft | Qt.AlignTop, + lo_txt, + ) + p.drawText( + QRectF(bar.right() - 48, bar.bottom() + 1, 52, legend.bottom() - bar.bottom()), + Qt.AlignRight | Qt.AlignTop, + hi_txt, + ) + + p.setFont(font) + unit_txt = f" {unit}" if unit else "" + hint = "" + if self._field_id == "health": + hint = " (<70紫)" + elif self._field_id == "bank": + hint = " (大→红)" + range_txt = ( + f"{format_metric_legend_value(self._field_id, val_lo)}" + f"-{format_metric_legend_value(self._field_id, val_hi)}" + ) + p.drawText( + legend.adjusted(0, 0, -(bar_w + 8), 0), + Qt.AlignVCenter | Qt.AlignLeft, + f"{field_label} {range_txt}{unit_txt}{hint}", + ) + + +class TrackMapCanvas(QWidget): + """航迹:PlotWidget + PlotDataItem;正北向上;官方 ViewBox 拖动/缩放。""" + + cursorIndexChanged = Signal(int) + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("TrackMapCanvas") + self.setAttribute(Qt.WA_StyledBackground, True) + self.setAutoFillBackground(True) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.setMinimumHeight(UITheme.scaled(140, 120)) + self.setFocusPolicy(Qt.WheelFocus) + + self._points: List[TrackPoint] = [] + self._compare: List[TrackPoint] = [] + self._visible_laps: List[bool] = [i == 0 for i in range(LAP_COUNT)] + self._overlay_laps: List[bool] = [False] * LAP_COUNT + self._color_field_id = _DEFAULT_COLOR_FIELD + self._compare_style = CompareStyle() + self._compare_visible = True + self._cursor_index = 0 + self._view_fitted = False + self._path_items: List[pg.PlotDataItem] = [] + self._compare_items: List[pg.PlotDataItem] = [] + self._overlay_items: List[pg.PlotDataItem] = [] + # 按圈缓存图元:全量绘制后跨圈/叠加仅 setVisible + self._path_by_lap: Dict[int, List[pg.PlotDataItem]] = {} + self._compare_by_lap: Dict[int, List[pg.PlotDataItem]] = {} + self._overlay_by_lap: Dict[int, List[pg.PlotDataItem]] = {} + # 少桶着色:每圈当前尾段 (bucket_id, PlotDataItem) + self._path_color_tail: Dict[int, Tuple[int, pg.PlotDataItem]] = {} + self._overlay_color_tail: Dict[int, Tuple[int, pg.PlotDataItem]] = {} + self._active_color_range: Optional[Tuple[float, float]] = None + self._laps_seen: set = set() + self._marker_start: Optional[pg.ScatterPlotItem] = None + self._marker_end: Optional[pg.ScatterPlotItem] = None + self._marker_start_lap = 0 + self._marker_end_lap = 0 + self._cursor_scatter: Optional[pg.ScatterPlotItem] = None + self._cursor_outer: Optional[pg.ScatterPlotItem] = None + self._cursor_cross: List[pg.PlotDataItem] = [] + self._hist_cursor_scatter: Optional[pg.ScatterPlotItem] = None + self._hist_cursor_outer: Optional[pg.ScatterPlotItem] = None + self._hist_cursor_cross: List[pg.PlotDataItem] = [] + self._float_label: Optional[pg.TextItem] = None + # 视窗锁定:非监测默认自由;开始监测时由 TrendChart.resume_live_follow 切锁定 + self._lock_mode = int(TRACK_VIEW_LOCK_OFF) + self._lock_half_w = 0.0 + self._lock_half_h = 0.0 + self._applying_camera = False + self._vb_mouse_drag_orig = None + self._lock_btn: Optional[widget_Btn_TrackViewLock] = None + # 航迹基底层;游标不进此层,锁定时保持屏幕正向钉在中心 + self._track_layer: Optional[pg.ItemGroup] = None + + self._plot = pg.PlotWidget() + self._plot.setObjectName("TrackMapPlot") + self._plot.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self._plot.setMenuEnabled(False) + well = chart_plot_bg_color() + well_css = chart_plot_bg_css() + self._plot.setBackground(well) + self._plot.setAutoFillBackground(True) + # 必须带选择器:无选择器会污染子控件(锁定钮边框/底色被 border:none 冲掉) + self._plot.setStyleSheet( + f"QWidget#TrackMapPlot {{ background-color: {well_css}; border: none; }}" + ) + self._plot.showGrid(x=True, y=True, alpha=float(CHART_GRID_ALPHA)) + pi = self._plot.plotItem + pi.hideButtons() + pi.showAxis("bottom") + pi.showAxis("left") + pi.hideAxis("right") + pi.hideAxis("top") + muted = axis_muted_color() + border = axis_border_color() + label_c = axis_label_color() + for name, label in (("bottom", "Lon"), ("left", "Lat")): + ax = pi.getAxis(name) + ax.setPen(pg.mkPen(border)) + ax.setTextPen(pg.mkPen(muted)) + pi.setLabel(name, label, color=label_c.name()) + vb = pi.getViewBox() + vb.invertY(False) + # 单轴图:VB 与 PlotWidget 同用不透明井区,避免窗体 alpha 透出跳变 + vb.setBackgroundColor(well) + vb.setMouseEnabled(x=True, y=True) + pi.setMouseEnabled(x=True, y=True) + pi.enableAutoRange(enable=False) + # 航迹/对比/标记进独立 ItemGroup(与游标分层) + self._track_layer = pg.ItemGroup() + pi.addItem(self._track_layer) + # 绘图区无边框;外框画在 TrackMapPane(与容器交界) + + # 固定 HUD:空态 + 圈号(不随缩放/拖动);初始即「等待航迹数据」 + self._empty_hud = ChartEmptyHud(CHART_EMPTY_HINT_TRACK, self._plot) + self._lap_hud = ChartLapHud(self._plot) + self._hud_filter = attach_huds(self._plot, self._empty_hud, self._lap_hud) + self.set_empty_hud(True) + + # 左下角二态视窗锁定;挂在 Canvas 上,避免 PlotWidget stylesheet 污染 + self._lock_btn = widget_Btn_TrackViewLock(self) + self._lock_btn.set_mode(TRACK_VIEW_LOCK_OFF, emit=False) + self._lock_btn.modeChanged.connect(self._on_lock_mode_changed) + self._lock_btn.raise_() + + self._legend = _LegendStrip(self) + + layout = QVBoxLayout(self) + layout.setContentsMargins( + SPACE_WIDGET_PAD, + SPACE_WIDGET_PAD, + SPACE_WIDGET_PAD, + SPACE_NONE, + ) + layout.setSpacing(SPACE_NONE) + layout.addWidget(self._plot, 1) + layout.addWidget(self._legend, 0) + + self._plot.viewport().installEventFilter(self) + self._plot.installEventFilter(self) + vb = self._plot.plotItem.getViewBox() + self._vb_mouse_drag_orig = vb.mouseDragEvent + vb.sigRangeChanged.connect(self._on_view_range_changed) + self._set_drag_enabled(True) + self._position_lock_btn() + + def eventFilter(self, obj, event): + if obj is self._plot and event.type() == QEvent.Resize: + self._position_lock_btn() + if obj is self._plot.viewport(): + if event.type() == QEvent.MouseButtonDblClick and event.button() == Qt.LeftButton: + # 双击:解除锁定并 fit 全图(与历史复位一致) + if self._lock_mode != TRACK_VIEW_LOCK_OFF: + self.set_view_lock_mode(TRACK_VIEW_LOCK_OFF) + self.reset_view() + return True + return super().eventFilter(obj, event) + + def _position_lock_btn(self) -> None: + if self._lock_btn is None: + return + m = UITheme.scaled(TRACK_VIEW_LOCK_BTN_MARGIN, 6) + # 相对 Canvas:对齐 PlotWidget 左下内侧 + pr = self._plot.geometry() + x = int(pr.x() + m) + y = int(pr.y() + max(m, pr.height() - self._lock_btn.height() - m)) + self._lock_btn.move(x, y) + self._lock_btn.raise_() + + def view_lock_mode(self) -> int: + return int(self._lock_mode) + + def set_view_lock_mode(self, mode: int) -> None: + """外部设定锁定态(同步按钮,不重复 emit)。""" + m = int(mode) + if self._lock_btn is not None: + self._lock_btn.set_mode(m, emit=False) + self._apply_lock_mode(m) + + def _on_lock_mode_changed(self, mode: int) -> None: + self._apply_lock_mode(int(mode)) + + def _apply_lock_mode(self, mode: int) -> None: + m = int(TRACK_VIEW_LOCK_ON if int(mode) == TRACK_VIEW_LOCK_ON else TRACK_VIEW_LOCK_OFF) + self._lock_mode = m + self._set_drag_enabled(m == TRACK_VIEW_LOCK_OFF) + if m == TRACK_VIEW_LOCK_OFF: + # 解除:停在当前地理视野 + return + self._apply_lock_camera() + + def _set_drag_enabled(self, enabled: bool) -> None: + vb = self._plot.plotItem.getViewBox() + if enabled: + vb.mouseDragEvent = self._vb_mouse_drag_orig + vb.setMouseEnabled(x=True, y=True) + return + + orig = self._vb_mouse_drag_orig + + def _drag(ev, axis=None): + try: + btn = ev.button() if hasattr(ev, "button") else None + except Exception: + btn = None + if btn == Qt.LeftButton: + ev.accept() + return + if orig is not None: + return orig(ev, axis) + + vb.mouseDragEvent = _drag + vb.setMouseEnabled(x=True, y=True) + + def _lock_track_bounds(self): + """锁定自动缩放用:本场 + 对比轨迹包络。""" + all_pts = list(self._points) + (list(self._compare) if self._compare else []) + if not all_pts: + return None + return compute_bounds(all_pts) + + def _refresh_lock_half_spans(self, cx: float, cy: float) -> None: + """半轴以游标为心,覆盖整段轨迹(含边距)。""" + pad = 1.0 + float(TRACK_VIEW_FIT_PAD) + bounds = self._lock_track_bounds() + if bounds is None: + self._lock_half_w = 1e-4 + self._lock_half_h = 1e-4 + return + hw = max( + abs(float(cx) - float(bounds.min_lon)), + abs(float(cx) - float(bounds.max_lon)), + 1e-5, + ) * pad + hh = max( + abs(float(cy) - float(bounds.min_lat)), + abs(float(cy) - float(bounds.max_lat)), + 1e-5, + ) * pad + self._lock_half_w = hw + self._lock_half_h = hh + + def _on_view_range_changed(self, *_args) -> None: + if self._applying_camera or self._lock_mode == TRACK_VIEW_LOCK_OFF: + return + # 锁定态缩放由轨迹包络决定,滚轮改写后立即拉回 + self._apply_lock_camera() + + def _lock_target_point(self) -> Optional[TrackPoint]: + """锁定跟随点 = 当前游标点(监测末点 / 时间轴 scrub)。""" + pts = self.scrub_points() + if not pts: + return None + idx = max(0, min(len(pts) - 1, int(self._cursor_index))) + return pts[idx] + + def _ensure_track_layer(self) -> pg.ItemGroup: + if self._track_layer is None: + self._track_layer = pg.ItemGroup() + self._plot.plotItem.addItem(self._track_layer) + return self._track_layer + + def _add_to_track_layer(self, item) -> None: + """航迹几何进基底层;勿把游标放进来。""" + layer = self._ensure_track_layer() + item.setParentItem(layer) + + def _set_range_centered(self, cx: float, cy: float) -> None: + hw = float(self._lock_half_w) if self._lock_half_w > 1e-12 else 1e-4 + hh = float(self._lock_half_h) if self._lock_half_h > 1e-12 else 1e-4 + vb = self._plot.plotItem.getViewBox() + self._applying_camera = True + try: + vb.setRange( + xRange=(cx - hw, cx + hw), + yRange=(cy - hh, cy + hh), + padding=0.0, + ) + self._view_fitted = True + finally: + self._applying_camera = False + + def _apply_lock_camera(self) -> None: + """锁定:游标钉中心;半轴自动覆盖整段轨迹,正北向上。""" + if self._lock_mode == TRACK_VIEW_LOCK_OFF: + return + pt = self._lock_target_point() + if pt is None: + return + cx = float(pt.longitude) + cy = float(pt.latitude) + bounds = self._lock_track_bounds() + if bounds is not None: + aspect = max(1e-6, float(bounds.ground_aspect())) + self._plot.plotItem.getViewBox().setAspectLocked(True, ratio=aspect) + self._refresh_lock_half_spans(cx, cy) + self._set_range_centered(cx, cy) + # 游标已在中心点;仅刷新十字/浮窗内容 + self._redraw_cursor_only() + + def set_lap_hud(self, lap_number: int) -> None: + self._lap_hud.set_lap(lap_number) + self._lap_hud.raise_() + if self._lock_btn is not None: + self._lock_btn.raise_() + + def set_empty_hud(self, empty: bool) -> None: + self._empty_hud.set_empty(empty) + # 圈号徽标始终压在空态之上 + self._lap_hud.raise_() + if self._lock_btn is not None: + self._lock_btn.raise_() + + def set_points(self, points: Sequence[TrackPoint]) -> None: + self._points = list(points or []) + self._refresh_laps_seen(self._points) + self._clamp_cursor() + self._rebuild() + + def live_point_count(self) -> int: + return len(self._points) + + def available_lap_numbers(self) -> List[int]: + return sorted(self._laps_seen) + + def append_point(self, point: TrackPoint) -> None: + self.append_points([point]) + + def append_points(self, points: Sequence[TrackPoint]) -> None: + """热路径批量追加:色域未扩且同圈连续时少桶增量;否则全量重建本场。""" + batch = [p for p in (points or ()) if p is not None] + if not batch: + return + old_len = len(self._points) + old_range = self._active_color_range or ( + self._color_metric_range() if old_len else None + ) + can_inc = self._can_append_incremental(old_len, old_range, batch) + self._points.extend(batch) + self._note_laps(batch) + self._cursor_index = len(self._points) - 1 + if not can_inc or old_range is None: + self._rebuild() + elif not self._incremental_add_edges(old_len - 1, old_range[0], old_range[1]): + self._rebuild() + else: + self._update_end_marker() + self._redraw_cursor_only() + # 锁定:游标钉中心,只平移/旋转底层 + self._apply_lock_camera() + + def _refresh_laps_seen(self, points: Sequence[TrackPoint]) -> None: + seen = set() + for p in points: + lap = int(getattr(p, "lap", 0) or 0) + if 1 <= lap <= LAP_COUNT: + seen.add(lap) + self._laps_seen = seen + + def _note_laps(self, points: Sequence[TrackPoint]) -> None: + for p in points: + lap = int(getattr(p, "lap", 0) or 0) + if 1 <= lap <= LAP_COUNT: + self._laps_seen.add(lap) + + def _continues_same_lap_run( + self, prev: TrackPoint, news: Sequence[TrackPoint] + ) -> bool: + prev_lap = int(prev.lap) + for pt in news: + lap = int(pt.lap) + if lap > 0 and prev_lap > 0 and lap != prev_lap: + return False + if lap > 0: + prev_lap = lap + return True + + def _can_append_incremental( + self, + old_len: int, + old_range: Optional[Tuple[float, float]], + batch: Sequence[TrackPoint], + ) -> bool: + if old_len < 2 or not self._path_items or old_range is None: + return False + if self._color_field_id in ("speed_rate", "accel_rate"): + return False + if not self._continues_same_lap_run(self._points[-1], batch): + return False + # 固定色带字段:量程不变,越界仅钳色,不必全量重建 + if self._color_field_id not in FIXED_SPEED_COLOR_FIELDS: + vals = metric_values_for_field(batch, self._color_field_id) + lo, hi = old_range + for v in vals: + if v != v: + continue + if float(v) < lo or float(v) > hi: + return False + lap_key = int(self._points[-1].lap) if int(self._points[-1].lap) > 0 else 0 + return bool(self._path_by_lap.get(lap_key)) + + def _extend_polyline_item( + self, item: pg.PlotDataItem, new_xs: Sequence[float], new_ys: Sequence[float] + ) -> bool: + try: + xd, yd = item.getData() + except Exception: + return False + if xd is None or yd is None: + return False + try: + item.setData(list(xd) + list(new_xs), list(yd) + list(new_ys)) + except Exception: + return False + return True + + def _metric_bucket(self, value: float, val_lo: float, val_hi: float) -> int: + n = max(1, int(TRACK_COLOR_BUCKETS)) + if val_hi <= val_lo: + return 0 + t = (float(value) - float(val_lo)) / (float(val_hi) - float(val_lo)) + if t != t: + t = 0.5 + t = max(0.0, min(1.0, t)) + b = int(t * n) + return n - 1 if b >= n else b + + def _bucket_pen_color( + self, + bucket: int, + val_lo: float, + val_hi: float, + *, + compare: bool, + overlay: bool, + opacity: float, + ) -> QColor: + n = max(1, int(TRACK_COLOR_BUCKETS)) + if n <= 1: + mid = 0.5 * (val_lo + val_hi) + else: + mid = float(val_lo) + (bucket + 0.5) / n * (float(val_hi) - float(val_lo)) + r, g, b = metric_to_rgb(mid, val_lo, val_hi, self._color_field_id) + c = QColor(r, g, b) + if compare or overlay: + c = desaturate_color(c, 0.45) + c.setAlphaF(max(0.20, min(1.0, opacity))) + else: + c.setAlphaF(float(CHART_LIVE_OPACITY)) + return c + + def _append_color_run( + self, + xs: Sequence[float], + ys: Sequence[float], + bucket: int, + val_lo: float, + val_hi: float, + *, + lap_key: int, + compare: bool, + overlay: bool, + ) -> pg.PlotDataItem: + opacity, _bg_w, line_w, pen_style, item_bucket, by_lap, z = self._layer_style( + compare=compare, overlay=overlay + ) + c = self._bucket_pen_color( + bucket, val_lo, val_hi, compare=compare, overlay=overlay, opacity=opacity + ) + seg = pg.PlotDataItem( + list(xs), + list(ys), + pen=pg.mkPen(c, width=line_w, style=pen_style), + antialias=True, + ) + if z is not None: + seg.setZValue(z) + show = ( + self._is_overlay_lap_visible(lap_key) + if overlay + else self._is_main_lap_visible(lap_key) + if not compare + else ( + bool(self._compare_visible) + and self._is_main_lap_visible(lap_key) + ) + ) + seg.setVisible(show) + self._add_to_track_layer(seg) + item_bucket.append(seg) + by_lap.setdefault(lap_key, []).append(seg) + return seg + + def _layer_style(self, *, compare: bool, overlay: bool): + if overlay: + return ( + float(TRACK_OVERLAY_OPACITY), + float(TRACK_OVERLAY_LINE_WIDTH_BG), + float(TRACK_OVERLAY_LINE_WIDTH), + field_pen_style(str(TRACK_OVERLAY_LINE_STYLE or "dash")), + self._overlay_items, + self._overlay_by_lap, + 21, + ) + if compare: + return ( + float(getattr(self._compare_style, "opacity", 0.7)), + float(_LINE_WIDTH_BG_COMPARE), + float(_LINE_WIDTH_COMPARE), + field_pen_style( + str(getattr(self._compare_style, "line_style", "dash") or "dash") + ), + self._compare_items, + self._compare_by_lap, + None, + ) + return ( + float(CHART_LIVE_OPACITY), + float(_LINE_WIDTH_BG_LIVE), + float(_LINE_WIDTH_LIVE), + field_pen_style("solid"), + self._path_items, + self._path_by_lap, + None, + ) + + def _incremental_add_edges( + self, from_idx: int, val_lo: float, val_hi: float + ) -> bool: + n = len(self._points) + if from_idx < 0 or from_idx >= n - 1: + return False + lap_key = ( + int(self._points[from_idx].lap) + if int(self._points[from_idx].lap) > 0 + else 0 + ) + path_items = self._path_by_lap.get(lap_key) or [] + if not path_items: + return False + new_xs = [float(p.longitude) for p in self._points[from_idx + 1 :]] + new_ys = [float(p.latitude) for p in self._points[from_idx + 1 :]] + if not self._extend_polyline_item(path_items[0], new_xs, new_ys): + return False + do_overlay = bool(self._overlay_items) and bool( + self._overlay_by_lap.get(lap_key) + ) + if do_overlay: + ov = self._overlay_by_lap[lap_key] + if not self._extend_polyline_item(ov[0], new_xs, new_ys): + return False + + slice_pts = self._points[from_idx:] + metrics = metric_values_for_field(slice_pts, self._color_field_id) + mid = 0.5 * (val_lo + val_hi) + for i in range(len(slice_pts) - 1): + raw1 = float(metrics[i]) if i < len(metrics) else float("nan") + raw2 = float(metrics[i + 1]) if (i + 1) < len(metrics) else float("nan") + s1 = mid if raw1 != raw1 else raw1 + s2 = mid if raw2 != raw2 else raw2 + b = self._metric_bucket(0.5 * (s1 + s2), val_lo, val_hi) + x1 = float(slice_pts[i].longitude) + y1 = float(slice_pts[i].latitude) + x2 = float(slice_pts[i + 1].longitude) + y2 = float(slice_pts[i + 1].latitude) + if not self._extend_or_new_color_edge( + lap_key, b, x1, y1, x2, y2, val_lo, val_hi, overlay=False + ): + return False + if do_overlay and not self._extend_or_new_color_edge( + lap_key, b, x1, y1, x2, y2, val_lo, val_hi, overlay=True + ): + return False + return True + + def _extend_or_new_color_edge( + self, + lap_key: int, + bucket: int, + x1: float, + y1: float, + x2: float, + y2: float, + val_lo: float, + val_hi: float, + *, + overlay: bool, + ) -> bool: + tail_map = self._overlay_color_tail if overlay else self._path_color_tail + tail = tail_map.get(lap_key) + if tail is not None and int(tail[0]) == int(bucket): + return self._extend_polyline_item(tail[1], [x2], [y2]) + seg = self._append_color_run( + [x1, x2], + [y1, y2], + bucket, + val_lo, + val_hi, + lap_key=lap_key, + compare=False, + overlay=overlay, + ) + tail_map[lap_key] = (int(bucket), seg) + return True + + def _update_end_marker(self) -> None: + if not self._points or self._marker_end is None: + return + pt = self._points[-1] + try: + self._marker_end.setData([float(pt.longitude)], [float(pt.latitude)]) + except Exception: + return + self._marker_end_lap = int(pt.lap) if int(pt.lap) > 0 else 0 + try: + self._marker_end.setVisible(self._is_main_lap_visible(self._marker_end_lap)) + except Exception: + pass + + def set_compare_points(self, points: Optional[Sequence[TrackPoint]]) -> None: + self._compare = list(points or []) + self._rebuild_compare_layer() + + def set_visible_laps(self, visible: Sequence[bool]) -> None: + flags = list(visible)[:LAP_COUNT] + while len(flags) < LAP_COUNT: + flags.append(False) + if flags == self._visible_laps: + return + self._visible_laps = flags + self._clamp_cursor() + self._sync_lap_layer_visibility() + + def set_overlay_laps(self, overlay: Sequence[bool]) -> None: + """叠加圈布尔旗(与 visible_laps 分离;虚线低透明层)。""" + flags = list(overlay)[:LAP_COUNT] + while len(flags) < LAP_COUNT: + flags.append(False) + if flags == self._overlay_laps: + return + self._overlay_laps = flags + self._ensure_overlay_geometry() + self._sync_lap_layer_visibility() + + def set_lap_filters( + self, + visible: Sequence[bool], + overlay: Sequence[bool], + *, + force: bool = False, + ) -> None: + """原子写入当前圈 / 叠加圈旗;叠加几何懒建,显隐不重建本场。""" + vflags = list(visible)[:LAP_COUNT] + while len(vflags) < LAP_COUNT: + vflags.append(False) + oflags = list(overlay)[:LAP_COUNT] + while len(oflags) < LAP_COUNT: + oflags.append(False) + if ( + not force + and vflags == self._visible_laps + and oflags == self._overlay_laps + ): + return + self._visible_laps = vflags + self._overlay_laps = oflags + self._clamp_cursor() + self._ensure_overlay_geometry() + self._sync_lap_layer_visibility() + + def overlay_laps(self) -> List[bool]: + return list(self._overlay_laps) + + def set_color_field(self, field_id: str) -> None: + fid = str(field_id or _DEFAULT_COLOR_FIELD) + if fid not in CHART_SERIES_CATALOG: + fid = _DEFAULT_COLOR_FIELD + if fid == self._color_field_id: + return + self._color_field_id = fid + self._rebuild() + + def color_field(self) -> str: + return self._color_field_id + + def set_compare_style(self, style: CompareStyle) -> None: + """对比样式:线宽/透明度/线型变才重建对比层;仅显隐则 setVisible。""" + old = self._compare_style + self._compare_style = style + self._compare_visible = bool(getattr(style, "visible", True)) + need_rebuild = ( + float(getattr(old, "opacity", 0.7)) != float(getattr(style, "opacity", 0.7)) + or float(getattr(old, "line_width", 1.6)) + != float(getattr(style, "line_width", 1.6)) + or str(getattr(old, "line_style", "dash") or "dash") + != str(getattr(style, "line_style", "dash") or "dash") + ) + if need_rebuild or (self._compare and not self._compare_items): + if self._compare: + self._rebuild_compare_layer() + else: + self._apply_compare_layer_visibility() + self._sync_hist_cursor_visibility() + return + self._apply_compare_layer_visibility() + self._sync_hist_cursor_visibility() + + def _apply_compare_layer_visibility(self) -> None: + self._sync_lap_layer_visibility() + + def _is_main_lap_visible(self, lap: int) -> bool: + """主路径显隐:lap<=0 在任一勾选圈时保留(与 filter_by_visible_laps 一致)。""" + n = int(lap) + if n <= 0: + return any(self._visible_laps) + if 1 <= n <= len(self._visible_laps): + return bool(self._visible_laps[n - 1]) + return False + + def _is_overlay_lap_visible(self, lap: int) -> bool: + n = int(lap) + if n <= 0: + return False + if 1 <= n <= len(self._overlay_laps): + return bool(self._overlay_laps[n - 1]) + return False + + def _sync_lap_layer_visibility(self) -> None: + """跨圈 / 叠加 / 历史显隐:只 setVisible,不拆建几何。""" + for lap, items in self._path_by_lap.items(): + show = self._is_main_lap_visible(lap) + for item in items: + try: + item.setVisible(show) + except Exception: + pass + for lap, items in self._overlay_by_lap.items(): + show = self._is_overlay_lap_visible(lap) + for item in items: + try: + item.setVisible(show) + except Exception: + pass + show_cmp = bool(self._compare_visible) and len(self._compare) > 1 + for lap, items in self._compare_by_lap.items(): + show = show_cmp and self._is_main_lap_visible(lap) + for item in items: + try: + item.setVisible(show) + except Exception: + pass + if self._marker_start is not None: + try: + self._marker_start.setVisible( + self._is_main_lap_visible(self._marker_start_lap) + ) + except Exception: + pass + if self._marker_end is not None: + try: + self._marker_end.setVisible( + self._is_main_lap_visible(self._marker_end_lap) + ) + except Exception: + pass + has_live = len(self._points) > 1 and any(self._visible_laps) + has_cmp = show_cmp and ( + any(self._is_main_lap_visible(lap) for lap in self._compare_by_lap) + if self._compare_by_lap + else True + ) + if has_live or has_cmp: + self.set_empty_hud(False) + else: + # 无可见航迹时保持「等待航迹数据」(含完全空图) + self.set_empty_hud(True) + + def _sync_hist_cursor_visibility(self) -> None: + """历史游标:有图元则 setVisible;需显示且尚未创建时再画一次。""" + want = ( + bool(self._compare_visible) + and len(self._compare) > 1 + and len(self._points) > 1 + ) + if want and self._hist_cursor_scatter is None: + self._draw_history_cursor() + return + for attr in ("_hist_cursor_scatter", "_hist_cursor_outer"): + item = getattr(self, attr, None) + if item is not None: + try: + item.setVisible(want) + except Exception: + pass + for item in self._hist_cursor_cross: + try: + item.setVisible(want) + except Exception: + pass + + def clear(self) -> None: + self._points = [] + self._laps_seen = set() + self._cursor_index = 0 + self._clear_plot_items() + self._purge_track_layer() + self.set_empty_hud(True) + self._view_fitted = False + self._active_color_range = None + self._update_legend(None) + + def reset_view(self) -> None: + """Fit 全图。""" + self._view_fitted = False + self._rebuild() + + def visible_points(self) -> List[TrackPoint]: + return filter_by_visible_laps(self._points, self._visible_laps) + + def all_points(self) -> List[TrackPoint]: + return list(self._points) + + def compare_points(self) -> List[TrackPoint]: + return list(self._compare) + + def compare_point_at_time(self, t_rel: float) -> Optional[TrackPoint]: + if not self._compare or not self._compare_visible: + return None + t = float(t_rel) + best = self._compare[0] + best_d = abs(float(best.timestamp_s) - t) + for pt in self._compare[1:]: + d = abs(float(pt.timestamp_s) - t) + if d < best_d: + best_d = d + best = pt + return best + + def history_cursor_point(self) -> Optional[TrackPoint]: + """本场+可见历史并存时:按本场相对时间对齐到历史航迹点。""" + if not self._compare_visible or len(self._compare) < 2 or len(self._points) < 2: + return None + idx = max(0, min(len(self._points) - 1, self._cursor_index)) + live_pt = self._points[idx] + t_rel = float(live_pt.timestamp_s) - float(self._points[0].timestamp_s) + return self.compare_point_at_time(t_rel) + + def scrub_points(self) -> List[TrackPoint]: + if len(self._points) > 1: + return self._points + if self._compare_visible and len(self._compare) > 1: + return self._compare + if self._points: + return self._points + if self._compare_visible: + return self._compare + return [] + + def cursor_index(self) -> int: + return int(self._cursor_index) + + def cursor_point(self) -> Optional[TrackPoint]: + pts = self.scrub_points() + if not pts: + return None + idx = max(0, min(len(pts) - 1, self._cursor_index)) + return pts[idx] + + def session_point_at_cursor(self) -> Optional[TrackPoint]: + """本场航迹游标点(仅 _points,绝不回退到对比轨迹)。""" + if not self._points: + return None + # 时间轴由对比轨迹驱动时(本场点过少),本场层无游标点 + if len(self._points) <= 1 and self._compare_visible and len(self._compare) > 1: + return None + idx = max(0, min(len(self._points) - 1, int(self._cursor_index))) + return self._points[idx] + + def set_cursor_index(self, index: int) -> None: + pts = self.scrub_points() + if not pts: + self._cursor_index = 0 + self._redraw_cursor_only() + return + idx = max(0, min(len(pts) - 1, int(index))) + changed = idx != self._cursor_index + if not changed and self._cursor_scatter is not None: + return + self._cursor_index = idx + # 游标移动只刷十字/浮窗,禁止整段航迹拆建 + self._redraw_cursor_only() + if self._lock_mode != TRACK_VIEW_LOCK_OFF: + # 时间轴/scrub:游标钉中心,底层跟着平移/旋转 + self._apply_lock_camera() + if changed: + self.cursorIndexChanged.emit(idx) + + def _clamp_cursor(self) -> None: + pts = self.scrub_points() + if not pts: + self._cursor_index = 0 + return + self._cursor_index = max(0, min(len(pts) - 1, self._cursor_index)) + + def _clear_cursor_items(self) -> None: + """卸下游标/浮窗(须用 detach,仅 removeItem 会在 ItemGroup/多轮监测后留幽灵)。""" + for attr in ( + "_cursor_scatter", + "_cursor_outer", + "_float_label", + "_hist_cursor_scatter", + "_hist_cursor_outer", + ): + item = getattr(self, attr, None) + self._detach_graphics_item(item) + setattr(self, attr, None) + for bucket in (self._cursor_cross, self._hist_cursor_cross): + for item in bucket: + self._detach_graphics_item(item) + bucket.clear() + + def _purge_track_layer(self) -> None: + """清空航迹基底层全部子项,防止多轮监测残留起点/线段幽灵。""" + layer = self._track_layer + if layer is None: + return + try: + children = list(layer.childItems()) + except Exception: + children = [] + for child in children: + self._detach_graphics_item(child) + + def _redraw_cursor_only(self) -> None: + self._clear_cursor_items() + scrub = self.scrub_points() + if scrub: + self._draw_cursor(scrub) + self._draw_history_cursor() + + def _visible_metric_sets( + self, + ) -> Tuple[List[TrackPoint], List[float], List[TrackPoint], List[float]]: + """全量点列(不按圈过滤);圈显隐由 _sync_lap_layer_visibility 控制。""" + fid = self._color_field_id + live = list(self._points) + live_m = metric_values_for_field(live, fid) if live else [] + if self._compare: + cmp_pts = list(self._compare) + cmp_m = metric_values_for_field(cmp_pts, fid) + else: + cmp_pts, cmp_m = [], [] + return live, live_m, cmp_pts, cmp_m + + def _color_metric_range(self) -> Optional[Tuple[float, float]]: + rng = metric_range_for_field(self._points, self._color_field_id) + if rng is not None: + return rng + # 隐藏历史时仍用对比量程,避免勾选后色标/视野跳变 + if self._compare: + return metric_range_for_field(self._compare, self._color_field_id) + return None + + def _detach_graphics_item(self, item) -> None: + """从 ItemGroup / PlotItem / Scene 卸下图元,避免幽灵残留。""" + if item is None: + return + try: + item.setVisible(False) + except Exception: + pass + try: + item.setParentItem(None) + except Exception: + pass + try: + pi = self._plot.plotItem if self._plot is not None else None + if pi is not None: + pi.removeItem(item) + except Exception: + pass + try: + sc = item.scene() + if sc is not None: + sc.removeItem(item) + except Exception: + pass + + def _clear_items_list( + self, + items: List[pg.PlotDataItem], + by_lap: Dict[int, List[pg.PlotDataItem]], + ) -> None: + for item in items: + self._detach_graphics_item(item) + items.clear() + by_lap.clear() + + def _clear_endpoint_markers(self) -> None: + """清除起终点标记(含 ItemGroup 幽灵)。""" + for attr in ("_marker_start", "_marker_end"): + item = getattr(self, attr, None) + self._detach_graphics_item(item) + setattr(self, attr, None) + self._marker_start_lap = 0 + self._marker_end_lap = 0 + + def _clear_live_path_items(self) -> None: + self._clear_items_list(self._path_items, self._path_by_lap) + self._path_color_tail.clear() + self._clear_endpoint_markers() + + def _clear_overlay_items(self) -> None: + self._clear_items_list(self._overlay_items, self._overlay_by_lap) + self._overlay_color_tail.clear() + + def _clear_compare_items(self) -> None: + self._clear_items_list(self._compare_items, self._compare_by_lap) + + def _clear_plot_items(self) -> None: + self._clear_live_path_items() + self._clear_compare_items() + self._clear_overlay_items() + self._active_color_range = None + self._clear_cursor_items() + + def _overlay_wanted(self) -> bool: + return any(self._overlay_laps) + + def _ensure_overlay_geometry(self) -> None: + if not self._overlay_wanted(): + if self._overlay_items: + self._clear_overlay_items() + return + if self._overlay_items: + return + live = list(self._points) + if len(live) < 2: + return + color_rng = self._active_color_range or self._color_metric_range() + if color_rng is None: + return + live_m = metric_values_for_field(live, self._color_field_id) + self._add_path_segments( + live, live_m, color_rng[0], color_rng[1], compare=False, overlay=True + ) + + def _add_path_segments( + self, + points: Sequence[TrackPoint], + metrics: Sequence[float], + val_lo: float, + val_hi: float, + *, + compare: bool, + overlay: bool = False, + ) -> None: + """少桶着色:相邻同色桶合并为一条折线,图元数 ~O(桶切换次数)。""" + if len(points) < 2: + return + mid = 0.5 * (val_lo + val_hi) + seq: List[Tuple[float, float, float, int]] = [] + for i, pt in enumerate(points): + raw = float(metrics[i]) if i < len(metrics) else float("nan") + val = mid if raw != raw else raw + x, y = float(pt.longitude), float(pt.latitude) + seq.append((x, y, val, int(pt.lap))) + + opacity, bg_w, line_w, pen_style, item_bucket, by_lap, z_seg = self._layer_style( + compare=compare, overlay=overlay + ) + bg_pen = pg.mkPen( + QColor(42, 52, 80, int(90 * opacity)), width=bg_w, style=pen_style + ) + tail_map = None + if overlay: + tail_map = self._overlay_color_tail + elif not compare: + tail_map = self._path_color_tail + + for run in _lap_runs(seq): + if len(run) < 2: + continue + lap_key = int(run[0][3]) if int(run[0][3]) > 0 else 0 + lap_bucket = by_lap.setdefault(lap_key, []) + xs = [p[0] for p in run] + ys = [p[1] for p in run] + bg = pg.PlotDataItem(xs, ys, pen=bg_pen, antialias=True) + if overlay: + bg.setZValue(20) + show = ( + self._is_overlay_lap_visible(lap_key) + if overlay + else self._is_main_lap_visible(lap_key) + if not compare + else ( + bool(self._compare_visible) + and self._is_main_lap_visible(lap_key) + ) + ) + bg.setVisible(show) + self._add_to_track_layer(bg) + item_bucket.append(bg) + lap_bucket.append(bg) + + i = 0 + last_bucket = -1 + last_seg = None + while i < len(run) - 1: + s_mid = 0.5 * (run[i][2] + run[i + 1][2]) + b = self._metric_bucket(s_mid, val_lo, val_hi) + run_xs = [run[i][0], run[i + 1][0]] + run_ys = [run[i][1], run[i + 1][1]] + j = i + 1 + while j < len(run) - 1: + s2 = 0.5 * (run[j][2] + run[j + 1][2]) + if self._metric_bucket(s2, val_lo, val_hi) != b: + break + run_xs.append(run[j + 1][0]) + run_ys.append(run[j + 1][1]) + j += 1 + c = self._bucket_pen_color( + b, val_lo, val_hi, compare=compare, overlay=overlay, opacity=opacity + ) + seg = pg.PlotDataItem( + run_xs, + run_ys, + pen=pg.mkPen(c, width=line_w, style=pen_style), + antialias=True, + ) + if overlay and z_seg is not None: + seg.setZValue(z_seg) + seg.setVisible(show) + self._add_to_track_layer(seg) + item_bucket.append(seg) + lap_bucket.append(seg) + last_bucket = b + last_seg = seg + i = j + if tail_map is not None and last_seg is not None: + tail_map[lap_key] = (int(last_bucket), last_seg) + + if compare or overlay or len(seq) < 2: + return + # 新建前先卸旧标记,防止多轮监测幽灵起点 + self._clear_endpoint_markers() + self._marker_start_lap = int(seq[0][3]) if int(seq[0][3]) > 0 else 0 + self._marker_end_lap = int(seq[-1][3]) if int(seq[-1][3]) > 0 else 0 + # useCache=False:高 DPI 下散点缓存位图易糊;关缓存换清晰(略增 CPU,航迹标记点数极少) + self._marker_start = pg.ScatterPlotItem( + [seq[0][0]], + [seq[0][1]], + size=11, + brush=pg.mkBrush("#4ade80"), + pen=pg.mkPen("#ffffff", width=1.4), + useCache=False, + ) + self._marker_end = pg.ScatterPlotItem( + [seq[-1][0]], + [seq[-1][1]], + size=11, + brush=pg.mkBrush("#ff6b6b"), + pen=pg.mkPen("#ffffff", width=1.4), + useCache=False, + ) + self._marker_start.setVisible(self._is_main_lap_visible(self._marker_start_lap)) + self._marker_end.setVisible(self._is_main_lap_visible(self._marker_end_lap)) + self._add_to_track_layer(self._marker_start) + self._add_to_track_layer(self._marker_end) + + def _fit_view(self, bounds) -> None: + vb = self._plot.plotItem.getViewBox() + aspect = max(1e-6, float(bounds.ground_aspect())) + vb.setAspectLocked(True, ratio=aspect) + lon0, lon1 = float(bounds.min_lon), float(bounds.max_lon) + lat0, lat1 = float(bounds.min_lat), float(bounds.max_lat) + if lon1 <= lon0: + lon1 = lon0 + 1e-5 + if lat1 <= lat0: + lat1 = lat0 + 1e-5 + vb.setRange(xRange=(lon0, lon1), yRange=(lat0, lat1), padding=float(TRACK_VIEW_FIT_PAD)) + self._view_fitted = True + + def _update_legend(self, color_rng: Optional[Tuple[float, float]]) -> None: + self._legend.set_metric( + self._color_field_id, + None if color_rng is None else color_rng[0], + None if color_rng is None else color_rng[1], + ) + + def _rebuild_live_layers(self) -> None: + """只重建本场实线(+ 按需叠加);保留对比层。""" + self._clear_live_path_items() + self._clear_overlay_items() + self._clear_cursor_items() + live = list(self._points) + live_m = ( + metric_values_for_field(live, self._color_field_id) if live else [] + ) + color_rng = self._color_metric_range() + self._active_color_range = color_rng + self._update_legend(color_rng) + all_pts = live + (list(self._compare) if self._compare else []) + bounds = compute_bounds(all_pts) if all_pts else None + if bounds is None or color_rng is None or len(live) < 2: + if not self._compare_items: + self.set_empty_hud(True) + self._view_fitted = False + self._sync_lap_layer_visibility() + self._redraw_cursor_only() + return + self.set_empty_hud(False) + if not self._view_fitted: + self._fit_view(bounds) + self._add_path_segments( + live, live_m, color_rng[0], color_rng[1], compare=False, overlay=False + ) + if self._overlay_wanted(): + self._add_path_segments( + live, live_m, color_rng[0], color_rng[1], compare=False, overlay=True + ) + self._sync_lap_layer_visibility() + self._redraw_cursor_only() + self._sync_hist_cursor_visibility() + + def _rebuild_compare_layer(self) -> None: + """只重建对比层;不拆本场几何。""" + self._clear_compare_items() + cmp_pts = list(self._compare) + color_rng = self._color_metric_range() + if color_rng is not None: + self._active_color_range = color_rng + self._update_legend(self._active_color_range or color_rng) + all_pts = list(self._points) + cmp_pts + bounds = compute_bounds(all_pts) if all_pts else None + if bounds is not None and not self._view_fitted: + self._fit_view(bounds) + if color_rng is not None and len(cmp_pts) >= 2: + cmp_m = metric_values_for_field(cmp_pts, self._color_field_id) + self._add_path_segments( + cmp_pts, cmp_m, color_rng[0], color_rng[1], compare=True + ) + self._sync_lap_layer_visibility() + self._redraw_cursor_only() + self._sync_hist_cursor_visibility() + + def _rebuild(self) -> None: + self._clear_plot_items() + # 列表外残留(多轮监测幽灵起点/线段)一并清掉;对比层随后重建 + self._purge_track_layer() + live, live_m, cmp_pts, cmp_m = self._visible_metric_sets() + color_rng = self._color_metric_range() + self._active_color_range = color_rng + self._update_legend(color_rng) + + all_pts = list(self._points) + (list(self._compare) if self._compare else []) + if len(all_pts) < 1: + all_pts = live + cmp_pts + bounds = compute_bounds(all_pts) if all_pts else None + + if bounds is None or color_rng is None or (not self._points and len(cmp_pts) < 2): + self.set_empty_hud(True) + self._view_fitted = False + return + + self.set_empty_hud(False) + val_lo, val_hi = color_rng + if not self._view_fitted: + self._fit_view(bounds) + + if cmp_pts and len(cmp_pts) >= 2: + self._add_path_segments(cmp_pts, cmp_m, val_lo, val_hi, compare=True) + if live and len(live) >= 2: + self._add_path_segments(live, live_m, val_lo, val_hi, compare=False) + if self._overlay_wanted(): + self._add_path_segments( + live, live_m, val_lo, val_hi, compare=False, overlay=True + ) + self._sync_lap_layer_visibility() + + # 与 _redraw_cursor_only 统一,避免漏清导致多轮监测游标/浮窗幽灵 + self._redraw_cursor_only() + self._sync_hist_cursor_visibility() + if self._lock_mode != TRACK_VIEW_LOCK_OFF: + self._apply_lock_camera() + + def _draw_cursor(self, points: Sequence[TrackPoint]) -> None: + idx = max(0, min(len(points) - 1, self._cursor_index)) + pt = points[idx] + lon, lat = float(pt.longitude), float(pt.latitude) + pi = self._plot.plotItem + self._cursor_scatter = pg.ScatterPlotItem( + [lon], + [lat], + size=16, + symbol="o", + brush=None, + pen=pg.mkPen(UITheme.DATA_CURRENT, width=1.6), + useCache=False, + ) + self._cursor_outer = pg.ScatterPlotItem( + [lon], + [lat], + size=16, + symbol="o", + brush=None, + pen=pg.mkPen("#ffffff", width=2.0), + useCache=False, + ) + self._cursor_outer.setZValue(40) + self._cursor_scatter.setZValue(41) + pi.addItem(self._cursor_outer) + pi.addItem(self._cursor_scatter) + + vb = pi.getViewBox() + (x0, x1), (y0, y1) = vb.viewRange() + dx = max(1e-9, (x1 - x0) * 0.02) + dy = max(1e-9, (y1 - y0) * 0.02) + pen = pg.mkPen(UITheme.DATA_CURRENT, width=1.6) + for xs, ys in ( + ([lon - dx * 1.5, lon - dx * 0.5], [lat, lat]), + ([lon + dx * 0.5, lon + dx * 1.5], [lat, lat]), + ([lon, lon], [lat - dy * 1.5, lat - dy * 0.5]), + ([lon, lon], [lat + dy * 0.5, lat + dy * 1.5]), + ): + seg = pg.PlotDataItem(xs, ys, pen=pen) + seg.setZValue(41) + pi.addItem(seg) + self._cursor_cross.append(seg) + + self._float_label = pg.TextItem( + html=chart_float_html(self._cursor_lines(pt)), + anchor=(0, 1), + ) + apply_chart_float_font(self._float_label) + self._float_label.setPos(lon, lat) + self._float_label.setZValue(42) + pi.addItem(self._float_label) + + def _draw_history_cursor(self) -> None: + """历史航迹游标:与本场游标时间对齐;样式对齐历史层(蓝 + 历史透明度)。""" + pt = self.history_cursor_point() + if pt is None: + return + lon, lat = float(pt.longitude), float(pt.latitude) + pi = self._plot.plotItem + opacity = float( + getattr(self._compare_style, "opacity", CHART_HISTORY_OPACITY) + or CHART_HISTORY_OPACITY + ) + opacity = max(0.20, min(1.0, opacity)) + color = QColor(UITheme.DATA_HISTORY) + color.setAlphaF(opacity) + outer = QColor(UITheme.FG) + outer.setAlphaF(opacity) + self._hist_cursor_outer = pg.ScatterPlotItem( + [lon], + [lat], + size=14, + symbol="o", + brush=None, + pen=pg.mkPen(outer, width=1.8), + useCache=False, + ) + self._hist_cursor_scatter = pg.ScatterPlotItem( + [lon], + [lat], + size=14, + symbol="o", + brush=None, + pen=pg.mkPen(color, width=1.5), + useCache=False, + ) + self._hist_cursor_outer.setZValue(35) + self._hist_cursor_scatter.setZValue(36) + pi.addItem(self._hist_cursor_outer) + pi.addItem(self._hist_cursor_scatter) + + vb = pi.getViewBox() + (x0, x1), (y0, y1) = vb.viewRange() + dx = max(1e-9, (x1 - x0) * 0.018) + dy = max(1e-9, (y1 - y0) * 0.018) + pen = pg.mkPen(color, width=1.4, style=Qt.DashLine) + for xs, ys in ( + ([lon - dx * 1.5, lon - dx * 0.5], [lat, lat]), + ([lon + dx * 0.5, lon + dx * 1.5], [lat, lat]), + ([lon, lon], [lat - dy * 1.5, lat - dy * 0.5]), + ([lon, lon], [lat + dy * 0.5, lat + dy * 1.5]), + ): + seg = pg.PlotDataItem(xs, ys, pen=pen) + seg.setZValue(36) + pi.addItem(seg) + self._hist_cursor_cross.append(seg) + + def _cursor_lines(self, pt: TrackPoint) -> list: + def _fmt(v: float, pattern: str) -> str: + if v != v: + return "--" + return format(v, pattern) + + def _field_value(src: TrackPoint, field_id: str) -> float: + if field_id in ("speed_rate", "accel_rate"): + # 率类需序列上下文;单点无法可靠估算,显示 -- + return float("nan") + vals = metric_values_for_field([src], field_id) + return float(vals[0]) if vals else float("nan") + + lines: list = [ + (f"时间 {float(pt.timestamp_s):.2f}s", "default"), + (f"空速 {_fmt(pt.airspeed_kts, '.1f')} kt", "default"), + (f"高度 {_fmt(pt.altitude_ft, '.0f')} ft", "default"), + ] + fid = self._color_field_id + if fid and fid != "airspeed": + opt = series_option(fid) + name = opt.label if opt is not None else fid + unit = (opt.axis_title if opt is not None else "") or "" + if fid in ("speed_rate", "accel_rate"): + scrub = self.scrub_points() + idx = max(0, min(len(scrub) - 1, self._cursor_index)) if scrub else 0 + full = metric_values_for_field(scrub, fid) if scrub else [] + v = float(full[idx]) if idx < len(full) else float("nan") + else: + v = _field_value(pt, fid) + parts = [ + (f"{name} ", "default"), + (_fmt(v, ".2f"), "current"), + ] + if unit: + parts.append((f" {unit}", "default")) + hist_pt = self.history_cursor_point() + if hist_pt is not None and self._compare_visible: + hv = _field_value(hist_pt, fid) + parts.extend([(" / ", "default"), (_fmt(hv, ".2f"), "history")]) + lines.append({"parts": parts}) + return lines + + +def _lap_runs( + pix: Sequence[Tuple[float, float, float, int]], +) -> List[List[Tuple[float, float, float, int]]]: + if not pix: + return [] + runs: List[List[Tuple[float, float, float, int]]] = [[pix[0]]] + for item in pix[1:]: + prev = runs[-1][-1] + if item[3] > 0 and prev[3] > 0 and item[3] != prev[3]: + runs.append([item]) + else: + runs[-1].append(item) + return runs + + +class TrackMapPane(QWidget): + """航迹图 Canvas;可见圈由进度条游标所在 LAP 决定;着色字段由顶栏下拉驱动。""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("TrackMapPane") + self.setAttribute(Qt.WA_StyledBackground, True) + self.setAutoFillBackground(True) + # 边框仅在与外层容器交界处;内部 Canvas/绘图区无框 + well_css = chart_plot_bg_css() + self.setStyleSheet( + f"QWidget#TrackMapPane {{" + f" background-color: {well_css};" + f" border: {UITheme.border_css()};" + f" border-radius: {UITheme.BORDER_RADIUS}px;" + f" }}" + ) + self._canvas = TrackMapCanvas(self) + self._focus_lap = 0 # 哨兵:使首次 set_focus_lap(1) 写入 canvas 过滤旗 + # 叠加圈(单选,与 focus 差集);独立虚线层绘制 + self._overlay_laps: List[int] = [] + + bw = int(UITheme.BORDER_WIDTH) + layout = QVBoxLayout(self) + layout.setContentsMargins(bw, bw, bw, bw) + layout.setSpacing(SPACE_NONE) + layout.addWidget(self._canvas, 1) + + self.set_focus_lap(1) + + def color_field(self) -> str: + return self._canvas.color_field() + + def set_color_field(self, field_id: str) -> None: + self._canvas.set_color_field(field_id) + + def focus_lap(self) -> int: + return int(self._focus_lap) + + def _compose_visible_flags(self) -> List[bool]: + """时间轴加亮用:当前圈 ∪ 叠加圈。""" + flags = [False] * LAP_COUNT + focus = max(1, min(LAP_COUNT, int(self._focus_lap or 1))) + flags[focus - 1] = True + for lap in self._overlay_laps: + n = int(lap) + if 1 <= n <= LAP_COUNT and n != focus: + flags[n - 1] = True + return flags + + def _apply_visible_flags(self, *, force: bool = False) -> None: + """主路径画当前∪叠加(保证可见);叠加圈另以虚线层置顶区分。""" + focus = max(1, min(LAP_COUNT, int(self._focus_lap or 1))) + # 与旧「显示LAPn」相同:勾选即进主路径,避免只加亮时间轴却无轨迹 + main = self._compose_visible_flags() + overlay = [False] * LAP_COUNT + for lap in self._overlay_laps: + n = int(lap) + if 1 <= n <= LAP_COUNT and n != focus: + overlay[n - 1] = True + self._canvas.set_lap_filters(main, overlay, force=force) + + def set_focus_lap(self, lap_number: int) -> None: + """进度条当前圈;叠加圈保留并差集排除当前圈。""" + lap = int(lap_number) if lap_number is not None else 1 + if lap < 1 or lap > LAP_COUNT: + lap = 1 + if lap == self._focus_lap: + return + self._focus_lap = lap + self._overlay_laps = [x for x in self._overlay_laps if int(x) != lap] + self._apply_visible_flags(force=True) + self._canvas.set_lap_hud(lap) + + def available_laps(self) -> List[int]: + """航迹点中实际出现的圈号(1..LAP_COUNT)。""" + return self._canvas.available_lap_numbers() + + def set_overlay_laps(self, laps: Sequence[int]) -> None: + """设置叠加圈(单选);空列表关闭叠加层。""" + focus = max(1, min(LAP_COUNT, int(self._focus_lap or 1))) + wanted: List[int] = [] + for x in laps or []: + n = int(x) + if 1 <= n <= LAP_COUNT and n != focus and n not in wanted: + wanted.append(n) + break # 单选 + self._overlay_laps = wanted + self._apply_visible_flags(force=True) + + def overlay_laps(self) -> List[int]: + return list(self._overlay_laps) + + def sync_focus_lap_from_cursor(self) -> None: + """按进度条游标点所在圈刷新焦点;叠加层由 set_overlay_laps 单独维护。""" + pt = self.cursor_point() + lap = int(getattr(pt, "lap", 0) or 0) if pt is not None else 0 + if lap < 1: + lap = self._focus_lap or 1 + if lap != self._focus_lap: + self.set_focus_lap(lap) + + def visible_laps(self) -> List[bool]: + return self._compose_visible_flags() + + def point_count(self) -> int: + return len(self._canvas.all_points()) + + def all_points(self) -> List[TrackPoint]: + return self._canvas.all_points() + + def compare_points(self) -> List[TrackPoint]: + return self._canvas.compare_points() + + def compare_point_count(self) -> int: + return len(self._canvas.compare_points()) + + def scrub_point_count(self) -> int: + return len(self._canvas.scrub_points()) + + def scrub_points(self) -> List[TrackPoint]: + return self._canvas.scrub_points() + + def cursor_index(self) -> int: + return self._canvas.cursor_index() + + def cursor_point(self) -> Optional[TrackPoint]: + return self._canvas.cursor_point() + + def session_point_at_cursor(self) -> Optional[TrackPoint]: + return self._canvas.session_point_at_cursor() + + def compare_point_at_time(self, t_rel: float) -> Optional[TrackPoint]: + return self._canvas.compare_point_at_time(t_rel) + + def history_cursor_point(self) -> Optional[TrackPoint]: + return self._canvas.history_cursor_point() + + def set_cursor_index(self, index: int) -> None: + self._canvas.set_cursor_index(index) + + def set_track_points(self, points: Sequence[TrackPoint]) -> None: + self._canvas.set_points(points) + self.sync_focus_lap_from_cursor() + + def live_track_count(self) -> int: + return self._canvas.live_point_count() + + def append_track_point(self, point: TrackPoint) -> None: + self._canvas.append_point(point) + self.sync_focus_lap_from_cursor() + + def append_track_points(self, points: Sequence[TrackPoint]) -> None: + self._canvas.append_points(points) + self.sync_focus_lap_from_cursor() + + def set_compare_track_points(self, points: Optional[Sequence[TrackPoint]]) -> None: + self._canvas.set_compare_points(points) + self.sync_focus_lap_from_cursor() + + def set_compare_style(self, style: CompareStyle) -> None: + self._canvas.set_compare_style(style) + + def reset_filters(self) -> None: + """复位为进度条驱动:默认聚焦 LAP1;清空叠加。""" + self._overlay_laps = [] + self.set_focus_lap(1) + + def clear(self) -> None: + self._canvas.clear() + + def reset_view(self) -> None: + self._canvas.reset_view() + + def view_lock_mode(self) -> int: + return self._canvas.view_lock_mode() + + def set_view_lock_mode(self, mode: int) -> None: + self._canvas.set_view_lock_mode(mode) diff --git a/components/Container/Container_Area_Chart.py b/components/Container/Container_Area_Chart.py new file mode 100644 index 00000000..9671fb60 --- /dev/null +++ b/components/Container/Container_Area_Chart.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +"""模块:Container_Area_Chart — 包裹 TrendChart 的版式容器 + 本地蒙版挂载点。""" + +from __future__ import annotations + +from PySide2.QtWidgets import QVBoxLayout, QWidget + +from charts import TrendChart +from components.Container.Container_Card_Surface import Container_Card_Surface +from components.overlay.overlay_Local_Base import overlay_Local_Base +from setting import CHART_CANVAS_GAP_PX + + +class Container_Area_Chart(Container_Card_Surface): + """训练页图表区:Card 表面 + TrendChart + overlay_Local_Base(默认隐藏)。""" + + def __init__(self, parent=None): + super().__init__(parent, flat=False, metric=False) + layout = QVBoxLayout(self) + gap = int(CHART_CANVAS_GAP_PX) + layout.setContentsMargins(gap, gap, gap, gap) + self.chart = TrendChart(self) + layout.addWidget(self.chart) + self._local_overlay = overlay_Local_Base(self) + self._local_overlay.hide() + + @property + def local_overlay(self) -> overlay_Local_Base: + return self._local_overlay + + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + self._local_overlay.setGeometry(self.rect()) + self._local_overlay.raise_() diff --git a/components/Container/Container_Bar_Action.py b/components/Container/Container_Bar_Action.py new file mode 100644 index 00000000..39a9ab37 --- /dev/null +++ b/components/Container/Container_Bar_Action.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +"""模块:Container_Bar_Action — 原 ActionBar。""" + +from __future__ import annotations + +from typing import Optional + +from PySide2.QtCore import Qt, Signal +from PySide2.QtWidgets import QHBoxLayout, QPushButton, QWidget + +from setting import SPACE_FRAME_GAP, SPACE_NONE + + +class Container_Bar_Action(QWidget): + """通用水平按钮行;clicked(button_id)。默认居中,可左/右对齐。""" + + clicked = Signal(str) + + def __init__( + self, + parent=None, + button_defs: Optional[list[dict]] = None, + alignment: Qt.AlignmentFlag = Qt.AlignCenter, + ): + super().__init__(parent) + self.setObjectName("Container_Bar_Action") + self.setAutoFillBackground(False) + self._buttons: dict[str, QPushButton] = {} + outer = QHBoxLayout(self) + outer.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + row = QWidget() + row.setObjectName("Container_Bar_ActionRow") + row.setAutoFillBackground(False) + self._row_layout = QHBoxLayout(row) + self._row_layout.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + self._row_layout.setSpacing(SPACE_FRAME_GAP) + align = int(alignment) + if align & int(Qt.AlignLeft): + outer.addWidget(row) + outer.addStretch(1) + elif align & int(Qt.AlignRight): + outer.addStretch(1) + outer.addWidget(row) + else: + outer.addStretch(1) + outer.addWidget(row) + outer.addStretch(1) + for spec in button_defs or []: + self.add_button( + spec["id"], + spec.get("text", spec["id"]), + enabled=spec.get("enabled", True), + visible=spec.get("visible", True), + ) + + def add_button(self, button_id: str, text: str, enabled: bool = True, visible: bool = True) -> QPushButton: + btn = QPushButton(text) + btn.setObjectName("FlatButton") + btn.setEnabled(enabled) + btn.setVisible(visible) + btn.clicked.connect(lambda _=False, i=button_id: self.clicked.emit(i)) + self._row_layout.addWidget(btn) + self._buttons[button_id] = btn + return btn + + def set_button(self, button_id: str, text: Optional[str] = None, enabled: Optional[bool] = None, visible: Optional[bool] = None) -> None: + btn = self._buttons.get(button_id) + if btn is None: + return + if text is not None: + btn.setText(text) + if enabled is not None: + btn.setEnabled(enabled) + if visible is not None: + btn.setVisible(visible) + + def set_button_visible(self, button_id: str, visible: bool) -> None: + self.set_button(button_id, visible=visible) + + def button(self, button_id: str) -> Optional[QPushButton]: + return self._buttons.get(button_id) diff --git a/components/Container/Container_Board_Lap.py b/components/Container/Container_Board_Lap.py new file mode 100644 index 00000000..f3acccf3 --- /dev/null +++ b/components/Container/Container_Board_Lap.py @@ -0,0 +1,246 @@ +# -*- coding: utf-8 -*- +"""模块:Container_Board_Lap — 圈时板 + 分数板(两列)。""" + +from __future__ import annotations + +from typing import Optional + +from PySide2.QtCore import Qt, Signal +from PySide2.QtGui import QResizeEvent, QShowEvent +from PySide2.QtWidgets import ( + QGridLayout, + QHBoxLayout, + QLabel, + QPushButton, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from components.Container.Container_Board_Score import Container_Board_Score +from components.Container.Container_Card_Surface import Container_Card_Surface +from components.Frame.Frame_Theme_UI import ( + UITheme, + format_lap_time_with_penalty, + format_total_time_display, +) +from models.mission_time_rule import compute_mission_score, load_mission_time_rule +from setting import ( + LAP_TABLE_ROWS, + MSG_MISSION_SWITCH_BUSY, + SPACE_CONTAINER_GAP, + SPACE_CONTAINER_PAD, + SPACE_FRAME_PAD_DIALOG, + SPACE_NONE, + SPACE_WIDGET_GAP_TIGHT, +) + + +class Container_Board_Lap(QWidget): + """左:切换任务方钮 | 模式列 + LAP 表 + NET TOTAL;右:分数板。""" + + missionSwitchClicked = Signal() + + def __init__(self, parent=None, rows: int = LAP_TABLE_ROWS): + super().__init__(parent) + self._rows = rows + self._session_mode = "freeflight" + self._mission_title = "" + self._total_s: Optional[float] = None + self._min_sec = 0.0 + self._max_sec = 0.0 + self._crashed = False + + outer = QHBoxLayout(self) + outer.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + outer.setSpacing(SPACE_CONTAINER_GAP) + + self._lap_card = Container_Card_Surface(self) + self._lap_card.setObjectName("LapBoardCard") + row = QHBoxLayout(self._lap_card) + row.setContentsMargins( + SPACE_CONTAINER_PAD, + SPACE_CONTAINER_PAD, + SPACE_CONTAINER_PAD, + SPACE_CONTAINER_PAD, + ) + row.setSpacing(SPACE_CONTAINER_GAP) + + self.mission_switch_btn = QPushButton("切换\n任务") + self.mission_switch_btn.setObjectName("MissionSwitchButton") + self.mission_switch_btn.setCursor(Qt.PointingHandCursor) + self.mission_switch_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + self.mission_switch_btn.clicked.connect(self.missionSwitchClicked.emit) + + # 右侧信息显示组件:模式 / 圈时表 / 总计 + self._info = QWidget(self._lap_card) + self._info.setObjectName("LapBoardInfo") + self._info.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + info_row = QHBoxLayout(self._info) + info_row.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + info_row.setSpacing(SPACE_CONTAINER_GAP) + + mode_col = QVBoxLayout() + mode_col.setAlignment(Qt.AlignCenter) + mode_col.setSpacing(SPACE_WIDGET_GAP_TIGHT) + self.mission_label = QLabel("") + self.mission_label.setObjectName("SessionMissionLabel") + self.mission_label.setFont(UITheme.font(UITheme.FONT_MD)) + self.mission_label.setAlignment(Qt.AlignCenter) + self.mission_label.setWordWrap(True) + self.mission_label.setVisible(False) + + self.mode_label = QLabel("") + self.mode_label.setObjectName("SessionModeLabel") + self.mode_label.setFont(UITheme.font_tech(UITheme.FONT_LG)) + self.mode_label.setAlignment(Qt.AlignCenter) + self.mode_label.setWordWrap(True) + mode_col.addStretch(1) + mode_col.addWidget(self.mission_label, 0, Qt.AlignCenter) + mode_col.addWidget(self.mode_label, 0, Qt.AlignCenter) + mode_col.addStretch(1) + + lap_col = QVBoxLayout() + lap_col.setAlignment(Qt.AlignCenter) + self._lap_labels: list[tuple[QLabel, QLabel]] = [] + grid = QGridLayout() + grid.setHorizontalSpacing(SPACE_FRAME_PAD_DIALOG) + grid.setVerticalSpacing(SPACE_WIDGET_GAP_TIGHT) + grid.setColumnStretch(0, 1) + grid.setColumnStretch(1, 1) + for i in range(rows): + name = QLabel(f"LAP {i + 1:02d}") + name.setObjectName("InfoValue") + name.setFont(UITheme.font_tech(UITheme.FONT_LG)) + tm = QLabel("--") + tm.setObjectName("InfoValue") + tm.setFont(UITheme.font_tech(UITheme.FONT_LG)) + grid.addWidget(name, i, 0, Qt.AlignCenter) + grid.addWidget(tm, i, 1, Qt.AlignCenter) + self._lap_labels.append((name, tm)) + lap_col.addLayout(grid) + + total_col = QVBoxLayout() + total_col.setAlignment(Qt.AlignCenter) + total_title = QLabel("NET(PENALTY)TOTAL") + total_title.setObjectName("MetricTitle") + total_title.setAlignment(Qt.AlignCenter) + self.total_label = QLabel("--") + self.total_label.setObjectName("LapTotalValue") + self.total_label.setAlignment(Qt.AlignCenter) + self.total_label.setFont(UITheme.font_tech(UITheme.FONT_LAP_TOTAL, bold=True)) + total_col.addWidget(total_title) + total_col.addWidget(self.total_label) + + info_row.addLayout(mode_col, 1) + info_row.addLayout(lap_col, 1) + info_row.addLayout(total_col, 1) + + row.addWidget(self.mission_switch_btn, 0, Qt.AlignLeft | Qt.AlignVCenter) + row.addWidget(self._info, 1) + + self.score_board = Container_Board_Score(self) + + outer.addWidget(self._lap_card, 1) + outer.addWidget(self.score_board, 0, Qt.AlignVCenter) + + self.set_session_mode("freeflight") + self.set_mission_title(None) + + def showEvent(self, event: QShowEvent) -> None: + super().showEvent(event) + self._sync_switch_btn_square() + self._sync_score_square() + + def resizeEvent(self, event: QResizeEvent) -> None: + super().resizeEvent(event) + self._sync_switch_btn_square() + self._sync_score_square() + + def _sync_switch_btn_square(self) -> None: + """方钮边长 = 信息显示组件高度。""" + h = int(self._info.height()) + if h <= 0: + return + if self.mission_switch_btn.width() != h or self.mission_switch_btn.height() != h: + self.mission_switch_btn.setFixedSize(h, h) + + def _sync_score_square(self) -> None: + """分数板边长 = 圈时板卡片高度。""" + h = int(self._lap_card.height()) + if h <= 0: + return + self.score_board.set_square_side(h) + + def set_mission_title(self, title: Optional[str] = None) -> None: + text = (title or "").strip() + self._mission_title = text + if text: + self.mission_label.setText(text) + self.mission_label.setVisible(True) + else: + self.mission_label.setText("") + self.mission_label.setVisible(False) + rule = load_mission_time_rule(text or None) + self._min_sec = float(rule.min_sec) + self._max_sec = float(rule.max_sec) + self._refresh_score() + + def set_mission_switch_enabled(self, enabled: bool) -> None: + """监测中禁用切换。""" + self.mission_switch_btn.setEnabled(bool(enabled)) + self.mission_switch_btn.setToolTip("" if enabled else MSG_MISSION_SWITCH_BUSY) + + def set_session_mode(self, mode: str = "freeflight") -> None: + self._session_mode = mode + if mode == "mission_sp": + self.mode_label.setText("Mission Mode") + self.mode_label.setStyleSheet(f"color: {UITheme.FLASH_BLUE}; background: transparent;") + elif mode == "mission_mp": + self.mode_label.setText("Multiplay Mode") + self.mode_label.setStyleSheet(f"color: {UITheme.WARNING}; background: transparent;") + else: + self.mode_label.setText("Free Flight Mode") + self.mode_label.setStyleSheet(f"color: {UITheme.SUCCESS}; background: transparent;") + + def set_laps( + self, + times: list, + total=None, + lap_penalties: Optional[list] = None, + total_penalty=None, + ) -> None: + for i, (_name, tm) in enumerate(self._lap_labels): + value = times[i] if i < len(times) else None + penalty = lap_penalties[i] if lap_penalties and i < len(lap_penalties) else None + tm.setText(format_lap_time_with_penalty(value, penalty)) + self.total_label.setText(format_total_time_display(total, total_penalty)) + try: + self._total_s = float(total) if total is not None else None + if self._total_s is not None and ( + self._total_s != self._total_s or self._total_s < 0 + ): + self._total_s = None + except (TypeError, ValueError): + self._total_s = None + self._refresh_score() + + def set_crashed(self, crashed: bool) -> None: + """坠毁锁存:分数固定为 0;新监测开始时应清除。""" + flag = bool(crashed) + if flag == self._crashed: + return + self._crashed = flag + self._refresh_score() + + def displayed_score(self) -> Optional[float]: + """分数面板当前显示值。""" + return self.score_board.displayed_score() + + def _refresh_score(self) -> None: + if self._crashed: + self.score_board.set_score(0.0) + return + self.score_board.set_score( + compute_mission_score(self._total_s, self._min_sec, self._max_sec) + ) diff --git a/components/Container/Container_Board_Score.py b/components/Container/Container_Board_Score.py new file mode 100644 index 00000000..267629b5 --- /dev/null +++ b/components/Container/Container_Board_Score.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +""" +模块:Container_Board_Score +职责:与圈时板同视觉的任务分数板(正方形卡片) +依赖:UITheme、setting.MISSION_SCORE_* +""" + +from __future__ import annotations + +from typing import Optional + +from PySide2.QtCore import Qt +from PySide2.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget + +from components.Container.Container_Card_Surface import Container_Card_Surface +from components.Frame.Frame_Theme_UI import UITheme +from setting import ( + MISSION_SCORE_DECIMALS, + MISSION_SCORE_TITLE, + SPACE_CONTAINER_PAD, + SPACE_NONE, + SPACE_WIDGET_GAP_TIGHT, +) + + +class Container_Board_Score(QWidget): + """SCORE 标题 + 分数(样式对齐圈时板总时)。""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + self._score_value: Optional[float] = None + card = Container_Card_Surface(self) + card.setObjectName("LapBoardCard") + root = QVBoxLayout(self) + root.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + root.addWidget(card) + col = QVBoxLayout(card) + col.setContentsMargins( + SPACE_CONTAINER_PAD, + SPACE_CONTAINER_PAD, + SPACE_CONTAINER_PAD, + SPACE_CONTAINER_PAD, + ) + col.setSpacing(SPACE_WIDGET_GAP_TIGHT) + col.setAlignment(Qt.AlignCenter) + title = QLabel(MISSION_SCORE_TITLE) + title.setObjectName("MetricTitle") + title.setAlignment(Qt.AlignCenter) + self._score_label = QLabel("--") + self._score_label.setObjectName("LapTotalValue") + self._score_label.setAlignment(Qt.AlignCenter) + self._score_label.setFont(UITheme.font_tech(UITheme.FONT_LAP_TOTAL, bold=True)) + col.addStretch(1) + col.addWidget(title, 0, Qt.AlignCenter) + col.addWidget(self._score_label, 0, Qt.AlignCenter) + col.addStretch(1) + + def set_score(self, score: Optional[float]) -> None: + if score is None: + self._score_value = None + self._score_label.setText("--") + return + try: + value = float(score) + except (TypeError, ValueError): + self._score_value = None + self._score_label.setText("--") + return + if value != value: + self._score_value = None + self._score_label.setText("--") + return + self._score_value = value + self._score_label.setText(f"{value:.{int(MISSION_SCORE_DECIMALS)}f}") + + def displayed_score(self) -> Optional[float]: + """当前分数面板显示值(无有效分数为 None)。""" + return self._score_value + + def set_square_side(self, side: int) -> None: + side = int(side) + if side <= 0: + return + if self.width() != side or self.height() != side: + self.setFixedSize(side, side) diff --git a/components/Container/Container_Card_Surface.py b/components/Container/Container_Card_Surface.py new file mode 100644 index 00000000..cf4e9c75 --- /dev/null +++ b/components/Container/Container_Card_Surface.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +"""模块:Container_Card_Surface — 原 CardFrame。""" + +from __future__ import annotations + +from PySide2.QtCore import Qt +from PySide2.QtWidgets import QFrame, QSizePolicy + + +class Container_Card_Surface(QFrame): + def __init__(self, parent=None, flat: bool = False, metric: bool = False): + super().__init__(parent) + if metric: + self.setObjectName("MetricCard") + elif flat: + self.setObjectName("SurfaceFlat") + else: + self.setObjectName("Card") + self.setAttribute(Qt.WA_StyledBackground, True) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) diff --git a/components/Container/Container_Panel_ShadowFollowOptions.py b/components/Container/Container_Panel_ShadowFollowOptions.py new file mode 100644 index 00000000..83329031 --- /dev/null +++ b/components/Container/Container_Panel_ShadowFollowOptions.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- +"""模块:Container_Panel_ShadowFollowOptions — 原 ShadowFollowOptionsPanel。""" + +from __future__ import annotations + +from typing import Optional + +from PySide2.QtCore import Qt +from PySide2.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget + +from components.Frame.Frame_Theme_UI import UITheme + +from setting import ( + MSG_TRACK_STORE_NEED_LOGIN, + SPACE_CONTAINER_GAP, + SPACE_CONTAINER_PAD, + SPACE_FRAME_GAP, + SPACE_NONE, + SPACE_WIDGET_GAP_TIGHT, +) +from components.widget.widget_Slider_Round import widget_Slider_Round +from components.widget.widget_Toggle_Slide import widget_Toggle_Slide + + +class Container_Panel_ShadowFollowOptions(QWidget): + """跟飞参数区(嵌入开始监测弹窗)。""" + + @classmethod + def body_height(cls) -> int: + traj_h = UITheme.scaled(28, 24) + gap = SPACE_CONTAINER_GAP + options_h = UITheme.scaled(58, 48) + content_h = traj_h + gap + options_h + body_margins = SPACE_CONTAINER_PAD + SPACE_CONTAINER_PAD + return content_h + body_margins + + def __init__( + self, + parent=None, + lead_s: float = 3.0, + smoke_enabled: bool = True, + trajectory_record_id: Optional[str] = None, + trajectory_label: Optional[str] = None, + user_id: Optional[str] = None, + ): + super().__init__(parent) + self.setAutoFillBackground(False) + from models.shadow_plane import SHADOW_LEAD_DEFAULT_S, SHADOW_LEAD_MAX_S, SHADOW_LEAD_MIN_S + + self._min_s = SHADOW_LEAD_MIN_S + self._max_s = SHADOW_LEAD_MAX_S + self._user_id = str(user_id or "").strip() or None + self._pick_busy = False + self._trajectory_record_id = str(trajectory_record_id or "").strip() or None + self._trajectory_label = str(trajectory_label or "").strip() or None + layout = QVBoxLayout(self) + layout.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + layout.setSpacing(SPACE_CONTAINER_GAP) + + traj_row = QHBoxLayout() + traj_row.setSpacing(SPACE_CONTAINER_GAP) + traj_lbl = QLabel("云端航迹") + traj_lbl.setObjectName("Muted") + traj_lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + traj_row.addWidget(traj_lbl) + self._path_edit = QLineEdit() + self._path_edit.setReadOnly(True) + self._path_edit.setPlaceholderText("请选择云端航迹") + if self._trajectory_record_id: + from models.track_store import format_track_id_short + + disp = self._trajectory_label or format_track_id_short( + self._trajectory_record_id + ) + self._apply_trajectory_display(disp) + traj_row.addWidget(self._path_edit, 1) + btn_browse = QPushButton("选择") + btn_browse.setObjectName("IconSm") + btn_browse.clicked.connect(self._browse_trajectory) + self._browse_btn = btn_browse + traj_row.addWidget(btn_browse) + layout.addLayout(traj_row) + + options_row = QHBoxLayout() + options_row.setSpacing(SPACE_FRAME_GAP) + + initial = float(lead_s if lead_s is not None else SHADOW_LEAD_DEFAULT_S) + initial = max(self._min_s, min(self._max_s, initial)) + + lead_cell = QWidget() + lead_cell.setAutoFillBackground(False) + lead_layout = QVBoxLayout(lead_cell) + lead_layout.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + lead_layout.setSpacing(SPACE_WIDGET_GAP_TIGHT) + lead_layout.setAlignment(Qt.AlignCenter) + self._value_label = QLabel(f"领先时间:{initial:.1f} 秒") + self._value_label.setObjectName("Muted") + self._value_label.setAlignment(Qt.AlignCenter) + lead_layout.addWidget(self._value_label, 0, Qt.AlignCenter) + self._slider = widget_Slider_Round( + from_=self._min_s, + to=self._max_s, + value=initial, + resolution=0.1, + length=UITheme.scaled(168, 128), + ) + self._slider.valueChanged.connect(self._on_slide) + lead_layout.addWidget(self._slider, 0, Qt.AlignCenter) + options_row.addWidget(lead_cell, 1) + + smoke_cell = QWidget() + smoke_cell.setAutoFillBackground(False) + smoke_layout = QVBoxLayout(smoke_cell) + smoke_layout.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + smoke_layout.setSpacing(SPACE_WIDGET_GAP_TIGHT) + smoke_layout.setAlignment(Qt.AlignCenter) + smoke_lbl = QLabel("拉烟") + smoke_lbl.setObjectName("Muted") + smoke_lbl.setAlignment(Qt.AlignCenter) + smoke_layout.addWidget(smoke_lbl, 0, Qt.AlignCenter) + self._smoke_toggle = widget_Toggle_Slide(checked=bool(smoke_enabled)) + smoke_layout.addWidget(self._smoke_toggle, 0, Qt.AlignCenter) + options_row.addWidget(smoke_cell, 1) + layout.addLayout(options_row) + + def set_user_id(self, user_id: Optional[str]) -> None: + self._user_id = str(user_id or "").strip() or None + + def _apply_trajectory_display(self, label: str) -> None: + self._path_edit.setText(label) + self._path_edit.setToolTip(label) + + def _browse_trajectory(self) -> None: + from components.Frame.Frame_Dialog_ThemeAlert import show_theme_alert + from components.Frame.Frame_Dialog_TrackStorePick import fetch_and_pick_track + + uid = self._user_id + if not uid: + show_theme_alert( + self.window(), + title="提示", + message=MSG_TRACK_STORE_NEED_LOGIN, + kind="warn", + show_title=True, + ) + return + if getattr(self, "_pick_busy", False): + return + self._pick_busy = True + btn = getattr(self, "_browse_btn", None) + if btn is not None: + btn.setEnabled(False) + + def _after_pick(record_id, picked) -> None: + self._pick_busy = False + if btn is not None: + btn.setEnabled(True) + if not record_id: + return + from models.track_store import format_track_id_short + + self._trajectory_record_id = record_id + short_id = format_track_id_short(record_id) + mission = picked.mission_title if picked else "" + self._trajectory_label = ( + f"{mission} ({short_id})" if mission else short_id + ) or short_id + self._apply_trajectory_display(self._trajectory_label) + + fetch_and_pick_track( + self.window(), + uid, + title="选择跟飞航迹", + message="请选择一条云端航迹用于跟飞", + on_finished=_after_pick, + ) + + def _on_slide(self, value: float) -> None: + self._value_label.setText(f"领先时间:{value:.1f} 秒") + + def lead_s(self) -> float: + return max(self._min_s, min(self._max_s, float(self._slider.value()))) + + def smoke_enabled(self) -> bool: + return bool(self._smoke_toggle.isChecked()) + + def trajectory_record_id(self) -> Optional[str]: + return self._trajectory_record_id + + def trajectory_label(self) -> Optional[str]: + return self._trajectory_label diff --git a/components/Container/Container_Panel_ShadowFollowSettings.py b/components/Container/Container_Panel_ShadowFollowSettings.py new file mode 100644 index 00000000..1c12e68f --- /dev/null +++ b/components/Container/Container_Panel_ShadowFollowSettings.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +"""模块:Container_Panel_ShadowFollowSettings — 原 ShadowFollowSettingsPanel。""" + +from __future__ import annotations + +from typing import Optional + +from PySide2.QtWidgets import QVBoxLayout, QWidget + +from components.Container.Container_Panel_ShadowFollowOptions import Container_Panel_ShadowFollowOptions +from setting import SPACE_NONE +from components.Container.Container_Section_Collapsible import Container_Section_Collapsible + + +class Container_Panel_ShadowFollowSettings(QWidget): + """跟飞设定折叠区。""" + + _INACTIVE_TOOLTIP = "跟飞模式未开启" + + def __init__( + self, + parent=None, + lead_s: float = 3.0, + smoke_enabled: bool = True, + trajectory_record_id: Optional[str] = None, + trajectory_label: Optional[str] = None, + user_id: Optional[str] = None, + expanded: bool = False, + ): + super().__init__(parent) + self.setAutoFillBackground(False) + self._body_reserve_h = Container_Panel_ShadowFollowOptions.body_height() + layout = QVBoxLayout(self) + layout.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + layout.setSpacing(SPACE_NONE) + + self._shadow_panel = Container_Panel_ShadowFollowOptions( + self, + lead_s=lead_s, + smoke_enabled=smoke_enabled, + trajectory_record_id=trajectory_record_id, + trajectory_label=trajectory_label, + user_id=user_id, + ) + self._section = Container_Section_Collapsible( + "Shadow Plane 设定", + self._shadow_panel, + expanded=expanded, + body_height=self._body_reserve_h, + ) + self._section.setInactiveTooltip(self._INACTIVE_TOOLTIP) + self._section.expandedChanged.connect(self._sync_body_reserve) + self._body_reserve = QWidget() + self._body_reserve.setAutoFillBackground(False) + layout.addWidget(self._section) + layout.addWidget(self._body_reserve) + self.set_expandable(expanded, auto_expand=expanded) + self._sync_body_reserve(self._section.isExpanded()) + + def _sync_body_reserve(self, expanded: bool) -> None: + self._body_reserve.setFixedHeight(0 if expanded else self._body_reserve_h) + + def set_expandable(self, expandable: bool, auto_expand: bool = False) -> None: + self._section.setExpandable(expandable) + if expandable and auto_expand: + self._section.setExpanded(True) + elif not expandable: + self._section.setExpanded(False) + + def setEnabled(self, enabled: bool) -> None: + super().setEnabled(enabled) + if not enabled: + self.set_expandable(False) + self._shadow_panel.setEnabled(enabled) + + @property + def section(self) -> Container_Section_Collapsible: + return self._section + + @property + def shadow_panel(self) -> Container_Panel_ShadowFollowOptions: + return self._shadow_panel diff --git a/components/Container/Container_Section_Collapsible.py b/components/Container/Container_Section_Collapsible.py new file mode 100644 index 00000000..58f72a54 --- /dev/null +++ b/components/Container/Container_Section_Collapsible.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +"""模块:Container_Section_Collapsible — 原 CollapsibleSection。""" + +from __future__ import annotations + +from typing import Optional + +from PySide2.QtCore import Qt, Signal +from PySide2.QtWidgets import QFrame, QHBoxLayout, QPushButton, QVBoxLayout, QWidget + +from components.Frame.Frame_Theme_UI import UITheme + +from setting import SPACE_CONTAINER_PAD, SPACE_NONE, SPACE_WIDGET_GAP + + +class Container_Section_Collapsible(QFrame): + """可折叠区块:标题栏 + 可收起的内容区(边框随展开状态变化)。""" + + expandedChanged = Signal(bool) + _TITLE_H = 28 + _BODY_H = 136 + + def __init__( + self, + title: str, + content: QWidget, + parent=None, + expanded: bool = False, + body_height: Optional[int] = None, + ): + super().__init__(parent) + self.setObjectName("CollapsibleBox") + self._title = title + self._expandable = False + self._inactive_tooltip = "" + self._content = content + self._body_height = ( + body_height if body_height is not None else UITheme.scaled(self._BODY_H, 112) + ) + + layout = QVBoxLayout(self) + layout.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + layout.setSpacing(SPACE_NONE) + + self._title_bar = QFrame() + self._title_bar.setObjectName("CollapsibleTitle") + self._title_bar.setFixedHeight(UITheme.scaled(self._TITLE_H, 24)) + title_layout = QHBoxLayout(self._title_bar) + title_layout.setContentsMargins( + SPACE_WIDGET_GAP, + SPACE_NONE, + SPACE_CONTAINER_PAD, + SPACE_NONE, + ) + title_layout.setSpacing(SPACE_NONE) + + self._header = QPushButton(self._header_text(expanded)) + self._header.setObjectName("CollapsibleHeader") + self._header.setCheckable(True) + self._header.setFlat(True) + self._header.setAttribute(Qt.WA_AlwaysShowToolTips, True) + self._header.clicked.connect(self._on_header_clicked) + title_layout.addWidget(self._header, 1) + layout.addWidget(self._title_bar) + + self._body = QFrame() + self._body.setObjectName("CollapsibleBody") + self._body.setFixedHeight(self._body_height) + body_layout = QVBoxLayout(self._body) + body_layout.setContentsMargins( + SPACE_WIDGET_GAP, + SPACE_CONTAINER_PAD, + SPACE_WIDGET_GAP, + SPACE_CONTAINER_PAD, + ) + body_layout.setSpacing(SPACE_NONE) + body_layout.addWidget(self._content) + layout.addWidget(self._body) + + self.setExpandable(False) + self.setExpanded(expanded) + + @classmethod + def default_body_height(cls) -> int: + return UITheme.scaled(cls._BODY_H, 112) + + def _header_text(self, expanded: bool) -> str: + arrow = "▾" if expanded else "▸" + return f"{arrow} {self._title}" + + def _on_header_clicked(self) -> None: + if not self._expandable: + self._header.setChecked(False) + return + self.setExpanded(self._header.isChecked()) + + def setInactiveTooltip(self, text: str) -> None: + self._inactive_tooltip = (text or "").strip() + self._apply_inactive_tooltip() + + def _apply_inactive_tooltip(self) -> None: + tip = self._inactive_tooltip if not self._expandable else "" + self.setToolTip(tip) + self._title_bar.setToolTip(tip) + self._header.setToolTip(tip) + + def setExpandable(self, expandable: bool) -> None: + self._expandable = bool(expandable) + self._header.setEnabled(self._expandable) + self._apply_inactive_tooltip() + if not self._expandable: + self.setExpanded(False) + + def setExpanded(self, expanded: bool) -> None: + if expanded and not self._expandable: + expanded = False + expanded = bool(expanded) + self._body.setVisible(expanded) + self._content.setVisible(expanded) + self._header.setChecked(expanded) + self._header.setText(self._header_text(expanded)) + self.setProperty("collapsed", "true" if not expanded else "false") + self.style().unpolish(self) + self.style().polish(self) + self.expandedChanged.emit(expanded) + + def isExpanded(self) -> bool: + return self._body.isVisible() + + def setEnabled(self, enabled: bool) -> None: + super().setEnabled(enabled) + if not enabled: + self.setExpandable(False) + self._content.setEnabled(enabled) diff --git a/components/Container/Container_Strip_Metric.py b/components/Container/Container_Strip_Metric.py new file mode 100644 index 00000000..a1f67996 --- /dev/null +++ b/components/Container/Container_Strip_Metric.py @@ -0,0 +1,315 @@ +# -*- coding: utf-8 -*- +"""模块:Container_Strip_Metric — 原 MetricStrip。""" + +from __future__ import annotations + +import sys +import threading +import time +from typing import Optional, Tuple + +from PySide2.QtCore import Qt, QTimer, Signal +from PySide2.QtWidgets import QComboBox, QHBoxLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget + +from components.Container.Container_Card_Surface import Container_Card_Surface +from components.Frame.Frame_Theme_UI import ( + UITheme, + cht_color, + format_metric_value, + health_color, + torque_color, +) +from models.chart import ( + DEFAULT_METRIC_STRIP_SLOTS, + METRIC_STRIP_CATALOG, + METRIC_STRIP_SLOT_COUNT, + health_band, + health_span_kind, + metric_strip_option, +) +from setting import HEALTH_BEEP_PATTERNS, HEALTH_FLASH_MS, SPACE_CONTAINER_GAP, SPACE_FRAME_GAP, SPACE_NONE + + +class Container_Strip_Metric(QWidget): + """一行三列数值仪表:每列下拉选择显示指标,版式不变。""" + + _health_beep_worker_finished = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setAutoFillBackground(False) + self._values: dict = {fid: None for fid in METRIC_STRIP_CATALOG} + self._health_pct = None + self._health_flash_on = False + self._health_flash_timer = QTimer(self) + self._health_flash_timer.timeout.connect(self._tick_health_flash) + self._health_beep_kind: Optional[str] = None + self._health_beep_armed = False # 解除暂停并进入录制后才允许蜂鸣 + self._health_beep_enabled = True # 「开启声音提示」用户开关 + self._health_beep_generation = 0 + self._health_beep_thread_alive = False + self._health_beep_worker_finished.connect(self._on_health_beep_worker_finished) + self._updating_combo = False + root = QHBoxLayout(self) + root.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + root.setSpacing(SPACE_CONTAINER_GAP) + defaults = list(DEFAULT_METRIC_STRIP_SLOTS) + while len(defaults) < METRIC_STRIP_SLOT_COUNT: + defaults.append("cht") + self._combos: list = [] + self._value_labels: list = [] + self._unit_labels: list = [] + for i in range(METRIC_STRIP_SLOT_COUNT): + fid = defaults[i] if defaults[i] in METRIC_STRIP_CATALOG else "cht" + value_l, unit_l, combo = self._add_column(root, fid) + self._combos.append(combo) + self._value_labels.append(value_l) + self._unit_labels.append(unit_l) + self._repaint_all_slots() + + def _add_column(self, row: QHBoxLayout, field_id: str): + card = Container_Card_Surface(metric=True) + cell_l = QVBoxLayout(card) + cell_l.setContentsMargins( + SPACE_FRAME_GAP, + SPACE_FRAME_GAP, + SPACE_FRAME_GAP, + SPACE_FRAME_GAP, + ) + cell_l.setSpacing(SPACE_NONE) + combo = QComboBox() + combo.setObjectName("MetricCardTitle") + combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + for opt in METRIC_STRIP_CATALOG.values(): + combo.addItem(opt.label, opt.id) + idx = combo.findData(field_id) + combo.setCurrentIndex(idx if idx >= 0 else 0) + combo.currentIndexChanged.connect(self._on_slot_changed) + cell_l.addWidget(combo) + bottom = QHBoxLayout() + bottom.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + value = QLabel(format_metric_value(None)) + value.setObjectName("MetricValue") + value.setFont(UITheme.font_tech(UITheme.FONT_CHT)) + value.setAlignment(Qt.AlignLeft | Qt.AlignBottom) + unit_l = QLabel("") + unit_l.setObjectName("MetricUnit") + unit_l.setAlignment(Qt.AlignLeft | Qt.AlignBottom) + bottom.addWidget(value, 1) + bottom.addWidget(unit_l, 0) + cell_l.addLayout(bottom) + row.addWidget(card, 1) + opt = metric_strip_option(str(combo.currentData() or field_id)) + unit_l.setText(opt.unit if opt is not None else "") + return value, unit_l, combo + + def _on_slot_changed(self, *_args) -> None: + if self._updating_combo: + return + self._repaint_all_slots() + + def slot_field_ids(self) -> Tuple[str, ...]: + out = [] + for combo in self._combos: + out.append(str(combo.currentData() or "")) + return tuple(out) + + def _paint_pair(self, value_lbl: QLabel, unit_lbl: QLabel, text: str, color: str) -> None: + value_lbl.setText(text) + value_lbl.setStyleSheet(f"color: {color}; background: transparent;") + unit_lbl.setStyleSheet(f"color: {color}; background: transparent;") + + def _color_for(self, field_id: str, value) -> str: + if field_id == "cht": + return cht_color(value) + if field_id == "torque": + return torque_color(value) + if field_id == "health": + return health_color(value) + return UITheme.FG + + def _repaint_slot(self, index: int) -> None: + if index < 0 or index >= len(self._combos): + return + fid = str(self._combos[index].currentData() or "") + opt = metric_strip_option(fid) + unit = opt.unit if opt is not None else "" + self._unit_labels[index].setText(unit) + raw = self._values.get(fid) + if fid == "health": + display = None if raw is None else round(float(raw), 1) + if ( + health_band(display) == "flash" + and self._health_flash_timer.isActive() + ): + color = UITheme.ERROR if self._health_flash_on else UITheme.FLASH_BLUE + else: + color = self._color_for(fid, display) + self._paint_pair( + self._value_labels[index], + self._unit_labels[index], + format_metric_value(display), + color, + ) + return + self._paint_pair( + self._value_labels[index], + self._unit_labels[index], + format_metric_value(raw), + self._color_for(fid, raw), + ) + + def _repaint_all_slots(self) -> None: + for i in range(len(self._combos)): + self._repaint_slot(i) + + def set_live_metrics(self, values: Optional[dict] = None) -> None: + """批量写入仪表缓存并刷新三列;keys 见 METRIC_STRIP_CATALOG。""" + payload = dict(values or {}) + for fid, val in payload.items(): + if fid in self._values: + self._values[fid] = val + if "health" in payload: + self._apply_health_state(payload.get("health")) + else: + self._repaint_all_slots() + + def _apply_health_state(self, pct) -> None: + self._health_pct = pct + self._values["health"] = pct + display = None if pct is None else round(float(pct), 1) + band = health_band(display) + if band == "flash": + self.start_health_flash() + self._repaint_all_slots() + else: + self._health_flash_timer.stop() + self._health_flash_on = False + self._repaint_all_slots() + self._sync_health_beep(health_span_kind(display)) + + def set_cht(self, value) -> None: + self._values["cht"] = value + self._repaint_all_slots() + + def set_torque(self, value) -> None: + self._values["torque"] = value + self._repaint_all_slots() + + def set_health(self, pct) -> None: + self._apply_health_state(pct) + + def start_health_flash(self) -> None: + if self._health_pct is None: + return + if not self._health_flash_timer.isActive(): + self._health_flash_on = False + self._tick_health_flash() + self._health_flash_timer.start(HEALTH_FLASH_MS) + + def stop_health_flash(self) -> None: + self._health_flash_timer.stop() + self._health_flash_on = False + self.set_health_beep_armed(False) + + def set_health_beep_armed(self, armed: bool) -> None: + """解除暂停并进入录制后武装蜂鸣;暂停/结束监测时解除。""" + armed = bool(armed) + if self._health_beep_armed == armed: + if not armed: + self._stop_health_beep() + return + self._health_beep_armed = armed + self._refresh_health_beep() + + def set_health_beep_enabled(self, enabled: bool) -> None: + """「开启声音提示」开关。""" + enabled = bool(enabled) + if self._health_beep_enabled == enabled: + if not enabled: + self._stop_health_beep() + return + self._health_beep_enabled = enabled + self._refresh_health_beep() + + def health_beep_enabled(self) -> bool: + return self._health_beep_enabled + + def _tick_health_flash(self) -> None: + if self._health_pct is None: + self._health_flash_timer.stop() + self._health_flash_on = False + return + self._health_flash_on = not self._health_flash_on + self._repaint_all_slots() + + def _refresh_health_beep(self) -> None: + if self._health_pct is None: + self._sync_health_beep(None) + return + display = round(float(self._health_pct), 1) + self._sync_health_beep(health_span_kind(display)) + + def _sync_health_beep(self, kind: Optional[str]) -> None: + """按蒙版色区启停持续蜂鸣:蓝长鸣 / 黄短鸣 / 红急促短鸣。""" + if ( + not self._health_beep_armed + or not self._health_beep_enabled + or kind not in HEALTH_BEEP_PATTERNS + ): + self._stop_health_beep() + return + self._health_beep_kind = kind + if not self._health_beep_thread_alive: + self._start_health_beep_worker() + + def _stop_health_beep(self) -> None: + self._health_beep_generation += 1 + self._health_beep_kind = None + + def _on_health_beep_worker_finished(self) -> None: + self._health_beep_thread_alive = False + if ( + self._health_beep_armed + and self._health_beep_enabled + and self._health_beep_kind in HEALTH_BEEP_PATTERNS + ): + self._start_health_beep_worker() + + def _start_health_beep_worker(self) -> None: + """单线程循环播鸣:鸣→静音→鸣,避免定时器丢拍导致断续。""" + if self._health_beep_thread_alive: + return + self._health_beep_generation += 1 + gen = self._health_beep_generation + + def _loop() -> None: + self._health_beep_thread_alive = True + try: + while gen == self._health_beep_generation: + if not self._health_beep_armed or not self._health_beep_enabled: + break + kind = self._health_beep_kind + if kind not in HEALTH_BEEP_PATTERNS: + break + freq, on_ms, off_ms = HEALTH_BEEP_PATTERNS[kind] + try: + if sys.platform == "win32": + import winsound + + winsound.Beep(int(freq), int(on_ms)) + else: + time.sleep(max(0.01, float(on_ms) / 1000.0)) + except Exception: + break + if gen != self._health_beep_generation: + return + deadline = time.time() + max(0, int(off_ms)) / 1000.0 + while time.time() < deadline: + if gen != self._health_beep_generation: + return + time.sleep(0.01) + finally: + self._health_beep_worker_finished.emit() + + threading.Thread(target=_loop, daemon=True).start() diff --git a/components/Container/__init__.py b/components/Container/__init__.py new file mode 100644 index 00000000..40a96afc --- /dev/null +++ b/components/Container/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/components/Frame/Frame_Dialog_HardwareSettings.py b/components/Frame/Frame_Dialog_HardwareSettings.py new file mode 100644 index 00000000..b3d4e849 --- /dev/null +++ b/components/Frame/Frame_Dialog_HardwareSettings.py @@ -0,0 +1,368 @@ +# -*- coding: utf-8 -*- +""" +模块:components.Frame.Frame_Dialog_HardwareSettings +职责:USB 控制器选择、轴映射与校准设定页 +依赖:models.hardware_monitor、UITheme +""" + +from __future__ import annotations + +from typing import Dict, List, Optional + +from PySide2.QtCore import Qt, QTimer +from PySide2.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QFormLayout, + QHBoxLayout, + QLabel, + QPushButton, + QVBoxLayout, + QWidget, +) + +from components.Frame.Frame_Theme_UI import UITheme, WindowGeom, apply_fixed_window +from components.Frame.Frame_Dialog_ThemeAlert import show_theme_alert +from setting import ( + SPACE_CONTAINER_PAD, + SPACE_FRAME_PAD_DIALOG, + SPACE_NONE, + SPACE_WIDGET_GAP, + UI_DIALOG_HARDWARE_SETTINGS, +) +from models.hardware_monitor import ( + AXIS_ROLE_LABELS, + AXIS_ROLES, + HardwareMonitor, + HardwareMonitorConfig, + JoystickInfo, + capture_neutral, + capture_travel_extents, + list_joysticks, + load_config, + physical_axis_label, + save_config, + seed_ranges_from_device, +) +from services.simulator import DATA_POLL_MS + + +class Frame_Dialog_HardwareSettings(QDialog): + """设定页:选择控制器、映射四轴、确认中立与最大行程。""" + + _DIALOG_W, _DIALOG_H = UI_DIALOG_HARDWARE_SETTINGS + + def __init__(self, parent=None, config: Optional[HardwareMonitorConfig] = None): + super().__init__(parent) + self.setWindowTitle("手柄设定") + self.setModal(True) + w, h = WindowGeom.scale_dialog_size(self._DIALOG_W, self._DIALOG_H) + UITheme.apply_scale(w, h, self._DIALOG_W, self._DIALOG_H) + apply_fixed_window(self, w, h) + + self._config = config or load_config() + self._devices: List[JoystickInfo] = [] + self._travel_samples: List[List[float]] = [] + self._capturing_travel = False + self._monitor = HardwareMonitor(self._config) + + self._live_timer = QTimer(self) + self._live_timer.setInterval(DATA_POLL_MS) + self._live_timer.timeout.connect(self._on_live_tick) + + root = QVBoxLayout(self) + root.setContentsMargins( + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + ) + root.setSpacing(SPACE_WIDGET_GAP) + + title = QLabel("USB 游戏控制器") + title.setObjectName("DialogTitle") + title.setAlignment(Qt.AlignCenter) + root.addWidget(title) + + dev_row = QHBoxLayout() + dev_row.setSpacing(SPACE_CONTAINER_PAD) + dev_lbl = QLabel("设备") + dev_lbl.setObjectName("Muted") + dev_row.addWidget(dev_lbl) + self._device_combo = QComboBox() + self._device_combo.currentIndexChanged.connect(self._on_device_changed) + dev_row.addWidget(self._device_combo, 1) + btn_refresh = QPushButton("刷新") + btn_refresh.setObjectName("IconSm") + btn_refresh.clicked.connect(self._refresh_devices) + dev_row.addWidget(btn_refresh) + root.addLayout(dev_row) + + self._info_label = QLabel("") + self._info_label.setObjectName("Muted") + self._info_label.setWordWrap(True) + root.addWidget(self._info_label) + + map_box = QWidget() + map_box.setAutoFillBackground(False) + form = QFormLayout(map_box) + form.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + form.setSpacing(SPACE_CONTAINER_PAD) + form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) + + self._axis_combos: Dict[str, QComboBox] = {} + self._invert_checks: Dict[str, QCheckBox] = {} + self._raw_labels: Dict[str, QLabel] = {} + + for role in AXIS_ROLES: + row = QWidget() + row.setAutoFillBackground(False) + hl = QHBoxLayout(row) + hl.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + hl.setSpacing(SPACE_CONTAINER_PAD) + combo = QComboBox() + cal = self._config.axis(role) + self._fill_axis_combo(combo, max(4, cal.index + 1), cal.index) + combo.currentIndexChanged.connect(self._sync_mapping_from_ui) + self._axis_combos[role] = combo + hl.addWidget(combo, 1) + inv = QCheckBox("反转") + inv.setChecked(bool(cal.invert)) + inv.toggled.connect(self._sync_mapping_from_ui) + self._invert_checks[role] = inv + hl.addWidget(inv) + raw_lbl = QLabel("—") + raw_lbl.setObjectName("Muted") + raw_lbl.setMinimumWidth(UITheme.scaled(72, 56)) + self._raw_labels[role] = raw_lbl + hl.addWidget(raw_lbl) + form.addRow(AXIS_ROLE_LABELS.get(role, role), row) + + root.addWidget(map_box) + + self._status = QLabel("请选择设备后完成校准") + self._status.setObjectName("Muted") + self._status.setWordWrap(True) + root.addWidget(self._status) + + cal_row = QHBoxLayout() + cal_row.setSpacing(SPACE_WIDGET_GAP) + self._btn_neutral = QPushButton("确认中立位") + self._btn_neutral.setObjectName("FlatButton") + self._btn_neutral.clicked.connect(self._on_capture_neutral) + cal_row.addWidget(self._btn_neutral) + self._btn_travel = QPushButton("开始采集行程") + self._btn_travel.setObjectName("FlatButton") + self._btn_travel.clicked.connect(self._on_toggle_travel) + cal_row.addWidget(self._btn_travel) + root.addLayout(cal_row) + + hint = QLabel( + "校准:杆置于中立后点「确认中立位」;再点「开始采集行程」," + "把俯仰/滚转/偏航/油门推到最大与最小,然后点「确认最大行程」。" + ) + hint.setObjectName("Muted") + hint.setWordWrap(True) + root.addWidget(hint) + root.addStretch(1) + + btn_row = QHBoxLayout() + btn_row.addStretch(1) + btn_cancel = QPushButton("取消") + btn_cancel.setObjectName("FlatButton") + btn_cancel.clicked.connect(self.reject) + btn_row.addWidget(btn_cancel) + btn_save = QPushButton("保存") + btn_save.setObjectName("FlatButton") + btn_save.clicked.connect(self._on_save) + btn_row.addWidget(btn_save) + btn_row.addStretch(1) + root.addLayout(btn_row) + + self._refresh_devices() + self._live_timer.start() + + def closeEvent(self, event) -> None: + self._live_timer.stop() + super().closeEvent(event) + + @staticmethod + def _fill_axis_combo(combo: QComboBox, axis_count: int, selected: int) -> None: + combo.blockSignals(True) + combo.clear() + n = max(1, int(axis_count)) + for i in range(n): + combo.addItem(f"{physical_axis_label(i)} ({i})", i) + combo.setCurrentIndex(max(0, min(n - 1, int(selected)))) + combo.blockSignals(False) + + def _refresh_devices(self) -> None: + self._devices = list_joysticks(rescan=True) + self._device_combo.blockSignals(True) + self._device_combo.clear() + if not self._devices: + self._device_combo.addItem("(未检测到控制器)", -1) + self._info_label.setText("后端:pygame / SDL") + else: + sel = 0 + for i, d in enumerate(self._devices): + label = f"{d.name} [#{d.index} 轴:{d.num_axes} 键:{d.num_buttons}]" + self._device_combo.addItem(label, d.index) + if self._config.device.matches(d.identity()): + sel = i + self._device_combo.setCurrentIndex(sel) + self._device_combo.blockSignals(False) + self._on_device_changed() + + def _current_device(self) -> Optional[JoystickInfo]: + idx = self._device_combo.currentData() + if idx is None or int(idx) < 0: + return None + for d in self._devices: + if d.index == int(idx): + return d + return None + + def _on_device_changed(self, _i: int = 0) -> None: + dev = self._current_device() + if dev is None: + self._info_label.setText("请连接 USB 游戏控制器后点击刷新(pygame)") + self._monitor.apply_config(self._config) + return + self._config.device = dev.identity() + if not self._config.calibrated: + seed_ranges_from_device(self._config, dev) + self._monitor.apply_config(self._config) + vid = f"{dev.vendor_id:04X}" if dev.vendor_id else "----" + pid = f"{dev.product_id:04X}" if dev.product_id else "----" + self._info_label.setText( + f"pygame | VID {vid} PID {pid} | " + f"校准:{'已完成' if self._config.calibrated else '未完成'}" + ) + self._rebuild_axis_combos(dev.num_axes) + self._apply_mapping_to_combos() + + def _rebuild_axis_combos(self, axis_count: int) -> None: + for role in AXIS_ROLES: + cal = self._config.axis(role) + cal.clamp_index(axis_count) + self._fill_axis_combo(self._axis_combos[role], axis_count, cal.index) + + def _apply_mapping_to_combos(self) -> None: + for role in AXIS_ROLES: + cal = self._config.axis(role) + combo = self._axis_combos[role] + combo.blockSignals(True) + # currentData 可能因轴数变化失效,按 index 对齐 + found = combo.findData(cal.index) + combo.setCurrentIndex(found if found >= 0 else 0) + combo.blockSignals(False) + self._invert_checks[role].blockSignals(True) + self._invert_checks[role].setChecked(bool(cal.invert)) + self._invert_checks[role].blockSignals(False) + + def _sync_mapping_from_ui(self, *_args) -> None: + dev = self._current_device() + axis_count = dev.num_axes if dev is not None else None + for role in AXIS_ROLES: + cal = self._config.axis(role) + data = self._axis_combos[role].currentData() + cal.index = int(data) if data is not None else 0 + cal.invert = bool(self._invert_checks[role].isChecked()) + cal.clamp_index(axis_count) + self._monitor.apply_config(self._config) + + def _on_live_tick(self) -> None: + raw = self._monitor.poll_raw() + if raw is None: + for role in AXIS_ROLES: + self._raw_labels[role].setText("—") + if self._capturing_travel: + self._status.setText("采集中…(设备断开)") + return + for role in AXIS_ROLES: + idx = self._config.axis(role).index + val = raw[idx] if 0 <= idx < len(raw) else float("nan") + self._raw_labels[role].setText(f"{val:+.3f}") + if self._capturing_travel: + self._travel_samples.append(list(raw)) + self._status.setText( + f"采集中…已采样 {len(self._travel_samples)} 帧,推满各轴后点「确认最大行程」" + ) + + def _on_capture_neutral(self) -> None: + raw = self._monitor.poll_raw() + if raw is None: + show_theme_alert(self, message="未读取到控制器数据", show_title=False) + return + self._sync_mapping_from_ui() + capture_neutral(self._config, raw) + self._monitor.apply_config(self._config) + self._status.setText("中立位已确认") + + def _on_toggle_travel(self) -> None: + if not self._capturing_travel: + raw = self._monitor.poll_raw() + if raw is None: + show_theme_alert(self, message="未读取到控制器数据", show_title=False) + return + self._sync_mapping_from_ui() + self._travel_samples = [list(raw)] + self._capturing_travel = True + self._btn_travel.setText("确认最大行程") + self._status.setText("采集中…请将各轴推到最大与最小位置") + return + if len(self._travel_samples) < 5: + show_theme_alert(self, message="采样不足,请继续推动各轴", show_title=False) + return + capture_travel_extents(self._config, self._travel_samples) + self._capturing_travel = False + self._travel_samples = [] + self._btn_travel.setText("开始采集行程") + self._monitor.apply_config(self._config) + self._status.setText("最大行程已确认,可保存") + dev = self._current_device() + if dev is not None: + vid = f"{dev.vendor_id:04X}" if dev.vendor_id else "----" + pid = f"{dev.product_id:04X}" if dev.product_id else "----" + self._info_label.setText( + f"pygame | VID {vid} PID {pid} | 校准:已完成" + ) + + def _on_save(self) -> None: + dev = self._current_device() + if dev is None: + show_theme_alert(self, message="请先选择控制器", show_title=False) + return + self._sync_mapping_from_ui() + self._config.device = dev.identity() + if not self._config.calibrated: + show_theme_alert( + self, + message="尚未完成行程校准,仍可保存映射;监控前建议完成校准。", + show_title=False, + ) + try: + save_config(self._config, require_cloud=True) + except Exception as exc: + from models.user_settings_store import UserSettingsError, UserSettingsNeedLogin + from setting import MSG_USER_SETTINGS_NEED_LOGIN, MSG_USER_SETTINGS_SAVE_FAIL + + if isinstance(exc, UserSettingsNeedLogin): + msg = MSG_USER_SETTINGS_NEED_LOGIN + elif isinstance(exc, UserSettingsError): + msg = f"{MSG_USER_SETTINGS_SAVE_FAIL}:{exc}" + else: + msg = f"{MSG_USER_SETTINGS_SAVE_FAIL}:{exc}" + show_theme_alert(self, message=msg, show_title=False) + return + self.accept() + + +def show_hardware_monitor_settings(parent=None) -> Optional[HardwareMonitorConfig]: + """打开手柄设定页;保存成功返回配置,取消返回 None。""" + dlg = Frame_Dialog_HardwareSettings(parent) + if dlg.exec_() == QDialog.Accepted: + return load_config() + return None diff --git a/components/Frame/Frame_Dialog_MissionSwitch.py b/components/Frame/Frame_Dialog_MissionSwitch.py new file mode 100644 index 00000000..36bf0d5c --- /dev/null +++ b/components/Frame/Frame_Dialog_MissionSwitch.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +"""模块:Frame_Dialog_MissionSwitch — 从登记任务表选择并切换 FLT。""" + +from __future__ import annotations + +from typing import List, Optional + +from PySide2.QtCore import Qt +from PySide2.QtWidgets import ( + QDialog, + QHBoxLayout, + QLabel, + QListWidget, + QListWidgetItem, + QPushButton, + QSizePolicy, + QVBoxLayout, +) + +from components.Frame.Frame_Theme_UI import UITheme, WindowGeom, apply_fixed_window +from models.mission_switch import MissionSwitchEntry, list_mission_switch_entries +from setting import ( + SPACE_FRAME_GAP, + SPACE_FRAME_PAD_DIALOG, + SPACE_NONE, + SPACE_WIDGET_GAP, + UI_DIALOG_MISSION_SWITCH, +) + + +class Frame_Dialog_MissionSwitch(QDialog): + """列出 Missions/*/config_*.csv 登记的任务;Accepted 时 selected 为选中条目。""" + + def __init__(self, parent=None, entries: Optional[List[MissionSwitchEntry]] = None): + super().__init__(parent) + self.selected: Optional[MissionSwitchEntry] = None + self.setWindowTitle("切换任务") + self.setModal(True) + w, h = WindowGeom.scale_dialog_size(*UI_DIALOG_MISSION_SWITCH) + UITheme.apply_scale(w, h, *UI_DIALOG_MISSION_SWITCH) + apply_fixed_window(self, w, h) + + layout = QVBoxLayout(self) + layout.setContentsMargins( + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + ) + layout.setSpacing(SPACE_FRAME_GAP) + + title = QLabel("选择要加载的任务") + title.setObjectName("DialogTitle") + title.setAlignment(Qt.AlignCenter) + layout.addWidget(title) + + self._list = QListWidget(self) + self._list.setObjectName("MissionSwitchList") + items = entries if entries is not None else list_mission_switch_entries() + for entry in items: + text = entry.label + if not entry.exists: + text = f"{text} (文件缺失)" + item = QListWidgetItem(text) + item.setData(Qt.UserRole, entry) + if not entry.exists: + item.setFlags(item.flags() & ~Qt.ItemIsEnabled) + self._list.addItem(item) + self._list.itemDoubleClicked.connect(self._accept_item) + layout.addWidget(self._list, 1) + + # 一行两列,与上方列表同宽(无左右 stretch) + row = QHBoxLayout() + row.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + row.setSpacing(SPACE_WIDGET_GAP) + ok = QPushButton("加载") + ok.setObjectName("MissionLoadButton") + ok.setCursor(Qt.PointingHandCursor) + ok.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + ok.clicked.connect(self._accept_current) + cancel = QPushButton("取消") + cancel.setObjectName("FlatButton") + cancel.setCursor(Qt.PointingHandCursor) + cancel.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + cancel.clicked.connect(self.reject) + row.addWidget(ok, 1) + row.addWidget(cancel, 1) + layout.addLayout(row) + + if self._list.count() > 0: + self._list.setCurrentRow(0) + + def _accept_item(self, item: QListWidgetItem) -> None: + if item is None or not (item.flags() & Qt.ItemIsEnabled): + return + entry = item.data(Qt.UserRole) + if not isinstance(entry, MissionSwitchEntry) or not entry.exists: + return + self.selected = entry + self.accept() + + def _accept_current(self) -> None: + self._accept_item(self._list.currentItem()) + + +def show_mission_switch(parent=None) -> Optional[MissionSwitchEntry]: + dlg = Frame_Dialog_MissionSwitch(parent) + if dlg.exec_() == QDialog.Accepted: + return dlg.selected + return None diff --git a/components/Frame/Frame_Dialog_RaceSuccess.py b/components/Frame/Frame_Dialog_RaceSuccess.py new file mode 100644 index 00000000..bda86c94 --- /dev/null +++ b/components/Frame/Frame_Dialog_RaceSuccess.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +"""模块:Frame_Dialog_RaceSuccess — 四圈完成成绩确认。""" + +from __future__ import annotations + +from typing import Callable, Optional, Sequence, Union + +from PySide2.QtCore import Qt +from PySide2.QtWidgets import ( + QDialog, + QGridLayout, + QHBoxLayout, + QLabel, + QPushButton, + QVBoxLayout, +) + +from components.Frame.Frame_Theme_UI import ( + UITheme, + WindowGeom, + apply_fixed_window, + apply_window_stays_on_top, + format_lap_time_with_penalty, + format_total_time_display, +) +from setting import LAP_TABLE_ROWS, SPACE_FRAME_GAP, SPACE_FRAME_PAD_DIALOG, SPACE_WIDGET_GAP_TIGHT, UI_ACTION_BTN_H, UI_DIALOG_RACE_SUCCESS, UI_MONITOR_BTN_W + +# on_save(dialog) -> bool;兼容无参 / 无返回值旧写法 +SaveCallback = Callable[..., Union[bool, None]] + + +class Frame_Dialog_RaceSuccess(QDialog): + """四圈完成:绿色 Success + 总时/单圈时;关闭;可另存当前轨迹。""" + + def __init__( + self, + parent=None, + *, + lap_times: Optional[Sequence] = None, + lap_penalties: Optional[Sequence] = None, + total_net_s=None, + total_penalty_s=None, + on_save: Optional[SaveCallback] = None, + ): + super().__init__(parent) + self._on_save = on_save + self.setWindowTitle("成绩确认") + self.setModal(True) + # 默认全局置顶以压住模拟器等第三方窗;保存链路由 controller 调用 + # yield_topmost / restore_topmost 动态让位,避免挡住保存/等待框。 + apply_window_stays_on_top(self, True) + w, h = WindowGeom.scale_dialog_size(*UI_DIALOG_RACE_SUCCESS) + apply_fixed_window(self, w, h) + + layout = QVBoxLayout(self) + layout.setContentsMargins( + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + ) + layout.setSpacing(SPACE_FRAME_GAP) + + success = QLabel("Success") + success.setAlignment(Qt.AlignCenter) + success.setFont(UITheme.font_tech(UITheme.FONT_RACE_SUCCESS, bold=True)) + success.setStyleSheet(f"color: {UITheme.SUCCESS}; background: transparent;") + layout.addWidget(success) + + total_title = QLabel("NET(PENALTY)TOTAL") + total_title.setObjectName("MetricTitle") + total_title.setAlignment(Qt.AlignCenter) + layout.addWidget(total_title) + + total_val = QLabel(format_total_time_display(total_net_s, total_penalty_s)) + total_val.setObjectName("LapTotalValue") + total_val.setAlignment(Qt.AlignCenter) + total_val.setFont(UITheme.font_tech(UITheme.FONT_LAP_TOTAL, bold=True)) + layout.addWidget(total_val) + + grid = QGridLayout() + grid.setHorizontalSpacing(SPACE_FRAME_PAD_DIALOG) + grid.setVerticalSpacing(SPACE_WIDGET_GAP_TIGHT) + grid.setColumnStretch(0, 1) + grid.setColumnStretch(1, 1) + times = list(lap_times or []) + pens = list(lap_penalties or []) + for i in range(LAP_TABLE_ROWS): + name = QLabel(f"LAP {i + 1:02d}") + name.setObjectName("InfoValue") + name.setFont(UITheme.font_tech(UITheme.FONT_LG)) + name.setAlignment(Qt.AlignCenter) + value = times[i] if i < len(times) else None + penalty = pens[i] if i < len(pens) else None + tm = QLabel(format_lap_time_with_penalty(value, penalty)) + tm.setObjectName("InfoValue") + tm.setFont(UITheme.font_tech(UITheme.FONT_LG)) + tm.setAlignment(Qt.AlignCenter) + grid.addWidget(name, i, 0, Qt.AlignCenter) + grid.addWidget(tm, i, 1, Qt.AlignCenter) + layout.addLayout(grid) + layout.addStretch(1) + + btn_row = QHBoxLayout() + btn_row.setSpacing(SPACE_FRAME_GAP) + btn_row.addStretch(1) + + self._save_btn = QPushButton("保存当前数据") + self._save_btn.setObjectName("MonitorButton") + self._save_btn.setProperty("monitorKind", "save") + mon_w = UITheme.scaled(UI_MONITOR_BTN_W, 124) + btn_h = UITheme.scaled(UI_ACTION_BTN_H, 30) + self._save_btn.setFixedSize(mon_w, btn_h) + self._save_btn.clicked.connect(self._handle_save) + btn_row.addWidget(self._save_btn) + + ok_btn = QPushButton("关闭") + ok_btn.setObjectName("FlatButton") + ok_btn.clicked.connect(self.accept) + btn_row.addWidget(ok_btn) + btn_row.addStretch(1) + layout.addLayout(btn_row) + + def showEvent(self, event) -> None: + super().showEvent(event) + self.raise_() + self.activateWindow() + + def yield_topmost(self) -> None: + """保存等后级 UI 弹出前让出 TOPMOST,避免盖住子流程对话框。""" + apply_window_stays_on_top(self, False) + + def restore_topmost(self) -> None: + """后级 UI 结束后恢复置顶(仍可见时)。""" + if self.isVisible(): + apply_window_stays_on_top(self, True) + + def _set_save_enabled(self, enabled: bool) -> None: + self._save_btn.setEnabled(bool(enabled)) + self._save_btn.style().unpolish(self._save_btn) + self._save_btn.style().polish(self._save_btn) + + def _handle_save(self) -> None: + if self._on_save is None: + return + ok = self._on_save(self) + if ok: + self._set_save_enabled(False) + + +def show_race_success_result( + parent=None, + *, + lap_times: Optional[Sequence] = None, + lap_penalties: Optional[Sequence] = None, + total_net_s=None, + total_penalty_s=None, + on_save: Optional[SaveCallback] = None, +) -> int: + dlg = Frame_Dialog_RaceSuccess( + parent, + lap_times=lap_times, + lap_penalties=lap_penalties, + total_net_s=total_net_s, + total_penalty_s=total_penalty_s, + on_save=on_save, + ) + dlg.raise_() + dlg.activateWindow() + return dlg.exec_() diff --git a/components/Frame/Frame_Dialog_SimNotFound.py b/components/Frame/Frame_Dialog_SimNotFound.py new file mode 100644 index 00000000..ef9dbf64 --- /dev/null +++ b/components/Frame/Frame_Dialog_SimNotFound.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +"""模块:Frame_Dialog_SimNotFound。""" + +from __future__ import annotations + +from PySide2.QtCore import Qt +from PySide2.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout + +from components.Frame.Frame_Theme_UI import WindowGeom, apply_fixed_window +from setting import SPACE_FRAME_PAD_DIALOG, UI_DIALOG_SIM_NOT_FOUND + + +class Frame_Dialog_SimNotFound(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("提示") + self.setModal(True) + w, h = WindowGeom.scale_dialog_size(*UI_DIALOG_SIM_NOT_FOUND) + apply_fixed_window(self, w, h) + layout = QVBoxLayout(self) + layout.setContentsMargins( + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + ) + label = QLabel("未检测到飞行模拟器\n\n请确保 FSX 已启动后再试") + label.setAlignment(Qt.AlignCenter) + layout.addWidget(label) + btn = QPushButton("关闭") + btn.setObjectName("FlatButton") + btn.clicked.connect(self.accept) + row = QHBoxLayout() + row.addStretch(1) + row.addWidget(btn) + row.addStretch(1) + layout.addLayout(row) + + +def show_sim_not_found(parent=None) -> int: + return Frame_Dialog_SimNotFound(parent).exec_() diff --git a/components/Frame/Frame_Dialog_SimulatorRoot.py b/components/Frame/Frame_Dialog_SimulatorRoot.py new file mode 100644 index 00000000..ec696613 --- /dev/null +++ b/components/Frame/Frame_Dialog_SimulatorRoot.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- +"""模块:Frame_Dialog_SimulatorRoot — 设定模拟器(FSX)安装根目录。""" + +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +from PySide2.QtCore import Qt +from PySide2.QtWidgets import ( + QDialog, + QFileDialog, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QSizePolicy, + QVBoxLayout, +) + +from components.Frame.Frame_Theme_UI import UITheme, WindowGeom, apply_fixed_window +from models.mission_switch import ( + detect_default_fsx_root, + is_valid_fsx_root, + normalize_fsx_root, +) +from setting import ( + MSG_FSX_ROOT_DETECTED, + MSG_FSX_ROOT_INVALID, + MSG_FSX_ROOT_NOT_DETECTED, + MSG_MISSION_SWITCH_NO_FSX_ROOT, + SPACE_FRAME_GAP, + SPACE_FRAME_PAD_DIALOG, + SPACE_NONE, + SPACE_WIDGET_GAP, + UI_DIALOG_SIMULATOR_ROOT, +) + + +class Frame_Dialog_SimulatorRoot(QDialog): + """请设定模拟器根目录路径;Accepted 时 selected_path 为本地已存在的目录。""" + + def __init__(self, parent=None, initial_path: str = ""): + super().__init__(parent) + self.selected_path: Optional[str] = None + self.setWindowTitle(MSG_MISSION_SWITCH_NO_FSX_ROOT) + self.setModal(True) + w, h = WindowGeom.scale_dialog_size(*UI_DIALOG_SIMULATOR_ROOT) + UITheme.apply_scale(w, h, *UI_DIALOG_SIMULATOR_ROOT) + apply_fixed_window(self, w, h) + + seed = str(initial_path or "").strip() + detected = False + if not is_valid_fsx_root(seed): + auto = detect_default_fsx_root() + if auto is not None: + seed = str(auto) + detected = True + + layout = QVBoxLayout(self) + layout.setContentsMargins( + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + ) + layout.setSpacing(SPACE_FRAME_GAP) + + title = QLabel(MSG_MISSION_SWITCH_NO_FSX_ROOT) + title.setObjectName("DialogTitle") + title.setAlignment(Qt.AlignCenter) + layout.addWidget(title) + + hint = QLabel( + "请选择本机模拟器安装根目录(须为绝对路径,且其下含 Missions 文件夹)" + ) + hint.setObjectName("Muted") + hint.setAlignment(Qt.AlignCenter) + hint.setWordWrap(True) + layout.addWidget(hint) + + row = QHBoxLayout() + row.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + row.setSpacing(SPACE_WIDGET_GAP) + self._edit = QLineEdit(self) + self._edit.setObjectName("SimulatorRootEdit") + self._edit.setPlaceholderText(MSG_FSX_ROOT_NOT_DETECTED) + self._edit.setText(seed) + self._edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + browse = QPushButton("浏览…") + browse.setObjectName("FlatButton") + browse.setCursor(Qt.PointingHandCursor) + browse.clicked.connect(self._browse) + row.addWidget(self._edit, 1) + row.addWidget(browse) + layout.addLayout(row) + + self._status = QLabel("") + self._status.setObjectName("Muted") + self._status.setAlignment(Qt.AlignCenter) + self._status.setWordWrap(True) + if detected: + self._status.setStyleSheet( + f"color: {UITheme.SUCCESS}; background: transparent;" + ) + self._status.setText(MSG_FSX_ROOT_DETECTED) + elif not seed: + self._status.setText(MSG_FSX_ROOT_NOT_DETECTED) + layout.addWidget(self._status) + + btns = QHBoxLayout() + btns.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + btns.setSpacing(SPACE_WIDGET_GAP) + cancel = QPushButton("取消") + cancel.setObjectName("FlatButton") + cancel.setCursor(Qt.PointingHandCursor) + cancel.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + cancel.clicked.connect(self.reject) + ok = QPushButton("确定") + ok.setObjectName("FlatButton") + ok.setCursor(Qt.PointingHandCursor) + ok.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + ok.clicked.connect(self._accept_path) + btns.addWidget(cancel) + btns.addWidget(ok) + layout.addLayout(btns) + + def _browse(self) -> None: + cur = normalize_fsx_root(self._edit.text()) + if is_valid_fsx_root(cur): + start = str(cur) + else: + auto = detect_default_fsx_root() + start = str(auto) if auto is not None else str(Path.home()) + chosen = QFileDialog.getExistingDirectory( + self, + MSG_MISSION_SWITCH_NO_FSX_ROOT, + start, + ) + if chosen: + self._edit.setText(chosen) + self._status.setStyleSheet("") + self._status.setText("") + + def _accept_path(self) -> None: + root = normalize_fsx_root(self._edit.text()) + if not is_valid_fsx_root(root): + self._status.setStyleSheet(f"color: {UITheme.ERROR}; background: transparent;") + self._status.setText(MSG_FSX_ROOT_INVALID) + return + self.selected_path = str(root) + self.accept() + + +def show_simulator_root_setup( + parent=None, + initial_path: str = "", +) -> Optional[str]: + """弹窗设定根目录;Accepted 返回绝对路径字符串,取消返回 None。""" + dlg = Frame_Dialog_SimulatorRoot(parent, initial_path=initial_path) + if dlg.exec_() == QDialog.Accepted: + return dlg.selected_path + return None diff --git a/components/Frame/Frame_Dialog_StartMonitoring.py b/components/Frame/Frame_Dialog_StartMonitoring.py new file mode 100644 index 00000000..1e0daaff --- /dev/null +++ b/components/Frame/Frame_Dialog_StartMonitoring.py @@ -0,0 +1,238 @@ +# -*- coding: utf-8 -*- +"""模块:Frame_Dialog_StartMonitoring。""" + +from __future__ import annotations + +from typing import Optional + +from PySide2.QtCore import Qt +from PySide2.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget + +from components.Container.Container_Panel_ShadowFollowSettings import Container_Panel_ShadowFollowSettings +from components.Frame.Frame_Dialog_ThemeAlert import show_theme_alert +from components.Frame.Frame_Theme_UI import UITheme, WindowGeom, apply_fixed_window +from components.widget.widget_Toggle_Slide import widget_Toggle_Slide +from setting import ( + SPACE_CONTAINER_PAD, + SPACE_FRAME_GAP, + SPACE_FRAME_PAD_DIALOG, + SPACE_NONE, + SPACE_WIDGET_GAP, + SPACE_WIDGET_GAP_TIGHT, + UI_DIALOG_START_MONITORING, +) + + +class Frame_Dialog_StartMonitoring(QDialog): + """开始监测设置;Accepted 时 result_value 为 dict。""" + + _DIALOG_W, _DIALOG_H = UI_DIALOG_START_MONITORING + + def __init__( + self, + parent=None, + session_mode: str = "freeflight", + cold_cabin_enabled: bool = False, + shadow_follow_enabled: bool = False, + lead_s: float = 3.0, + smoke_enabled: bool = True, + trajectory_record_id: Optional[str] = None, + trajectory_label: Optional[str] = None, + user_id: Optional[str] = None, + ): + super().__init__(parent) + self.result_value: Optional[dict] = None + self._session_mode = session_mode + self.setWindowTitle("开始监测") + self.setModal(True) + self._apply_fixed_size() + + layout = QVBoxLayout(self) + layout.setContentsMargins( + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + ) + layout.setSpacing(SPACE_FRAME_GAP) + + title = QLabel("启动参数设置") + title.setObjectName("DialogTitle") + title.setAlignment(Qt.AlignCenter) + layout.addWidget(title) + + toggles_row = QWidget() + toggles_row.setAutoFillBackground(False) + toggles_layout = QHBoxLayout(toggles_row) + toggles_layout.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + toggles_layout.setSpacing(SPACE_FRAME_GAP) + + cold_cell = QWidget() + cold_cell.setAutoFillBackground(False) + cold_layout = QVBoxLayout(cold_cell) + cold_layout.setContentsMargins( + SPACE_CONTAINER_PAD, + SPACE_CONTAINER_PAD, + SPACE_CONTAINER_PAD, + SPACE_CONTAINER_PAD, + ) + cold_layout.setSpacing(SPACE_WIDGET_GAP_TIGHT) + cold_layout.setAlignment(Qt.AlignCenter) + cold_lbl = QLabel("开启冷舱") + cold_lbl.setObjectName("Muted") + cold_lbl.setAlignment(Qt.AlignCenter) + cold_layout.addWidget(cold_lbl, 0, Qt.AlignCenter) + self._cold_toggle = widget_Toggle_Slide(checked=bool(cold_cabin_enabled)) + cold_layout.addWidget(self._cold_toggle, 0, Qt.AlignCenter) + toggles_layout.addWidget(cold_cell, 1) + + shadow_cell = QWidget() + shadow_cell.setAutoFillBackground(False) + shadow_layout = QVBoxLayout(shadow_cell) + shadow_layout.setContentsMargins( + SPACE_CONTAINER_PAD, + SPACE_CONTAINER_PAD, + SPACE_CONTAINER_PAD, + SPACE_CONTAINER_PAD, + ) + shadow_layout.setSpacing(SPACE_WIDGET_GAP_TIGHT) + shadow_layout.setAlignment(Qt.AlignCenter) + shadow_lbl = QLabel("跟飞") + shadow_lbl.setObjectName("Muted") + shadow_lbl.setAlignment(Qt.AlignCenter) + shadow_layout.addWidget(shadow_lbl, 0, Qt.AlignCenter) + shadow_on = bool(shadow_follow_enabled) and session_mode != "mission_mp" + self._shadow_toggle = widget_Toggle_Slide(checked=shadow_on) + self._shadow_toggle.toggled.connect(self._on_shadow_toggled) + shadow_layout.addWidget(self._shadow_toggle, 0, Qt.AlignCenter) + toggles_layout.addWidget(shadow_cell, 1) + layout.addWidget(toggles_row) + + self._shadow_settings = Container_Panel_ShadowFollowSettings( + self, + lead_s=lead_s, + smoke_enabled=smoke_enabled, + trajectory_record_id=trajectory_record_id, + trajectory_label=trajectory_label, + user_id=user_id, + expanded=shadow_on, + ) + layout.addWidget(self._shadow_settings) + self._apply_shadow_toggle(shadow_on, auto_expand=shadow_on) + + start_mode = QWidget() + start_mode.setAutoFillBackground(False) + start_mode_layout = QVBoxLayout(start_mode) + start_mode_layout.setContentsMargins( + SPACE_CONTAINER_PAD, + SPACE_CONTAINER_PAD, + SPACE_CONTAINER_PAD, + SPACE_CONTAINER_PAD, + ) + start_mode_layout.setSpacing(SPACE_WIDGET_GAP) + self._mode_label = QLabel("启动方式") + self._mode_label.setObjectName("Muted") + self._mode_label.setAlignment(Qt.AlignCenter) + start_mode_layout.addWidget(self._mode_label) + + mode_btns = QWidget() + mode_btns.setAutoFillBackground(False) + mode_row = QHBoxLayout(mode_btns) + mode_row.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + mode_row.setSpacing(SPACE_FRAME_GAP) + mode_row.addStretch(1) + self._btn_current = QPushButton("当前任务") + self._btn_current.setObjectName("FlatButton") + self._btn_current.clicked.connect(lambda: self._choose("current")) + mode_row.addWidget(self._btn_current) + self._btn_reset = QPushButton("重置任务") + self._btn_reset.setObjectName("FlatButton") + self._btn_reset.clicked.connect(lambda: self._choose("reset")) + mode_row.addWidget(self._btn_reset) + mode_row.addStretch(1) + start_mode_layout.addWidget(mode_btns) + layout.addWidget(start_mode) + + self._apply_session_mode(session_mode) + + def _apply_fixed_size(self) -> None: + w, h = WindowGeom.scale_dialog_size(self._DIALOG_W, self._DIALOG_H) + UITheme.apply_scale(w, h, self._DIALOG_W, self._DIALOG_H) + apply_fixed_window(self, w, h) + + def _apply_shadow_toggle(self, checked: bool, auto_expand: bool = False) -> None: + self._shadow_settings.set_expandable(checked, auto_expand=auto_expand) + + def _on_shadow_toggled(self, checked: bool) -> None: + if self._session_mode == "mission_mp": + return + self._apply_shadow_toggle(checked, auto_expand=checked) + + def _apply_session_mode(self, session_mode: str) -> None: + self._session_mode = session_mode + multiplay = session_mode == "mission_mp" + self._btn_reset.setEnabled(not multiplay) + self._shadow_toggle.setEnabled(not multiplay) + self._shadow_settings.setEnabled(not multiplay) + if multiplay: + if self._shadow_toggle.isChecked(): + self._shadow_toggle.setChecked(False) + self._apply_shadow_toggle(False) + else: + on = self._shadow_toggle.isChecked() + self._apply_shadow_toggle(on, auto_expand=on) + + def _choose(self, task: str) -> None: + shadow_on = ( + self._session_mode != "mission_mp" + and self._shadow_toggle.isChecked() + ) + trajectory_id = self._shadow_settings.shadow_panel.trajectory_record_id() + if shadow_on and not trajectory_id: + show_theme_alert( + self, + message="云端航迹未选择!", + show_title=False, + ) + return + self.result_value = { + "task": task, + "cold_cabin": bool(self._cold_toggle.isChecked()), + "shadow_follow": shadow_on, + "lead": self._shadow_settings.shadow_panel.lead_s(), + "smoke": self._shadow_settings.shadow_panel.smoke_enabled(), + "trajectory_record_id": trajectory_id if shadow_on else None, + "trajectory_label": ( + self._shadow_settings.shadow_panel.trajectory_label() + if shadow_on + else None + ), + } + self.accept() + + +def show_start_monitoring( + parent=None, + session_mode: str = "freeflight", + cold_cabin_enabled: bool = False, + shadow_follow_enabled: bool = False, + lead_s: float = 3.0, + smoke_enabled: bool = True, + trajectory_record_id: Optional[str] = None, + trajectory_label: Optional[str] = None, + user_id: Optional[str] = None, +) -> Optional[dict]: + dlg = Frame_Dialog_StartMonitoring( + parent, + session_mode=session_mode, + cold_cabin_enabled=cold_cabin_enabled, + shadow_follow_enabled=shadow_follow_enabled, + lead_s=lead_s, + smoke_enabled=smoke_enabled, + trajectory_record_id=trajectory_record_id, + trajectory_label=trajectory_label, + user_id=user_id, + ) + if dlg.exec_() == QDialog.Accepted: + return dlg.result_value + return None diff --git a/components/Frame/Frame_Dialog_ThemeAlert.py b/components/Frame/Frame_Dialog_ThemeAlert.py new file mode 100644 index 00000000..46eb2e3f --- /dev/null +++ b/components/Frame/Frame_Dialog_ThemeAlert.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +"""模块:Frame_Dialog_ThemeAlert — 主题化提示框。""" + +from __future__ import annotations + +import sys + +from PySide2.QtCore import Qt +from PySide2.QtWidgets import QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout + +from components.Frame.Frame_Theme_UI import ( + UITheme, + WindowGeom, + apply_fixed_window, + apply_window_stays_on_top, + status_kind_color, +) +from setting import SPACE_FRAME_GAP, SPACE_FRAME_PAD_DIALOG, UI_DIALOG_THEME_ALERT + + +def play_ui_alert_sound() -> None: + try: + if sys.platform == "win32": + import winsound + + winsound.MessageBeep(winsound.MB_ICONEXCLAMATION) + else: + app = QApplication.instance() + if app is not None: + app.beep() + except Exception: + pass + + +class Frame_Dialog_ThemeAlert(QDialog): + """主题化提示框(替代系统 QMessageBox)。""" + + _DIALOG_W, _DIALOG_H = UI_DIALOG_THEME_ALERT + + def __init__( + self, + parent=None, + title: str = "提示", + message: str = "", + kind: str = "warn", + button_text: str = "确定", + show_title: bool = True, + play_sound: bool = True, + stays_on_top: bool = False, + ): + super().__init__(parent) + self._play_sound = bool(play_sound) + self.setWindowTitle(title if show_title else "提示") + self.setModal(True) + if stays_on_top: + apply_window_stays_on_top(self, True) + w, h = WindowGeom.scale_dialog_size(self._DIALOG_W, self._DIALOG_H) + UITheme.apply_scale(w, h, self._DIALOG_W, self._DIALOG_H) + apply_fixed_window(self, w, h) + + layout = QVBoxLayout(self) + layout.setContentsMargins( + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + ) + layout.setSpacing(SPACE_FRAME_GAP) + + if show_title: + title_lbl = QLabel(title) + title_lbl.setObjectName("DialogTitle") + title_lbl.setAlignment(Qt.AlignCenter) + layout.addWidget(title_lbl) + + msg_lbl = QLabel(message) + msg_lbl.setObjectName("DialogTitle" if not show_title else "Muted") + msg_lbl.setAlignment(Qt.AlignCenter) + msg_lbl.setWordWrap(True) + msg_lbl.setStyleSheet( + f"color: {status_kind_color(kind)}; background: transparent;" + if not show_title or kind != "muted" + else "" + ) + layout.addWidget(msg_lbl) + + btn = QPushButton(button_text) + btn.setObjectName("FlatButton") + btn.clicked.connect(self.accept) + row = QHBoxLayout() + row.addStretch(1) + row.addWidget(btn) + row.addStretch(1) + layout.addLayout(row) + + def showEvent(self, event) -> None: + super().showEvent(event) + if self._play_sound: + play_ui_alert_sound() + + +def show_theme_alert( + parent=None, + title: str = "提示", + message: str = "", + kind: str = "warn", + button_text: str = "确定", + show_title: bool = True, + play_sound: bool = True, + stays_on_top: bool = False, +) -> int: + return Frame_Dialog_ThemeAlert( + parent, + title=title, + message=message, + kind=kind, + button_text=button_text, + show_title=show_title, + play_sound=play_sound, + stays_on_top=stays_on_top, + ).exec_() diff --git a/components/Frame/Frame_Dialog_TrackStorePick.py b/components/Frame/Frame_Dialog_TrackStorePick.py new file mode 100644 index 00000000..4b2982b7 --- /dev/null +++ b/components/Frame/Frame_Dialog_TrackStorePick.py @@ -0,0 +1,402 @@ +# -*- coding: utf-8 -*- +"""模块:Frame_Dialog_TrackStorePick — 云端航迹列表(选择 / 腾位删除)。""" + +from __future__ import annotations + +import threading +from typing import Callable, List, Optional, Sequence + +from PySide2.QtCore import QObject, Qt, Signal +from PySide2.QtWidgets import ( + QAbstractItemView, + QDialog, + QHBoxLayout, + QHeaderView, + QLabel, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, +) + +from components.Frame.Frame_Theme_UI import ( + UITheme, + WindowGeom, + apply_fixed_window, + apply_window_stays_on_top, + format_lap_time, +) +from models.track_store import ( + TrackStoreMeta, + format_track_id_short, + session_mode_label, + track_result_label, +) +from setting import ( + MISSION_SCORE_DECIMALS, + MSG_TRACK_STORE_EMPTY, + MSG_TRACK_STORE_LIST_FAIL, + MSG_TRACK_STORE_LIST_LOADING, + SPACE_FRAME_GAP, + SPACE_FRAME_PAD_DIALOG, + TRACK_STORE_MAX_RECORDS, + UI_DIALOG_TRACK_STORE_PICK, + UI_DIALOG_TRACK_STORE_WAIT, +) + +_TABLE_HEADERS = ( + "航迹ID", + "任务名称", + "任务模式", + "净飞行时间", + "罚时", + "总飞行时间", + "分数", + "状态", +) + + +def _fmt_time(seconds: Optional[float]) -> str: + return format_lap_time(seconds) + + +def _fmt_score(score: Optional[float]) -> str: + if score is None: + return "—" + try: + value = float(score) + except (TypeError, ValueError): + return "—" + if value != value: + return "—" + return f"{value:.{int(MISSION_SCORE_DECIMALS)}f}" + + +def _meta_row_texts(meta: TrackStoreMeta) -> List[str]: + return [ + format_track_id_short(meta.record_id) or "—", + meta.mission_title or "—", + session_mode_label(meta.session_mode) if meta.session_mode else "—", + _fmt_time(meta.net_s), + _fmt_time(meta.penalty_s), + _fmt_time(meta.total_s), + _fmt_score(meta.score), + track_result_label(meta.status), + ] + + +class Frame_Dialog_TrackStoreWait(QDialog): + """列表拉取等待框:立刻弹出,不阻塞事件循环(show,非 exec)。""" + + _DIALOG_W, _DIALOG_H = UI_DIALOG_TRACK_STORE_WAIT + + def __init__(self, parent=None, message: str = ""): + super().__init__(parent) + self.setWindowTitle("请稍候") + self.setModal(True) + self.setWindowFlags( + (self.windowFlags() | Qt.CustomizeWindowHint | Qt.WindowStaysOnTopHint) + & ~Qt.WindowCloseButtonHint + & ~Qt.WindowContextHelpButtonHint + ) + w, h = WindowGeom.scale_dialog_size(self._DIALOG_W, self._DIALOG_H) + UITheme.apply_scale(w, h, self._DIALOG_W, self._DIALOG_H) + apply_fixed_window(self, w, h) + + layout = QVBoxLayout(self) + layout.setContentsMargins( + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + ) + layout.setSpacing(SPACE_FRAME_GAP) + msg = QLabel(message or MSG_TRACK_STORE_LIST_LOADING) + msg.setWordWrap(True) + msg.setAlignment(Qt.AlignCenter) + msg.setObjectName("Muted") + msg.setStyleSheet(f"color: {UITheme.FG}; background: transparent;") + layout.addWidget(msg) + + +class Frame_Dialog_TrackStorePick(QDialog): + """ + mode=pick:单选一条加载(对比/跟飞) + mode=make_space:多选删除,腾出至少 need_slots 个名额 + """ + + _DIALOG_W, _DIALOG_H = UI_DIALOG_TRACK_STORE_PICK + + def __init__( + self, + parent=None, + *, + metas: Sequence[TrackStoreMeta], + mode: str = "pick", + need_slots: int = 1, + title: str = "", + message: str = "", + ok_text: str = "", + cancel_text: str = "", + ): + super().__init__(parent) + self._metas = list(metas) + self._mode = str(mode) + self._need_slots = max(1, int(need_slots)) + self.selected_id: Optional[str] = None + self.deleted_ids: List[str] = [] + + self.setWindowTitle(title or ("选择航迹" if self._mode == "pick" else "云端航迹已满")) + self.setModal(True) + # 置顶:满额腾位/选航迹时压住模拟器,并盖过已让位的成绩框 + apply_window_stays_on_top(self, True) + w, h = WindowGeom.scale_dialog_size(self._DIALOG_W, self._DIALOG_H) + UITheme.apply_scale(w, h, self._DIALOG_W, self._DIALOG_H) + apply_fixed_window(self, w, h) + + layout = QVBoxLayout(self) + layout.setContentsMargins( + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + ) + layout.setSpacing(SPACE_FRAME_GAP) + + head = QLabel( + message + or ( + "请选择一条云端航迹" + if self._mode == "pick" + else f"最多保存 {TRACK_STORE_MAX_RECORDS} 条。请选择要覆盖的记录(至少 {self._need_slots} 条)后继续保存。" + ) + ) + head.setWordWrap(True) + head.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + head.setStyleSheet(f"color: {UITheme.FG}; background: transparent;") + layout.addWidget(head) + + self._table = QTableWidget(len(self._metas), len(_TABLE_HEADERS)) + self._table.setHorizontalHeaderLabels(list(_TABLE_HEADERS)) + self._table.setStyleSheet( + f"QTableWidget {{ background: {UITheme.SURFACE}; color: {UITheme.FG}; " + f"border: 1px solid {UITheme.BORDER}; gridline-color: {UITheme.BORDER}; }}" + f"QHeaderView::section {{ background: {UITheme.SURFACE}; color: {UITheme.FG}; " + f"border: 1px solid {UITheme.BORDER}; padding: 4px; }}" + ) + self._table.verticalHeader().setVisible(False) + self._table.setShowGrid(True) + self._table.setAlternatingRowColors(False) + self._table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self._table.setSelectionBehavior(QAbstractItemView.SelectRows) + multi = self._mode == "make_space" + self._table.setSelectionMode( + QAbstractItemView.MultiSelection if multi else QAbstractItemView.SingleSelection + ) + hdr = self._table.horizontalHeader() + # 各列按内容自适应;多余宽度给任务名称列 + for col in range(len(_TABLE_HEADERS)): + hdr.setSectionResizeMode(col, QHeaderView.ResizeToContents) + hdr.setSectionResizeMode(1, QHeaderView.Stretch) + + for row, meta in enumerate(self._metas): + texts = _meta_row_texts(meta) + tip = ( + f"航迹ID:{format_track_id_short(meta.record_id)}\n" + f"{meta.label}\n{meta.mission_title}\n{meta.recorded_at}\n{meta.aircraft_title}" + ) + for col, text in enumerate(texts): + item = QTableWidgetItem(text) + item.setData(Qt.UserRole, meta.record_id) + item.setToolTip(tip) + item.setFlags(item.flags() & ~Qt.ItemIsEditable) + self._table.setItem(row, col, item) + layout.addWidget(self._table, 1) + + if self._mode == "pick": + _ok = ok_text or "确定" + _cancel = cancel_text or "取消" + else: + _ok = ok_text or "覆盖并保存" + _cancel = cancel_text or "放弃保存" + + row = QHBoxLayout() + row.addStretch(1) + btn_cancel = QPushButton(_cancel) + btn_cancel.setObjectName("FlatButton") + btn_cancel.clicked.connect(self.reject) + row.addWidget(btn_cancel) + btn_ok = QPushButton(_ok) + btn_ok.setObjectName("FlatButton") + btn_ok.clicked.connect(self._on_ok) + row.addWidget(btn_ok) + row.addStretch(1) + layout.addLayout(row) + + def _selected_record_ids(self) -> List[str]: + rows = sorted({it.row() for it in self._table.selectedItems()}) + ids: List[str] = [] + seen = set() + for r in rows: + cell = self._table.item(r, 0) + if cell is None: + continue + rid = cell.data(Qt.UserRole) + if not rid or rid in seen: + continue + seen.add(rid) + ids.append(str(rid)) + return ids + + def _on_ok(self) -> None: + ids = self._selected_record_ids() + if self._mode == "pick": + if not ids: + return + self.selected_id = ids[0] + self.accept() + return + if len(ids) < self._need_slots: + return + self.deleted_ids = ids + self.accept() + + +def show_track_store_pick( + parent=None, + metas: Sequence[TrackStoreMeta] = (), + *, + title: str = "选择云端航迹", + message: str = "", + ok_text: str = "", + cancel_text: str = "", +) -> Optional[str]: + """选择一条记录 id;取消返回 None。默认按钮:确定 / 取消。""" + if not metas: + return None + dlg = Frame_Dialog_TrackStorePick( + parent, + metas=metas, + mode="pick", + title=title, + message=message or "请选择一条航迹", + ok_text=ok_text, + cancel_text=cancel_text, + ) + if dlg.exec_() != Frame_Dialog_TrackStorePick.Accepted: + return None + return dlg.selected_id + + +def show_track_store_make_space( + parent=None, + metas: Sequence[TrackStoreMeta] = (), + *, + need_slots: int = 1, + message: str = "", + ok_text: str = "", + cancel_text: str = "", +) -> Optional[List[str]]: + """满额时选删;取消返回 None;确定返回待删 id 列表。默认按钮:覆盖并保存 / 放弃保存。""" + if not metas: + return None + dlg = Frame_Dialog_TrackStorePick( + parent, + metas=metas, + mode="make_space", + need_slots=need_slots, + title="云端航迹已满", + message=message, + ok_text=ok_text, + cancel_text=cancel_text, + ) + if dlg.exec_() != Frame_Dialog_TrackStorePick.Accepted: + return None + return list(dlg.deleted_ids) + + +class _ListMetasBridge(QObject): + finished = Signal(object, object) # metas|None, err|None + + +def fetch_and_pick_track( + parent, + user_id: str, + *, + title: str = "选择云端航迹", + message: str = "", + ok_text: str = "", + cancel_text: str = "", + on_finished: Optional[Callable[[Optional[str], Optional[TrackStoreMeta]], None]] = None, +) -> None: + """ + 异步拉列表(立刻出等待框)→ 选轨弹窗。 + on_finished(record_id, meta);取消/失败/空列表为 (None, None)。 + """ + from components.Frame.Frame_Dialog_ThemeAlert import show_theme_alert + from models.track_store import TrackStoreError, get_cached_track_metas, list_track_metas + + uid = str(user_id or "").strip() + cb = on_finished or (lambda _rid, _meta: None) + + def _open_pick(metas: List[TrackStoreMeta]) -> None: + if not metas: + show_theme_alert( + parent, + title="提示", + message=MSG_TRACK_STORE_EMPTY, + kind="warn", + show_title=True, + ) + cb(None, None) + return + record_id = show_track_store_pick( + parent, + metas, + title=title, + message=message, + ok_text=ok_text, + cancel_text=cancel_text, + ) + if not record_id: + cb(None, None) + return + picked = next((m for m in metas if m.record_id == record_id), None) + cb(record_id, picked) + + cached = get_cached_track_metas(uid) + if cached is not None: + _open_pick(cached) + return + + wait = Frame_Dialog_TrackStoreWait(parent, message=MSG_TRACK_STORE_LIST_LOADING) + wait.show() + wait.raise_() + wait.activateWindow() + + bridge = _ListMetasBridge(wait) + wait._list_bridge = bridge # 防止线程完成前被 GC + + def _done(metas_obj, err_obj) -> None: + wait.close() + wait.deleteLater() + if err_obj is not None: + if isinstance(err_obj, TrackStoreError): + msg = str(err_obj) + else: + msg = MSG_TRACK_STORE_LIST_FAIL.format(err=err_obj) + show_theme_alert(parent, title="提示", message=msg, kind="error", show_title=True) + cb(None, None) + return + _open_pick(list(metas_obj or [])) + + bridge.finished.connect(_done) + + def _work() -> None: + try: + metas = list_track_metas(uid) + bridge.finished.emit(metas, None) + except Exception as exc: + bridge.finished.emit(None, exc) + + threading.Thread(target=_work, daemon=True).start() diff --git a/components/Frame/Frame_Dialog_TrajectorySave.py b/components/Frame/Frame_Dialog_TrajectorySave.py new file mode 100644 index 00000000..9655f821 --- /dev/null +++ b/components/Frame/Frame_Dialog_TrajectorySave.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +"""模块:Frame_Dialog_TrajectorySave。""" + +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +from PySide2.QtCore import Qt +from PySide2.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout + +from components.Frame.Frame_Theme_UI import UITheme, WindowGeom, apply_fixed_window, apply_window_stays_on_top +from setting import SPACE_FRAME_GAP, SPACE_FRAME_PAD_DIALOG, UI_DIALOG_TRAJECTORY_SAVE + + +class Frame_Dialog_TrajectorySave(QDialog): + def __init__(self, parent=None, ok: bool = True, path: Optional[str] = None, message: str = ""): + super().__init__(parent) + self.setWindowTitle("保存轨迹") + self.setModal(True) + # 短生命周期结果框:置顶以压模拟器,并盖过已让位的成绩框 + apply_window_stays_on_top(self, True) + w, h = WindowGeom.scale_dialog_size(*UI_DIALOG_TRAJECTORY_SAVE) + apply_fixed_window(self, w, h) + layout = QVBoxLayout(self) + layout.setContentsMargins( + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + SPACE_FRAME_PAD_DIALOG, + ) + layout.setSpacing(SPACE_FRAME_GAP) + status = QLabel(message or ("保存成功" if ok else "保存失败")) + status.setAlignment(Qt.AlignCenter) + status.setStyleSheet(f"color: {UITheme.SUCCESS if ok else UITheme.ERROR}; background: transparent;") + layout.addWidget(status) + if path: + path_lbl = QLabel(str(path)) + path_lbl.setAlignment(Qt.AlignCenter) + path_lbl.setWordWrap(True) + path_lbl.setStyleSheet(f"color: {UITheme.FG_MUTED}; background: transparent;") + layout.addWidget(path_lbl) + btn = QPushButton("确定") + btn.setObjectName("FlatButton") + btn.clicked.connect(self.accept) + row = QHBoxLayout() + row.addStretch(1) + row.addWidget(btn) + row.addStretch(1) + layout.addLayout(row) + + +def show_trajectory_save_result( + parent=None, + ok: bool = True, + path: Optional[Path] = None, + message: str = "", +) -> int: + text = message or ("保存成功" if ok else "保存失败") + path_text = str(path) if path else "" + return Frame_Dialog_TrajectorySave(parent, ok=ok, path=path_text or None, message=text).exec_() diff --git a/components/Frame/Frame_Shell_AppMain.py b/components/Frame/Frame_Shell_AppMain.py new file mode 100644 index 00000000..09850718 --- /dev/null +++ b/components/Frame/Frame_Shell_AppMain.py @@ -0,0 +1,227 @@ +# -*- coding: utf-8 -*- +""" +模块:components.Frame.Frame_Shell_AppMain +职责:Capability / Feature / FeatureRegistry / 主窗壳 +依赖:Frame_Theme_UI、overlay_Global_Base +""" + +from __future__ import annotations + +import math +import time +from typing import Iterable, Optional + +from PySide2.QtCore import Signal +from PySide2.QtWidgets import QApplication, QMainWindow, QStackedWidget, QWidget + +from components.Frame.Frame_Theme_UI import ( + UITheme, + WindowGeom, + apply_fixed_window, + apply_window_icon, + create_app, +) +from components.overlay.overlay_Global_Base import overlay_Global_Base +from models.chart import ChartHistoryBuffer, TRAINING_CHART +from setting import DEFAULT_CAPABILITIES + + +class CapabilitySet: + def __init__(self, enabled: Optional[Iterable[str]] = None): + self._enabled = set(enabled if enabled is not None else DEFAULT_CAPABILITIES) + + def has(self, key: str) -> bool: + return key in self._enabled + + def set_enabled(self, key: str, on: bool) -> None: + if on: + self._enabled.add(key) + else: + self._enabled.discard(key) + + def replace(self, keys: Iterable[str]) -> None: + self._enabled = set(keys) + + def copy(self) -> "CapabilitySet": + return CapabilitySet(self._enabled) + + def as_set(self) -> set[str]: + return set(self._enabled) + + +class Feature(QWidget): + """功能页基类:在 pages/ 中继承并组合 Container / widget。""" + + feature_id: str = "" + title: str = "" + + def apply_capabilities(self, caps: CapabilitySet) -> None: + pass + + def on_activated(self) -> None: + pass + + def on_deactivated(self) -> None: + pass + + +class FeatureRegistry: + def __init__(self): + self._features: dict[str, Feature] = {} + + def register(self, feature: Feature) -> None: + if not feature.feature_id: + raise ValueError("feature_id 不能为空") + self._features[feature.feature_id] = feature + + def get(self, feature_id: str) -> Optional[Feature]: + return self._features.get(feature_id) + + def all_ids(self) -> list[str]: + return list(self._features.keys()) + + def values(self) -> list[Feature]: + return list(self._features.values()) + + +class Frame_Shell_AppMain(QMainWindow): + """主壳:QStackedWidget 切换 Feature,统一下发 Capability。""" + + featureChanged = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("TALENT ACADEMY") + apply_window_icon(self) + self._registry = FeatureRegistry() + self._caps = CapabilitySet() + self._current_id: Optional[str] = None + self._stack = QStackedWidget() + self.setCentralWidget(self._stack) + self._global_overlay = overlay_Global_Base(self) + self._global_overlay.hide() + + @property + def registry(self) -> FeatureRegistry: + return self._registry + + @property + def current_id(self) -> Optional[str]: + return self._current_id + + @property + def capabilities(self) -> CapabilitySet: + return self._caps + + @property + def global_overlay(self) -> overlay_Global_Base: + return self._global_overlay + + def register(self, feature: Feature) -> None: + self._registry.register(feature) + self._stack.addWidget(feature) + feature.apply_capabilities(self._caps) + + def show_feature(self, feature_id: str) -> None: + feature = self._registry.get(feature_id) + if feature is None: + raise KeyError(f"未注册功能: {feature_id}") + if self._current_id and self._current_id != feature_id: + prev = self._registry.get(self._current_id) + if prev is not None: + prev.on_deactivated() + self._stack.setCurrentWidget(feature) + self._current_id = feature_id + if feature.title: + self.setWindowTitle(f"TALENT ACADEMY - {feature.title}") + else: + self.setWindowTitle("TALENT ACADEMY") + feature.apply_capabilities(self._caps) + feature.on_activated() + self.featureChanged.emit(feature_id) + apply_feature_window(self, feature_id) + self._global_overlay.resize(self.size()) + self._global_overlay.raise_() + + def set_capabilities(self, caps: CapabilitySet) -> None: + self._caps = caps.copy() + for feature in self._registry.values(): + feature.apply_capabilities(self._caps) + + def current_feature(self) -> Optional[Feature]: + if not self._current_id: + return None + return self._registry.get(self._current_id) + + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + self._global_overlay.resize(self.size()) + + +def apply_feature_window(shell: Frame_Shell_AppMain, feature_id: str) -> None: + """按功能页切换窗口尺寸与主题缩放。""" + spec = WindowGeom.feature_window_size(feature_id) + if spec is None: + return + width, height, design_w, design_h = spec + UITheme.apply_scale(width, height, design_w, design_h) + app = QApplication.instance() + if app is not None: + app.setStyleSheet(UITheme.qss()) + apply_fixed_window(shell, width, height) + + +def _run_smoke() -> int: + from pages import register_default_features + + app = create_app() + shell = Frame_Shell_AppMain() + w, h = WindowGeom.main_window_size() + UITheme.apply_scale(w, h, 680, 820) + app.setStyleSheet(UITheme.qss()) + register_default_features(shell) + apply_fixed_window(shell, w, h) + + stub = shell.registry.get("mission_stub") + if stub is not None: + stub.secondaryClicked.connect(lambda: shell.show_feature("training_assist")) + + train = shell.registry.get("training_assist") + if train is not None and hasattr(train, "actions"): + # 烟测:开始监测按钮临时用来跳到 stub,验证整页切换 + train.startMonitorClicked.connect(lambda: shell.show_feature("mission_stub")) + + shell.show_feature("training_assist") + shell.show() + + page = shell.current_feature() + hist = ChartHistoryBuffer(TRAINING_CHART) + t0 = time.time() + for i in range(120): + hist.append( + t0 + i * 0.5, + torque=400 + 200 * math.sin(i / 8), + health=98.5 - (i % 40) * 0.05, + ) + if hasattr(page, "set_cht"): + page.set_cht(110) + page.set_torque(560) + page.set_health(98.2) + page.set_laps( + [52.3, 51.8, None, None], + 104.1, + lap_penalties=[5.0, 2.5, None, None], + total_penalty=7.5, + ) + page.set_status("烟测:训练助手假数据|点「开始监测」切 stub", "ok") + page.update_chart(hist) + page.add_lap_marker(t0 + 30, 1) + if hasattr(page, "set_stick_axes"): + page.set_stick_axes( + pitch=0.35, + roll=-0.2, + yaw=0.1, + throttle=0.6, + connected=True, + ) + return app.exec_() diff --git a/components/Frame/Frame_Theme_UI.py b/components/Frame/Frame_Theme_UI.py new file mode 100644 index 00000000..3013ac31 --- /dev/null +++ b/components/Frame/Frame_Theme_UI.py @@ -0,0 +1,1099 @@ +# -*- coding: utf-8 -*- +""" +模块:components.Frame.Frame_Theme_UI +职责:UITheme / WindowGeom / 品牌与窗口工具 / 展示格式化 +依赖:setting(色板字号几何)、PySide2 +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Optional, Tuple + +from PySide2.QtCore import Qt +from PySide2.QtGui import QFont, QFontDatabase, QIcon +from PySide2.QtWidgets import QApplication, QWidget + +from models.chart import health_band +from setting import ( + APP_ICON_PATH, + APP_NAME, + C_CHT, + D_TORQUE, + E_TORQUE, + FEATURE_WINDOW_SPECS, + METRIC_VALUE_DECIMALS, + METRIC_VALUE_INDENT, + UI_AUTH_WINDOW_BASE, + UI_CHART_HEALTH_SPAN_ALPHA, + UI_COLOR_ACCENT_HOVER, + UI_COLOR_BLUE_DEEP, + UI_COLOR_BLUE_LIGHT, + UI_COLOR_BLUE_MID, + UI_COLOR_BTN_DISABLED_BG, + UI_COLOR_BTN_DISABLED_BORDER, + UI_COLOR_BTN_DISABLED_FG, + UI_COLOR_TRACK_LOCK_FREE_BG, + UI_COLOR_TRACK_LOCK_FREE_BORDER, + UI_COLOR_TRACK_LOCK_FREE_FG, + UI_COLOR_TRACK_LOCK_FREE_HOVER_BG, + UI_COLOR_TRACK_LOCK_FREE_HOVER_BORDER, + UI_COLOR_TRACK_LOCK_ON_BG, + UI_COLOR_TRACK_LOCK_ON_BORDER, + UI_COLOR_TRACK_LOCK_ON_FG, + UI_COLOR_TRACK_LOCK_ON_HOVER_BG, + UI_COLOR_TRACK_LOCK_ON_HOVER_BORDER, + UI_COLOR_CHART_ACCEL_RATE, + UI_COLOR_CHART_AIRSPEED, + UI_COLOR_CHART_BANK, + UI_COLOR_CHART_DI_PROXY, + UI_COLOR_CHART_ETA_PROP, + UI_COLOR_CHART_GROUNDSPEED, + UI_COLOR_CHART_HEALTH, + UI_COLOR_CHART_LAP, + UI_COLOR_CHART_PITCH, + UI_COLOR_CHART_REFERENCE, + UI_COLOR_CHART_SPEED_RATE, + UI_COLOR_CHART_TORQUE, + UI_COLOR_CHART_VG_COURSE, + UI_COLOR_CHART_WELL, + UI_COLOR_CHART_FLOAT_DEFAULT, + UI_COLOR_DATA_CURRENT, + UI_COLOR_DATA_HISTORY, + UI_COLOR_ERROR, + UI_COLOR_ERROR_HOVER, + UI_COLOR_FG, + UI_COLOR_FG_MUTED, + UI_COLOR_FG_ON_ACCENT, + UI_COLOR_FG_TITLE, + UI_BORDER_CHECKBOX_RADIUS_PX, + UI_BORDER_COLOR, + UI_BORDER_METRIC_COLOR, + UI_BORDER_METRIC_RADIUS_PX, + UI_BORDER_METRIC_WIDTH_PX, + UI_BORDER_RADIUS_PX, + UI_BORDER_WIDTH_PX, + UI_COLOR_FLASH_BLUE, + UI_COLOR_METRIC_SURFACE, + UI_COLOR_SIGNAL_FAIL, + UI_COLOR_SIGNAL_IDLE, + UI_COLOR_SIGNAL_OK, + UI_COLOR_SIGNAL_RUN, + UI_COLOR_SUCCESS, + UI_COLOR_TOGGLE_KNOB_OFF, + UI_COLOR_TOGGLE_TRACK_BORDER, + UI_COLOR_TOGGLE_TRACK_OFF, + UI_COLOR_WARNING, + UI_CONTROLLER_SETTINGS_BTN_PAD_H, + UI_CONTROLLER_SETTINGS_BTN_PAD_V, + UI_DIALOG_MIN, + UI_FONT_CHT, + UI_FONT_CONTROLLER_SETTINGS, + UI_FONT_FAMILY, + UI_FONT_FAMILY_TECH, + UI_FONT_HINT, + UI_FONT_LAP_TOTAL, + UI_FONT_LG, + UI_FONT_MD, + UI_FONT_METRIC_TITLE, + UI_FONT_RACE_SUCCESS, + UI_FONT_SM, + UI_FONT_TITLE, + UI_FONT_UNIT, + UI_FONT_XS, + UI_MAIN_WINDOW_H_EXTRA, + UI_SCREEN_FALLBACK, + WINDOW_ALPHA, +) + + +def _configure_qt_plugins() -> None: + """conda / pip 安装的 Qt 常找不到 platforms 插件,需显式设置路径。""" + try: + import PySide2 + except ImportError: + return + root = Path(PySide2.__file__).resolve().parent + plugins = root / "plugins" + platforms = plugins / "platforms" + if not platforms.is_dir(): + return + os.environ["QT_PLUGIN_PATH"] = str(plugins) + os.environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = str(platforms) + os.environ["QT_QPA_PLATFORM"] = "windows" + + +_configure_qt_plugins() + + +def format_lap_time(seconds): + if seconds is None: + return "--" + try: + total = float(seconds) + except (TypeError, ValueError): + return "--" + if total < 0 or total != total: + return "--" + minutes = int(total // 60) + secs = total - minutes * 60 + if minutes > 0: + return f"{minutes}:{secs:04.1f}" + return f"{secs:.1f}s" + + +def format_lap_time_with_penalty(time_s, penalty_s=None) -> str: + """净圈时;本圈罚时(若有)在括号内。""" + text = format_lap_time(time_s) + if penalty_s is None: + return text + try: + penalty = float(penalty_s) + except (TypeError, ValueError): + return text + if penalty <= 0 or penalty != penalty: + return text + return f"{text} ({format_lap_time(penalty)})" + + +def format_total_time_display(net_s, cumulative_penalty_s=None) -> str: + """净总时(累计罚时)含罚总时。""" + net_text = format_lap_time(net_s) + if net_text == "--": + return "--" + if cumulative_penalty_s is None: + return net_text + try: + penalty = float(cumulative_penalty_s) + except (TypeError, ValueError): + return net_text + if penalty <= 0 or penalty != penalty: + return net_text + try: + net = float(net_s) + except (TypeError, ValueError): + return net_text + if net < 0 or net != net: + return net_text + gross = net + penalty + return f"{net_text} ({format_lap_time(penalty)}) {format_lap_time(gross)}" + + +def format_metric_value(value, decimals=METRIC_VALUE_DECIMALS): + if value is None: + text = "--" + else: + try: + number = float(value) + except (TypeError, ValueError): + text = "--" + else: + if number != number: + text = "--" + else: + text = f"{number:.{decimals}f}" + return METRIC_VALUE_INDENT + text + + +def cht_color(cht_f): + if cht_f is None: + return UITheme.FG + return UITheme.FLASH_BLUE if float(cht_f) < C_CHT else UITheme.ERROR + + +def torque_color(torque): + if torque is None: + return UITheme.FG + tq = float(torque) + if tq > E_TORQUE: + return UITheme.FLASH_BLUE + if D_TORQUE <= tq <= E_TORQUE: + return UITheme.WARNING + return UITheme.ERROR + + +def health_color(health_pct): + band = health_band(health_pct) + if band is None: + return UITheme.FG + if band == "flash": + return UITheme.FLASH_BLUE + if band == "green": + return UITheme.SUCCESS + if band == "orange": + return UITheme.WARNING + return UITheme.ERROR + + +def status_kind_color(kind: str) -> str: + return { + "ok": UITheme.SUCCESS, + "warn": UITheme.WARNING, + "error": UITheme.ERROR, + "muted": UITheme.FG_MUTED, + }.get(kind, UITheme.FG_MUTED) + + +class UITheme: + BLUE_DEEP = UI_COLOR_BLUE_DEEP + BLUE_MID = UI_COLOR_BLUE_MID + BLUE_LIGHT = UI_COLOR_BLUE_LIGHT + BG = BLUE_DEEP + SURFACE = BLUE_MID + CHART_WELL = UI_COLOR_CHART_WELL + METRIC_SURFACE = UI_COLOR_METRIC_SURFACE + METRIC_BORDER = UI_BORDER_METRIC_COLOR + METRIC_BORDER_WIDTH = UI_BORDER_METRIC_WIDTH_PX + METRIC_BORDER_RADIUS = UI_BORDER_METRIC_RADIUS_PX + FG = UI_COLOR_FG + FG_MUTED = UI_COLOR_FG_MUTED + FG_TITLE = UI_COLOR_FG_TITLE + FG_ON_ACCENT = UI_COLOR_FG_ON_ACCENT + ACCENT = BLUE_LIGHT + ACCENT_HOVER = UI_COLOR_ACCENT_HOVER + BORDER = UI_BORDER_COLOR + BORDER_WIDTH = UI_BORDER_WIDTH_PX + BORDER_RADIUS = UI_BORDER_RADIUS_PX + CHECKBOX_BORDER_RADIUS = UI_BORDER_CHECKBOX_RADIUS_PX + TOGGLE_TRACK_OFF = UI_COLOR_TOGGLE_TRACK_OFF + TOGGLE_TRACK_ON = BLUE_LIGHT + TOGGLE_TRACK_BORDER = UI_COLOR_TOGGLE_TRACK_BORDER + TOGGLE_KNOB_OFF = UI_COLOR_TOGGLE_KNOB_OFF + TOGGLE_KNOB_ON = FG_ON_ACCENT + COLLAPSIBLE_TITLE_BG = BLUE_DEEP + COLLAPSIBLE_BODY_BG = BLUE_MID + SUCCESS = UI_COLOR_SUCCESS + WARNING = UI_COLOR_WARNING + ERROR = UI_COLOR_ERROR + ERROR_HOVER = UI_COLOR_ERROR_HOVER + BTN_DISABLED_BG = UI_COLOR_BTN_DISABLED_BG + BTN_DISABLED_FG = UI_COLOR_BTN_DISABLED_FG + BTN_DISABLED_BORDER = UI_COLOR_BTN_DISABLED_BORDER + # 航迹视窗自由/锁定钮(叠 CHART_WELL) + TRACK_LOCK_FREE_BG = UI_COLOR_TRACK_LOCK_FREE_BG + TRACK_LOCK_FREE_FG = UI_COLOR_TRACK_LOCK_FREE_FG + TRACK_LOCK_FREE_BORDER = UI_COLOR_TRACK_LOCK_FREE_BORDER + TRACK_LOCK_FREE_HOVER_BG = UI_COLOR_TRACK_LOCK_FREE_HOVER_BG + TRACK_LOCK_FREE_HOVER_BORDER = UI_COLOR_TRACK_LOCK_FREE_HOVER_BORDER + TRACK_LOCK_ON_BG = UI_COLOR_TRACK_LOCK_ON_BG + TRACK_LOCK_ON_FG = UI_COLOR_TRACK_LOCK_ON_FG + TRACK_LOCK_ON_BORDER = UI_COLOR_TRACK_LOCK_ON_BORDER + TRACK_LOCK_ON_HOVER_BG = UI_COLOR_TRACK_LOCK_ON_HOVER_BG + TRACK_LOCK_ON_HOVER_BORDER = UI_COLOR_TRACK_LOCK_ON_HOVER_BORDER + FLASH_BLUE = UI_COLOR_FLASH_BLUE + DATA_CURRENT = UI_COLOR_DATA_CURRENT + DATA_HISTORY = UI_COLOR_DATA_HISTORY + CHART_FLOAT_DEFAULT = UI_COLOR_CHART_FLOAT_DEFAULT + CHART_TORQUE = UI_COLOR_CHART_TORQUE + CHART_HEALTH = UI_COLOR_CHART_HEALTH + CHART_AIRSPEED = UI_COLOR_CHART_AIRSPEED + CHART_GROUNDSPEED = UI_COLOR_CHART_GROUNDSPEED + CHART_SPEED_RATE = UI_COLOR_CHART_SPEED_RATE + CHART_ACCEL_RATE = UI_COLOR_CHART_ACCEL_RATE + CHART_PITCH = UI_COLOR_CHART_PITCH + CHART_BANK = UI_COLOR_CHART_BANK + CHART_ETA_PROP = UI_COLOR_CHART_ETA_PROP + CHART_DI_PROXY = UI_COLOR_CHART_DI_PROXY + CHART_VG_COURSE = UI_COLOR_CHART_VG_COURSE + CHART_REFERENCE = UI_COLOR_CHART_REFERENCE + CHART_LAP_COLORS = UI_COLOR_CHART_LAP + CHART_HEALTH_RED_FILL = UI_COLOR_ERROR + CHART_HEALTH_ORANGE_FILL = UI_COLOR_WARNING + CHART_HEALTH_BLUE_FILL = UI_COLOR_FLASH_BLUE + CHART_HEALTH_SPAN_ALPHA = UI_CHART_HEALTH_SPAN_ALPHA + SIGNAL_IDLE = UI_COLOR_SIGNAL_IDLE + SIGNAL_RUN = UI_COLOR_SIGNAL_RUN + SIGNAL_OK = UI_COLOR_SIGNAL_OK + SIGNAL_FAIL = UI_COLOR_SIGNAL_FAIL + + FONT_FAMILY = UI_FONT_FAMILY + FONT_FAMILY_TECH = UI_FONT_FAMILY_TECH + FONT_XS = UI_FONT_XS + FONT_SM = UI_FONT_SM + FONT_MD = UI_FONT_MD + FONT_LG = UI_FONT_LG + FONT_METRIC_TITLE = UI_FONT_METRIC_TITLE + FONT_TITLE = UI_FONT_TITLE + FONT_CHT = UI_FONT_CHT + FONT_LAP_TOTAL = UI_FONT_LAP_TOTAL + FONT_RACE_SUCCESS = UI_FONT_RACE_SUCCESS + FONT_UNIT = UI_FONT_UNIT + FONT_HINT = UI_FONT_HINT + FONT_CONTROLLER_SETTINGS = UI_FONT_CONTROLLER_SETTINGS + CONTROLLER_SETTINGS_BTN_PAD_V = UI_CONTROLLER_SETTINGS_BTN_PAD_V + CONTROLLER_SETTINGS_BTN_PAD_H = UI_CONTROLLER_SETTINGS_BTN_PAD_H + + _fonts_loaded = False + _scale = 1.0 + + @classmethod + def resource_root(cls) -> Path: + if getattr(sys, "frozen", False): + return Path(sys._MEIPASS) + # components/Frame/Frame_Theme_UI.py → project root + return Path(__file__).resolve().parent.parent.parent + + @classmethod + def fonts_dir(cls) -> Path: + return cls.resource_root() / "resources" / "fonts" + + @classmethod + def icons_dir(cls) -> Path: + return cls.resource_root() / "resources" / "icons" + + @classmethod + def combo_arrow_url(cls) -> str: + """QSS url() 用下拉箭头资源路径(正斜杠)。""" + return (cls.icons_dir() / "combo_down_arrow.png").as_posix() + + @classmethod + def load_fonts(cls) -> None: + if cls._fonts_loaded: + return + font_dir = cls.fonts_dir() + for name in ( + "NotoSansSC-Regular.ttf", + "NotoSansSC-Bold.ttf", + "Orbitron-Medium.ttf", + "Orbitron-Bold.ttf", + ): + path = font_dir / name + if path.is_file(): + QFontDatabase.addApplicationFont(str(path)) + families = QFontDatabase().families() + if "Noto Sans SC" in families: + cls.FONT_FAMILY = "Noto Sans SC" + if "Orbitron" in families: + cls.FONT_FAMILY_TECH = "Orbitron" + cls._fonts_loaded = True + + @classmethod + def apply_scale(cls, window_w, window_h, design_w, design_h) -> None: + if design_w <= 0 or design_h <= 0: + cls._scale = 1.0 + return + cls._scale = min(window_w / float(design_w), window_h / float(design_h)) + + @classmethod + def scaled(cls, value, minimum=0) -> int: + scaled = int(round(value * cls._scale)) + return max(minimum, scaled) if minimum else scaled + + @classmethod + def border_css(cls) -> str: + """统一边框:width + solid + color。""" + return f"{cls.BORDER_WIDTH}px solid {cls.BORDER}" + + @classmethod + def btn_disabled_css(cls) -> str: + """按钮非激活态(相对常见布局底可辨:填充+描边+静音色)。""" + return ( + f"background-color: {cls.BTN_DISABLED_BG}; " + f"color: {cls.BTN_DISABLED_FG}; " + f"border: {cls.BORDER_WIDTH}px solid {cls.BTN_DISABLED_BORDER};" + ) + + @classmethod + def track_lock_btn_qss(cls) -> str: + """ + 航迹自由/锁定钮完整 QSS(挂到按钮自身)。 + 避免父级 PlotWidget 无选择器 stylesheet 污染子控件边框/底色。 + """ + bw = cls.BORDER_WIDTH + br = cls.BORDER_RADIUS + return f""" + QPushButton#TrackViewLockBtn {{ + padding: 4px 8px; + font-size: {cls.FONT_SM}pt; + font-family: "{cls.FONT_FAMILY}"; + background-color: {cls.TRACK_LOCK_FREE_BG}; + color: {cls.TRACK_LOCK_FREE_FG}; + border: {bw}px solid {cls.TRACK_LOCK_FREE_BORDER}; + border-radius: {br}px; + }} + QPushButton#TrackViewLockBtn[lockMode="off"] {{ + background-color: {cls.TRACK_LOCK_FREE_BG}; + color: {cls.TRACK_LOCK_FREE_FG}; + border: {bw}px solid {cls.TRACK_LOCK_FREE_BORDER}; + }} + QPushButton#TrackViewLockBtn:hover:enabled, + QPushButton#TrackViewLockBtn[lockMode="off"]:hover:enabled {{ + background-color: {cls.TRACK_LOCK_FREE_HOVER_BG}; + color: {cls.FG_ON_ACCENT}; + border: {bw}px solid {cls.TRACK_LOCK_FREE_HOVER_BORDER}; + }} + QPushButton#TrackViewLockBtn:pressed:enabled, + QPushButton#TrackViewLockBtn[lockMode="off"]:pressed:enabled {{ + background-color: {cls.TRACK_LOCK_FREE_HOVER_BORDER}; + color: {cls.FG_ON_ACCENT}; + border: {bw}px solid {cls.TRACK_LOCK_FREE_HOVER_BORDER}; + }} + QPushButton#TrackViewLockBtn:checked, + QPushButton#TrackViewLockBtn[lockMode="on"] {{ + background-color: {cls.TRACK_LOCK_ON_BG}; + color: {cls.TRACK_LOCK_ON_FG}; + border: {bw}px solid {cls.TRACK_LOCK_ON_BORDER}; + }} + QPushButton#TrackViewLockBtn:checked:hover:enabled, + QPushButton#TrackViewLockBtn[lockMode="on"]:hover:enabled {{ + background-color: {cls.TRACK_LOCK_ON_HOVER_BG}; + color: {cls.TRACK_LOCK_ON_FG}; + border: {bw}px solid {cls.TRACK_LOCK_ON_HOVER_BORDER}; + }} + QPushButton#TrackViewLockBtn:checked:pressed:enabled, + QPushButton#TrackViewLockBtn[lockMode="on"]:pressed:enabled {{ + background-color: {cls.TRACK_LOCK_ON_BG}; + color: {cls.TRACK_LOCK_ON_FG}; + border: {bw}px solid {cls.TRACK_LOCK_ON_HOVER_BORDER}; + }} + QPushButton#TrackViewLockBtn:disabled {{ + {cls.btn_disabled_css()} + }} + """ + + @classmethod + def metric_border_css(cls) -> str: + """MetricCard 特例边框。""" + return f"{cls.METRIC_BORDER_WIDTH}px solid {cls.METRIC_BORDER}" + + @classmethod + def font(cls, size: int, bold: bool = False) -> QFont: + cls.load_fonts() + f = QFont(cls.FONT_FAMILY, size) + f.setBold(bold) + return f + + @classmethod + def font_tech(cls, size: int, bold: bool = False) -> QFont: + cls.load_fonts() + f = QFont(cls.FONT_FAMILY_TECH, size) + f.setBold(bold) + return f + + @classmethod + def qss(cls) -> str: + cls.load_fonts() + return f""" + QWidget {{ + background-color: {cls.BG}; + color: {cls.FG}; + font-family: "{cls.FONT_FAMILY}"; + font-size: {cls.FONT_MD}pt; + }} + QLabel {{ + background: transparent; + }} + QMainWindow, QDialog {{ + background-color: {cls.BG}; + }} + QFrame#Card, QFrame#Surface {{ + background-color: {cls.SURFACE}; + border: {cls.border_css()}; + border-radius: {cls.BORDER_RADIUS}px; + }} + QFrame#LapBoardCard {{ + background-color: {cls.BLUE_LIGHT}; + border: {cls.border_css()}; + border-radius: {cls.BORDER_RADIUS}px; + }} + QFrame#LapBoardCard QWidget#LapBoardInfo {{ + background: transparent; + }} + QFrame#MetricCard {{ + background-color: {cls.METRIC_SURFACE}; + border: {cls.metric_border_css()}; + border-radius: {cls.METRIC_BORDER_RADIUS}px; + }} + QFrame#MetricCard QLabel#MetricCardTitle {{ + color: {cls.FG_TITLE}; + font-size: {cls.FONT_METRIC_TITLE}pt; + font-weight: bold; + background: transparent; + }} + QFrame#MetricCard QComboBox#MetricCardTitle {{ + color: {cls.FG_TITLE}; + font-size: {cls.FONT_METRIC_TITLE}pt; + font-weight: bold; + background: transparent; + border: none; + padding: 0px 4px; + min-height: {cls.scaled(22, 18)}px; + }} + QFrame#MetricCard QComboBox#MetricCardTitle::drop-down {{ + border: none; + width: 16px; + }} + QFrame#MetricCard QComboBox#MetricCardTitle QAbstractItemView {{ + background-color: {cls.SURFACE}; + color: {cls.FG}; + selection-background-color: {cls.ACCENT}; + border: {cls.border_css()}; + }} + QWidget#ActionBar, QWidget#ActionBarRow, QWidget#MonitorSlot, + QWidget#Container_Bar_Action, QWidget#Container_Bar_ActionRow {{ + background: transparent; + }} + QFrame#CollapsibleBox {{ + background-color: {cls.COLLAPSIBLE_BODY_BG}; + border: {cls.border_css()}; + border-radius: {cls.BORDER_RADIUS}px; + }} + QFrame#CollapsibleBox[collapsed="true"] {{ + border-radius: {cls.BORDER_RADIUS}px; + }} + QFrame#CollapsibleTitle {{ + background-color: {cls.COLLAPSIBLE_BODY_BG}; + border: none; + border-top-left-radius: {cls.BORDER_RADIUS}px; + border-top-right-radius: {cls.BORDER_RADIUS}px; + }} + QFrame#CollapsibleBox[collapsed="true"] QFrame#CollapsibleTitle {{ + border-bottom-left-radius: {cls.BORDER_RADIUS}px; + border-bottom-right-radius: {cls.BORDER_RADIUS}px; + }} + QFrame#CollapsibleBody {{ + background-color: {cls.COLLAPSIBLE_TITLE_BG}; + border: none; + border-bottom-left-radius: {cls.BORDER_RADIUS}px; + border-bottom-right-radius: {cls.BORDER_RADIUS}px; + }} + QPushButton#CollapsibleHeader {{ + background: transparent; + color: {cls.FG}; + border: none; + padding: 0 8px; + text-align: left; + font-size: {cls.FONT_MD}pt; + }} + QPushButton#CollapsibleHeader:hover:enabled {{ + color: {cls.ACCENT_HOVER}; + }} + QPushButton#CollapsibleHeader:disabled {{ + color: {cls.FG_MUTED}; + }} + QLabel#DialogTitle {{ + color: {cls.FG_TITLE}; + font-size: {cls.FONT_LG}pt; + background: transparent; + }} + QFrame#SurfaceFlat {{ + background-color: {cls.SURFACE}; + border: none; + }} + QFrame#CheckListCard {{ + background-color: {cls.SURFACE}; + border: {cls.border_css()}; + border-radius: {cls.BORDER_RADIUS}px; + }} + QLabel#Title {{ + color: {cls.FG_TITLE}; + font-size: {cls.FONT_TITLE}pt; + font-weight: bold; + background: transparent; + }} + QLabel#Subtitle {{ + color: {cls.FG_MUTED}; + font-size: {cls.FONT_LG}pt; + background: transparent; + }} + QLabel#Muted, QLabel#ChartHint {{ + color: {cls.FG_MUTED}; + font-size: {cls.FONT_SM}pt; + background: transparent; + }} + QLabel#MetricTitle {{ + color: {cls.FG_TITLE}; + font-size: {cls.FONT_MD}pt; + background: transparent; + }} + QLabel#MetricValue, QLabel#CHTValue, QLabel#InfoValue {{ + color: {cls.FG}; + font-family: "{cls.FONT_FAMILY_TECH}"; + font-size: {cls.FONT_CHT}pt; + background: transparent; + }} + QLabel#InfoValue {{ + font-size: {cls.FONT_LG}pt; + }} + QLabel#SessionModeLabel {{ + font-family: "{cls.FONT_FAMILY_TECH}"; + font-size: {cls.FONT_LG}pt; + background: transparent; + }} + QLabel#SessionMissionLabel {{ + font-family: "{cls.FONT_FAMILY}"; + font-size: {cls.FONT_MD}pt; + background: transparent; + color: {cls.FG}; + }} + QPushButton#MissionSwitchButton {{ + background-color: {cls.ERROR}; + color: {cls.FG_ON_ACCENT}; + border: none; + border-radius: 3px; + padding: 4px; + font-family: "{cls.FONT_FAMILY}"; + font-size: {cls.FONT_LG}pt; + min-width: 0px; + min-height: 0px; + }} + QPushButton#MissionSwitchButton:hover:enabled {{ + background-color: {cls.ERROR_HOVER}; + }} + QPushButton#MissionSwitchButton:pressed:enabled {{ + background-color: {cls.ERROR_HOVER}; + }} + QPushButton#MissionSwitchButton:disabled {{ + {cls.btn_disabled_css()} + }} + QPushButton#MissionLoadButton {{ + background-color: {cls.ERROR}; + color: {cls.FG_ON_ACCENT}; + border: none; + border-radius: 3px; + padding: 8px 18px; + font-size: {cls.FONT_LG}pt; + }} + QPushButton#MissionLoadButton:hover:enabled {{ + background-color: {cls.ERROR_HOVER}; + }} + QPushButton#MissionLoadButton:pressed:enabled {{ + background-color: {cls.ERROR_HOVER}; + }} + QPushButton#MissionLoadButton:disabled {{ + {cls.btn_disabled_css()} + }} + QListWidget#MissionSwitchList {{ + background-color: {cls.SURFACE}; + color: {cls.FG}; + border: {cls.border_css()}; + border-radius: {cls.BORDER_RADIUS}px; + padding: 4px; + }} + QListWidget#MissionSwitchList::item {{ + padding: 6px 8px; + }} + QListWidget#MissionSwitchList::item:selected {{ + background-color: {cls.ACCENT}; + color: {cls.FG_ON_ACCENT}; + }} + QLabel#LapTotalValue {{ + color: {cls.FG}; + font-family: "{cls.FONT_FAMILY_TECH}"; + font-size: {cls.FONT_LAP_TOTAL}pt; + font-weight: bold; + background: transparent; + }} + QLabel#MetricUnit {{ + color: {cls.FG_MUTED}; + font-size: {cls.FONT_UNIT}pt; + background: transparent; + }} + QWidget#StatusBar {{ + background: transparent; + }} + QLabel#StatusEvent, QLabel#StatusRate {{ + font-size: {cls.FONT_SM}pt; + background: transparent; + }} + QLabel#CheckWait {{ + color: {cls.FG_MUTED}; + font-size: {cls.FONT_LG}pt; + background: transparent; + }} + QLabel#CheckRun {{ color: {cls.SIGNAL_RUN}; font-size: {cls.FONT_LG}pt; background: transparent; }} + QLabel#CheckOk {{ color: {cls.SIGNAL_OK}; font-size: {cls.FONT_LG}pt; background: transparent; }} + QLabel#CheckFail {{ color: {cls.SIGNAL_FAIL}; font-size: {cls.FONT_LG}pt; background: transparent; }} + QLabel#CheckItemDetail {{ + color: {cls.FG_MUTED}; + font-size: {cls.FONT_MD}pt; + background: transparent; + }} + QLineEdit {{ + background-color: {cls.BG}; + color: {cls.FG}; + border: {cls.border_css()}; + border-radius: {cls.BORDER_RADIUS}px; + padding: 6px 8px; + selection-background-color: {cls.ACCENT}; + }} + QLineEdit:focus {{ + border: {cls.BORDER_WIDTH}px solid {cls.ACCENT_HOVER}; + }} + QPushButton#FlatButton, QPushButton#ChartTool, QPushButton#IconSm {{ + background-color: {cls.ACCENT}; + color: {cls.FG_ON_ACCENT}; + border: none; + border-radius: 3px; + padding: 8px 18px; + font-size: {cls.FONT_LG}pt; + }} + QPushButton#FlatButton:hover, QPushButton#ChartTool:hover, QPushButton#IconSm:hover {{ + background-color: {cls.ACCENT_HOVER}; + }} + QPushButton#FlatButton:checked {{ + background-color: {cls.ACCENT}; + color: {cls.FG_ON_ACCENT}; + }} + QPushButton#FlatButton:checked:hover {{ + background-color: {cls.ACCENT_HOVER}; + }} + QPushButton#FlatButton:disabled, QPushButton#ChartTool:disabled, QPushButton#IconSm:disabled, + QPushButton#IconSmAccent:disabled {{ + {cls.btn_disabled_css()} + }} + QPushButton#ControllerSettingsButton {{ + background-color: {cls.ACCENT}; + color: {cls.FG_ON_ACCENT}; + border: none; + border-radius: 3px; + padding: {cls.CONTROLLER_SETTINGS_BTN_PAD_V}px {cls.CONTROLLER_SETTINGS_BTN_PAD_H}px; + font-size: {cls.FONT_CONTROLLER_SETTINGS}pt; + }} + QPushButton#ControllerSettingsButton:hover {{ + background-color: {cls.ACCENT_HOVER}; + }} + QPushButton#ControllerSettingsButton:disabled {{ + {cls.btn_disabled_css()} + }} + QPushButton#IconSm, QPushButton#IconSmAccent {{ + padding: 4px 10px; + font-size: {cls.FONT_MD}pt; + min-width: 28px; + }} + QPushButton#IconSmAccent {{ + background-color: {cls.FLASH_BLUE}; + color: {cls.FG_ON_ACCENT}; + border: none; + border-radius: 3px; + }} + QPushButton#ChartTool {{ + padding: 4px 12px; + font-size: {cls.FONT_SM}pt; + }} + {cls.track_lock_btn_qss()} + QPushButton#MonitorButton {{ + border: none; + border-radius: 3px; + padding: 8px 18px; + font-size: {cls.FONT_LG}pt; + color: {cls.FG_ON_ACCENT}; + }} + QPushButton#MonitorButton[monitorKind="stop"] {{ + background-color: {cls.WARNING}; + }} + QPushButton#MonitorButton[monitorKind="stop"]:hover {{ + background-color: #e67e22; + }} + QPushButton#MonitorButton[monitorKind="save"] {{ + background-color: {cls.ERROR}; + }} + QPushButton#MonitorButton[monitorKind="save"]:hover {{ + background-color: #c0392b; + }} + QPushButton#MonitorButton:disabled, + QPushButton#MonitorButton[monitorKind="stop"]:disabled, + QPushButton#MonitorButton[monitorKind="save"]:disabled {{ + {cls.btn_disabled_css()} + }} + QChartView {{ + background-color: {cls.SURFACE}; + border: none; + }} + /* 全局下拉:显式箭头,避免自定义底色吞掉原生指示 */ + QComboBox {{ + background-color: {cls.SURFACE}; + color: {cls.FG}; + border: {cls.border_css()}; + border-radius: {cls.BORDER_RADIUS}px; + padding: 2px 22px 2px 6px; + min-height: 20px; + }} + QComboBox::drop-down {{ + subcontrol-origin: padding; + subcontrol-position: top right; + width: 18px; + border: none; + background: transparent; + }} + QComboBox::down-arrow {{ + image: url("{cls.combo_arrow_url()}"); + width: 10px; + height: 6px; + }} + QComboBox QAbstractItemView {{ + background-color: {cls.SURFACE}; + color: {cls.FG}; + border: {cls.border_css()}; + selection-background-color: {cls.ACCENT}; + }} + /* 图表嵌套控件:相对 SURFACE 的预计算井区色 + 边框 */ + QWidget#ChartToolbar QComboBox {{ + background-color: {cls.CHART_WELL}; + color: {cls.FG}; + border: {cls.border_css()}; + border-radius: {cls.BORDER_RADIUS}px; + padding: 2px 22px 2px 6px; + min-height: 20px; + }} + QWidget#TrackColorToolbar QComboBox {{ + background-color: {cls.CHART_WELL}; + color: {cls.FG}; + border: {cls.border_css()}; + border-radius: {cls.BORDER_RADIUS}px; + padding: 2px 22px 2px 6px; + min-height: 20px; + }} + QWidget#CompareStylePanel QComboBox {{ + background-color: {cls.SURFACE}; + color: {cls.FG}; + border: {cls.border_css()}; + border-radius: {cls.BORDER_RADIUS}px; + padding: 2px 22px 2px 6px; + min-height: 20px; + }} + QWidget#ChartToolbar QComboBox QAbstractItemView {{ + background-color: {cls.CHART_WELL}; + color: {cls.FG}; + border: {cls.border_css()}; + selection-background-color: {cls.ACCENT}; + }} + QWidget#TrackColorToolbar QComboBox QAbstractItemView {{ + background-color: {cls.CHART_WELL}; + color: {cls.FG}; + border: {cls.border_css()}; + selection-background-color: {cls.ACCENT}; + }} + QWidget#CompareStylePanel QComboBox QAbstractItemView {{ + background-color: {cls.SURFACE}; + color: {cls.FG}; + border: {cls.border_css()}; + selection-background-color: {cls.ACCENT}; + }} + QWidget#ChartToolbar QLabel#ChartHint, + QWidget#TrackColorToolbar QLabel#ChartHint {{ + color: {cls.FG}; + background-color: {cls.SURFACE}; + }} + /* 全局复选框:选中为亮橙色;圆角为 CheckBox 特例 */ + QCheckBox::indicator {{ + width: 14px; + height: 14px; + background-color: {cls.CHART_WELL}; + border: {cls.border_css()}; + border-radius: {cls.CHECKBOX_BORDER_RADIUS}px; + }} + QCheckBox::indicator:checked {{ + background-color: {cls.WARNING}; + border: {cls.BORDER_WIDTH}px solid {cls.WARNING}; + }} + QWidget#ChartToolbar, + QWidget#TrackColorToolbar {{ + background-color: {cls.SURFACE}; + border: none; + }} + QWidget#ChartToolbarCell, + QWidget#TrackColorToolbarCell {{ + background-color: {cls.SURFACE}; + border: none; + }} + QWidget#ChartToolbar QLabel#ChartHint, + QWidget#TrackColorToolbar QLabel#ChartHint {{ + background-color: {cls.SURFACE}; + color: {cls.FG_MUTED}; + }} + QWidget#ChartToolbar QCheckBox, + QWidget#TrackColorToolbar QCheckBox {{ + background-color: {cls.SURFACE}; + color: {cls.FG}; + border: none; + }} + QWidget#ChartToolbar QCheckBox::indicator, + QWidget#TrackColorToolbar QCheckBox::indicator {{ + width: 14px; + height: 14px; + background-color: {cls.CHART_WELL}; + border: {cls.border_css()}; + border-radius: {cls.CHECKBOX_BORDER_RADIUS}px; + }} + QWidget#ChartToolbar QCheckBox::indicator:checked, + QWidget#TrackColorToolbar QCheckBox::indicator:checked {{ + background-color: {cls.WARNING}; + border: {cls.BORDER_WIDTH}px solid {cls.WARNING}; + }} + QWidget#ChartSlot {{ + background-color: {cls.CHART_WELL}; + border: {cls.border_css()}; + border-radius: {cls.BORDER_RADIUS}px; + }} + QWidget#TrackMapPane {{ + background-color: {cls.CHART_WELL}; + border: {cls.border_css()}; + border-radius: {cls.BORDER_RADIUS}px; + }} + QWidget#TrackMapCanvas {{ + background-color: {cls.CHART_WELL}; + border: none; + }} + QWidget#StickInputPane {{ + background-color: {cls.CHART_WELL}; + border: {cls.border_css()}; + border-radius: {cls.BORDER_RADIUS}px; + }} + QWidget#CompareStylePanel {{ + background-color: {cls.CHART_WELL}; + border: {cls.border_css()}; + border-radius: {cls.BORDER_RADIUS}px; + padding: 4px 6px; + }} + QWidget#ChartDisplayArea, + QWidget#TrendChart {{ + background-color: {cls.SURFACE}; + border: none; + }} + """ + + +class WindowGeom: + @classmethod + def screen_size(cls) -> tuple[int, int]: + screen = QApplication.primaryScreen() + if screen is None: + return tuple(UI_SCREEN_FALLBACK) + geo = screen.availableGeometry() + return geo.width(), geo.height() + + @classmethod + def main_window_size(cls) -> tuple[int, int]: + sw, sh = cls.screen_size() + return sw // 3, sh * 3 // 5 + UI_MAIN_WINDOW_H_EXTRA + + @classmethod + def auth_window_size(cls) -> tuple[int, int]: + return cls.scale_dialog_size(*UI_AUTH_WINDOW_BASE) + + @classmethod + def scale_dialog_size(cls, base_w: int, base_h: int) -> tuple[int, int]: + """对话框尺寸不超过屏幕比例范围,并按需缩小(对齐原 Tk 逻辑)。""" + sw, sh = cls.screen_size() + max_w, max_h = sw // 3, sh * 3 // 5 + UI_MAIN_WINDOW_H_EXTRA + scale = min(max_w / base_w, max_h / base_h, 1.0) + min_w, min_h = UI_DIALOG_MIN + return max(min_w, int(base_w * scale)), max(min_h, int(base_h * scale)) + + @classmethod + def feature_window_size(cls, feature_id: str) -> Optional[tuple[int, int, int, int]]: + spec = FEATURE_WINDOW_SPECS.get(feature_id) + if spec is None: + return None + design_w, design_h, base_w, base_h = spec + if feature_id == "training_assist": + w, h = cls.main_window_size() + else: + w, h = cls.scale_dialog_size(base_w, base_h) + return w, h, design_w, design_h + + @classmethod + def center_on_screen(cls, widget: QWidget, width: int, height: int) -> None: + sw, sh = cls.screen_size() + x = max(0, (sw - width) // 2) + y = max(0, (sh - height) // 2) + widget.setGeometry(x, y, width, height) + + +_APP_ICON: Optional[QIcon] = None + + +def app_icon() -> QIcon: + global _APP_ICON + if _APP_ICON is None: + path = UITheme.resource_root() / APP_ICON_PATH + _APP_ICON = QIcon(str(path)) if path.is_file() else QIcon() + return _APP_ICON + + +def apply_window_icon(widget: QWidget) -> None: + widget.setWindowIcon(app_icon()) + + +def _hex_to_colorref(hex_color: str) -> int: + h = str(hex_color).lstrip("#") + if len(h) != 6: + return 0 + r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) + return r | (g << 8) | (b << 16) + + +def apply_window_titlebar_theme(widget: QWidget, bg_hex=None, fg_hex=None) -> None: + """ + 仅用 DwmSetWindowAttribute 改标题栏色(非 QtWin / 非 SetWindowLong)。 + 严禁自定义原生非客户区控件:125%+ 缩放下易出现点击失效(PySide2 固有风险)。 + 此处需人工验证:各缩放比例下标题栏拖拽/最小化/关闭命中是否正常。 + """ + if sys.platform != "win32": + return + try: + import ctypes + + hwnd = int(widget.winId()) + dwmapi = ctypes.windll.dwmapi + dark = ctypes.c_int(1) + dwmapi.DwmSetWindowAttribute(hwnd, 20, ctypes.byref(dark), ctypes.sizeof(dark)) + bg = _hex_to_colorref(bg_hex or UITheme.BG) + fg = _hex_to_colorref(fg_hex or UITheme.FG) + for attr, color in ((34, bg), (35, bg), (36, fg)): + value = ctypes.c_int(color) + dwmapi.DwmSetWindowAttribute(hwnd, attr, ctypes.byref(value), ctypes.sizeof(value)) + except Exception: + pass + + +def apply_fixed_window(widget: QWidget, width: int, height: int, alpha: float = WINDOW_ALPHA) -> None: + widget.setFixedSize(width, height) + WindowGeom.center_on_screen(widget, width, height) + widget.setWindowOpacity(float(alpha)) + apply_window_icon(widget) + apply_window_titlebar_theme(widget) + + +def apply_window_stays_on_top(widget: QWidget, on: bool = True) -> None: + """ + 切换 Qt.WindowStaysOnTopHint(纯 Qt,非 SetWindowLong)。 + 改 flag 后 Qt 会隐藏窗口,若原先可见则重新 show + raise,避免闪没。 + 用于成绩框默认压模拟器,保存链路期间让位给后级对话框。 + """ + if widget is None: + return + want = bool(on) + has = bool(widget.windowFlags() & Qt.WindowStaysOnTopHint) + if has == want: + if want and widget.isVisible(): + widget.raise_() + widget.activateWindow() + return + visible = widget.isVisible() + widget.setWindowFlag(Qt.WindowStaysOnTopHint, want) + if visible: + widget.show() + widget.raise_() + widget.activateWindow() + apply_window_titlebar_theme(widget) + + +def create_app(argv=None) -> QApplication: + # DPI 属性必须在 QApplication(...) 之前(见 app_bootstrap 注释) + from app_bootstrap import apply_qt_high_dpi + + apply_qt_high_dpi() + # 在任何可能 import pygame 的代码之前写入,避免 SDL 闪窗 + os.environ["SDL_VIDEODRIVER"] = "dummy" + os.environ["SDL_AUDIODRIVER"] = "dummy" + app = QApplication.instance() + if app is None: + app = QApplication(argv or sys.argv) + UITheme.load_fonts() + app.setWindowIcon(app_icon()) + app.setStyleSheet(UITheme.qss()) + return app diff --git a/components/Frame/__init__.py b/components/Frame/__init__.py new file mode 100644 index 00000000..40a96afc --- /dev/null +++ b/components/Frame/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/components/__init__.py b/components/__init__.py new file mode 100644 index 00000000..40a96afc --- /dev/null +++ b/components/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/components/overlay/__init__.py b/components/overlay/__init__.py new file mode 100644 index 00000000..40a96afc --- /dev/null +++ b/components/overlay/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/components/overlay/overlay_Global_Base.py b/components/overlay/overlay_Global_Base.py new file mode 100644 index 00000000..a305c460 --- /dev/null +++ b/components/overlay/overlay_Global_Base.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +"""模块:overlay_Global_Base — 全局蒙版挂载点(默认隐藏)。""" + +from __future__ import annotations + +from PySide2.QtCore import Qt +from PySide2.QtGui import QColor +from PySide2.QtWidgets import QWidget + +from components.Frame.Frame_Theme_UI import UITheme + + +class overlay_Global_Base(QWidget): + """尺寸跟随主窗;半透明底,本轮不接业务遮罩。""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("overlay_Global_Base") + self.setAttribute(Qt.WA_StyledBackground, True) + c = QColor(UITheme.BG) + c.setAlpha(120) + self.setStyleSheet( + f"background-color: rgba({c.red()}, {c.green()}, {c.blue()}, {c.alpha()});" + ) + self.setVisible(False) + if parent is not None: + self.resize(parent.size()) + self.raise_() diff --git a/components/overlay/overlay_Local_Base.py b/components/overlay/overlay_Local_Base.py new file mode 100644 index 00000000..9ba37c28 --- /dev/null +++ b/components/overlay/overlay_Local_Base.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +"""模块:overlay_Local_Base — 容器级本地蒙版挂载点(默认隐藏)。""" + +from __future__ import annotations + +from PySide2.QtCore import Qt +from PySide2.QtGui import QColor +from PySide2.QtWidgets import QWidget + +from components.Frame.Frame_Theme_UI import UITheme + + +class overlay_Local_Base(QWidget): + """覆盖指定 Container;本轮不接业务遮罩。""" + + def __init__(self, parent: QWidget = None): + super().__init__(parent) + self.setObjectName("overlay_Local_Base") + self.setAttribute(Qt.WA_StyledBackground, True) + c = QColor(UITheme.BG) + c.setAlpha(100) + self.setStyleSheet( + f"background-color: rgba({c.red()}, {c.green()}, {c.blue()}, {c.alpha()});" + ) + self.setVisible(False) + if parent is not None: + self.setGeometry(parent.rect()) + self.raise_() diff --git a/components/widget/__init__.py b/components/widget/__init__.py new file mode 100644 index 00000000..40a96afc --- /dev/null +++ b/components/widget/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/components/widget/widget_Bar_Status.py b/components/widget/widget_Bar_Status.py new file mode 100644 index 00000000..f92e475e --- /dev/null +++ b/components/widget/widget_Bar_Status.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +"""模块:widget_Bar_Status — 原 StatusBar。""" + +from __future__ import annotations + +from PySide2.QtCore import Qt +from PySide2.QtWidgets import QHBoxLayout, QLabel, QWidget + +from components.Frame.Frame_Theme_UI import status_kind_color + +from setting import SPACE_FRAME_GAP, SPACE_NONE + + +class widget_Bar_Status(QWidget): + """同一行两列:左事件提示(左对齐),右倍速提示(右对齐)。""" + + def __init__(self, text: str = "", parent=None): + super().__init__(parent) + self.setObjectName("widget_Bar_Status") + self.setAutoFillBackground(False) + row = QHBoxLayout(self) + row.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + row.setSpacing(SPACE_FRAME_GAP) + + self._event = QLabel("") + self._event.setObjectName("StatusEvent") + self._event.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + self._event.setWordWrap(False) + + self._rate = QLabel("") + self._rate.setObjectName("StatusRate") + self._rate.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self._rate.setWordWrap(False) + + row.addWidget(self._event, 1) + row.addWidget(self._rate, 1) + self.set_status(text or "", "muted") + + def set_status(self, text: str, kind: str = "muted") -> None: + self._event.setText(text) + self._event.setStyleSheet(f"color: {status_kind_color(kind)}; background: transparent;") + + def set_rate_status(self, text: str, kind: str = "muted") -> None: + self._rate.setText(text) + self._rate.setStyleSheet(f"color: {status_kind_color(kind)}; background: transparent;") diff --git a/components/widget/widget_Btn_TrackViewLock.py b/components/widget/widget_Btn_TrackViewLock.py new file mode 100644 index 00000000..0ba2bbaa --- /dev/null +++ b/components/widget/widget_Btn_TrackViewLock.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +""" +模块:components.widget.widget_Btn_TrackViewLock +职责:航迹图视窗二态锁定按钮(自由 ↔ 锁定) +依赖:UITheme、setting.TRACK_VIEW_LOCK_* +""" + +from __future__ import annotations + +from PySide2.QtCore import Qt, Signal +from PySide2.QtWidgets import QPushButton + +from components.Frame.Frame_Theme_UI import UITheme +from setting import ( + TRACK_VIEW_LOCK_LABELS, + TRACK_VIEW_LOCK_ON, + TRACK_VIEW_LOCK_OFF, + TRACK_VIEW_LOCK_BTN_H, + TRACK_VIEW_LOCK_BTN_W, +) + + +class widget_Btn_TrackViewLock(QPushButton): + """左下角二态按钮;点击切换自由/锁定,发出 modeChanged(mode)。""" + + modeChanged = Signal(int) + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("TrackViewLockBtn") + self.setCursor(Qt.PointingHandCursor) + self.setFocusPolicy(Qt.NoFocus) + self.setCheckable(True) + self.setAutoDefault(False) + self.setDefault(False) + self.setFlat(False) + self.setAttribute(Qt.WA_Hover, True) + self.setAttribute(Qt.WA_StyledBackground, True) + self.setMouseTracking(True) + self.setFixedSize( + UITheme.scaled(TRACK_VIEW_LOCK_BTN_W, 44), + UITheme.scaled(TRACK_VIEW_LOCK_BTN_H, 28), + ) + # 自身完整 QSS:父 PlotWidget 若写无选择器 stylesheet 不会冲掉本钮 + self.setStyleSheet(UITheme.track_lock_btn_qss()) + self._mode = int(TRACK_VIEW_LOCK_OFF) + self.clicked.connect(self._on_clicked) + self._sync_chrome() + + def mode(self) -> int: + return int(self._mode) + + def set_mode(self, mode: int, *, emit: bool = False) -> None: + m = int(TRACK_VIEW_LOCK_ON if int(mode) == TRACK_VIEW_LOCK_ON else TRACK_VIEW_LOCK_OFF) + if m == self._mode: + self._sync_chrome() + return + self._mode = m + self._sync_chrome() + if emit: + self.modeChanged.emit(self._mode) + + def _on_clicked(self) -> None: + # checkable 点击后 isChecked 已翻转;对齐 mode + self._mode = ( + int(TRACK_VIEW_LOCK_ON) if self.isChecked() else int(TRACK_VIEW_LOCK_OFF) + ) + self._sync_chrome() + self.modeChanged.emit(self._mode) + + def _sync_chrome(self) -> None: + locked = self._mode == TRACK_VIEW_LOCK_ON + labels = TRACK_VIEW_LOCK_LABELS + self.setText(str(labels[1] if locked else labels[0])) + self.setChecked(locked) + self.setToolTip( + "视窗:自由 ↔ 锁定(正北居中)\n" + "锁定:禁止拖动,视野自动覆盖轨迹;自由:可拖动查看" + ) + # 双态属性驱动 QSS(自由=off 描边底,锁定=on 蓝底);悬停另有加亮 + self.setProperty("lockMode", "on" if locked else "off") + # 动态属性变更后须重挂样式,否则 Qt 不重算属性选择器 + self.setStyleSheet(UITheme.track_lock_btn_qss()) + self.style().unpolish(self) + self.style().polish(self) + self.update() diff --git a/components/widget/widget_Light_Signal.py b/components/widget/widget_Light_Signal.py new file mode 100644 index 00000000..067e4554 --- /dev/null +++ b/components/widget/widget_Light_Signal.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +"""模块:widget_Light_Signal — 原 SignalLightIndicator。""" + +from __future__ import annotations + +from PySide2.QtCore import Qt +from PySide2.QtGui import QColor, QPainter +from PySide2.QtWidgets import QWidget + +from components.Frame.Frame_Theme_UI import UITheme + + +class widget_Light_Signal(QWidget): + """扁平圆形信号灯:idle 熄灭,run 蓝,ok 绿,fail 红。""" + + _COLORS = { + "idle": UITheme.SIGNAL_IDLE, + "run": UITheme.SIGNAL_RUN, + "ok": UITheme.SIGNAL_OK, + "fail": UITheme.SIGNAL_FAIL, + } + + def __init__(self, parent=None, diameter: int = 11): + super().__init__(parent) + self._state = "idle" + size = UITheme.scaled(diameter, 9) + self.setFixedSize(size, size) + + def set_state(self, state: str) -> None: + next_state = state if state in self._COLORS else "idle" + if next_state == self._state: + return + self._state = next_state + self.update() + + def paintEvent(self, _event) -> None: + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing, True) + painter.setPen(Qt.NoPen) + painter.setBrush(QColor(self._COLORS[self._state])) + margin = max(1, self.width() // 10) + diameter = min(self.width(), self.height()) - margin * 2 + x = (self.width() - diameter) / 2.0 + y = (self.height() - diameter) / 2.0 + painter.drawEllipse(int(x), int(y), int(diameter), int(diameter)) + painter.end() diff --git a/components/widget/widget_Slider_Round.py b/components/widget/widget_Slider_Round.py new file mode 100644 index 00000000..5fe7ef90 --- /dev/null +++ b/components/widget/widget_Slider_Round.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +"""模块:widget_Slider_Round — 原 RoundSlider。""" + +from __future__ import annotations + +from typing import Optional + +from PySide2.QtCore import Qt, Signal +from PySide2.QtGui import QColor, QMouseEvent, QPainter, QPen +from PySide2.QtWidgets import QWidget + +from components.Frame.Frame_Theme_UI import UITheme + + +class widget_Slider_Round(QWidget): + """主题圆角滑动条(胶囊轨道 + 圆形滑块)。""" + + valueChanged = Signal(float) + + def __init__( + self, + parent=None, + from_: float = 0.0, + to: float = 1.0, + value: float = 0.0, + resolution: float = 0.1, + length: Optional[int] = None, + ): + super().__init__(parent) + self._from = float(from_) + self._to = float(to) + self._resolution = float(resolution) + self._value = self._clamp(value) + width = length if length is not None else UITheme.scaled(320, 220) + height = UITheme.scaled(32, 26) + self.setFixedSize(width, height) + self.setCursor(Qt.PointingHandCursor) + self.setAutoFillBackground(False) + # 禁止 WA_TranslucentBackground:会打穿父级不透明底到更下层 + self.setStyleSheet("background: transparent;") + self._pad_x = UITheme.scaled(12, 10) + self._thumb_r = UITheme.scaled(10, 8) + self._track_h = UITheme.scaled(6, 5) + + def value(self) -> float: + return self._value + + def setValue(self, value: float) -> None: + value = self._clamp(value) + if abs(self._value - value) < 1e-9: + return + self._value = value + self.update() + self.valueChanged.emit(self._value) + + def _clamp(self, v: float) -> float: + span = max(1e-6, self._to - self._from) + v = max(self._from, min(self._to, float(v))) + if self._resolution and self._resolution > 0: + v = round(v / self._resolution) * self._resolution + return max(self._from, min(self._to, v)) + + def _track_bounds(self) -> tuple[float, float, float]: + track_x1 = self._pad_x + self._thumb_r + track_x2 = self.width() - self._pad_x - self._thumb_r + track_y = self.height() // 2 + return track_x1, track_x2, track_y + + def _value_to_x(self, v: float) -> float: + track_x1, track_x2, _ = self._track_bounds() + frac = (self._clamp(v) - self._from) / max(1e-6, self._to - self._from) + return track_x1 + frac * (track_x2 - track_x1) + + def _x_to_value(self, x: float) -> float: + track_x1, track_x2, _ = self._track_bounds() + frac = (x - track_x1) / max(1e-6, track_x2 - track_x1) + frac = max(0.0, min(1.0, frac)) + return self._clamp(self._from + frac * (self._to - self._from)) + + def paintEvent(self, _event) -> None: + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + track_x1, track_x2, track_y = self._track_bounds() + tx = self._value_to_x(self._value) + ty1 = track_y - self._track_h / 2 + r = self._track_h / 2 + # 深色底轨 + 边框,避免在 CHART_WELL 背景上隐没 + p.setPen(QPen(QColor(UITheme.BORDER), float(UITheme.BORDER_WIDTH))) + p.setBrush(QColor(UITheme.BG)) + p.drawRoundedRect(int(track_x1), int(ty1), int(track_x2 - track_x1), int(self._track_h), r, r) + if tx > track_x1 + 1: + p.setPen(Qt.NoPen) + p.setBrush(QColor(UITheme.ACCENT)) + p.drawRoundedRect(int(track_x1), int(ty1), int(tx - track_x1), int(self._track_h), r, r) + p.setPen(QPen(QColor(UITheme.BORDER), float(UITheme.BORDER_WIDTH))) + p.setBrush(QColor(UITheme.FG_ON_ACCENT)) + p.drawEllipse(int(tx - self._thumb_r), int(track_y - self._thumb_r), int(2 * self._thumb_r), int(2 * self._thumb_r)) + + def _apply_pos(self, pos) -> None: + self.setValue(self._x_to_value(pos.x())) + + def mousePressEvent(self, event: QMouseEvent) -> None: + if event.button() == Qt.LeftButton: + self._apply_pos(event.pos()) + + def mouseMoveEvent(self, event: QMouseEvent) -> None: + if event.buttons() & Qt.LeftButton: + self._apply_pos(event.pos()) diff --git a/components/widget/widget_Toggle_Slide.py b/components/widget/widget_Toggle_Slide.py new file mode 100644 index 00000000..44998efb --- /dev/null +++ b/components/widget/widget_Toggle_Slide.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +"""模块:widget_Toggle_Slide — 原 SlideToggle。""" + +from __future__ import annotations + +from PySide2.QtCore import Qt, Signal +from PySide2.QtGui import QColor, QMouseEvent, QPainter, QPen +from PySide2.QtWidgets import QWidget + +from components.Frame.Frame_Theme_UI import UITheme + + +class widget_Toggle_Slide(QWidget): + """主题圆角滑动开关(统一配色,勿在外部硬编码样式)。""" + + toggled = Signal(bool) + + def __init__(self, parent=None, checked: bool = False): + super().__init__(parent) + self._checked = bool(checked) + self._track_w = UITheme.scaled(52, 42) + self._track_h = UITheme.scaled(26, 20) + self.setFixedSize(self._track_w, self._track_h) + self.setCursor(Qt.PointingHandCursor) + self.setAutoFillBackground(False) + + def isChecked(self) -> bool: + return self._checked + + def setChecked(self, checked: bool) -> None: + checked = bool(checked) + if self._checked == checked: + return + self._checked = checked + self.update() + self.toggled.emit(self._checked) + + def paintEvent(self, _event) -> None: + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + p.setPen(Qt.NoPen) + enabled = self.isEnabled() + pad = max(2, UITheme.scaled(3, 2)) + knob_d = self._track_h - 2 * pad + radius = self._track_h / 2 + if self._checked: + track_color = QColor(UITheme.TOGGLE_TRACK_ON) + border_color = None + else: + track_color = QColor(UITheme.TOGGLE_TRACK_OFF) + border_color = QColor(UITheme.TOGGLE_TRACK_BORDER) + if not enabled: + track_color.setAlpha(140) + p.setPen(Qt.NoPen) + p.setBrush(track_color) + p.drawRoundedRect(0, 0, self._track_w, self._track_h, radius, radius) + if border_color is not None: + pen = QPen(border_color) + pen.setWidth(max(1, UITheme.scaled(1, 1))) + p.setPen(pen) + p.setBrush(Qt.NoBrush) + p.drawRoundedRect(0, 0, self._track_w - 1, self._track_h - 1, radius, radius) + kx = (self._track_w - pad - knob_d) if self._checked else pad + knob_color = QColor( + UITheme.TOGGLE_KNOB_ON if self._checked else UITheme.TOGGLE_KNOB_OFF + ) + if not enabled: + knob_color.setAlpha(140) + p.setPen(Qt.NoPen) + p.setBrush(knob_color) + p.drawEllipse(int(kx), pad, int(knob_d), int(knob_d)) + + def mousePressEvent(self, event: QMouseEvent) -> None: + if event.button() == Qt.LeftButton and self.isEnabled(): + self.setChecked(not self._checked) diff --git a/glass_server.py b/glass_server.py deleted file mode 100644 index f5ff0b5e..00000000 --- a/glass_server.py +++ /dev/null @@ -1,464 +0,0 @@ -from flask import Flask, jsonify, render_template, request -from SimConnect import * -import random - -# -# glass_server.py is an example web app which demonstrates how data can be read and set in the simulator -# -# When run this code will start an http server running on http://localhost:5000/ which can be accessed. It includes both -# an HTML/JS front end which can be accessed through a browser and the ability to read/write datapoints and datasets -# via API requests using JSON -# -# The server runs using Flask: https://flask.palletsprojects.com/en/1.1.x/ -# -# This is intended to be a demonstration of the Python-SimConnect library rather than a fully fledged implementation. -# This code has been forked into more fully worked projects including: -# - MSFS 2020 Cockpit Companion: https://msfs2020.cc/ -# - MSFS Mobile Companion App: https://github.com/mracko/MSFS-Mobile-Companion-App -# - - -app = Flask(__name__) - -# SIMCONNECTION RELATED STARTUPS - -# Create simconnection -sm = SimConnect() -ae = AircraftEvents(sm) -aq = AircraftRequests(sm, _time=10) - -# Create request holders -# These are groups of datapoints which it is convenient to call as a group because they fulfill a specific function -request_location = [ - 'ALTITUDE', - 'LATITUDE', - 'LONGITUDE', - 'KOHLSMAN', -] - -request_airspeed = [ - 'AIRSPEED_TRUE', - 'AIRSPEED_INDICATE', - 'AIRSPEED_TRUE CALIBRATE', - 'AIRSPEED_BARBER POLE', - 'AIRSPEED_MACH', -] - -request_compass = [ - 'WISKEY_COMPASS_INDICATION_DEGREES', - 'PARTIAL_PANEL_COMPASS', - 'ADF_CARD', # ADF compass rose setting - 'MAGNETIC_COMPASS', # Compass reading - 'INDUCTOR_COMPASS_PERCENT_DEVIATION', # Inductor compass deviation reading - 'INDUCTOR_COMPASS_HEADING_REF', # Inductor compass heading -] - -request_vertical_speed = [ - 'VELOCITY_BODY_Y', # True vertical speed, relative to aircraft axis - 'RELATIVE_WIND_VELOCITY_BODY_Y', # Vertical speed relative to wind - 'VERTICAL_SPEED', # Vertical speed indication - 'GPS_WP_VERTICAL_SPEED', # Vertical speed to waypoint -] - -request_fuel = [ - 'FUEL_TANK_CENTER_LEVEL', # Percent of maximum capacity - 'FUEL_TANK_CENTER2_LEVEL', # Percent of maximum capacity - 'FUEL_TANK_CENTER3_LEVEL', # Percent of maximum capacity - 'FUEL_TANK_LEFT_MAIN_LEVEL', # Percent of maximum capacity - 'FUEL_TANK_LEFT_AUX_LEVEL', # Percent of maximum capacity - 'FUEL_TANK_LEFT_TIP_LEVEL', # Percent of maximum capacity - 'FUEL_TANK_RIGHT_MAIN_LEVEL', # Percent of maximum capacity - 'FUEL_TANK_RIGHT_AUX_LEVEL', # Percent of maximum capacity - 'FUEL_TANK_RIGHT_TIP_LEVEL', # Percent of maximum capacity - 'FUEL_TANK_EXTERNAL1_LEVEL', # Percent of maximum capacity - 'FUEL_TANK_EXTERNAL2_LEVEL', # Percent of maximum capacity - 'FUEL_TANK_CENTER_CAPACITY', # Maximum capacity in volume - 'FUEL_TANK_CENTER2_CAPACITY', # Maximum capacity in volume - 'FUEL_TANK_CENTER3_CAPACITY', # Maximum capacity in volume - 'FUEL_TANK_LEFT_MAIN_CAPACITY', # Maximum capacity in volume - 'FUEL_TANK_LEFT_AUX_CAPACITY', # Maximum capacity in volume - 'FUEL_TANK_LEFT_TIP_CAPACITY', # Maximum capacity in volume - 'FUEL_TANK_RIGHT_MAIN_CAPACITY', # Maximum capacity in volume - 'FUEL_TANK_RIGHT_AUX_CAPACITY', # Maximum capacity in volume - 'FUEL_TANK_RIGHT_TIP_CAPACITY', # Maximum capacity in volume - 'FUEL_TANK_EXTERNAL1_CAPACITY', # Maximum capacity in volume - 'FUEL_TANK_EXTERNAL2_CAPACITY', # Maximum capacity in volume - 'FUEL_LEFT_CAPACITY', # Maximum capacity in volume - 'FUEL_RIGHT_CAPACITY', # Maximum capacity in volume - 'FUEL_TANK_CENTER_QUANTITY', # Current quantity in volume - 'FUEL_TANK_CENTER2_QUANTITY', # Current quantity in volume - 'FUEL_TANK_CENTER3_QUANTITY', # Current quantity in volume - 'FUEL_TANK_LEFT_MAIN_QUANTITY', # Current quantity in volume - 'FUEL_TANK_LEFT_AUX_QUANTITY', # Current quantity in volume - 'FUEL_TANK_LEFT_TIP_QUANTITY', # Current quantity in volume - 'FUEL_TANK_RIGHT_MAIN_QUANTITY', # Current quantity in volume - 'FUEL_TANK_RIGHT_AUX_QUANTITY', # Current quantity in volume - 'FUEL_TANK_RIGHT_TIP_QUANTITY', # Current quantity in volume - 'FUEL_TANK_EXTERNAL1_QUANTITY', # Current quantity in volume - 'FUEL_TANK_EXTERNAL2_QUANTITY', # Current quantity in volume - 'FUEL_LEFT_QUANTITY', # Current quantity in volume - 'FUEL_RIGHT_QUANTITY', # Current quantity in volume - 'FUEL_TOTAL_QUANTITY', # Current quantity in volume - 'FUEL_WEIGHT_PER_GALLON', # Fuel weight per gallon - 'FUEL_TOTAL_CAPACITY', # Total capacity of the aircraft - 'FUEL_SELECTED_QUANTITY_PERCENT', # Percent or capacity for selected tank - 'FUEL_SELECTED_QUANTITY', # Quantity of selected tank - 'FUEL_TOTAL_QUANTITY_WEIGHT', # Current total fuel weight of the aircraft - 'NUM_FUEL_SELECTORS', # Number of selectors on the aircraft - 'UNLIMITED_FUEL', # Unlimited fuel flag - 'ESTIMATED_FUEL_FLOW', # Estimated fuel flow at cruise -] - -request_flaps = [ - 'FLAPS_HANDLE_PERCENT', # Percent flap handle extended - 'FLAPS_HANDLE_INDEX', # Index of current flap position - 'FLAPS_NUM_HANDLE_POSITIONS', # Number of flap positions - 'TRAILING_EDGE_FLAPS_LEFT_PERCENT', # Percent left trailing edge flap extended - 'TRAILING_EDGE_FLAPS_RIGHT_PERCENT', # Percent right trailing edge flap extended - 'TRAILING_EDGE_FLAPS_LEFT_ANGLE', # Angle left trailing edge flap extended. Use TRAILING EDGE FLAPS LEFT PERCENT to set a value. - 'TRAILING_EDGE_FLAPS_RIGHT_ANGLE', # Angle right trailing edge flap extended. Use TRAILING EDGE FLAPS RIGHT PERCENT to set a value. - 'LEADING_EDGE_FLAPS_LEFT_PERCENT', # Percent left leading edge flap extended - 'LEADING_EDGE_FLAPS_RIGHT_PERCENT', # Percent right leading edge flap extended - 'LEADING_EDGE_FLAPS_LEFT_ANGLE', # Angle left leading edge flap extended. Use LEADING EDGE FLAPS LEFT PERCENT to set a value. - 'LEADING_EDGE_FLAPS_RIGHT_ANGLE', # Angle right leading edge flap extended. Use LEADING EDGE FLAPS RIGHT PERCENT to set a value. - 'FLAPS_AVAILABLE', # True if flaps available - 'FLAP_DAMAGE_BY_SPEED', # True if flagps are damaged by excessive speed - 'FLAP_SPEED_EXCEEDED', # True if safe speed limit for flaps exceeded -] - -request_throttle = [ - 'AUTOPILOT_THROTTLE_ARM', # Autothrottle armed - 'AUTOPILOT_TAKEOFF_POWER_ACTIVE', # Takeoff / Go Around power mode active - 'AUTOTHROTTLE_ACTIVE', # Auto-throttle active - 'FULL_THROTTLE_THRUST_TO_WEIGHT_RATIO', # Full throttle thrust to weight ratio - 'THROTTLE_LOWER_LIMIT', - 'GENERAL_ENG_THROTTLE_LEVER_POSITION:index', # Percent of max throttle position - 'AUTOPILOT_THROTTLE_ARM', # Autothrottle armed - 'AUTOTHROTTLE_ACTIVE', # Auto-throttle active - 'FULL_THROTTLE_THRUST_TO_WEIGHT_RATIO', # Full throttle thrust to weight ratio -] - -request_gear = [ - 'IS_GEAR_RETRACTABLE', # True if gear can be retracted - 'IS_GEAR_SKIS', # True if landing gear is skis - 'IS_GEAR_FLOATS', # True if landing gear is floats - 'IS_GEAR_SKIDS', # True if landing gear is skids - 'IS_GEAR_WHEELS', # True if landing gear is wheels - 'GEAR_HANDLE_POSITION', # True if gear handle is applied - 'GEAR_HYDRAULIC_PRESSURE', # Gear hydraulic pressure - 'TAILWHEEL_LOCK_ON', # True if tailwheel lock applied - 'GEAR_CENTER_POSITION', # Percent center gear extended - 'GEAR_LEFT_POSITION', # Percent left gear extended - 'GEAR_RIGHT_POSITION', # Percent right gear extended - 'GEAR_TAIL_POSITION', # Percent tail gear extended - 'GEAR_AUX_POSITION', # Percent auxiliary gear extended - 'GEAR_TOTAL_PCT_EXTENDED', # Percent total gear extended - 'AUTO_BRAKE_SWITCH_CB', # Auto brake switch position - 'WATER_RUDDER_HANDLE_POSITION', - 'WATER_LEFT_RUDDER_EXTENDED', # Percent extended - 'WATER_RIGHT_RUDDER_EXTENDED', # Percent extended - 'GEAR_CENTER_STEER_ANGLE', # Center wheel angle, negative to the left, positive to the right. - 'GEAR_LEFT_STEER_ANGLE', # Left wheel angle, negative to the left, positive to the right. - 'GEAR_RIGHT_STEER_ANGLE', # Right wheel angle, negative to the left, positive to the right. - 'GEAR_AUX_STEER_ANGLE', # Aux wheel angle, negative to the left, positive to the right. The aux wheel is the fourth set of gear, sometimes used on helicopters. - 'WATER_LEFT_RUDDER_STEER_ANGLE', # Water left rudder angle, negative to the left, positive to the right. - 'WATER_RIGHT_RUDDER_STEER_ANGLE', # Water right rudder angle, negative to the left, positive to the right. - 'GEAR_CENTER_STEER_ANGLE_PCT', # Center steer angle as a percentage - 'GEAR_LEFT_STEER_ANGLE_PCT', # Left steer angle as a percentage - 'GEAR_RIGHT_STEER_ANGLE_PCT', # Right steer angle as a percentage - 'GEAR_AUX_STEER_ANGLE_PCT', # Aux steer angle as a percentage - 'WATER_LEFT_RUDDER_STEER_ANGLE_PCT', # Water left rudder angle as a percentage - 'WATER_RIGHT_RUDDER_STEER_ANGLE_PCT', # Water right rudder as a percentage - 'CENTER_WHEEL_RPM', # Center landing gear rpm - 'LEFT_WHEEL_RPM', # Left landing gear rpm - 'RIGHT_WHEEL_RPM', # Right landing gear rpm - 'AUX_WHEEL_RPM', # Rpm of fourth set of gear wheels. - 'CENTER_WHEEL_ROTATION_ANGLE', # Center wheel rotation angle - 'LEFT_WHEEL_ROTATION_ANGLE', # Left wheel rotation angle - 'RIGHT_WHEEL_ROTATION_ANGLE', # Right wheel rotation angle - 'AUX_WHEEL_ROTATION_ANGLE', # Aux wheel rotation angle - 'GEAR_EMERGENCY_HANDLE_POSITION', # True if gear emergency handle applied - 'ANTISKID_BRAKES_ACTIVE', # True if antiskid brakes active - 'RETRACT_FLOAT_SWITCH', # True if retract float switch on - 'RETRACT_LEFT_FLOAT_EXTENDED', # If aircraft has retractable floats. - 'RETRACT_RIGHT_FLOAT_EXTENDED', # If aircraft has retractable floats. - 'STEER_INPUT_CONTROL', # Position of steering tiller - 'GEAR_DAMAGE_BY_SPEED', # True if gear has been damaged by excessive speed - 'GEAR_SPEED_EXCEEDED', # True if safe speed limit for gear exceeded - 'NOSEWHEEL_LOCK_ON', # True if the nosewheel lock is engaged. -] - -request_trim = [ - 'ROTOR_LATERAL_TRIM_PCT', # Trim percent - 'ELEVATOR_TRIM_POSITION', # Elevator trim deflection - 'ELEVATOR_TRIM_INDICATOR', - 'ELEVATOR_TRIM_PCT', # Percent elevator trim - 'AILERON_TRIM', # Angle deflection - 'AILERON_TRIM_PCT', # The trim position of the ailerons. Zero is fully retracted. - 'RUDDER_TRIM_PCT', # The trim position of the rudder. Zero is no trim. - 'RUDDER_TRIM', # Angle deflection -] - -request_autopilot = [ - 'AUTOPILOT_MASTER', - 'AUTOPILOT_AVAILABLE', - 'AUTOPILOT_NAV_SELECTED', - 'AUTOPILOT_WING_LEVELER', - 'AUTOPILOT_NAV1_LOCK', - 'AUTOPILOT_HEADING_LOCK', - 'AUTOPILOT_HEADING_LOCK_DIR', - 'AUTOPILOT_ALTITUDE_LOCK', - 'AUTOPILOT_ALTITUDE_LOCK_VAR', - 'AUTOPILOT_ATTITUDE_HOLD', - 'AUTOPILOT_GLIDESLOPE_HOLD', - 'AUTOPILOT_PITCH_HOLD_REF', - 'AUTOPILOT_APPROACH_HOLD', - 'AUTOPILOT_BACKCOURSE_HOLD', - 'AUTOPILOT_VERTICAL_HOLD_VAR', - 'AUTOPILOT_PITCH_HOLD', - 'AUTOPILOT_FLIGHT_DIRECTOR_ACTIVE', - 'AUTOPILOT_FLIGHT_DIRECTOR_PITCH', - 'AUTOPILOT_FLIGHT_DIRECTOR_BANK', - 'AUTOPILOT_AIRSPEED_HOLD', - 'AUTOPILOT_AIRSPEED_HOLD_VAR', - 'AUTOPILOT_MACH_HOLD', - 'AUTOPILOT_MACH_HOLD_VAR', - 'AUTOPILOT_YAW_DAMPER', - 'AUTOPILOT_RPM_HOLD_VAR', - 'AUTOPILOT_THROTTLE_ARM', - 'AUTOPILOT_TAKEOFF_POWER ACTIVE', - 'AUTOTHROTTLE_ACTIVE', - 'AUTOPILOT_VERTICAL_HOLD', - 'AUTOPILOT_RPM_HOLD', - 'AUTOPILOT_MAX_BANK', - 'FLY_BY_WIRE_ELAC_SWITCH', - 'FLY_BY_WIRE_FAC_SWITCH', - 'FLY_BY_WIRE_SEC_SWITCH', - 'FLY_BY_WIRE_ELAC_FAILED', - 'FLY_BY_WIRE_FAC_FAILED', - 'FLY_BY_WIRE_SEC_FAILED' -] - -request_cabin = [ - 'CABIN_SEATBELTS_ALERT_SWITCH', - 'CABIN_NO_SMOKING_ALERT_SWITCH' -] - -# This is a helper function which just adds a comma in the right place for readability, -# for instance converting 30000 to 30,000 -def thousandify(x): - return f"{x:,}" - - -@app.route('/') -def glass(): - return render_template("glass.html") - - -@app.route('/attitude-indicator') -def AttInd(): - return render_template("attitude-indicator/index.html") - - -def get_dataset(data_type): - if data_type == "navigation": request_to_action = request_location - if data_type == "airspeed": request_to_action = request_airspeed - if data_type == "compass": request_to_action = request_compass - if data_type == "vertical_speed": request_to_action = request_vertical_speed - if data_type == "fuel": request_to_action = request_fuel - if data_type == "flaps": request_to_action = request_flaps - if data_type == "throttle": request_to_action = request_throttle - if data_type == "gear": request_to_action = request_gear - if data_type == "trim": request_to_action = request_trim - if data_type == "autopilot": request_to_action = request_autopilot - if data_type == 'cabin': request_to_action = request_cabin - - return request_to_action - - -# In addition to the datapoints which can be pulled individually or as groups via JSON, the UI endpoint returns JSON -# with the datapoints which the HTML / JS uses in a friendly format -@app.route('/ui') -def output_ui_variables(): - - # Initialise dictionary - ui_friendly_dictionary = {} - ui_friendly_dictionary["STATUS"] = "success" - - # Fuel - fuel_percentage = (aq.get("FUEL_TOTAL_QUANTITY") / aq.get("FUEL_TOTAL_CAPACITY")) * 100 - ui_friendly_dictionary["FUEL_PERCENTAGE"] = round(fuel_percentage) - - # Airspeed and altitude - ui_friendly_dictionary["AIRSPEED_INDICATE"] = round(aq.get("AIRSPEED_INDICATED")) - ui_friendly_dictionary["ALTITUDE"] = thousandify(round(aq.get("PLANE_ALTITUDE"))) - - # Control surfaces - if aq.get("GEAR_HANDLE_POSITION") == 1: - ui_friendly_dictionary["GEAR_HANDLE_POSITION"] = "DOWN" - else: - ui_friendly_dictionary["GEAR_HANDLE_POSITION"] = "UP" - ui_friendly_dictionary["FLAPS_HANDLE_PERCENT"] = round(aq.get("FLAPS_HANDLE_PERCENT") * 100) - - ui_friendly_dictionary["ELEVATOR_TRIM_PCT"] = round(aq.get("ELEVATOR_TRIM_PCT") * 100) - ui_friendly_dictionary["RUDDER_TRIM_PCT"] = round(aq.get("RUDDER_TRIM_PCT") * 100) - - # Navigation - ui_friendly_dictionary["LATITUDE"] = aq.get("PLANE_LATITUDE") - ui_friendly_dictionary["LONGITUDE"] = aq.get("PLANE_LONGITUDE") - ui_friendly_dictionary["MAGNETIC_COMPASS"] = round(aq.get("MAGNETIC_COMPASS")) - ui_friendly_dictionary["MAGVAR"] = round(aq.get("MAGVAR")) - ui_friendly_dictionary["VERTICAL_SPEED"] = round(aq.get("VERTICAL_SPEED")) - - # Autopilot - ui_friendly_dictionary["AUTOPILOT_MASTER"] = aq.get("AUTOPILOT_MASTER") - ui_friendly_dictionary["AUTOPILOT_NAV_SELECTED"] = aq.get("AUTOPILOT_NAV_SELECTED") - ui_friendly_dictionary["AUTOPILOT_WING_LEVELER"] = aq.get("AUTOPILOT_WING_LEVELER") - ui_friendly_dictionary["AUTOPILOT_HEADING_LOCK"] = aq.get("AUTOPILOT_HEADING_LOCK") - ui_friendly_dictionary["AUTOPILOT_HEADING_LOCK_DIR"] = round(aq.get("AUTOPILOT_HEADING_LOCK_DIR")) - ui_friendly_dictionary["AUTOPILOT_ALTITUDE_LOCK"] = aq.get("AUTOPILOT_ALTITUDE_LOCK") - ui_friendly_dictionary["AUTOPILOT_ALTITUDE_LOCK_VAR"] = thousandify(round(aq.get("AUTOPILOT_ALTITUDE_LOCK_VAR"))) - ui_friendly_dictionary["AUTOPILOT_ATTITUDE_HOLD"] = aq.get("AUTOPILOT_ATTITUDE_HOLD") - ui_friendly_dictionary["AUTOPILOT_GLIDESLOPE_HOLD"] = aq.get("AUTOPILOT_GLIDESLOPE_HOLD") - ui_friendly_dictionary["AUTOPILOT_APPROACH_HOLD"] = aq.get("AUTOPILOT_APPROACH_HOLD") - ui_friendly_dictionary["AUTOPILOT_BACKCOURSE_HOLD"] = aq.get("AUTOPILOT_BACKCOURSE_HOLD") - ui_friendly_dictionary["AUTOPILOT_VERTICAL_HOLD"] = aq.get("AUTOPILOT_VERTICAL_HOLD") - ui_friendly_dictionary["AUTOPILOT_VERTICAL_HOLD_VAR"] = aq.get("AUTOPILOT_VERTICAL_HOLD_VAR") - ui_friendly_dictionary["AUTOPILOT_PITCH_HOLD"] = aq.get("AUTOPILOT_PITCH_HOLD") - ui_friendly_dictionary["AUTOPILOT_PITCH_HOLD_REF"] = aq.get("AUTOPILOT_PITCH_HOLD_REF") - ui_friendly_dictionary["AUTOPILOT_FLIGHT_DIRECTOR_ACTIVE"] = aq.get("AUTOPILOT_FLIGHT_DIRECTOR_ACTIVE") - ui_friendly_dictionary["AUTOPILOT_AIRSPEED_HOLD"] = aq.get("AUTOPILOT_AIRSPEED_HOLD") - ui_friendly_dictionary["AUTOPILOT_AIRSPEED_HOLD_VAR"] = round(aq.get("AUTOPILOT_AIRSPEED_HOLD_VAR")) - - # Cabin - ui_friendly_dictionary["CABIN_SEATBELTS_ALERT_SWITCH"] = aq.get("CABIN_SEATBELTS_ALERT_SWITCH") - ui_friendly_dictionary["CABIN_NO_SMOKING_ALERT_SWITCH"] = aq.get("CABIN_NO_SMOKING_ALERT_SWITCH") - - return jsonify(ui_friendly_dictionary) - - -@app.route('/dataset//', methods=["GET"]) -def output_json_dataset(dataset_name): - dataset_map = {} - - # This uses get_dataset() to pull in a bunch of different datapoint names into a dictionary which means they can - # then be requested from the sim - data_dictionary = get_dataset(dataset_name) - - for datapoint_name in data_dictionary: - dataset_map[datapoint_name] = aq.get(datapoint_name) - - return jsonify(dataset_map) - - -# This function actually does the work of getting an individual datapoint from the sim -def get_datapoint(datapoint_name, index=None): - - if index is not None and ':index' in datapoint_name: - dp = aq.find(datapoint_name) - if dp is not None: - dp.setIndex(int(index)) - - return aq.get(datapoint_name) - - -# This is the http endpoint wrapper for getting an individual datapoint -@app.route('/datapoint//get', methods=["GET"]) -def get_datapoint_endpoint(datapoint_name): - - ds = request.get_json() if request.is_json else request.form - index = ds.get('index') - - output = get_datapoint(datapoint_name, index) - - if isinstance(output, bytes): - output = output.decode('ascii') - - return jsonify(output) - - -# This function actually does the work of setting an individual datapoint -def set_datapoint(datapoint_name, index=None, value_to_use=None): - - if index is not None and ':index' in datapoint_name: - clas = aq.find(datapoint_name) - if clas is not None: - clas.setIndex(int(index)) - - sent = False - if value_to_use is None: - sent = aq.set(datapoint_name, 0) - else: - sent = aq.set(datapoint_name, int(value_to_use)) - - if sent is True: - status = "success" - else: - status = "Error with sending request: %s" % (datapoint_name) - - return status - - -# This is the http endpoint wrapper for setting a datapoint -@app.route('/datapoint//set', methods=["POST"]) -def set_datapoint_endpoint(datapoint_name): - - ds = request.get_json() if request.is_json else request.form - index = ds.get('index') - value_to_use = ds.get('value_to_use') - - status = set_datapoint (datapoint_name, index, value_to_use) - - return jsonify(status) - - -# This function actually does the work of triggering an event -def trigger_event(event_name, value_to_use = None): - - EVENT_TO_TRIGGER = ae.find(event_name) - if EVENT_TO_TRIGGER is not None: - if value_to_use is None: - EVENT_TO_TRIGGER() - else: - EVENT_TO_TRIGGER(int(value_to_use)) - - status = "success" - else: - status = "Error: %s is not an Event" % (event_name) - - return status - - -# This is the http endpoint wrapper for triggering an event -@app.route('/event//trigger', methods=["POST"]) -def trigger_event_endpoint(event_name): - - ds = request.get_json() if request.is_json else request.form - value_to_use = ds.get('value_to_use') - - status = trigger_event(event_name, value_to_use) - - return jsonify(status) - - -@app.route('/custom_emergency/', methods=["GET", "POST"]) -def custom_emergency(emergency_type): - - text_to_return = "No valid emergency type passed" - - if emergency_type == "random_engine_fire": - # Calculate number of engines - number_of_engines = aq.get("NUMBER_OF_ENGINES") - - if number_of_engines < 0: return "error, no engines found - is sim running?" - engine_to_set_on_fire = random.randint(1,number_of_engines) - - set_datapoint("ENG_ON_FIRE:index", engine_to_set_on_fire, 1) - - text_to_return = "Engine " + str(engine_to_set_on_fire) + " on fire" - - return text_to_return - - -# Main loop to run the flask app -app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/local_example.py b/local_example.py deleted file mode 100644 index 54a18496..00000000 --- a/local_example.py +++ /dev/null @@ -1,94 +0,0 @@ -from SimConnect import * -import logging -from SimConnect.Enum import * -from time import sleep - - -logging.basicConfig(level=logging.DEBUG) -LOGGER = logging.getLogger(__name__) -LOGGER.info("START") -# time holder for inline commands -ct_g = millis() - -# creat simconnection and pass used user classes -sm = SimConnect() -aq = AircraftRequests(sm) -ae = AircraftEvents(sm) - - -mc = aq.find("MAGNETIC_COMPASS") -mv = aq.find("MAGVAR") -print(mc.get() + mv.get()) - -sm.exit() -quit() - -# Set pos arund space nedle in WA. -sm.set_pos( - _Altitude=1000.0, - _Latitude=47.614699, - _Longitude=-122.358473, - _Airspeed=130, - _Heading=70.0, - # _Pitch=0.0, - # _Bank=0.0, - # _OnGround=0 -) - -# PARKING_BRAKES = Event(b'PARKING_BRAKES', sm) -# long path -PARKING_BRAKES = ae.Miscellaneous_Systems.PARKING_BRAKES -# using get -GEAR_TOGGLE = ae.Miscellaneous_Systems.get("GEAR_TOGGLE") -# Using find to lookup Event -AP_MASTER = ae.find("AP_MASTER") - -# THROTTLE1 Event -THROTTLE1 = ae.Engine.THROTTLE1_SET - - -# THROTTLE1 Request -Throttle = aq.find('GENERAL_ENG_THROTTLE_LEVER_POSITION:1') - -# If useing -# Throttle = aq.find('GENERAL_ENG_THROTTLE_LEVER_POSITION:index') -# Need to set index befor read/write -# Note to set index 2 vs 1 just re-run -# Throttle.setIndex(1) - - -# print the built in description -# AP_MASTER Toggles AP on/off -print("AP_MASTER", AP_MASTER.description) -# Throttle Percent of max throttle position -print("Throttle", Throttle.description) -# THROTTLE1 Set throttle 1 exactly (0 to 16383) -print("THROTTLE1", THROTTLE1.description) - - -while not sm.quit: - print("Throttle:", Throttle.value) - print("Alt=%f Lat=%f Lon=%f Kohlsman=%.2f" % ( - aq.PositionandSpeedData.get('PLANE_ALTITUDE'), - aq.PositionandSpeedData.get('PLANE_LATITUDE'), - aq.PositionandSpeedData.get('PLANE_LONGITUDE'), - aq.FlightInstrumentationData.get('KOHLSMAN_SETTING_HG') - )) - sleep(2) - - # Send Event with value - # THROTTLE1(1500) - - # Send Event toggle AP_MASTER - # AP_MASTER() - - # PARKING_BRAKES() - - # send new data inine @ 5s - if ct_g + 5000 < millis(): - if Throttle.value < 100: - Throttle.value += 5 - print("THROTTLE SET") - ct_g = millis() - -sm.exit() diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 00000000..148008df --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +""" +模块:models +职责:数据与业务模型包(与 Qt 无关) +""" diff --git a/models/chart.py b/models/chart.py new file mode 100644 index 00000000..0a4af623 --- /dev/null +++ b/models/chart.py @@ -0,0 +1,478 @@ +# -*- coding: utf-8 -*- +""" +模块:models.chart +职责:图表缓冲、视窗切片、健康色带判定;字段元数据从 data_bridge.FIELDS 派生 +依赖:models.data_bridge(字段模版) +""" + +from __future__ import annotations + +import math +from collections import deque +from dataclasses import dataclass +from enum import Enum +from typing import Dict, List, Mapping, Optional, Sequence, Tuple + +from setting import ( + CHART_HISTORY_LEN as HISTORY_LEN, + CHART_VIEW_WINDOW_S as VIEW_WINDOW_S, + CHART_X_AXIS_MAJOR_SEC as X_AXIS_MAJOR_SEC, + CHART_YLIM_AXIS_IDLE, + HEALTH_BOUND_FLASH, + HEALTH_BOUND_GREEN, + HEALTH_BOUND_ORANGE, +) + + +class AxisSide(str, Enum): + LEFT = "left" + RIGHT = "right" + + +# 工具栏槽位:Y轴(L)/ Y轴(R)各一 +SERIES_SLOT_COUNT = 2 +SERIES_SLOT_SIDES: Tuple[AxisSide, ...] = (AxisSide.LEFT, AxisSide.RIGHT) +SERIES_SLOT_LABELS: Tuple[str, ...] = ("Y轴(L)", "Y轴(R)") + + +@dataclass(frozen=True) +class ChartFieldSpec: + """单条曲线字段:绑定 Y 轴一侧的刻度范围与标题。""" + + id: str + ymin: float + ymax: float + axis_title: str + side: AxisSide = AxisSide.LEFT + color_key: str = "" + color: str = "" + line_width: Optional[float] = None + line_style: str = "" + health_spans: bool = False + + def ylim(self) -> Tuple[float, float]: + return self.ymin, self.ymax + + +@dataclass(frozen=True) +class ChartSeriesOption: + """系列选项:由 data_bridge.FieldDef 派生,供工具栏使用。""" + + id: str + label: str + ymin: float + ymax: float + axis_title: str + side: AxisSide = AxisSide.LEFT + color_key: str = "" + color: str = "" + line_width: Optional[float] = None + line_style: str = "" + health_spans: bool = False + + def to_field_spec(self) -> ChartFieldSpec: + return ChartFieldSpec( + id=self.id, + ymin=self.ymin, + ymax=self.ymax, + axis_title=self.axis_title, + side=self.side, + color_key=self.color_key or self.id, + color=self.color, + line_width=self.line_width, + line_style=self.line_style, + health_spans=self.health_spans, + ) + + +def _option_from_field_def(fdef) -> ChartSeriesOption: + ymin = 0.0 if fdef.ymin is None else float(fdef.ymin) + ymax = 100.0 if fdef.ymax is None else float(fdef.ymax) + return ChartSeriesOption( + id=fdef.id, + label=fdef.label or fdef.id, + ymin=ymin, + ymax=ymax, + axis_title=fdef.unit or "", + side=AxisSide(fdef.side or "left"), + color_key=fdef.color_key or fdef.id, + color=fdef.color or "", + line_width=fdef.line_width, + line_style=fdef.line_style or "", + health_spans=bool(fdef.health_spans), + ) + + +def _build_catalog() -> Dict[str, ChartSeriesOption]: + from models.data_bridge import FIELDS + + return {fid: _option_from_field_def(fdef) for fid, fdef in FIELDS.items()} + + +# 系列选项库(派生自 data_bridge.FIELDS;增字段只改 FIELDS) +CHART_SERIES_CATALOG: Dict[str, ChartSeriesOption] = _build_catalog() + +# 工具栏默认:Y轴(L)扭矩、Y轴(R)健康(均显示) +DEFAULT_SERIES_SLOTS: Tuple[str, ...] = ("torque", "health") +DEFAULT_SERIES_VISIBLE: Tuple[bool, ...] = (True, True) + +# 取消「显示」时切换到的空 default(不进 CHART_SERIES_CATALOG;无曲线;标题空白) +AXIS_IDLE_FIELD_ID = "axis_idle" +AXIS_IDLE_SPEC = ChartFieldSpec( + id=AXIS_IDLE_FIELD_ID, + ymin=float(CHART_YLIM_AXIS_IDLE[0]), + ymax=float(CHART_YLIM_AXIS_IDLE[1]), + axis_title="", + side=AxisSide.LEFT, +) + + +def axis_idle_series_id(side: AxisSide | str) -> str: + """左右各一条空 default 系列 id(不进下拉)。""" + side_val = side.value if isinstance(side, AxisSide) else str(side) + return f"{AXIS_IDLE_FIELD_ID}_{side_val}" + + +def is_axis_idle_id(field_id: str) -> bool: + return str(field_id or "").startswith(AXIS_IDLE_FIELD_ID) + +# 顶部三数值仪表:可选指标 + 默认槽(左→右) +METRIC_STRIP_SLOT_COUNT = 3 +# 派生率类需历史上下文,不进仪表下拉 +METRIC_STRIP_EXCLUDE_IDS: Tuple[str, ...] = ("speed_rate", "accel_rate") +DEFAULT_METRIC_STRIP_SLOTS: Tuple[str, ...] = ("cht", "torque", "health") + + +@dataclass(frozen=True) +class MetricStripOption: + """顶部仪表条单列选项。""" + + id: str + label: str + unit: str + + +def _build_metric_strip_catalog() -> Dict[str, MetricStripOption]: + from setting import CHART_FIELD_UNITS + + out: Dict[str, MetricStripOption] = { + "cht": MetricStripOption( + id="cht", + label="缸头温度", + unit=str(CHART_FIELD_UNITS.get("cht", "F") or "F"), + ), + } + for fid, opt in CHART_SERIES_CATALOG.items(): + if fid in METRIC_STRIP_EXCLUDE_IDS: + continue + out[fid] = MetricStripOption( + id=fid, + label=opt.label, + unit=opt.axis_title or "", + ) + return out + + +METRIC_STRIP_CATALOG: Dict[str, MetricStripOption] = _build_metric_strip_catalog() + + +def metric_strip_option(field_id: str) -> Optional[MetricStripOption]: + return METRIC_STRIP_CATALOG.get(str(field_id or "")) + + +def catalog_field_specs() -> Tuple[ChartFieldSpec, ...]: + """选项库全部字段规格(供缓冲录制)。""" + return tuple(opt.to_field_spec() for opt in CHART_SERIES_CATALOG.values()) + + +def series_option(field_id: str) -> Optional[ChartSeriesOption]: + return CHART_SERIES_CATALOG.get(field_id) + + +def field_spec_for(field_id: str) -> Optional[ChartFieldSpec]: + opt = CHART_SERIES_CATALOG.get(field_id) + return opt.to_field_spec() if opt is not None else None + + +@dataclass(frozen=True) +class ChartConfig: + """图表字段组合;缓冲通常含全库字段,显示由工具栏槽位裁剪。""" + + fields: Tuple[ChartFieldSpec, ...] + view_window_s: float = VIEW_WINDOW_S + x_axis_major_sec: float = X_AXIS_MAJOR_SEC + x_axis_title: str = "Time (s)" # 单位见 TRACK_FIELD_UNITS["timestamp_s"] + + def field_ids(self) -> Tuple[str, ...]: + return tuple(f.id for f in self.fields) + + def field_by_id(self, field_id: str) -> Optional[ChartFieldSpec]: + for field in self.fields: + if field.id == field_id: + return field + return None + + def field_for_side(self, side: AxisSide | str) -> Optional[ChartFieldSpec]: + side_val = AxisSide(side) + for field in self.fields: + if field.side == side_val: + return field + return None + + def span_field(self) -> Optional[ChartFieldSpec]: + for field in self.fields: + if field.health_spans: + return field + return None + + def primary_field(self) -> ChartFieldSpec: + """拖拽/圈标映射用的主字段(优先左侧)。""" + left = self.field_for_side(AxisSide.LEFT) + return left if left is not None else self.fields[0] + + +TRAINING_CHART = ChartConfig(fields=catalog_field_specs()) + + +def health_band(health_pct: Optional[float]) -> Optional[str]: + """ + 说明:按展示精度判定健康色带档位 + 参数: + health_pct — 健康百分比;非法/NaN 返回 None + 返回: + "flash" | "green" | "orange" | "red" | None + """ + if health_pct is None: + return None + try: + value = float(health_pct) + except (TypeError, ValueError): + return None + if value != value: + return None + display = round(value, 1) + if HEALTH_BOUND_FLASH <= display <= 100.0: + return "flash" + if HEALTH_BOUND_GREEN <= display < HEALTH_BOUND_FLASH: + return "green" + if HEALTH_BOUND_ORANGE <= display < HEALTH_BOUND_GREEN: + return "orange" + if display < HEALTH_BOUND_ORANGE: + return "red" + return None + + +def health_span_kind(health_pct: Optional[float]) -> Optional[str]: + """将 health_band 映射为蒙版配色键(flash→blue;green 不着色)。""" + band = health_band(health_pct) + if band == "flash": + return "blue" + if band == "orange": + return "orange" + if band == "red": + return "red" + return None + + +def relative_times(timestamps: List[float]) -> List[float]: + """将绝对时间戳转为相对首点的秒数序列。""" + if not timestamps: + return [] + t0 = float(timestamps[0]) + return [float(t) - t0 for t in timestamps] + + +class ChartHistoryBuffer: + """按 ChartConfig 字段存储时间序列,供趋势图渲染。""" + + def __init__(self, config: ChartConfig = TRAINING_CHART, maxlen: int = HISTORY_LEN): + self.config = config + self.timestamps: deque = deque(maxlen=maxlen) + self._series: Dict[str, deque] = { + field.id: deque(maxlen=maxlen) for field in config.fields + } + + @property + def t_min(self) -> float: + return self.timestamps[0] if self.timestamps else 0.0 + + @property + def t_max(self) -> float: + return self.timestamps[-1] if self.timestamps else 0.0 + + def __len__(self) -> int: + return len(self.timestamps) + + def append(self, timestamp: float, **values: Optional[float]) -> None: + """追加一拍;kwargs 为 data_bridge.extract 产出的 field_id → float。""" + if not values: + return + self._append_raw(timestamp, values) + self._update_derived_fields() + + def extend_from_arrays( + self, + timestamps: Sequence[float], + series_by_id: Mapping[str, Sequence[float]], + ) -> None: + """ + 批量写入已对齐序列;不逐点派生。 + 派生列须已在 series_by_id 中(如 FieldDef.extract_series)。 + 超出 maxlen 时保留尾部。 + """ + n = len(timestamps) + if n <= 0: + return + maxlen = self.timestamps.maxlen + start = 0 if maxlen is None else max(0, n - int(maxlen)) + count = n - start + if count <= 0: + return + self.timestamps.extend(float(timestamps[i]) for i in range(start, n)) + for field in self.config.fields: + series = self._series[field.id] + src = series_by_id.get(field.id) + if src is None or len(src) < n: + series.extend(float("nan") for _ in range(count)) + continue + for i in range(start, n): + try: + v = float(src[i]) + series.append(v if math.isfinite(v) else float("nan")) + except (TypeError, ValueError): + series.append(float("nan")) + + def _append_raw( + self, timestamp: float, values: Mapping[str, Optional[float]] + ) -> None: + self.timestamps.append(timestamp) + for field in self.config.fields: + value = values.get(field.id) + if value is None: + self._series[field.id].append(float("nan")) + else: + try: + self._series[field.id].append(float(value)) + except (TypeError, ValueError): + self._series[field.id].append(float("nan")) + + def _update_derived_fields(self) -> None: + """加工字段(速度/加速度变化率)按已写入序列回填末点。""" + from models.data_bridge import FIELDS + + if len(self.timestamps) < 2: + return + for field in self.config.fields: + fdef = FIELDS.get(field.id) + derive = getattr(fdef, "derive_latest", None) + if not callable(derive): + continue + series = self._series.get(field.id) + if series is None or len(series) <= 0: + continue + try: + value = float(derive(self.timestamps, self._series)) + except (TypeError, ValueError): + continue + if math.isfinite(value): + series[-1] = value + + def slice_view( + self, + view_start: float, + view_end: float, + ) -> Tuple[List[float], Dict[str, List[float]]]: + times = relative_times(list(self.timestamps)) + xs: List[float] = [] + out: Dict[str, List[float]] = {field.id: [] for field in self.config.fields} + for i, x in enumerate(times): + if x < view_start or x > view_end: + continue + xs.append(x) + for field in self.config.fields: + series = self._series[field.id] + if i < len(series): + out[field.id].append(float(series[i])) + else: + out[field.id].append(float("nan")) + return xs, out + + def clear(self) -> None: + self.timestamps.clear() + for series in self._series.values(): + series.clear() + + def nearest_index(self, t_rel: float) -> int: + """相对时间 → 最近采样下标;空缓冲返回 -1。""" + times = relative_times(list(self.timestamps)) + if not times: + return -1 + best_i = 0 + best_d = abs(times[0] - float(t_rel)) + for i, x in enumerate(times): + d = abs(x - float(t_rel)) + if d < best_d: + best_d = d + best_i = i + return best_i + + def values_at_relative(self, t_rel: float) -> Dict[str, float]: + """取相对时间最近点的各字段值。""" + idx = self.nearest_index(t_rel) + out: Dict[str, float] = {} + if idx < 0: + return out + for field in self.config.fields: + series = self._series.get(field.id) + if series is None or idx >= len(series): + out[field.id] = float("nan") + else: + try: + out[field.id] = float(series[idx]) + except (TypeError, ValueError): + out[field.id] = float("nan") + return out + + def relative_time_at(self, index: int) -> Optional[float]: + times = relative_times(list(self.timestamps)) + if index < 0 or index >= len(times): + return None + return float(times[index]) + + +def view_bounds( + t_max: float, + view_end: Optional[float], + view_window_s: float, +) -> Tuple[float, float]: + """计算滑动视窗 [start, end],宽度固定为 view_window_s。 + + - 直播:end = t_max + - 回溯:end = clamp(view_end, …) + - 数据不足一窗时:钉在 [0, window](右侧可留空) + """ + window = float(view_window_s) if view_window_s and view_window_s > 0 else VIEW_WINDOW_S + t_max = max(0.0, float(t_max)) + if view_end is None: + end = t_max + else: + end = min(max(0.0, float(view_end)), t_max) + start = end - window + if start < 0.0: + start = 0.0 + end = window + else: + end = start + window + if end <= start: + end = start + window + return start, end + + +def series_points(xs: List[float], ys: List[float]) -> List[Tuple[float, float]]: + """过滤 NaN/None,生成 (x, y) 点列供折线渲染。""" + points: List[Tuple[float, float]] = [] + for x, y in zip(xs, ys): + if y is None or (isinstance(y, float) and math.isnan(y)): + continue + points.append((float(x), float(y))) + return points diff --git a/models/consume_cache.py b/models/consume_cache.py new file mode 100644 index 00000000..17472149 --- /dev/null +++ b/models/consume_cache.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +""" +模块:models.consume_cache +职责:消费板块共享只读缓存——仅从 save replica 增量投影,供折线/航迹共用 +约定:禁止从 SimConnect 直写;紧急消费只截取唯一副本(Debug.md) +""" + +from __future__ import annotations + +from typing import List, Optional, Sequence + +from models.chart import ChartHistoryBuffer, TRAINING_CHART, ChartConfig +from models.data_bridge import extract +from models.track_map import TrackPoint, points_from_trajectory_samples + + +def chart_values_from_sample(sample) -> dict: + """TrajectorySample → 图表 field_id 字典(经 FieldDef.extract)。""" + return extract(sample) + + +class SessionReadCache: + """ + 消费侧唯一时序缓存:history + track 同源自 replica 投影。 + 子业务(折线 / 航迹 / 游标)只读本对象,不互传缓冲本体。 + """ + + def __init__(self, config: Optional[ChartConfig] = None): + self._config = config or TRAINING_CHART + self._history = ChartHistoryBuffer(self._config) + self._track: List[TrackPoint] = [] + self._synced_len = 0 + + @property + def history(self) -> ChartHistoryBuffer: + return self._history + + @property + def track_points(self) -> List[TrackPoint]: + return self._track + + def __len__(self) -> int: + return len(self._history) + + @property + def synced_len(self) -> int: + """已投影的 replica 样本下标(下一拍从该下标切片)。""" + return int(self._synced_len) + + def clear(self) -> None: + self._history.clear() + self._track.clear() + self._synced_len = 0 + + def last_track_point(self) -> Optional[TrackPoint]: + if not self._track: + return None + return self._track[-1] + + def track_points_from(self, start: int) -> List[TrackPoint]: + """返回 track[start:];start 越界时返回空列表。""" + if start < 0: + start = 0 + if start >= len(self._track): + return [] + return list(self._track[start:]) + + def pull_replica(self, samples: Sequence) -> int: + """ + 从 replica 增量投影;若副本缩短(新会话)则整表重建。 + 返回本拍新增投影次数(含因无 lat/lon 未进图的样本)。 + """ + n = len(samples) if samples is not None else 0 + if n < self._synced_len: + self.clear() + if n == self._synced_len: + return 0 + added = 0 + for sample in samples[self._synced_len :]: + self._append_sample(sample) + added += 1 + self._synced_len = n + return added + + def pull_replica_tail(self, new_samples: Sequence, new_synced_len: int) -> int: + """ + 仅消费 replica 尾部切片;new_synced_len 为切片后的 replica 总长。 + 会话缩短请改走 pull_replica(全量) / rebuild_from_replica。 + """ + if int(new_synced_len) < self._synced_len: + self.clear() + if not new_samples: + self._synced_len = int(new_synced_len) + return 0 + added = 0 + for sample in new_samples: + self._append_sample(sample) + added += 1 + self._synced_len = int(new_synced_len) + return added + + def rebuild_from_replica(self, samples: Sequence) -> None: + """全量重建(复位/过圈后总线全量推送)。""" + self.clear() + self.pull_replica(samples) + + def _append_sample(self, sample) -> None: + pts = points_from_trajectory_samples([sample]) + if not pts: + return + values = chart_values_from_sample(sample) + try: + ts = float(getattr(sample, "timestamp_s", 0.0)) + except (TypeError, ValueError): + ts = 0.0 + payload = {field.id: values.get(field.id) for field in self._config.fields} + self._history.append(ts, **payload) + self._track.append(pts[0]) diff --git a/models/cursor_model.py b/models/cursor_model.py new file mode 100644 index 00000000..d87e92a9 --- /dev/null +++ b/models/cursor_model.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +""" +模块:models.cursor_model +职责:全局游标权威(index / t_rel / follow_live);折线·航迹·摇杆订阅,不互写 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +from PySide2.QtCore import QObject, Signal + + +@dataclass(frozen=True) +class CursorState: + """游标只读快照。""" + + index: int = 0 + t_rel: float = 0.0 + follow_live: bool = True + length: int = 0 + + +class CursorModel(QObject): + """ + 游标唯一写口。订阅方只读 changed 后的 state,禁止子图互相驱动。 + """ + + changed = Signal(object) # CursorState + + def __init__(self, parent=None): + super().__init__(parent) + self._index = 0 + self._t_rel = 0.0 + self._follow_live = True + self._length = 0 + self._block = False + + def state(self) -> CursorState: + return CursorState( + index=int(self._index), + t_rel=float(self._t_rel), + follow_live=bool(self._follow_live), + length=int(self._length), + ) + + @property + def index(self) -> int: + return int(self._index) + + @property + def t_rel(self) -> float: + return float(self._t_rel) + + @property + def follow_live(self) -> bool: + return bool(self._follow_live) + + @property + def length(self) -> int: + return int(self._length) + + def set_length(self, length: int, *, prefer_end: bool = False) -> None: + n = max(0, int(length)) + if n == self._length and not prefer_end: + return + self._length = n + if n <= 0: + self._index = 0 + self._t_rel = 0.0 + self._follow_live = True + self._emit() + return + if prefer_end or self._follow_live: + self._index = n - 1 + self._follow_live = True + else: + self._index = max(0, min(self._index, n - 1)) + self._follow_live = self._index >= n - 1 + self._emit() + + def set_index(self, index: int, *, t_rel: Optional[float] = None) -> None: + n = self._length + if n <= 0: + self._index = 0 + self._t_rel = 0.0 if t_rel is None else float(t_rel) + self._follow_live = True + self._emit() + return + idx = max(0, min(n - 1, int(index))) + follow = idx >= n - 1 + if ( + idx == self._index + and follow == self._follow_live + and (t_rel is None or abs(float(t_rel) - self._t_rel) < 1e-9) + ): + return + self._index = idx + self._follow_live = follow + if t_rel is not None: + self._t_rel = float(t_rel) + self._emit() + + def set_t_rel(self, t_rel: float) -> None: + if abs(float(t_rel) - self._t_rel) < 1e-9: + return + self._t_rel = float(t_rel) + self._emit() + + def set_follow_live(self, follow: bool) -> None: + follow = bool(follow) + if follow == self._follow_live: + return + self._follow_live = follow + if follow and self._length > 0: + self._index = self._length - 1 + self._emit() + + def assign( + self, + *, + index: Optional[int] = None, + t_rel: Optional[float] = None, + follow_live: Optional[bool] = None, + length: Optional[int] = None, + emit: bool = True, + ) -> None: + """批量静默/发信号更新(facade 同步 scrub 时用 emit=False)。""" + if length is not None: + self._length = max(0, int(length)) + if index is not None: + n = self._length + if n <= 0: + self._index = 0 + else: + self._index = max(0, min(n - 1, int(index))) + if follow_live is not None: + self._follow_live = bool(follow_live) + if self._follow_live and self._length > 0: + self._index = self._length - 1 + if t_rel is not None: + self._t_rel = float(t_rel) + if emit: + self._emit() + + def _emit(self) -> None: + if self._block: + return + self.changed.emit(self.state()) diff --git a/models/data_bridge.py b/models/data_bridge.py new file mode 100644 index 00000000..6cd9f214 --- /dev/null +++ b/models/data_bridge.py @@ -0,0 +1,801 @@ +# -*- coding: utf-8 -*- +""" +模块:models.data_bridge +职责:图表字段模版(出数 + 轴/线型/蒙版元数据)与采样表/缓存 → 字段值 +约定:缓存为时间×列二维表;读用 pandas,算用 numpy +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple + +import numpy as np +import pandas as pd + +from services.simulator import ( + AIRSPEED_VAR, + BANK_VAR, + GROUND_ALT_VAR, + GROUND_SPEED_VAR, + METERS_TO_FEET, + PITCH_VAR, + PLANE_AGL_VAR, + PLANE_ALT_VAR, + PLANE_HEADING_VAR, + PLANE_LAT_VAR, + PLANE_LON_VAR, + optional_float, + rad_to_deg, +) +from setting import ( + CHART_FIELD_UNITS, + CHART_YLIM_ACCEL_RATE, + CHART_YLIM_AIRSPEED, + CHART_YLIM_BANK, + CHART_YLIM_DI_PROXY, + CHART_YLIM_ETA_PROP, + CHART_YLIM_GROUNDSPEED, + CHART_YLIM_HEALTH, + CHART_YLIM_PITCH, + CHART_YLIM_SPEED_RATE, + CHART_YLIM_TORQUE, + CHART_YLIM_VG_COURSE, + TRACK_FIELD_UNITS, + UI_CHART_HEALTH_SPAN_ALPHA, +) + + +def _field_unit(csv_key: str = "", chart_id: str = "") -> str: + """轴/仪表单位:优先 CSV 列量纲,其次图表派生字段。""" + if csv_key and csv_key in TRACK_FIELD_UNITS: + return str(TRACK_FIELD_UNITS[csv_key] or "") + if chart_id and chart_id in CHART_FIELD_UNITS: + return str(CHART_FIELD_UNITS[chart_id] or "") + if chart_id and chart_id in TRACK_FIELD_UNITS: + return str(TRACK_FIELD_UNITS[chart_id] or "") + return "" + +# 缓存行关心的列(与航迹采样表头对齐;动态扩列见 track_fields) +from models.track_fields import SAMPLE_METRIC_KEYS, enabled_track_columns, read_enabled_sim_fields + +ROW_KEYS = tuple( + dict.fromkeys( + ( + "timestamp_s", + "airspeed_kts", + "groundspeed_kts", + "health_pct", + "torque", + "pitch_deg", + "bank_deg", + "lap", + ) + + SAMPLE_METRIC_KEYS + ) +) + +DEFAULT_SPAN_COLORS: Dict[str, str] = { + "orange": "#f39c12", + "blue": "#1e90ff", + "red": "#e74c3c", +} + + +# --------------------------------------------------------------------------- +# 蒙版 / 字段模版 +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class FieldOverlay: + """挂在字段上的蒙版;无需要则 overlays=() 不填。""" + + kind: str = "" # "" | threshold_span | … + enabled: bool = False + direction: str = "x" # x | y | xy + alpha: Optional[float] = None + colors: Optional[Dict[str, str]] = None + resolver: str = "" # e.g. health_span_kind + z_order: Optional[int] = None + overlay_id: str = "" # 空则用 {field_id}_{kind} + axis_field_id: str = "" # 空则同字段 id + + +@dataclass(frozen=True) +class ChartLevelOverlay: + """图级蒙版(不绑 Y 字段),如圈完成竖线。""" + + overlay_id: str + kind: str # lap_marker | … + enabled: bool = True + z_order: int = 20 + label_template: str = "LAP {lap} FINISHED" + + +@dataclass +class FieldDef: + """ + 字段模版:有属性则填,无则保持空/None(后续处理用默认)。 + 增字段:在 FIELDS 加一条(或子类 override extract_*)。 + """ + + id: str + csv_key: str = "" + label: str = "" + unit: str = "" + ymin: Optional[float] = None + ymax: Optional[float] = None + side: str = "left" # left | right + color: str = "" # 空 → theme[color_key|id] + color_key: str = "" + line_width: Optional[float] = None + line_style: str = "" # "" | solid | dash | dot | dashdot + overlays: Tuple[FieldOverlay, ...] = () + + @property + def health_spans(self) -> bool: + return any(o.kind == "threshold_span" and o.enabled for o in self.overlays) + + def extract_row(self, row: Mapping[str, Any] | pd.Series) -> float: + """单行透传;无 csv_key 的加工字段请子类 override。""" + if not self.csv_key: + return float("nan") + return _to_float(row.get(self.csv_key) if hasattr(row, "get") else row[self.csv_key]) + + def extract_series(self, df: pd.DataFrame) -> pd.Series: + """整列时间序列;默认取 csv_key。""" + n = len(df) + if not self.csv_key or self.csv_key not in df.columns: + return pd.Series(np.full(n, np.nan, dtype=float), index=df.index, dtype=float) + return pd.to_numeric(df[self.csv_key], errors="coerce").astype(float) + + +class AirspeedField(FieldDef): + """空速:列 NaN 时回退地速。""" + + def extract_row(self, row: Mapping[str, Any] | pd.Series) -> float: + v = _to_float(row.get("airspeed_kts") if hasattr(row, "get") else None) + if v != v: + v = _to_float(row.get("groundspeed_kts") if hasattr(row, "get") else None) + return v + + def extract_series(self, df: pd.DataFrame) -> pd.Series: + ias = ( + pd.to_numeric(df["airspeed_kts"], errors="coerce") + if "airspeed_kts" in df.columns + else pd.Series(np.nan, index=df.index, dtype=float) + ) + gs = ( + pd.to_numeric(df["groundspeed_kts"], errors="coerce") + if "groundspeed_kts" in df.columns + else pd.Series(np.nan, index=df.index, dtype=float) + ) + return ias.fillna(gs).astype(float) + + +def _to_float(value: Any) -> float: + v = optional_float(value) + return float("nan") if v is None else float(v) + + +def _window_diff(values: Sequence[float] | np.ndarray | pd.Series) -> float: + arr = np.asarray(values, dtype=float) + if arr.size < 2: + return float("nan") + d = arr[-1] - arr[-2] + return float(d) if np.isfinite(d) else float("nan") + + +def airspeed_change_rate(data: Sequence[float] | np.ndarray | pd.Series) -> float: + """窗口:空速序列 → 最新一阶差分。""" + return _window_diff(data) + + +def speed_change_rate(data: Sequence[float] | np.ndarray | pd.Series) -> float: + """窗口:地速序列 → 最新一阶差分(无时间归一;优先用 first_derivative_*)。""" + return _window_diff(data) + + +def first_derivative_series( + timestamps: Sequence[float] | np.ndarray | pd.Series, + values: Sequence[float] | np.ndarray | pd.Series, +) -> np.ndarray: + """对时间求一阶导 Δy/Δt;不等间隔;不足 2 点处为 NaN。""" + t = np.asarray(timestamps, dtype=float) + y = np.asarray(values, dtype=float) + n = int(t.size) + out = np.full(n, np.nan, dtype=float) + if n < 2 or y.size != n: + return out + for i in range(1, n): + dt = t[i] - t[i - 1] + if dt > 0.0 and np.isfinite(y[i]) and np.isfinite(y[i - 1]): + out[i] = (y[i] - y[i - 1]) / dt + return out + + +def first_derivative_latest( + timestamps: Sequence[float] | np.ndarray | pd.Series, + values: Sequence[float] | np.ndarray | pd.Series, +) -> float: + """序列末点的一阶导(仅末两点,O(1));无法计算时返回 NaN。""" + try: + n_t = len(timestamps) + n_y = len(values) + except TypeError: + return float("nan") + if n_t < 2 or n_y < 2: + return float("nan") + t1 = float(timestamps[-1]) + t0 = float(timestamps[-2]) + y1 = float(values[-1]) + y0 = float(values[-2]) + dt = t1 - t0 + if dt <= 0.0 or not ( + np.isfinite(t1) and np.isfinite(t0) and np.isfinite(y1) and np.isfinite(y0) + ): + return float("nan") + return (y1 - y0) / dt + + +def second_derivative_series( + timestamps: Sequence[float] | np.ndarray | pd.Series, + values: Sequence[float] | np.ndarray | pd.Series, +) -> np.ndarray: + """ + 对时间求二阶导:先 (Δy/Δt) 得一阶,再对一阶做 (Δa/Δt)。 + 不等间隔采样;不足 3 点或非有限值处为 NaN。 + """ + t = np.asarray(timestamps, dtype=float) + accel = first_derivative_series(t, values) + n = int(t.size) + out = np.full(n, np.nan, dtype=float) + if n < 3 or accel.size != n: + return out + for i in range(2, n): + dt = t[i] - t[i - 1] + if dt > 0.0 and np.isfinite(accel[i]) and np.isfinite(accel[i - 1]): + out[i] = (accel[i] - accel[i - 1]) / dt + return out + + +def second_derivative_latest( + timestamps: Sequence[float] | np.ndarray | pd.Series, + values: Sequence[float] | np.ndarray | pd.Series, +) -> float: + """序列末点的二阶导(仅末三点,O(1));无法计算时返回 NaN。""" + try: + n_t = len(timestamps) + n_y = len(values) + except TypeError: + return float("nan") + if n_t < 3 or n_y < 3: + return float("nan") + t2 = float(timestamps[-1]) + t1 = float(timestamps[-2]) + t0 = float(timestamps[-3]) + y2 = float(values[-1]) + y1 = float(values[-2]) + y0 = float(values[-3]) + if not ( + np.isfinite(t2) + and np.isfinite(t1) + and np.isfinite(t0) + and np.isfinite(y2) + and np.isfinite(y1) + and np.isfinite(y0) + ): + return float("nan") + dt2 = t2 - t1 + dt1 = t1 - t0 + if dt2 <= 0.0 or dt1 <= 0.0: + return float("nan") + a2 = (y2 - y1) / dt2 + a1 = (y1 - y0) / dt1 + return (a2 - a1) / dt2 + + +class _GroundspeedDerivedField(FieldDef): + """地速派生字段基类:单行无值,系列/尾点由地速+时间推导。""" + + SOURCE_FIELD_ID = "groundspeed" + SOURCE_CSV_KEY = "groundspeed_kts" + _order: int = 1 # 1=一阶导 2=二阶导 + + def extract_row(self, row: Mapping[str, Any] | pd.Series) -> float: + return float("nan") + + def extract_series(self, df: pd.DataFrame) -> pd.Series: + n = len(df) + if ( + "timestamp_s" not in df.columns + or self.SOURCE_CSV_KEY not in df.columns + or n <= 0 + ): + return pd.Series(np.full(n, np.nan, dtype=float), index=df.index, dtype=float) + t = pd.to_numeric(df["timestamp_s"], errors="coerce") + gs = pd.to_numeric(df[self.SOURCE_CSV_KEY], errors="coerce") + fn = second_derivative_series if self._order >= 2 else first_derivative_series + return pd.Series(fn(t, gs), index=df.index, dtype=float) + + def derive_latest( + self, + timestamps: Sequence[float], + series_by_id: Mapping[str, Sequence[float]], + ) -> float: + src = series_by_id.get(self.SOURCE_FIELD_ID) + if src is None: + return float("nan") + if self._order >= 2: + return second_derivative_latest(timestamps, src) + return first_derivative_latest(timestamps, src) + + +class GroundspeedChangeRateField(_GroundspeedDerivedField): + """速度变化率 - speed_rate:地速对时间的一阶导数 d(gs)/dt(kts/s)。""" + + _order = 1 + + +class GroundspeedAccelRateField(_GroundspeedDerivedField): + """加速度变化率 - accel_rate:地速对时间的二阶导数 d²(gs)/dt²(kts/s²)。""" + + _order = 2 + + + +# --------------------------------------------------------------------------- +# 字段注册表(唯一真相源) +# --------------------------------------------------------------------------- + +FIELDS: Dict[str, FieldDef] = { + "airspeed": AirspeedField( + id="airspeed", + csv_key="airspeed_kts", + label="空速", + unit=_field_unit("airspeed_kts", "airspeed"), + ymin=CHART_YLIM_AIRSPEED[0], + ymax=CHART_YLIM_AIRSPEED[1], + side="left", + color_key="airspeed", + ), + "groundspeed": FieldDef( + id="groundspeed", + csv_key="groundspeed_kts", + label="地速", + unit=_field_unit("groundspeed_kts", "groundspeed"), + ymin=CHART_YLIM_GROUNDSPEED[0], + ymax=CHART_YLIM_GROUNDSPEED[1], + side="left", + color_key="groundspeed", + ), + "speed_rate": GroundspeedChangeRateField( + id="speed_rate", + csv_key="", + label="速度变化率", + unit=_field_unit("", "speed_rate"), + ymin=CHART_YLIM_SPEED_RATE[0], + ymax=CHART_YLIM_SPEED_RATE[1], + side="right", + color_key="speed_rate", + ), + "accel_rate": GroundspeedAccelRateField( + id="accel_rate", + csv_key="", + label="加速度变化率", + unit=_field_unit("", "accel_rate"), + ymin=CHART_YLIM_ACCEL_RATE[0], + ymax=CHART_YLIM_ACCEL_RATE[1], + side="right", + color_key="accel_rate", + ), + "health": FieldDef( + id="health", + csv_key="health_pct", + label="健康值", + unit=_field_unit("health_pct", "health"), + ymin=CHART_YLIM_HEALTH[0], + ymax=CHART_YLIM_HEALTH[1], + side="right", + color_key="health", + overlays=( + FieldOverlay( + kind="threshold_span", + enabled=True, + direction="x", + alpha=UI_CHART_HEALTH_SPAN_ALPHA, + colors=dict(DEFAULT_SPAN_COLORS), + resolver="health_span_kind", + z_order=10, + overlay_id="health_span", + ), + ), + ), + "torque": FieldDef( + id="torque", + csv_key="torque", + label="扭矩", + unit=_field_unit("torque", "torque"), + ymin=CHART_YLIM_TORQUE[0], + ymax=CHART_YLIM_TORQUE[1], + side="left", + color_key="torque", + ), + "pitch": FieldDef( + id="pitch", + csv_key="pitch_deg", + label="俯仰角", + unit=_field_unit("pitch_deg", "pitch"), + ymin=CHART_YLIM_PITCH[0], + ymax=CHART_YLIM_PITCH[1], + side="right", + color_key="pitch", + ), + "bank": FieldDef( + id="bank", + csv_key="bank_deg", + label="坡度", + unit=_field_unit("bank_deg", "bank"), + ymin=CHART_YLIM_BANK[0], + ymax=CHART_YLIM_BANK[1], + side="right", + color_key="bank", + ), + "eta_prop": FieldDef( + id="eta_prop", + csv_key="eta_prop", + label="推进效率", + unit=_field_unit("eta_prop", "eta_prop"), + ymin=CHART_YLIM_ETA_PROP[0], + ymax=CHART_YLIM_ETA_PROP[1], + side="right", + color_key="eta_prop", + ), + "di_proxy": FieldDef( + id="di_proxy", + csv_key="di_proxy", + label="诱导阻相对强度", + unit=_field_unit("di_proxy", "di_proxy"), + ymin=CHART_YLIM_DI_PROXY[0], + ymax=CHART_YLIM_DI_PROXY[1], + side="left", + color_key="di_proxy", + ), + "vg_course": FieldDef( + id="vg_course", + csv_key="vg_course_kts", + label="沿迹地速", + unit=_field_unit("vg_course_kts", "vg_course"), + ymin=CHART_YLIM_VG_COURSE[0], + ymax=CHART_YLIM_VG_COURSE[1], + side="left", + color_key="vg_course", + ), +} + +# 图级蒙版(不绑字段) +CHART_LEVEL_OVERLAYS: Tuple[ChartLevelOverlay, ...] = ( + ChartLevelOverlay( + overlay_id="lap_marker", + kind="lap_marker", + enabled=True, + z_order=20, + label_template="LAP {lap} FINISHED", + ), +) + + +# --------------------------------------------------------------------------- +# 行 / 帧出入 +# --------------------------------------------------------------------------- + +def as_row(source: Any) -> Dict[str, Any]: + """TrajectorySample / Mapping → 统一缓存行。""" + if isinstance(source, pd.Series): + return source.to_dict() + if isinstance(source, Mapping): + return dict(source) + keys = enabled_track_columns() + # 回放/旧样本可能含未启用列,一并透出 + extra = [k for k in SAMPLE_METRIC_KEYS if k not in keys] + out: Dict[str, Any] = {} + for key in list(keys) + extra: + if hasattr(source, key): + out[key] = getattr(source, key, float("nan")) + return out + + +def read_aq_snapshot( + aq, + *, + torque: Optional[float] = None, + health_ratio: Optional[float] = None, + timestamp_s: Optional[float] = None, + prev: Optional[Mapping[str, Any]] = None, + raw: Optional[Mapping[str, Any]] = None, +) -> Dict[str, Any]: + """ + 单拍 SimConnect 快照:图表行与轨迹录制共用同一读出口。 + 缺 aq 时仍返回图表字段(NaN);缺 lat/lon 时位置字段为 None。 + 扩录列受 setting.TRACK_RECORD_FIELD_ENABLE 控制。 + timestamp_s + prev:用于派生沿迹地速 ds_m / vg_course_kts(需相邻点)。 + raw:可选批量拉取字典(sim_var → 值);缺省时本函数内一次 batch_get。 + """ + from models.track_fields import fill_derived_fields, is_field_enabled + from services.sim_batch import batch_get_sim_vars, monitor_snapshot_keys, raw_get + + health_pct = ( + float(health_ratio) * 100.0 if health_ratio is not None else float("nan") + ) + torque_v = float(torque) if torque is not None else float("nan") + if not is_field_enabled("torque"): + torque_v = float("nan") + if not is_field_enabled("health_pct"): + health_pct = float("nan") + + empty = { + "latitude": None, + "longitude": None, + "altitude_ft": float("nan"), + "agl_ft": float("nan"), + "heading_deg": float("nan"), + "airspeed_kts": float("nan"), + "groundspeed_kts": float("nan"), + "pitch_deg": float("nan"), + "bank_deg": float("nan"), + "health_pct": health_pct, + "torque": torque_v, + } + if not aq: + empty.update({k: float("nan") for k in SAMPLE_METRIC_KEYS if k not in empty}) + return empty + + batch = raw + if batch is None: + batch = batch_get_sim_vars(aq, monitor_snapshot_keys()) + + lat = optional_float(raw_get(batch, PLANE_LAT_VAR, aq)) + lon = optional_float(raw_get(batch, PLANE_LON_VAR, aq)) + ground = optional_float(raw_get(batch, GROUND_SPEED_VAR, aq)) + ias = optional_float(raw_get(batch, AIRSPEED_VAR, aq)) + if ias is None: + ias = ground + alt_msl = optional_float(raw_get(batch, PLANE_ALT_VAR, aq), 0.0) or 0.0 + agl = optional_float(raw_get(batch, PLANE_AGL_VAR, aq), 0.0) or 0.0 + if agl <= 0.0 and alt_msl > 0.0: + ground_alt_m = raw_get(batch, GROUND_ALT_VAR, aq) + if ground_alt_m is not None: + ga = optional_float(ground_alt_m, 0.0) or 0.0 + agl = max(0.0, alt_msl - ga * METERS_TO_FEET) + + ts = float("nan") + if timestamp_s is not None: + try: + ts = float(timestamp_s) + except (TypeError, ValueError): + ts = float("nan") + + snap: Dict[str, Any] = { + "latitude": lat, + "longitude": lon, + "altitude_ft": float(alt_msl), + "agl_ft": float(agl), + "heading_deg": rad_to_deg(raw_get(batch, PLANE_HEADING_VAR, aq), float("nan")), + "airspeed_kts": float(ias) if ias is not None else float("nan"), + "groundspeed_kts": float(ground) if ground is not None else float("nan"), + "pitch_deg": rad_to_deg(raw_get(batch, PITCH_VAR, aq), float("nan")), + "bank_deg": rad_to_deg(raw_get(batch, BANK_VAR, aq), float("nan")), + "health_pct": health_pct, + "torque": torque_v, + "timestamp_s": ts, + } + snap.update(read_enabled_sim_fields(aq, raw=batch)) + + fill_row = {k: snap.get(k, float("nan")) for k in SAMPLE_METRIC_KEYS} + fill_row["latitude"] = lat if lat is not None else float("nan") + fill_row["longitude"] = lon if lon is not None else float("nan") + fill_row["altitude_ft"] = snap["altitude_ft"] + fill_row["timestamp_s"] = ts + fill_derived_fields(fill_row, prev=prev) + for key in ( + "p_shaft_w", + "p_prop_w", + "eta_prop", + "e_mech_j", + "di_proxy", + "ds_m", + "vg_course_kts", + ): + if key in fill_row: + snap[key] = fill_row[key] + return snap + + +def derive_prev_from_snap(snap: Mapping[str, Any]) -> Dict[str, float]: + """从本拍快照抽出下一拍 fill_derived 所需的 prev 字段。""" + out: Dict[str, float] = {} + for key in ("latitude", "longitude", "timestamp_s", "altitude_ft"): + try: + v = float(snap.get(key)) # type: ignore[arg-type] + except (TypeError, ValueError): + v = float("nan") + out[key] = v + return out + + +def row_from_aq( + aq, + *, + torque: Optional[float] = None, + health_ratio: Optional[float] = None, +) -> Dict[str, Any]: + """从 SimConnect 快照取图表列(列名同 CSV)。""" + snap = read_aq_snapshot(aq, torque=torque, health_ratio=health_ratio) + return { + "airspeed_kts": snap["airspeed_kts"], + "groundspeed_kts": snap["groundspeed_kts"], + "health_pct": snap["health_pct"], + "torque": snap["torque"], + "pitch_deg": snap["pitch_deg"], + "bank_deg": snap["bank_deg"], + } + + +def extract( + source: Any, + field_ids: Optional[Iterable[str]] = None, +) -> Dict[str, float]: + """缓存行 → 图表字段字典(跳过 NaN/Inf)。""" + row = as_row(source) + series = pd.Series(row) + ids = list(field_ids) if field_ids is not None else list(FIELDS.keys()) + out: Dict[str, float] = {} + for fid in ids: + field_def = FIELDS.get(fid) + if field_def is None: + continue + try: + value = float(field_def.extract_row(series)) + except (TypeError, ValueError): + continue + if not np.isfinite(value): + continue + out[fid] = value + return out + + +def samples_to_frame(samples: Sequence[Any]) -> pd.DataFrame: + """采样序列 → DataFrame。""" + if not samples: + return pd.DataFrame(columns=list(ROW_KEYS)) + return pd.DataFrame([as_row(s) for s in samples]) + + +def frame_to_history(df: pd.DataFrame, config=None): + """ + DataFrame → ChartHistoryBuffer(列经 FieldDef.extract_series 一次算齐后批量写入)。 + config 默认 TRAINING_CHART。 + """ + from models.chart import ChartHistoryBuffer, TRAINING_CHART + + buf = ChartHistoryBuffer(config or TRAINING_CHART) + if df is None or df.empty or "timestamp_s" not in df.columns: + return buf + ts = pd.to_numeric(df["timestamp_s"], errors="coerce").to_numpy(dtype=float) + cols = { + fid: fdef.extract_series(df).to_numpy(dtype=float) + for fid, fdef in FIELDS.items() + } + n = int(ts.size) + if n <= 0: + return buf + keep = np.isfinite(ts) + any_val = np.zeros(n, dtype=bool) + for arr in cols.values(): + if int(arr.size) == n: + any_val |= np.isfinite(arr) + keep &= any_val + if not bool(np.any(keep)): + return buf + buf.extend_from_arrays(ts[keep], {fid: arr[keep] for fid, arr in cols.items()}) + return buf + + +def trajectory_to_history(traj) -> Any: + """Trajectory.samples → history(对比与直播同一套 FieldDef)。""" + return frame_to_history(samples_to_frame(traj.samples)) + + +# --------------------------------------------------------------------------- +# 蒙版收集 → 供 charts.mask.registry 使用的 Spec +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class ThresholdSpanOverlaySpec: + """阈值色带蒙版规格(由 FieldOverlay 展开)。""" + + overlay_id: str + overlay_type: str = "threshold_span" + source_field_id: str = "health" + axis_field_id: str = "health" + direction: str = "x" + kind_resolver_name: str = "health_span_kind" + alpha: float = UI_CHART_HEALTH_SPAN_ALPHA + colors: Dict[str, str] = field(default_factory=lambda: dict(DEFAULT_SPAN_COLORS)) + z_order: int = 10 + enabled: bool = True + + +@dataclass(frozen=True) +class LapMarkerOverlaySpec: + """圈完成竖线/标签蒙版规格。""" + + overlay_id: str = "lap_marker" + overlay_type: str = "lap_marker" + z_order: int = 20 + enabled: bool = True + label_template: str = "LAP {lap} FINISHED" + + +@dataclass +class ChartOverlayConfig: + """蒙版规格列表容器。""" + + overlays: List[object] = field(default_factory=list) + + +def build_overlay_config( + fields: Optional[Mapping[str, FieldDef]] = None, + chart_overlays: Optional[Sequence[ChartLevelOverlay]] = None, +) -> ChartOverlayConfig: + """从 FieldDef.overlays + 图级蒙版组装配置(替代 JSON)。""" + field_map = fields if fields is not None else FIELDS + level = chart_overlays if chart_overlays is not None else CHART_LEVEL_OVERLAYS + specs: List[object] = [] + for fdef in field_map.values(): + for ov in fdef.overlays: + if ov.kind != "threshold_span": + continue + colors = dict(DEFAULT_SPAN_COLORS) + if ov.colors: + colors.update(ov.colors) + oid = ov.overlay_id or f"{fdef.id}_{ov.kind}" + specs.append( + ThresholdSpanOverlaySpec( + overlay_id=oid, + source_field_id=fdef.id, + axis_field_id=ov.axis_field_id or fdef.id, + direction=ov.direction or "x", + kind_resolver_name=ov.resolver or "health_span_kind", + alpha=float(ov.alpha) if ov.alpha is not None else UI_CHART_HEALTH_SPAN_ALPHA, + colors=colors, + z_order=int(ov.z_order) if ov.z_order is not None else 10, + enabled=bool(ov.enabled), + ) + ) + for cov in level: + if cov.kind == "lap_marker": + specs.append( + LapMarkerOverlaySpec( + overlay_id=cov.overlay_id, + z_order=int(cov.z_order), + enabled=bool(cov.enabled), + label_template=cov.label_template, + ) + ) + return ChartOverlayConfig(overlays=specs) + + +def kind_resolver(name: str): + """按名称查找阈值→配色键的解析函数。""" + from models.chart import health_span_kind + + resolvers = { + "health_span_kind": health_span_kind, + } + return resolvers.get(name) + + +def load_overlay_config() -> ChartOverlayConfig: + """从 FieldDef.overlays + CHART_LEVEL_OVERLAYS 组装蒙版配置。""" + return build_overlay_config() diff --git a/models/db_operate.py b/models/db_operate.py new file mode 100644 index 00000000..8fc4f37a --- /dev/null +++ b/models/db_operate.py @@ -0,0 +1,232 @@ +# -*- coding: utf-8 -*- +""" +数据库访问封装: + +- 通过 PooledDB 管理 MySQL 连接池,降低频繁建连的开销; +- 以线程池 + 任务队列的方式执行 SQL,可按需获取异步结果; +- 暴露统一的 auth_db_query / auth_db_execute / app_db_query / app_db_execute,供业务层同步使用。 +""" + +from __future__ import annotations + +import os +import queue +import time +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Callable, Optional + +import pymysql +from dbutils.pooled_db import PooledDB + +import setting as se + + +class QueryTask: + """封装单个数据库查询/更新任务。""" + + def __init__( + self, + query_type: str, + sql: str, + params: Optional[tuple] = None, + callback: Optional[Callable[[Any], None]] = None, + task_id: Optional[str] = None, + ) -> None: + self.query_type: str = query_type + self.sql: str = sql + self.params: Optional[tuple] = params + self.callback: Optional[Callable[[Any], None]] = callback + self.task_id: str = task_id or str(time.time()) + self.result: Any = None + self.error: Optional[str] = None + + +class DatabaseOperate: + """数据库操作基类:异步提交 + 按 task_id 取回结果。""" + + def __init__(self, db_type_name: str = "未知数据库") -> None: + self.db_type_name: str = db_type_name + cpu_cores = os.cpu_count() + self.max_workers = cpu_cores if cpu_cores else 2 + self.pool: Optional[PooledDB] = None + self.result_queue: Optional[queue.Queue] = None + self.executor: Optional[ThreadPoolExecutor] = None + + def process_task(self, pool: PooledDB, task: QueryTask, result_queue: queue.Queue) -> None: + try: + conn = pool.connection() + try: + with conn.cursor() as cursor: + cursor.execute(task.sql, task.params) + if task.query_type == "query": + task.result = cursor.fetchall() + else: + conn.commit() + task.result = { + "rowcount": cursor.rowcount, + "lastrowid": cursor.lastrowid, + } + finally: + conn.close() + + if task.callback: + task.callback(task.result) + + result_queue.put(task) + except Exception as e: + task.error = str(e) + result_queue.put(task) + print(f"{self.db_type_name}任务处理失败: {str(e)}") + + def execute_query( + self, + sql: str, + params: Optional[tuple] = None, + callback: Optional[Callable[[Any], None]] = None, + task_id: Optional[str] = None, + ) -> str: + if self.pool is None or self.executor is None or self.result_queue is None: + raise AttributeError(f"{self.db_type_name}的连接池、结果队列或线程池未初始化") + task = QueryTask("query", sql, params, callback, task_id) + self.executor.submit(self.process_task, self.pool, task, self.result_queue) + return task.task_id + + def execute_update( + self, + sql: str, + params: Optional[tuple] = None, + callback: Optional[Callable[[Any], None]] = None, + task_id: Optional[str] = None, + ) -> str: + if self.pool is None or self.executor is None or self.result_queue is None: + raise AttributeError(f"{self.db_type_name}的连接池、结果队列或线程池未初始化") + task = QueryTask("update", sql, params, callback, task_id) + self.executor.submit(self.process_task, self.pool, task, self.result_queue) + return task.task_id + + def get_result(self, task_id: str, timeout: float = 5.0) -> Any: + if self.result_queue is None: + raise AttributeError(f"{self.db_type_name}的结果队列未初始化") + try: + while True: + task = self.result_queue.get(timeout=timeout) + if task.task_id == task_id: + if task.error: + raise Exception(f"{self.db_type_name}查询执行失败: {task.error}") + return task.result + self.result_queue.put(task) + except queue.Empty: + return None + except Exception as e: + print(f"获取{self.db_type_name}查询结果时发生错误: {str(e)}") + raise + + +def _connect_kwargs(config: dict) -> dict: + """从配置字典抽出 pymysql 连接参数。""" + keys = ( + "host", + "port", + "user", + "password", + "charset", + "connect_timeout", + "read_timeout", + "write_timeout", + "autocommit", + "ssl_disabled", + ) + out = {k: config[k] for k in keys if k in config} + out["database"] = config["db"] + return out + + +def _pymysql_connect(**kwargs): + """兼容旧版 pymysql(可能不认 ssl_disabled)。""" + try: + return pymysql.connect(**kwargs) + except TypeError: + kwargs.pop("ssl_disabled", None) + return pymysql.connect(**kwargs) + + +class PooledDatabaseManager(DatabaseOperate): + """参数化连接池管理器;config 需含 host/port/user/password/db/charset。""" + + def __init__( + self, + db_type_name: str, + config: dict, + *, + mincached: int, + maxcached: int, + maxconnections: int, + ) -> None: + super().__init__(db_type_name=db_type_name) + self.pool = PooledDB( + creator=_pymysql_connect, + maxconnections=maxconnections, + mincached=mincached, + maxcached=maxcached, + maxshared=2, + blocking=True, + maxusage=None, + setsession=[], + ping=0, + cursorclass=pymysql.cursors.DictCursor, + **_connect_kwargs(config), + ) + self.result_queue = queue.Queue() + self.executor = ThreadPoolExecutor( + max_workers=self.max_workers, + thread_name_prefix=db_type_name, + ) + + +# mincached=0:避免 import 时预建连(登录页需先做网络探测) +app_db_manager = PooledDatabaseManager( + "AppDB", + se.app_db_config, + mincached=0, + maxcached=4, + maxconnections=8, +) + +auth_db_manager = PooledDatabaseManager( + "AuthDB", + se.auth_db_config, + mincached=0, + maxcached=4, + maxconnections=max(4, (os.cpu_count() or 2) + 2), +) + + +def _wait_result(manager: PooledDatabaseManager, task_id: str, timeout: float) -> Any: + result = manager.get_result(task_id, timeout=timeout) + if result is None: + raise TimeoutError(f"{manager.db_type_name} 等待结果超时({timeout}s)") + return result + + +def auth_db_query(sql: str, params: Optional[tuple] = None, timeout: float = 8.0) -> Any: + """AuthDB 同步查询中间件。""" + task_id = auth_db_manager.execute_query(sql, params) + return _wait_result(auth_db_manager, task_id, timeout) + + +def auth_db_execute(sql: str, params: Optional[tuple] = None, timeout: float = 8.0) -> Any: + """AuthDB 同步写中间件(INSERT/UPDATE/DELETE)。""" + task_id = auth_db_manager.execute_update(sql, params) + return _wait_result(auth_db_manager, task_id, timeout) + + +def app_db_query(sql: str, params: Optional[tuple] = None, timeout: float = 8.0) -> Any: + """AppDB 同步查询中间件。""" + task_id = app_db_manager.execute_query(sql, params) + return _wait_result(app_db_manager, task_id, timeout) + + +def app_db_execute(sql: str, params: Optional[tuple] = None, timeout: float = 8.0) -> Any: + """AppDB 同步写中间件。""" + task_id = app_db_manager.execute_update(sql, params) + return _wait_result(app_db_manager, task_id, timeout) diff --git a/models/hardware_monitor.py b/models/hardware_monitor.py new file mode 100644 index 00000000..efec1ef0 --- /dev/null +++ b/models/hardware_monitor.py @@ -0,0 +1,731 @@ +# -*- coding: utf-8 -*- +""" +模块:models.hardware_monitor +职责:USB 游戏控制器枚举、轴映射/校准;对外唯一摇杆取数口(UI + 轨迹) +依赖:pygame(SDL 摇杆);用户设置经 user_defined_4 云存(零本地落盘) +""" + +from __future__ import annotations + +import os +from dataclasses import asdict, dataclass, field +from typing import Any, Dict, List, Optional, Sequence, Tuple + +# --------------------------------------------------------------------------- +# 轴语义(pygame 轴序号由设定页映射) +# --------------------------------------------------------------------------- + +AXIS_ROLES: Tuple[str, ...] = ("pitch", "roll", "yaw", "throttle") +AXIS_ROLE_LABELS = { + "pitch": "俯仰", + "roll": "滚转", + "yaw": "偏航", + "throttle": "油门", +} +# pygame 轴上限;具体设备轴数以枚举结果为准 +PHYSICAL_AXIS_MAX = 16 + +# pygame.get_axis() 典型范围 +_DEFAULT_RAW_MIN = -1.0 +_DEFAULT_RAW_MAX = 1.0 +_DEFAULT_RAW_NEUTRAL = 0.0 + +_CONFIG_GENERATION = 0 +_pygame_mod: Any = None +_pygame_ready = False +# 登录后注入;未登录则仅内存、不能云存 +_session_user_id: Optional[str] = None +_session_config: Optional["HardwareMonitorConfig"] = None + + +def config_generation() -> int: + return _CONFIG_GENERATION + + +def set_settings_user_id(user_id: Optional[str]) -> None: + """登录成功后注入;登出传 None。供 save_config 写 user_defined_4。""" + global _session_user_id + _session_user_id = str(user_id or "").strip() or None + + +def settings_user_id() -> Optional[str]: + return _session_user_id + + +def physical_axis_label(index: int) -> str: + return f"Axis{int(index)}" + + +# --------------------------------------------------------------------------- +# 数据结构 +# --------------------------------------------------------------------------- + + +@dataclass +class AxisCalibration: + """单轴映射与校准(raw → [-1, 1],0 为中立)。""" + + index: int = 0 + invert: bool = False + raw_min: float = _DEFAULT_RAW_MIN + raw_max: float = _DEFAULT_RAW_MAX + raw_neutral: float = _DEFAULT_RAW_NEUTRAL + + def clamp_index(self, axis_count: Optional[int] = None) -> None: + limit = PHYSICAL_AXIS_MAX - 1 + if axis_count is not None and axis_count > 0: + limit = max(0, int(axis_count) - 1) + self.index = max(0, min(limit, int(self.index))) + + def ensure_order(self) -> None: + lo = min(float(self.raw_min), float(self.raw_max)) + hi = max(float(self.raw_min), float(self.raw_max)) + self.raw_min = lo + self.raw_max = hi + self.raw_neutral = max(lo, min(hi, float(self.raw_neutral))) + + def normalize(self, raw: float) -> float: + """将原始轴值归一到 [-1, 1],中立为 0。""" + self.ensure_order() + v = float(raw) + n = float(self.raw_neutral) + lo = float(self.raw_min) + hi = float(self.raw_max) + if v >= n: + span = hi - n + out = (v - n) / span if span > 1e-6 else 0.0 + else: + span = n - lo + out = (v - n) / span if span > 1e-6 else 0.0 + out = max(-1.0, min(1.0, out)) + if self.invert: + out = -out + return out + + +@dataclass +class DeviceIdentity: + """用于持久化匹配的设备身份。""" + + index: int = 0 + name: str = "" + vendor_id: int = 0 + product_id: int = 0 + guid: str = "" + + def matches(self, other: "DeviceIdentity") -> bool: + if other is None: + return False + if self.guid and other.guid and self.guid == other.guid: + return True + if self.vendor_id and self.product_id: + if self.vendor_id == other.vendor_id and self.product_id == other.product_id: + return True + if self.name and other.name and self.name == other.name: + return True + return False + + +@dataclass +class JoystickInfo: + """枚举得到的设备快照。""" + + index: int + name: str + vendor_id: int + product_id: int + num_axes: int + num_buttons: int + guid: str = "" + axis_raw_ranges: Dict[int, Tuple[float, float]] = field(default_factory=dict) + + def identity(self) -> DeviceIdentity: + return DeviceIdentity( + index=self.index, + name=self.name, + vendor_id=self.vendor_id, + product_id=self.product_id, + guid=self.guid, + ) + + +@dataclass +class StickAxes: + """归一化操纵输出;各轴 [-1, 1],0 为中立。""" + + pitch: float = 0.0 + roll: float = 0.0 + yaw: float = 0.0 + throttle: float = 0.0 + connected: bool = False + device_name: str = "" + + def as_dict(self) -> Dict[str, float]: + return { + "pitch": self.pitch, + "roll": self.roll, + "yaw": self.yaw, + "throttle": self.throttle, + } + + def record_fields(self) -> Dict[str, float]: + """轨迹 CSV / TrajectorySample 字段;未连接返回空 dict(样本保持 nan)。""" + if not self.connected: + return {} + return { + "stick_pitch": float(self.pitch), + "stick_roll": float(self.roll), + "stick_yaw": float(self.yaw), + "stick_throttle": float(self.throttle), + } + + +@dataclass +class HardwareMonitorConfig: + """设定页写入、监控器只读消费的完整配置。""" + + device: DeviceIdentity = field(default_factory=DeviceIdentity) + axes: Dict[str, AxisCalibration] = field(default_factory=dict) + calibrated: bool = False + + def __post_init__(self) -> None: + if not self.axes: + self.axes = default_axis_map() + for role in AXIS_ROLES: + if role not in self.axes: + self.axes[role] = default_axis_map()[role] + cal = self.axes[role] + if isinstance(cal, dict): + # JSON / 会话字典 → AxisCalibration + payload = dict(cal) + self.axes[role] = AxisCalibration( + index=int(payload.get("index", 0)), + invert=bool(payload.get("invert", False)), + raw_min=float(payload.get("raw_min", _DEFAULT_RAW_MIN)), + raw_max=float(payload.get("raw_max", _DEFAULT_RAW_MAX)), + raw_neutral=float(payload.get("raw_neutral", _DEFAULT_RAW_NEUTRAL)), + ) + self.axes[role].clamp_index() + self.axes[role].ensure_order() + if isinstance(self.device, dict): + d = dict(self.device) + self.device = DeviceIdentity( + index=int(d.get("index", 0)), + name=str(d.get("name", "") or ""), + vendor_id=int(d.get("vendor_id", 0)), + product_id=int(d.get("product_id", 0)), + guid=str(d.get("guid", "") or ""), + ) + + def axis(self, role: str) -> AxisCalibration: + return self.axes[role] + + +def default_axis_map() -> Dict[str, AxisCalibration]: + """常见飞行摇杆默认:0=滚转 1=俯仰 2=油门 3=偏航。""" + return { + "roll": AxisCalibration(index=0), + "pitch": AxisCalibration(index=1), + "throttle": AxisCalibration(index=2, invert=True), + "yaw": AxisCalibration(index=3), + } + + +def default_config() -> HardwareMonitorConfig: + return HardwareMonitorConfig( + device=DeviceIdentity(), + axes=default_axis_map(), + calibrated=False, + ) + + +# --------------------------------------------------------------------------- +# 配置读写(内存会话 + 登录后 user_defined_4;禁止成品写本地 JSON) +# --------------------------------------------------------------------------- + + +def config_to_dict(config: HardwareMonitorConfig) -> Dict[str, Any]: + return { + "device": asdict(config.device), + "axes": {role: asdict(config.axis(role)) for role in AXIS_ROLES}, + "calibrated": bool(config.calibrated), + } + + +def config_from_dict(data: Optional[Dict[str, Any]]) -> HardwareMonitorConfig: + if not isinstance(data, dict): + return default_config() + device = data.get("device") or {} + axes_raw = data.get("axes") or {} + axes: Dict[str, AxisCalibration] = {} + defaults = default_axis_map() + for role in AXIS_ROLES: + src = axes_raw.get(role) or {} + if not isinstance(src, dict): + src = {} + # 旧 winmm 量程(0..65535)自动迁移为 pygame 量程 + raw_min = float(src.get("raw_min", _DEFAULT_RAW_MIN)) + raw_max = float(src.get("raw_max", _DEFAULT_RAW_MAX)) + raw_neutral = float(src.get("raw_neutral", _DEFAULT_RAW_NEUTRAL)) + if raw_max > 2.0 or raw_min < -2.0: + raw_min, raw_max, raw_neutral = ( + _DEFAULT_RAW_MIN, + _DEFAULT_RAW_MAX, + _DEFAULT_RAW_NEUTRAL, + ) + axes[role] = AxisCalibration( + index=int(src.get("index", defaults[role].index)), + invert=bool(src.get("invert", defaults[role].invert)), + raw_min=raw_min, + raw_max=raw_max, + raw_neutral=raw_neutral, + ) + return HardwareMonitorConfig( + device=DeviceIdentity( + index=int(device.get("index", 0)) if isinstance(device, dict) else 0, + name=str((device or {}).get("name", "") or "") if isinstance(device, dict) else "", + vendor_id=int((device or {}).get("vendor_id", 0) or 0) if isinstance(device, dict) else 0, + product_id=int((device or {}).get("product_id", 0) or 0) if isinstance(device, dict) else 0, + guid=str((device or {}).get("guid", "") or "") if isinstance(device, dict) else "", + ), + axes=axes, + calibrated=bool(data.get("calibrated", False)), + ) + + +def apply_session_config(config: HardwareMonitorConfig) -> HardwareMonitorConfig: + """仅更新进程内会话配置(不落盘、不写库)。""" + global _session_config, _CONFIG_GENERATION + _session_config = config + _CONFIG_GENERATION += 1 + return config + + +def load_config() -> HardwareMonitorConfig: + """返回当前会话配置;无则默认。""" + if _session_config is not None: + return _session_config + return default_config() + + +def load_config_from_cloud(user_id: str) -> HardwareMonitorConfig: + """登录后从 user_defined_4 拉取摇杆设置并写入会话。""" + from models.user_settings_store import get_hardware_monitor + + raw = get_hardware_monitor(user_id) + cfg = config_from_dict(raw) if raw else default_config() + return apply_session_config(cfg) + + +def ensure_bound_device(config: HardwareMonitorConfig) -> bool: + """ + 若配置无法解析到当前已连接设备,则绑定列表第一台(与设定页「缺省选首台」一致)。 + 返回是否改写了 device。 + """ + devs = list_joysticks() + if not devs: + return False + if resolve_device_index(config.device, devs) is not None: + return False + config.device = devs[0].identity() + return True + + +def bind_session_device(*, persist_if_logged_in: bool = True) -> HardwareMonitorConfig: + """会话配置补齐设备身份;已登录且有改写时可写回 UD4。""" + cfg = load_config() + changed = ensure_bound_device(cfg) + apply_session_config(cfg) + if changed and persist_if_logged_in and _session_user_id: + try: + from models.user_settings_store import set_hardware_monitor + + set_hardware_monitor(_session_user_id, config_to_dict(cfg)) + except Exception: + pass + return cfg + + +def save_config( + config: HardwareMonitorConfig, + *, + require_cloud: bool = True, +) -> HardwareMonitorConfig: + """ + 更新内存配置;已登录则一次 UPDATE user_defined_4。 + require_cloud=True(设定页):未登录抛错。 + require_cloud=False(面板切设备):未登录仅内存。 + """ + apply_session_config(config) + uid = _session_user_id + if not uid: + if require_cloud: + from models.user_settings_store import UserSettingsNeedLogin + import setting as se + + raise UserSettingsNeedLogin(se.MSG_USER_SETTINGS_NEED_LOGIN) + return config + from models.user_settings_store import set_hardware_monitor + + set_hardware_monitor(uid, config_to_dict(config)) + return config + + +# --------------------------------------------------------------------------- +# pygame / SDL 后端 +# --------------------------------------------------------------------------- + + +def _parse_sdl_guid(guid: str) -> Tuple[int, int]: + """从 SDL joystick GUID 解析 vendor/product(USB 布局)。""" + g = (guid or "").strip().lower() + if len(g) < 20: + return 0, 0 + try: + raw = bytes.fromhex(g) + except ValueError: + return 0, 0 + if len(raw) < 10: + return 0, 0 + vendor = int.from_bytes(raw[4:6], "little") + product = int.from_bytes(raw[8:10], "little") + return vendor, product + + +def _ensure_pygame() -> bool: + """初始化 pygame 摇杆;display 用 dummy,保证 event.pump 可用且不闪 SDL 窗。""" + global _pygame_mod, _pygame_ready + if _pygame_ready and _pygame_mod is not None: + return True + try: + # Windows 专属 DPI 感知:必须在 import pygame 之前(见 app_bootstrap 注释) + from app_bootstrap import apply_windows_dpi_aware_for_pygame + + apply_windows_dpi_aware_for_pygame() + # 必须在 import pygame 之前强制写入(setdefault 不够) + os.environ["SDL_VIDEODRIVER"] = "dummy" + os.environ["SDL_AUDIODRIVER"] = "dummy" + import pygame + + # joystick 读轴依赖 event.pump,而 pump 需要已初始化的 video 子系统; + # 用 dummy 驱动做 pygame.init(),避免真开窗,同时让轴值能刷新。 + if not pygame.get_init(): + pygame.init() + if not pygame.joystick.get_init(): + pygame.joystick.init() + _pygame_mod = pygame + _pygame_ready = True + return True + except Exception: + _pygame_mod = None + _pygame_ready = False + return False + + +def _pump_events() -> None: + pg = _pygame_mod + if pg is None: + return + try: + pg.event.pump() + except Exception: + pass + + +def refresh_joystick_subsystem() -> None: + """重新扫描热插拔设备。""" + if not _ensure_pygame(): + return + pg = _pygame_mod + try: + pg.joystick.quit() + except Exception: + pass + try: + pg.joystick.init() + except Exception: + pass + + +def list_joysticks(*, rescan: bool = False) -> List[JoystickInfo]: + """枚举当前 pygame 可见的游戏控制器。""" + if not _ensure_pygame(): + return [] + if rescan: + refresh_joystick_subsystem() + pg = _pygame_mod + _pump_events() + out: List[JoystickInfo] = [] + try: + n = int(pg.joystick.get_count()) + except Exception: + return [] + for i in range(n): + try: + joy = pg.joystick.Joystick(i) + joy.init() + name = str(joy.get_name() or f"Joystick {i}") + num_axes = int(joy.get_numaxes()) + num_buttons = int(joy.get_numbuttons()) + guid = "" + if hasattr(joy, "get_guid"): + try: + guid = str(joy.get_guid() or "") + except Exception: + guid = "" + vendor_id, product_id = _parse_sdl_guid(guid) + ranges = {a: (_DEFAULT_RAW_MIN, _DEFAULT_RAW_MAX) for a in range(num_axes)} + out.append( + JoystickInfo( + index=i, + name=name, + vendor_id=vendor_id, + product_id=product_id, + num_axes=num_axes, + num_buttons=num_buttons, + guid=guid, + axis_raw_ranges=ranges, + ) + ) + except Exception: + continue + return out + + +def resolve_device_index( + identity: DeviceIdentity, + devices: Optional[Sequence[JoystickInfo]] = None, +) -> Optional[int]: + """按配置身份在当前设备列表中解析 index;未在设定页选定设备时返回 None。""" + devs = list(devices) if devices is not None else list_joysticks() + if identity is None: + return None + has_id = ( + bool((identity.guid or "").strip()) + or bool((identity.name or "").strip()) + or (int(identity.vendor_id) and int(identity.product_id)) + ) + if not has_id: + return None + for d in devs: + if identity.matches(d.identity()): + return d.index + for d in devs: + if d.index == identity.index and (not identity.name or identity.name == d.name): + return d.index + return None + + +def open_joystick(device_index: int) -> Any: + """打开并初始化指定 index 的 pygame.Joystick。""" + if not _ensure_pygame(): + return None + pg = _pygame_mod + _pump_events() + try: + if device_index < 0 or device_index >= int(pg.joystick.get_count()): + return None + joy = pg.joystick.Joystick(int(device_index)) + joy.init() + return joy + except Exception: + return None + + +def read_raw_axes(device_index: int, joy: Any = None) -> Optional[List[float]]: + """读取指定设备全部轴原始值(pygame:通常约 [-1, 1])。""" + if not _ensure_pygame(): + return None + _pump_events() + handle = joy + if handle is None: + handle = open_joystick(device_index) + if handle is None: + return None + try: + n = int(handle.get_numaxes()) + return [float(handle.get_axis(i)) for i in range(n)] + except Exception: + return None + + +# --------------------------------------------------------------------------- +# 校准辅助 +# --------------------------------------------------------------------------- + + +def capture_neutral(config: HardwareMonitorConfig, raw: Sequence[float]) -> HardwareMonitorConfig: + """用当前原始值写入各映射轴的中立位。""" + for role in AXIS_ROLES: + cal = config.axis(role) + idx = cal.index + if 0 <= idx < len(raw): + cal.raw_neutral = float(raw[idx]) + cal.ensure_order() + return config + + +def capture_travel_extents( + config: HardwareMonitorConfig, + samples: Sequence[Sequence[float]], +) -> HardwareMonitorConfig: + """根据采样序列确认各映射轴最大行程(min/max)。""" + if not samples: + return config + for role in AXIS_ROLES: + cal = config.axis(role) + idx = cal.index + vals = [float(s[idx]) for s in samples if 0 <= idx < len(s)] + if not vals: + continue + cal.raw_min = min(vals) + cal.raw_max = max(vals) + cal.ensure_order() + config.calibrated = True + return config + + +def seed_ranges_from_device(config: HardwareMonitorConfig, info: JoystickInfo) -> HardwareMonitorConfig: + """用设备能力范围初始化未校准轴的 min/max。""" + for role in AXIS_ROLES: + cal = config.axis(role) + cal.clamp_index(info.num_axes) + rng = info.axis_raw_ranges.get(cal.index) + if rng is None: + cal.raw_min, cal.raw_max = _DEFAULT_RAW_MIN, _DEFAULT_RAW_MAX + else: + cal.raw_min, cal.raw_max = float(rng[0]), float(rng[1]) + cal.raw_neutral = (cal.raw_min + cal.raw_max) * 0.5 + cal.ensure_order() + return config + + +# --------------------------------------------------------------------------- +# 监控器:只接受设定参数,输出归一化数据 +# --------------------------------------------------------------------------- + + +class HardwareMonitor: + """ + 运行时监控器。 + 调用方传入 HardwareMonitorConfig(或已保存配置),poll() 输出 StickAxes。 + """ + + def __init__(self, config: Optional[HardwareMonitorConfig] = None) -> None: + self._config = config or load_config() + self._device_index: Optional[int] = None + self._device_name = "" + self._joy: Any = None + self._config_gen = config_generation() + self._rebind() + + @property + def config(self) -> HardwareMonitorConfig: + return self._config + + def apply_config(self, config: HardwareMonitorConfig) -> None: + """接受设定页参数并重新绑定设备。""" + self._config = config + self._config_gen = config_generation() + self._rebind() + + def reload(self) -> None: + self.apply_config(load_config()) + + def _sync_saved_config(self) -> None: + """设定页保存后自动热加载。""" + gen = config_generation() + if gen != self._config_gen: + self.reload() + + def _close_joy(self) -> None: + self._joy = None + + def _rebind(self) -> None: + self._close_joy() + devs = list_joysticks() + idx = resolve_device_index(self._config.device, devs) + self._device_index = idx + self._device_name = "" + if idx is None: + return + for d in devs: + if d.index == idx: + self._device_name = d.name + break + self._joy = open_joystick(idx) + + def connected(self) -> bool: + return self._device_index is not None and self._joy is not None + + def poll_raw(self) -> Optional[List[float]]: + self._sync_saved_config() + if self._device_index is None or self._joy is None: + self._rebind() + if self._device_index is None: + return None + raw = read_raw_axes(self._device_index, self._joy) + if raw is None: + self._device_index = None + self._device_name = "" + self._close_joy() + return None + return raw + + def poll(self) -> StickAxes: + """读取操作系统原始轴值(仅按设定选轴 / 取反,不做量程归一化)。""" + raw = self.poll_raw() + if raw is None: + return StickAxes(connected=False, device_name="") + cfg = self._config + + def _axis_value(role: str) -> float: + cal = cfg.axis(role) + if 0 <= cal.index < len(raw): + v = float(raw[cal.index]) + return -v if cal.invert else v + return 0.0 + + return StickAxes( + pitch=_axis_value("pitch"), + roll=_axis_value("roll"), + yaw=_axis_value("yaw"), + throttle=_axis_value("throttle"), + connected=True, + device_name=self._device_name, + ) + + +# --------------------------------------------------------------------------- +# 对外唯一取数口(UI 显示与轨迹 CSV 录制均经此) +# --------------------------------------------------------------------------- + +_shared_monitor: Optional[HardwareMonitor] = None + + +def shared_monitor() -> HardwareMonitor: + """进程内唯一运行时监控器;推迟创建以避免启动时 pygame 闪窗。""" + global _shared_monitor + if _shared_monitor is None: + _shared_monitor = HardwareMonitor(load_config()) + return _shared_monitor + + +def poll_stick() -> StickAxes: + """摇杆操纵数据唯一对外接口。UI 与航迹采样均从此取数,勿另建 HardwareMonitor 读业务轴。""" + return shared_monitor().poll() + + +def reload_stick() -> StickAxes: + """重新加载硬件设定并确保绑定当前设备后立刻采样。""" + bind_session_device(persist_if_logged_in=False) + mon = shared_monitor() + mon.reload() + return mon.poll() + + +def stick_record_fields(axes: Optional[StickAxes] = None) -> Dict[str, float]: + """将轴数据转为轨迹采样字段;未传 axes 时内部调用 poll_stick()。""" + if axes is None: + axes = poll_stick() + return axes.record_fields() diff --git a/models/lap_reference.py b/models/lap_reference.py new file mode 100644 index 00000000..a39dc5a9 --- /dev/null +++ b/models/lap_reference.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +""" +模块:models.lap_reference +职责:四圈圈时数据结构与会话条目提取 +依赖:无 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import List, Optional, Sequence + + +LAP_COUNT = 4 + + +@dataclass +class LapReference: + """四圈圈时参考(对比蛛网用)。""" + + laps_s: List[Optional[float]] + label: str = "" + + def complete(self) -> bool: + """四圈均有有效正数时长。""" + return len(self.laps_s) >= LAP_COUNT and all( + t is not None and t == t and t > 0 for t in self.laps_s[:LAP_COUNT] + ) + + def as_four(self) -> List[Optional[float]]: + """固定长度 4 的圈时列表(不足补 None)。""" + out: List[Optional[float]] = list(self.laps_s[:LAP_COUNT]) + while len(out) < LAP_COUNT: + out.append(None) + return out + + +def laps_from_session(entries: Sequence[dict], rows: int = LAP_COUNT) -> List[Optional[float]]: + """从 app_controller._lap_times 条目生成 [lap1..lapN]。""" + by_lap = {int(x["lap"]): float(x["time_s"]) for x in entries if x.get("time_s") is not None} + return [by_lap.get(i + 1) for i in range(rows)] + + +def lap_penalties_from_session( + entries: Sequence[dict], rows: int = LAP_COUNT +) -> List[Optional[float]]: + """从会话圈条目生成 [lap1..lapN] 本圈罚时(优先 penalty_delta_s)。""" + ordered = sorted( + (x for x in entries if x.get("lap") is not None), + key=lambda x: int(x["lap"]), + ) + by_lap: dict = {} + prev_cum = 0.0 + for item in ordered: + lap_no = int(item["lap"]) + if "penalty_delta_s" in item: + try: + delta = float(item.get("penalty_delta_s") or 0.0) + except (TypeError, ValueError): + delta = 0.0 + else: + try: + cum = float(item.get("penalty_s") or 0.0) + except (TypeError, ValueError): + cum = 0.0 + delta = cum - prev_cum + prev_cum = cum + by_lap[lap_no] = delta if delta > 0 and delta == delta else None + return [by_lap.get(i + 1) for i in range(rows)] diff --git a/models/mission_config.py b/models/mission_config.py new file mode 100644 index 00000000..9e98baf3 --- /dev/null +++ b/models/mission_config.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +""" +模块:models.mission_config +职责:Missions/{任务}/config_{任务}.csv 路径与行列读取 +依赖:无(纯路径/CSV) +""" + +from __future__ import annotations + +import csv +from pathlib import Path +from typing import Dict, List, Optional, Tuple + + +def mission_config_path(mission_dir: Path) -> Path: + """Missions/{任务}/config_{任务}.csv。""" + return Path(mission_dir) / f"config_{Path(mission_dir).name}.csv" + + +def _field_key(fieldnames: Optional[List[str]], want: str) -> Optional[str]: + want_l = want.lower() + for name in fieldnames or (): + if name and name.strip().lower() == want_l: + return name + return None + + +def read_mission_config_rows(mission_dir: Path) -> List[Dict[str, str]]: + """ + 读取 config_{任务}.csv 为行字典列表。 + 约定列:display_name, min_sec, max_sec, flt_rel + (多行时显示名/时间填首行,后续行可仅填 flt_rel)。 + """ + path = mission_config_path(mission_dir) + if not path.is_file(): + return [] + try: + with path.open("r", encoding="utf-8-sig", newline="") as f: + reader = csv.DictReader(f) + if not reader.fieldnames: + return [] + return [dict(row) for row in reader] + except Exception: + return [] + + +def load_config_time_bounds( + mission_dir: Path, +) -> Optional[Tuple[float, float]]: + """取首个同时给出 min_sec/max_sec 的行;无效则 None。""" + k_min = k_max = None + for row in read_mission_config_rows(mission_dir): + if k_min is None: + k_min = _field_key(list(row.keys()), "min_sec") + k_max = _field_key(list(row.keys()), "max_sec") + if not k_min or not k_max: + return None + ms = str(row.get(k_min) or "").strip() + xs = str(row.get(k_max) or "").strip() + if not ms or not xs: + continue + try: + return float(ms), float(xs) + except ValueError: + continue + return None + + +def load_config_flt_refs(mission_dir: Path) -> List[str]: + """收集 flt_rel 列非空值(保序,多行=候选回退)。""" + out: List[str] = [] + k_flt = None + for row in read_mission_config_rows(mission_dir): + if k_flt is None: + k_flt = _field_key(list(row.keys()), "flt_rel") + if not k_flt: + return [] + text = str(row.get(k_flt) or "").strip().strip('"') + if text: + out.append(text) + return out + + +def load_config_display_name(mission_dir: Path) -> str: + """取首个非空 display_name(切换列表 UI 用;圈时板仍读 FLT Title)。""" + k_name = None + for row in read_mission_config_rows(mission_dir): + if k_name is None: + k_name = _field_key(list(row.keys()), "display_name") + if not k_name: + return "" + text = str(row.get(k_name) or "").strip() + if text: + return text + return "" diff --git a/models/mission_switch.py b/models/mission_switch.py new file mode 100644 index 00000000..9e2a32bf --- /dev/null +++ b/models/mission_switch.py @@ -0,0 +1,562 @@ +# -*- coding: utf-8 -*- +""" +模块:models.mission_switch +职责:扫描 Missions/{任务}/config_{任务}.csv,解析 FSX FLT 相对路径并探测存在性 +依赖:setting、models.mission_config +说明:模拟器根目录按登录用户会话缓存(来源 Auth UD4),禁止 .env 硬编码 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional, Sequence, Tuple, Union + +from models.mission_config import load_config_display_name, load_config_flt_refs +from setting import ( + FSX_MISSIONS_DIRNAME, + MISSIONS_DIR, +) + +# 未配置 display_name 时的列表占位(不回退文件名/目录名) +_MISSING_DISPLAY_NAME = "(未配置显示名)" + +# 当前登录会话的模拟器根目录(绝对路径);登出或未设定时为 None +_session_fsx_root: Optional[Path] = None + + +def normalize_fsx_root(path: Union[str, Path, None]) -> Path: + """规范化路径字符串;空 / 相对占位(如 ``.``)视为未设定。""" + if path is None: + return Path() + text = str(path).strip().strip('"') + if not text or text in (".", "./", ".\\"): + return Path() + return Path(text) + + +def is_valid_fsx_root(path: Union[str, Path, None]) -> bool: + """ + 本地是否为有效的模拟器安装根:非空、绝对路径、目录存在, + 且其下存在 Missions 子目录(与 FLT 拼接约定一致)。 + 禁止把 Path('.')(空串规范化结果)当成已配置。 + """ + root = normalize_fsx_root(path) + if not str(root): + return False + try: + root = root.expanduser() + except Exception: + pass + # 空 Path 的 str 在 Windows 为 '.',必须再拦一次相对路径 + if not root.is_absolute(): + return False + if not root.is_dir(): + return False + return (root / FSX_MISSIONS_DIRNAME).is_dir() + + +def set_session_fsx_root(path: Union[str, Path, None]) -> None: + """写入会话根目录;无效则清空。""" + global _session_fsx_root + root = normalize_fsx_root(path) + if not is_valid_fsx_root(root): + _session_fsx_root = None + return + try: + _session_fsx_root = root.expanduser() + except Exception: + _session_fsx_root = root + + +def clear_session_fsx_root() -> None: + """登出或取消登录时清空会话根目录。""" + global _session_fsx_root + _session_fsx_root = None + + +def get_session_fsx_root() -> Path: + """当前会话模拟器根目录;未设定返回空 Path(不可当已配置)。""" + return _session_fsx_root if _session_fsx_root is not None else Path() + + +@dataclass(frozen=True) +class MissionSwitchEntry: + """一条可切换任务;label 仅来自 config.display_name。""" + + label: str + flt_path: Path + exists: bool + + +def fsx_root_configured() -> bool: + """会话中是否已有本地有效的模拟器根目录。""" + if _session_fsx_root is None: + return False + return is_valid_fsx_root(_session_fsx_root) + + +def fsx_missions_dir() -> Path: + """{会话根}/Missions。""" + if _session_fsx_root is None: + return Path() + root = _session_fsx_root + if not is_valid_fsx_root(root): + return Path() + return root / FSX_MISSIONS_DIRNAME + + +def _coerce_install_root(path: Union[str, Path, None]) -> Optional[Path]: + """候选路径 → 有效安装根;若指向 exe 则上溯到含 Missions 的目录。""" + root = normalize_fsx_root(path) + if not str(root): + return None + try: + root = root.expanduser() + except Exception: + pass + if root.is_file(): + root = root.parent + # 若落在子目录,向上最多 4 层找含 Missions 的根 + cur = root + for _ in range(5): + if is_valid_fsx_root(cur): + return cur.resolve() if cur.exists() else cur + parent = cur.parent + if parent == cur: + break + cur = parent + return None + + +def _reg_read_str(hive, subkey: str, value_name: str) -> Optional[str]: + import winreg + + try: + with winreg.OpenKey(hive, subkey) as key: + raw, _ = winreg.QueryValueEx(key, value_name) + except OSError: + return None + text = str(raw or "").strip().strip('"') + return text or None + + +def _detect_from_registry() -> Optional[Path]: + """读 FSX 10.0 注册表 AppPath / SetupPath。""" + import sys + + if sys.platform != "win32": + return None + import winreg + + from setting import FSX_DETECT_REG_SUBKEYS, FSX_DETECT_REG_VALUE_NAMES + + hives = (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER) + for hive in hives: + for sub in FSX_DETECT_REG_SUBKEYS: + for name in FSX_DETECT_REG_VALUE_NAMES: + raw = _reg_read_str(hive, sub, name) + found = _coerce_install_root(raw) + if found is not None: + return found + return None + + +def _detect_from_steam() -> Optional[Path]: + """Steam 安装路径 + steamapps\\common\\FSX。""" + import sys + + if sys.platform != "win32": + return None + import winreg + + from setting import FSX_DETECT_STEAM_COMMON_REL, FSX_DETECT_STEAM_REG_SUBKEYS + + for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER): + for sub in FSX_DETECT_STEAM_REG_SUBKEYS: + steam = _reg_read_str(hive, sub, "InstallPath") + if not steam: + continue + cand = Path(steam) / FSX_DETECT_STEAM_COMMON_REL + found = _coerce_install_root(cand) + if found is not None: + return found + return None + + +def _detect_from_program_files() -> Optional[Path]: + """Program Files / Program Files (x86) 下常见默认目录。""" + import os + import sys + + from setting import FSX_DETECT_PROGRAM_FILES_RELS + + bases = [] + if sys.platform == "win32": + for key in ("ProgramFiles(x86)", "ProgramFiles", "PROGRAMFILES(X86)", "PROGRAMFILES"): + val = os.environ.get(key) + if val: + bases.append(Path(val)) + # 去重保序 + seen = set() + uniq = [] + for b in bases: + k = str(b).lower() + if k in seen: + continue + seen.add(k) + uniq.append(b) + for base in uniq: + for rel in FSX_DETECT_PROGRAM_FILES_RELS: + found = _coerce_install_root(base / rel) + if found is not None: + return found + return None + + +def _detect_from_running_process() -> Optional[Path]: + """若 fsx.exe / fsx_se.exe 正在运行,取其可执行文件目录上溯。""" + import ctypes + import sys + from ctypes import wintypes + + from setting import FSX_DETECT_EXE_NAMES + + if sys.platform != "win32": + return None + + kernel32 = ctypes.windll.kernel32 + TH32CS_SNAPPROCESS = 0x00000002 + INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + + class PROCESSENTRY32W(ctypes.Structure): + _fields_ = [ + ("dwSize", wintypes.DWORD), + ("cntUsage", wintypes.DWORD), + ("th32ProcessID", wintypes.DWORD), + ("th32DefaultHeapID", ctypes.POINTER(ctypes.c_ulong)), + ("th32ModuleID", wintypes.DWORD), + ("cntThreads", wintypes.DWORD), + ("th32ParentProcessID", wintypes.DWORD), + ("pcPriClassBase", ctypes.c_long), + ("dwFlags", wintypes.DWORD), + ("szExeFile", wintypes.WCHAR * 260), + ] + + want = {n.lower() for n in FSX_DETECT_EXE_NAMES} + snap = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) + if snap == INVALID_HANDLE_VALUE or snap is None: + return None + try: + entry = PROCESSENTRY32W() + entry.dwSize = ctypes.sizeof(PROCESSENTRY32W) + if not kernel32.Process32FirstW(snap, ctypes.byref(entry)): + return None + pids = [] + while True: + name = (entry.szExeFile or "").lower() + if name in want: + pids.append(int(entry.th32ProcessID)) + if not kernel32.Process32NextW(snap, ctypes.byref(entry)): + break + finally: + kernel32.CloseHandle(snap) + + QueryFullProcessImageNameW = getattr(kernel32, "QueryFullProcessImageNameW", None) + if QueryFullProcessImageNameW is None: + return None + for pid in pids: + handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if not handle: + continue + try: + buf = ctypes.create_unicode_buffer(1024) + size = wintypes.DWORD(len(buf)) + ok = QueryFullProcessImageNameW(handle, 0, buf, ctypes.byref(size)) + if not ok: + continue + found = _coerce_install_root(buf.value) + if found is not None: + return found + finally: + kernel32.CloseHandle(handle) + return None + + +def detect_default_fsx_root() -> Optional[Path]: + """ + 自动侦测本机 FSX 默认安装根目录(须通过 is_valid_fsx_root)。 + 优先级:运行中进程 → 注册表 → Steam → Program Files 常见路径。 + """ + for probe in ( + _detect_from_running_process, + _detect_from_registry, + _detect_from_steam, + _detect_from_program_files, + ): + try: + found = probe() + except Exception: + found = None + if found is not None and is_valid_fsx_root(found): + return found + return None + + +def resolve_fsx_root_auto(user_id: Optional[str] = None, *, persist: bool = True) -> bool: + """ + 无需弹窗:UD4 有效路径 → 会话;否则自动侦测 → 会话,并可写回 UD4。 + 成功返回 True;仍无法获得有效根目录返回 False(由调用方决定是否再弹窗)。 + """ + uid = str(user_id or "").strip() + + stored = "" + if uid: + try: + from models.user_settings_store import get_fsx_root + + stored = get_fsx_root(uid) or "" + except Exception: + stored = "" + + if is_valid_fsx_root(stored): + set_session_fsx_root(stored) + return True + + detected = detect_default_fsx_root() + if detected is None or not is_valid_fsx_root(detected): + clear_session_fsx_root() + return False + + path_text = str(detected) + set_session_fsx_root(path_text) + if persist and uid: + try: + from models.user_settings_store import set_fsx_root + + # 与已存不一致或本地失效时,用侦测结果覆盖写回 + if normalize_fsx_root(stored) != normalize_fsx_root(path_text): + set_fsx_root(uid, path_text) + except Exception: + pass + return True + + +def resolve_mission_flt_path(flt_ref: str) -> Path: + """ + 相对路径:拼到 {会话模拟器根}/Missions/ 下; + 绝对路径:原样使用。 + """ + text = (flt_ref or "").strip().strip('"') + if not text: + return Path() + p = Path(text) + if p.is_absolute(): + return p + base = fsx_missions_dir() + if not str(base): + return p + return base / p + + +def _probe_flt_file(path: Path) -> Tuple[Path, bool]: + """探测路径是否为已存在 .flt;缺后缀时尝试补 .FLT/.flt。""" + if not path: + return path, False + if path.is_file(): + return path, True + if path.suffix.lower() == ".flt": + return path, False + for suffix in (".FLT", ".flt"): + cand = Path(str(path) + suffix) if not path.suffix else path.with_suffix(suffix) + if cand.is_file(): + return cand, True + return path, False + + +def _entry_from_refs(display_name: str, refs: Sequence[str]) -> MissionSwitchEntry: + """多候选按序取首个存在文件;label 只用 display_name,不回退路径/文件名。""" + label = (display_name or "").strip() or _MISSING_DISPLAY_NAME + if not refs: + return MissionSwitchEntry(label=label, flt_path=Path(), exists=False) + chosen = resolve_mission_flt_path(refs[0]) + exists = False + for ref in refs: + path, ok = _probe_flt_file(resolve_mission_flt_path(ref)) + if ok: + chosen, exists = path, True + break + if not exists: + chosen = path + return MissionSwitchEntry(label=label, flt_path=chosen, exists=exists) + + +def list_mission_switch_entries( + missions_dir: Optional[Path] = None, +) -> List[MissionSwitchEntry]: + """ + 扫描 Missions 子目录:config 中含 flt_rel 的纳入切换列表。 + 显示名 = config.display_name;FLT 候选来自 flt_rel。 + """ + root = Path(missions_dir) if missions_dir is not None else Path(MISSIONS_DIR) + out: List[MissionSwitchEntry] = [] + if not root.is_dir(): + return out + for child in sorted(root.iterdir(), key=lambda p: p.name.lower()): + if not child.is_dir(): + continue + refs = load_config_flt_refs(child) + if not refs: + continue + out.append(_entry_from_refs(load_config_display_name(child), refs)) + out.sort(key=lambda e: e.label.lower()) + return out + + +def _match_missions_dir_by_flt_path(flt_path: Optional[str]) -> Optional[Path]: + """按 FLT 路径匹配 Missions 子目录;找不到返回 None。""" + raw = str(flt_path or "").strip() + if not raw: + return None + path_norm = raw.replace("\\", "/").lower() + stem = Path(raw).stem.lower() + root = Path(MISSIONS_DIR) + if not root.is_dir(): + return None + for child in root.iterdir(): + if not child.is_dir(): + continue + child_l = child.name.lower() + # 路径里带任务目录名(如 .../RB_SP_Longleat_Intermediate/...) + if child_l in path_norm: + return child + # stem / 文件名与目录名互相包含(MP 另存 FLT 时常仍带 SP 任务键) + if child_l in stem or stem in child_l: + return child + for ref in load_config_flt_refs(child): + ref_path = Path(str(ref).replace("\\", "/")) + ref_name = ref_path.name.lower() + ref_stem = ref_path.stem.lower() + if ref_name in path_norm or ref_stem == stem: + return child + if ref_stem and (ref_stem in stem or stem in ref_stem): + return child + return None + + +def _match_missions_dir_by_display_name(display_name: Optional[str]) -> Optional[Path]: + """按 config.display_name 精确匹配 Missions 子目录。""" + want = (display_name or "").strip() + if not want or want == _MISSING_DISPLAY_NAME: + return None + root = Path(MISSIONS_DIR) + if not root.is_dir(): + return None + for child in root.iterdir(): + if not child.is_dir(): + continue + name = (load_config_display_name(child) or "").strip() + if name and name == want: + return child + return None + + +def resolve_mission_dir_for_context( + *, + flt_path: Optional[str] = None, + mission_title: Optional[str] = None, + display_hint: Optional[str] = None, + mission_key: Optional[str] = None, +) -> Optional[Path]: + """ + 解析当前上下文对应的 Missions/{mission_key}/ 目录。 + 优先级:显式 mission_key → FLT 路径 → display_hint → FLT Title。 + """ + from models.mission_time_rule import resolve_mission_dir + + key = (mission_key or "").strip() + if key: + exact = resolve_mission_dir(key) + if exact is not None: + return exact + by_path = _match_missions_dir_by_flt_path(flt_path) + if by_path is not None: + return by_path + by_display = _match_missions_dir_by_display_name(display_hint) + if by_display is not None: + return by_display + title = (mission_title or "").strip() + if title: + by_title = resolve_mission_dir(title) + if by_title is not None: + return by_title + by_title_disp = _match_missions_dir_by_display_name( + # Title 有时已是 display_name + title + ) + if by_title_disp is not None: + return by_title_disp + return None + + +def resolve_display_name_for_flt(flt_path: Optional[str]) -> str: + """ + 当前 FLT 路径 → 直接读对应 Missions/*/config_*.csv 的 display_name。 + 匹配:路径中含任务目录名,或含 config.flt_rel 的文件名/stem。 + """ + child = _match_missions_dir_by_flt_path(flt_path) + if child is None: + return "" + return (load_config_display_name(child) or "").strip() + + +def resolve_canonical_mission_key( + *, + flt_path: Optional[str] = None, + mission_title: Optional[str] = None, + display_hint: Optional[str] = None, + mission_key: Optional[str] = None, +) -> str: + """ + 任务稳定键 = Missions 子目录名(如 RB_SP_Longleat_Intermediate)。 + 同一任务 SP/MP 共用;解析失败返回空串(调用方可再回退 Title)。 + """ + hint = (mission_key or "").strip() + mission_dir = resolve_mission_dir_for_context( + flt_path=flt_path, + mission_title=mission_title, + display_hint=display_hint, + mission_key=hint or None, + ) + if mission_dir is not None: + return mission_dir.name + return hint + + +def resolve_canonical_mission_display( + *, + flt_path: Optional[str] = None, + mission_title: Optional[str] = None, + display_hint: Optional[str] = None, + mission_key: Optional[str] = None, +) -> str: + """ + 任务规范显示名(与 Mission 切换列表一致 = config.display_name)。 + 优先读 Missions/{mission_key}/config;同一任务 SP/MP 同名;session_mode 不参与。 + """ + hint = (display_hint or "").strip() + if hint and hint != _MISSING_DISPLAY_NAME: + return hint + mission_dir = resolve_mission_dir_for_context( + flt_path=flt_path, + mission_title=mission_title, + display_hint=display_hint, + mission_key=mission_key, + ) + if mission_dir is not None: + name = (load_config_display_name(mission_dir) or "").strip() + if name: + return name + title = (mission_title or "").strip() + return title diff --git a/models/mission_time_rule.py b/models/mission_time_rule.py new file mode 100644 index 00000000..c64cb18d --- /dev/null +++ b/models/mission_time_rule.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +""" +模块:models.mission_time_rule +职责:按任务名读取 Missions/{任务}/config_{任务}.csv 的 min_sec/max_sec +依赖:setting、models.mission_config +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +from models.mission_config import load_config_time_bounds +from setting import ( + CHART_TIME_RULE_DEFAULT_MAX_SEC, + CHART_TIME_RULE_DEFAULT_MIN_SEC, + MISSION_SCORE_SCALE, + MISSIONS_DIR, +) + + +@dataclass(frozen=True) +class MissionTimeRule: + """性能图 X 轴硬右界 max_sec;min_sec 供分数板,缩放最小窗宽见 CHART_TIME_ZOOM_MIN_SEC。""" + + mission_key: str + min_sec: float + max_sec: float + + +def normalize_mission_key(title: str) -> str: + """任务名 → 安全文件/目录键(空格/连字符→下划线,去掉非法路径字符)。""" + text = (title or "").strip() + if not text: + return "" + text = re.sub(r"[\s\-]+", "_", text) + text = re.sub(r"[^\w.\-]+", "", text, flags=re.UNICODE) + return text + + +def resolve_mission_dir(mission_title: str) -> Optional[Path]: + """圈时板任务名 → Missions 子目录(精确 / 忽略大小写 / 包含匹配)。""" + key = normalize_mission_key(mission_title) + root = Path(MISSIONS_DIR) + if not root.is_dir(): + return None + if key: + exact = root / key + if exact.is_dir(): + return exact + lower = key.lower() + for child in root.iterdir(): + if child.is_dir() and child.name.lower() == lower: + return child + for child in root.iterdir(): + if not child.is_dir(): + continue + name_l = child.name.lower() + if lower in name_l or name_l in lower: + return child + return None + + +def resolve_mission_display_name(mission_title: Optional[str]) -> str: + """ + 按 Missions 子目录名匹配后读 config.display_name。 + 航迹保存请优先用 mission_switch.resolve_display_name_for_flt(按 FLT 路径)。 + """ + from models.mission_config import load_config_display_name + + title = (mission_title or "").strip() + if not title: + return "" + mission_dir = resolve_mission_dir(title) + if mission_dir is None: + return "" + return (load_config_display_name(mission_dir) or "").strip() + + +def default_time_rule(mission_key: str = "") -> MissionTimeRule: + return MissionTimeRule( + mission_key=mission_key or "", + min_sec=float(CHART_TIME_RULE_DEFAULT_MIN_SEC), + max_sec=float(CHART_TIME_RULE_DEFAULT_MAX_SEC), + ) + + +def load_mission_time_rule(mission_title: Optional[str]) -> MissionTimeRule: + """ + 读取 config_{任务名}.csv 的 min_sec/max_sec。 + 无任务或文件缺失时回退默认 100/240。 + """ + title = (mission_title or "").strip() + if not title: + return default_time_rule() + mission_dir = resolve_mission_dir(title) + if mission_dir is None: + return default_time_rule(normalize_mission_key(title)) + key = mission_dir.name + bounds = load_config_time_bounds(mission_dir) + if bounds is None: + return default_time_rule(key) + min_sec, max_sec = bounds + if not (min_sec > 0 and max_sec > min_sec): + return default_time_rule(key) + return MissionTimeRule(mission_key=key, min_sec=min_sec, max_sec=max_sec) + + +def compute_mission_score( + total_s: Optional[float], + min_sec: float, + max_sec: float, + *, + scale: float = MISSION_SCORE_SCALE, +) -> Optional[float]: + """ + 任务分数:(max_sec - total_s) / (max_sec - min_sec) * scale。 + 总时无效或分母非正时返回 None。 + """ + if total_s is None: + return None + try: + total = float(total_s) + lo = float(min_sec) + hi = float(max_sec) + span_scale = float(scale) + except (TypeError, ValueError): + return None + if total != total or lo != lo or hi != hi or span_scale != span_scale: + return None + span = hi - lo + if span <= 0.0: + return None + return (hi - total) / span * span_scale diff --git a/models/runtime_watch.py b/models/runtime_watch.py new file mode 100644 index 00000000..d33e60fe --- /dev/null +++ b/models/runtime_watch.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +""" +模块:models.runtime_watch +职责:暂停检测器 / 坠毁判定(输入侧);时间状态权威见 state_bus.lifecycle +""" + +from __future__ import annotations + +from typing import Optional + +from services.simulator import simulation_clock_advancing + + +class PauseDetector: + """根据 ABSOLUTE TIME 是否推进判定模拟器是否暂停;结果可缓存供同轮只读。""" + + def __init__(self) -> None: + self._last_sim_time: Optional[float] = None + self._paused = False + + @property + def is_paused(self) -> bool: + return self._paused + + def clear(self) -> None: + self._last_sim_time = None + self._paused = False + + def refresh(self, aq, sm) -> bool: + """刷新并返回当前是否暂停(时钟未推进视为暂停)。""" + if not sm: + self._paused = False + return False + advancing, self._last_sim_time = simulation_clock_advancing( + aq, self._last_sim_time + ) + if advancing: + if getattr(sm, "paused", False): + sm.paused = False + self._paused = False + return False + if getattr(sm, "paused", False): + self._paused = True + return True + self._paused = True + return True + + +def aircraft_has_crashed(aq) -> bool: + """读取 CRASH_FLAG / CRASH_SEQUENCE;不含锁存,可每次直接读取。""" + if not aq: + return False + try: + flag = aq.get("CRASH_FLAG") + if flag is not None and float(flag) != 0: + return True + seq = aq.get("CRASH_SEQUENCE") + if seq is not None and float(seq) != 0: + return True + except Exception: + return False + return False diff --git a/models/shadow_plane.py b/models/shadow_plane.py new file mode 100644 index 00000000..2acd350d --- /dev/null +++ b/models/shadow_plane.py @@ -0,0 +1,1203 @@ +# -*- coding: utf-8 -*- +"""影子机跟飞:内存轨迹录制 + 回放 60Hz 段内线性插值;成品航迹经 track_store 云存。""" + +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass, field +from datetime import datetime +from typing import List, Mapping, Optional, Sequence, Tuple + +from SimConnect.Constants import SIMCONNECT_OBJECT_ID_USER +from SimConnect.Enum import SIMCONNECT_DATA_WAYPOINT, SIMCONNECT_WAYPOINT_FLAGS + +from services.simulator import ( + AIRSPEED_VAR, + BANK_VAR, + DATA_POLL_HZ, + GROUND_SPEED_VAR, + PITCH_VAR, + PLANE_AGL_VAR, + PLANE_ALT_VAR, + PLANE_HEADING_VAR, + PLANE_LAT_VAR, + PLANE_LON_VAR, + RECORD_INTERVAL_S as SIM_RECORD_INTERVAL_S, + TITLE_VAR, + optional_float, +) +from models.track_fields import ( + SAMPLE_METRIC_KEYS, + fill_derived_fields, + is_field_enabled, +) + +# SimConnect 变量名(与 RequestList / services.simulator 一致) +VAR_TITLE = TITLE_VAR +VAR_LAT = PLANE_LAT_VAR +VAR_LON = PLANE_LON_VAR +VAR_ALT = PLANE_ALT_VAR +VAR_AGL = PLANE_AGL_VAR +VAR_HEADING = PLANE_HEADING_VAR +VAR_PITCH = PITCH_VAR +VAR_BANK = BANK_VAR +VAR_AIRSPEED = AIRSPEED_VAR +VAR_GROUND_SPEED = GROUND_SPEED_VAR + + +DEFAULT_AIRCRAFT_TITLE = "Mooney Bravo" +SPAWN_TIMEOUT_S = 12.0 +RESET_SETTLE_S = 2.0 +MISSION_AI_SETTLE_S = 1.0 +# 连续位姿写入失败次数达到后停止回放(坠毁/Object 失效) +POSE_FAIL_ABORT = 3 +# 监测录制:模拟时间轴目标 Hz(与 DATA_POLL_HZ 同步) +RECORD_HZ = float(DATA_POLL_HZ) +RECORD_INTERVAL_S = float(SIM_RECORD_INTERVAL_S) +# 回放刷新率 / 航点密化采样率(原始轨迹段内线性上采样到 60Hz) +REPLAY_HZ = 60.0 +REPLAY_DENSE_HZ = 60.0 +SMOKE_REFRESH_S = 1.0 +# 滑动窗口航点:前瞻时长、窗口点数、刷新间隔 +WAYPOINT_HORIZON_S = 2.5 +WAYPOINT_WINDOW_COUNT = 12 +WAYPOINT_REFRESH_S = 0.35 +# 影子机轨迹领先量(秒):以轨迹时间轴为基准,在参考时刻上向前偏移 +SHADOW_LEAD_MIN_S = 0.0 +SHADOW_LEAD_MAX_S = 6.0 +SHADOW_LEAD_DEFAULT_S = 3.0 + + +@dataclass +class TrajectorySample: + timestamp_s: float + latitude: float + longitude: float + altitude_ft: float + heading_deg: float + airspeed_kts: float + groundspeed_kts: float + pitch_deg: float + bank_deg: float + agl_ft: float = 0.0 + torque: float = float("nan") + health_pct: float = float("nan") + # 摇杆操纵量 [-1, 1];未连接或缺列为 nan + stick_pitch: float = float("nan") + stick_roll: float = float("nan") + stick_yaw: float = float("nan") + stick_throttle: float = float("nan") + lap: int = 0 + # B/C 扩录(缺省 nan;旧 CSV / 未启用列保持 nan) + tas_kts: float = float("nan") + vs_fpm: float = float("nan") + rpm: float = float("nan") + eng_rpm: float = float("nan") + manifold_psi: float = float("nan") + prop_thrust_lbf: float = float("nan") + alpha_deg: float = float("nan") + beta_deg: float = float("nan") + g_force: float = float("nan") + weight_lb: float = float("nan") + ambient_density: float = float("nan") + ambient_temp_c: float = float("nan") + wind_kts: float = float("nan") + wind_dir_deg: float = float("nan") + smoke_on: float = float("nan") + prop_beta_deg: float = float("nan") + prop_lever_pct: float = float("nan") + elevator_deg: float = float("nan") + aileron_deg: float = float("nan") + rudder_deg: float = float("nan") + p_shaft_w: float = float("nan") + p_prop_w: float = float("nan") + eta_prop: float = float("nan") + e_mech_j: float = float("nan") + di_proxy: float = float("nan") + ds_m: float = float("nan") + vg_course_kts: float = float("nan") + + def metric_dict(self) -> dict: + """位姿 + 指标 → 派生计算用扁平字典。""" + d = { + "timestamp_s": self.timestamp_s, + "latitude": self.latitude, + "longitude": self.longitude, + "altitude_ft": self.altitude_ft, + "agl_ft": self.agl_ft, + "heading_deg": self.heading_deg, + "airspeed_kts": self.airspeed_kts, + "groundspeed_kts": self.groundspeed_kts, + "pitch_deg": self.pitch_deg, + "bank_deg": self.bank_deg, + "lap": float(self.lap), + } + for key in SAMPLE_METRIC_KEYS: + d[key] = float(getattr(self, key, float("nan"))) + return d + + +def _apply_metrics_from_mapping(sample: TrajectorySample, data: Mapping) -> None: + """把 mapping 中的启用 Sim/指标写入 sample(不含 stick;stick 由调用方注入)。""" + for key in SAMPLE_METRIC_KEYS: + if key.startswith("stick_"): + continue + if key not in data: + continue + if not is_field_enabled(key): + if key in ("torque", "health_pct"): + setattr(sample, key, float("nan")) + continue + setattr(sample, key, _safe_float(data.get(key), float("nan"))) + + +_DERIVED_KEYS = ( + "p_shaft_w", + "p_prop_w", + "eta_prop", + "e_mech_j", + "di_proxy", + "ds_m", + "vg_course_kts", +) + + +@dataclass +class Trajectory: + aircraft_title: str + samples: List[TrajectorySample] + recorded_at: str = "" + laps_s: List[Optional[float]] = field(default_factory=list) + mission_title: str = "" + + def first(self) -> TrajectorySample: + return self.samples[0] + + def last(self) -> TrajectorySample: + return self.samples[-1] + + @property + def duration_s(self) -> float: + if len(self.samples) < 2: + return 0.0 + return max(0.0, self.samples[-1].timestamp_s - self.samples[0].timestamp_s) + + +def format_duration_suffix(total_s: Optional[float]) -> str: + """总时长后缀:MMSScc(分/秒/百分秒各两位);无 total 时为 000000。""" + if total_s is None: + return "000000" + try: + value = float(total_s) + except (TypeError, ValueError): + return "000000" + if value <= 0 or value != value: + return "000000" + minutes = min(99, int(value // 60)) + remainder = value - minutes * 60 + seconds = int(remainder) + centis = int(round((remainder - seconds) * 100)) + if centis >= 100: + centis = 0 + seconds += 1 + if seconds >= 60: + seconds -= 60 + minutes = min(99, minutes + 1) + return f"{minutes:02d}{seconds:02d}{centis:02d}" + + +def trajectory_label_mission_prefix(mission_title: Optional[str]) -> str: + """云端航迹标签前缀:规范化任务名;空则 untitled。""" + from models.mission_time_rule import normalize_mission_key + from setting import TRACK_LABEL_UNTITLED + + key = normalize_mission_key(mission_title or "") + return key or str(TRACK_LABEL_UNTITLED) + + +def build_trajectory_label( + mission_title: Optional[str], + *, + stamp: Optional[str] = None, + total_s: Optional[float] = None, +) -> str: + """可读航迹标签:`{任务名}_{YYYYMMDD_HHMMSS}_{MMSScc}`。""" + prefix = trajectory_label_mission_prefix(mission_title) + ts = stamp or datetime.now().strftime("%Y%m%d_%H%M%S") + return f"{prefix}_{ts}_{format_duration_suffix(total_s)}" + + +def mission_titles_match(current: Optional[str], other: Optional[str]) -> bool: + """两段任务名是否同一任务(规范化相等,或解析到同一 config.display_name / 目录键)。""" + return mission_identities_match([current], [other]) + + +def _mission_identity_tokens(*names: Optional[str]) -> set: + """收集可用于同一任务判定的规范化键与显示名。""" + from models.mission_time_rule import ( + normalize_mission_key, + resolve_mission_dir, + resolve_mission_display_name, + ) + + tokens: set = set() + for raw in names: + text = (raw or "").strip() + if not text: + continue + # 原文(中文 display_name 等) + tokens.add(text) + key = normalize_mission_key(text) + if key: + tokens.add(key.lower()) + disp = (resolve_mission_display_name(text) or "").strip() + if disp: + tokens.add(disp) + dk = normalize_mission_key(disp) + if dk: + tokens.add(dk.lower()) + mission_dir = resolve_mission_dir(text) + if mission_dir is not None: + tokens.add(mission_dir.name.lower()) + return tokens + + +def mission_identities_match( + left: Sequence[Optional[str]], + right: Sequence[Optional[str]], +) -> bool: + """ + 多候选任务名是否指向同一任务。 + 候选可含 mission_key、config.display_name、FLT Title;session_mode 不参与。 + """ + a = _mission_identity_tokens(*(left or ())) + b = _mission_identity_tokens(*(right or ())) + if not a or not b: + return False + return bool(a & b) + + +def _safe_float(value, default=0.0) -> float: + v = optional_float(value, default) + return default if v is None else v + + +def lerp(a: float, b: float, t: float) -> float: + return a + (b - a) * t + + +def lerp_angle_deg(a: float, b: float, t: float) -> float: + """最短角插值(度)。""" + delta = (b - a + 180.0) % 360.0 - 180.0 + return (a + delta * t) % 360.0 + + +def sample_is_airborne(sample: TrajectorySample) -> bool: + speed = max(sample.airspeed_kts, sample.groundspeed_kts) + if sample.agl_ft > 0.5: + return sample.agl_ft >= 8.0 or speed >= 40.0 + return speed >= 40.0 or sample.altitude_ft >= 50.0 + + +def read_sample_from_aq(aq) -> Optional[TrajectorySample]: + """从统一 AQ 快照构建轨迹采样;缺经纬则返回 None。""" + from models.data_bridge import read_aq_snapshot + + try: + snap = read_aq_snapshot(aq) + except OSError: + return None + lat = snap.get("latitude") + lon = snap.get("longitude") + if lat is None or lon is None: + return None + return TrajectorySample( + timestamp_s=0.0, + latitude=float(lat), + longitude=float(lon), + altitude_ft=_safe_float(snap.get("altitude_ft")), + agl_ft=_safe_float(snap.get("agl_ft")), + heading_deg=_safe_float(snap.get("heading_deg")), + airspeed_kts=_safe_float(snap.get("airspeed_kts")), + groundspeed_kts=_safe_float(snap.get("groundspeed_kts")), + pitch_deg=_safe_float(snap.get("pitch_deg")), + bank_deg=_safe_float(snap.get("bank_deg")), + ) + + +def sample_from_snapshot( + snap: dict, + *, + timestamp_s: float = 0.0, + lap: int = 0, + stick_pitch: Optional[float] = None, + stick_roll: Optional[float] = None, + stick_yaw: Optional[float] = None, + stick_throttle: Optional[float] = None, + prev_sample: Optional[TrajectorySample] = None, +) -> Optional[TrajectorySample]: + """由 read_aq_snapshot 字典组装 TrajectorySample(缺经纬返回 None)。""" + lat = snap.get("latitude") + lon = snap.get("longitude") + if lat is None or lon is None: + return None + sample = TrajectorySample( + timestamp_s=float(timestamp_s), + latitude=float(lat), + longitude=float(lon), + altitude_ft=_safe_float(snap.get("altitude_ft")), + agl_ft=_safe_float(snap.get("agl_ft")), + heading_deg=_safe_float(snap.get("heading_deg")), + airspeed_kts=_safe_float(snap.get("airspeed_kts")), + groundspeed_kts=_safe_float(snap.get("groundspeed_kts")), + pitch_deg=_safe_float(snap.get("pitch_deg")), + bank_deg=_safe_float(snap.get("bank_deg")), + lap=max(0, int(lap)), + ) + _apply_metrics_from_mapping(sample, snap) + for attr, value in ( + ("stick_pitch", stick_pitch), + ("stick_roll", stick_roll), + ("stick_yaw", stick_yaw), + ("stick_throttle", stick_throttle), + ): + if value is None or not is_field_enabled(attr): + continue + try: + setattr(sample, attr, float(value)) + except (TypeError, ValueError): + pass + row = sample.metric_dict() + fill_derived_fields( + row, + prev=prev_sample.metric_dict() if prev_sample is not None else None, + ) + for key in _DERIVED_KEYS: + if is_field_enabled(key): + setattr(sample, key, _safe_float(row.get(key), float("nan"))) + return sample + + +def _planar_distance_sq(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + dlat = lat2 - lat1 + dlon = lon2 - lon1 + return dlat * dlat + dlon * dlon + + +def find_trajectory_reference_time( + samples: Sequence[TrajectorySample], + lat: float, + lon: float, +) -> float: + """在轨迹采样上找与给定经纬最接近的参考时刻。""" + if not samples: + return 0.0 + best_t = samples[0].timestamp_s + best_d = float("inf") + for sample in samples: + d = _planar_distance_sq(lat, lon, sample.latitude, sample.longitude) + if d < best_d: + best_d = d + best_t = sample.timestamp_s + return best_t + + +class TrajectoryRecorder: + """保存板块主缓冲;增量副本供消费板块航迹读取(禁止跨板块直读主缓冲)。""" + + def __init__(self): + self._active = False + self._start_mono = 0.0 + self._aircraft_title = "" + self._samples: List[TrajectorySample] = [] + # 消费侧增量副本:与主缓冲最多差最后一个未提交批次 + self._replica: List[TrajectorySample] = [] + self._aq = None + self._record_stop = threading.Event() + self._record_thread: Optional[threading.Thread] = None + self._paused = False + self._lock = threading.Lock() + self._record_interval_s = float(RECORD_INTERVAL_S) + self._last_record_slot: Optional[int] = None + + @property + def active(self) -> bool: + return self._active + + def start(self, aircraft_title: Optional[str] = None, aq=None, rate_hz: float = RECORD_HZ) -> None: + """开始录制;采样由主线程 poll 驱动,按模拟时间格子落点。""" + self.stop_record_thread() + self._active = True + self._paused = False + self._start_mono = time.monotonic() + self._aircraft_title = (aircraft_title or "").strip() + self._samples = [] + self._replica = [] + self._aq = aq + self._record_stop = threading.Event() + hz = float(rate_hz) if rate_hz and float(rate_hz) > 0 else float(RECORD_HZ) + self._record_interval_s = 1.0 / hz + self._last_record_slot = None + + def set_paused(self, paused: bool) -> None: + """本地门控:获取暂停时不再追加;非 save 板块时间态。""" + self._paused = bool(paused) + + @property + def paused(self) -> bool: + return self._paused + + def stop_record_thread(self) -> None: + self._record_stop.set() + thread = self._record_thread + if thread is not None and thread.is_alive() and thread is not threading.current_thread(): + thread.join(timeout=1.5) + self._record_thread = None + + def sample_from_aq( + self, + aq, + *, + torque: Optional[float] = None, + health_ratio: Optional[float] = None, + lap: Optional[int] = None, + stick_pitch: Optional[float] = None, + stick_roll: Optional[float] = None, + stick_yaw: Optional[float] = None, + stick_throttle: Optional[float] = None, + timestamp_s: Optional[float] = None, + snapshot: Optional[dict] = None, + ) -> bool: + """ + 按模拟时间格子采样;本拍若未跨入新格子则跳过。 + 返回是否真正追加样本。 + """ + if not self._active or self._paused: + return False + if snapshot is None: + from models.data_bridge import read_aq_snapshot + + try: + snapshot = read_aq_snapshot(aq, torque=torque, health_ratio=health_ratio) + except OSError: + return False + if timestamp_s is not None: + try: + ts = float(timestamp_s) + except (TypeError, ValueError): + ts = time.monotonic() - self._start_mono + else: + ts = time.monotonic() - self._start_mono + if ts != ts or ts < 0.0: + return False + interval = float(self._record_interval_s) or float(RECORD_INTERVAL_S) + slot = int(ts / interval + 1e-9) + with self._lock: + if self._last_record_slot is not None and slot <= self._last_record_slot: + return False + prev = self._samples[-1] if self._samples else None + # 写入格子左缘,保证 timestamp_s 为 0.0 / 0.1 / 0.2 … + ts_grid = float(slot) * interval + sample = sample_from_snapshot( + snapshot, + timestamp_s=ts_grid, + lap=int(lap) if lap is not None else 0, + stick_pitch=stick_pitch, + stick_roll=stick_roll, + stick_yaw=stick_yaw, + stick_throttle=stick_throttle, + prev_sample=prev, + ) + if sample is None: + return False + with self._lock: + if self._last_record_slot is not None and slot <= self._last_record_slot: + return False + self._last_record_slot = slot + self._samples.append(sample) + self._replica.append(sample) + return True + + @property + def has_pending_samples(self) -> bool: + with self._lock: + return len(self._samples) > 0 + + def samples_snapshot(self) -> List[TrajectorySample]: + """仅保存板块内部使用;消费侧请用 replica_snapshot。""" + with self._lock: + return list(self._samples) + + def replica_snapshot(self) -> List[TrajectorySample]: + """消费板块只读增量副本(全量;优先用 replica_len + replica_slice)。""" + with self._lock: + return list(self._replica) + + def replica_len(self) -> int: + with self._lock: + return len(self._replica) + + def replica_slice(self, start: int) -> List[TrajectorySample]: + """返回 replica[start:] 的拷贝;供消费侧增量投影。""" + with self._lock: + n = len(self._replica) + if start <= 0: + return list(self._replica) + if start >= n: + return [] + return list(self._replica[start:]) + + def stop(self) -> None: + """停止录制线程,保留内存中的采样点供后续保存。""" + self._active = False + self._paused = False + self._aq = None + self.stop_record_thread() + + def discard(self) -> None: + """停止并丢弃未保存采样(新监测会话进入 WAITING 时)。""" + self.stop() + with self._lock: + self._samples = [] + self._replica = [] + + def save( + self, + total_s: Optional[float] = None, + laps_s: Optional[Sequence[Optional[float]]] = None, + mission_title: Optional[str] = None, + ) -> Optional[Trajectory]: + """组装内存 Trajectory 并清空缓冲;云端由 track_store 写入。""" + with self._lock: + samples = list(self._samples) + self._samples = [] + self._replica = [] + if not samples: + return None + title = (mission_title or "").strip() + _ = total_s + return Trajectory( + aircraft_title=self._aircraft_title, + samples=samples, + recorded_at=datetime.now().isoformat(timespec="seconds"), + laps_s=list(laps_s) if laps_s is not None else [], + mission_title=title, + ) + + def stop_and_save( + self, + total_s: Optional[float] = None, + laps_s: Optional[Sequence[Optional[float]]] = None, + mission_title: Optional[str] = None, + ) -> Optional[Trajectory]: + self.stop() + return self.save(total_s=total_s, laps_s=laps_s, mission_title=mission_title) + + +def _segment_indices(samples: Sequence[TrajectorySample], t: float) -> Tuple[int, int]: + """返回包围时间 t 的样本下标 (lo, hi)。""" + if t <= samples[0].timestamp_s: + return 0, min(1, len(samples) - 1) + if t >= samples[-1].timestamp_s: + n = len(samples) - 1 + return max(0, n - 1), n + lo = 0 + hi = len(samples) - 1 + while lo + 1 < hi: + mid = (lo + hi) // 2 + if samples[mid].timestamp_s <= t: + lo = mid + else: + hi = mid + return lo, hi + + +def interpolate_sample(samples: Sequence[TrajectorySample], t: float) -> TrajectorySample: + """按轨迹时间做段内线性插值(航向最短角);超出末尾则停在最后一点。""" + n = len(samples) + if n == 0: + raise ValueError("空轨迹") + if n == 1 or t <= samples[0].timestamp_s: + return samples[0] + if t >= samples[-1].timestamp_s: + return samples[-1] + + lo, hi = _segment_indices(samples, t) + a = samples[lo] + b = samples[hi] + dt = b.timestamp_s - a.timestamp_s + u = 0.0 if dt <= 1e-6 else (t - a.timestamp_s) / dt + u = max(0.0, min(1.0, u)) + + return TrajectorySample( + timestamp_s=t, + latitude=lerp(a.latitude, b.latitude, u), + longitude=lerp(a.longitude, b.longitude, u), + altitude_ft=lerp(a.altitude_ft, b.altitude_ft, u), + agl_ft=max(0.0, lerp(a.agl_ft, b.agl_ft, u)), + heading_deg=lerp_angle_deg(a.heading_deg, b.heading_deg, u), + airspeed_kts=max(0.0, lerp(a.airspeed_kts, b.airspeed_kts, u)), + groundspeed_kts=max(0.0, lerp(a.groundspeed_kts, b.groundspeed_kts, u)), + pitch_deg=lerp(a.pitch_deg, b.pitch_deg, u), + bank_deg=lerp(a.bank_deg, b.bank_deg, u), + ) + + +def smooth_samples_once(samples: Sequence[TrajectorySample]) -> List[TrajectorySample]: + """对经纬/高度/空速做一遍三点滑动平均;航向与姿态保持密化点原值;端点与时间戳不变。""" + n = len(samples) + if n < 3: + return list(samples) + out: List[TrajectorySample] = [samples[0]] + for i in range(1, n - 1): + p0, p1, p2 = samples[i - 1], samples[i], samples[i + 1] + out.append( + TrajectorySample( + timestamp_s=p1.timestamp_s, + latitude=(p0.latitude + p1.latitude + p2.latitude) / 3.0, + longitude=(p0.longitude + p1.longitude + p2.longitude) / 3.0, + altitude_ft=(p0.altitude_ft + p1.altitude_ft + p2.altitude_ft) / 3.0, + agl_ft=max(0.0, (p0.agl_ft + p1.agl_ft + p2.agl_ft) / 3.0), + heading_deg=p1.heading_deg, + airspeed_kts=max( + 0.0, (p0.airspeed_kts + p1.airspeed_kts + p2.airspeed_kts) / 3.0 + ), + groundspeed_kts=p1.groundspeed_kts, + pitch_deg=p1.pitch_deg, + bank_deg=p1.bank_deg, + ) + ) + out.append(samples[-1]) + return out + + +def densify_samples( + samples: Sequence[TrajectorySample], + target_hz: float = REPLAY_DENSE_HZ, +) -> List[TrajectorySample]: + """把稀疏轨迹按时间轴均匀线性上采样到更高密度(默认 60Hz),再对经纬/高度/空速做一遍三点平滑。""" + if len(samples) < 2: + return list(samples) + duration = samples[-1].timestamp_s - samples[0].timestamp_s + if duration <= 0: + return list(samples) + step = 1.0 / max(10.0, float(target_hz)) + out: List[TrajectorySample] = [] + t = samples[0].timestamp_s + end = samples[-1].timestamp_s + while t < end - 1e-9: + out.append(interpolate_sample(samples, t)) + t += step + out.append(samples[-1]) + return smooth_samples_once(out) + + +def resolve_shadow_start( + trajectory: Trajectory, + aq, + lead_s: float, + use_user_reference: bool, +) -> Tuple[float, TrajectorySample, float]: + """根据 CSV 参考时刻 + 领先偏移,计算影子机起始时刻与初始位姿。 + + 领先 0 秒:始终使用 CSV 第一条记录;领先 >0 时按 CSV 时间轴向前偏移。 + ``use_user_reference=True``(当前任务)时参考时刻为用户在 CSV 上的最近点; + ``False``(重置任务)时参考时刻为 CSV 起点。 + """ + samples = trajectory.samples + t0 = samples[0].timestamp_s + t1 = samples[-1].timestamp_s + lead = max(SHADOW_LEAD_MIN_S, min(SHADOW_LEAD_MAX_S, float(lead_s))) + + if lead <= 1e-6: + return t0, samples[0], 0.0 + + if use_user_reference: + user = read_sample_from_aq(aq) + if user is not None: + t_ref = find_trajectory_reference_time(samples, user.latitude, user.longitude) + else: + t_ref = t0 + else: + t_ref = t0 + + if t1 > t0: + lead = min(lead, max(0.0, t1 - t_ref)) + + start_t = max(t0, min(t1, t_ref + lead)) + spawn = interpolate_sample(samples, start_t) + + return start_t, spawn, lead + + +def build_waypoint_window( + samples: Sequence[TrajectorySample], + t_now: float, + horizon_s: float = WAYPOINT_HORIZON_S, + count: int = WAYPOINT_WINDOW_COUNT, +) -> List[SIMCONNECT_DATA_WAYPOINT]: + """从当前时刻向前取滑动窗口航点(在密化轨迹上按 60Hz 语义采样)。""" + if len(samples) < 2 or count < 1: + return [] + t_end = samples[-1].timestamp_s + horizon = max(0.2, float(horizon_s)) + flags_base = ( + SIMCONNECT_WAYPOINT_FLAGS.SIMCONNECT_WAYPOINT_SPEED_REQUESTED + | SIMCONNECT_WAYPOINT_FLAGS.SIMCONNECT_WAYPOINT_COMPUTE_VERTICAL_SPEED + ) + out: List[SIMCONNECT_DATA_WAYPOINT] = [] + for i in range(1, count + 1): + t = min(t_end, t_now + horizon * (i / float(count))) + sample = interpolate_sample(samples, t) + wp = SIMCONNECT_DATA_WAYPOINT() + wp.Latitude = float(sample.latitude) + wp.Longitude = float(sample.longitude) + wp.Altitude = float(sample.altitude_ft) + flags = int(flags_base) + if not sample_is_airborne(sample): + flags |= int(SIMCONNECT_WAYPOINT_FLAGS.SIMCONNECT_WAYPOINT_ON_GROUND) + wp.Flags = flags + wp.ktsSpeed = float(max(0.0, sample.airspeed_kts or sample.groundspeed_kts)) + wp.percentThrottle = 0.0 + out.append(wp) + return out + + +def find_mission_ai_ids(sm) -> List[int]: + keep = {int(SIMCONNECT_OBJECT_ID_USER.value), 0, 1} + try: + ids = sm.enumerate_aircraft_ids(timeout=3.0) + except Exception: + ids = [] + return [int(oid) for oid in ids if int(oid) not in keep] + + +def resolve_aircraft_title(aq, trajectory: Trajectory) -> str: + if trajectory.aircraft_title: + return trajectory.aircraft_title + title = aq.get(VAR_TITLE) + if title: + text = title.decode() if isinstance(title, bytes) else str(title) + text = text.strip("\x00").strip() + if text: + return text + return DEFAULT_AIRCRAFT_TITLE + + +class ShadowPlaneController: + """位置驱动影子机:60Hz 刷位置;姿态不冻结;滑动航点辅助顺滑。""" + + def __init__(self, sm): + self.sm = sm + self.object_id: Optional[int] = None + self.request_id = None + self.adopted = False + self._replay_stop = threading.Event() + self._replay_thread: Optional[threading.Thread] = None + self._replay_paused = False + self._frozen = False + self._smoke_on = False + self._pose_fail_count = 0 + self._pose_poisoned = False # 已要求停止写入(坠毁/脱离) + self._freeze_attitude = False + + def _sim_alive(self) -> bool: + sm = self.sm + if sm is None: + return False + try: + if getattr(sm, "quit", 0): + return False + if not getattr(sm, "ok", False): + return False + except Exception: + return False + return True + + def stop_replay(self, join_timeout: float = 0.6) -> None: + """停止回放线程;短超时,避免任务重置后 join 卡死。""" + self._replay_stop.set() + self._replay_paused = False + thread = self._replay_thread + self._replay_thread = None + if thread is not None and thread.is_alive() and thread is not threading.current_thread(): + thread.join(timeout=max(0.1, float(join_timeout))) + + def signal_stop(self) -> None: + """仅置位停止(可在 UI 线程调用):不 join、不调用 SimConnect。""" + self._replay_stop.set() + self._replay_paused = False + self._pose_poisoned = True + self.object_id = None + + def set_replay_paused(self, paused: bool) -> None: + """模拟器暂停时冻结轨迹回放进度(保持当前位姿)。""" + self._replay_paused = bool(paused) + + def detach(self) -> None: + """本地脱离;可 join 回放线程,勿在 Tk 主线程长时间调用。""" + self.signal_stop() + self.stop_replay(join_timeout=0.35) + self.adopted = False + self._frozen = False + self._smoke_on = False + self._pose_fail_count = 0 + self.request_id = None + + def remove(self, force_delete: bool = False) -> None: + """停止回放;默认不强制删除可能已失效的 Object,避免 SimConnect 卡死。""" + oid = self.object_id + alive = self._sim_alive() and not self._pose_poisoned + self.signal_stop() + self.stop_replay(join_timeout=0.5) + # 任务重置/坠毁后 Object 常已消失:只做本地脱离,除非明确要求删除且连接正常 + if force_delete and alive and oid is not None and not self.adopted: + try: + done = threading.Event() + + def _do_remove(): + try: + self.sm.remove_sim_object(oid) + except Exception: + pass + finally: + done.set() + + threading.Thread(target=_do_remove, daemon=True).start() + done.wait(timeout=0.8) + except Exception: + pass + self.adopted = False + self._frozen = False + self._smoke_on = False + self._pose_fail_count = 0 + self.request_id = None + + def _set_freeze(self, enabled: bool, freeze_attitude: Optional[bool] = None) -> None: + """冻结经纬/高度以保证位置驱动;姿态默认不冻结,由物理+航点更顺滑。""" + if self.object_id is None or not self._sim_alive() or self._pose_poisoned: + return + if freeze_attitude is None: + freeze_attitude = self._freeze_attitude + value = 1 if enabled else 0 + oid = self.object_id + for evt in ( + b"FREEZE_LATITUDE_LONGITUDE_SET", + b"FREEZE_ALTITUDE_SET", + ): + try: + self.sm.send_event_to_object(oid, evt, value) + except Exception: + pass + att_value = 1 if (enabled and freeze_attitude) else 0 + try: + self.sm.send_event_to_object(oid, b"FREEZE_ATTITUDE_SET", att_value) + except Exception: + pass + self._frozen = enabled + self._freeze_attitude = bool(freeze_attitude) + + def _push_waypoint_window(self, samples: Sequence[TrajectorySample], t_now: float) -> None: + """下发当前时刻的滑动窗口航点(辅助姿态/油门意图,位置仍由刷帧主导)。""" + oid = self.object_id + if oid is None or not self._sim_alive() or self._pose_poisoned: + return + if getattr(self.sm, "waypoint_broken", False): + return + waypoints = build_waypoint_window(samples, t_now) + if not waypoints: + return + try: + self.sm.set_ai_waypoints(oid, waypoints) + except Exception: + pass + + def _set_smoke(self, enabled: bool) -> None: + """开启/关闭拉烟(机型需支持,如 Extra);仅用事件,避免 SetData Bool 触发 INVALID_DATA_SIZE。""" + if self.object_id is None or not self._sim_alive() or self._pose_poisoned: + return + value = 1 if enabled else 0 + oid = self.object_id + try: + self.sm.send_event_to_object(oid, b"SMOKE_SET", value) + except Exception: + pass + try: + self.sm.send_event_to_object( + oid, + b"SMOKE_ON" if enabled else b"SMOKE_OFF", + 0, + ) + except Exception: + pass + self._smoke_on = enabled + + def _apply_pose(self, sample: TrajectorySample) -> bool: + """在回放线程内同步写位姿(避免多线程并发打 SimConnect)。""" + oid = self.object_id + if oid is None or not self._sim_alive() or self._pose_poisoned: + return False + if self._replay_stop.is_set(): + return False + airborne = sample_is_airborne(sample) + speed = int(max(0, round(sample.airspeed_kts or sample.groundspeed_kts))) + try: + ok = bool( + self.sm.set_init_position_on_object( + oid, + sample.latitude, + sample.longitude, + sample.altitude_ft, + hdg=sample.heading_deg, + pitch=sample.pitch_deg, + bank=sample.bank_deg, + gnd=0 if airborne else 1, + speed=speed, + ) + ) + except Exception: + ok = False + if ok: + self._pose_fail_count = 0 + else: + self._pose_fail_count += 1 + return ok + + def adopt(self, object_id: int, sample: TrajectorySample) -> int: + self.stop_replay() + oid = int(object_id) + self.object_id = oid + self.adopted = True + self._set_freeze(True, freeze_attitude=False) + time.sleep(0.05) + self._apply_pose(sample) + return oid + + def spawn( + self, + aircraft_title: str, + sample: TrajectorySample, + timeout: float = SPAWN_TIMEOUT_S, + enable_smoke: bool = True, + ) -> int: + # 新建前只做本地脱离,避免删除已失效 Object卡死 + self.detach() + self._pose_poisoned = False + self._replay_stop = threading.Event() + if not self._sim_alive(): + raise RuntimeError("SimConnect 未连接或已断开") + request_id = self.sm.new_request_id() + assigned = threading.Event() + result = {"object_id": None} + + def _on_assigned(_req_id, object_id): + result["object_id"] = int(object_id) + assigned.set() + + self.sm.register_object_id_waiter(request_id, _on_assigned) + self.request_id = request_id + airborne = sample_is_airborne(sample) + speed = int(max(0, round(sample.airspeed_kts or sample.groundspeed_kts))) + try: + ok = self.sm.create_non_atc_aircraft( + aircraft_title, + sample.latitude, + sample.longitude, + request_id, + tail_number="SHADOW1", + hdg=sample.heading_deg, + gnd=0 if airborne else 1, + alt=sample.altitude_ft, + pitch=sample.pitch_deg, + bank=sample.bank_deg, + speed=speed, + ) + except Exception as exc: + self.sm._object_id_waiters.pop( + int(request_id.value if hasattr(request_id, "value") else request_id), + None, + ) + raise RuntimeError(f"创建影子机失败:{exc}") from exc + if not ok: + self.sm._object_id_waiters.pop( + int(request_id.value if hasattr(request_id, "value") else request_id), + None, + ) + raise RuntimeError(f"无法创建影子机:{aircraft_title}") + if not assigned.wait(timeout): + self.sm._object_id_waiters.pop( + int(request_id.value if hasattr(request_id, "value") else request_id), + None, + ) + raise TimeoutError("创建影子机超时(模拟器可能刚重置,请稍后再试)") + object_id = result["object_id"] + if object_id is None: + raise RuntimeError("未获得影子机 Object ID") + self.object_id = object_id + self.adopted = False + self._pose_fail_count = 0 + # 保留 AI 以便滑动航点辅助;冻结经纬/高度,姿态放开 + time.sleep(0.1) + self._set_freeze(True, freeze_attitude=False) + time.sleep(0.05) + self._apply_pose(sample) + if enable_smoke: + self._set_smoke(True) + return object_id + + def start_replay( + self, + samples: Sequence[TrajectorySample], + rate_hz: float = REPLAY_HZ, + enable_smoke: bool = True, + lead_s: float = SHADOW_LEAD_DEFAULT_S, + start_time_s: Optional[float] = None, + initial_sample: Optional[TrajectorySample] = None, + enable_waypoint_assist: bool = False, + ) -> None: + if self.object_id is None: + raise RuntimeError("影子机尚未就绪") + if len(samples) < 2: + raise ValueError("轨迹点不足") + self.stop_replay(join_timeout=0.4) + self._replay_stop = threading.Event() + samples = densify_samples(samples, REPLAY_DENSE_HZ) + t0_traj = samples[0].timestamp_s + t1_traj = samples[-1].timestamp_s + if start_time_s is not None: + start_t = max(t0_traj, min(t1_traj, float(start_time_s))) + else: + duration = max(0.0, t1_traj - t0_traj) + lead = max(SHADOW_LEAD_MIN_S, min(SHADOW_LEAD_MAX_S, float(lead_s))) + lead = min(lead, duration * 0.9) if duration > 0 else 0.0 + start_t = t0_traj + lead + + def _loop(): + period = 1.0 / max(10.0, float(rate_hz)) + replay_elapsed = 0.0 + last_tick = time.monotonic() + try: + self._set_freeze(True, freeze_attitude=False) + if enable_smoke: + self._set_smoke(True) + last_smoke = time.monotonic() + last_wp = 0.0 + start_sample = initial_sample or interpolate_sample(samples, start_t) + self._apply_pose(start_sample) + if enable_waypoint_assist: + self._push_waypoint_window(samples, start_t) + last_wp = time.monotonic() + while not self._replay_stop.is_set(): + if not self._sim_alive(): + break + now = time.monotonic() + dt = now - last_tick + last_tick = now + if self._replay_paused: + # 暂停时冻结进度且不再刷 SetData,避免 INVALID_DATA_SIZE 刷屏 + time.sleep(period) + continue + replay_elapsed += dt + target_t = start_t + replay_elapsed + if target_t >= t1_traj: + self._apply_pose(samples[-1]) + break + pose = interpolate_sample(samples, target_t) + if not self._apply_pose(pose): + if self._pose_fail_count >= POSE_FAIL_ABORT or self._pose_poisoned: + break + wp_ok = enable_waypoint_assist and not getattr(self.sm, "waypoint_broken", False) + if wp_ok and (now - last_wp) >= WAYPOINT_REFRESH_S: + self._push_waypoint_window(samples, target_t) + last_wp = now + if enable_smoke and (now - last_smoke) >= SMOKE_REFRESH_S: + self._set_smoke(True) + last_smoke = now + sleep_s = period - (time.monotonic() - now) + if sleep_s > 0.0005: + time.sleep(sleep_s) + except Exception: + # 回放线程内吞掉异常,避免未捕获异常导致进程退出 + pass + finally: + self._replay_thread = None + + self._replay_thread = threading.Thread(target=_loop, daemon=True) + self._replay_thread.start() + + +def launch_shadow_follow( + sm, + aq, + trajectory: Trajectory, + situation_reset_event=None, + reset_already_applied: bool = False, + reset_settle_s: float = RESET_SETTLE_S, + replace_mission_ai: bool = False, + lead_s: float = SHADOW_LEAD_DEFAULT_S, + enable_smoke: bool = True, + use_user_reference: bool = False, +) -> dict: + """位置驱动跟飞:可选重置任务 → 新建影子机 → 轨迹 60Hz 插值回放。""" + if sm is None or getattr(sm, "quit", 0) or not getattr(sm, "ok", False): + raise RuntimeError("SimConnect 未就绪,请确认模拟器已连接") + + if trajectory is None or not trajectory.samples: + raise RuntimeError("跟飞航迹为空") + + if situation_reset_event is not None: + try: + situation_reset_event() + except Exception as exc: + raise RuntimeError(f"任务重置失败:{exc}") from exc + time.sleep(max(reset_settle_s, 2.0)) + elif reset_already_applied: + time.sleep(max(reset_settle_s, 2.0)) + else: + time.sleep(0.3) + + time.sleep(MISSION_AI_SETTLE_S) + + if getattr(sm, "quit", 0) or not getattr(sm, "ok", False): + raise RuntimeError("任务重置后连接异常,请稍后重试跟飞") + + # 再等一拍,避免重置动画未结束就 AICreate + time.sleep(0.5) + + start_t, start, lead = resolve_shadow_start( + trajectory, + aq, + lead_s, + use_user_reference=use_user_reference, + ) + initial_sample = start if lead <= 1e-6 else None + + aircraft_title = resolve_aircraft_title(aq, trajectory) + controller = ShadowPlaneController(sm) + object_id = controller.spawn(aircraft_title, start, enable_smoke=enable_smoke) + time.sleep(0.25) + controller.start_replay( + trajectory.samples, + enable_smoke=enable_smoke, + start_time_s=start_t, + initial_sample=initial_sample, + enable_waypoint_assist=False, + ) + # 跟飞准备阶段模拟器通常处于暂停;先冻结,由主线程 _poll 随暂停状态同步 + controller.set_replay_paused(True) + return { + "controller": controller, + "object_id": object_id, + "aircraft_title": aircraft_title, + "sample_count": len(trajectory.samples), + "duration_s": trajectory.duration_s, + "removed_ai": 0, + "mode": "spawn", + "waypoint_count": 0, + "replay_hz": REPLAY_HZ, + "dense_hz": REPLAY_DENSE_HZ, + "waypoint_assist": False, + "smoke": bool(enable_smoke), + "lead_s": lead, + } diff --git a/models/speed_bands.py b/models/speed_bands.py new file mode 100644 index 00000000..fa9740a6 --- /dev/null +++ b/models/speed_bands.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +""" +模块:models.speed_bands +职责:速度带定义(航迹色带刻度) +依赖:无 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Tuple + + +@dataclass(frozen=True) +class SpeedBand: + """半开区间 [lo, hi);hi 为 None 表示不低于 lo。""" + + label: str + lo: float + hi: Optional[float] = None + + def contains(self, speed_kts: float) -> bool: + if speed_kts < self.lo: + return False + if self.hi is None: + return True + return speed_kts < self.hi + + +# IAS/GS kts。比较符用 ≤ ≥ >(勿用半角「<」,会被 Qt Charts 当富文本吃掉)。 +DEFAULT_SPEED_BANDS: Tuple[SpeedBand, ...] = ( + SpeedBand("V≤160", 0.0, 160.0), + SpeedBand("180≥V>160", 160.0, 180.0), + SpeedBand("200≥V>180", 180.0, 200.0), + SpeedBand("220≥V>200", 200.0, 220.0), + SpeedBand("240≥V>220", 220.0, 240.0), + SpeedBand("V>240", 240.0, None), +) diff --git a/models/state_bus/__init__.py b/models/state_bus/__init__.py new file mode 100644 index 00000000..46dc2f4a --- /dev/null +++ b/models/state_bus/__init__.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +""" +模块:models.state_bus +职责:分组状态总线对外入口(Store / Hub / 采集 / 分析 / 生命周期) +""" + +from __future__ import annotations + +from models.state_bus.acquire import AcquireWriter +from models.state_bus.analyze import AnalyzeWorker +from models.state_bus.hub import StateHub +from models.state_bus.keys import ( + ALL_GROUPS, + GROUP_ANALYZE, + GROUP_CHART, + GROUP_LIVE, + GROUP_RUNTIME, + GROUP_SESSION, + GROUP_STICK, + GROUP_TRACK, + KEY_ACQUIRE_PAUSED, + KEY_ACQUIRE_START, + KEY_ACQUIRE_STOP, + KEY_AIRSPEED, + KEY_BANK, + KEY_CHT, + KEY_COMPARE_ERROR, + KEY_COMPARE_LABEL, + KEY_COMPARE_PATH, + KEY_COMPARE_PAYLOAD, + KEY_COMPARE_STATUS, + KEY_CONSUME_PAUSED, + KEY_CONSUME_START, + KEY_CONSUME_STOP, + KEY_CRASH_LATCHED, + KEY_GROUNDSPEED, + KEY_HEALTH, + KEY_MISSION_TITLE, + KEY_MP_ACTIVE, + KEY_PITCH, + KEY_SAVE_PENDING, + KEY_SAVE_START, + KEY_SAVE_STOP, + KEY_SEQ, + KEY_SESSION_MODE, + KEY_STICK_AXES, + KEY_TORQUE, +) +from models.state_bus.lifecycle import DataLifecycle +from models.state_bus.store import StateStore + + +class StateBus: + """同进程状态总线门面;生命周期状态机挂在同一 Store。""" + + def __init__(self, parent=None) -> None: + self.store = StateStore() + self.hub = StateHub(self.store, parent=parent) + self.acquire = AcquireWriter(self.store) + self.analyze = AnalyzeWorker(self.store) + self.lifecycle = DataLifecycle(self.store.commit) + + def start(self) -> None: + self.acquire.start() + self.analyze.start() + + def stop(self) -> None: + self.acquire.stop() + self.analyze.stop() + + def commit(self, group: str, patch: dict) -> int: + return self.store.commit(group, patch) + + def snapshot(self, group: str) -> dict: + return self.store.snapshot(group) + + +__all__ = [ + "StateBus", + "StateStore", + "StateHub", + "AcquireWriter", + "AnalyzeWorker", + "DataLifecycle", + "ALL_GROUPS", + "GROUP_SESSION", + "GROUP_RUNTIME", + "GROUP_LIVE", + "GROUP_STICK", + "GROUP_TRACK", + "GROUP_CHART", + "GROUP_ANALYZE", + "KEY_SESSION_MODE", + "KEY_MISSION_TITLE", + "KEY_MP_ACTIVE", + "KEY_ACQUIRE_START", + "KEY_ACQUIRE_PAUSED", + "KEY_ACQUIRE_STOP", + "KEY_SAVE_START", + "KEY_SAVE_STOP", + "KEY_SAVE_PENDING", + "KEY_CONSUME_START", + "KEY_CONSUME_PAUSED", + "KEY_CONSUME_STOP", + "KEY_CRASH_LATCHED", + "KEY_CHT", + "KEY_TORQUE", + "KEY_HEALTH", + "KEY_AIRSPEED", + "KEY_GROUNDSPEED", + "KEY_PITCH", + "KEY_BANK", + "KEY_STICK_AXES", + "KEY_SEQ", + "KEY_COMPARE_STATUS", + "KEY_COMPARE_PATH", + "KEY_COMPARE_LABEL", + "KEY_COMPARE_ERROR", + "KEY_COMPARE_PAYLOAD", +] diff --git a/models/state_bus/acquire.py b/models/state_bus/acquire.py new file mode 100644 index 00000000..f63d472a --- /dev/null +++ b/models/state_bus/acquire.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +""" +模块:models.state_bus.acquire +职责:摇杆采样写入 stick 组;采样门控对齐总线 acquire_*(可另开 UI 预览) +依赖:hardware_monitor、StateStore、setting、keys +""" + +from __future__ import annotations + +import threading +import time +from typing import Optional + +from models.hardware_monitor import StickAxes, poll_stick +from models.state_bus.keys import ( + GROUP_RUNTIME, + GROUP_STICK, + KEY_ACQUIRE_PAUSED, + KEY_ACQUIRE_START, + KEY_STICK_AXES, +) +from models.state_bus.store import StateStore +from setting import STATE_BUS_STICK_MS + + +class AcquireWriter: + """后台线程:按 STATE_BUS_STICK_MS 采样摇杆并 commit。 + + 门控: + - 总线 acquire_start / acquire_paused → 采(对齐获取板块) + - ui_preview → 训练页未监测时的摇杆预览(单板块,不同步 save/consume) + """ + + def __init__(self, store: StateStore) -> None: + self._store = store + self._stop = threading.Event() + self._thread: Optional[threading.Thread] = None + self._ui_preview = False + + @property + def ui_preview(self) -> bool: + return self._ui_preview + + def set_ui_preview(self, enabled: bool) -> None: + """训练页摇杆预览;非全局时间态,不改 acquire_* 定义。""" + self._ui_preview = bool(enabled) + + @property + def enabled(self) -> bool: + return self._should_sample() + + def start(self) -> None: + if self._thread is not None and self._thread.is_alive(): + return + self._stop.clear() + self._thread = threading.Thread(target=self._loop, name="state-bus-acquire", daemon=True) + self._thread.start() + + def stop(self, join_timeout: float = 1.0) -> None: + self._stop.set() + self._ui_preview = False + th = self._thread + self._thread = None + if th is not None and th.is_alive(): + th.join(timeout=join_timeout) + + def _should_sample(self) -> bool: + if self._ui_preview: + return True + snap = self._store.snapshot(GROUP_RUNTIME) + return bool(snap.get(KEY_ACQUIRE_START) or snap.get(KEY_ACQUIRE_PAUSED)) + + def _loop(self) -> None: + interval_s = max(0.01, float(STATE_BUS_STICK_MS) / 1000.0) + while not self._stop.is_set(): + t0 = time.monotonic() + if self._should_sample(): + try: + axes: StickAxes = poll_stick() + self._store.commit(GROUP_STICK, {KEY_STICK_AXES: axes}) + except Exception: + pass + elapsed = time.monotonic() - t0 + self._stop.wait(timeout=max(0.0, interval_s - elapsed)) diff --git a/models/state_bus/analyze.py b/models/state_bus/analyze.py new file mode 100644 index 00000000..0ce0424f --- /dev/null +++ b/models/state_bus/analyze.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +""" +模块:models.state_bus.analyze +职责:单线程任务队列(pandas/numpy 冷路径,如轨迹对比加载) +""" + +from __future__ import annotations + +import threading +import traceback +from dataclasses import dataclass +from typing import Any, Callable, Optional + +from models.state_bus.keys import ( + GROUP_ANALYZE, + KEY_COMPARE_ERROR, + KEY_COMPARE_LABEL, + KEY_COMPARE_PATH, + KEY_COMPARE_PAYLOAD, + KEY_COMPARE_STATUS, +) +from models.state_bus.store import StateStore + + +@dataclass +class AnalyzeJob: + kind: str + record_id: str = "" + user_id: str = "" + on_done: Optional[Callable[[Optional[Any], Optional[str]], None]] = None + + +class AnalyzeWorker: + """串行分析线程:load_compare → Store analyze 组。""" + + def __init__(self, store: StateStore) -> None: + self._store = store + self._queue: list = [] + self._cv = threading.Condition() + self._stop = threading.Event() + self._thread: Optional[threading.Thread] = None + self._busy = False + + @property + def busy(self) -> bool: + return self._busy + + def start(self) -> None: + if self._thread is not None and self._thread.is_alive(): + return + self._stop.clear() + self._thread = threading.Thread(target=self._loop, name="state-bus-analyze", daemon=True) + self._thread.start() + + def stop(self, join_timeout: float = 2.0) -> None: + self._stop.set() + with self._cv: + self._cv.notify_all() + th = self._thread + self._thread = None + if th is not None and th.is_alive(): + th.join(timeout=join_timeout) + + def enqueue_load_compare( + self, + record_id: str, + *, + user_id: str, + on_done: Optional[Callable[[Optional[Any], Optional[str]], None]] = None, + ) -> bool: + """入队云端对比加载;已在忙则返回 False。""" + rid = str(record_id or "").strip() + uid = str(user_id or "").strip() + if not rid or not uid: + return False + with self._cv: + if self._busy or self._queue: + return False + self._store.commit( + GROUP_ANALYZE, + { + KEY_COMPARE_STATUS: "loading", + KEY_COMPARE_PATH: rid, + KEY_COMPARE_LABEL: rid, + KEY_COMPARE_ERROR: "", + KEY_COMPARE_PAYLOAD: None, + }, + ) + self._queue.append( + AnalyzeJob( + kind="load_compare_record", + record_id=rid, + user_id=uid, + on_done=on_done, + ) + ) + self._cv.notify() + return True + + def _loop(self) -> None: + while not self._stop.is_set(): + with self._cv: + while not self._queue and not self._stop.is_set(): + self._cv.wait(timeout=0.2) + if self._stop.is_set(): + break + job = self._queue.pop(0) if self._queue else None + self._busy = job is not None + if job is None: + continue + err: Optional[str] = None + payload = None + from models.track_store import format_track_id_short + + label = format_track_id_short(job.record_id) or job.record_id + try: + if job.kind == "load_compare_record": + from models.track_compare import load_track_compare_record + + payload = load_track_compare_record(job.user_id, job.record_id) + label = getattr(payload, "label", None) or format_track_id_short( + job.record_id + ) or job.record_id + else: + err = f"unknown job: {job.kind}" + except Exception as exc: + err = str(exc) or traceback.format_exc(limit=1) + payload = None + status = "error" if err else "ready" + self._store.commit( + GROUP_ANALYZE, + { + KEY_COMPARE_STATUS: status, + KEY_COMPARE_PATH: job.record_id, + KEY_COMPARE_LABEL: label, + KEY_COMPARE_ERROR: err or "", + KEY_COMPARE_PAYLOAD: payload, + }, + ) + if job.on_done is not None: + try: + job.on_done(payload, err) + except Exception: + pass + with self._cv: + self._busy = False diff --git a/models/state_bus/hub.py b/models/state_bus/hub.py new file mode 100644 index 00000000..cc2e14d1 --- /dev/null +++ b/models/state_bus/hub.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +""" +模块:models.state_bus.hub +职责:主线程订阅分发;按组 period_ms 节流;按需唤醒(无常驻空转) +依赖:PySide2、setting 周期常量、StateStore +""" + +from __future__ import annotations + +import time +from typing import Any, Callable, Dict, Optional + +from PySide2.QtCore import QObject, QTimer, Qt, Signal + +from models.state_bus.keys import ( + GROUP_ANALYZE, + GROUP_CHART, + GROUP_LIVE, + GROUP_RUNTIME, + GROUP_SESSION, + GROUP_STICK, + GROUP_TRACK, +) +from models.state_bus.store import StateStore +from setting import ( + STATE_GROUP_ANALYZE_MS, + STATE_GROUP_CHART_MS, + STATE_GROUP_LIVE_MS, + STATE_GROUP_RUNTIME_MS, + STATE_GROUP_SESSION_MS, + STATE_GROUP_STICK_MS, + STATE_GROUP_TRACK_MS, +) + +DEFAULT_PERIODS: Dict[str, int] = { + GROUP_SESSION: STATE_GROUP_SESSION_MS, + GROUP_RUNTIME: STATE_GROUP_RUNTIME_MS, + GROUP_LIVE: STATE_GROUP_LIVE_MS, + GROUP_STICK: STATE_GROUP_STICK_MS, + GROUP_TRACK: STATE_GROUP_TRACK_MS, + GROUP_CHART: STATE_GROUP_CHART_MS, + GROUP_ANALYZE: STATE_GROUP_ANALYZE_MS, +} + + +class StateHub(QObject): + """主线程 Hub:Store 变更 → 排队 → 按订阅 period 回调。""" + + _groupDirty = Signal(str, int) + + def __init__(self, store: StateStore, parent=None): + super().__init__(parent) + self._store = store + self._subs: Dict[str, list] = {} + self._last_fire: Dict[str, float] = {} + self._pending: Dict[str, int] = {} + self._groupDirty.connect(self._on_group_dirty, Qt.QueuedConnection) + self._store.add_listener(self._on_store_commit) + self._tick = QTimer(self) + self._tick.setSingleShot(True) + self._tick.timeout.connect(self._flush_pending) + + def _on_store_commit(self, group: str, version: int) -> None: + self._groupDirty.emit(str(group), int(version)) + + def _on_group_dirty(self, group: str, version: int) -> None: + self._pending[group] = int(version) + self._schedule_flush() + + def _schedule_flush(self, delay_ms: int = 0) -> None: + if self._tick.isActive(): + return + self._tick.start(max(0, int(delay_ms))) + + def subscribe( + self, + group: str, + callback: Callable[[str, Dict[str, Any], int], None], + period_ms: Optional[int] = None, + ) -> None: + """注册主线程回调;period_ms=None 用 setting 默认;0 表示立即。""" + period = DEFAULT_PERIODS.get(group, 0) if period_ms is None else int(period_ms) + bucket = self._subs.setdefault(group, []) + bucket.append({"cb": callback, "period_ms": max(0, period), "last_ver": -1}) + + def unsubscribe(self, group: str, callback: Callable) -> None: + bucket = self._subs.get(group) or [] + self._subs[group] = [s for s in bucket if s["cb"] is not callback] + + def _flush_pending(self) -> None: + if not self._pending: + return + now = time.monotonic() + pending = dict(self._pending) + self._pending.clear() + retry_ms: Optional[int] = None + for group, version in pending.items(): + subs = self._subs.get(group) or [] + if not subs: + continue + snap = self._store.snapshot(group) + for sub in subs: + if sub["last_ver"] == version: + continue + period_ms = int(sub["period_ms"]) + if period_ms > 0: + key = f"{group}:{id(sub['cb'])}" + last = self._last_fire.get(key, 0.0) + waited = (now - last) * 1000.0 + if waited < period_ms: + remain = int(period_ms - waited) + 1 + self._pending[group] = max(self._pending.get(group, 0), version) + retry_ms = remain if retry_ms is None else min(retry_ms, remain) + continue + self._last_fire[key] = now + sub["last_ver"] = version + try: + sub["cb"](group, snap, version) + except Exception: + pass + if self._pending: + self._schedule_flush(0 if retry_ms is None else retry_ms) diff --git a/models/state_bus/keys.py b/models/state_bus/keys.py new file mode 100644 index 00000000..97a8ab1f --- /dev/null +++ b/models/state_bus/keys.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +""" +模块:models.state_bus.keys +职责:状态总线分组与字段名常量 +约定:协同时间态均为唯一布尔名(见 GROUP_RUNTIME / acquire|save|consume) +""" + +from __future__ import annotations + +GROUP_SESSION = "session" +GROUP_RUNTIME = "runtime" # 三板块时间状态机(唯一协同权威) +GROUP_LIVE = "live" +GROUP_STICK = "stick" +GROUP_TRACK = "track" +GROUP_CHART = "chart" +GROUP_ANALYZE = "analyze" + +ALL_GROUPS = ( + GROUP_SESSION, + GROUP_RUNTIME, + GROUP_LIVE, + GROUP_STICK, + GROUP_TRACK, + GROUP_CHART, + GROUP_ANALYZE, +) + +# session +KEY_SESSION_MODE = "session_mode" +KEY_MISSION_TITLE = "mission_title" +KEY_MP_ACTIVE = "mp_active" + +# runtime — acquire(start / paused / stop 互斥) +KEY_ACQUIRE_START = "acquire_start" +KEY_ACQUIRE_PAUSED = "acquire_paused" +KEY_ACQUIRE_STOP = "acquire_stop" + +# runtime — save(start / stop 互斥;无 paused) +KEY_SAVE_START = "save_start" +KEY_SAVE_STOP = "save_stop" +KEY_SAVE_PENDING = "save_pending" + +# runtime — consume(start / paused / stop 互斥) +KEY_CONSUME_START = "consume_start" +KEY_CONSUME_PAUSED = "consume_paused" +KEY_CONSUME_STOP = "consume_stop" + +# runtime — 旁路事件锁存(非时间态) +KEY_CRASH_LATCHED = "crash_latched" + +# live +KEY_CHT = "cht" +KEY_TORQUE = "torque" +KEY_HEALTH = "health" +KEY_AIRSPEED = "airspeed" +KEY_GROUNDSPEED = "groundspeed" +KEY_PITCH = "pitch" +KEY_BANK = "bank" + +# stick +KEY_STICK_AXES = "axes" + +# track / chart(消费侧版本号;缓冲不在总线) +KEY_SEQ = "seq" + +# analyze +KEY_COMPARE_STATUS = "compare_status" # idle | loading | ready | error +KEY_COMPARE_PATH = "compare_path" +KEY_COMPARE_LABEL = "compare_label" +KEY_COMPARE_ERROR = "compare_error" +KEY_COMPARE_PAYLOAD = "compare_payload" diff --git a/models/state_bus/lifecycle.py b/models/state_bus/lifecycle.py new file mode 100644 index 00000000..5312206a --- /dev/null +++ b/models/state_bus/lifecycle.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +""" +模块:models.state_bus.lifecycle +职责:唯一总线时间状态机(acquire / save / consume 布尔态) +约定:各板块时间态互斥;协同状态只经本类 commit,禁止板块外散落权威态 +""" + +from __future__ import annotations + +from typing import Callable, Optional + +from models.runtime_watch import PauseDetector +from models.state_bus.keys import ( + GROUP_RUNTIME, + KEY_ACQUIRE_PAUSED, + KEY_ACQUIRE_START, + KEY_ACQUIRE_STOP, + KEY_CONSUME_PAUSED, + KEY_CONSUME_START, + KEY_CONSUME_STOP, + KEY_CRASH_LATCHED, + KEY_SAVE_PENDING, + KEY_SAVE_START, + KEY_SAVE_STOP, +) + +CommitFn = Callable[[str, dict], int] + + +def _lane_acquire(mode: str) -> dict: + return { + KEY_ACQUIRE_START: mode == "start", + KEY_ACQUIRE_PAUSED: mode == "paused", + KEY_ACQUIRE_STOP: mode == "stop", + } + + +def _lane_save(mode: str) -> dict: + return { + KEY_SAVE_START: mode == "start", + KEY_SAVE_STOP: mode == "stop", + } + + +def _lane_consume(mode: str) -> dict: + return { + KEY_CONSUME_START: mode == "start", + KEY_CONSUME_PAUSED: mode == "paused", + KEY_CONSUME_STOP: mode == "stop", + } + + +class DataLifecycle: + """三板块时间状态权威:start/paused/stop(save 无 paused)。""" + + def __init__(self, commit: CommitFn) -> None: + self._commit = commit + self.pause_detector = PauseDetector() + self.crash_latched = False + self._acquire = "stop" + self._save = "stop" + self._consume = "stop" + self._save_pending = False + self.publish() + + def publish(self) -> None: + patch = {} + patch.update(_lane_acquire(self._acquire)) + patch.update(_lane_save(self._save)) + patch.update(_lane_consume(self._consume)) + patch[KEY_SAVE_PENDING] = bool(self._save_pending) + patch[KEY_CRASH_LATCHED] = bool(self.crash_latched) + self._commit(GROUP_RUNTIME, patch) + + # ---- 派生查询(UI / 编排用,权威仍是总线布尔)---- + + def is_active(self) -> bool: + """获取板块未停止(start 或 paused)。""" + return self._acquire != "stop" + + def is_waiting(self) -> bool: + """已启动监测、尚未开保存(等待解除暂停后出数)。""" + return self._acquire == "paused" and self._save == "stop" + + def is_recording(self) -> bool: + """获取未停且保存已启动(正在采数录制;含获取 paused 的短暂暂停)。 + + 停监测后若仅剩 save_start(异步冲刷 pending),不算录制中。 + """ + return self._save == "start" and self._acquire != "stop" + + def should_poll(self, shadow_active: bool, live_readouts: bool) -> bool: + return self.is_active() or shadow_active or live_readouts + + def should_refresh_metrics(self, live_readouts: bool) -> bool: + return self.is_active() or live_readouts + + # ---- 编排命令 ---- + + def enter_waiting(self) -> None: + """监测启动:获取进入 paused,保存/消费停止;清暂停基准与坠毁锁。""" + self._acquire = "paused" + self._save = "stop" + self._consume = "stop" + self._save_pending = False + self.pause_detector.clear() + self.crash_latched = False + self.publish() + + def sync_start_recording(self) -> None: + """有数据可采:三板块开始对齐(获取优先,保存/消费同步 start)。""" + self._acquire = "start" + self._save = "start" + self._consume = "start" + self.publish() + + def apply_acquire_paused(self, paused: bool) -> None: + """模拟器暂停 → 获取强制 paused;保存无 paused(会话内保持 start);消费跟随。""" + if self._acquire == "stop": + return + if paused: + in_record_session = self._consume in ("start", "paused") or self._save == "start" + self._acquire = "paused" + if self._consume != "stop": + self._consume = "paused" + # 保存板块无 paused:录制会话中保持 start;仅无会话且无 pending 时可 stop + if in_record_session or self._save_pending: + self._save = "start" + elif self._save == "start": + self._save = "stop" + else: + if self.is_waiting(): + # WAITING:保持 acquire paused,等出数后再 sync_start_recording + pass + elif self._consume == "paused" or self._save == "start": + self._acquire = "start" + self._consume = "start" + self._save = "start" + self.publish() + + def mark_save_pending(self, pending: bool) -> None: + self._save_pending = bool(pending) + if self._save_pending and self._save == "stop" and self._acquire != "stop": + self._save = "start" + self.publish() + + def return_to_idle(self) -> None: + """获取停止;消费停止;保存有 pending 则保持 start 直至冲刷完。""" + self._acquire = "stop" + self._consume = "stop" + if self._save_pending: + self._save = "start" + else: + self._save = "stop" + self.pause_detector.clear() + self.publish() + + def mark_save_flushed(self) -> None: + self._save_pending = False + self._save = "stop" + self.publish() + + def reset(self) -> None: + self._acquire = "stop" + self._save = "stop" + self._consume = "stop" + self._save_pending = False + self.pause_detector.clear() + self.crash_latched = False + self.publish() diff --git a/models/state_bus/store.py b/models/state_bus/store.py new file mode 100644 index 00000000..bede3c90 --- /dev/null +++ b/models/state_bus/store.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +""" +模块:models.state_bus.store +职责:分组状态仓(单写锁 commit;可读 snapshot) +""" + +from __future__ import annotations + +import threading +from copy import deepcopy +from typing import Any, Callable, Dict, List, Optional + +from models.state_bus.keys import ALL_GROUPS + + +class StateStore: + """按组存储状态;commit 后递增 version 并回调监听。""" + + def __init__(self) -> None: + self._lock = threading.RLock() + self._data: Dict[str, Dict[str, Any]] = {g: {} for g in ALL_GROUPS} + self._version: Dict[str, int] = {g: 0 for g in ALL_GROUPS} + self._listeners: List[Callable[[str, int], None]] = [] + + def add_listener(self, fn: Callable[[str, int], None]) -> None: + with self._lock: + if fn not in self._listeners: + self._listeners.append(fn) + + def remove_listener(self, fn: Callable[[str, int], None]) -> None: + with self._lock: + if fn in self._listeners: + self._listeners.remove(fn) + + def commit(self, group: str, patch: Dict[str, Any]) -> int: + """合并 patch 到组;返回新 version。空 patch 不升版。""" + if not patch: + return self.version(group) + listeners: List[Callable[[str, int], None]] + with self._lock: + if group not in self._data: + self._data[group] = {} + self._version[group] = 0 + bucket = self._data[group] + changed = False + for key, value in patch.items(): + if bucket.get(key) != value: + bucket[key] = value + changed = True + if not changed: + return int(self._version.get(group, 0)) + self._version[group] = int(self._version.get(group, 0)) + 1 + ver = self._version[group] + listeners = list(self._listeners) + for fn in listeners: + try: + fn(group, ver) + except Exception: + pass + return ver + + def version(self, group: str) -> int: + with self._lock: + return int(self._version.get(group, 0)) + + def get(self, group: str, key: str, default: Any = None) -> Any: + with self._lock: + return self._data.get(group, {}).get(key, default) + + def snapshot(self, group: str) -> Dict[str, Any]: + with self._lock: + return deepcopy(self._data.get(group, {})) + + def snapshot_many(self, groups: Optional[List[str]] = None) -> Dict[str, Dict[str, Any]]: + with self._lock: + keys = list(groups) if groups is not None else list(self._data.keys()) + return {g: deepcopy(self._data.get(g, {})) for g in keys} diff --git a/models/track_acquire_worker.py b/models/track_acquire_worker.py new file mode 100644 index 00000000..9789b8fb --- /dev/null +++ b/models/track_acquire_worker.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +""" +模块:models.track_acquire_worker +职责:监测录制期独占采集线程(读 Sim + 格子落点),主线程只消费有界队列 +借鉴:Real_TD 单生产者 + 有界队列(满则丢旧保新) +约束:SimConnect 经 sm._api_lock 串行;勿在多线程并发写 UI +""" + +from __future__ import annotations + +import queue +import threading +import time +from dataclasses import dataclass +from typing import Any, Callable, Dict, Optional + +from services.simulator import data_poll_ms_for_sim_rate, read_simulation_rate + + +@dataclass +class AcquireTick: + """采集线程产出的一拍(供主线程灌总线 / 图表)。""" + + snap: Dict[str, Any] + metrics: Dict[str, Any] + torque: Optional[float] + health_ratio: Optional[float] + flight_t: float + wrote_sample: bool + + +class TrackAcquireWorker: + """ + 录制中后台循环:模拟时间格子采样 → TrajectoryRecorder; + 轻量 tick 入有界队列供 UI(满则丢最旧)。 + """ + + def __init__(self, queue_size: int = 8): + self._stop = threading.Event() + self._thread: Optional[threading.Thread] = None + self._q: queue.Queue = queue.Queue(maxsize=max(2, int(queue_size))) + self._dropped = 0 + self._fault: Optional[str] = None + self._lock = threading.Lock() + + @property + def dropped(self) -> int: + return int(self._dropped) + + @property + def fault(self) -> Optional[str]: + with self._lock: + return self._fault + + def is_running(self) -> bool: + th = self._thread + return th is not None and th.is_alive() + + def start( + self, + *, + aq, + read_tick: Callable[[Optional[float]], tuple], + get_flight_t: Callable[[], Optional[float]], + sample_fn: Callable[..., bool], + get_lap: Callable[[], int], + get_stick_kwargs: Callable[[], dict], + is_paused: Callable[[], bool], + should_run: Callable[[], bool], + ) -> None: + self.stop() + self._stop = threading.Event() + self._fault = None + self._dropped = 0 + while True: + try: + self._q.get_nowait() + except queue.Empty: + break + + def _loop() -> None: + while not self._stop.is_set(): + t0 = time.perf_counter() + try: + if not should_run(): + break + if is_paused(): + self._stop.wait(0.05) + continue + flight_t = get_flight_t() + if flight_t is None: + self._stop.wait(0.02) + continue + snap, metrics, torque, health_ratio = read_tick(flight_t) + wrote = False + try: + wrote = bool( + sample_fn( + aq, + torque=torque, + health_ratio=health_ratio, + lap=get_lap(), + timestamp_s=flight_t, + snapshot=snap, + **get_stick_kwargs(), + ) + ) + except OSError as exc: + with self._lock: + self._fault = str(exc) or type(exc).__name__ + break + self._offer( + AcquireTick( + snap=snap, + metrics=dict(metrics or {}), + torque=torque, + health_ratio=health_ratio, + flight_t=float(flight_t), + wrote_sample=wrote, + ) + ) + rate = read_simulation_rate(aq) + budget = data_poll_ms_for_sim_rate(rate) / 1000.0 + except Exception as exc: + with self._lock: + self._fault = str(exc) or type(exc).__name__ + break + elapsed = time.perf_counter() - t0 + self._stop.wait(timeout=max(0.001, budget - elapsed)) + + self._thread = threading.Thread( + target=_loop, name="track-acquire", daemon=True + ) + self._thread.start() + + def stop(self, join_timeout: float = 1.5) -> None: + self._stop.set() + th = self._thread + self._thread = None + if th is not None and th.is_alive() and th is not threading.current_thread(): + th.join(timeout=float(join_timeout)) + + def drain(self) -> list: + """取出队列中全部 tick(主线程调用)。""" + out = [] + while True: + try: + out.append(self._q.get_nowait()) + except queue.Empty: + break + return out + + def _offer(self, tick: AcquireTick) -> None: + """满则丢最旧,保证 UI 拿到较新帧。""" + while True: + try: + self._q.put_nowait(tick) + return + except queue.Full: + try: + self._q.get_nowait() + self._dropped += 1 + except queue.Empty: + return diff --git a/models/track_compare.py b/models/track_compare.py new file mode 100644 index 00000000..2ae8cb99 --- /dev/null +++ b/models/track_compare.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +""" +模块:models.track_compare +职责:从云端航迹 / Trajectory 构建趋势/圈时对比载荷 +依赖:models.chart、lap_reference、shadow_plane、data_bridge、track_store +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional, Sequence + +from models.chart import ChartHistoryBuffer +from models.data_bridge import trajectory_to_history +from models.lap_reference import LAP_COUNT, LapReference +from models.shadow_plane import Trajectory, TrajectorySample, build_trajectory_label + + +@dataclass +class CompareStyle: + """历史对比层样式:颜色取自原系列并降饱和;线宽/线型/透明度固定。""" + + opacity: float = 0.7 # 与 setting.CHART_HISTORY_OPACITY 对齐;实例化时由调用方覆盖 + line_width: float = 1.6 + line_style: str = "dash" # solid | dash | dot | dashdot + visible: bool = True + + +def default_compare_style() -> CompareStyle: + from setting import CHART_HISTORY_OPACITY + + return CompareStyle(opacity=float(CHART_HISTORY_OPACITY)) + + +@dataclass +class TrackComparePayload: + """一次对比加载结果(轨迹只读一次)。""" + + path: Path + label: str + trajectory: Trajectory + history: ChartHistoryBuffer + laps: LapReference + record_id: str = "" + + @property + def has_trend(self) -> bool: + return len(self.history) > 0 + + @property + def has_laps(self) -> bool: + return any(t is not None for t in self.laps.as_four()) + + +def payload_from_trajectory( + traj: Trajectory, + *, + label: str, + record_id: str = "", +) -> TrackComparePayload: + """由已加载 Trajectory 组装对比载荷。""" + history = trajectory_to_history(traj) + name = label or record_id or "cloud航迹" + laps = LapReference(laps_s=list(traj.laps_s), label=name) + if not any(t is not None for t in laps.as_four()): + inferred = _infer_laps_from_samples(traj.samples) + if any(t is not None for t in inferred): + laps = LapReference(laps_s=inferred, label=name) + return TrackComparePayload( + path=Path(record_id or name), + label=name, + trajectory=traj, + history=history, + laps=laps, + record_id=str(record_id or ""), + ) + + +def load_track_compare_record(user_id: str, record_id: str) -> TrackComparePayload: + """从云端 user_defined_3 按 id 加载对比载荷。""" + from models.track_store import format_track_id_short, load_trajectory_record + + traj = load_trajectory_record(user_id, record_id) + label = build_trajectory_label(traj.mission_title, total_s=traj.duration_s) + short = format_track_id_short(record_id) + if traj.recorded_at: + # 优先可读标签:任务名 + 录制时间 + 短 ID + stamp = traj.recorded_at.replace(":", "").replace("T", "_")[:15] + prefix = (traj.mission_title or label).strip() or label + label = f"{prefix}_{stamp}" if stamp else label + if short: + label = f"{label} ({short})" + return payload_from_trajectory(traj, label=label, record_id=record_id) + + +def _infer_laps_from_samples(samples: Sequence[TrajectorySample]) -> List[Optional[float]]: + """按 lap 列切换点估算各圈耗时;无法推断则返回空。""" + if not samples: + return [None] * LAP_COUNT + starts = {1: float(samples[0].timestamp_s)} + last_lap = int(samples[0].lap) if samples[0].lap else 0 + for sample in samples[1:]: + lap = int(sample.lap) if sample.lap else last_lap + if lap != last_lap and lap >= 1: + starts[lap] = float(sample.timestamp_s) + last_lap = lap + ends: dict[int, float] = {} + for sample in samples: + lap = int(sample.lap) if sample.lap else 0 + if lap >= 1: + ends[lap] = float(sample.timestamp_s) + out: List[Optional[float]] = [] + for i in range(1, LAP_COUNT + 1): + if i not in starts or i not in ends: + out.append(None) + continue + dt = ends[i] - starts[i] + out.append(dt if dt > 0 else None) + return out + + +def lap_finish_markers_from_samples( + samples: Sequence[TrajectorySample], +) -> List[tuple]: + """ + 从采样 lap 列提取圈完成绝对时刻 → [(lap_number, timestamp_s), ...]。 + 圈切换点记上一圈结束;末圈补最后一点。 + """ + if not samples: + return [] + markers: List[tuple] = [] + last_lap = int(samples[0].lap) if samples[0].lap else 0 + for i in range(1, len(samples)): + lap = int(samples[i].lap) if samples[i].lap else last_lap + if lap <= 0: + lap = last_lap + if lap != last_lap and last_lap >= 1: + markers.append((last_lap, float(samples[i - 1].timestamp_s))) + if lap >= 1: + last_lap = lap + if last_lap >= 1: + end_ts = float(samples[-1].timestamp_s) + if markers and markers[-1][0] == last_lap: + markers[-1] = (last_lap, end_ts) + else: + markers.append((last_lap, end_ts)) + return markers diff --git a/models/track_fields.py b/models/track_fields.py new file mode 100644 index 00000000..f7d434c8 --- /dev/null +++ b/models/track_fields.py @@ -0,0 +1,425 @@ +# -*- coding: utf-8 -*- +""" +模块:models.track_fields +职责:航迹采样字段注册(Sim/摇杆/派生)、启用开关解析、采集列序 +依赖:setting.TRACK_RECORD_FIELD_ENABLE、services.simulator 变量名与换算 +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Dict, Iterable, List, Mapping, Optional, Sequence, Tuple + +from services.simulator import ( + AIRSPEED_TRUE_VAR, + AIRSPEED_VAR, + AMBIENT_DENSITY_VAR, + AMBIENT_TEMP_VAR, + AMBIENT_WIND_DIR_VAR, + AMBIENT_WIND_VEL_VAR, + BANK_VAR, + DAMAGE_VAR, + FT_LB_TO_N_M, + FT_TO_M, + G_FORCE_VAR, + GENERAL_ENG_RPM_VAR, + GROUND_SPEED_VAR, + INCIDENCE_ALPHA_VAR, + INCIDENCE_BETA_VAR, + KTS_TO_M_S, + LB_TO_KG, + LBF_TO_N, + M_S_TO_KTS, + MANIFOLD_PSI_VAR, + PITCH_VAR, + PLANE_AGL_VAR, + PLANE_ALT_VAR, + PLANE_HEADING_VAR, + PLANE_LAT_VAR, + PLANE_LON_VAR, + PROP_BETA_VAR, + PROP_LEVER_VAR, + PROP_RPM_VAR, + PROP_THRUST_VAR, + ELEVATOR_DEFLECTION_VAR, + AILERON_DEFLECTION_VAR, + RUDDER_DEFLECTION_VAR, + SMOKE_ENABLE_VAR, + TOTAL_WEIGHT_VAR, + TORQUE_VAR, + VERTICAL_SPEED_VAR, + optional_float, + rad_to_deg, +) + +try: + from setting import TRACK_FIELD_UNITS, TRACK_RECORD_FIELD_ENABLE +except ImportError: # pragma: no cover + TRACK_RECORD_FIELD_ENABLE = {} + TRACK_FIELD_UNITS = {} + + +# --------------------------------------------------------------------------- +# 字段规格 +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class TrackFieldSpec: + """单列轨迹字段。""" + + key: str + fmt: str + # core | sim | inject | stick | derived + source: str + sim_var: str = "" + # "" | rad_to_deg | bool01 + convert: str = "" + level: str = "A" + # 核心位姿:不可关闭,影子机/航迹依赖 + core: bool = False + default_enabled: bool = True + label: str = "" + unit: str = "" + + def __post_init__(self) -> None: + if not self.unit: + object.__setattr__(self, "unit", str(TRACK_FIELD_UNITS.get(self.key, "") or "")) + + +# 表头顺序 = 云存/采样列序 +TRACK_FIELD_SPECS: Tuple[TrackFieldSpec, ...] = ( + # —— 核心(始终采集)—— + TrackFieldSpec("timestamp_s", ".3f", "core", core=True, label="任务相对时间"), + TrackFieldSpec("latitude", ".8f", "sim", PLANE_LAT_VAR, core=True, label="纬度"), + TrackFieldSpec("longitude", ".8f", "sim", PLANE_LON_VAR, core=True, label="经度"), + TrackFieldSpec("altitude_ft", ".2f", "sim", PLANE_ALT_VAR, core=True, label="气压高度"), + TrackFieldSpec("agl_ft", ".2f", "sim", PLANE_AGL_VAR, core=True, label="离地高度"), + TrackFieldSpec( + "heading_deg", ".2f", "sim", PLANE_HEADING_VAR, convert="rad_to_deg", core=True, label="真航向" + ), + TrackFieldSpec("airspeed_kts", ".2f", "sim", AIRSPEED_VAR, core=True, label="指示空速"), + TrackFieldSpec("groundspeed_kts", ".2f", "sim", GROUND_SPEED_VAR, core=True, label="地速"), + TrackFieldSpec( + "pitch_deg", ".2f", "sim", PITCH_VAR, convert="rad_to_deg", core=True, label="俯仰" + ), + TrackFieldSpec( + "bank_deg", ".2f", "sim", BANK_VAR, convert="rad_to_deg", core=True, label="坡度" + ), + # —— A 既有可开关 —— + TrackFieldSpec("torque", ".2f", "inject", TORQUE_VAR, level="A", label="扭矩"), + TrackFieldSpec("health_pct", ".2f", "inject", DAMAGE_VAR, level="A", label="健康度"), + TrackFieldSpec("stick_pitch", ".4f", "stick", level="A", label="摇杆俯仰"), + TrackFieldSpec("stick_roll", ".4f", "stick", level="A", label="摇杆横滚"), + TrackFieldSpec("stick_yaw", ".4f", "stick", level="A", label="摇杆方向"), + TrackFieldSpec("stick_throttle", ".4f", "stick", level="A", label="摇杆油门"), + TrackFieldSpec("lap", "", "inject", level="A", core=True, label="圈号"), + # —— B 扩录 —— + TrackFieldSpec("tas_kts", ".2f", "sim", AIRSPEED_TRUE_VAR, level="B", label="真空速"), + TrackFieldSpec("vs_fpm", ".1f", "sim", VERTICAL_SPEED_VAR, level="B", label="垂直速度"), + TrackFieldSpec("rpm", ".1f", "sim", PROP_RPM_VAR, level="B", label="螺旋桨转速"), + TrackFieldSpec("eng_rpm", ".1f", "sim", GENERAL_ENG_RPM_VAR, level="B", label="发动机转速"), + TrackFieldSpec("manifold_psi", ".2f", "sim", MANIFOLD_PSI_VAR, level="B", label="歧管压"), + TrackFieldSpec("prop_thrust_lbf", ".2f", "sim", PROP_THRUST_VAR, level="B", label="桨推力"), + TrackFieldSpec( + "alpha_deg", ".3f", "sim", INCIDENCE_ALPHA_VAR, convert="rad_to_deg", level="B", label="迎角" + ), + TrackFieldSpec( + "beta_deg", ".3f", "sim", INCIDENCE_BETA_VAR, convert="rad_to_deg", level="B", label="侧滑角" + ), + TrackFieldSpec("g_force", ".3f", "sim", G_FORCE_VAR, level="B", label="过载"), + TrackFieldSpec("weight_lb", ".1f", "sim", TOTAL_WEIGHT_VAR, level="B", label="总重"), + TrackFieldSpec( + "ambient_density", ".6f", "sim", AMBIENT_DENSITY_VAR, level="B", label="大气密度" + ), + TrackFieldSpec( + "ambient_temp_c", ".2f", "sim", AMBIENT_TEMP_VAR, level="B", label="外界气温" + ), + TrackFieldSpec("wind_kts", ".2f", "sim", AMBIENT_WIND_VEL_VAR, level="B", label="风速"), + TrackFieldSpec( + "wind_dir_deg", ".2f", "sim", AMBIENT_WIND_DIR_VAR, level="B", label="风向" + ), + TrackFieldSpec( + "smoke_on", ".0f", "sim", SMOKE_ENABLE_VAR, convert="bool01", level="B", label="烟雾" + ), + TrackFieldSpec( + "prop_beta_deg", + ".3f", + "sim", + PROP_BETA_VAR, + convert="rad_to_deg", + level="B", + label="桨叶角", + ), + TrackFieldSpec("prop_lever_pct", ".2f", "sim", PROP_LEVER_VAR, level="B", label="桨距杆"), + TrackFieldSpec( + "elevator_deg", + ".3f", + "sim", + ELEVATOR_DEFLECTION_VAR, + convert="rad_to_deg", + level="B", + label="升降舵偏角", + ), + TrackFieldSpec( + "aileron_deg", + ".3f", + "sim", + AILERON_DEFLECTION_VAR, + convert="rad_to_deg", + level="B", + label="副翼偏角", + ), + TrackFieldSpec( + "rudder_deg", + ".3f", + "sim", + RUDDER_DEFLECTION_VAR, + convert="rad_to_deg", + level="B", + label="方向舵偏角", + ), + # —— C 派生(有依赖才算)—— + TrackFieldSpec("p_shaft_w", ".2f", "derived", level="C", label="轴功率"), + TrackFieldSpec("p_prop_w", ".2f", "derived", level="C", label="推进功率"), + TrackFieldSpec("eta_prop", ".4f", "derived", level="C", label="推进效率"), + TrackFieldSpec("e_mech_j", ".2f", "derived", level="C", label="机械能"), + TrackFieldSpec("di_proxy", ".6f", "derived", level="C", label="诱导阻相对强度"), + TrackFieldSpec("ds_m", ".3f", "derived", level="C", label="航迹微元"), + TrackFieldSpec("vg_course_kts", ".2f", "derived", level="C", label="沿迹地速"), +) + +TRACK_FIELD_BY_KEY: Dict[str, TrackFieldSpec] = {s.key: s for s in TRACK_FIELD_SPECS} + +# 样本 dataclass / 读写共用的可空浮点指标列(不含 timestamp/位姿核心与 lap) +SAMPLE_METRIC_KEYS: Tuple[str, ...] = tuple( + s.key + for s in TRACK_FIELD_SPECS + if s.key + not in ( + "timestamp_s", + "latitude", + "longitude", + "altitude_ft", + "agl_ft", + "heading_deg", + "airspeed_kts", + "groundspeed_kts", + "pitch_deg", + "bank_deg", + "lap", + ) +) + +# 影子机插值只动这些位姿列 +POSE_INTERP_KEYS: Tuple[str, ...] = ( + "latitude", + "longitude", + "altitude_ft", + "agl_ft", + "heading_deg", + "airspeed_kts", + "groundspeed_kts", + "pitch_deg", + "bank_deg", +) + + +def is_field_enabled(key: str) -> bool: + spec = TRACK_FIELD_BY_KEY.get(key) + if spec is None: + return False + if spec.core: + return True + cfg = TRACK_RECORD_FIELD_ENABLE + if isinstance(cfg, Mapping) and key in cfg: + return bool(cfg[key]) + return bool(spec.default_enabled) + + +def enabled_track_columns() -> List[str]: + """当前启用的航迹采样列(含核心)。""" + return [s.key for s in TRACK_FIELD_SPECS if is_field_enabled(s.key)] + + +def enabled_sim_specs() -> List[TrackFieldSpec]: + """需从 AircraftRequests 读取的启用字段(不含 inject 的 torque/health 主路径)。""" + out: List[TrackFieldSpec] = [] + for spec in TRACK_FIELD_SPECS: + if not is_field_enabled(spec.key): + continue + if spec.source != "sim" or not spec.sim_var: + continue + # 核心位姿由 read_aq_snapshot 固定路径读取(含 AGL 修补) + if spec.core: + continue + out.append(spec) + return out + + +def _apply_convert(spec: TrackFieldSpec, raw) -> float: + if spec.convert == "rad_to_deg": + v = rad_to_deg(raw, float("nan")) + return float("nan") if v is None else float(v) + if spec.convert == "bool01": + v = optional_float(raw) + if v is None: + return float("nan") + return 1.0 if v else 0.0 + v = optional_float(raw) + return float("nan") if v is None else float(v) + + +def read_enabled_sim_fields( + aq, + raw: Optional[Mapping[str, object]] = None, +) -> Dict[str, float]: + """按开关读取扩录 Sim 字段;raw 为批量拉取结果时可免 aq.get。""" + out: Dict[str, float] = {} + if not aq and raw is None: + return out + for spec in enabled_sim_specs(): + try: + if raw is not None: + val = raw.get(spec.sim_var) + else: + val = aq.get(spec.sim_var) if aq else None + except Exception: + out[spec.key] = float("nan") + continue + out[spec.key] = _apply_convert(spec, val) + return out + + +def _finite(value) -> Optional[float]: + try: + v = float(value) + except (TypeError, ValueError): + return None + if v != v or v in (float("inf"), float("-inf")): + return None + return v + + +def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """球面距离(m)。""" + r = 6371000.0 + p1 = math.radians(lat1) + p2 = math.radians(lat2) + dphi = math.radians(lat2 - lat1) + dlmb = math.radians(lon2 - lon1) + a = math.sin(dphi * 0.5) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlmb * 0.5) ** 2 + return 2.0 * r * math.asin(min(1.0, math.sqrt(a))) + + +def fill_derived_fields( + row: Dict[str, float], + *, + prev: Optional[Mapping[str, float]] = None, +) -> None: + """按开关就地写入 C 列;缺依赖则写 nan。""" + nan = float("nan") + + if is_field_enabled("p_shaft_w") or is_field_enabled("eta_prop"): + torque = _finite(row.get("torque")) + rpm = _finite(row.get("rpm")) + if rpm is None: + rpm = _finite(row.get("eng_rpm")) + if torque is None or rpm is None: + if is_field_enabled("p_shaft_w"): + row["p_shaft_w"] = nan + p_shaft = None + else: + omega = 2.0 * math.pi * rpm / 60.0 + p_shaft = (torque * FT_LB_TO_N_M) * omega + if is_field_enabled("p_shaft_w"): + row["p_shaft_w"] = p_shaft + else: + p_shaft = _finite(row.get("p_shaft_w")) + + if is_field_enabled("p_prop_w") or is_field_enabled("eta_prop"): + thrust = _finite(row.get("prop_thrust_lbf")) + tas = _finite(row.get("tas_kts")) + if thrust is None or tas is None: + if is_field_enabled("p_prop_w"): + row["p_prop_w"] = nan + p_prop = None + else: + p_prop = (thrust * LBF_TO_N) * (tas * KTS_TO_M_S) + if is_field_enabled("p_prop_w"): + row["p_prop_w"] = p_prop + else: + p_prop = _finite(row.get("p_prop_w")) + + if is_field_enabled("eta_prop"): + if p_shaft is None: + p_shaft = _finite(row.get("p_shaft_w")) + if p_prop is None: + p_prop = _finite(row.get("p_prop_w")) + if p_shaft is None or p_prop is None or abs(p_shaft) < 1e-9: + row["eta_prop"] = nan + else: + row["eta_prop"] = p_prop / p_shaft + + if is_field_enabled("e_mech_j"): + weight = _finite(row.get("weight_lb")) + tas = _finite(row.get("tas_kts")) + alt = _finite(row.get("altitude_ft")) + if weight is None or tas is None or alt is None: + row["e_mech_j"] = nan + else: + m = weight * LB_TO_KG + v = tas * KTS_TO_M_S + h = alt * FT_TO_M + row["e_mech_j"] = 0.5 * m * v * v + m * 9.80665 * h + + if is_field_enabled("di_proxy"): + n = _finite(row.get("g_force")) + w = _finite(row.get("weight_lb")) + rho = _finite(row.get("ambient_density")) + tas = _finite(row.get("tas_kts")) + if n is None or w is None or rho is None or tas is None or abs(tas) < 1e-6: + row["di_proxy"] = nan + else: + # 单位强度:n² W² /(ρ V²);密度保持 slug/ft³、V 用 kt 亦可,圈内归一化 + row["di_proxy"] = (n * n * w * w) / (rho * tas * tas) + + if is_field_enabled("ds_m") or is_field_enabled("vg_course_kts"): + lat = _finite(row.get("latitude")) + lon = _finite(row.get("longitude")) + ts = _finite(row.get("timestamp_s")) + if prev is None or lat is None or lon is None or ts is None: + if is_field_enabled("ds_m"): + row["ds_m"] = 0.0 if prev is None else nan + if is_field_enabled("vg_course_kts"): + row["vg_course_kts"] = nan + else: + plat = _finite(prev.get("latitude")) + plon = _finite(prev.get("longitude")) + pts = _finite(prev.get("timestamp_s")) + if plat is None or plon is None or pts is None: + if is_field_enabled("ds_m"): + row["ds_m"] = nan + if is_field_enabled("vg_course_kts"): + row["vg_course_kts"] = nan + else: + ds = haversine_m(plat, plon, lat, lon) + if is_field_enabled("ds_m"): + row["ds_m"] = ds + if is_field_enabled("vg_course_kts"): + dt = ts - pts + if dt <= 1e-6: + row["vg_course_kts"] = nan + else: + row["vg_course_kts"] = (ds / dt) * M_S_TO_KTS + + +def sample_metric_defaults() -> Dict[str, float]: + return {k: float("nan") for k in SAMPLE_METRIC_KEYS} + + +def iter_toggleable_fields() -> Iterable[TrackFieldSpec]: + """可供 setting 开关的字段(非 core)。""" + for spec in TRACK_FIELD_SPECS: + if not spec.core: + yield spec diff --git a/models/track_map.py b/models/track_map.py new file mode 100644 index 00000000..ea0282c5 --- /dev/null +++ b/models/track_map.py @@ -0,0 +1,460 @@ +# -*- coding: utf-8 -*- +""" +模块:models.track_map +职责:航迹点数据结构、经纬投影与连续指标着色(供航迹图 UI) +依赖:models.speed_bands、models.data_bridge(派生字段) +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import List, Optional, Sequence, Tuple + +from models.speed_bands import DEFAULT_SPEED_BANDS +from setting import ( + TRACK_COLOR_BANK_CEIL, + TRACK_COLOR_BANK_FLOOR, + TRACK_COLOR_HEALTH_FLOOR, + TRACK_COLOR_HEALTH_PURPLE_BELOW, + TRACK_COLOR_HEALTH_PURPLE_RGB, + TRACK_COLOR_SPEED_FLOOR_KTS, + TRACK_COLOR_YLIM_DI_PROXY, + TRACK_COLOR_YLIM_ETA_PROP, + TRACK_COLOR_YLIM_SPEED, + TRACK_LEGEND_DECIMAL_BELOW, +) + +# 航迹色带固定量程字段(空速 / 地速 / 沿迹地速) +FIXED_SPEED_COLOR_FIELDS = frozenset({"airspeed", "groundspeed", "vg_course"}) + + +@dataclass(frozen=True) +class TrackPoint: + """单点航迹:经纬 + 高度 + 速度 + 姿态 + 健康 + 扭矩 + 摇杆 + 扩录指标 + 圈号。""" + + latitude: float + longitude: float + airspeed_kts: float + groundspeed_kts: float + lap: int = 0 + timestamp_s: float = 0.0 + altitude_ft: float = float("nan") + pitch_deg: float = float("nan") + bank_deg: float = float("nan") + heading_deg: float = float("nan") + health_pct: float = float("nan") + torque: float = float("nan") + stick_pitch: float = float("nan") + stick_roll: float = float("nan") + stick_yaw: float = float("nan") + stick_throttle: float = float("nan") + eta_prop: float = float("nan") + di_proxy: float = float("nan") + vg_course_kts: float = float("nan") + + def stick_connected(self) -> bool: + return ( + self.stick_pitch == self.stick_pitch + or self.stick_roll == self.stick_roll + or self.stick_yaw == self.stick_yaw + or self.stick_throttle == self.stick_throttle + ) + + +@dataclass(frozen=True) +class TrackBounds: + min_lon: float + max_lon: float + min_lat: float + max_lat: float + + @property + def lon_range(self) -> float: + return max(1e-9, self.max_lon - self.min_lon) + + @property + def lat_range(self) -> float: + return max(1e-9, self.max_lat - self.min_lat) + + @property + def avg_lat(self) -> float: + return 0.5 * (self.min_lat + self.max_lat) + + def ground_aspect(self) -> float: + """地面真实宽高比(经度按 cos(lat) 修正)。""" + lon_scale = 111.0 * math.cos(math.radians(self.avg_lat)) + lat_scale = 111.0 + return (self.lon_range * lon_scale) / (self.lat_range * lat_scale) + + +def points_from_trajectory_samples(samples: Sequence) -> List[TrackPoint]: + """TrajectorySample 序列 → TrackPoint 列表。""" + out: List[TrackPoint] = [] + for s in samples: + try: + lat = float(getattr(s, "latitude", 0.0)) + lon = float(getattr(s, "longitude", 0.0)) + except (TypeError, ValueError): + continue + if lat != lat or lon != lon: + continue + out.append( + TrackPoint( + latitude=lat, + longitude=lon, + airspeed_kts=_safe_float(getattr(s, "airspeed_kts", 0.0)), + groundspeed_kts=_safe_float(getattr(s, "groundspeed_kts", 0.0)), + lap=int(getattr(s, "lap", 0) or 0), + timestamp_s=_safe_float(getattr(s, "timestamp_s", 0.0)), + altitude_ft=_optional_float(getattr(s, "altitude_ft", None)), + pitch_deg=_optional_float(getattr(s, "pitch_deg", None)), + bank_deg=_optional_float(getattr(s, "bank_deg", None)), + heading_deg=_optional_float(getattr(s, "heading_deg", None)), + health_pct=_optional_float(getattr(s, "health_pct", None)), + torque=_optional_float(getattr(s, "torque", None)), + stick_pitch=_optional_float(getattr(s, "stick_pitch", None)), + stick_roll=_optional_float(getattr(s, "stick_roll", None)), + stick_yaw=_optional_float(getattr(s, "stick_yaw", None)), + stick_throttle=_optional_float(getattr(s, "stick_throttle", None)), + eta_prop=_optional_float(getattr(s, "eta_prop", None)), + di_proxy=_optional_float(getattr(s, "di_proxy", None)), + vg_course_kts=_optional_float(getattr(s, "vg_course_kts", None)), + ) + ) + return out + + +def filter_by_visible_laps( + points: Sequence[TrackPoint], + visible_laps: Sequence[bool], +) -> List[TrackPoint]: + """按 LAP1..N 勾选过滤;无圈号点在任一勾选时保留。""" + if not points: + return [] + n = len(visible_laps) + if n <= 0 or all(visible_laps): + return list(points) + allowed = {i + 1 for i, on in enumerate(visible_laps) if on} + if not allowed: + return [] + out: List[TrackPoint] = [] + for p in points: + lap = int(p.lap) + if lap <= 0 or lap in allowed: + out.append(p) + return out + + +def filter_points_with_metrics( + points: Sequence[TrackPoint], + metrics: Sequence[float], + visible_laps: Sequence[bool], + *, + include_unknown_lap: bool = True, +) -> Tuple[List[TrackPoint], List[float]]: + """与 filter_by_visible_laps 同步过滤指标序列。 + + include_unknown_lap=False 时仅保留明确落在 allowed 的点(叠加层用,避免 lap<=0 污染)。 + """ + if len(metrics) != len(points): + metrics = list(metric_values_for_field(points, "airspeed")) + if not points: + return [], [] + n = len(visible_laps) + if n <= 0 or all(visible_laps): + return list(points), [float(m) for m in metrics] + allowed = {i + 1 for i, on in enumerate(visible_laps) if on} + if not allowed: + return [], [] + out_p: List[TrackPoint] = [] + out_m: List[float] = [] + for p, m in zip(points, metrics): + lap = int(p.lap) + if lap in allowed or (include_unknown_lap and lap <= 0): + out_p.append(p) + out_m.append(float(m)) + return out_p, out_m + + +def compute_bounds(points: Sequence[TrackPoint]) -> Optional[TrackBounds]: + if not points: + return None + min_lon = min(p.longitude for p in points) + max_lon = max(p.longitude for p in points) + min_lat = min(p.latitude for p in points) + max_lat = max(p.latitude for p in points) + if max_lon - min_lon < 1e-9: + max_lon = min_lon + 1e-4 + if max_lat - min_lat < 1e-9: + max_lat = min_lat + 1e-4 + return TrackBounds(min_lon, max_lon, min_lat, max_lat) + + +# 色标阈值:统一从 setting 引入(保留旧名别名) +COLOR_SPEED_FLOOR_KTS = TRACK_COLOR_SPEED_FLOOR_KTS +COLOR_HEALTH_FLOOR = TRACK_COLOR_HEALTH_FLOOR +COLOR_HEALTH_PURPLE_BELOW = TRACK_COLOR_HEALTH_PURPLE_BELOW +COLOR_HEALTH_PURPLE_RGB = TRACK_COLOR_HEALTH_PURPLE_RGB +COLOR_BANK_FLOOR = TRACK_COLOR_BANK_FLOOR +COLOR_BANK_CEIL = TRACK_COLOR_BANK_CEIL + +_DIRECT_FIELD_GETTERS = { + "airspeed": lambda p: float(p.airspeed_kts), + "groundspeed": lambda p: float(p.groundspeed_kts), + "pitch": lambda p: float(p.pitch_deg), + "bank": lambda p: _abs_finite(p.bank_deg), + "health": lambda p: float(p.health_pct), + "torque": lambda p: float(p.torque), + "eta_prop": lambda p: float(p.eta_prop), + "di_proxy": lambda p: float(p.di_proxy), + "vg_course": lambda p: float(p.vg_course_kts), +} + + +def _abs_finite(value: float) -> float: + try: + v = float(value) + except (TypeError, ValueError): + return float("nan") + return abs(v) if v == v else float("nan") + + +def metric_values_for_field( + points: Sequence[TrackPoint], + field_id: str, +) -> List[float]: + """ + 按图表字段 id 取与 points 等长的着色指标。 + speed_rate / accel_rate 用地速对时间的一/二阶导(全序列上下文)。 + """ + n = len(points) + if n <= 0: + return [] + fid = str(field_id or "airspeed") + getter = _DIRECT_FIELD_GETTERS.get(fid) + if getter is not None: + return [getter(p) for p in points] + if fid in ("speed_rate", "accel_rate"): + from models.data_bridge import first_derivative_series, second_derivative_series + + t = [float(p.timestamp_s) for p in points] + gs = [float(p.groundspeed_kts) for p in points] + arr = ( + second_derivative_series(t, gs) + if fid == "accel_rate" + else first_derivative_series(t, gs) + ) + return [float(v) for v in arr] + return [float(p.airspeed_kts) for p in points] + + +def metric_range( + values: Sequence[float], + *, + floor: Optional[float] = None, +) -> Optional[Tuple[float, float]]: + """有限样本指标范围;可选下限。""" + finite = [float(v) for v in values if v == v] + if not finite: + return None + lo = float(min(finite)) + hi = float(max(finite)) + if hi < lo: + lo, hi = hi, lo + if floor is not None: + lo = max(float(floor), lo) + if hi < lo: + hi = lo + if hi - lo < 1e-3: + pad = max(1.0, abs(lo) * 0.05 if lo != 0 else 1.0) + return lo, hi + pad + return lo, hi + + +def metric_range_for_field( + points: Sequence[TrackPoint], + field_id: str, +) -> Optional[Tuple[float, float]]: + """会话点在指定字段上的色标范围(含字段特例)。""" + fid = str(field_id or "airspeed") + values = metric_values_for_field(points, fid) + finite = [float(v) for v in values if v == v] + if not finite: + return None + if fid == "health": + return metric_range(values, floor=COLOR_HEALTH_FLOOR) + if fid == "bank": + return float(COLOR_BANK_FLOOR), float(COLOR_BANK_CEIL) + # 空速 / 地速 / 沿迹地速:固定 (160, 240) + if fid in FIXED_SPEED_COLOR_FIELDS: + return float(TRACK_COLOR_YLIM_SPEED[0]), float(TRACK_COLOR_YLIM_SPEED[1]) + # 推进效率 / 诱导阻:setting 预计算固定色标(P5–P95) + if fid == "eta_prop": + return float(TRACK_COLOR_YLIM_ETA_PROP[0]), float(TRACK_COLOR_YLIM_ETA_PROP[1]) + if fid == "di_proxy": + return float(TRACK_COLOR_YLIM_DI_PROXY[0]), float(TRACK_COLOR_YLIM_DI_PROXY[1]) + return metric_range(values) + + +def speed_tick_marks(speed_min: float, speed_max: float) -> List[float]: + """色带上的速度分隔刻度:两端 + 落在范围内的速度带边界。""" + ticks = {float(speed_min), float(speed_max)} + for band in DEFAULT_SPEED_BANDS: + for edge in (band.lo, band.hi): + if edge is None: + continue + v = float(edge) + if speed_min < v < speed_max: + ticks.add(v) + return sorted(ticks) + + +def metric_tick_marks(lo: float, hi: float, field_id: str) -> List[float]: + """色带刻度:空速/地速保留速度带边界;健康含 70/95;坡度两端。""" + fid = str(field_id) + if fid in ("airspeed", "groundspeed"): + return speed_tick_marks(lo, hi) + ticks = {float(lo), float(hi)} + if fid == "health": + for edge in (COLOR_HEALTH_PURPLE_BELOW, COLOR_HEALTH_FLOOR): + if lo < float(edge) < hi: + ticks.add(float(edge)) + return sorted(ticks) + + +def speed_to_rgb( + speed: float, + speed_min: float, + speed_max: float, + *, + reverse: bool = False, +) -> Tuple[int, int, int]: + """红(低) → 黄 → 绿 → 青(高);reverse 时高→红。""" + span = max(1e-6, speed_max - speed_min) + t = max(0.0, min(1.0, (float(speed) - speed_min) / span)) + if reverse: + t = 1.0 - t + if t < 0.33: + u = t / 0.33 + r, g, b = 1.0, u, 0.0 + elif t < 0.66: + u = (t - 0.33) / 0.33 + r, g, b = 1.0 - u, 1.0, 0.0 + else: + u = (t - 0.66) / 0.34 + r, g, b = 0.0, 1.0, u + return int(round(r * 255)), int(round(g * 255)), int(round(b * 255)) + + +def metric_to_rgb( + value: float, + val_min: float, + val_max: float, + field_id: str, +) -> Tuple[int, int, int]: + """ + 按字段规则映射 RGB。 + - health:<70 → 紫;色标下限 95 + - bank / di_proxy:越大越红(反向) + - 其它:红(低)→青(高) + """ + fid = str(field_id or "airspeed") + if value != value: + return speed_to_rgb(val_min, val_min, val_max) + if fid == "health": + if float(value) < COLOR_HEALTH_PURPLE_BELOW: + return COLOR_HEALTH_PURPLE_RGB + return speed_to_rgb(value, val_min, val_max, reverse=False) + if fid in ("bank", "di_proxy"): + return speed_to_rgb(value, val_min, val_max, reverse=True) + return speed_to_rgb(value, val_min, val_max, reverse=False) + + +def metric_legend_reversed(field_id: str) -> bool: + """图例色带是否反向(左低右高时高为红)。""" + return str(field_id) in ("bank", "di_proxy") + + +def format_metric_legend_value(field_id: str, value: float) -> str: + """航迹色标两端/标题用的数值文本(按字段量级选小数位)。""" + try: + v = float(value) + except (TypeError, ValueError): + return "--" + if v != v: + return "--" + fid = str(field_id or "") + if fid == "eta_prop": + return format(v, ".2f") + if fid in ("speed_rate", "accel_rate"): + return format(v, ".1f") + if fid == "di_proxy": + av = abs(v) + if av >= 100000: + return format(v, ".0f") + if av >= 1000: + return format(v, ".0f") + return format(v, ".1f") + if fid in ("vg_course", "airspeed", "groundspeed", "pitch", "bank", "health", "torque"): + return format(v, ".0f") + # 默认:绝对值小于 TRACK_LEGEND_DECIMAL_BELOW 用两位小数 + return ( + format(v, ".2f") + if abs(v) < float(TRACK_LEGEND_DECIMAL_BELOW) + else format(v, ".0f") + ) + + +def project_to_pixel( + lon: float, + lat: float, + bounds: TrackBounds, + draw_left: float, + draw_top: float, + draw_w: float, + draw_h: float, +) -> Tuple[float, float]: + """经纬 → 像素(北上、西左)。""" + x = draw_left + ((lon - bounds.min_lon) / bounds.lon_range) * draw_w + y = draw_top + ((bounds.max_lat - lat) / bounds.lat_range) * draw_h + return x, y + + +def fit_draw_rect( + bounds: TrackBounds, + avail_w: float, + avail_h: float, + margin: float, +) -> Tuple[float, float, float, float]: + """在可用区域内按地面比例放置绘图矩形,返回 (left, top, w, h)。""" + inner_w = max(1.0, avail_w - 2.0 * margin) + inner_h = max(1.0, avail_h - 2.0 * margin) + aspect = max(0.05, min(20.0, bounds.ground_aspect())) + if aspect >= inner_w / inner_h: + w = inner_w + h = w / aspect + else: + h = inner_h + w = h * aspect + left = margin + 0.5 * (inner_w - w) + top = margin + 0.5 * (inner_h - h) + return left, top, w, h + + +def _safe_float(value) -> float: + try: + v = float(value) + except (TypeError, ValueError): + return 0.0 + return 0.0 if v != v else v + + +def _optional_float(value) -> float: + if value is None: + return float("nan") + try: + v = float(value) + except (TypeError, ValueError): + return float("nan") + return v diff --git a/models/track_store.py b/models/track_store.py new file mode 100644 index 00000000..54c97206 --- /dev/null +++ b/models/track_store.py @@ -0,0 +1,873 @@ +# -*- coding: utf-8 -*- +""" +模块:models.track_store +职责:航迹记录存入 Auth 用户表 user_defined_3(多层字典);监测内存缓存,保存时增量 UPDATE +依赖:models.db_operate 中间件、shadow_plane.Trajectory、setting +""" + +from __future__ import annotations + +import json +import math +import threading +import uuid +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Dict, List, Optional, Sequence, Tuple + +import setting as se +from models.db_operate import auth_db_execute, auth_db_query +from models.shadow_plane import ( + Trajectory, + TrajectorySample, + build_trajectory_label, +) +from models.track_fields import enabled_track_columns +from models.lap_reference import LAP_COUNT + + +@dataclass(frozen=True) +class TrackStoreMeta: + """列表项摘要(不含采样点);时间语义与圈时板一致。""" + + record_id: str + label: str + mission_title: str + aircraft_title: str + recorded_at: str + duration_s: float + sample_count: int + laps_s: Tuple[Optional[float], ...] + laps_penalty_s: Tuple[Optional[float], ...] = () + net_s: Optional[float] = None + penalty_s: Optional[float] = None + total_s: Optional[float] = None + session_mode: str = "" + score: Optional[float] = None + status: str = "" + mission_flt_title: str = "" + mission_key: str = "" + + +class TrackStoreError(Exception): + """云端航迹读写失败。""" + + +class TrackStoreFull(TrackStoreError): + """已达上限,需先删再存。""" + + +@dataclass +class _UserTrackCache: + """进程内按用户缓存:列表摘要 + 已加载的单条记录。""" + + metas: Optional[List[TrackStoreMeta]] = None + entries: Dict[str, Dict[str, Any]] = field(default_factory=dict) + + +_cache_lock = threading.RLock() +_user_caches: Dict[str, _UserTrackCache] = {} + + +def format_track_id_short(record_id: str) -> str: + """提示/列表用:航迹 UUID 后 N 位。""" + rid = str(record_id or "").strip() + if not rid: + return "" + n = int(se.TRACK_STORE_ID_DISPLAY_LEN) + return rid[-n:] if len(rid) >= n else rid + + +def session_mode_label(mode: str) -> str: + """session_mode → 圈时板同款英文文案。""" + key = str(mode or "").strip() + return str(se.SESSION_MODE_LABELS.get(key) or key or "—") + + +def track_result_from_score(score: Optional[float]) -> Optional[str]: + """以分数为准:0 分失败,其余有值成功;无效分数返回 None。""" + if score is None: + return None + try: + value = float(score) + except (TypeError, ValueError): + return None + if value != value: + return None + if value == 0.0: + return str(se.TRACK_RESULT_FAIL) + return str(se.TRACK_RESULT_SUCCESS) + + +def track_result_label(status: Optional[str]) -> str: + """status 码 → 成功/失败;未知为空占位。""" + key = str(status or "").strip() + if not key: + return "—" + return str(se.TRACK_RESULT_LABELS.get(key) or key) + + +def _cache_for(user_id: str) -> _UserTrackCache: + uid = str(user_id or "").strip() + with _cache_lock: + cur = _user_caches.get(uid) + if cur is None: + cur = _UserTrackCache() + _user_caches[uid] = cur + return cur + + +def invalidate_track_store_cache(user_id: Optional[str] = None) -> None: + """保存/删除后失效;user_id 空则清空全部。""" + with _cache_lock: + if user_id is None or not str(user_id).strip(): + _user_caches.clear() + return + _user_caches.pop(str(user_id).strip(), None) + + +def get_cached_track_metas(user_id: str) -> Optional[List[TrackStoreMeta]]: + """命中进程缓存则返回副本,否则 None(不访问数据库)。""" + uid = str(user_id or "").strip() + if not uid: + return None + with _cache_lock: + cur = _user_caches.get(uid) + if cur is None or cur.metas is None: + return None + return list(cur.metas) + + +def _empty_blob() -> Dict[str, Any]: + return { + "version": int(se.TRACK_STORE_JSON_VERSION), + "tracks": {}, + "order": [], + } + + +def _parse_blob(raw: Any) -> Dict[str, Any]: + if raw is None or raw == "": + return _empty_blob() + if isinstance(raw, (bytes, bytearray, memoryview)): + raw = bytes(raw).decode("utf-8", errors="ignore") + if isinstance(raw, str): + raw = raw.strip() + if not raw: + return _empty_blob() + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + raise TrackStoreError(f"user_defined_3 JSON 无效: {exc}") from exc + elif isinstance(raw, dict): + data = raw + else: + raise TrackStoreError("user_defined_3 类型无法解析") + if not isinstance(data, dict): + return _empty_blob() + tracks = data.get("tracks") + order = data.get("order") + if not isinstance(tracks, dict): + tracks = {} + if not isinstance(order, list): + order = list(tracks.keys()) + # 清理幽灵 id + order = [str(i) for i in order if str(i) in tracks] + for kid in list(tracks.keys()): + if kid not in order: + order.append(kid) + return { + "version": int(data.get("version") or se.TRACK_STORE_JSON_VERSION), + "tracks": tracks, + "order": order, + } + + +def _json_num(v: Any) -> Any: + """float → JSON 友好值;nan/inf → None。""" + if v is None: + return None + try: + f = float(v) + except (TypeError, ValueError): + return None + if f != f or math.isinf(f): + return None + return f + + +def _from_json_num(v: Any, default: float = float("nan")) -> float: + if v is None: + return float(default) + try: + f = float(v) + except (TypeError, ValueError): + return float(default) + return f + + +def _optional_meta_float(v: Any) -> Optional[float]: + num = _json_num(v) + if num is None: + return None + return float(num) + + +def _laps_tuple(raw: Any, *, length: int = LAP_COUNT) -> Tuple[Optional[float], ...]: + laps: List[Optional[float]] = [] + if isinstance(raw, list): + for x in raw[:length]: + if x is None: + laps.append(None) + else: + v = _from_json_num(x) + laps.append(None if v != v else v) + while len(laps) < length: + laps.append(None) + return tuple(laps) + + +def _json_laps_list(values: Optional[Sequence[Optional[float]]]) -> List[Any]: + out: List[Any] = [] + for x in values or []: + out.append(_json_num(x) if x is not None else None) + return out + + +def trajectory_to_record( + trajectory: Trajectory, + *, + record_id: Optional[str] = None, + label: Optional[str] = None, + total_s: Optional[float] = None, + net_s: Optional[float] = None, + penalty_s: Optional[float] = None, + gross_s: Optional[float] = None, + laps_penalty_s: Optional[Sequence[Optional[float]]] = None, + session_mode: Optional[str] = None, + score: Optional[float] = None, + mission_display_name: Optional[str] = None, + mission_key: Optional[str] = None, + status: Optional[str] = None, +) -> Tuple[str, Dict[str, Any]]: + """Trajectory → (record_id, 多层字典记录)。meta 对齐圈时板:净/罚/总/每圈/分数/模式/状态。""" + columns = list(enabled_track_columns()) + rows: List[List[Any]] = [] + for sample in trajectory.samples: + row = [] + for key in columns: + val = getattr(sample, key, float("nan")) + if key == "lap": + try: + row.append(int(val) if val == val else 0) + except (TypeError, ValueError): + row.append(0) + else: + row.append(_json_num(val)) + rows.append(row) + # 净时:net_s 优先,否则 total_s,再否则采样时长 + net = net_s if net_s is not None else total_s + if net is None: + net = float(trajectory.duration_s) + else: + net = float(net) + pen = _optional_meta_float(penalty_s) + if gross_s is not None: + gross = float(gross_s) + elif net == net: + gross = float(net) + float(pen or 0.0) + else: + gross = None + flt_title = (trajectory.mission_title or "").strip() + # 规范名 = Mission display_name;稳定键 = Missions 子目录名 + display = (mission_display_name or "").strip() or flt_title + mkey = (mission_key or "").strip() + score_num = _json_num(score) + result = (status or "").strip() or (track_result_from_score(score_num) or "") + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + rid = (record_id or "").strip() or uuid.uuid4().hex + # 标签前缀优先 mission_key(SP/MP 同键),否则 display_name + lbl = (label or "").strip() or build_trajectory_label( + mkey or display or flt_title, stamp=stamp, total_s=net + ) + meta = { + # 列表「任务名称」:config.display_name;身份键:Missions 目录名 + "mission_title": display, + "mission_key": mkey, + "mission_flt_title": flt_title, + "aircraft_title": trajectory.aircraft_title or "", + "recorded_at": trajectory.recorded_at + or datetime.now().isoformat(timespec="seconds"), + "laps_s": _json_laps_list(trajectory.laps_s), + "laps_penalty_s": _json_laps_list(laps_penalty_s), + "net_s": _json_num(net), + "penalty_s": pen, + "total_s": _json_num(gross), + # 列表时长:净飞行时间 + "duration_s": _json_num(net) or 0.0, + "session_mode": str(session_mode or "").strip(), + "score": score_num, + "status": result, + "label": lbl, + "sample_count": len(rows), + } + return rid, {"meta": meta, "columns": columns, "rows": rows} + + +def record_to_trajectory(record: Dict[str, Any]) -> Trajectory: + """多层字典记录 → Trajectory。""" + meta = record.get("meta") if isinstance(record.get("meta"), dict) else {} + columns = record.get("columns") + rows = record.get("rows") + if not isinstance(columns, list) or not isinstance(rows, list): + raise TrackStoreError("航迹记录缺少 columns/rows") + samples: List[TrajectorySample] = [] + for raw in rows: + if not isinstance(raw, (list, tuple)): + continue + kwargs: Dict[str, Any] = {} + for i, key in enumerate(columns): + if i >= len(raw): + break + if key == "lap": + try: + kwargs[key] = int(raw[i] or 0) + except (TypeError, ValueError): + kwargs[key] = 0 + else: + kwargs[key] = _from_json_num(raw[i]) + # TrajectorySample 必填字段兜底 + for req in ( + "timestamp_s", + "latitude", + "longitude", + "altitude_ft", + "heading_deg", + "airspeed_kts", + "groundspeed_kts", + "pitch_deg", + "bank_deg", + ): + if req not in kwargs: + kwargs[req] = 0.0 if req == "timestamp_s" else float("nan") + try: + samples.append(TrajectorySample(**kwargs)) + except TypeError: + # 未知列忽略:只传 dataclass 已知字段 + from dataclasses import fields as dc_fields + + allowed = {f.name for f in dc_fields(TrajectorySample)} + samples.append( + TrajectorySample(**{k: v for k, v in kwargs.items() if k in allowed}) + ) + laps_raw = meta.get("laps_s") or [] + laps_s: List[Optional[float]] = [] + if isinstance(laps_raw, list): + for x in laps_raw: + if x is None: + laps_s.append(None) + else: + v = _from_json_num(x) + laps_s.append(None if v != v or v <= 0 else v) + return Trajectory( + aircraft_title=str(meta.get("aircraft_title") or ""), + samples=samples, + recorded_at=str(meta.get("recorded_at") or ""), + laps_s=laps_s, + # 回放标签/身份优先 mission 规范显示名;FLT 原名仅作兼容兜底 + mission_title=str( + meta.get("mission_title") or meta.get("mission_flt_title") or "" + ), + ) + + +def _meta_from_entry(record_id: str, entry: Dict[str, Any]) -> TrackStoreMeta: + meta = entry.get("meta") if isinstance(entry.get("meta"), dict) else {} + rows = entry.get("rows") if isinstance(entry.get("rows"), list) else [] + laps_s = _laps_tuple(meta.get("laps_s")) + laps_penalty_s = _laps_tuple(meta.get("laps_penalty_s")) + duration_s = float(_from_json_num(meta.get("duration_s"), 0.0) or 0.0) + net_s = _optional_meta_float(meta.get("net_s")) + if net_s is None and duration_s > 0: + net_s = duration_s + penalty_s = _optional_meta_float(meta.get("penalty_s")) + total_s = _optional_meta_float(meta.get("total_s")) + if total_s is None and net_s is not None: + total_s = float(net_s) + float(penalty_s or 0.0) + label = str(meta.get("label") or record_id) + score_val = _optional_meta_float(meta.get("score")) + status = str(meta.get("status") or "").strip() + if not status: + status = track_result_from_score(score_val) or "" + flt_title = str(meta.get("mission_flt_title") or "").strip() + mission_key = str(meta.get("mission_key") or "").strip() + return TrackStoreMeta( + record_id=record_id, + label=label, + mission_title=str(meta.get("mission_title") or ""), + aircraft_title=str(meta.get("aircraft_title") or ""), + recorded_at=str(meta.get("recorded_at") or ""), + duration_s=duration_s if duration_s > 0 else float(net_s or 0.0), + sample_count=int(meta.get("sample_count") or len(rows)), + laps_s=laps_s, + laps_penalty_s=laps_penalty_s, + net_s=net_s, + penalty_s=penalty_s, + total_s=total_s, + session_mode=str(meta.get("session_mode") or ""), + score=score_val, + status=status, + mission_flt_title=flt_title, + mission_key=mission_key, + ) + + +def _loads_json_cell(raw: Any) -> Any: + """pymysql JSON/CHAR 单元格 → Python 对象。""" + if raw is None or raw == "": + return None + if isinstance(raw, (bytes, bytearray, memoryview)): + raw = bytes(raw).decode("utf-8", errors="ignore") + if isinstance(raw, str): + text = raw.strip() + if not text or text.upper() == "NULL": + return None + return json.loads(text) + return raw + + +def _as_json_list(raw: Any) -> List[Any]: + """JSON_EXTRACT 单值不包数组时归一成 list。""" + val = _loads_json_cell(raw) + if val is None: + return [] + if isinstance(val, list): + return val + return [val] + + +def _fetch_blob(user_id: str, *, timeout: Optional[float] = None) -> Dict[str, Any]: + db_name = se.auth_db_config["db"] + col = se.TRACK_STORE_UD_COLUMN + table = se.AUTH_USER_TABLE + sql = ( + f"SELECT `{col}` AS blob_json FROM `{db_name}`.`{table}` " + "WHERE user_id = %s LIMIT 1" + ) + t = float(timeout if timeout is not None else se.TRACK_STORE_DB_TIMEOUT_S) + rows = auth_db_query(sql, (user_id,), timeout=t) + if not rows: + raise TrackStoreError("用户不存在,无法读写航迹") + row = rows[0] + raw = row.get("blob_json") if isinstance(row, dict) else row[0] + return _parse_blob(raw) + + +def _write_blob(user_id: str, blob: Dict[str, Any], *, timeout: Optional[float] = None) -> None: + db_name = se.auth_db_config["db"] + col = se.TRACK_STORE_UD_COLUMN + table = se.AUTH_USER_TABLE + payload = json.dumps(blob, ensure_ascii=False, separators=(",", ":")) + sql = ( + f"UPDATE `{db_name}`.`{table}` SET `{col}` = CAST(%s AS JSON) " + "WHERE user_id = %s" + ) + t = float(timeout if timeout is not None else se.TRACK_STORE_DB_TIMEOUT_S) + auth_db_execute(sql, (payload, user_id), timeout=t) + invalidate_track_store_cache(user_id) + + +def _fetch_order_slim( + user_id: str, *, timeout: Optional[float] = None +) -> List[str]: + """只取 $.order(不含 rows),供容量判断 / 增量写。""" + db_name = se.auth_db_config["db"] + col = se.TRACK_STORE_UD_COLUMN + table = se.AUTH_USER_TABLE + sql = ( + f"SELECT JSON_EXTRACT(`{col}`, '$.order') AS order_json " + f"FROM `{db_name}`.`{table}` WHERE user_id = %s LIMIT 1" + ) + t = float(timeout if timeout is not None else se.TRACK_STORE_DB_TIMEOUT_S) + rows = auth_db_query(sql, (user_id,), timeout=t) + if not rows: + raise TrackStoreError("用户不存在,无法读写航迹") + row = rows[0] + raw = row.get("order_json") if isinstance(row, dict) else row[0] + if raw is None: + return [] + return [str(x) for x in _as_json_list(raw)] + + +def _patch_tracks( + user_id: str, + *, + remove_ids: Optional[Sequence[str]] = None, + set_rid: Optional[str] = None, + set_entry: Optional[Dict[str, Any]] = None, + new_order: Sequence[str], + timeout: Optional[float] = None, +) -> None: + """ + 增量改 user_defined_3:JSON_REMOVE 删轨 + JSON_SET 写单条/order。 + 不读写其它航迹的 rows。 + """ + db_name = se.auth_db_config["db"] + col = se.TRACK_STORE_UD_COLUMN + table = se.AUTH_USER_TABLE + empty_json = json.dumps(_empty_blob(), ensure_ascii=False, separators=(",", ":")) + order_json = json.dumps( + [str(x) for x in new_order], ensure_ascii=False, separators=(",", ":") + ) + version = int(se.TRACK_STORE_JSON_VERSION) + want_remove = [str(x) for x in (remove_ids or []) if str(x).strip()] + + params: List[Any] = [empty_json] + base_expr = f"COALESCE(`{col}`, CAST(%s AS JSON))" + if want_remove: + path_args = ", ".join( + ["CONCAT('$.tracks.', JSON_QUOTE(%s))"] * len(want_remove) + ) + base_expr = f"JSON_REMOVE({base_expr}, {path_args})" + params.extend(want_remove) + + set_parts: List[str] = [] + if set_rid is not None and set_entry is not None: + entry_json = json.dumps( + set_entry, ensure_ascii=False, separators=(",", ":") + ) + set_parts.append("CONCAT('$.tracks.', JSON_QUOTE(%s)), CAST(%s AS JSON)") + params.append(str(set_rid)) + params.append(entry_json) + set_parts.append("'$.order', CAST(%s AS JSON)") + params.append(order_json) + set_parts.append("'$.version', %s") + params.append(version) + + sql = ( + f"UPDATE `{db_name}`.`{table}` SET `{col}` = CAST(" + f"JSON_SET({base_expr}, {', '.join(set_parts)}) AS JSON) " + "WHERE user_id = %s" + ) + params.append(user_id) + t = float(timeout if timeout is not None else se.TRACK_STORE_DB_TIMEOUT_S) + auth_db_execute(sql, tuple(params), timeout=t) + invalidate_track_store_cache(user_id) + + +def _metas_from_blob(blob: Dict[str, Any]) -> List[TrackStoreMeta]: + out: List[TrackStoreMeta] = [] + for rid in blob["order"]: + entry = blob["tracks"].get(rid) + if isinstance(entry, dict): + out.append(_meta_from_entry(str(rid), entry)) + return out + + +def _list_metas_slim(user_id: str, *, timeout: Optional[float] = None) -> List[TrackStoreMeta]: + """ + 只取 order + 各轨 meta(JSON 路径,不含 rows)。 + 依赖 MySQL JSON_EXTRACT / JSON_KEYS(5.7.22+ / 8.x)。 + """ + db_name = se.auth_db_config["db"] + col = se.TRACK_STORE_UD_COLUMN + table = se.AUTH_USER_TABLE + sql = ( + f"SELECT " + f"JSON_EXTRACT(`{col}`, '$.order') AS order_json, " + f"JSON_KEYS(JSON_EXTRACT(`{col}`, '$.tracks')) AS keys_json, " + f"JSON_EXTRACT(`{col}`, '$.tracks.*.meta') AS metas_json " + f"FROM `{db_name}`.`{table}` WHERE user_id = %s LIMIT 1" + ) + t = float(timeout if timeout is not None else se.TRACK_STORE_DB_TIMEOUT_S) + rows = auth_db_query(sql, (user_id,), timeout=t) + if not rows: + raise TrackStoreError("用户不存在,无法读写航迹") + row = rows[0] if isinstance(rows[0], dict) else { + "order_json": rows[0][0], + "keys_json": rows[0][1], + "metas_json": rows[0][2], + } + # 空列 / 非 JSON:当无航迹 + order_raw = row.get("order_json") + keys_raw = row.get("keys_json") + metas_raw = row.get("metas_json") + if order_raw is None and keys_raw is None and metas_raw is None: + # 用户存在但 blob 为 NULL + return [] + + order = [str(x) for x in _as_json_list(order_raw)] + keys = [str(x) for x in _as_json_list(keys_raw)] + metas = _as_json_list(metas_raw) + + by_id: Dict[str, Dict[str, Any]] = {} + if keys and len(keys) == len(metas): + for kid, meta in zip(keys, metas): + if isinstance(meta, dict): + by_id[str(kid)] = meta + elif metas and not keys: + # 异常形态:无法对齐 id + raise ValueError("云端航迹 meta 索引无法对齐") + elif keys and metas and len(keys) != len(metas): + raise ValueError("云端航迹 meta 数量与键不一致") + + # 与 _parse_blob 一致:order 优先,补幽灵 id + seen = set() + ordered_ids: List[str] = [] + for rid in order: + if rid in by_id and rid not in seen: + ordered_ids.append(rid) + seen.add(rid) + for kid in keys: + if kid in by_id and kid not in seen: + ordered_ids.append(kid) + seen.add(kid) + + if (order or keys) and not by_id: + raise ValueError("云端航迹 meta 为空,回退全量") + + out: List[TrackStoreMeta] = [] + for rid in ordered_ids: + out.append(_meta_from_entry(rid, {"meta": by_id[rid], "rows": []})) + return out + + +def _fetch_entry_slim( + user_id: str, record_id: str, *, timeout: Optional[float] = None +) -> Dict[str, Any]: + """只 EXTRACT 单条 tracks[id](含 rows),不拖其它航迹。""" + db_name = se.auth_db_config["db"] + col = se.TRACK_STORE_UD_COLUMN + table = se.AUTH_USER_TABLE + rid = str(record_id) + sql = ( + f"SELECT JSON_EXTRACT(`{col}`, CONCAT('$.tracks.', JSON_QUOTE(%s))) AS entry_json " + f"FROM `{db_name}`.`{table}` WHERE user_id = %s LIMIT 1" + ) + t = float(timeout if timeout is not None else se.TRACK_STORE_DB_TIMEOUT_S) + rows = auth_db_query(sql, (rid, user_id), timeout=t) + if not rows: + raise TrackStoreError("用户不存在,无法读写航迹") + row = rows[0] + raw = row.get("entry_json") if isinstance(row, dict) else row[0] + entry = _loads_json_cell(raw) + if not isinstance(entry, dict): + raise TrackStoreError(f"航迹记录不存在:{rid}") + return entry + + +def list_track_metas(user_id: str) -> List[TrackStoreMeta]: + """只读列表摘要:优先缓存 → JSON 瘦身查询 → 全量回退。""" + uid = str(user_id or "").strip() + if not uid: + raise TrackStoreError("用户无效") + cache = _cache_for(uid) + with _cache_lock: + if cache.metas is not None: + return list(cache.metas) + + metas: List[TrackStoreMeta] + try: + metas = _list_metas_slim(uid) + except TrackStoreError: + raise + except Exception: + blob = _fetch_blob(uid) + metas = _metas_from_blob(blob) + with _cache_lock: + for rid, entry in blob["tracks"].items(): + if isinstance(entry, dict): + cache.entries[str(rid)] = entry + + with _cache_lock: + cache.metas = list(metas) + return list(cache.metas) + + +def load_trajectory_record(user_id: str, record_id: str) -> Trajectory: + """按 id 加载完整 Trajectory:优先缓存 → 单条 JSON_EXTRACT → 全量回退。""" + uid = str(user_id or "").strip() + rid = str(record_id or "").strip() + if not uid or not rid: + raise TrackStoreError("用户或航迹 id 无效") + cache = _cache_for(uid) + with _cache_lock: + cached = cache.entries.get(rid) + if isinstance(cached, dict): + return record_to_trajectory(cached) + + entry: Dict[str, Any] + try: + entry = _fetch_entry_slim(uid, rid) + except TrackStoreError: + raise + except Exception: + blob = _fetch_blob(uid) + got = blob["tracks"].get(rid) + if not isinstance(got, dict): + raise TrackStoreError(f"航迹记录不存在:{rid}") + entry = got + with _cache_lock: + for k, v in blob["tracks"].items(): + if isinstance(v, dict): + cache.entries[str(k)] = v + cache.metas = _metas_from_blob(blob) + + with _cache_lock: + cache.entries[rid] = entry + return record_to_trajectory(entry) + + +def delete_track_records(user_id: str, record_ids: Sequence[str]) -> int: + """删除若干记录;优先 JSON 增量写,失败回退全量 SELECT+UPDATE。返回删除条数。""" + want = {str(x) for x in record_ids if str(x).strip()} + if not want: + return 0 + try: + order = _fetch_order_slim(user_id) + new_order = [i for i in order if i not in want] + removed = len(order) - len(new_order) + _patch_tracks( + user_id, + remove_ids=list(want), + set_rid=None, + set_entry=None, + new_order=new_order, + ) + return removed + except TrackStoreError: + raise + except Exception: + blob = _fetch_blob(user_id) + removed = 0 + for rid in list(want): + if rid in blob["tracks"]: + del blob["tracks"][rid] + removed += 1 + blob["order"] = [i for i in blob["order"] if i in blob["tracks"]] + if removed: + _write_blob(user_id, blob) + return removed + + +def save_trajectory_record( + user_id: str, + trajectory: Trajectory, + *, + total_s: Optional[float] = None, + net_s: Optional[float] = None, + penalty_s: Optional[float] = None, + gross_s: Optional[float] = None, + laps_penalty_s: Optional[Sequence[Optional[float]]] = None, + session_mode: Optional[str] = None, + score: Optional[float] = None, + mission_display_name: Optional[str] = None, + mission_key: Optional[str] = None, + status: Optional[str] = None, + replace_ids: Optional[Sequence[str]] = None, + allow_full_raise: bool = True, +) -> TrackStoreMeta: + """ + 将一条航迹写入 user_defined_3。 + 优先:瘦身读 order → 内存合并 → JSON 增量 UPDATE(不拖历史 rows)。 + 失败回退:一次全量 SELECT → 合并 → 一次全量 UPDATE。 + 若仍满且 allow_full_raise:抛 TrackStoreFull(调用方弹窗选删后再带 replace_ids 重试)。 + meta 写入圈时板同款:净时/罚时/总时/每圈/分数/模式/状态/映射任务名/mission_key。 + """ + if not trajectory.samples: + raise TrackStoreError("无有效轨迹数据") + uid = str(user_id or "").strip() + if not uid: + raise TrackStoreError("用户无效") + replace = [str(x) for x in (replace_ids or []) if str(x).strip()] + replace_set = set(replace) + max_n = int(se.TRACK_STORE_MAX_RECORDS) + + def _prepare_order(order: List[str]) -> List[str]: + if replace_set: + return [i for i in order if i not in replace_set] + return list(order) + + # —— 增量路径 —— + try: + order = _prepare_order(_fetch_order_slim(uid)) + if len(order) >= max_n: + if allow_full_raise: + raise TrackStoreFull(se.MSG_TRACK_STORE_FULL.format(n=max_n)) + raise TrackStoreError(se.MSG_TRACK_STORE_FULL.format(n=max_n)) + rid, entry = trajectory_to_record( + trajectory, + total_s=total_s, + net_s=net_s, + penalty_s=penalty_s, + gross_s=gross_s, + laps_penalty_s=laps_penalty_s, + session_mode=session_mode, + score=score, + mission_display_name=mission_display_name, + mission_key=mission_key, + status=status, + ) + new_order = list(order) + new_order.append(rid) + _patch_tracks( + uid, + remove_ids=replace, + set_rid=rid, + set_entry=entry, + new_order=new_order, + ) + return _meta_from_entry(rid, entry) + except TrackStoreFull: + raise + except TrackStoreError: + raise + except Exception: + pass + + # —— 全量回退 —— + blob = _fetch_blob(uid) + if replace: + for rid_del in replace: + if rid_del in blob["tracks"]: + del blob["tracks"][rid_del] + blob["order"] = [i for i in blob["order"] if i in blob["tracks"]] + if len(blob["order"]) >= max_n: + if allow_full_raise: + raise TrackStoreFull(se.MSG_TRACK_STORE_FULL.format(n=max_n)) + raise TrackStoreError(se.MSG_TRACK_STORE_FULL.format(n=max_n)) + rid, entry = trajectory_to_record( + trajectory, + total_s=total_s, + net_s=net_s, + penalty_s=penalty_s, + gross_s=gross_s, + laps_penalty_s=laps_penalty_s, + session_mode=session_mode, + score=score, + mission_display_name=mission_display_name, + mission_key=mission_key, + status=status, + ) + blob["tracks"][rid] = entry + blob["order"].append(rid) + _write_blob(uid, blob) + return _meta_from_entry(rid, entry) + + +def build_pending_trajectory( + samples: Sequence[TrajectorySample], + *, + aircraft_title: str = "", + mission_title: str = "", + laps_s: Optional[Sequence[Optional[float]]] = None, + recorded_at: Optional[str] = None, +) -> Trajectory: + """由内存采样组装 Trajectory(不落盘)。""" + return Trajectory( + aircraft_title=aircraft_title or "", + samples=list(samples), + recorded_at=recorded_at or datetime.now().isoformat(timespec="seconds"), + laps_s=list(laps_s) if laps_s is not None else [], + mission_title=(mission_title or "").strip(), + ) diff --git a/models/user_settings_store.py b/models/user_settings_store.py new file mode 100644 index 00000000..0441ec89 --- /dev/null +++ b/models/user_settings_store.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +""" +模块:models.user_settings_store +职责:用户自定义设置存入 Auth 表 user_defined_4(多层字典);禁止读写 UD1/UD2 +依赖:models.db_operate 中间件、setting +说明:memberlevel 由其他程序维护;本程序只读、写回时置于 JSON 最前且不改其值 +""" + +from __future__ import annotations + +import json +from typing import Any, Dict, Optional + +import setting as se +from models.db_operate import auth_db_execute, auth_db_query + + +class UserSettingsError(Exception): + """云端用户设置读写失败。""" + + +class UserSettingsNeedLogin(UserSettingsError): + """未登录,不能持久化设置。""" + + +def _empty_blob() -> Dict[str, Any]: + return {"version": int(se.USER_SETTINGS_JSON_VERSION)} + + +def _with_memberlevel_first(blob: Dict[str, Any]) -> Dict[str, Any]: + """保证 memberlevel(若存在)位于 JSON 对象最前端;本程序不创建该键。""" + key = se.USER_SETTINGS_KEY_MEMBERLEVEL + if key not in blob: + return dict(blob) + out: Dict[str, Any] = {key: blob[key]} + for k, v in blob.items(): + if k == key: + continue + out[k] = v + return out + + +def _parse_blob(raw: Any) -> Dict[str, Any]: + if raw is None or raw == "": + return _empty_blob() + if isinstance(raw, (bytes, bytearray, memoryview)): + raw = bytes(raw).decode("utf-8", errors="ignore") + if isinstance(raw, str): + raw = raw.strip() + if not raw: + return _empty_blob() + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + raise UserSettingsError(f"user_defined_4 JSON 无效: {exc}") from exc + elif isinstance(raw, dict): + data = raw + else: + raise UserSettingsError("user_defined_4 类型无法解析") + if not isinstance(data, dict): + return _empty_blob() + out = dict(data) + out["version"] = int(out.get("version") or se.USER_SETTINGS_JSON_VERSION) + return _with_memberlevel_first(out) + + +def _fetch_blob(user_id: str, *, timeout: Optional[float] = None) -> Dict[str, Any]: + uid = str(user_id or "").strip() + if not uid: + raise UserSettingsNeedLogin(se.MSG_USER_SETTINGS_NEED_LOGIN) + db_name = se.auth_db_config["db"] + col = se.USER_SETTINGS_UD_COLUMN + table = se.AUTH_USER_TABLE + # 仅 SELECT 本程序有权使用的 UD4;绝不读写 user_defined_1/2 + sql = ( + f"SELECT `{col}` AS blob_json FROM `{db_name}`.`{table}` " + "WHERE user_id = %s LIMIT 1" + ) + t = float(timeout if timeout is not None else se.USER_SETTINGS_DB_TIMEOUT_S) + rows = auth_db_query(sql, (uid,), timeout=t) + if not rows: + raise UserSettingsError("用户不存在,无法读写设置") + row = rows[0] + raw = row.get("blob_json") if isinstance(row, dict) else row[0] + return _parse_blob(raw) + + +def _write_blob(user_id: str, blob: Dict[str, Any], *, timeout: Optional[float] = None) -> None: + uid = str(user_id or "").strip() + if not uid: + raise UserSettingsNeedLogin(se.MSG_USER_SETTINGS_NEED_LOGIN) + db_name = se.auth_db_config["db"] + col = se.USER_SETTINGS_UD_COLUMN + table = se.AUTH_USER_TABLE + payload = json.dumps( + _with_memberlevel_first(blob), + ensure_ascii=False, + separators=(",", ":"), + ) + sql = ( + f"UPDATE `{db_name}`.`{table}` SET `{col}` = CAST(%s AS JSON) " + "WHERE user_id = %s" + ) + t = float(timeout if timeout is not None else se.USER_SETTINGS_DB_TIMEOUT_S) + auth_db_execute(sql, (payload, uid), timeout=t) + + +def load_user_settings(user_id: str) -> Dict[str, Any]: + """一次 SELECT 拉取 user_defined_4。""" + return _fetch_blob(user_id) + + +def get_hardware_monitor(user_id: str) -> Optional[Dict[str, Any]]: + """返回 UD4 中 hardware_monitor 字典;无则 None。""" + blob = _fetch_blob(user_id) + key = se.USER_SETTINGS_KEY_HARDWARE + raw = blob.get(key) + return raw if isinstance(raw, dict) else None + + +def set_hardware_monitor(user_id: str, payload: Dict[str, Any]) -> None: + """SELECT → 合并 hardware_monitor → 一次 UPDATE user_defined_4(保留 memberlevel)。""" + if not isinstance(payload, dict): + raise UserSettingsError("hardware_monitor 载荷无效") + blob = _fetch_blob(user_id) + blob[se.USER_SETTINGS_KEY_HARDWARE] = payload + blob["version"] = int(se.USER_SETTINGS_JSON_VERSION) + _write_blob(user_id, blob) + + +def get_fsx_root(user_id: str) -> Optional[str]: + """返回 UD4 中 fsx_root 路径字符串;无则 None。""" + blob = _fetch_blob(user_id) + raw = blob.get(se.USER_SETTINGS_KEY_FSX_ROOT) + if raw is None: + return None + text = str(raw).strip() + return text or None + + +def set_fsx_root(user_id: str, path: str) -> None: + """SELECT → 合并 fsx_root → 一次 UPDATE user_defined_4(保留 memberlevel)。""" + text = str(path or "").strip() + if not text: + raise UserSettingsError("fsx_root 路径无效") + blob = _fetch_blob(user_id) + blob[se.USER_SETTINGS_KEY_FSX_ROOT] = text + blob["version"] = int(se.USER_SETTINGS_JSON_VERSION) + _write_blob(user_id, blob) diff --git a/pages/__init__.py b/pages/__init__.py new file mode 100644 index 00000000..fd95362b --- /dev/null +++ b/pages/__init__.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +""" +模块:pages +职责:功能页注册入口(生产 / 冒烟含 stub) +依赖:Frame_Shell_AppMain、各 Feature 页 +""" + +from components.Frame.Frame_Shell_AppMain import Frame_Shell_AppMain + +from .page_auth import AuthPage +from .page_sim_check import SimCheckPage +from .page_training_assist import TrainingAssistPage + + +def register_flow_features(shell: Frame_Shell_AppMain) -> None: + """注册启动流程轻量页:自检 → 登录。parent=shell 避免无父控件闪成顶层窗。""" + shell.register(SimCheckPage(shell)) + shell.register(AuthPage(shell)) + + +def register_training_feature(shell: Frame_Shell_AppMain) -> None: + """注册训练助手(含图表 / 摇杆枚举,较重);已注册则跳过。""" + if shell.registry.get("training_assist") is not None: + return + shell.register(TrainingAssistPage(shell)) + + +def register_production_features(shell: Frame_Shell_AppMain) -> None: + """注册正式功能页:自检 → 登录 → 训练助手。""" + register_flow_features(shell) + register_training_feature(shell) + + +def register_default_features(shell: Frame_Shell_AppMain) -> None: + """生产页 + MissionStub(冒烟/扩展示例)。""" + from .page_mission_stub import MissionStubPage + + register_production_features(shell) + shell.register(MissionStubPage(shell)) + + +__all__ = [ + "SimCheckPage", + "AuthPage", + "TrainingAssistPage", + "register_flow_features", + "register_training_feature", + "register_production_features", + "register_default_features", +] diff --git a/pages/page_auth.py b/pages/page_auth.py new file mode 100644 index 00000000..4ba17dc7 --- /dev/null +++ b/pages/page_auth.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +"""模块:pages.page_auth — 登录页(原 AuthPage + AuthPanel)。""" + +from __future__ import annotations + +from PySide2.QtCore import Qt, Signal +from PySide2.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout + +from components.Frame.Frame_Shell_AppMain import CapabilitySet, Feature +from components.Frame.Frame_Theme_UI import UITheme, status_kind_color +from setting import SPACE_FRAME_GAP, SPACE_FRAME_PAD, SPACE_WIDGET_GAP + + +class AuthPage(Feature): + """登录页:对 Controller 暴露凭证/状态 API。""" + + feature_id = "flow.auth" + title = "登录" + + loginRequested = Signal(str, str) + + def __init__(self, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins( + SPACE_FRAME_PAD, + SPACE_FRAME_PAD, + SPACE_FRAME_PAD, + SPACE_FRAME_PAD, + ) + layout.setSpacing(SPACE_FRAME_GAP) + + body = QVBoxLayout() + body.setSpacing(SPACE_WIDGET_GAP) + title = QLabel("TALENT ACADEMY") + title.setObjectName("Title") + title.setAlignment(Qt.AlignCenter) + sub = QLabel("模拟飞行训练助手") + sub.setObjectName("Subtitle") + sub.setAlignment(Qt.AlignCenter) + body.addWidget(title) + body.addWidget(sub) + body.addSpacing(SPACE_FRAME_GAP) + body.addWidget(QLabel("用户名")) + self.username = QLineEdit() + body.addWidget(self.username) + body.addSpacing(SPACE_WIDGET_GAP) + body.addWidget(QLabel("密码")) + self.password = QLineEdit() + self.password.setEchoMode(QLineEdit.Password) + body.addWidget(self.password) + body.addStretch(1) + layout.addLayout(body, 1) + + self.status = QLabel("") + self.status.setObjectName("Muted") + self.status.setAlignment(Qt.AlignCenter) + self.status.setWordWrap(True) + layout.addWidget(self.status) + + self.login_btn = QPushButton("登录") + self.login_btn.setObjectName("FlatButton") + self.login_btn.clicked.connect(self._emit_login) + btn_row = QHBoxLayout() + btn_row.addStretch(1) + btn_row.addWidget(self.login_btn) + btn_row.addStretch(1) + layout.addLayout(btn_row) + + self.username.returnPressed.connect(self._emit_login) + self.password.returnPressed.connect(self._emit_login) + + def _emit_login(self) -> None: + self.loginRequested.emit(self.username.text(), self.password.text()) + + def get_credentials(self): + """返回当前用户名/密码。""" + return self.username.text(), self.password.text() + + def set_status(self, text: str, kind: str = "muted") -> None: + self.status.setText(text) + color = status_kind_color(kind if kind != "ok" else "ok") + if kind == "error": + color = UITheme.ERROR + self.status.setStyleSheet(f"color: {color}; background: transparent;") + + def set_login_enabled(self, enabled: bool) -> None: + self.login_btn.setEnabled(enabled) + + def clear_password(self) -> None: + self.password.clear() + + def apply_capabilities(self, caps: CapabilitySet) -> None: + """鉴权页无能力裁剪。""" + pass diff --git a/pages/page_mission_stub.py b/pages/page_mission_stub.py new file mode 100644 index 00000000..ae0c176a --- /dev/null +++ b/pages/page_mission_stub.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +""" +模块:pages.page_mission_stub +职责:第二功能页 stub,证明只改 ui/ 即可扩展 +依赖:Feature / Container_Bar_Action / widget_Bar_Status +""" + +from PySide2.QtCore import Qt, Signal +from PySide2.QtWidgets import QLabel, QVBoxLayout + +from components.Container.Container_Bar_Action import Container_Bar_Action +from components.Frame.Frame_Shell_AppMain import CapabilitySet, Feature +from components.widget.widget_Bar_Status import widget_Bar_Status +from setting import SPACE_FRAME_GAP, SPACE_FRAME_PAD + + +class MissionStubPage(Feature): + """布局与训练助手完全不同的扩展示例页。""" + + feature_id = "mission_stub" + title = "任务 Stub" + + primaryClicked = Signal() + secondaryClicked = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins( + SPACE_FRAME_PAD, + SPACE_FRAME_PAD, + SPACE_FRAME_PAD, + SPACE_FRAME_PAD, + ) + layout.setSpacing(SPACE_FRAME_GAP) + + title = QLabel("飞行任务 B(示例)") + title.setObjectName("Title") + title.setAlignment(Qt.AlignCenter) + + hint = QLabel("此页仅作框架扩展示例:布局与训练助手完全不同。") + hint.setObjectName("Subtitle") + hint.setWordWrap(True) + hint.setAlignment(Qt.AlignCenter) + + self.actions = Container_Bar_Action( + button_defs=[ + {"id": "primary", "text": "开始任务"}, + {"id": "secondary", "text": "返回训练助手"}, + ] + ) + self.actions.clicked.connect(self._on_action) + self.status = widget_Bar_Status("stub 就绪") + + layout.addStretch(1) + layout.addWidget(title) + layout.addWidget(hint) + layout.addSpacing(SPACE_FRAME_PAD) + layout.addWidget(self.actions) + layout.addWidget(self.status) + layout.addStretch(2) + + def _on_action(self, button_id: str) -> None: + if button_id == "primary": + self.primaryClicked.emit() + self.status.set_status("已点击开始任务(无业务)", "ok") + elif button_id == "secondary": + self.secondaryClicked.emit() + + def apply_capabilities(self, caps: CapabilitySet) -> None: + """Stub 页无能力裁剪。""" + pass + + def set_status(self, text: str, kind: str = "muted") -> None: + self.status.set_status(text, kind) diff --git a/pages/page_sim_check.py b/pages/page_sim_check.py new file mode 100644 index 00000000..f6fb4283 --- /dev/null +++ b/pages/page_sim_check.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +"""模块:pages.page_sim_check — 开机自检页(原 SimCheckPage + SimCheckPanel)。""" + +from __future__ import annotations + +from typing import Optional + +from PySide2.QtCore import Qt, Signal +from PySide2.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout + +from components.Container.Container_Card_Surface import Container_Card_Surface +from components.Frame.Frame_Shell_AppMain import CapabilitySet, Feature +from components.Frame.Frame_Theme_UI import UITheme +from components.widget.widget_Light_Signal import widget_Light_Signal +from setting import ( + SPACE_CONTAINER_PAD, + SPACE_FRAME_GAP, + SPACE_FRAME_PAD, + SPACE_NONE, + SPACE_WIDGET_GAP, + SPACE_WIDGET_PAD, +) + + +class SimCheckPage(Feature): + """自检页:信号灯项与底部动作按钮。""" + + feature_id = "flow.sim_check" + title = "" + + actionClicked = Signal() + + _TITLE_OBJ = { + "idle": "CheckWait", + "run": "CheckRun", + "ok": "CheckOk", + "fail": "CheckFail", + } + + def __init__(self, parent=None, items: Optional[list[str]] = None): + super().__init__(parent) + labels = items or ["网络连接", "服务器", "模拟器"] + layout = QVBoxLayout(self) + layout.setContentsMargins( + SPACE_FRAME_PAD, + SPACE_FRAME_PAD, + SPACE_FRAME_PAD, + SPACE_FRAME_PAD, + ) + layout.setSpacing(SPACE_NONE) + title = QLabel("FSX竞速助手") + title.setObjectName("Title") + title.setAlignment(Qt.AlignCenter) + self.subtitle = QLabel("Initializing...") + self.subtitle.setObjectName("Subtitle") + self.subtitle.setAlignment(Qt.AlignCenter) + layout.addWidget(title) + layout.addSpacing(SPACE_WIDGET_PAD) + layout.addWidget(self.subtitle) + layout.addSpacing(SPACE_FRAME_GAP) + + list_card = Container_Card_Surface() + list_card.setObjectName("CheckListCard") + list_l = QVBoxLayout(list_card) + list_l.setContentsMargins( + SPACE_CONTAINER_PAD, + SPACE_CONTAINER_PAD, + SPACE_CONTAINER_PAD, + SPACE_CONTAINER_PAD, + ) + list_l.setSpacing(SPACE_FRAME_GAP) + self._rows = [] + for label in labels: + row = QHBoxLayout() + row.setSpacing(SPACE_WIDGET_GAP) + mark = widget_Light_Signal(diameter=13) + name = QLabel(label) + name.setObjectName("CheckWait") + detail = QLabel("等待") + detail.setObjectName("CheckItemDetail") + row.addWidget(mark, 0, Qt.AlignVCenter) + row.addWidget(name, 0, Qt.AlignVCenter) + row.addStretch(1) + row.addWidget(detail, 0, Qt.AlignVCenter) + list_l.addLayout(row) + self._rows.append({"mark": mark, "title": name, "detail": detail}) + layout.addWidget(list_card, 1) + layout.addSpacing(SPACE_FRAME_GAP) + + self.action_btn = QPushButton("START") + self.action_btn.setObjectName("FlatButton") + self.action_btn.setMinimumWidth(UITheme.scaled(120, 96)) + self.action_btn.setEnabled(False) + self.action_btn.clicked.connect(self.actionClicked.emit) + btn_row = QHBoxLayout() + btn_row.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_WIDGET_PAD) + btn_row.addStretch(1) + btn_row.addWidget(self.action_btn) + btn_row.addStretch(1) + layout.addLayout(btn_row) + + def set_subtitle(self, text: str) -> None: + self.subtitle.setText(text) + + def set_item(self, index: int, state: str, detail: str) -> None: + if index < 0 or index >= len(self._rows): + return + widgets = self._rows[index] + widgets["mark"].set_state(state) + widgets["title"].setObjectName(self._TITLE_OBJ.get(state, "CheckWait")) + widgets["detail"].setText(detail) + w = widgets["title"] + w.style().unpolish(w) + w.style().polish(w) + + def set_action(self, text: str, enabled: bool = True) -> None: + self.action_btn.setText(text) + self.action_btn.setEnabled(enabled) + + def set_busy(self, busy: bool) -> None: + if busy: + self.action_btn.setEnabled(False) + + def apply_capabilities(self, caps: CapabilitySet) -> None: + """自检页无能力裁剪。""" + pass diff --git a/pages/page_training_assist.py b/pages/page_training_assist.py new file mode 100644 index 00000000..a962a0ec --- /dev/null +++ b/pages/page_training_assist.py @@ -0,0 +1,530 @@ +# -*- coding: utf-8 -*- +""" +模块:pages.page_training_assist +职责:训练助手主功能页(指标 / 圈板 / 图表 / 操作栏) +依赖:components Container/widget、charts.TrendChart、CompareStylePanel +""" + +from typing import Callable, Optional + +from PySide2.QtCore import QEvent, QObject, Qt, Signal +from PySide2.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget + +from components.Container.Container_Area_Chart import Container_Area_Chart +from components.Container.Container_Bar_Action import Container_Bar_Action +from components.Container.Container_Board_Lap import Container_Board_Lap +from components.Container.Container_Card_Surface import Container_Card_Surface +from components.Container.Container_Strip_Metric import Container_Strip_Metric +from components.Frame.Frame_Shell_AppMain import CapabilitySet, Feature +from components.Frame.Frame_Theme_UI import UITheme +from components.widget.widget_Bar_Status import widget_Bar_Status +from components.widget.widget_Toggle_Slide import widget_Toggle_Slide +from models.state_bus.keys import ( + GROUP_ANALYZE, + GROUP_CHART, + GROUP_LIVE, + GROUP_SESSION, + GROUP_STICK, + GROUP_TRACK, + KEY_BANK, + KEY_CHT, + KEY_COMPARE_ERROR, + KEY_COMPARE_PAYLOAD, + KEY_COMPARE_STATUS, + KEY_GROUNDSPEED, + KEY_HEALTH, + KEY_MISSION_TITLE, + KEY_AIRSPEED, + KEY_PITCH, + KEY_SESSION_MODE, + KEY_STICK_AXES, + KEY_TORQUE, +) +from setting import ( + CAP_CHART, + CAP_CHART_SAVE, + CAP_COLD_CABIN, + CAP_LAPS, + CAP_METRICS, + CAP_SIM_RATE, + SPACE_CONTAINER_PAD, + SPACE_FRAME_GAP, + SPACE_FRAME_PAD, + SPACE_NONE, + SPACE_WIDGET_GAP, + SPACE_WIDGET_GAP_TIGHT, + UI_ACTION_BTN_H, + UI_ACTION_BTN_W, + UI_MONITOR_BTN_W, +) +from charts.widgets.compare_style_panel import CompareStylePanel + + +_START_MONITOR_NEED_MISSION = "请先选择飞行任务" +# 操作栏 FlatButton 固定尺寸(设计分辨率) +# 「保存当前数据」文案更长,单独固定宽度 + + +class _DisabledForbiddenCursorFilter(QObject): + """禁用控件上悬停显示禁止光标。""" + + def eventFilter(self, watched, event): + if event.type() == QEvent.Enter and not watched.isEnabled(): + watched.setCursor(Qt.ForbiddenCursor) + elif event.type() == QEvent.Leave: + watched.unsetCursor() + return False + + +class TrainingAssistPage(Feature): + """Controller 面向的训练页 Feature API。""" + + feature_id = "training_assist" + title = "模拟飞行训练助手" + + startMonitorClicked = Signal() + rateClicked = Signal() + monitorClicked = Signal() + switchMissionClicked = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self._monitor_mode: Optional[str] = None + self._mission_ready = False + self._bus = None + self._history_provider: Optional[Callable] = None + self._track_provider: Optional[Callable] = None + self._consume_provider: Optional[Callable] = None + self._start_monitor_cursor_filter = _DisabledForbiddenCursorFilter(self) + layout = QVBoxLayout(self) + layout.setContentsMargins( + SPACE_FRAME_PAD, SPACE_FRAME_PAD, SPACE_FRAME_PAD, SPACE_FRAME_PAD + ) + layout.setSpacing(SPACE_FRAME_GAP) + + self.metrics = Container_Strip_Metric() + self.laps = Container_Board_Lap() + self.laps.missionSwitchClicked.connect(self.switchMissionClicked.emit) + + sound_row = QHBoxLayout() + sound_row.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + sound_row.setSpacing(SPACE_WIDGET_GAP_TIGHT) + sound_row.addStretch(1) + sound_lbl = QLabel("开启声音提示") + sound_lbl.setObjectName("Muted") + sound_lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self._sound_toggle = widget_Toggle_Slide(checked=True) + self._sound_toggle.toggled.connect(self._on_sound_prompt_toggled) + sound_row.addWidget(sound_lbl, 0, Qt.AlignVCenter) + sound_row.addWidget(self._sound_toggle, 0, Qt.AlignVCenter) + self.metrics.set_health_beep_enabled(True) + + chart_card = Container_Area_Chart() + self.chart = chart_card.chart + self.chart.set_save_caption_provider(self._chart_save_caption) + self.chart.compareActiveChanged.connect(self._on_compare_active_changed) + self.chart.compareHintChanged.connect(self._on_compare_hint) + self.chart.set_compare_loader(self._enqueue_compare_load) + + btn_card = Container_Card_Surface() + btn_l = QHBoxLayout(btn_card) + btn_l.setContentsMargins( + SPACE_CONTAINER_PAD, + SPACE_CONTAINER_PAD, + SPACE_CONTAINER_PAD, + SPACE_CONTAINER_PAD, + ) + btn_l.setSpacing(SPACE_WIDGET_GAP) + self.actions = Container_Bar_Action( + button_defs=[ + {"id": "start_monitor", "text": "开始监测"}, + {"id": "sim_rate", "text": "开启倍速"}, + {"id": "chart_save", "text": "保存图表"}, + {"id": "chart_compare", "text": "对比数据"}, + ], + alignment=Qt.AlignLeft, + ) + self.actions.clicked.connect(self._on_action) + btn_w = UITheme.scaled(UI_ACTION_BTN_W, 90) + btn_h = UITheme.scaled(UI_ACTION_BTN_H, 30) + for bid in ("start_monitor", "sim_rate", "chart_save", "chart_compare"): + btn = self.actions.button(bid) + if btn is not None: + btn.setFixedSize(btn_w, btn_h) + btn_l.addWidget(self.actions, 0, Qt.AlignLeft | Qt.AlignVCenter) + + self.compare_panel = CompareStylePanel() + self.compare_panel.styleChanged.connect(self.chart.apply_compare_style) + btn_l.addWidget(self.compare_panel, 1, Qt.AlignVCenter) + + btn_l.addStretch(1) + # 右侧固定占位:隐藏「保存当前数据」时仍保留宽度,避免中间按钮位移 + self._monitor_slot = QWidget() + self._monitor_slot.setObjectName("MonitorSlot") + self._monitor_slot.setAttribute(Qt.WA_StyledBackground, True) + self._monitor_slot.setAutoFillBackground(False) + slot_l = QHBoxLayout(self._monitor_slot) + slot_l.setContentsMargins(SPACE_NONE, SPACE_NONE, SPACE_NONE, SPACE_NONE) + slot_l.setSpacing(SPACE_NONE) + self.monitor_btn = QPushButton("保存当前数据") + self.monitor_btn.setObjectName("MonitorButton") + self.monitor_btn.setProperty("monitorKind", "save") + mon_w = UITheme.scaled(UI_MONITOR_BTN_W, 124) + self.monitor_btn.setFixedSize(mon_w, btn_h) + self.monitor_btn.setVisible(False) + self.monitor_btn.clicked.connect(self.monitorClicked.emit) + slot_l.addWidget(self.monitor_btn, 0, Qt.AlignRight | Qt.AlignVCenter) + self._monitor_slot.setFixedSize(mon_w, btn_h) + btn_l.addWidget(self._monitor_slot, 0, Qt.AlignRight | Qt.AlignVCenter) + + self.status = widget_Bar_Status("正在检测模拟器...") + + layout.addLayout(sound_row) + layout.addWidget(self.metrics) + layout.addWidget(self.laps) + layout.addWidget(chart_card, 1) + layout.addWidget(btn_card) + layout.addWidget(self.status) + + self._chart_host = chart_card + self._apply_start_monitor_style("start") + start_btn = self.actions.button("start_monitor") + if start_btn is not None: + start_btn.installEventFilter(self._start_monitor_cursor_filter) + self._apply_start_monitor_availability() + + def _on_action(self, button_id: str) -> None: + if button_id == "start_monitor": + if self._monitor_mode in ("stop", "pending"): + self.monitorClicked.emit() + elif self._mission_ready: + self.startMonitorClicked.emit() + elif button_id == "sim_rate": + self.rateClicked.emit() + elif button_id == "chart_save": + self.chart.save_chart() + elif button_id == "chart_compare": + if self.chart.is_compare_active(): + self.chart.clear_compare() + if self._bus is not None: + self._bus.commit( + GROUP_ANALYZE, + { + KEY_COMPARE_STATUS: "idle", + KEY_COMPARE_PAYLOAD: None, + KEY_COMPARE_ERROR: "", + }, + ) + else: + self.chart.load_compare_dialog() + + def _on_compare_active_changed(self, active: bool, label: str = "") -> None: + self.actions.set_button( + "chart_compare", + text="关闭对比" if active else "对比数据", + ) + if active: + self.compare_panel.set_style(self.chart.compare_style()) + self.compare_panel.set_label(label or self.chart.compare_label()) + self.compare_panel.setVisible(True) + else: + self.compare_panel.setVisible(False) + self.compare_panel.set_label("") + + def _on_compare_hint(self, text: str) -> None: + if text: + self.set_status(text, "warn") + + def _apply_start_monitor_style(self, mode: str) -> None: + btn = self.actions.button("start_monitor") + if btn is None: + return + if mode == "stop": + btn.setText("停止监测") + btn.setObjectName("MonitorButton") + btn.setProperty("monitorKind", "stop") + else: + btn.setText("开始监测") + btn.setObjectName("FlatButton") + btn.setProperty("monitorKind", "") + btn.style().unpolish(btn) + btn.style().polish(btn) + + def _chart_save_caption(self): + rows = [] + for i, (_name, tm) in enumerate(self.laps._lap_labels): + rows.append((f"LAP {i + 1:02d}", tm.text())) + return rows, self.laps.total_label.text() + + def apply_capabilities(self, caps: CapabilitySet) -> None: + """按 CapabilitySet 显隐指标/圈板/图表与相关按钮。""" + self.metrics.setVisible(caps.has(CAP_METRICS)) + self.laps.setVisible(caps.has(CAP_LAPS)) + self._chart_host.setVisible(caps.has(CAP_CHART)) + can_save = caps.has(CAP_CHART_SAVE) + self.chart.set_save_enabled(can_save) + self.actions.set_button_visible("chart_save", can_save) + self.actions.set_button_visible("chart_compare", can_save) + self.actions.set_button_visible("start_monitor", caps.has(CAP_COLD_CABIN)) + self.actions.set_button_visible("sim_rate", caps.has(CAP_SIM_RATE)) + + def bind_state_bus( + self, + bus, + history_provider=None, + track_provider=None, + consume_provider=None, + ) -> None: + """订阅状态总线;由 AppController 在接线时调用一次。""" + self._bus = bus + self._history_provider = history_provider + self._track_provider = track_provider + self._consume_provider = consume_provider + hub = bus.hub + hub.subscribe(GROUP_SESSION, self._on_bus_session) + hub.subscribe(GROUP_LIVE, self._on_bus_live) + hub.subscribe(GROUP_STICK, self._on_bus_stick) + hub.subscribe(GROUP_CHART, self._on_bus_chart) + hub.subscribe(GROUP_TRACK, self._on_bus_track) + hub.subscribe(GROUP_ANALYZE, self._on_bus_analyze) + + def _on_bus_session(self, _group, snap, _ver) -> None: + self.set_session_mode(str(snap.get(KEY_SESSION_MODE) or "freeflight")) + title = snap.get(KEY_MISSION_TITLE) or None + self.set_mission_title(str(title) if title else None) + + def _on_bus_live(self, _group, snap, _ver) -> None: + payload = { + "cht": snap.get(KEY_CHT), + "torque": snap.get(KEY_TORQUE), + "health": snap.get(KEY_HEALTH), + "airspeed": snap.get(KEY_AIRSPEED), + "groundspeed": snap.get(KEY_GROUNDSPEED), + "pitch": snap.get(KEY_PITCH), + "bank": snap.get(KEY_BANK), + } + for fid in ("eta_prop", "di_proxy", "vg_course"): + if fid in snap: + payload[fid] = snap.get(fid) + self.set_live_metrics(payload) + + def _on_bus_stick(self, _group, snap, _ver) -> None: + axes = snap.get(KEY_STICK_AXES) + if axes is not None: + self.set_stick(axes) + + def _on_bus_chart(self, _group, _snap, _ver) -> None: + if self._history_provider is None: + return + hist = self._history_provider() + if hist is not None: + self.update_chart(hist) + + def _on_bus_track(self, _group, _snap, _ver) -> None: + if self._track_provider is None: + return + pts = self._track_provider() + if pts is not None: + self.set_track_points(pts) + + def _on_bus_analyze(self, _group, snap, _ver) -> None: + status = str(snap.get(KEY_COMPARE_STATUS) or "") + if status == "loading": + self.set_status("正在加载对比数据…", "muted") + return + if status == "error": + err = snap.get(KEY_COMPARE_ERROR) or "未知错误" + self.set_status(f"对比加载失败: {err}", "warn") + self.chart.compareHintChanged.emit(f"对比加载失败: {err}") + return + if status == "ready": + payload = snap.get(KEY_COMPARE_PAYLOAD) + if payload is not None: + self.chart.apply_compare(payload) + + def set_auth_user_id(self, user_id: Optional[str]) -> None: + """登录后注入 user_id,供对比/云端航迹使用。""" + uid = str(user_id or "").strip() or None + self._auth_user_id = uid + self.chart.set_track_user_id(uid) + + def _enqueue_compare_load(self, record_id: str) -> None: + if self._bus is None: + return + uid = getattr(self, "_auth_user_id", None) + if not uid: + self.set_status("未登录,无法加载云端航迹", "warn") + return + ok = self._bus.analyze.enqueue_load_compare(record_id, user_id=uid) + if not ok: + self.set_status("对比加载进行中,请稍候", "warn") + + def set_cht(self, value) -> None: + """更新 CHT 读数。""" + self.metrics.set_cht(value) + + def set_torque(self, value) -> None: + """更新扭矩读数。""" + self.metrics.set_torque(value) + + def set_health(self, pct) -> None: + """更新健康度读数。""" + self.metrics.set_health(pct) + + def set_live_metrics(self, values) -> None: + """批量更新顶部三仪表缓存(按字段 id)。""" + self.metrics.set_live_metrics(values) + + def set_health_beep_armed(self, armed: bool) -> None: + """监测进行中才允许健康蜂鸣。""" + self.metrics.set_health_beep_armed(armed) + + def _on_sound_prompt_toggled(self, checked: bool = False) -> None: + self.metrics.set_health_beep_enabled(bool(checked)) + + def set_laps( + self, + times: list, + total=None, + lap_penalties: Optional[list] = None, + total_penalty=None, + ) -> None: + """更新圈时板。""" + self.laps.set_laps(times, total, lap_penalties=lap_penalties, total_penalty=total_penalty) + + def set_score_crashed(self, crashed: bool) -> None: + """坠毁时分数置 0;新监测开始清除。""" + self.laps.set_crashed(crashed) + + def displayed_score(self): + """圈时板分数面板当前显示值。""" + return self.laps.displayed_score() + + def set_session_mode(self, mode: str = "freeflight") -> None: + """更新会话模式标签。""" + self.laps.set_session_mode(mode) + + def set_mission_title(self, title: Optional[str] = None) -> None: + """更新任务名称标签,并驱动性能图 X 轴时间规则。""" + self.laps.set_mission_title(title) + self.chart.set_mission_title(title) + + def set_mission_switch_enabled(self, enabled: bool) -> None: + """监测中禁用圈时板切换任务按钮。""" + self.laps.set_mission_switch_enabled(enabled) + + def set_status(self, text: str, kind: str = "muted") -> None: + """更新底部运行状态文案。""" + self.status.set_status(text, kind) + + def set_rate_status(self, text: str, kind: str = "muted") -> None: + """更新倍速相关状态文案。""" + self.status.set_rate_status(text, kind) + + def set_rate_button(self, text: str) -> None: + """更新倍速按钮文案。""" + self.actions.set_button("sim_rate", text=text) + + def set_start_monitor_mission_ready(self, ready: bool) -> None: + """任务是否已选好,决定「开始监测」可点。""" + self._mission_ready = bool(ready) + self._apply_start_monitor_availability() + + def _apply_start_monitor_availability(self) -> None: + btn = self.actions.button("start_monitor") + if btn is None: + return + if self._monitor_mode in ("stop", "pending"): + btn.setEnabled(True) + btn.setToolTip("") + return + enabled = self._mission_ready + btn.setEnabled(enabled) + btn.setToolTip("" if enabled else _START_MONITOR_NEED_MISSION) + + def set_monitor_button(self, mode: Optional[str] = None) -> None: + """ + 说明:切换监测按钮外观 + 参数: + mode — "stop"|"pending"|"save"|None + """ + self._monitor_mode = mode + if mode == "stop": + # 仅录制中(已解除暂停)武装蜂鸣;等待解除暂停时不响 + self._apply_start_monitor_style("stop") + self.monitor_btn.setVisible(False) + self.set_health_beep_armed(True) + elif mode == "pending": + self._apply_start_monitor_style("stop") + self.monitor_btn.setVisible(False) + self.set_health_beep_armed(False) + elif mode == "save": + self._apply_start_monitor_style("start") + self.monitor_btn.setVisible(True) + self.monitor_btn.setText("保存当前数据") + self.monitor_btn.setProperty("monitorKind", "save") + self.set_health_beep_armed(False) + else: + self._apply_start_monitor_style("start") + self.monitor_btn.setVisible(False) + self.set_health_beep_armed(False) + self._apply_start_monitor_availability() + self.monitor_btn.style().unpolish(self.monitor_btn) + self.monitor_btn.style().polish(self.monitor_btn) + + def update_chart(self, history) -> None: + """刷新趋势图缓冲。""" + self.chart.update(history) + + def pull_live_frame(self) -> None: + """热路径通知:从消费缓存 pull 增量(不接收外部缓冲本体)。""" + cache = self._consume_provider() if self._consume_provider else None + if cache is None: + return + n = self.chart.live_track_count() + track = cache.track_points + if len(track) < n: + # 会话缩短(复位/新开):全量对齐,避免增量错位 + self.chart.set_track_points(list(track)) + new_pts = None + elif len(track) > n: + new_pts = cache.track_points_from(n) + else: + new_pts = None + self.chart.append_live_frame(cache.history, new_pts) + + def set_track_points(self, points) -> None: + """写入当前会话航迹点。""" + self.chart.set_track_points(points) + + def reset_track_filters(self) -> None: + """复位航迹图圈过滤复选。""" + self.chart.reset_track_filters() + + def set_stick_axes(self, pitch=0.0, roll=0.0, yaw=0.0, throttle=0.0, connected=True) -> None: + """更新操纵输入附图。""" + self.chart.set_stick_axes(pitch, roll, yaw, throttle, connected) + + def set_stick(self, axes) -> None: + """写入 StickAxes 到操纵输入附图。""" + self.chart.set_stick(axes) + + def resume_live_follow(self) -> None: + """恢复时间轴跟随与实时摇杆刷新(重新开始监测时调用)。""" + self.chart.resume_live_follow() + + def leave_live_follow(self) -> None: + """停止监测后:航迹视窗切回自由。""" + self.chart.leave_live_follow() + + def add_lap_marker(self, ts=None, lap_number=None) -> None: + """在趋势图添加圈完成竖线。""" + self.chart.add_lap_marker(ts, lap_number) + + def clear_lap_markers(self) -> None: + """清除趋势图圈标。""" + self.chart.clear_lap_markers() + + def clear_track(self) -> None: + """清空航迹图本场几何与起终点标记。""" + self.chart.clear_track() diff --git a/requirements.txt b/requirements.txt index d38605d3..91c9f01f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,14 @@ --i https://pypi.org/simple - +-i https://mirrors.aliyun.com/pypi/simple/ +pymysql>=1.0,<2 +DBUtils>=3.0,<4 +python-dotenv>=1.0,<2 +passlib>=1.7,<2 +# sim_connect:Python 3.10 win32;勿装 numpy 2.5(仅 cp314/amd64) +numpy>=1.21,<1.25 +pandas>=2.0,<2.1 +# SimConnect.dll 为 32 位;需 32 位 Python,Qt 使用同架构最高兼容版本 +PySide2==5.15.2.1; platform_machine == "x86" +# 趋势图 / 航迹图渲染(与 PySide2 同进程;装前设 PYQTGRAPH_QT_LIB=PySide2) +pyqtgraph>=0.13,<0.14 +# 手柄枚举/读轴(SDL);sim_connect 为 win32 cp310 +pygame>=2.1,<2.6 diff --git a/requirements_dev.txt b/requirements_dev.txt deleted file mode 100644 index 03a64296..00000000 --- a/requirements_dev.txt +++ /dev/null @@ -1,15 +0,0 @@ --i https://pypi.org/simple -atomicwrites==1.4.0; sys_platform == 'win32' -attrs==20.1.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' -colorama==0.4.3; sys_platform == 'win32' -coverage==5.2.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4' -iniconfig==1.0.1 -more-itertools==8.5.0; python_version >= '3.5' -packaging==20.4; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' -pluggy==0.13.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' -py==1.9.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' -pyparsing==2.4.7; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2' -pytest-cov==2.10.1 -pytest==6.0.1; python_version >= '3.5' -six==1.15.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2' -toml==0.10.1 diff --git a/resources/fonts/NotoSansSC-Bold.ttf b/resources/fonts/NotoSansSC-Bold.ttf new file mode 100644 index 00000000..1decf2bf Binary files /dev/null and b/resources/fonts/NotoSansSC-Bold.ttf differ diff --git a/resources/fonts/NotoSansSC-Regular.ttf b/resources/fonts/NotoSansSC-Regular.ttf new file mode 100644 index 00000000..92714869 Binary files /dev/null and b/resources/fonts/NotoSansSC-Regular.ttf differ diff --git a/resources/fonts/Orbitron-Bold.ttf b/resources/fonts/Orbitron-Bold.ttf new file mode 100644 index 00000000..374f46f8 Binary files /dev/null and b/resources/fonts/Orbitron-Bold.ttf differ diff --git a/resources/fonts/Orbitron-Medium.ttf b/resources/fonts/Orbitron-Medium.ttf new file mode 100644 index 00000000..e84885a2 Binary files /dev/null and b/resources/fonts/Orbitron-Medium.ttf differ diff --git a/resources/fonts/README.txt b/resources/fonts/README.txt new file mode 100644 index 00000000..30475e1b --- /dev/null +++ b/resources/fonts/README.txt @@ -0,0 +1,13 @@ +Fonts bundled with TALENT ACADEMY flight training assistant. + +1) Noto Sans SC (Regular / Bold) + Copyright 2015+ Google Inc. / Adobe + License: SIL Open Font License 1.1 + Source: https://fonts.google.com/noto/specimen/Noto+Sans+SC + +2) Orbitron (Medium / Bold) + Copyright 2018 The Orbitron Project Authors + License: SIL Open Font License 1.1 + Source: https://fonts.google.com/specimen/Orbitron + +These files are redistributed under the OFL so the app UI does not depend on OS-installed fonts. diff --git a/resources/icons/combo_down_arrow.png b/resources/icons/combo_down_arrow.png new file mode 100644 index 00000000..49f44435 Binary files /dev/null and b/resources/icons/combo_down_arrow.png differ diff --git a/resources/icons/combo_down_arrow.svg b/resources/icons/combo_down_arrow.svg new file mode 100644 index 00000000..eb3a4294 --- /dev/null +++ b/resources/icons/combo_down_arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/icons/icon.ico b/resources/icons/icon.ico new file mode 100644 index 00000000..c8a14081 Binary files /dev/null and b/resources/icons/icon.ico differ diff --git a/resources/icons/icon.png b/resources/icons/icon.png new file mode 100644 index 00000000..915201a7 Binary files /dev/null and b/resources/icons/icon.png differ diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 00000000..44a25439 --- /dev/null +++ b/services/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +""" +模块:services +职责:模拟器连接、鉴权等无 UI 服务 +""" diff --git a/services/auth.py b/services/auth.py new file mode 100644 index 00000000..b8e6cb44 --- /dev/null +++ b/services/auth.py @@ -0,0 +1,196 @@ +# -*- coding: utf-8 -*- +""" +模块:services.auth +职责:外网/鉴权服务器探测与 MySQL 登录校验(账号密码 + UD4 memberlevel) +依赖:passlib;数据库经 models.db_operate 中间件(勿直连 pymysql) +""" + +from __future__ import annotations + +import json +import socket +import subprocess +import sys +import urllib.parse +from dataclasses import dataclass +from typing import Any, Optional + +import setting as se +from models.db_operate import auth_db_query + + +@dataclass(frozen=True) +class AuthUser: + """登录成功后的会话用户(含云端航迹写入所需 user_id、会员级别)。""" + + user_id: str + user_name: str + member_level: str + +CHECK_TIMEOUT_S = se.CHECK_TIMEOUT_S +AUTH_LOGIN_TIMEOUT_S = se.AUTH_LOGIN_TIMEOUT_S +CHECK_NETWORK_URL = se.CHECK_NETWORK_URL +AUTH_SERVER_URL = se.AUTH_SERVER_URL +AUTH_USER_TABLE = se.AUTH_USER_TABLE + + +def _tcp_reachable(host, port, timeout=CHECK_TIMEOUT_S): + try: + with socket.create_connection((host, int(port)), timeout=timeout): + return True + except (OSError, ValueError, TypeError): + return False + + +def check_network_online(timeout=CHECK_TIMEOUT_S): + """curl -I 探测外网连通性。""" + sec = max(1, int(timeout)) + cmd = [ + "curl", + "-I", + "-sS", + "--connect-timeout", + str(sec), + "--max-time", + str(sec), + CHECK_NETWORK_URL, + ] + flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) if sys.platform == "win32" else 0 + try: + result = subprocess.run( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=timeout + 1, + creationflags=flags, + ) + return result.returncode == 0 + except (OSError, subprocess.TimeoutExpired): + return False + + +def parse_auth_server(url=AUTH_SERVER_URL): + """解析鉴权 URL 为 (host, port)。""" + parsed = urllib.parse.urlparse(url) + host = parsed.hostname or url + if parsed.port: + port = parsed.port + elif parsed.scheme == "http": + port = 80 + else: + port = 443 + return host, int(port) + + +def check_auth_server_online(url=AUTH_SERVER_URL, timeout=CHECK_TIMEOUT_S): + """TCP 探测鉴权服务器是否可达。""" + host, port = parse_auth_server(url) + return _tcp_reachable(host, port, timeout) + + +class AuthFailed(Exception): + """用户名/密码错误,或 memberlevel 无效。对外统一同一提示文案。""" + + +def _pwd_context(): + try: + from passlib.context import CryptContext + except ImportError as exc: + raise RuntimeError("缺少 passlib,请先安装") from exc + return CryptContext( + schemes=["pbkdf2_sha256"], + default="pbkdf2_sha256", + pbkdf2_sha256__rounds=200_000, + deprecated="auto", + ) + + +_PWD_CONTEXT = None + + +def _get_pwd_context(): + global _PWD_CONTEXT + if _PWD_CONTEXT is None: + _PWD_CONTEXT = _pwd_context() + return _PWD_CONTEXT + + +def _coerce_stored_hash(value): + if value is None: + return "" + if isinstance(value, (bytes, bytearray, memoryview)): + return bytes(value).decode("utf-8", errors="ignore").strip() + return str(value).strip() + + +def verify_password(plain_password, hashed_password): + """PBKDF2-SHA256 校验明文密码与库内哈希。""" + stored = _coerce_stored_hash(hashed_password) + if not stored: + return False + try: + return bool(_get_pwd_context().verify(plain_password, stored)) + except Exception: + return False + + +def _parse_ud4_raw(raw: Any) -> Optional[dict]: + if raw is None or raw == "": + return None + if isinstance(raw, (bytes, bytearray, memoryview)): + raw = bytes(raw).decode("utf-8", errors="ignore") + if isinstance(raw, str): + raw = raw.strip() + if not raw: + return None + try: + data = json.loads(raw) + except json.JSONDecodeError: + return None + elif isinstance(raw, dict): + data = raw + else: + return None + return data if isinstance(data, dict) else None + + +def _memberlevel_from_ud4(raw: Any) -> Optional[str]: + """读取 UD4.memberlevel;缺失 / null / 空白视为无效。""" + data = _parse_ud4_raw(raw) + if not data: + return None + val = data.get(se.USER_SETTINGS_KEY_MEMBERLEVEL) + if val is None: + return None + text = str(val).strip() + return text or None + + +def try_auth_login(username, password, timeout=AUTH_LOGIN_TIMEOUT_S) -> AuthUser: + """核用户名/密码,并要求 UD4.memberlevel 有效;失败一律 AuthFailed(统一文案)。""" + fail = AuthFailed(se.MSG_AUTH_LOGIN_FAILED) + db_name = se.auth_db_config["db"] + col = se.USER_SETTINGS_UD_COLUMN + sql = ( + f"SELECT user_id, user_name, password_hash, `{col}` AS ud4 " + f"FROM `{db_name}`.`{AUTH_USER_TABLE}` " + "WHERE user_name = %s LIMIT 1" + ) + try: + rows = auth_db_query(sql, (username.strip(),), timeout=float(timeout)) + except ImportError as exc: + raise RuntimeError("缺少数据库依赖,请先安装 pymysql / DBUtils") from exc + row = rows[0] if rows else None + if not isinstance(row, dict): + raise fail + hashed = row.get("password_hash") + if not verify_password(password, hashed): + raise fail + member_level = _memberlevel_from_ud4(row.get("ud4")) + if member_level is None: + raise fail + user_id = str(row.get("user_id") or "").strip() + user_name = str(row.get("user_name") or username).strip() + if not user_id: + raise fail + return AuthUser(user_id=user_id, user_name=user_name, member_level=member_level) diff --git a/services/sim_batch.py b/services/sim_batch.py new file mode 100644 index 00000000..44cc8e22 --- /dev/null +++ b/services/sim_batch.py @@ -0,0 +1,193 @@ +# -*- coding: utf-8 -*- +""" +模块:services.sim_batch +职责:多 SimVar 合并为一次 RequestDataOnSimObjectType(FLOAT64) +说明:原生 AircraftRequests.get 是一变量一请求;监视路径改走本模块避免 N 次往返。 +""" + +from __future__ import annotations + +import logging +from typing import Dict, List, Mapping, Optional, Sequence, Tuple + +from SimConnect.Constants import SIMCONNECT_UNUSED +from SimConnect.Enum import SIMCONNECT_DATATYPE + +from services.simulator import ( + AIRSPEED_VAR, + BANK_VAR, + CHT_VAR, + DAMAGE_VAR, + GROUND_ALT_VAR, + GROUND_SPEED_VAR, + PITCH_VAR, + PLANE_AGL_VAR, + PLANE_ALT_VAR, + PLANE_HEADING_VAR, + PLANE_LAT_VAR, + PLANE_LON_VAR, + TORQUE_VAR, +) + +LOGGER = logging.getLogger(__name__) + +_CACHE_ATTR = "_fa_sim_batch_by_keys" + +# 仪表轻量读(暂停/非录制刷表) +LIVE_GAUGE_KEYS: Tuple[str, ...] = ( + CHT_VAR, + TORQUE_VAR, + DAMAGE_VAR, + AIRSPEED_VAR, + GROUND_SPEED_VAR, + PITCH_VAR, + BANK_VAR, +) + + +def resolve_aq_def(aq, key: str) -> Optional[Tuple[bytes, bytes]]: + """从 AircraftRequests 目录解析 (sim_name_bytes, units_bytes),处理 :index。""" + if not aq or not key: + return None + index = None + lookup = key + if ":" in key: + keyname, index = key.split(":", 1) + lookup = "%s:index" % keyname + for clas in getattr(aq, "list", ()) or (): + meta = clas.list.get(lookup) + if meta is None: + continue + name_b, unit_b = meta[1], meta[2] + if index is not None: + name_b = name_b.replace(b":index", (":" + str(index)).encode()) + if b":index" in name_b: + return None + return (name_b, unit_b) + return None + + +class BatchFloatRequest: + """一个 DATA_DEFINITION 内多个 FLOAT64,一次 RequestData。""" + + def __init__(self, sm, definitions: Sequence[Tuple[bytes, bytes]], attemps: int = 10): + self.sm = sm + self.definitions = list(definitions) + self.attemps = int(attemps) + self.defined = False + self.outData = None + self.LastID = 0 + self.DATA_DEFINITION_ID = sm.new_def_id() + self.DATA_REQUEST_ID = sm.new_request_id() + sm.Requests[self.DATA_REQUEST_ID.value] = self + self._define_all() + + def _define_all(self) -> bool: + datatype = SIMCONNECT_DATATYPE.SIMCONNECT_DATATYPE_FLOAT64 + for name_b, unit_b in self.definitions: + err = self.sm.dll.AddToDataDefinition( + self.sm.hSimConnect, + self.DATA_DEFINITION_ID.value, + name_b, + unit_b, + datatype, + 0, + SIMCONNECT_UNUSED, + ) + if not self.sm.IsHR(err, 0): + LOGGER.error("batch AddToDataDefinition failed: %s / %s", name_b, unit_b) + self.defined = False + return False + self.defined = True + return True + + def read(self) -> Optional[List[float]]: + if not self.defined or not self.definitions: + return None + if not self.sm.get_data(self): + return None + data = self.outData + if data is None: + return None + if isinstance(data, (list, tuple)): + return [float(x) for x in data] + return [float(data)] + + +def batch_get_sim_vars(aq, keys: Sequence[str]) -> Dict[str, Optional[float]]: + """ + 按 keys 顺序一次拉取;解析失败的 key 值为 None。 + 同一 aq + 相同 keys 复用已注册的 BatchFloatRequest。 + """ + out: Dict[str, Optional[float]] = {k: None for k in keys} + if not aq or not getattr(aq, "sm", None) or not keys: + return out + + key_t = tuple(keys) + cache: Dict = getattr(aq, _CACHE_ATTR, None) + if cache is None: + cache = {} + setattr(aq, _CACHE_ATTR, cache) + + entry = cache.get(key_t) + if entry is None: + ok_keys: List[str] = [] + defs: List[Tuple[bytes, bytes]] = [] + for k in keys: + d = resolve_aq_def(aq, k) + if d is None: + LOGGER.warning("batch: unresolved sim var %s", k) + continue + ok_keys.append(k) + defs.append(d) + req = BatchFloatRequest(aq.sm, defs) if defs else None + entry = {"ok_keys": ok_keys, "req": req} + cache[key_t] = entry + + req = entry.get("req") + ok_keys = entry.get("ok_keys") or [] + if req is None: + return out + vals = req.read() + if vals is None or len(vals) != len(ok_keys): + return out + for k, v in zip(ok_keys, vals): + out[k] = v + return out + + +def monitor_snapshot_keys() -> List[str]: + """监视一拍所需全部 Sim 键(核心 + CHT/扭矩/损伤 + 启用扩录)。""" + from models.track_fields import enabled_sim_specs + + keys: List[str] = [ + PLANE_LAT_VAR, + PLANE_LON_VAR, + GROUND_SPEED_VAR, + AIRSPEED_VAR, + PLANE_ALT_VAR, + PLANE_AGL_VAR, + GROUND_ALT_VAR, + PLANE_HEADING_VAR, + PITCH_VAR, + BANK_VAR, + CHT_VAR, + TORQUE_VAR, + DAMAGE_VAR, + ] + seen = set(keys) + for spec in enabled_sim_specs(): + sv = spec.sim_var + if sv and sv not in seen: + keys.append(sv) + seen.add(sv) + return keys + + +def raw_get(raw: Optional[Mapping[str, Optional[float]]], key: str, aq=None): + """优先批量字典,否则回退 aq.get。""" + if raw is not None and key in raw: + return raw.get(key) + if aq is not None: + return aq.get(key) + return None diff --git a/services/simulator.py b/services/simulator.py new file mode 100644 index 00000000..39045225 --- /dev/null +++ b/services/simulator.py @@ -0,0 +1,380 @@ +# -*- coding: utf-8 -*- +""" +模块:services.simulator +职责:SimConnect 连接、仿真变量读写、.flt 解析与单位换算 +依赖:vendored SimConnect/ +""" + +import sys +from pathlib import Path +from typing import Optional, Tuple + +from SimConnect import AircraftRequests, Event, SimConnect + +# --------------------------------------------------------------------------- +# 仿真变量名与运行参数 +# --------------------------------------------------------------------------- + +CHT_VAR = "RECIP_ENG_CYLINDER_HEAD_TEMPERATURE:1" +TORQUE_VAR = "ENG_TORQUE:1" +DAMAGE_VAR = "GENERAL_ENG_DAMAGE_PERCENT:1" +# 健康值获取偏移(百分点,正值=向上);仅作用于采集,色带/蜂鸣等消费逻辑仍用原阈值 +HEALTH_PCT_OFFSET = 0.5 +HEALTH_PCT_MIN = 0.0 +HEALTH_PCT_MAX = 100.0 +AIRSPEED_VAR = "AIRSPEED_INDICATED" +AIRSPEED_TRUE_VAR = "AIRSPEED_TRUE" +GROUND_SPEED_VAR = "GROUND_VELOCITY" +VERTICAL_SPEED_VAR = "VERTICAL_SPEED" +PITCH_VAR = "PLANE_PITCH_DEGREES" +BANK_VAR = "PLANE_BANK_DEGREES" +SIM_RATE_VAR = "SIMULATION_RATE" +ABSOLUTE_TIME_VAR = "ABSOLUTE_TIME" +PLANE_LAT_VAR = "PLANE_LATITUDE" +PLANE_LON_VAR = "PLANE_LONGITUDE" +PLANE_ALT_VAR = "PLANE_ALTITUDE" +PLANE_AGL_VAR = "PLANE_ALT_ABOVE_GROUND" +PLANE_HEADING_VAR = "PLANE_HEADING_DEGREES_TRUE" +GROUND_ALT_VAR = "GROUND_ALTITUDE" +TITLE_VAR = "TITLE" +PROP_RPM_VAR = "PROP_RPM:1" +GENERAL_ENG_RPM_VAR = "GENERAL_ENG_RPM:1" +MANIFOLD_PSI_VAR = "RECIP_ENG_MANIFOLD_PRESSURE:1" +PROP_THRUST_VAR = "PROP_THRUST:1" +PROP_BETA_VAR = "PROP_BETA:1" +PROP_LEVER_VAR = "GENERAL_ENG_PROPELLER_LEVER_POSITION:1" +INCIDENCE_ALPHA_VAR = "INCIDENCE_ALPHA" +INCIDENCE_BETA_VAR = "INCIDENCE_BETA" +G_FORCE_VAR = "G_FORCE" +TOTAL_WEIGHT_VAR = "TOTAL_WEIGHT" +AMBIENT_DENSITY_VAR = "AMBIENT_DENSITY" +AMBIENT_TEMP_VAR = "AMBIENT_TEMPERATURE" +AMBIENT_WIND_VEL_VAR = "AMBIENT_WIND_VELOCITY" +AMBIENT_WIND_DIR_VAR = "AMBIENT_WIND_DIRECTION" +SMOKE_ENABLE_VAR = "SMOKE_ENABLE" +ELEVATOR_DEFLECTION_VAR = "ELEVATOR_DEFLECTION" +AILERON_DEFLECTION_VAR = "AILERON_AVERAGE_DEFLECTION" +RUDDER_DEFLECTION_VAR = "RUDDER_DEFLECTION" +SIM_CLOCK_MIN_DELTA_S = 0.001 +RAD_TO_DEG = 180.0 / 3.141592653589793 +DEG_TO_RAD = 3.141592653589793 / 180.0 +METERS_TO_FEET = 3.280839895 +FT_TO_M = 1.0 / METERS_TO_FEET +# 轨迹派生功率 / 能量(Docs/空气动力学原理及分析.md) +FT_LB_TO_N_M = 1.3558179483314004 +LBF_TO_N = 4.4482216152605 +LB_TO_KG = 0.45359237 +KTS_TO_M_S = 0.5144444444444445 +M_S_TO_KTS = 1.0 / KTS_TO_M_S +SLUG_FT3_TO_KG_M3 = 515.3788184 + +TARGET_CHT_F = 52.0 +TARGET_SIM_RATE = 2.0 +SIM_RATE_STEP_MS = 120 +SIM_RATE_MAX_STEPS = 10 +# 监测采样:按「模拟时间」目标 DATA_POLL_HZ(B:timestamp_s 约每 1/Hz 秒一点) +DATA_POLL_HZ = 10 +RECORD_INTERVAL_S = 1.0 / float(DATA_POLL_HZ) +# 1× 倍速时墙钟 poll;倍速升高时用 data_poll_ms_for_sim_rate 加密 +DATA_POLL_MS = 1000 // DATA_POLL_HZ +DATA_POLL_MS_MIN = 25 +DATA_POLL_SIM_RATE_CAP = 4.0 +# 等待解除暂停:仅需时钟推进检测,降低主线程占用 +WAITING_POLL_MS = 250 +MOTION_LAT_LON_EPS_DEG = 1e-5 +CONNECT_TIMEOUT_S = 5.0 +CHECK_ITEM_MIN_S = 0.6 +COLD_CABIN_DELAY_MS = 400 +HEALTH_FLASH_MS = 400 + + +def data_poll_ms_for_sim_rate(sim_rate: Optional[float]) -> int: + """ + 墙钟 poll 周期(ms),使在当前 SIMULATION_RATE 下仍能赶上 + 模拟时间轴 DATA_POLL_HZ(例:10Hz×2× → 约 50ms)。 + """ + try: + rate = float(sim_rate) if sim_rate is not None else 1.0 + except (TypeError, ValueError): + rate = 1.0 + if rate != rate or rate < 1.0: + rate = 1.0 + if rate > DATA_POLL_SIM_RATE_CAP: + rate = DATA_POLL_SIM_RATE_CAP + ms = int(round(1000.0 / (float(DATA_POLL_HZ) * rate))) + return max(int(DATA_POLL_MS_MIN), ms) + + +def c_to_f(celsius): + """摄氏度转华氏度。""" + return celsius * 9 / 5 + 32 + + +def f_to_c(fahrenheit): + """华氏度转摄氏度(写入 CHT 前使用)。""" + return (fahrenheit - 32) * 5 / 9 + + +def health_ratio_from_damage(damage) -> float: + """ + 损伤百分比 → 健康比(0~1)。 + 采集时施加 HEALTH_PCT_OFFSET,并钳位到 [HEALTH_PCT_MIN, HEALTH_PCT_MAX], + 保证显示与判定均不超过 100%。 + """ + pct = 100.0 - float(damage) + float(HEALTH_PCT_OFFSET) + if pct < HEALTH_PCT_MIN: + pct = HEALTH_PCT_MIN + elif pct > HEALTH_PCT_MAX: + pct = HEALTH_PCT_MAX + return pct / 100.0 + + +def optional_float(value, default=None): + """安全转 float;失败返回 default。""" + if value is None: + return default + try: + return float(value) + except (TypeError, ValueError): + return default + + +def rad_to_deg(value, default=None): + """SimConnect 姿态角(弧度)→ 度。""" + v = optional_float(value) + if v is None: + return default + return v * RAD_TO_DEG + + +def is_plane_moved( + aq, + baseline_lat: Optional[float], + baseline_lon: Optional[float], + eps_deg: float = MOTION_LAT_LON_EPS_DEG, +) -> Tuple[bool, Optional[float], Optional[float]]: + """判断飞机是否相对基准经纬发生位移。 + + 返回 ``(是否移动, 基准纬度, 基准经度)``。尚无基准时用当前读数初始化并返回 ``(False, lat, lon)``。 + """ + if aq is None: + return False, baseline_lat, baseline_lon + lat = aq.get(PLANE_LAT_VAR) + lon = aq.get(PLANE_LON_VAR) + if lat is None or lon is None: + return False, baseline_lat, baseline_lon + lat = float(lat) + lon = float(lon) + if baseline_lat is None or baseline_lon is None: + return False, lat, lon + moved = ( + abs(lat - baseline_lat) >= eps_deg + or abs(lon - baseline_lon) >= eps_deg + ) + return moved, baseline_lat, baseline_lon + + +def read_simulation_rate(aq) -> Optional[float]: + """读取 SIMULATION_RATE;失败返回 None。""" + if aq is None: + return None + value = aq.get(SIM_RATE_VAR) + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def read_simulation_time_s(aq) -> Optional[float]: + """读取模拟器 ABSOLUTE TIME(秒);失败返回 None。""" + if aq is None: + return None + current = aq.get(ABSOLUTE_TIME_VAR) + if current is None: + return None + try: + return float(current) + except (TypeError, ValueError): + return None + + +def chart_elapsed_time_s( + aq, + origin: Optional[float], +) -> Tuple[Optional[float], Optional[float]]: + """基于 ABSOLUTE TIME 计算相对任务起点的时间(模拟暂停时不推进)。 + + 返回 ``(elapsed_s, origin)``;读数失败时 ``elapsed_s`` 为 None。 + """ + current = read_simulation_time_s(aq) + if current is None: + return None, origin + if origin is None: + origin = current + return max(0.0, current - origin), origin + + +def simulation_clock_advancing( + aq, + last_clock: Optional[float], + min_delta_s: float = SIM_CLOCK_MIN_DELTA_S, +) -> Tuple[bool, Optional[float]]: + """根据 ABSOLUTE TIME 是否推进判断模拟是否在运行。 + + 返回 ``(是否推进, 本次读数)``;首次读数仅建立基准并返回 ``(False, t)``。 + """ + if aq is None: + return False, last_clock + current = aq.get(ABSOLUTE_TIME_VAR) + if current is None: + return False, last_clock + try: + now = float(current) + except (TypeError, ValueError): + return False, last_clock + if last_clock is None: + return False, now + return (now - last_clock) > min_delta_s, now + + +def resource_root() -> Path: + """打包运行返回 _MEIPASS,否则返回项目根目录。""" + if getattr(sys, "frozen", False): + return Path(sys._MEIPASS) + return Path(__file__).resolve().parent.parent + + +def get_simconnect_dll_path(): + """返回 SimConnect.dll 的绝对路径。""" + return str(resource_root() / "SimConnect" / "SimConnect.dll") + + +def safe_disconnect_simulator(sm): + """安全断开 SimConnect,忽略清理过程中的异常。""" + if sm is None: + return + try: + sm.exit() + except Exception: + pass + + +def try_connect_simulator(timeout=CONNECT_TIMEOUT_S): + """尝试连接模拟器并完成握手验证;失败时释放资源并抛出异常。""" + sm = None + try: + sm = SimConnect(library_path=get_simconnect_dll_path(), auto_connect=False) + sm.connect(timeout=timeout) + if not sm.ok: + raise ConnectionError("SimConnect 握手未完成") + aq = AircraftRequests(sm, _time=0) + sim_rate_incr = Event(b"SIM_RATE_INCR", sm) + sim_rate_decr = Event(b"SIM_RATE_DECR", sm) + return sm, aq, sim_rate_incr, sim_rate_decr + except Exception: + safe_disconnect_simulator(sm) + raise + + +def normalize_flt_path(path_text: str) -> str: + """FlightLoaded 常省略扩展名;若路径本身不是文件则尝试补 .FLT/.flt。""" + text = (path_text or "").strip().strip('"') + if not text: + return "" + path = Path(text) + if path.is_file(): + return str(path) + if path.suffix.lower() != ".flt": + for suffix in (".FLT", ".flt"): + candidate = Path(text + suffix) + if candidate.is_file(): + return str(candidate) + return text + + +def parse_flt_main_fields(flt_path: str) -> dict: + """解析 .flt 文件 [Main] 段键值。""" + try: + path = Path(normalize_flt_path(flt_path)) + if not path.is_file(): + return {} + section = "" + main_fields = {} + for raw in path.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = raw.strip() + if not line or line.startswith(";"): + continue + if line.startswith("[") and line.endswith("]"): + section = line[1:-1].strip() + continue + if section != "Main" or "=" not in line: + continue + key, value = line.split("=", 1) + main_fields[key.strip()] = value.strip() + return main_fields + except Exception: + return {} + + +def _resolve_flt_path(path_text: str, base: Optional[Path] = None) -> Path: + normalized = normalize_flt_path(path_text) + candidate = Path(normalized) if normalized else Path(path_text) + if candidate.is_file(): + return candidate + if base is not None: + relative = normalize_flt_path(str(base.parent / path_text)) + rel_path = Path(relative) if relative else base.parent / path_text + if rel_path.is_file(): + return rel_path + return candidate + + +def _is_placeholder_flt_title(title: str) -> bool: + lower = title.lower() + return any( + marker in lower + for marker in ("previous flight", "vorheriger flug", "上次", "previous flug") + ) + + +def parse_flt_mission_type(flt_path: str) -> str: + """从 .flt 文件 [Main] 段读取 MissionType,默认 FreeFlight。""" + main_fields = parse_flt_main_fields(flt_path) + if not main_fields: + return "FreeFlight" + for key in ("MissionType", "FlightType"): + text = main_fields.get(key, "") + if text: + return text + lower = str(flt_path).lower() + if "missions" in lower or "mission" in Path(flt_path).stem.lower(): + return "Mission" + return "FreeFlight" + + +def parse_mission_title(flt_path: str, _depth: int = 0) -> str: + """从 .flt 的 [Main].Title 读取任务菜单标题;必要时沿 OriginalFlight 追溯。""" + if _depth > 3: + return "" + main_fields = parse_flt_main_fields(flt_path) + if not main_fields: + return "" + mission_type = (main_fields.get("MissionType") or main_fields.get("FlightType") or "FreeFlight").strip() + title = main_fields.get("Title", "").strip() + if mission_type.lower() != "freeflight": + if title and not _is_placeholder_flt_title(title): + return title + original = main_fields.get("OriginalFlight", "").strip() + if original: + resolved = _resolve_flt_path(original, Path(flt_path)) + if resolved.is_file(): + nested = parse_mission_title(str(resolved), _depth + 1) + if nested: + return nested + lower = str(flt_path).lower() + if title and not _is_placeholder_flt_title(title): + if "missions" in lower or "mission" in Path(flt_path).stem.lower(): + return title + return "" diff --git a/setting.py b/setting.py new file mode 100644 index 00000000..7effd5d4 --- /dev/null +++ b/setting.py @@ -0,0 +1,579 @@ +# -*- coding: utf-8 -*- +""" +业务与运行配置:敏感项从 .env 读取,业务常量在本文件维护。 +依赖:python-dotenv(缺失时仅跳过 .env 加载,仍可读进程环境变量) +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + + +def _project_root() -> Path: + """打包运行返回 _MEIPASS,否则返回项目根目录。""" + if getattr(sys, "frozen", False): + return Path(sys._MEIPASS) + return Path(__file__).resolve().parent + + +_ROOT = _project_root() + +try: + from dotenv import load_dotenv + + load_dotenv(_ROOT / ".env") +except ImportError: + pass + + +def _env(key: str, default: str = "") -> str: + return str(os.getenv(key, default)).strip() + + +def _env_int(key: str, default: int) -> int: + raw = _env(key, "") + if not raw: + return int(default) + return int(raw) + + +# --------------------------------------------------------------------------- +# 业务常量(非密钥) +# --------------------------------------------------------------------------- + +CHECK_TIMEOUT_S = 3.0 +AUTH_LOGIN_TIMEOUT_S = float(_env("AUTH_LOGIN_TIMEOUT_S", "8") or "8") +CHECK_NETWORK_URL = _env("CHECK_NETWORK_URL", "https://baidu.com") or "https://baidu.com" +AUTH_USER_TABLE = _env("AUTH_USER_TABLE", "aero_quiz_base_user_info") or "aero_quiz_base_user_info" + +# --------------------------------------------------------------------------- +# 数据库配置(部署/密钥 → .env) +# --------------------------------------------------------------------------- + +auth_db_config = { + "host": _env("AUTH_DB_HOST", "quizbase.cn") or "quizbase.cn", + "port": _env_int("AUTH_DB_PORT", 3306), + "user": _env("AUTH_DB_USER", "flight_assistant") or "flight_assistant", + "password": _env("AUTH_DB_PASSWORD", ""), + "db": _env("AUTH_DB_NAME", "talent_auth_info_schema") or "talent_auth_info_schema", + "charset": _env("AUTH_DB_CHARSET", "utf8mb4") or "utf8mb4", + "connect_timeout": _env_int("AUTH_DB_CONNECT_TIMEOUT", int(AUTH_LOGIN_TIMEOUT_S)), + "read_timeout": _env_int("AUTH_DB_READ_TIMEOUT", int(AUTH_LOGIN_TIMEOUT_S)), + "write_timeout": _env_int("AUTH_DB_WRITE_TIMEOUT", int(AUTH_LOGIN_TIMEOUT_S)), + "autocommit": True, + "ssl_disabled": True, +} + +app_db_config = { + "host": _env("APP_DB_HOST", "127.0.0.1") or "127.0.0.1", + "port": _env_int("APP_DB_PORT", 3306), + "user": _env("APP_DB_USER", "root") or "root", + "password": _env("APP_DB_PASSWORD", ""), + "db": _env("APP_DB_NAME", "flight_assistant") or "flight_assistant", + "charset": _env("APP_DB_CHARSET", "utf8mb4") or "utf8mb4", + "connect_timeout": _env_int("APP_DB_CONNECT_TIMEOUT", 8), + "read_timeout": _env_int("APP_DB_READ_TIMEOUT", 8), + "write_timeout": _env_int("APP_DB_WRITE_TIMEOUT", 8), + "autocommit": True, +} + +# 鉴权可达性探测:与 auth_db_config 同源 +AUTH_SERVER_URL = ( + _env("AUTH_SERVER_URL", "") + or f"http://{auth_db_config['host']}:{auth_db_config['port']}" +) + +# --------------------------------------------------------------------------- +# UI 常量(业务 UI:色板 / 字号 / 几何 / 控件尺寸) +# --------------------------------------------------------------------------- + +WINDOW_ALPHA = 0.85 +LAP_TABLE_ROWS = 4 +# 任务分数板:score = (max_sec - total_s) / (max_sec - min_sec) * scale +MISSION_SCORE_SCALE = 90.0 +MISSION_SCORE_DECIMALS = 2 +MISSION_SCORE_TITLE = "SCORE" +C_CHT = 120.0 +D_TORQUE = 500.0 +E_TORQUE = 550.0 +METRIC_VALUE_DECIMALS = 1 +METRIC_VALUE_INDENT = " " + +CAP_METRICS = "metrics" +CAP_LAPS = "laps" +CAP_CHART = "chart" +CAP_CHART_SAVE = "chart_save" +CAP_COLD_CABIN = "cold_cabin" +CAP_SIM_RATE = "sim_rate" +CAP_SHADOW = "shadow" + +HEALTH_FLASH_MS = 200 +HEALTH_BEEP_BLUE = (750, 900, 60) +HEALTH_BEEP_ORANGE = (900, 140, 260) +HEALTH_BEEP_RED = (1100, 55, 75) +HEALTH_BEEP_PATTERNS = { + "blue": HEALTH_BEEP_BLUE, + "orange": HEALTH_BEEP_ORANGE, + "red": HEALTH_BEEP_RED, +} + +DEFAULT_CAPABILITIES = frozenset( + { + CAP_METRICS, + CAP_LAPS, + CAP_CHART, + CAP_CHART_SAVE, + CAP_COLD_CABIN, + CAP_SIM_RATE, + CAP_SHADOW, + } +) + +FEATURE_WINDOW_SPECS = { + "flow.sim_check": (460, 400, 460, 400), + "flow.auth": (360, 400, 360, 400), + "training_assist": (680, 820, 680, 820), +} + +APP_NAME = "TALENT ACADEMY" +APP_ICON_PATH = "resources/icons/icon.png" +# Windows exe shell icon (multi-size .ico from icon.png; used by build_exe / spec) +APP_EXE_ICON_PATH = "resources/icons/icon.ico" + +# 色板 +UI_COLOR_BLUE_DEEP = "#0B1420" +UI_COLOR_BLUE_MID = "#152A40" +UI_COLOR_BLUE_LIGHT = "#2E4F73" +UI_COLOR_CHART_WELL = "#112233" +UI_COLOR_METRIC_SURFACE = "#101E2E" +UI_COLOR_FG = "#C5D6E8" +UI_COLOR_FG_MUTED = "#7A9BB8" +UI_COLOR_FG_TITLE = "#8AABC8" +UI_COLOR_FG_ON_ACCENT = "#E8F1F8" +UI_COLOR_ACCENT_HOVER = "#3A638F" +UI_COLOR_TOGGLE_TRACK_OFF = "#122436" +UI_COLOR_TOGGLE_KNOB_OFF = "#8FAEC8" +UI_COLOR_SUCCESS = "#2ecc71" +UI_COLOR_WARNING = "#f39c12" +UI_COLOR_ERROR = "#e74c3c" +UI_COLOR_ERROR_HOVER = "#c0392b" +UI_COLOR_FLASH_BLUE = "#1e90ff" +UI_COLOR_CHART_TORQUE = "#4a90d9" +UI_COLOR_CHART_HEALTH = "#e67e22" +UI_COLOR_CHART_AIRSPEED = "#2ecc71" +UI_COLOR_CHART_GROUNDSPEED = "#1abc9c" +UI_COLOR_CHART_SPEED_RATE = "#3498db" +UI_COLOR_CHART_ACCEL_RATE = "#8e44ad" +UI_COLOR_CHART_PITCH = "#f1c40f" +UI_COLOR_CHART_BANK = "#e74c3c" +UI_COLOR_CHART_ETA_PROP = "#00cec9" +UI_COLOR_CHART_DI_PROXY = "#e17055" +UI_COLOR_CHART_VG_COURSE = "#0984e3" +UI_COLOR_CHART_REFERENCE = "#9b59b6" +UI_COLOR_CHART_LAP = ("#4a90d9", "#e67e22", "#2ecc71", "#e74c3c") +# 图表语义色:当前黄 / 历史蓝(浮窗下拉字段、摇杆对比;固定行仍用 FLOAT_DEFAULT) +UI_COLOR_DATA_CURRENT = UI_COLOR_CHART_PITCH +UI_COLOR_DATA_HISTORY = UI_COLOR_FLASH_BLUE +UI_COLOR_CHART_FLOAT_DEFAULT = "#e8edf5" +UI_COLOR_SIGNAL_IDLE = "#4A6278" +UI_COLOR_SIGNAL_RUN = "#5E7F9A" +UI_COLOR_SIGNAL_OK = "#5F8F75" +UI_COLOR_SIGNAL_FAIL = "#946868" +UI_CHART_HEALTH_SPAN_ALPHA = 0.18 +UI_TIMELINE_BG = "#122536" + +# --------------------------------------------------------------------------- +# 统一边框(与时间轴一致;全项目默认) +# --------------------------------------------------------------------------- +UI_BORDER_COLOR = "#3A5F82" +UI_BORDER_WIDTH_PX = 1 +UI_BORDER_RADIUS_PX = 4 +# 特例:MetricCard +UI_BORDER_METRIC_COLOR = "#243D58" +UI_BORDER_METRIC_WIDTH_PX = 2 +UI_BORDER_METRIC_RADIUS_PX = 8 +# 特例:CheckBox 指示器(小方块) +UI_BORDER_CHECKBOX_RADIUS_PX = 2 +UI_COLOR_TOGGLE_TRACK_BORDER = UI_BORDER_COLOR + +# --------------------------------------------------------------------------- +# 按钮非激活态(UI 标准:相对布局底须可辨) +# 对照底:BG(#0B1420) / SURFACE=MID(#152A40) / BLUE_LIGHT(#2E4F73) +# / METRIC(#101E2E) / CHART_WELL(#112233) +# 规则:填充 ≠ 所在底;描边用统一边框色;文字用 FG_MUTED +# --------------------------------------------------------------------------- +UI_COLOR_BTN_DISABLED_BG = "#1E3A56" +UI_COLOR_BTN_DISABLED_FG = UI_COLOR_FG_MUTED +UI_COLOR_BTN_DISABLED_BORDER = UI_BORDER_COLOR + +# --------------------------------------------------------------------------- +# 航迹视窗 自由/锁定钮(叠在 CHART_WELL #112233 上,须可辨) +# 自由:抬起面 + 标准描边;锁定:语义蓝填充 + 同色描边;悬停各自加亮 +# --------------------------------------------------------------------------- +UI_COLOR_TRACK_LOCK_FREE_BG = "#1E3A56" # ≠ WELL/SURFACE,与非激活钮同阶 +UI_COLOR_TRACK_LOCK_FREE_FG = UI_COLOR_FG +UI_COLOR_TRACK_LOCK_FREE_BORDER = UI_BORDER_COLOR +UI_COLOR_TRACK_LOCK_FREE_HOVER_BG = UI_COLOR_BLUE_LIGHT +UI_COLOR_TRACK_LOCK_FREE_HOVER_BORDER = UI_COLOR_ACCENT_HOVER +UI_COLOR_TRACK_LOCK_ON_BG = UI_COLOR_FLASH_BLUE +UI_COLOR_TRACK_LOCK_ON_FG = UI_COLOR_FG_ON_ACCENT +UI_COLOR_TRACK_LOCK_ON_BORDER = UI_COLOR_FLASH_BLUE +UI_COLOR_TRACK_LOCK_ON_HOVER_BG = "#4AA3FF" # 相对 FLASH_BLUE 加亮 +UI_COLOR_TRACK_LOCK_ON_HOVER_BORDER = "#4AA3FF" + +# 字号 +UI_FONT_FAMILY = "Noto Sans SC" +UI_FONT_FAMILY_TECH = "Orbitron" +UI_FONT_XS = 8 +UI_FONT_SM = 9 +UI_FONT_MD = 10 +UI_FONT_LG = 11 +UI_FONT_METRIC_TITLE = 12 +UI_FONT_TITLE = 20 +UI_FONT_CHT = 36 +UI_FONT_LAP_TOTAL = 20 +UI_FONT_RACE_SUCCESS = 18 +UI_FONT_UNIT = 14 +UI_FONT_HINT = 8 + +# 几何 / 对话框基准 +UI_SCREEN_FALLBACK = (1920, 1080) +UI_DIALOG_MIN = (280, 140) +UI_MAIN_WINDOW_H_EXTRA = 180 +UI_DIALOG_THEME_ALERT = (360, 160) +UI_DIALOG_SIM_NOT_FOUND = (380, 180) +UI_DIALOG_START_MONITORING = (420, 450) +UI_DIALOG_TRAJECTORY_SAVE = (480, 200) +UI_DIALOG_TRACK_STORE_PICK = (920, 420) +UI_DIALOG_TRACK_STORE_WAIT = (320, 120) +UI_DIALOG_RACE_SUCCESS = (480, 420) +UI_DIALOG_HARDWARE_SETTINGS = (520, 520) +UI_DIALOG_MISSION_SWITCH = (420, 360) +UI_DIALOG_SIMULATOR_ROOT = (480, 220) +UI_AUTH_WINDOW_BASE = (360, 400) + +# 训练页按钮设计尺寸 +UI_ACTION_BTN_W = 108 +UI_ACTION_BTN_H = 36 +UI_MONITOR_BTN_W = 148 +# 控制器设定按钮(相对 FlatButton 约 80%:padding 8/18→6/14,字号 LG 11→9) +UI_CONTROLLER_SETTINGS_BTN_PAD_V = 6 +UI_CONTROLLER_SETTINGS_BTN_PAD_H = 14 +UI_FONT_CONTROLLER_SETTINGS = 9 + +# --------------------------------------------------------------------------- +# UI 层级间距(字面量 px;全项目 layout 的 margins/spacing 只用下列令牌) +# 自下而上:Frame → Container → widget → overlay +# 图表↔图表 / Canvas 分栏:SPACE_CONTAINER_GAP +# 例外:同一图表内三方库原生边距(pyqtgraph 等)见下方 CHART_MARGIN_* +# --------------------------------------------------------------------------- +SPACE_NONE = 0 +SPACE_FRAME_PAD = 20 # Frame / 页外壳内边距 +SPACE_FRAME_PAD_DIALOG = 24 # 对话框外壳内边距 +SPACE_FRAME_GAP = 14 # Frame 内各大块(容器)间距 +SPACE_CONTAINER_PAD = 8 # Container 内边距 +SPACE_CONTAINER_GAP = 8 # Container↔Container;图表↔图表 +SPACE_WIDGET_GAP = 10 # 同层控件常规间距 +SPACE_WIDGET_GAP_TIGHT = 6 # 同层密排(标签+下拉、开关行) +SPACE_WIDGET_PAD = 6 # 控件内部边距(工具栏等) +SPACE_OVERLAY_PAD = 0 # 蒙版贴齐覆盖 + +# 状态总线分组刷新周期(ms;0 = 仅事件驱动,字面量) +STATE_GROUP_SESSION_MS = 0 +STATE_GROUP_RUNTIME_MS = 0 +STATE_GROUP_LIVE_MS = 100 +# 摇杆 UI 独立于监测采样;更短周期以保持实时手感 +STATE_GROUP_STICK_MS = 50 +STATE_GROUP_TRACK_MS = 100 +STATE_GROUP_CHART_MS = 100 +STATE_GROUP_ANALYZE_MS = 0 +STATE_BUS_ACQUIRE_MS = 100 +# 摇杆采集线程周期(与监测 DATA_POLL 解耦) +STATE_BUS_STICK_MS = 50 + +# --------------------------------------------------------------------------- +# 图表 / 航迹 / 健康:阈值一律预计算字面量(禁止业务侧硬编码或运行时算缩放) +# 扩录三列统计样本:离线合并算得 μ±2σ / P5–P95(n≈379) +# --------------------------------------------------------------------------- + +# 趋势图 Y 轴区间 (ymin, ymax) +CHART_YLIM_AIRSPEED = (0.0, 300.0) +CHART_YLIM_GROUNDSPEED = (0.0, 300.0) +CHART_YLIM_SPEED_RATE = (-40.0, 40.0) +CHART_YLIM_ACCEL_RATE = (-50.0, 50.0) +CHART_YLIM_HEALTH = (65.0, 110.0) +CHART_YLIM_TORQUE = (220.0, 1200.0) +CHART_YLIM_PITCH = (-90.0, 90.0) +CHART_YLIM_BANK = (-180.0, 180.0) +# 推进效率 / 诱导阻 / 沿迹地速:μ±2σ(di_proxy 下沿钳 0) +CHART_YLIM_ETA_PROP = (0.56, 1.07) +CHART_YLIM_DI_PROXY = (0.0, 1070864.0) +CHART_YLIM_VG_COURSE = (159.6, 235.2) + +# 航迹图着色固定区间:合并样本 P5–P95(体现段内变化,抗离群) +TRACK_COLOR_YLIM_ETA_PROP = (0.71, 0.97) +TRACK_COLOR_YLIM_DI_PROXY = (15629.0, 1101826.0) +# 空速 / 地速 / 沿迹地速:色带固定 (lo, hi) kt +TRACK_COLOR_YLIM_SPEED = (160.0, 240.0) +TRACK_COLOR_YLIM_VG_COURSE = TRACK_COLOR_YLIM_SPEED + +# 航迹图其它字段色标阈值 +TRACK_COLOR_SPEED_FLOOR_KTS = 120.0 # 非固定色带字段兜底下限(速度类已改固定区间) +TRACK_COLOR_HEALTH_FLOOR = 95.0 +TRACK_COLOR_HEALTH_PURPLE_BELOW = 70.0 +TRACK_COLOR_HEALTH_PURPLE_RGB = (160, 64, 255) +TRACK_COLOR_BANK_FLOOR = 45.0 +TRACK_COLOR_BANK_CEIL = 110.0 + +# 健康度色带阈值(%) +HEALTH_BOUND_FLASH = 99.5 +HEALTH_BOUND_GREEN = 97.5 +HEALTH_BOUND_ORANGE = 96.5 + +# 趋势图缓冲 / 视窗 +CHART_HISTORY_LEN = 3000 +CHART_VIEW_WINDOW_S = 150.0 +CHART_X_AXIS_MAJOR_SEC = 10.0 +# 槽位取消「显示」时 Y 轴占位标尺(不进下拉;标题空白) +CHART_YLIM_AXIS_IDLE = (0.0, 100.0) +# 折线图外边距:左右为 0(贴容器);上下留给轴标题/标签 +# (同一图表内三方库原生布局例外,不走 SPACE_*) +CHART_MARGIN_Y_LABEL = 0 +CHART_MARGIN_TOP = 8 +CHART_MARGIN_BOTTOM = 28 +# 性能图游标浮窗:底边相对绘图区下沿上移(避免盖住 X 轴时间刻度) +CHART_FLOAT_BOTTOM_GAP_PX = 6 +# 性能图 / 航迹图网格透明度(0–1) +CHART_GRID_ALPHA = 0.40 +# Canvas:图表↔图表间距 = SPACE_CONTAINER_GAP;航迹列宽高比(宽/高) +CHART_CANVAS_GAP_PX = SPACE_CONTAINER_GAP +CHART_TRACK_ASPECT_WH = 1.2 +# 视口 HUD 蒙版(不随 ViewBox 缩放/拖动):透明度、文案 +CHART_VIEWPORT_HUD_ALPHA = 0.70 +CHART_EMPTY_HINT_TRACK = "等待航迹数据" +CHART_EMPTY_HINT_PERF = "等待性能数据" +CHART_LAP_HUD_TEMPLATE = "LAP {lap}" +# 当前层 / 历史对比层透明度(AGENTS:当前 90%、历史 70%) +CHART_LIVE_OPACITY = 0.90 +CHART_HISTORY_OPACITY = 0.70 +# 摇杆包络(凸包填充):低于十字,便于叠看 +STICK_ENVELOPE_LIVE_OPACITY = 0.28 +STICK_ENVELOPE_HISTORY_OPACITY = 0.22 +STICK_ENVELOPE_EDGE_OPACITY = 0.45 +# 航迹叠加圈(当前圈以外):线宽 / 透明度(对齐历史层)/ 线型(与当前实线区分) +TRACK_OVERLAY_LINE_WIDTH = 2.4 +TRACK_OVERLAY_LINE_WIDTH_BG = 5.0 +TRACK_OVERLAY_OPACITY = CHART_HISTORY_OPACITY +TRACK_OVERLAY_LINE_STYLE = "dash" +# 航迹伪彩色:相邻同色桶合并为一条 PlotDataItem(避免一边一图元) +TRACK_COLOR_BUCKETS = 16 +# 航迹视窗锁定(二态):自由 ↔ 锁定(正北居中;锁定态按轨迹包络自动缩放) +TRACK_VIEW_LOCK_OFF = 0 +TRACK_VIEW_LOCK_ON = 1 +TRACK_VIEW_LOCK_LABELS = ("自由", "锁定") +TRACK_VIEW_LOCK_BTN_H = 32 +TRACK_VIEW_LOCK_BTN_W = 52 +TRACK_VIEW_LOCK_BTN_MARGIN = 8 +# 航迹 fit / 锁定自动缩放边距(相对半轴;与 ViewBox setRange padding 同量级) +TRACK_VIEW_FIT_PAD = 0.08 +# 任务配置(Missions/{任务}/config_{任务}.csv:display_name,min_sec,max_sec,flt_rel);缺省时间回退 +# min_sec/max_sec:任务分数板用;性能图缩放另见 CHART_TIME_ZOOM_MIN_SEC(最大窗=任务 max_sec) +CHART_TIME_RULE_DEFAULT_MIN_SEC = 100.0 +CHART_TIME_RULE_DEFAULT_MAX_SEC = 240.0 +# 性能图 X 轴可放大的最小窗宽(秒);放大上限为任务 max_sec +CHART_TIME_ZOOM_MIN_SEC = 10.0 +MISSIONS_DIR = _ROOT / "Missions" +# 模拟器安装根目录:按用户存 Auth UD4(键 USER_SETTINGS_KEY_FSX_ROOT);FLT = {根}/Missions/{flt_rel} +FSX_MISSIONS_DIRNAME = "Missions" +# 默认安装路径侦测:进程名 / 注册表子键 / 常见相对目录名(相对 Program Files) +FSX_DETECT_EXE_NAMES = ("fsx.exe", "fsx_se.exe") +FSX_DETECT_REG_SUBKEYS = ( + r"SOFTWARE\Microsoft\Microsoft Games\Flight Simulator\10.0", + r"SOFTWARE\WOW6432Node\Microsoft\Microsoft Games\Flight Simulator\10.0", +) +FSX_DETECT_REG_VALUE_NAMES = ("AppPath", "SetupPath", "Path") +FSX_DETECT_STEAM_REG_SUBKEYS = ( + r"SOFTWARE\WOW6432Node\Valve\Steam", + r"SOFTWARE\Valve\Steam", +) +FSX_DETECT_STEAM_COMMON_REL = r"steamapps\common\FSX" +FSX_DETECT_PROGRAM_FILES_RELS = ( + r"Microsoft Games\Microsoft Flight Simulator X", + r"Microsoft Flight Simulator X", + "FSX", +) +MSG_FSX_ROOT_DETECTED = "已侦测到默认安装路径,可直接确定或浏览修改" +MSG_FSX_ROOT_NOT_DETECTED = "未侦测到默认安装,请浏览选择模拟器根目录" +# 可切换任务:扫描 config(列表显示名=display_name;圈时板标题仍读 FLT Title) +MSG_MISSION_SWITCH_BUSY = "监测进行中,无法切换任务" +MSG_MISSION_SWITCH_NO_SIM = "未连接模拟器,无法切换任务" +MSG_MISSION_SWITCH_NO_FSX_ROOT = "请设定模拟器根目录路径" +MSG_FSX_ROOT_INVALID = "路径无效或不存在,请重新选择模拟器根目录" +MSG_FSX_ROOT_SAVE_FAIL = "模拟器根目录保存失败" +MSG_MISSION_SWITCH_FAIL = "任务加载失败,请检查 FLT 路径与模拟器根目录配置" + +# 历史比对:与当前任务(Mission 规范名 / FLT Title 解析后)不一致时的提示 +MSG_COMPARE_MISSION_MISMATCH = "当前加载的历史数据与任务不匹配,停止加载。" + +# 航迹标签:{mission_key或显示名}_{YYYYMMDD_HHMMSS}_{时长MMSScc} +TRACK_LABEL_UNTITLED = "untitled" + +# 航迹云存:写入 aero_quiz_base_user_info.user_defined_3(JSON 多层字典);监测中仅内存,保存一次 UPDATE +TRACK_STORE_UD_COLUMN = "user_defined_3" +TRACK_STORE_JSON_VERSION = 2 +TRACK_STORE_MAX_RECORDS = 10 +TRACK_STORE_ID_DISPLAY_LEN = 8 +TRACK_STORE_DB_TIMEOUT_S = float(_env("TRACK_STORE_DB_TIMEOUT_S", "30") or "30") +# 会话模式 → 选轨表 / 圈时板同款英文文案 +SESSION_MODE_LABELS = { + "freeflight": "Free Flight Mode", + "mission_sp": "Mission Mode", + "mission_mp": "Multiplay Mode", +} +# 航迹结果:以分数为准,0 分为失败;保存时写入,列表只读 +TRACK_RESULT_SUCCESS = "success" +TRACK_RESULT_FAIL = "fail" +TRACK_RESULT_LABELS = { + TRACK_RESULT_SUCCESS: "成功", + TRACK_RESULT_FAIL: "失败", +} +MSG_TRACK_STORE_NEED_LOGIN = "未登录,无法保存航迹到云端" +MSG_TRACK_STORE_FULL = "云端航迹已满(最多 {n} 条),请选择要删除的记录后再保存" +MSG_TRACK_STORE_EMPTY = "云端暂无航迹记录" +MSG_TRACK_STORE_LIST_LOADING = "正在读取云端航迹…" +MSG_TRACK_STORE_LIST_FAIL = "读取云端航迹失败:{err}" +MSG_TRACK_STORE_SAVING = "正在保存航迹到云端…" +MSG_TRACK_STORE_SAVE_OK = "航迹已保存到云端" +MSG_TRACK_STORE_SAVE_FAIL = "航迹保存失败" +MSG_TRACK_STORE_SAVE_OK_ID = "航迹已保存到云端\n航迹ID:{track_id}" + +# 用户设置云存:仅 user_defined_4(禁止触碰 user_defined_1/2;本程序不用 5) +USER_SETTINGS_UD_COLUMN = "user_defined_4" +USER_SETTINGS_JSON_VERSION = 1 +USER_SETTINGS_DB_TIMEOUT_S = float(_env("USER_SETTINGS_DB_TIMEOUT_S", "15") or "15") +USER_SETTINGS_KEY_HARDWARE = "hardware_monitor" +# 模拟器(FSX)安装根目录:字符串绝对路径;登录后校验本地目录存在 +USER_SETTINGS_KEY_FSX_ROOT = "fsx_root" +# 会员级别:由其他程序写入 UD4 JSON 最前端;本程序只读、写回时原样保留 +USER_SETTINGS_KEY_MEMBERLEVEL = "memberlevel" +# 当前版本:全功能对 L5 开放(后续按级别做功能门控) +MEMBER_LEVEL_FULL_ACCESS = "L5" +MSG_AUTH_LOGIN_FAILED = "用户名或密码错误" +MSG_USER_SETTINGS_NEED_LOGIN = "未登录,无法保存设置到云端" +MSG_USER_SETTINGS_SAVE_FAIL = "设置保存失败" +MSG_USER_SETTINGS_SAVE_OK = "设置已保存到云端" + +# 图例数值:绝对值小于此用两位小数(避免推进效率显示成 1-1) +TRACK_LEGEND_DECIMAL_BELOW = 10.0 + +# --------------------------------------------------------------------------- +# 轨迹 CSV 采集字段单位(量纲对齐 Docs/空气动力学原理及分析.md §5.2 / §9) +# 全量定义见 models.track_fields.TRACK_FIELD_SPECS;图表轴标题/仪表条从此取 +# --------------------------------------------------------------------------- + +TRACK_FIELD_UNITS = { + # 核心 + "timestamp_s": "s", + "latitude": "deg", + "longitude": "deg", + "altitude_ft": "ft", + "agl_ft": "ft", + "heading_deg": "deg", + "airspeed_kts": "kt", + "groundspeed_kts": "kt", + "pitch_deg": "deg", + "bank_deg": "deg", + "lap": "—", + # A 既有 + "torque": "ft·lb", + "health_pct": "%", + "stick_pitch": "—", + "stick_roll": "—", + "stick_yaw": "—", + "stick_throttle": "—", + # B 扩录 + "tas_kts": "kt", + "vs_fpm": "ft/min", + "rpm": "rpm", + "eng_rpm": "rpm", + "manifold_psi": "psi", + "prop_thrust_lbf": "lbf", + "alpha_deg": "deg", + "beta_deg": "deg", + "g_force": "G", + "weight_lb": "lb", + "ambient_density": "slug/ft³", + "ambient_temp_c": "°C", + "wind_kts": "kt", + "wind_dir_deg": "deg", + "smoke_on": "—", + "prop_beta_deg": "deg", + "prop_lever_pct": "%", + "elevator_deg": "deg", + "aileron_deg": "deg", + "rudder_deg": "deg", + # C 派生 + "p_shaft_w": "W", + "p_prop_w": "W", + "eta_prop": "—", + "e_mech_j": "J", + "di_proxy": "单位强度", + "ds_m": "m", + "vg_course_kts": "kt", +} + +# 图表派生字段(无独立采样列;轴标题/仪表用) +CHART_FIELD_UNITS = { + "speed_rate": "kt/s", + "accel_rate": "kt/s²", + "cht": "F", +} + +# --------------------------------------------------------------------------- +# 航迹采样字段开关(True=采集并写入 Auth 航迹;核心位姿列不可关) +# 全量定义见 models.track_fields.TRACK_FIELD_SPECS;此处可覆盖 default_enabled +# --------------------------------------------------------------------------- + +# 核心列(始终采集,不可关):timestamp_s 任务相对时间 / latitude 纬度 / +# longitude 经度 / altitude_ft 气压高度 / agl_ft 离地高度 / heading_deg 真航向 / +# airspeed_kts 指示空速 / groundspeed_kts 地速 / pitch_deg 俯仰 / bank_deg 坡度 / lap 圈号 +TRACK_RECORD_FIELD_ENABLE = { + # A 既有 + "torque": True, # 扭矩 + "health_pct": True, # 健康度 + "stick_pitch": True, # 摇杆俯仰 + "stick_roll": True, # 摇杆横滚 + "stick_yaw": True, # 摇杆方向 + "stick_throttle": True, # 摇杆油门 + # B 扩录(空气动力学最小集 + 桨距/环境) + "tas_kts": True, # 真空速 + "vs_fpm": True, # 垂直速度 + "rpm": True, # 螺旋桨转速 + "eng_rpm": True, # 发动机转速 + "manifold_psi": True, # 歧管压 + "prop_thrust_lbf": True, # 桨推力 + "alpha_deg": True, # 迎角 + "beta_deg": True, # 侧滑角 + "g_force": True, # 过载 + "weight_lb": True, # 总重 + "ambient_density": True, # 大气密度 + "ambient_temp_c": True, # 外界气温 + "wind_kts": True, # 风速 + "wind_dir_deg": True, # 风向 + "smoke_on": False, # 烟雾 + "prop_beta_deg": True, # 桨叶角 + "prop_lever_pct": True, # 桨距杆 + "elevator_deg": True, # 升降舵偏角 + "aileron_deg": True, # 副翼偏角 + "rudder_deg": True, # 方向舵偏角 + # C 派生(依赖对应 B/A 列) + "p_shaft_w": True, # 轴功率 + "p_prop_w": True, # 推进功率 + "eta_prop": True, # 推进效率 + "e_mech_j": True, # 机械能 + "di_proxy": True, # 诱导阻相对强度 + "ds_m": True, # 航迹微元 + "vg_course_kts": True, # 沿迹地速 +} diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 213897f2..00000000 --- a/setup.cfg +++ /dev/null @@ -1,49 +0,0 @@ -[metadata] -name = SimConnect -version = attr: SimConnect.__version__ -description = Adds a pythonic wrapper for SimConnect SDK. -long_description = file: README.md -long_description_content_type = text/markdown -url = https://github.com/odwdinc/Python-SimConnect -author = Anthony Pray -author_email = anthony.pray@gmail.com -maintainer = Anthony Pray -maintainer_email = anthony.pray@gmail.com -keywords = ctypes, FlightSim, SimConnect, Flight, Simulator -license = AGPL 3.0 -classifiers = - Development Status :: 2 - Pre-Alpha - Environment :: Console - Intended Audience :: Developers - License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+) - Operating System :: OS Independent - Programming Language :: Python - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.6 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: Implementation :: CPython - - -[options] -packages = SimConnect -python_requires = >=3.6 -include_package_data=True -test_suite="tests" - -[flake8] -exclude = .venv,.tox,dist,docs,build,*.egg,env,venv,.undodir - -[bdist_wheel] -universal = 1 - -[build-system] -requires = ["setuptools >= 40.6.0", "wheel"] -build-backend = "setuptools.build_meta" - -[tool:pytest] -minversion = 6.0 -testpaths = - tests -log_level = DEBUG -log_cli_level = DEBUG \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 17ebdb49..00000000 --- a/setup.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python -from setuptools import setup -import distutils.sysconfig - -with open("requirements.txt") as f: - install_requires = f.read()[1:].splitlines()[1:] - -with open("requirements_dev.txt") as f: - tests_require = f.read().splitlines()[1:] - -setup( - install_requires=install_requires, - tests_require=tests_require, - package_data={"": ["*.dll"]} -) \ No newline at end of file diff --git a/static/css/attind_style.css b/static/css/attind_style.css deleted file mode 100644 index 81b89e62..00000000 --- a/static/css/attind_style.css +++ /dev/null @@ -1,332 +0,0 @@ -.casing { - background: #111 url("../img/metal.jpg") repeat; - border: 1px solid #aaa; - border-radius: 10px; - box-shadow: 0 0 10px #000; - padding: 20px; - height: 334px; - width: 334px; -} - -.inner-case { - background: #222; - border: 2px solid #333; - border-radius: 165px; - box-shadow: 0 0 10px #000; - height: 300px; - overflow: hidden; - padding: 15px; -} - -.case-control { - box-shadow: 0px 2px 0px #333; - border-bottom: 1px solid #555; - height: 230px; - overflow: hidden; -} - -/* panel */ -.panel { - box-shadow: 0 0 10px #111; - border-radius: 30px 30px 300px 300px; - background: #333; - color: #aaa; - font-family: Arial; - margin: 13px 0 0 55px; - width: 190px; - height: 40px; -} -.panel ul { - padding: 0; -} -.panel li { - list-style-type: none; - float: left; -} - -/* labels */ -.labels li { - margin: 3px 8px; -} -.labels li:nth-child(3) { - margin-left: 25px; -} - -/* lights */ -.light { - background: #555; - border-radius: 10px; - height: 10px; - margin: 0 23px; - width: 10px; -} -.light:nth-child(3) { - margin-left: 42px; -} -.light.on { - box-shadow: 0 0 8px #3f3; - background: #6f6; -} -.light.off { - box-shadow: 0 0 8px #f33; - background: #f66; -} - -.weight { - position: absolute; - height: 300px; - width: 300px; - z-index: 3; -} - -.aircraft div { - background: #e98219; - border-radius: 5px; - box-shadow: 0 0 6px #000; - position: absolute; - top: 150px; - height: 5px; -} - -/* up-chevron */ -.up-chevron { - position: relative; - top: 50px; - left: 140px; -} -.up-chevron div { - background: #e98219; - border-radius: 5px; - box-shadow: 0 0 6px #000; - position: absolute; - width: 5px; -} -.up-chevron div:nth-child(1), -.up-chevron div:nth-child(2) { - height: 35px; -} -.up-chevron div:nth-child(3) { - height: 25px; -} -.up-chevron div:nth-child(1) { - box-shadow: 2px 0px 2px #555; - transform: rotate(-20deg); - top: 0; - left: 11px; -} -.up-chevron div:nth-child(2) { - box-shadow: -2px 0px 2px #555; - transform: rotate(20deg); - top: 0; - left: 0; -} -.up-chevron div:nth-child(3) { - box-shadow: 2px 0px 2px #555; - transform: rotate(90deg); - top: 19px; - left: 5px; -} - -.left { - left: 80px; - width: 50px; -} - -.centre { - left: 150px; - width: 5px; -} - -.right { - left: 175px; - width: 50px; -} - -.sky { - background: #558ebb; -} - -.terrain { - background: #503723; -} - -.mechanism { - transform: rotate(0deg); /* dynamic */ -} - -/* back */ -.back.sky { - border-radius: 150px 150px 0 0; - height: 150px; - width: 300px; -} -.back.terrain { - border-radius: 0 0 150px 150px; - height: 150px; - width: 300px; -} - -.roll { - border-top: 50px solid #558ebb; - border-right: 50px solid #558ebb; - border-bottom: 50px solid #503723; - border-left: 50px solid #503723; - border-radius: 200px; - box-shadow: inset 0 0 20px #000; - transform: rotate(-45deg); - position: absolute; - top: 0; - left: 0; - height: 200px; - width: 200px; - z-index: 2; -} - -/* roll-lines */ -.roll-lines { - transform: rotate(45deg); - position: absolute; -} -.roll-lines div { - background: #fff; - position: absolute; - height: 4px; - width: 40px; -} - -#ninety-left, -#ninety-right { - width: 50px; - top: -1px; -} -#ninety-left { - left: -9px; -} -#ninety-right { - left: 242px; -} - -#zero { - background: none; - border-left: 15px solid transparent; - border-right: 15px solid transparent; - border-top: 40px solid #fff; - top: -140px; - left: 126px; - height: 0; - width: 0; -} - -#fortyfive-left, -#fortyfive-right { - background: none; - border-left: 5px solid transparent; - border-right: 5px solid transparent; - border-top: 15px solid #fff; - top: -81px; - left: 57px; - height: 0; - width: 0; -} -#fortyfive-left { - transform: rotate(-45deg); -} -#fortyfive-right { - transform: rotate(45deg); - left: 215px; -} -#thirty-left, -#thirty-right { - top: -60px; -} -#thirty-left { - transform: rotate(30deg); - left: 16px; -} -#thirty-right { - transform: rotate(-30deg); - left: 227px; -} -#sixty-left, -#sixty-right { - top: -105px; -} -#sixty-left { - transform: rotate(60deg); - left: 60px; -} -#sixty-right { - transform: rotate(-60deg); - left: 183px; -} - -.ball { - position: absolute; - border-radius: 75px; - box-shadow: 0 0 10px #000; - left: 50px; - top: 75px; /* dynamic */ - z-index: 1; -} - -/* pitch-lines */ -.pitch-lines { - position: absolute; - padding: 8px 70px; -} -.pitch-lines div { - background: #fff; - margin-top: 12px; - height: 2px; - width: 25px; -} -.pitch-lines .small { - margin-left: 18px; -} - -#ten, -#minus-ten { - margin-left: 10px; - width: 40px; -} -#twenty, -#minus-twenty { - width: 60px; -} -#minus-five { - margin-top: 24px; -} - -/* ball */ -.ball .sky { - width: 200px; - height: 75px; - background-image: linear-gradient(top, #558ebb, #9ccbe5); - border-radius: 100px 100px 0 0; - border-bottom: 2px solid #fff; -} -.ball .terrain { - width: 200px; - height: 75px; - background-image: linear-gradient(top, #553a29, #3d2618); - border-radius: 0 0 100px 100px; -} - -.cage-toggle { - background: #222; - border: 2px solid #333; - border-radius: 40px; - box-shadow: 0 0 10px #000; - color: #aaa; - font-family: Arial; - font-size: 10px; - height: 40px; - line-height: 13px; - left: 315px; - padding: 5px; - position: absolute; - top: 315px; - text-align: center; - transform: rotate(-34deg); - width: 40px; -} diff --git a/static/img/metal.jpg b/static/img/metal.jpg deleted file mode 100644 index 9658e28e..00000000 Binary files a/static/img/metal.jpg and /dev/null differ diff --git a/static/img/plane.png b/static/img/plane.png deleted file mode 100644 index e67f2e5b..00000000 Binary files a/static/img/plane.png and /dev/null differ diff --git a/static/js/attind_script.js b/static/js/attind_script.js deleted file mode 100644 index 38da78f2..00000000 --- a/static/js/attind_script.js +++ /dev/null @@ -1,62 +0,0 @@ -// Cache DOM elements -const standby = document.querySelector(".standby"); -const power = document.querySelector(".power"); -const test = document.querySelector(".test"); - -const mechanism = document.querySelector(".mechanism"); -const ball = document.querySelector(".ball"); -let roll; -let pitch; - -standby.classList.remove("off"); -power.classList.add("on"); - -function handleOrientation(event) { - var doc = document, - x = Math.round(event.beta), - z = Math.round(event.gamma); - - var roll = -1*z; - var pitch = 75+x; - - mechanism.style.transform = "rotate(" + roll + "deg)"; - ball.style.top = pitch + "px"; -} - -var temp = false; - -function getSimulatorData(){ - $.getJSON($SCRIPT_ROOT + '/datapoint/PLANE_BANK_DEGREES/get', {}, function(data) { - var z = Math.round(data * (180/Math.PI)); - if ((z > 100) || (z < -100)){ - return; - } - roll = z; - mechanism.style.transform = "rotate(" + roll + "deg)"; - - }); - $.getJSON($SCRIPT_ROOT + '/datapoint/PLANE_PITCH_DEGREES/get', {}, function(data) { - var x = Math.round(data * (180/Math.PI)); - if ((x > 10) || (x < -10)){ - return; - } - pitch = 75+(-5*x); - ball.style.top = pitch + "px"; - }); -} - -function displayData(){ - if (temp){ - temp = false; - //test.classList.remove("on"); - }else{ - temp = true; - //test.classList.add("on"); - } - -} - -window.setInterval(function(){ - getSimulatorData(); - displayData(); -}, 500); diff --git a/static/js/custom/getSimData.js b/static/js/custom/getSimData.js deleted file mode 100644 index 1d34d2d5..00000000 --- a/static/js/custom/getSimData.js +++ /dev/null @@ -1,260 +0,0 @@ -let altitude; -let fuel_percentage; -let vertical_speed; -let compass; -let airspeed; -let latitude; -let longitude; - -let autopilot_master; -let autopilot_nav_selected; -let autopilot_wing_leveler; -let autopilot_heading_lock; -let autopilot_heading_lock_dir; -let autopilot_altitude_lock; -let autopilot_altitude_lock_var; -let autopilot_attitude_hold; -let autopilot_glidescope_hold; -let autopilot_approach_hold; -let autopilot_backcourse_hold; -let autopilot_vertical_hold; -let autopilot_vertical_hold_var; -let autopilot_pitch_hold; -let autopilot_pitch_hold_ref; -let autopilot_flight_director_active; -let autopilot_airspeed_hold; -let autopilot_airspeed_hold_var; - -let gear_handle_position; -let elevator_trim_pct; -let elevator_trim_pct_reversed; -let rudder_trim_pct; -let flaps_handle_pct; -let flaps_handle_pct_reversed; - -let cabin_seatbelts_alert_switch; -let cabin_no_smoking_alert_switch; - -window.setInterval(function(){ - getSimulatorData(); - displayData() - updateMap() -}, 2000); - - -function getSimulatorData() { - $.getJSON($SCRIPT_ROOT + '/ui', {}, function(data) { - - //Navigation - altitude = data.ALTITUDE; - vertical_speed = data.VERTICAL_SPEED; - compass = data.MAGNETIC_COMPASS + data.MAGVAR; - airspeed = data.AIRSPEED_INDICATE; - latitude = data.LATITUDE; - longitude = data.LONGITUDE; - - //Fuel - fuel_percentage = data.FUEL_PERCENTAGE; - - //Autopilot - autopilot_master = data.AUTOPILOT_MASTER; - autopilot_nav_selected = data.AUTOPILOT_NAV_SELECTED; - autopilot_wing_leveler = data.AUTOPILOT_WING_LEVELER; - autopilot_heading_lock = data.AUTOPILOT_HEADING_LOCK; - autopilot_heading_lock_dir = data.AUTOPILOT_HEADING_LOCK_DIR; - autopilot_altitude_lock = data.AUTOPILOT_ALTITUDE_LOCK; - autopilot_altitude_lock_var = data.AUTOPILOT_ALTITUDE_LOCK_VAR; - autopilot_attitude_hold = data.AUTOPILOT_ATTITUDE_HOLD; - autopilot_glidescope_hold = data.AUTOPILOT_GLIDESLOPE_HOLD; - autopilot_approach_hold = data.AUTOPILOT_APPROACH_HOLD; - autopilot_backcourse_hold = data.AUTOPILOT_BACKCOURSE_HOLD; - autopilot_vertical_hold = data.AUTOPILOT_VERTICAL_HOLD - autopilot_vertical_hold_var = data.AUTOPILOT_VERTICAL_HOLD_VAR; - autopilot_pitch_hold = data.AUTOPILOT_PITCH_HOLD; - autopilot_pitch_hold_ref = data.AUTOPILOT_PITCH_HOLD_REF; - autopilot_flight_director_active = data.AUTOPILOT_FLIGHT_DIRECTOR_ACTIVE; - autopilot_airspeed_hold = data.AUTOPILOT_AIRSPEED_HOLD; - autopilot_airspeed_hold_var = data.AUTOPILOT_AIRSPEED_HOLD_VAR; - - //Control surfaces - gear_handle_position = data.GEAR_HANDLE_POSITION; - elevator_trim_pct = data.ELEVATOR_TRIM_PCT; - elevator_trim_pct_reversed = - elevator_trim_pct - //rudder_trim_pct = data.RUDDER_TRIM_PCT; - flaps_handle_pct = data.FLAPS_HANDLE_PERCENT; - flaps_handle_pct_reversed = - flaps_handle_pct; - - //Cabin - cabin_no_smoking_alert_switch = data.CABIN_NO_SMOKING_ALERT_SWITCH; - cabin_seatbelts_alert_switch = data.CABIN_SEATBELTS_ALERT_SWITCH; - - }); - return false; -} - - -function displayData() { - //Navigation - $("#altitude").text(altitude); - $("#compass").text(compass); - $("#vertical-speed").text(vertical_speed); - $("#airspeed").text(airspeed); - - //Fuel - $("#fuel-percentage").text(fuel_percentage); - $("#fuel-percentage-bar").css("width", fuel_percentage+"%"); - - //Autopilot - checkAndUpdateButton("#autopilot-master", autopilot_master, "Engaged", "Disengaged"); - checkAndUpdateButton("#autopilot-wing-leveler", autopilot_wing_leveler); - checkAndUpdateButton("#autopilot-heading-lock", autopilot_heading_lock); - checkAndUpdateButton("#autopilot-altitude-lock", autopilot_altitude_lock); - checkAndUpdateButton("#autopilot-airspeed-hold", autopilot_airspeed_hold); - checkAndUpdateButton("#autopilot-attitude-hold", autopilot_attitude_hold); - checkAndUpdateButton("#autopilot-backcourse-hold", autopilot_backcourse_hold); - checkAndUpdateButton("#autopilot-approach-hold", autopilot_approach_hold); - checkAndUpdateButton("#autopilot-vertical-hold", autopilot_vertical_hold); - - $("#autopilot-heading-lock-dir").attr('placeholder', autopilot_heading_lock_dir); - $("#autopilot-altitude-lock-var").attr('placeholder', autopilot_altitude_lock_var); - $("#autopilot-airspeed-hold-var").attr('placeholder', autopilot_airspeed_hold_var); - $("#autopilot-pitch-hold-ref").attr('placeholder', autopilot_pitch_hold_ref); - $("#autopilot-vertical-hold-ref").attr('placeholder', autopilot_vertical_hold_var); - - //Control surfaces - $("#gear-handle-position").html(gear_handle_position); - if (gear_handle_position === "UP"){ - $("#gear-handle-position").removeClass("btn-success").addClass("btn-danger"); - } else { - $("#gear-handle-position").removeClass("btn-danger").addClass("btn-success"); - } - - $("#flaps-handle-pct").text(flaps_handle_pct); - $("#flaps-slider").slider({values: [flaps_handle_pct_reversed]}) - - $("#elevator-trim-pct").text(elevator_trim_pct); - $("#elevator-trim-slider").slider({values: [elevator_trim_pct_reversed]}) - - //$("#rudder-trim-pct").text(rudder_trim_pct); - //$("#rudder-trim-slider").slider({values: [rudder_trim_pct]}) - - //Cabin - if (cabin_seatbelts_alert_switch === 1){ - $("#seatbelt-sign").removeClass("btn-outline-danger").addClass("btn-danger").html("Seatbelt sign on"); - } else { - $("#seatbelt-sign").removeClass("btn-danger").addClass("btn-outline-danger").html("Seatbelt sign off"); - } - - if (cabin_no_smoking_alert_switch === 1){ - $("#no-smoking-sign").removeClass("btn-outline-danger").addClass("btn-danger").html("No smoking sign on"); - } else { - $("#no-smoking-sign").removeClass("btn-danger").addClass("btn-outline-danger").html("No smoking sign off"); - }} - -function checkAndUpdateButton(buttonName, variableToCheck, onText="On", offText="Off") { - if (variableToCheck === 1) { - $(buttonName).removeClass("btn-danger").addClass("btn-success").html(onText); - } else { - $(buttonName).removeClass("btn-success").addClass("btn-danger").html(offText); - } -} - - -function toggleFollowPlane() { - followPlane = !followPlane; - if (followPlane === true) { - $("#followMode").text("Moving map enabled") - $("#followModeButton").removeClass("btn-outline-danger").addClass("btn-primary") - } - if (followPlane === false) { - $("#followMode").text("Moving map disabled") - $("#followModeButton").removeClass("btn-primary").addClass("btn-outline-danger") - } -} - -function updateMap() { - var pos = L.latLng(latitude, longitude); - - marker.slideTo( pos, { - duration: 1500, - }); - marker.setRotationAngle(compass); - - if (followPlane === true) { - map.panTo(pos); - } -} - -function setSimDatapoint(datapointToSet, valueToUse) { - url_to_call = "/datapoint/"+datapointToSet+"/set"; - $.post( url_to_call, { value_to_use: valueToUse } ); -} - -function triggerSimEvent(eventToTrigger, valueToUse, hideAlert = false){ - url_to_call = "/event/"+eventToTrigger+"/trigger"; - $.post( url_to_call, { value_to_use: valueToUse } ); - - if (!hideAlert) { - temporaryAlert('', "Sending instruction", "success") - } -} - -function triggerSimEventFromField(eventToTrigger, fieldToUse, messageToDisplay = null){ - // Get the field and the value in there - fieldToUse = "#" + fieldToUse - valueToUse = $(fieldToUse).val(); - - // Pass it to the API - url_to_call = "/event/"+eventToTrigger+"/trigger"; - $.post( url_to_call, { value_to_use: valueToUse } ); - - // Clear the field so it can be repopulated with the placeholder - $(fieldToUse).val("") - - if (messageToDisplay) { - temporaryAlert('', messageToDisplay + " to " + valueToUse, "success") - } - -} - -function triggerCustomEmergency(emergency_type) { - url_to_call = "/custom_emergency/" + emergency_type - $.post (url_to_call) - - if (emergency_type === "random_engine_fire") { - temporaryAlert("Fire!", "Random engine fire trigger sent", "error") - } -} - - -function temporaryAlert(title, message, icon) { - let timerInterval - - Swal.fire({ - title: title, - html: message, - icon: icon, - timer: 2000, - timerProgressBar: true, - onBeforeOpen: () => { - Swal.showLoading() - timerInterval = setInterval(() => { - const content = Swal.getContent() - if (content) { - const b = content.querySelector('b') - if (b) { - b.textContent = Swal.getTimerLeft() - } - } - }, 100) - }, - onClose: () => { - clearInterval(timerInterval) - } - }).then((result) => { - /* Read more about handling dismissals below */ - if (result.dismiss === Swal.DismissReason.timer) { - console.log('I was closed by the timer') - } - }) -} \ No newline at end of file diff --git a/static/js/custom/plane.png b/static/js/custom/plane.png deleted file mode 100644 index e67f2e5b..00000000 Binary files a/static/js/custom/plane.png and /dev/null differ diff --git a/static/vendor/bootstrap/bootstrap.min.css b/static/vendor/bootstrap/bootstrap.min.css deleted file mode 100644 index 21d10bad..00000000 --- a/static/vendor/bootstrap/bootstrap.min.css +++ /dev/null @@ -1,7 +0,0 @@ -/*! - * Bootstrap v4.5.2 (https://getbootstrap.com/) - * Copyright 2011-2020 The Bootstrap Authors - * Copyright 2011-2020 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - */:root{--blue:#007bff;--indigo:#6610f2;--purple:#6f42c1;--pink:#e83e8c;--red:#dc3545;--orange:#fd7e14;--yellow:#ffc107;--green:#28a745;--teal:#20c997;--cyan:#17a2b8;--white:#fff;--gray:#6c757d;--gray-dark:#343a40;--primary:#007bff;--secondary:#6c757d;--success:#28a745;--info:#17a2b8;--warning:#ffc107;--danger:#dc3545;--light:#f8f9fa;--dark:#343a40;--breakpoint-xs:0;--breakpoint-sm:576px;--breakpoint-md:768px;--breakpoint-lg:992px;--breakpoint-xl:1200px;--font-family-sans-serif:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-family-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace}*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:left;background-color:#fff}[tabindex="-1"]:focus:not(:focus-visible){outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[data-original-title],abbr[title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#007bff;text-decoration:none;background-color:transparent}a:hover{color:#0056b3;text-decoration:underline}a:not([href]):not([class]){color:inherit;text-decoration:none}a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto;-ms-overflow-style:scrollbar}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg{overflow:hidden;vertical-align:middle}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#6c757d;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none!important}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:2.5rem}.h2,h2{font-size:2rem}.h3,h3{font-size:1.75rem}.h4,h4{font-size:1.5rem}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:6rem;font-weight:300;line-height:1.2}.display-2{font-size:5.5rem;font-weight:300;line-height:1.2}.display-3{font-size:4.5rem;font-weight:300;line-height:1.2}.display-4{font-size:3.5rem;font-weight:300;line-height:1.2}hr{margin-top:1rem;margin-bottom:1rem;border:0;border-top:1px solid rgba(0,0,0,.1)}.small,small{font-size:80%;font-weight:400}.mark,mark{padding:.2em;background-color:#fcf8e3}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:90%;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote-footer{display:block;font-size:80%;color:#6c757d}.blockquote-footer::before{content:"\2014\00A0"}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid #dee2e6;border-radius:.25rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:90%;color:#6c757d}code{font-size:87.5%;color:#e83e8c;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:87.5%;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:100%;font-weight:700}pre{display:block;font-size:87.5%;color:#212529}pre code{font-size:inherit;color:inherit;word-break:normal}.pre-scrollable{max-height:340px;overflow-y:scroll}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}.row{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-15px;margin-left:-15px}.no-gutters{margin-right:0;margin-left:0}.no-gutters>.col,.no-gutters>[class*=col-]{padding-right:0;padding-left:0}.col,.col-1,.col-10,.col-11,.col-12,.col-2,.col-3,.col-4,.col-5,.col-6,.col-7,.col-8,.col-9,.col-auto,.col-lg,.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-auto,.col-md,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-auto,.col-sm,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-auto,.col-xl,.col-xl-1,.col-xl-10,.col-xl-11,.col-xl-12,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-auto{position:relative;width:100%;padding-right:15px;padding-left:15px}.col{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-3>*{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.row-cols-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-6>*{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-first{-ms-flex-order:-1;order:-1}.order-last{-ms-flex-order:13;order:13}.order-0{-ms-flex-order:0;order:0}.order-1{-ms-flex-order:1;order:1}.order-2{-ms-flex-order:2;order:2}.order-3{-ms-flex-order:3;order:3}.order-4{-ms-flex-order:4;order:4}.order-5{-ms-flex-order:5;order:5}.order-6{-ms-flex-order:6;order:6}.order-7{-ms-flex-order:7;order:7}.order-8{-ms-flex-order:8;order:8}.order-9{-ms-flex-order:9;order:9}.order-10{-ms-flex-order:10;order:10}.order-11{-ms-flex-order:11;order:11}.order-12{-ms-flex-order:12;order:12}.offset-1{margin-left:8.333333%}.offset-2{margin-left:16.666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.333333%}.offset-5{margin-left:41.666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.333333%}.offset-8{margin-left:66.666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.333333%}.offset-11{margin-left:91.666667%}@media (min-width:576px){.col-sm{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-sm-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-sm-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-sm-3>*{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.row-cols-sm-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-sm-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-sm-6>*{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-sm-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-sm-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-sm-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-sm-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-sm-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-sm-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-sm-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-sm-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-sm-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-sm-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-sm-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-sm-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-sm-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-sm-first{-ms-flex-order:-1;order:-1}.order-sm-last{-ms-flex-order:13;order:13}.order-sm-0{-ms-flex-order:0;order:0}.order-sm-1{-ms-flex-order:1;order:1}.order-sm-2{-ms-flex-order:2;order:2}.order-sm-3{-ms-flex-order:3;order:3}.order-sm-4{-ms-flex-order:4;order:4}.order-sm-5{-ms-flex-order:5;order:5}.order-sm-6{-ms-flex-order:6;order:6}.order-sm-7{-ms-flex-order:7;order:7}.order-sm-8{-ms-flex-order:8;order:8}.order-sm-9{-ms-flex-order:9;order:9}.order-sm-10{-ms-flex-order:10;order:10}.order-sm-11{-ms-flex-order:11;order:11}.order-sm-12{-ms-flex-order:12;order:12}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.333333%}.offset-sm-2{margin-left:16.666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.333333%}.offset-sm-5{margin-left:41.666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.333333%}.offset-sm-8{margin-left:66.666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.333333%}.offset-sm-11{margin-left:91.666667%}}@media (min-width:768px){.col-md{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-md-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-md-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-md-3>*{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.row-cols-md-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-md-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-md-6>*{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-md-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-md-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-md-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-md-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-md-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-md-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-md-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-md-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-md-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-md-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-md-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-md-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-md-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-md-first{-ms-flex-order:-1;order:-1}.order-md-last{-ms-flex-order:13;order:13}.order-md-0{-ms-flex-order:0;order:0}.order-md-1{-ms-flex-order:1;order:1}.order-md-2{-ms-flex-order:2;order:2}.order-md-3{-ms-flex-order:3;order:3}.order-md-4{-ms-flex-order:4;order:4}.order-md-5{-ms-flex-order:5;order:5}.order-md-6{-ms-flex-order:6;order:6}.order-md-7{-ms-flex-order:7;order:7}.order-md-8{-ms-flex-order:8;order:8}.order-md-9{-ms-flex-order:9;order:9}.order-md-10{-ms-flex-order:10;order:10}.order-md-11{-ms-flex-order:11;order:11}.order-md-12{-ms-flex-order:12;order:12}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.333333%}.offset-md-2{margin-left:16.666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.333333%}.offset-md-5{margin-left:41.666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.333333%}.offset-md-8{margin-left:66.666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.333333%}.offset-md-11{margin-left:91.666667%}}@media (min-width:992px){.col-lg{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-lg-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-lg-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-lg-3>*{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.row-cols-lg-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-lg-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-lg-6>*{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-lg-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-lg-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-lg-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-lg-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-lg-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-lg-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-lg-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-lg-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-lg-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-lg-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-lg-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-lg-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-lg-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-lg-first{-ms-flex-order:-1;order:-1}.order-lg-last{-ms-flex-order:13;order:13}.order-lg-0{-ms-flex-order:0;order:0}.order-lg-1{-ms-flex-order:1;order:1}.order-lg-2{-ms-flex-order:2;order:2}.order-lg-3{-ms-flex-order:3;order:3}.order-lg-4{-ms-flex-order:4;order:4}.order-lg-5{-ms-flex-order:5;order:5}.order-lg-6{-ms-flex-order:6;order:6}.order-lg-7{-ms-flex-order:7;order:7}.order-lg-8{-ms-flex-order:8;order:8}.order-lg-9{-ms-flex-order:9;order:9}.order-lg-10{-ms-flex-order:10;order:10}.order-lg-11{-ms-flex-order:11;order:11}.order-lg-12{-ms-flex-order:12;order:12}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.333333%}.offset-lg-2{margin-left:16.666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.333333%}.offset-lg-5{margin-left:41.666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.333333%}.offset-lg-8{margin-left:66.666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.333333%}.offset-lg-11{margin-left:91.666667%}}@media (min-width:1200px){.col-xl{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-xl-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-xl-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-xl-3>*{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.row-cols-xl-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-xl-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-xl-6>*{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-xl-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-xl-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-xl-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-xl-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-xl-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-xl-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-xl-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-xl-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-xl-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-xl-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-xl-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-xl-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-xl-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-xl-first{-ms-flex-order:-1;order:-1}.order-xl-last{-ms-flex-order:13;order:13}.order-xl-0{-ms-flex-order:0;order:0}.order-xl-1{-ms-flex-order:1;order:1}.order-xl-2{-ms-flex-order:2;order:2}.order-xl-3{-ms-flex-order:3;order:3}.order-xl-4{-ms-flex-order:4;order:4}.order-xl-5{-ms-flex-order:5;order:5}.order-xl-6{-ms-flex-order:6;order:6}.order-xl-7{-ms-flex-order:7;order:7}.order-xl-8{-ms-flex-order:8;order:8}.order-xl-9{-ms-flex-order:9;order:9}.order-xl-10{-ms-flex-order:10;order:10}.order-xl-11{-ms-flex-order:11;order:11}.order-xl-12{-ms-flex-order:12;order:12}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.333333%}.offset-xl-2{margin-left:16.666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.333333%}.offset-xl-5{margin-left:41.666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.333333%}.offset-xl-8{margin-left:66.666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.333333%}.offset-xl-11{margin-left:91.666667%}}.table{width:100%;margin-bottom:1rem;color:#212529}.table td,.table th{padding:.75rem;vertical-align:top;border-top:1px solid #dee2e6}.table thead th{vertical-align:bottom;border-bottom:2px solid #dee2e6}.table tbody+tbody{border-top:2px solid #dee2e6}.table-sm td,.table-sm th{padding:.3rem}.table-bordered{border:1px solid #dee2e6}.table-bordered td,.table-bordered th{border:1px solid #dee2e6}.table-bordered thead td,.table-bordered thead th{border-bottom-width:2px}.table-borderless tbody+tbody,.table-borderless td,.table-borderless th,.table-borderless thead th{border:0}.table-striped tbody tr:nth-of-type(odd){background-color:rgba(0,0,0,.05)}.table-hover tbody tr:hover{color:#212529;background-color:rgba(0,0,0,.075)}.table-primary,.table-primary>td,.table-primary>th{background-color:#b8daff}.table-primary tbody+tbody,.table-primary td,.table-primary th,.table-primary thead th{border-color:#7abaff}.table-hover .table-primary:hover{background-color:#9fcdff}.table-hover .table-primary:hover>td,.table-hover .table-primary:hover>th{background-color:#9fcdff}.table-secondary,.table-secondary>td,.table-secondary>th{background-color:#d6d8db}.table-secondary tbody+tbody,.table-secondary td,.table-secondary th,.table-secondary thead th{border-color:#b3b7bb}.table-hover .table-secondary:hover{background-color:#c8cbcf}.table-hover .table-secondary:hover>td,.table-hover .table-secondary:hover>th{background-color:#c8cbcf}.table-success,.table-success>td,.table-success>th{background-color:#c3e6cb}.table-success tbody+tbody,.table-success td,.table-success th,.table-success thead th{border-color:#8fd19e}.table-hover .table-success:hover{background-color:#b1dfbb}.table-hover .table-success:hover>td,.table-hover .table-success:hover>th{background-color:#b1dfbb}.table-info,.table-info>td,.table-info>th{background-color:#bee5eb}.table-info tbody+tbody,.table-info td,.table-info th,.table-info thead th{border-color:#86cfda}.table-hover .table-info:hover{background-color:#abdde5}.table-hover .table-info:hover>td,.table-hover .table-info:hover>th{background-color:#abdde5}.table-warning,.table-warning>td,.table-warning>th{background-color:#ffeeba}.table-warning tbody+tbody,.table-warning td,.table-warning th,.table-warning thead th{border-color:#ffdf7e}.table-hover .table-warning:hover{background-color:#ffe8a1}.table-hover .table-warning:hover>td,.table-hover .table-warning:hover>th{background-color:#ffe8a1}.table-danger,.table-danger>td,.table-danger>th{background-color:#f5c6cb}.table-danger tbody+tbody,.table-danger td,.table-danger th,.table-danger thead th{border-color:#ed969e}.table-hover .table-danger:hover{background-color:#f1b0b7}.table-hover .table-danger:hover>td,.table-hover .table-danger:hover>th{background-color:#f1b0b7}.table-light,.table-light>td,.table-light>th{background-color:#fdfdfe}.table-light tbody+tbody,.table-light td,.table-light th,.table-light thead th{border-color:#fbfcfc}.table-hover .table-light:hover{background-color:#ececf6}.table-hover .table-light:hover>td,.table-hover .table-light:hover>th{background-color:#ececf6}.table-dark,.table-dark>td,.table-dark>th{background-color:#c6c8ca}.table-dark tbody+tbody,.table-dark td,.table-dark th,.table-dark thead th{border-color:#95999c}.table-hover .table-dark:hover{background-color:#b9bbbe}.table-hover .table-dark:hover>td,.table-hover .table-dark:hover>th{background-color:#b9bbbe}.table-active,.table-active>td,.table-active>th{background-color:rgba(0,0,0,.075)}.table-hover .table-active:hover{background-color:rgba(0,0,0,.075)}.table-hover .table-active:hover>td,.table-hover .table-active:hover>th{background-color:rgba(0,0,0,.075)}.table .thead-dark th{color:#fff;background-color:#343a40;border-color:#454d55}.table .thead-light th{color:#495057;background-color:#e9ecef;border-color:#dee2e6}.table-dark{color:#fff;background-color:#343a40}.table-dark td,.table-dark th,.table-dark thead th{border-color:#454d55}.table-dark.table-bordered{border:0}.table-dark.table-striped tbody tr:nth-of-type(odd){background-color:rgba(255,255,255,.05)}.table-dark.table-hover tbody tr:hover{color:#fff;background-color:rgba(255,255,255,.075)}@media (max-width:575.98px){.table-responsive-sm{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-sm>.table-bordered{border:0}}@media (max-width:767.98px){.table-responsive-md{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-md>.table-bordered{border:0}}@media (max-width:991.98px){.table-responsive-lg{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-lg>.table-bordered{border:0}}@media (max-width:1199.98px){.table-responsive-xl{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-xl>.table-bordered{border:0}}.table-responsive{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive>.table-bordered{border:0}.form-control{display:block;width:100%;height:calc(1.5em + .75rem + 2px);padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#495057;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control::-ms-expand{background-color:transparent;border:0}.form-control:-moz-focusring{color:transparent;text-shadow:0 0 0 #495057}.form-control:focus{color:#495057;background-color:#fff;border-color:#80bdff;outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.form-control::-webkit-input-placeholder{color:#6c757d;opacity:1}.form-control::-moz-placeholder{color:#6c757d;opacity:1}.form-control:-ms-input-placeholder{color:#6c757d;opacity:1}.form-control::-ms-input-placeholder{color:#6c757d;opacity:1}.form-control::placeholder{color:#6c757d;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#e9ecef;opacity:1}input[type=date].form-control,input[type=datetime-local].form-control,input[type=month].form-control,input[type=time].form-control{-webkit-appearance:none;-moz-appearance:none;appearance:none}select.form-control:focus::-ms-value{color:#495057;background-color:#fff}.form-control-file,.form-control-range{display:block;width:100%}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.25rem;line-height:1.5}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.875rem;line-height:1.5}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;font-size:1rem;line-height:1.5;color:#212529;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{height:calc(1.5em + .5rem + 2px);padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.form-control-lg{height:calc(1.5em + 1rem + 2px);padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}select.form-control[multiple],select.form-control[size]{height:auto}textarea.form-control{height:auto}.form-group{margin-bottom:1rem}.form-text{display:block;margin-top:.25rem}.form-row{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-5px;margin-left:-5px}.form-row>.col,.form-row>[class*=col-]{padding-right:5px;padding-left:5px}.form-check{position:relative;display:block;padding-left:1.25rem}.form-check-input{position:absolute;margin-top:.3rem;margin-left:-1.25rem}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{color:#6c757d}.form-check-label{margin-bottom:0}.form-check-inline{display:-ms-inline-flexbox;display:inline-flex;-ms-flex-align:center;align-items:center;padding-left:0;margin-right:.75rem}.form-check-inline .form-check-input{position:static;margin-top:0;margin-right:.3125rem;margin-left:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:80%;color:#28a745}.valid-tooltip{position:absolute;top:100%;left:0;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;line-height:1.5;color:#fff;background-color:rgba(40,167,69,.9);border-radius:.25rem}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:#28a745;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%2328a745' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#28a745;box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.custom-select.is-valid,.was-validated .custom-select:valid{border-color:#28a745;padding-right:calc(.75em + 2.3125rem);background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px,url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%2328a745' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e") #fff no-repeat center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem)}.custom-select.is-valid:focus,.was-validated .custom-select:valid:focus{border-color:#28a745;box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#28a745}.form-check-input.is-valid~.valid-feedback,.form-check-input.is-valid~.valid-tooltip,.was-validated .form-check-input:valid~.valid-feedback,.was-validated .form-check-input:valid~.valid-tooltip{display:block}.custom-control-input.is-valid~.custom-control-label,.was-validated .custom-control-input:valid~.custom-control-label{color:#28a745}.custom-control-input.is-valid~.custom-control-label::before,.was-validated .custom-control-input:valid~.custom-control-label::before{border-color:#28a745}.custom-control-input.is-valid:checked~.custom-control-label::before,.was-validated .custom-control-input:valid:checked~.custom-control-label::before{border-color:#34ce57;background-color:#34ce57}.custom-control-input.is-valid:focus~.custom-control-label::before,.was-validated .custom-control-input:valid:focus~.custom-control-label::before{box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.custom-control-input.is-valid:focus:not(:checked)~.custom-control-label::before,.was-validated .custom-control-input:valid:focus:not(:checked)~.custom-control-label::before{border-color:#28a745}.custom-file-input.is-valid~.custom-file-label,.was-validated .custom-file-input:valid~.custom-file-label{border-color:#28a745}.custom-file-input.is-valid:focus~.custom-file-label,.was-validated .custom-file-input:valid:focus~.custom-file-label{border-color:#28a745;box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:80%;color:#dc3545}.invalid-tooltip{position:absolute;top:100%;left:0;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;line-height:1.5;color:#fff;background-color:rgba(220,53,69,.9);border-radius:.25rem}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:#dc3545;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc3545' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.custom-select.is-invalid,.was-validated .custom-select:invalid{border-color:#dc3545;padding-right:calc(.75em + 2.3125rem);background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px,url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc3545' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e") #fff no-repeat center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem)}.custom-select.is-invalid:focus,.was-validated .custom-select:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#dc3545}.form-check-input.is-invalid~.invalid-feedback,.form-check-input.is-invalid~.invalid-tooltip,.was-validated .form-check-input:invalid~.invalid-feedback,.was-validated .form-check-input:invalid~.invalid-tooltip{display:block}.custom-control-input.is-invalid~.custom-control-label,.was-validated .custom-control-input:invalid~.custom-control-label{color:#dc3545}.custom-control-input.is-invalid~.custom-control-label::before,.was-validated .custom-control-input:invalid~.custom-control-label::before{border-color:#dc3545}.custom-control-input.is-invalid:checked~.custom-control-label::before,.was-validated .custom-control-input:invalid:checked~.custom-control-label::before{border-color:#e4606d;background-color:#e4606d}.custom-control-input.is-invalid:focus~.custom-control-label::before,.was-validated .custom-control-input:invalid:focus~.custom-control-label::before{box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.custom-control-input.is-invalid:focus:not(:checked)~.custom-control-label::before,.was-validated .custom-control-input:invalid:focus:not(:checked)~.custom-control-label::before{border-color:#dc3545}.custom-file-input.is-invalid~.custom-file-label,.was-validated .custom-file-input:invalid~.custom-file-label{border-color:#dc3545}.custom-file-input.is-invalid:focus~.custom-file-label,.was-validated .custom-file-input:invalid:focus~.custom-file-label{border-color:#dc3545;box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.form-inline{display:-ms-flexbox;display:flex;-ms-flex-flow:row wrap;flex-flow:row wrap;-ms-flex-align:center;align-items:center}.form-inline .form-check{width:100%}@media (min-width:576px){.form-inline label{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;margin-bottom:0}.form-inline .form-group{display:-ms-flexbox;display:flex;-ms-flex:0 0 auto;flex:0 0 auto;-ms-flex-flow:row wrap;flex-flow:row wrap;-ms-flex-align:center;align-items:center;margin-bottom:0}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-plaintext{display:inline-block}.form-inline .custom-select,.form-inline .input-group{width:auto}.form-inline .form-check{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;width:auto;padding-left:0}.form-inline .form-check-input{position:relative;-ms-flex-negative:0;flex-shrink:0;margin-top:0;margin-right:.25rem;margin-left:0}.form-inline .custom-control{-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center}.form-inline .custom-control-label{margin-bottom:0}}.btn{display:inline-block;font-weight:400;color:#212529;text-align:center;vertical-align:middle;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-color:transparent;border:1px solid transparent;padding:.375rem .75rem;font-size:1rem;line-height:1.5;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:#212529;text-decoration:none}.btn.focus,.btn:focus{outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.btn.disabled,.btn:disabled{opacity:.65}.btn:not(:disabled):not(.disabled){cursor:pointer}a.btn.disabled,fieldset:disabled a.btn{pointer-events:none}.btn-primary{color:#fff;background-color:#007bff;border-color:#007bff}.btn-primary:hover{color:#fff;background-color:#0069d9;border-color:#0062cc}.btn-primary.focus,.btn-primary:focus{color:#fff;background-color:#0069d9;border-color:#0062cc;box-shadow:0 0 0 .2rem rgba(38,143,255,.5)}.btn-primary.disabled,.btn-primary:disabled{color:#fff;background-color:#007bff;border-color:#007bff}.btn-primary:not(:disabled):not(.disabled).active,.btn-primary:not(:disabled):not(.disabled):active,.show>.btn-primary.dropdown-toggle{color:#fff;background-color:#0062cc;border-color:#005cbf}.btn-primary:not(:disabled):not(.disabled).active:focus,.btn-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(38,143,255,.5)}.btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:hover{color:#fff;background-color:#5a6268;border-color:#545b62}.btn-secondary.focus,.btn-secondary:focus{color:#fff;background-color:#5a6268;border-color:#545b62;box-shadow:0 0 0 .2rem rgba(130,138,145,.5)}.btn-secondary.disabled,.btn-secondary:disabled{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:not(:disabled):not(.disabled).active,.btn-secondary:not(:disabled):not(.disabled):active,.show>.btn-secondary.dropdown-toggle{color:#fff;background-color:#545b62;border-color:#4e555b}.btn-secondary:not(:disabled):not(.disabled).active:focus,.btn-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(130,138,145,.5)}.btn-success{color:#fff;background-color:#28a745;border-color:#28a745}.btn-success:hover{color:#fff;background-color:#218838;border-color:#1e7e34}.btn-success.focus,.btn-success:focus{color:#fff;background-color:#218838;border-color:#1e7e34;box-shadow:0 0 0 .2rem rgba(72,180,97,.5)}.btn-success.disabled,.btn-success:disabled{color:#fff;background-color:#28a745;border-color:#28a745}.btn-success:not(:disabled):not(.disabled).active,.btn-success:not(:disabled):not(.disabled):active,.show>.btn-success.dropdown-toggle{color:#fff;background-color:#1e7e34;border-color:#1c7430}.btn-success:not(:disabled):not(.disabled).active:focus,.btn-success:not(:disabled):not(.disabled):active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(72,180,97,.5)}.btn-info{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-info:hover{color:#fff;background-color:#138496;border-color:#117a8b}.btn-info.focus,.btn-info:focus{color:#fff;background-color:#138496;border-color:#117a8b;box-shadow:0 0 0 .2rem rgba(58,176,195,.5)}.btn-info.disabled,.btn-info:disabled{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-info:not(:disabled):not(.disabled).active,.btn-info:not(:disabled):not(.disabled):active,.show>.btn-info.dropdown-toggle{color:#fff;background-color:#117a8b;border-color:#10707f}.btn-info:not(:disabled):not(.disabled).active:focus,.btn-info:not(:disabled):not(.disabled):active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(58,176,195,.5)}.btn-warning{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-warning:hover{color:#212529;background-color:#e0a800;border-color:#d39e00}.btn-warning.focus,.btn-warning:focus{color:#212529;background-color:#e0a800;border-color:#d39e00;box-shadow:0 0 0 .2rem rgba(222,170,12,.5)}.btn-warning.disabled,.btn-warning:disabled{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-warning:not(:disabled):not(.disabled).active,.btn-warning:not(:disabled):not(.disabled):active,.show>.btn-warning.dropdown-toggle{color:#212529;background-color:#d39e00;border-color:#c69500}.btn-warning:not(:disabled):not(.disabled).active:focus,.btn-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(222,170,12,.5)}.btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:hover{color:#fff;background-color:#c82333;border-color:#bd2130}.btn-danger.focus,.btn-danger:focus{color:#fff;background-color:#c82333;border-color:#bd2130;box-shadow:0 0 0 .2rem rgba(225,83,97,.5)}.btn-danger.disabled,.btn-danger:disabled{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:not(:disabled):not(.disabled).active,.btn-danger:not(:disabled):not(.disabled):active,.show>.btn-danger.dropdown-toggle{color:#fff;background-color:#bd2130;border-color:#b21f2d}.btn-danger:not(:disabled):not(.disabled).active:focus,.btn-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(225,83,97,.5)}.btn-light{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:hover{color:#212529;background-color:#e2e6ea;border-color:#dae0e5}.btn-light.focus,.btn-light:focus{color:#212529;background-color:#e2e6ea;border-color:#dae0e5;box-shadow:0 0 0 .2rem rgba(216,217,219,.5)}.btn-light.disabled,.btn-light:disabled{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:not(:disabled):not(.disabled).active,.btn-light:not(:disabled):not(.disabled):active,.show>.btn-light.dropdown-toggle{color:#212529;background-color:#dae0e5;border-color:#d3d9df}.btn-light:not(:disabled):not(.disabled).active:focus,.btn-light:not(:disabled):not(.disabled):active:focus,.show>.btn-light.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(216,217,219,.5)}.btn-dark{color:#fff;background-color:#343a40;border-color:#343a40}.btn-dark:hover{color:#fff;background-color:#23272b;border-color:#1d2124}.btn-dark.focus,.btn-dark:focus{color:#fff;background-color:#23272b;border-color:#1d2124;box-shadow:0 0 0 .2rem rgba(82,88,93,.5)}.btn-dark.disabled,.btn-dark:disabled{color:#fff;background-color:#343a40;border-color:#343a40}.btn-dark:not(:disabled):not(.disabled).active,.btn-dark:not(:disabled):not(.disabled):active,.show>.btn-dark.dropdown-toggle{color:#fff;background-color:#1d2124;border-color:#171a1d}.btn-dark:not(:disabled):not(.disabled).active:focus,.btn-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(82,88,93,.5)}.btn-outline-primary{color:#007bff;border-color:#007bff}.btn-outline-primary:hover{color:#fff;background-color:#007bff;border-color:#007bff}.btn-outline-primary.focus,.btn-outline-primary:focus{box-shadow:0 0 0 .2rem rgba(0,123,255,.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{color:#007bff;background-color:transparent}.btn-outline-primary:not(:disabled):not(.disabled).active,.btn-outline-primary:not(:disabled):not(.disabled):active,.show>.btn-outline-primary.dropdown-toggle{color:#fff;background-color:#007bff;border-color:#007bff}.btn-outline-primary:not(:disabled):not(.disabled).active:focus,.btn-outline-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(0,123,255,.5)}.btn-outline-secondary{color:#6c757d;border-color:#6c757d}.btn-outline-secondary:hover{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-outline-secondary.focus,.btn-outline-secondary:focus{box-shadow:0 0 0 .2rem rgba(108,117,125,.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{color:#6c757d;background-color:transparent}.btn-outline-secondary:not(:disabled):not(.disabled).active,.btn-outline-secondary:not(:disabled):not(.disabled):active,.show>.btn-outline-secondary.dropdown-toggle{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-outline-secondary:not(:disabled):not(.disabled).active:focus,.btn-outline-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(108,117,125,.5)}.btn-outline-success{color:#28a745;border-color:#28a745}.btn-outline-success:hover{color:#fff;background-color:#28a745;border-color:#28a745}.btn-outline-success.focus,.btn-outline-success:focus{box-shadow:0 0 0 .2rem rgba(40,167,69,.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{color:#28a745;background-color:transparent}.btn-outline-success:not(:disabled):not(.disabled).active,.btn-outline-success:not(:disabled):not(.disabled):active,.show>.btn-outline-success.dropdown-toggle{color:#fff;background-color:#28a745;border-color:#28a745}.btn-outline-success:not(:disabled):not(.disabled).active:focus,.btn-outline-success:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(40,167,69,.5)}.btn-outline-info{color:#17a2b8;border-color:#17a2b8}.btn-outline-info:hover{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-outline-info.focus,.btn-outline-info:focus{box-shadow:0 0 0 .2rem rgba(23,162,184,.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{color:#17a2b8;background-color:transparent}.btn-outline-info:not(:disabled):not(.disabled).active,.btn-outline-info:not(:disabled):not(.disabled):active,.show>.btn-outline-info.dropdown-toggle{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-outline-info:not(:disabled):not(.disabled).active:focus,.btn-outline-info:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(23,162,184,.5)}.btn-outline-warning{color:#ffc107;border-color:#ffc107}.btn-outline-warning:hover{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-outline-warning.focus,.btn-outline-warning:focus{box-shadow:0 0 0 .2rem rgba(255,193,7,.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{color:#ffc107;background-color:transparent}.btn-outline-warning:not(:disabled):not(.disabled).active,.btn-outline-warning:not(:disabled):not(.disabled):active,.show>.btn-outline-warning.dropdown-toggle{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-outline-warning:not(:disabled):not(.disabled).active:focus,.btn-outline-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(255,193,7,.5)}.btn-outline-danger{color:#dc3545;border-color:#dc3545}.btn-outline-danger:hover{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-outline-danger.focus,.btn-outline-danger:focus{box-shadow:0 0 0 .2rem rgba(220,53,69,.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{color:#dc3545;background-color:transparent}.btn-outline-danger:not(:disabled):not(.disabled).active,.btn-outline-danger:not(:disabled):not(.disabled):active,.show>.btn-outline-danger.dropdown-toggle{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-outline-danger:not(:disabled):not(.disabled).active:focus,.btn-outline-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(220,53,69,.5)}.btn-outline-light{color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light:hover{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light.focus,.btn-outline-light:focus{box-shadow:0 0 0 .2rem rgba(248,249,250,.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{color:#f8f9fa;background-color:transparent}.btn-outline-light:not(:disabled):not(.disabled).active,.btn-outline-light:not(:disabled):not(.disabled):active,.show>.btn-outline-light.dropdown-toggle{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light:not(:disabled):not(.disabled).active:focus,.btn-outline-light:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-light.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(248,249,250,.5)}.btn-outline-dark{color:#343a40;border-color:#343a40}.btn-outline-dark:hover{color:#fff;background-color:#343a40;border-color:#343a40}.btn-outline-dark.focus,.btn-outline-dark:focus{box-shadow:0 0 0 .2rem rgba(52,58,64,.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{color:#343a40;background-color:transparent}.btn-outline-dark:not(:disabled):not(.disabled).active,.btn-outline-dark:not(:disabled):not(.disabled):active,.show>.btn-outline-dark.dropdown-toggle{color:#fff;background-color:#343a40;border-color:#343a40}.btn-outline-dark:not(:disabled):not(.disabled).active:focus,.btn-outline-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(52,58,64,.5)}.btn-link{font-weight:400;color:#007bff;text-decoration:none}.btn-link:hover{color:#0056b3;text-decoration:underline}.btn-link.focus,.btn-link:focus{text-decoration:underline}.btn-link.disabled,.btn-link:disabled{color:#6c757d;pointer-events:none}.btn-group-lg>.btn,.btn-lg{padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}.btn-group-sm>.btn,.btn-sm{padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:.5rem}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{position:relative;height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.dropdown,.dropleft,.dropright,.dropup{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:10rem;padding:.5rem 0;margin:.125rem 0 0;font-size:1rem;color:#212529;text-align:left;list-style:none;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.15);border-radius:.25rem}.dropdown-menu-left{right:auto;left:0}.dropdown-menu-right{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-left{right:auto;left:0}.dropdown-menu-sm-right{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-left{right:auto;left:0}.dropdown-menu-md-right{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-left{right:auto;left:0}.dropdown-menu-lg-right{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-left{right:auto;left:0}.dropdown-menu-xl-right{right:0;left:auto}}.dropup .dropdown-menu{top:auto;bottom:100%;margin-top:0;margin-bottom:.125rem}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropright .dropdown-menu{top:0;right:auto;left:100%;margin-top:0;margin-left:.125rem}.dropright .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropright .dropdown-toggle:empty::after{margin-left:0}.dropright .dropdown-toggle::after{vertical-align:0}.dropleft .dropdown-menu{top:0;right:100%;left:auto;margin-top:0;margin-right:.125rem}.dropleft .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropleft .dropdown-toggle::after{display:none}.dropleft .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropleft .dropdown-toggle:empty::after{margin-left:0}.dropleft .dropdown-toggle::before{vertical-align:0}.dropdown-menu[x-placement^=bottom],.dropdown-menu[x-placement^=left],.dropdown-menu[x-placement^=right],.dropdown-menu[x-placement^=top]{right:auto;bottom:auto}.dropdown-divider{height:0;margin:.5rem 0;overflow:hidden;border-top:1px solid #e9ecef}.dropdown-item{display:block;width:100%;padding:.25rem 1.5rem;clear:both;font-weight:400;color:#212529;text-align:inherit;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:focus,.dropdown-item:hover{color:#16181b;text-decoration:none;background-color:#f8f9fa}.dropdown-item.active,.dropdown-item:active{color:#fff;text-decoration:none;background-color:#007bff}.dropdown-item.disabled,.dropdown-item:disabled{color:#6c757d;pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:.5rem 1.5rem;margin-bottom:0;font-size:.875rem;color:#6c757d;white-space:nowrap}.dropdown-item-text{display:block;padding:.25rem 1.5rem;color:#212529}.btn-group,.btn-group-vertical{position:relative;display:-ms-inline-flexbox;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;-ms-flex:1 1 auto;flex:1 1 auto}.btn-group-vertical>.btn:hover,.btn-group>.btn:hover{z-index:1}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus{z-index:1}.btn-toolbar{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-pack:start;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn-group:not(:first-child),.btn-group>.btn:not(:first-child){margin-left:-1px}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropright .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropleft .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{-ms-flex-direction:column;flex-direction:column;-ms-flex-align:start;align-items:flex-start;-ms-flex-pack:center;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn:not(:first-child){border-top-left-radius:0;border-top-right-radius:0}.btn-group-toggle>.btn,.btn-group-toggle>.btn-group>.btn{margin-bottom:0}.btn-group-toggle>.btn input[type=checkbox],.btn-group-toggle>.btn input[type=radio],.btn-group-toggle>.btn-group>.btn input[type=checkbox],.btn-group-toggle>.btn-group>.btn input[type=radio]{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.input-group{position:relative;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:stretch;align-items:stretch;width:100%}.input-group>.custom-file,.input-group>.custom-select,.input-group>.form-control,.input-group>.form-control-plaintext{position:relative;-ms-flex:1 1 auto;flex:1 1 auto;width:1%;min-width:0;margin-bottom:0}.input-group>.custom-file+.custom-file,.input-group>.custom-file+.custom-select,.input-group>.custom-file+.form-control,.input-group>.custom-select+.custom-file,.input-group>.custom-select+.custom-select,.input-group>.custom-select+.form-control,.input-group>.form-control+.custom-file,.input-group>.form-control+.custom-select,.input-group>.form-control+.form-control,.input-group>.form-control-plaintext+.custom-file,.input-group>.form-control-plaintext+.custom-select,.input-group>.form-control-plaintext+.form-control{margin-left:-1px}.input-group>.custom-file .custom-file-input:focus~.custom-file-label,.input-group>.custom-select:focus,.input-group>.form-control:focus{z-index:3}.input-group>.custom-file .custom-file-input:focus{z-index:4}.input-group>.custom-select:not(:last-child),.input-group>.form-control:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.custom-select:not(:first-child),.input-group>.form-control:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.custom-file{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center}.input-group>.custom-file:not(:last-child) .custom-file-label,.input-group>.custom-file:not(:last-child) .custom-file-label::after{border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.custom-file:not(:first-child) .custom-file-label{border-top-left-radius:0;border-bottom-left-radius:0}.input-group-append,.input-group-prepend{display:-ms-flexbox;display:flex}.input-group-append .btn,.input-group-prepend .btn{position:relative;z-index:2}.input-group-append .btn:focus,.input-group-prepend .btn:focus{z-index:3}.input-group-append .btn+.btn,.input-group-append .btn+.input-group-text,.input-group-append .input-group-text+.btn,.input-group-append .input-group-text+.input-group-text,.input-group-prepend .btn+.btn,.input-group-prepend .btn+.input-group-text,.input-group-prepend .input-group-text+.btn,.input-group-prepend .input-group-text+.input-group-text{margin-left:-1px}.input-group-prepend{margin-right:-1px}.input-group-append{margin-left:-1px}.input-group-text{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;padding:.375rem .75rem;margin-bottom:0;font-size:1rem;font-weight:400;line-height:1.5;color:#495057;text-align:center;white-space:nowrap;background-color:#e9ecef;border:1px solid #ced4da;border-radius:.25rem}.input-group-text input[type=checkbox],.input-group-text input[type=radio]{margin-top:0}.input-group-lg>.custom-select,.input-group-lg>.form-control:not(textarea){height:calc(1.5em + 1rem + 2px)}.input-group-lg>.custom-select,.input-group-lg>.form-control,.input-group-lg>.input-group-append>.btn,.input-group-lg>.input-group-append>.input-group-text,.input-group-lg>.input-group-prepend>.btn,.input-group-lg>.input-group-prepend>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}.input-group-sm>.custom-select,.input-group-sm>.form-control:not(textarea){height:calc(1.5em + .5rem + 2px)}.input-group-sm>.custom-select,.input-group-sm>.form-control,.input-group-sm>.input-group-append>.btn,.input-group-sm>.input-group-append>.input-group-text,.input-group-sm>.input-group-prepend>.btn,.input-group-sm>.input-group-prepend>.input-group-text{padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.input-group-lg>.custom-select,.input-group-sm>.custom-select{padding-right:1.75rem}.input-group>.input-group-append:last-child>.btn:not(:last-child):not(.dropdown-toggle),.input-group>.input-group-append:last-child>.input-group-text:not(:last-child),.input-group>.input-group-append:not(:last-child)>.btn,.input-group>.input-group-append:not(:last-child)>.input-group-text,.input-group>.input-group-prepend>.btn,.input-group>.input-group-prepend>.input-group-text{border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.input-group-append>.btn,.input-group>.input-group-append>.input-group-text,.input-group>.input-group-prepend:first-child>.btn:not(:first-child),.input-group>.input-group-prepend:first-child>.input-group-text:not(:first-child),.input-group>.input-group-prepend:not(:first-child)>.btn,.input-group>.input-group-prepend:not(:first-child)>.input-group-text{border-top-left-radius:0;border-bottom-left-radius:0}.custom-control{position:relative;z-index:1;display:block;min-height:1.5rem;padding-left:1.5rem}.custom-control-inline{display:-ms-inline-flexbox;display:inline-flex;margin-right:1rem}.custom-control-input{position:absolute;left:0;z-index:-1;width:1rem;height:1.25rem;opacity:0}.custom-control-input:checked~.custom-control-label::before{color:#fff;border-color:#007bff;background-color:#007bff}.custom-control-input:focus~.custom-control-label::before{box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.custom-control-input:focus:not(:checked)~.custom-control-label::before{border-color:#80bdff}.custom-control-input:not(:disabled):active~.custom-control-label::before{color:#fff;background-color:#b3d7ff;border-color:#b3d7ff}.custom-control-input:disabled~.custom-control-label,.custom-control-input[disabled]~.custom-control-label{color:#6c757d}.custom-control-input:disabled~.custom-control-label::before,.custom-control-input[disabled]~.custom-control-label::before{background-color:#e9ecef}.custom-control-label{position:relative;margin-bottom:0;vertical-align:top}.custom-control-label::before{position:absolute;top:.25rem;left:-1.5rem;display:block;width:1rem;height:1rem;pointer-events:none;content:"";background-color:#fff;border:#adb5bd solid 1px}.custom-control-label::after{position:absolute;top:.25rem;left:-1.5rem;display:block;width:1rem;height:1rem;content:"";background:no-repeat 50%/50% 50%}.custom-checkbox .custom-control-label::before{border-radius:.25rem}.custom-checkbox .custom-control-input:checked~.custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26l2.974 2.99L8 2.193z'/%3e%3c/svg%3e")}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label::before{border-color:#007bff;background-color:#007bff}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4' viewBox='0 0 4 4'%3e%3cpath stroke='%23fff' d='M0 2h4'/%3e%3c/svg%3e")}.custom-checkbox .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-checkbox .custom-control-input:disabled:indeterminate~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-radio .custom-control-label::before{border-radius:50%}.custom-radio .custom-control-input:checked~.custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.custom-radio .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-switch{padding-left:2.25rem}.custom-switch .custom-control-label::before{left:-2.25rem;width:1.75rem;pointer-events:all;border-radius:.5rem}.custom-switch .custom-control-label::after{top:calc(.25rem + 2px);left:calc(-2.25rem + 2px);width:calc(1rem - 4px);height:calc(1rem - 4px);background-color:#adb5bd;border-radius:.5rem;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,-webkit-transform .15s ease-in-out;transition:transform .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:transform .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,-webkit-transform .15s ease-in-out}@media (prefers-reduced-motion:reduce){.custom-switch .custom-control-label::after{transition:none}}.custom-switch .custom-control-input:checked~.custom-control-label::after{background-color:#fff;-webkit-transform:translateX(.75rem);transform:translateX(.75rem)}.custom-switch .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-select{display:inline-block;width:100%;height:calc(1.5em + .75rem + 2px);padding:.375rem 1.75rem .375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#495057;vertical-align:middle;background:#fff url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px;border:1px solid #ced4da;border-radius:.25rem;-webkit-appearance:none;-moz-appearance:none;appearance:none}.custom-select:focus{border-color:#80bdff;outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.custom-select:focus::-ms-value{color:#495057;background-color:#fff}.custom-select[multiple],.custom-select[size]:not([size="1"]){height:auto;padding-right:.75rem;background-image:none}.custom-select:disabled{color:#6c757d;background-color:#e9ecef}.custom-select::-ms-expand{display:none}.custom-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #495057}.custom-select-sm{height:calc(1.5em + .5rem + 2px);padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem}.custom-select-lg{height:calc(1.5em + 1rem + 2px);padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem}.custom-file{position:relative;display:inline-block;width:100%;height:calc(1.5em + .75rem + 2px);margin-bottom:0}.custom-file-input{position:relative;z-index:2;width:100%;height:calc(1.5em + .75rem + 2px);margin:0;opacity:0}.custom-file-input:focus~.custom-file-label{border-color:#80bdff;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.custom-file-input:disabled~.custom-file-label,.custom-file-input[disabled]~.custom-file-label{background-color:#e9ecef}.custom-file-input:lang(en)~.custom-file-label::after{content:"Browse"}.custom-file-input~.custom-file-label[data-browse]::after{content:attr(data-browse)}.custom-file-label{position:absolute;top:0;right:0;left:0;z-index:1;height:calc(1.5em + .75rem + 2px);padding:.375rem .75rem;font-weight:400;line-height:1.5;color:#495057;background-color:#fff;border:1px solid #ced4da;border-radius:.25rem}.custom-file-label::after{position:absolute;top:0;right:0;bottom:0;z-index:3;display:block;height:calc(1.5em + .75rem);padding:.375rem .75rem;line-height:1.5;color:#495057;content:"Browse";background-color:#e9ecef;border-left:inherit;border-radius:0 .25rem .25rem 0}.custom-range{width:100%;height:1.4rem;padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.custom-range:focus{outline:0}.custom-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25)}.custom-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25)}.custom-range:focus::-ms-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25)}.custom-range::-moz-focus-outer{border:0}.custom-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#007bff;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.custom-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.custom-range::-webkit-slider-thumb:active{background-color:#b3d7ff}.custom-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.custom-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#007bff;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.custom-range::-moz-range-thumb{-moz-transition:none;transition:none}}.custom-range::-moz-range-thumb:active{background-color:#b3d7ff}.custom-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.custom-range::-ms-thumb{width:1rem;height:1rem;margin-top:0;margin-right:.2rem;margin-left:.2rem;background-color:#007bff;border:0;border-radius:1rem;-ms-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;appearance:none}@media (prefers-reduced-motion:reduce){.custom-range::-ms-thumb{-ms-transition:none;transition:none}}.custom-range::-ms-thumb:active{background-color:#b3d7ff}.custom-range::-ms-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:transparent;border-color:transparent;border-width:.5rem}.custom-range::-ms-fill-lower{background-color:#dee2e6;border-radius:1rem}.custom-range::-ms-fill-upper{margin-right:15px;background-color:#dee2e6;border-radius:1rem}.custom-range:disabled::-webkit-slider-thumb{background-color:#adb5bd}.custom-range:disabled::-webkit-slider-runnable-track{cursor:default}.custom-range:disabled::-moz-range-thumb{background-color:#adb5bd}.custom-range:disabled::-moz-range-track{cursor:default}.custom-range:disabled::-ms-thumb{background-color:#adb5bd}.custom-control-label::before,.custom-file-label,.custom-select{transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.custom-control-label::before,.custom-file-label,.custom-select{transition:none}}.nav{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:.5rem 1rem}.nav-link:focus,.nav-link:hover{text-decoration:none}.nav-link.disabled{color:#6c757d;pointer-events:none;cursor:default}.nav-tabs{border-bottom:1px solid #dee2e6}.nav-tabs .nav-item{margin-bottom:-1px}.nav-tabs .nav-link{border:1px solid transparent;border-top-left-radius:.25rem;border-top-right-radius:.25rem}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{border-color:#e9ecef #e9ecef #dee2e6}.nav-tabs .nav-link.disabled{color:#6c757d;background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:#495057;background-color:#fff;border-color:#dee2e6 #dee2e6 #fff}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.nav-pills .nav-link{border-radius:.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:#fff;background-color:#007bff}.nav-fill .nav-item,.nav-fill>.nav-link{-ms-flex:1 1 auto;flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;text-align:center}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{position:relative;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between;padding:.5rem 1rem}.navbar .container,.navbar .container-fluid,.navbar .container-lg,.navbar .container-md,.navbar .container-sm,.navbar .container-xl{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between}.navbar-brand{display:inline-block;padding-top:.3125rem;padding-bottom:.3125rem;margin-right:1rem;font-size:1.25rem;line-height:inherit;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-nav{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link{padding-right:0;padding-left:0}.navbar-nav .dropdown-menu{position:static;float:none}.navbar-text{display:inline-block;padding-top:.5rem;padding-bottom:.5rem}.navbar-collapse{-ms-flex-preferred-size:100%;flex-basis:100%;-ms-flex-positive:1;flex-grow:1;-ms-flex-align:center;align-items:center}.navbar-toggler{padding:.25rem .75rem;font-size:1.25rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:.25rem}.navbar-toggler:focus,.navbar-toggler:hover{text-decoration:none}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;content:"";background:no-repeat center center;background-size:100% 100%}@media (max-width:575.98px){.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid,.navbar-expand-sm>.container-lg,.navbar-expand-sm>.container-md,.navbar-expand-sm>.container-sm,.navbar-expand-sm>.container-xl{padding-right:0;padding-left:0}}@media (min-width:576px){.navbar-expand-sm{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-sm .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid,.navbar-expand-sm>.container-lg,.navbar-expand-sm>.container-md,.navbar-expand-sm>.container-sm,.navbar-expand-sm>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-sm .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}}@media (max-width:767.98px){.navbar-expand-md>.container,.navbar-expand-md>.container-fluid,.navbar-expand-md>.container-lg,.navbar-expand-md>.container-md,.navbar-expand-md>.container-sm,.navbar-expand-md>.container-xl{padding-right:0;padding-left:0}}@media (min-width:768px){.navbar-expand-md{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-md .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-md>.container,.navbar-expand-md>.container-fluid,.navbar-expand-md>.container-lg,.navbar-expand-md>.container-md,.navbar-expand-md>.container-sm,.navbar-expand-md>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-md .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}}@media (max-width:991.98px){.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid,.navbar-expand-lg>.container-lg,.navbar-expand-lg>.container-md,.navbar-expand-lg>.container-sm,.navbar-expand-lg>.container-xl{padding-right:0;padding-left:0}}@media (min-width:992px){.navbar-expand-lg{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-lg .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid,.navbar-expand-lg>.container-lg,.navbar-expand-lg>.container-md,.navbar-expand-lg>.container-sm,.navbar-expand-lg>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-lg .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}}@media (max-width:1199.98px){.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid,.navbar-expand-xl>.container-lg,.navbar-expand-xl>.container-md,.navbar-expand-xl>.container-sm,.navbar-expand-xl>.container-xl{padding-right:0;padding-left:0}}@media (min-width:1200px){.navbar-expand-xl{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-xl .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid,.navbar-expand-xl>.container-lg,.navbar-expand-xl>.container-md,.navbar-expand-xl>.container-sm,.navbar-expand-xl>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-xl .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}}.navbar-expand{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand>.container,.navbar-expand>.container-fluid,.navbar-expand>.container-lg,.navbar-expand>.container-md,.navbar-expand>.container-sm,.navbar-expand>.container-xl{padding-right:0;padding-left:0}.navbar-expand .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand>.container,.navbar-expand>.container-fluid,.navbar-expand>.container-lg,.navbar-expand>.container-md,.navbar-expand>.container-sm,.navbar-expand>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-light .navbar-brand{color:rgba(0,0,0,.9)}.navbar-light .navbar-brand:focus,.navbar-light .navbar-brand:hover{color:rgba(0,0,0,.9)}.navbar-light .navbar-nav .nav-link{color:rgba(0,0,0,.5)}.navbar-light .navbar-nav .nav-link:focus,.navbar-light .navbar-nav .nav-link:hover{color:rgba(0,0,0,.7)}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(0,0,0,.3)}.navbar-light .navbar-nav .active>.nav-link,.navbar-light .navbar-nav .nav-link.active,.navbar-light .navbar-nav .nav-link.show,.navbar-light .navbar-nav .show>.nav-link{color:rgba(0,0,0,.9)}.navbar-light .navbar-toggler{color:rgba(0,0,0,.5);border-color:rgba(0,0,0,.1)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.5%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-light .navbar-text{color:rgba(0,0,0,.5)}.navbar-light .navbar-text a{color:rgba(0,0,0,.9)}.navbar-light .navbar-text a:focus,.navbar-light .navbar-text a:hover{color:rgba(0,0,0,.9)}.navbar-dark .navbar-brand{color:#fff}.navbar-dark .navbar-brand:focus,.navbar-dark .navbar-brand:hover{color:#fff}.navbar-dark .navbar-nav .nav-link{color:rgba(255,255,255,.5)}.navbar-dark .navbar-nav .nav-link:focus,.navbar-dark .navbar-nav .nav-link:hover{color:rgba(255,255,255,.75)}.navbar-dark .navbar-nav .nav-link.disabled{color:rgba(255,255,255,.25)}.navbar-dark .navbar-nav .active>.nav-link,.navbar-dark .navbar-nav .nav-link.active,.navbar-dark .navbar-nav .nav-link.show,.navbar-dark .navbar-nav .show>.nav-link{color:#fff}.navbar-dark .navbar-toggler{color:rgba(255,255,255,.5);border-color:rgba(255,255,255,.1)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.5%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-dark .navbar-text{color:rgba(255,255,255,.5)}.navbar-dark .navbar-text a{color:#fff}.navbar-dark .navbar-text a:focus,.navbar-dark .navbar-text a:hover{color:#fff}.card{position:relative;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#fff;background-clip:border-box;border:1px solid rgba(0,0,0,.125);border-radius:.25rem}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{-ms-flex:1 1 auto;flex:1 1 auto;min-height:1px;padding:1.25rem}.card-title{margin-bottom:.75rem}.card-subtitle{margin-top:-.375rem;margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link:hover{text-decoration:none}.card-link+.card-link{margin-left:1.25rem}.card-header{padding:.75rem 1.25rem;margin-bottom:0;background-color:rgba(0,0,0,.03);border-bottom:1px solid rgba(0,0,0,.125)}.card-header:first-child{border-radius:calc(.25rem - 1px) calc(.25rem - 1px) 0 0}.card-footer{padding:.75rem 1.25rem;background-color:rgba(0,0,0,.03);border-top:1px solid rgba(0,0,0,.125)}.card-footer:last-child{border-radius:0 0 calc(.25rem - 1px) calc(.25rem - 1px)}.card-header-tabs{margin-right:-.625rem;margin-bottom:-.75rem;margin-left:-.625rem;border-bottom:0}.card-header-pills{margin-right:-.625rem;margin-left:-.625rem}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1.25rem;border-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom,.card-img-top{-ms-flex-negative:0;flex-shrink:0;width:100%}.card-img,.card-img-top{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card-deck .card{margin-bottom:15px}@media (min-width:576px){.card-deck{display:-ms-flexbox;display:flex;-ms-flex-flow:row wrap;flex-flow:row wrap;margin-right:-15px;margin-left:-15px}.card-deck .card{-ms-flex:1 0 0%;flex:1 0 0%;margin-right:15px;margin-bottom:0;margin-left:15px}}.card-group>.card{margin-bottom:15px}@media (min-width:576px){.card-group{display:-ms-flexbox;display:flex;-ms-flex-flow:row wrap;flex-flow:row wrap}.card-group>.card{-ms-flex:1 0 0%;flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.card-columns .card{margin-bottom:.75rem}@media (min-width:576px){.card-columns{-webkit-column-count:3;-moz-column-count:3;column-count:3;-webkit-column-gap:1.25rem;-moz-column-gap:1.25rem;column-gap:1.25rem;orphans:1;widows:1}.card-columns .card{display:inline-block;width:100%}}.accordion{overflow-anchor:none}.accordion>.card{overflow:hidden}.accordion>.card:not(:last-of-type){border-bottom:0;border-bottom-right-radius:0;border-bottom-left-radius:0}.accordion>.card:not(:first-of-type){border-top-left-radius:0;border-top-right-radius:0}.accordion>.card>.card-header{border-radius:0;margin-bottom:-1px}.breadcrumb{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding:.75rem 1rem;margin-bottom:1rem;list-style:none;background-color:#e9ecef;border-radius:.25rem}.breadcrumb-item{display:-ms-flexbox;display:flex}.breadcrumb-item+.breadcrumb-item{padding-left:.5rem}.breadcrumb-item+.breadcrumb-item::before{display:inline-block;padding-right:.5rem;color:#6c757d;content:"/"}.breadcrumb-item+.breadcrumb-item:hover::before{text-decoration:underline}.breadcrumb-item+.breadcrumb-item:hover::before{text-decoration:none}.breadcrumb-item.active{color:#6c757d}.pagination{display:-ms-flexbox;display:flex;padding-left:0;list-style:none;border-radius:.25rem}.page-link{position:relative;display:block;padding:.5rem .75rem;margin-left:-1px;line-height:1.25;color:#007bff;background-color:#fff;border:1px solid #dee2e6}.page-link:hover{z-index:2;color:#0056b3;text-decoration:none;background-color:#e9ecef;border-color:#dee2e6}.page-link:focus{z-index:3;outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.page-item:first-child .page-link{margin-left:0;border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.page-item:last-child .page-link{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.page-item.active .page-link{z-index:3;color:#fff;background-color:#007bff;border-color:#007bff}.page-item.disabled .page-link{color:#6c757d;pointer-events:none;cursor:auto;background-color:#fff;border-color:#dee2e6}.pagination-lg .page-link{padding:.75rem 1.5rem;font-size:1.25rem;line-height:1.5}.pagination-lg .page-item:first-child .page-link{border-top-left-radius:.3rem;border-bottom-left-radius:.3rem}.pagination-lg .page-item:last-child .page-link{border-top-right-radius:.3rem;border-bottom-right-radius:.3rem}.pagination-sm .page-link{padding:.25rem .5rem;font-size:.875rem;line-height:1.5}.pagination-sm .page-item:first-child .page-link{border-top-left-radius:.2rem;border-bottom-left-radius:.2rem}.pagination-sm .page-item:last-child .page-link{border-top-right-radius:.2rem;border-bottom-right-radius:.2rem}.badge{display:inline-block;padding:.25em .4em;font-size:75%;font-weight:700;line-height:1;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.badge{transition:none}}a.badge:focus,a.badge:hover{text-decoration:none}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.badge-pill{padding-right:.6em;padding-left:.6em;border-radius:10rem}.badge-primary{color:#fff;background-color:#007bff}a.badge-primary:focus,a.badge-primary:hover{color:#fff;background-color:#0062cc}a.badge-primary.focus,a.badge-primary:focus{outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.5)}.badge-secondary{color:#fff;background-color:#6c757d}a.badge-secondary:focus,a.badge-secondary:hover{color:#fff;background-color:#545b62}a.badge-secondary.focus,a.badge-secondary:focus{outline:0;box-shadow:0 0 0 .2rem rgba(108,117,125,.5)}.badge-success{color:#fff;background-color:#28a745}a.badge-success:focus,a.badge-success:hover{color:#fff;background-color:#1e7e34}a.badge-success.focus,a.badge-success:focus{outline:0;box-shadow:0 0 0 .2rem rgba(40,167,69,.5)}.badge-info{color:#fff;background-color:#17a2b8}a.badge-info:focus,a.badge-info:hover{color:#fff;background-color:#117a8b}a.badge-info.focus,a.badge-info:focus{outline:0;box-shadow:0 0 0 .2rem rgba(23,162,184,.5)}.badge-warning{color:#212529;background-color:#ffc107}a.badge-warning:focus,a.badge-warning:hover{color:#212529;background-color:#d39e00}a.badge-warning.focus,a.badge-warning:focus{outline:0;box-shadow:0 0 0 .2rem rgba(255,193,7,.5)}.badge-danger{color:#fff;background-color:#dc3545}a.badge-danger:focus,a.badge-danger:hover{color:#fff;background-color:#bd2130}a.badge-danger.focus,a.badge-danger:focus{outline:0;box-shadow:0 0 0 .2rem rgba(220,53,69,.5)}.badge-light{color:#212529;background-color:#f8f9fa}a.badge-light:focus,a.badge-light:hover{color:#212529;background-color:#dae0e5}a.badge-light.focus,a.badge-light:focus{outline:0;box-shadow:0 0 0 .2rem rgba(248,249,250,.5)}.badge-dark{color:#fff;background-color:#343a40}a.badge-dark:focus,a.badge-dark:hover{color:#fff;background-color:#1d2124}a.badge-dark.focus,a.badge-dark:focus{outline:0;box-shadow:0 0 0 .2rem rgba(52,58,64,.5)}.jumbotron{padding:2rem 1rem;margin-bottom:2rem;background-color:#e9ecef;border-radius:.3rem}@media (min-width:576px){.jumbotron{padding:4rem 2rem}}.jumbotron-fluid{padding-right:0;padding-left:0;border-radius:0}.alert{position:relative;padding:.75rem 1.25rem;margin-bottom:1rem;border:1px solid transparent;border-radius:.25rem}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:4rem}.alert-dismissible .close{position:absolute;top:0;right:0;padding:.75rem 1.25rem;color:inherit}.alert-primary{color:#004085;background-color:#cce5ff;border-color:#b8daff}.alert-primary hr{border-top-color:#9fcdff}.alert-primary .alert-link{color:#002752}.alert-secondary{color:#383d41;background-color:#e2e3e5;border-color:#d6d8db}.alert-secondary hr{border-top-color:#c8cbcf}.alert-secondary .alert-link{color:#202326}.alert-success{color:#155724;background-color:#d4edda;border-color:#c3e6cb}.alert-success hr{border-top-color:#b1dfbb}.alert-success .alert-link{color:#0b2e13}.alert-info{color:#0c5460;background-color:#d1ecf1;border-color:#bee5eb}.alert-info hr{border-top-color:#abdde5}.alert-info .alert-link{color:#062c33}.alert-warning{color:#856404;background-color:#fff3cd;border-color:#ffeeba}.alert-warning hr{border-top-color:#ffe8a1}.alert-warning .alert-link{color:#533f03}.alert-danger{color:#721c24;background-color:#f8d7da;border-color:#f5c6cb}.alert-danger hr{border-top-color:#f1b0b7}.alert-danger .alert-link{color:#491217}.alert-light{color:#818182;background-color:#fefefe;border-color:#fdfdfe}.alert-light hr{border-top-color:#ececf6}.alert-light .alert-link{color:#686868}.alert-dark{color:#1b1e21;background-color:#d6d8d9;border-color:#c6c8ca}.alert-dark hr{border-top-color:#b9bbbe}.alert-dark .alert-link{color:#040505}@-webkit-keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}.progress{display:-ms-flexbox;display:flex;height:1rem;overflow:hidden;line-height:0;font-size:.75rem;background-color:#e9ecef;border-radius:.25rem}.progress-bar{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:center;justify-content:center;overflow:hidden;color:#fff;text-align:center;white-space:nowrap;background-color:#007bff;transition:width .6s ease}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:1rem 1rem}.progress-bar-animated{-webkit-animation:progress-bar-stripes 1s linear infinite;animation:progress-bar-stripes 1s linear infinite}@media (prefers-reduced-motion:reduce){.progress-bar-animated{-webkit-animation:none;animation:none}}.media{display:-ms-flexbox;display:flex;-ms-flex-align:start;align-items:flex-start}.media-body{-ms-flex:1;flex:1}.list-group{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:.25rem}.list-group-item-action{width:100%;color:#495057;text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:#495057;text-decoration:none;background-color:#f8f9fa}.list-group-item-action:active{color:#212529;background-color:#e9ecef}.list-group-item{position:relative;display:block;padding:.75rem 1.25rem;background-color:#fff;border:1px solid rgba(0,0,0,.125)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:#6c757d;pointer-events:none;background-color:#fff}.list-group-item.active{z-index:2;color:#fff;background-color:#007bff;border-color:#007bff}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:-1px;border-top-width:1px}.list-group-horizontal{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}@media (min-width:576px){.list-group-horizontal-sm{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:768px){.list-group-horizontal-md{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:992px){.list-group-horizontal-lg{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1200px){.list-group-horizontal-xl{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 1px}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{color:#004085;background-color:#b8daff}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{color:#004085;background-color:#9fcdff}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#004085;border-color:#004085}.list-group-item-secondary{color:#383d41;background-color:#d6d8db}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{color:#383d41;background-color:#c8cbcf}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#383d41;border-color:#383d41}.list-group-item-success{color:#155724;background-color:#c3e6cb}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{color:#155724;background-color:#b1dfbb}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#155724;border-color:#155724}.list-group-item-info{color:#0c5460;background-color:#bee5eb}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{color:#0c5460;background-color:#abdde5}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#0c5460;border-color:#0c5460}.list-group-item-warning{color:#856404;background-color:#ffeeba}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{color:#856404;background-color:#ffe8a1}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#856404;border-color:#856404}.list-group-item-danger{color:#721c24;background-color:#f5c6cb}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{color:#721c24;background-color:#f1b0b7}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#721c24;border-color:#721c24}.list-group-item-light{color:#818182;background-color:#fdfdfe}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{color:#818182;background-color:#ececf6}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#818182;border-color:#818182}.list-group-item-dark{color:#1b1e21;background-color:#c6c8ca}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{color:#1b1e21;background-color:#b9bbbe}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#1b1e21;border-color:#1b1e21}.close{float:right;font-size:1.5rem;font-weight:700;line-height:1;color:#000;text-shadow:0 1px 0 #fff;opacity:.5}.close:hover{color:#000;text-decoration:none}.close:not(:disabled):not(.disabled):focus,.close:not(:disabled):not(.disabled):hover{opacity:.75}button.close{padding:0;background-color:transparent;border:0}a.close.disabled{pointer-events:none}.toast{-ms-flex-preferred-size:350px;flex-basis:350px;max-width:350px;font-size:.875rem;background-color:rgba(255,255,255,.85);background-clip:padding-box;border:1px solid rgba(0,0,0,.1);box-shadow:0 .25rem .75rem rgba(0,0,0,.1);opacity:0;border-radius:.25rem}.toast:not(:last-child){margin-bottom:.75rem}.toast.showing{opacity:1}.toast.show{display:block;opacity:1}.toast.hide{display:none}.toast-header{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;padding:.25rem .75rem;color:#6c757d;background-color:rgba(255,255,255,.85);background-clip:padding-box;border-bottom:1px solid rgba(0,0,0,.05);border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.toast-body{padding:.75rem}.modal-open{overflow:hidden}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal{position:fixed;top:0;left:0;z-index:1050;display:none;width:100%;height:100%;overflow:hidden;outline:0}.modal-dialog{position:relative;width:auto;margin:.5rem;pointer-events:none}.modal.fade .modal-dialog{transition:-webkit-transform .3s ease-out;transition:transform .3s ease-out;transition:transform .3s ease-out,-webkit-transform .3s ease-out;-webkit-transform:translate(0,-50px);transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{-webkit-transform:none;transform:none}.modal.modal-static .modal-dialog{-webkit-transform:scale(1.02);transform:scale(1.02)}.modal-dialog-scrollable{display:-ms-flexbox;display:flex;max-height:calc(100% - 1rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 1rem);overflow:hidden}.modal-dialog-scrollable .modal-footer,.modal-dialog-scrollable .modal-header{-ms-flex-negative:0;flex-shrink:0}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;min-height:calc(100% - 1rem)}.modal-dialog-centered::before{display:block;height:calc(100vh - 1rem);height:-webkit-min-content;height:-moz-min-content;height:min-content;content:""}.modal-dialog-centered.modal-dialog-scrollable{-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:center;justify-content:center;height:100%}.modal-dialog-centered.modal-dialog-scrollable .modal-content{max-height:none}.modal-dialog-centered.modal-dialog-scrollable::before{content:none}.modal-content{position:relative;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;width:100%;pointer-events:auto;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem;outline:0}.modal-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:.5}.modal-header{display:-ms-flexbox;display:flex;-ms-flex-align:start;align-items:flex-start;-ms-flex-pack:justify;justify-content:space-between;padding:1rem 1rem;border-bottom:1px solid #dee2e6;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.modal-header .close{padding:1rem 1rem;margin:-1rem -1rem -1rem auto}.modal-title{margin-bottom:0;line-height:1.5}.modal-body{position:relative;-ms-flex:1 1 auto;flex:1 1 auto;padding:1rem}.modal-footer{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:end;justify-content:flex-end;padding:.75rem;border-top:1px solid #dee2e6;border-bottom-right-radius:calc(.3rem - 1px);border-bottom-left-radius:calc(.3rem - 1px)}.modal-footer>*{margin:.25rem}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:576px){.modal-dialog{max-width:500px;margin:1.75rem auto}.modal-dialog-scrollable{max-height:calc(100% - 3.5rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 3.5rem)}.modal-dialog-centered{min-height:calc(100% - 3.5rem)}.modal-dialog-centered::before{height:calc(100vh - 3.5rem);height:-webkit-min-content;height:-moz-min-content;height:min-content}.modal-sm{max-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{max-width:800px}}@media (min-width:1200px){.modal-xl{max-width:1140px}}.tooltip{position:absolute;z-index:1070;display:block;margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;opacity:0}.tooltip.show{opacity:.9}.tooltip .arrow{position:absolute;display:block;width:.8rem;height:.4rem}.tooltip .arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[x-placement^=top],.bs-tooltip-top{padding:.4rem 0}.bs-tooltip-auto[x-placement^=top] .arrow,.bs-tooltip-top .arrow{bottom:0}.bs-tooltip-auto[x-placement^=top] .arrow::before,.bs-tooltip-top .arrow::before{top:0;border-width:.4rem .4rem 0;border-top-color:#000}.bs-tooltip-auto[x-placement^=right],.bs-tooltip-right{padding:0 .4rem}.bs-tooltip-auto[x-placement^=right] .arrow,.bs-tooltip-right .arrow{left:0;width:.4rem;height:.8rem}.bs-tooltip-auto[x-placement^=right] .arrow::before,.bs-tooltip-right .arrow::before{right:0;border-width:.4rem .4rem .4rem 0;border-right-color:#000}.bs-tooltip-auto[x-placement^=bottom],.bs-tooltip-bottom{padding:.4rem 0}.bs-tooltip-auto[x-placement^=bottom] .arrow,.bs-tooltip-bottom .arrow{top:0}.bs-tooltip-auto[x-placement^=bottom] .arrow::before,.bs-tooltip-bottom .arrow::before{bottom:0;border-width:0 .4rem .4rem;border-bottom-color:#000}.bs-tooltip-auto[x-placement^=left],.bs-tooltip-left{padding:0 .4rem}.bs-tooltip-auto[x-placement^=left] .arrow,.bs-tooltip-left .arrow{right:0;width:.4rem;height:.8rem}.bs-tooltip-auto[x-placement^=left] .arrow::before,.bs-tooltip-left .arrow::before{left:0;border-width:.4rem 0 .4rem .4rem;border-left-color:#000}.tooltip-inner{max-width:200px;padding:.25rem .5rem;color:#fff;text-align:center;background-color:#000;border-radius:.25rem}.popover{position:absolute;top:0;left:0;z-index:1060;display:block;max-width:276px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem}.popover .arrow{position:absolute;display:block;width:1rem;height:.5rem;margin:0 .3rem}.popover .arrow::after,.popover .arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid}.bs-popover-auto[x-placement^=top],.bs-popover-top{margin-bottom:.5rem}.bs-popover-auto[x-placement^=top]>.arrow,.bs-popover-top>.arrow{bottom:calc(-.5rem - 1px)}.bs-popover-auto[x-placement^=top]>.arrow::before,.bs-popover-top>.arrow::before{bottom:0;border-width:.5rem .5rem 0;border-top-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=top]>.arrow::after,.bs-popover-top>.arrow::after{bottom:1px;border-width:.5rem .5rem 0;border-top-color:#fff}.bs-popover-auto[x-placement^=right],.bs-popover-right{margin-left:.5rem}.bs-popover-auto[x-placement^=right]>.arrow,.bs-popover-right>.arrow{left:calc(-.5rem - 1px);width:.5rem;height:1rem;margin:.3rem 0}.bs-popover-auto[x-placement^=right]>.arrow::before,.bs-popover-right>.arrow::before{left:0;border-width:.5rem .5rem .5rem 0;border-right-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=right]>.arrow::after,.bs-popover-right>.arrow::after{left:1px;border-width:.5rem .5rem .5rem 0;border-right-color:#fff}.bs-popover-auto[x-placement^=bottom],.bs-popover-bottom{margin-top:.5rem}.bs-popover-auto[x-placement^=bottom]>.arrow,.bs-popover-bottom>.arrow{top:calc(-.5rem - 1px)}.bs-popover-auto[x-placement^=bottom]>.arrow::before,.bs-popover-bottom>.arrow::before{top:0;border-width:0 .5rem .5rem .5rem;border-bottom-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=bottom]>.arrow::after,.bs-popover-bottom>.arrow::after{top:1px;border-width:0 .5rem .5rem .5rem;border-bottom-color:#fff}.bs-popover-auto[x-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:1rem;margin-left:-.5rem;content:"";border-bottom:1px solid #f7f7f7}.bs-popover-auto[x-placement^=left],.bs-popover-left{margin-right:.5rem}.bs-popover-auto[x-placement^=left]>.arrow,.bs-popover-left>.arrow{right:calc(-.5rem - 1px);width:.5rem;height:1rem;margin:.3rem 0}.bs-popover-auto[x-placement^=left]>.arrow::before,.bs-popover-left>.arrow::before{right:0;border-width:.5rem 0 .5rem .5rem;border-left-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=left]>.arrow::after,.bs-popover-left>.arrow::after{right:1px;border-width:.5rem 0 .5rem .5rem;border-left-color:#fff}.popover-header{padding:.5rem .75rem;margin-bottom:0;font-size:1rem;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.popover-header:empty{display:none}.popover-body{padding:.5rem .75rem;color:#212529}.carousel{position:relative}.carousel.pointer-event{-ms-touch-action:pan-y;touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:-webkit-transform .6s ease-in-out;transition:transform .6s ease-in-out;transition:transform .6s ease-in-out,-webkit-transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-right,.carousel-item-next:not(.carousel-item-left){-webkit-transform:translateX(100%);transform:translateX(100%)}.active.carousel-item-left,.carousel-item-prev:not(.carousel-item-right){-webkit-transform:translateX(-100%);transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;-webkit-transform:none;transform:none}.carousel-fade .carousel-item-next.carousel-item-left,.carousel-fade .carousel-item-prev.carousel-item-right,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;width:15%;color:#fff;text-align:center;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:20px;height:20px;background:no-repeat 50%/100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath d='M5.25 0l-4 4 4 4 1.5-1.5L4.25 4l2.5-2.5L5.25 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath d='M2.75 0l-1.5 1.5L3.75 4l-2.5 2.5L2.75 8l4-4-4-4z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:15;display:-ms-flexbox;display:flex;-ms-flex-pack:center;justify-content:center;padding-left:0;margin-right:15%;margin-left:15%;list-style:none}.carousel-indicators li{box-sizing:content-box;-ms-flex:0 1 auto;flex:0 1 auto;width:30px;height:3px;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators li{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center}@-webkit-keyframes spinner-border{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes spinner-border{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.spinner-border{display:inline-block;width:2rem;height:2rem;vertical-align:text-bottom;border:.25em solid currentColor;border-right-color:transparent;border-radius:50%;-webkit-animation:spinner-border .75s linear infinite;animation:spinner-border .75s linear infinite}.spinner-border-sm{width:1rem;height:1rem;border-width:.2em}@-webkit-keyframes spinner-grow{0%{-webkit-transform:scale(0);transform:scale(0)}50%{opacity:1;-webkit-transform:none;transform:none}}@keyframes spinner-grow{0%{-webkit-transform:scale(0);transform:scale(0)}50%{opacity:1;-webkit-transform:none;transform:none}}.spinner-grow{display:inline-block;width:2rem;height:2rem;vertical-align:text-bottom;background-color:currentColor;border-radius:50%;opacity:0;-webkit-animation:spinner-grow .75s linear infinite;animation:spinner-grow .75s linear infinite}.spinner-grow-sm{width:1rem;height:1rem}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.bg-primary{background-color:#007bff!important}a.bg-primary:focus,a.bg-primary:hover,button.bg-primary:focus,button.bg-primary:hover{background-color:#0062cc!important}.bg-secondary{background-color:#6c757d!important}a.bg-secondary:focus,a.bg-secondary:hover,button.bg-secondary:focus,button.bg-secondary:hover{background-color:#545b62!important}.bg-success{background-color:#28a745!important}a.bg-success:focus,a.bg-success:hover,button.bg-success:focus,button.bg-success:hover{background-color:#1e7e34!important}.bg-info{background-color:#17a2b8!important}a.bg-info:focus,a.bg-info:hover,button.bg-info:focus,button.bg-info:hover{background-color:#117a8b!important}.bg-warning{background-color:#ffc107!important}a.bg-warning:focus,a.bg-warning:hover,button.bg-warning:focus,button.bg-warning:hover{background-color:#d39e00!important}.bg-danger{background-color:#dc3545!important}a.bg-danger:focus,a.bg-danger:hover,button.bg-danger:focus,button.bg-danger:hover{background-color:#bd2130!important}.bg-light{background-color:#f8f9fa!important}a.bg-light:focus,a.bg-light:hover,button.bg-light:focus,button.bg-light:hover{background-color:#dae0e5!important}.bg-dark{background-color:#343a40!important}a.bg-dark:focus,a.bg-dark:hover,button.bg-dark:focus,button.bg-dark:hover{background-color:#1d2124!important}.bg-white{background-color:#fff!important}.bg-transparent{background-color:transparent!important}.border{border:1px solid #dee2e6!important}.border-top{border-top:1px solid #dee2e6!important}.border-right{border-right:1px solid #dee2e6!important}.border-bottom{border-bottom:1px solid #dee2e6!important}.border-left{border-left:1px solid #dee2e6!important}.border-0{border:0!important}.border-top-0{border-top:0!important}.border-right-0{border-right:0!important}.border-bottom-0{border-bottom:0!important}.border-left-0{border-left:0!important}.border-primary{border-color:#007bff!important}.border-secondary{border-color:#6c757d!important}.border-success{border-color:#28a745!important}.border-info{border-color:#17a2b8!important}.border-warning{border-color:#ffc107!important}.border-danger{border-color:#dc3545!important}.border-light{border-color:#f8f9fa!important}.border-dark{border-color:#343a40!important}.border-white{border-color:#fff!important}.rounded-sm{border-radius:.2rem!important}.rounded{border-radius:.25rem!important}.rounded-top{border-top-left-radius:.25rem!important;border-top-right-radius:.25rem!important}.rounded-right{border-top-right-radius:.25rem!important;border-bottom-right-radius:.25rem!important}.rounded-bottom{border-bottom-right-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-left{border-top-left-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-lg{border-radius:.3rem!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:50rem!important}.rounded-0{border-radius:0!important}.clearfix::after{display:block;clear:both;content:""}.d-none{display:none!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:-ms-flexbox!important;display:flex!important}.d-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}@media (min-width:576px){.d-sm-none{display:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:-ms-flexbox!important;display:flex!important}.d-sm-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:768px){.d-md-none{display:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:-ms-flexbox!important;display:flex!important}.d-md-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:992px){.d-lg-none{display:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:-ms-flexbox!important;display:flex!important}.d-lg-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:1200px){.d-xl-none{display:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:-ms-flexbox!important;display:flex!important}.d-xl-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media print{.d-print-none{display:none!important}.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:-ms-flexbox!important;display:flex!important}.d-print-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}.embed-responsive{position:relative;display:block;width:100%;padding:0;overflow:hidden}.embed-responsive::before{display:block;content:""}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive-21by9::before{padding-top:42.857143%}.embed-responsive-16by9::before{padding-top:56.25%}.embed-responsive-4by3::before{padding-top:75%}.embed-responsive-1by1::before{padding-top:100%}.flex-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-center{-ms-flex-align:center!important;align-items:center!important}.align-items-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}@media (min-width:576px){.flex-sm-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-sm-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-sm-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-sm-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-sm-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-sm-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-sm-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-sm-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-sm-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-sm-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-sm-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-sm-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-sm-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-sm-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-sm-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-sm-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-sm-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-sm-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-sm-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-sm-center{-ms-flex-align:center!important;align-items:center!important}.align-items-sm-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-sm-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-sm-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-sm-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-sm-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-sm-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-sm-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-sm-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-sm-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-sm-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-sm-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-sm-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-sm-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-sm-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:768px){.flex-md-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-md-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-md-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-md-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-md-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-md-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-md-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-md-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-md-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-md-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-md-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-md-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-md-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-md-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-md-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-md-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-md-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-md-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-md-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-md-center{-ms-flex-align:center!important;align-items:center!important}.align-items-md-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-md-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-md-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-md-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-md-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-md-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-md-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-md-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-md-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-md-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-md-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-md-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-md-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-md-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:992px){.flex-lg-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-lg-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-lg-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-lg-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-lg-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-lg-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-lg-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-lg-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-lg-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-lg-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-lg-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-lg-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-lg-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-lg-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-lg-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-lg-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-lg-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-lg-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-lg-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-lg-center{-ms-flex-align:center!important;align-items:center!important}.align-items-lg-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-lg-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-lg-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-lg-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-lg-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-lg-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-lg-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-lg-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-lg-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-lg-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-lg-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-lg-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-lg-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-lg-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:1200px){.flex-xl-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-xl-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-xl-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-xl-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-xl-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-xl-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-xl-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-xl-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-xl-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-xl-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-xl-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-xl-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-xl-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-xl-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-xl-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-xl-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-xl-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-xl-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-xl-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-xl-center{-ms-flex-align:center!important;align-items:center!important}.align-items-xl-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-xl-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-xl-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-xl-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-xl-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-xl-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-xl-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-xl-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-xl-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-xl-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-xl-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-xl-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-xl-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-xl-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}.float-left{float:left!important}.float-right{float:right!important}.float-none{float:none!important}@media (min-width:576px){.float-sm-left{float:left!important}.float-sm-right{float:right!important}.float-sm-none{float:none!important}}@media (min-width:768px){.float-md-left{float:left!important}.float-md-right{float:right!important}.float-md-none{float:none!important}}@media (min-width:992px){.float-lg-left{float:left!important}.float-lg-right{float:right!important}.float-lg-none{float:none!important}}@media (min-width:1200px){.float-xl-left{float:left!important}.float-xl-right{float:right!important}.float-xl-none{float:none!important}}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;-ms-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;-ms-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;-ms-user-select:none!important;user-select:none!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}@supports ((position:-webkit-sticky) or (position:sticky)){.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;overflow:visible;clip:auto;white-space:normal}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mw-100{max-width:100%!important}.mh-100{max-height:100%!important}.min-vw-100{min-width:100vw!important}.min-vh-100{min-height:100vh!important}.vw-100{width:100vw!important}.vh-100{height:100vh!important}.m-0{margin:0!important}.mt-0,.my-0{margin-top:0!important}.mr-0,.mx-0{margin-right:0!important}.mb-0,.my-0{margin-bottom:0!important}.ml-0,.mx-0{margin-left:0!important}.m-1{margin:.25rem!important}.mt-1,.my-1{margin-top:.25rem!important}.mr-1,.mx-1{margin-right:.25rem!important}.mb-1,.my-1{margin-bottom:.25rem!important}.ml-1,.mx-1{margin-left:.25rem!important}.m-2{margin:.5rem!important}.mt-2,.my-2{margin-top:.5rem!important}.mr-2,.mx-2{margin-right:.5rem!important}.mb-2,.my-2{margin-bottom:.5rem!important}.ml-2,.mx-2{margin-left:.5rem!important}.m-3{margin:1rem!important}.mt-3,.my-3{margin-top:1rem!important}.mr-3,.mx-3{margin-right:1rem!important}.mb-3,.my-3{margin-bottom:1rem!important}.ml-3,.mx-3{margin-left:1rem!important}.m-4{margin:1.5rem!important}.mt-4,.my-4{margin-top:1.5rem!important}.mr-4,.mx-4{margin-right:1.5rem!important}.mb-4,.my-4{margin-bottom:1.5rem!important}.ml-4,.mx-4{margin-left:1.5rem!important}.m-5{margin:3rem!important}.mt-5,.my-5{margin-top:3rem!important}.mr-5,.mx-5{margin-right:3rem!important}.mb-5,.my-5{margin-bottom:3rem!important}.ml-5,.mx-5{margin-left:3rem!important}.p-0{padding:0!important}.pt-0,.py-0{padding-top:0!important}.pr-0,.px-0{padding-right:0!important}.pb-0,.py-0{padding-bottom:0!important}.pl-0,.px-0{padding-left:0!important}.p-1{padding:.25rem!important}.pt-1,.py-1{padding-top:.25rem!important}.pr-1,.px-1{padding-right:.25rem!important}.pb-1,.py-1{padding-bottom:.25rem!important}.pl-1,.px-1{padding-left:.25rem!important}.p-2{padding:.5rem!important}.pt-2,.py-2{padding-top:.5rem!important}.pr-2,.px-2{padding-right:.5rem!important}.pb-2,.py-2{padding-bottom:.5rem!important}.pl-2,.px-2{padding-left:.5rem!important}.p-3{padding:1rem!important}.pt-3,.py-3{padding-top:1rem!important}.pr-3,.px-3{padding-right:1rem!important}.pb-3,.py-3{padding-bottom:1rem!important}.pl-3,.px-3{padding-left:1rem!important}.p-4{padding:1.5rem!important}.pt-4,.py-4{padding-top:1.5rem!important}.pr-4,.px-4{padding-right:1.5rem!important}.pb-4,.py-4{padding-bottom:1.5rem!important}.pl-4,.px-4{padding-left:1.5rem!important}.p-5{padding:3rem!important}.pt-5,.py-5{padding-top:3rem!important}.pr-5,.px-5{padding-right:3rem!important}.pb-5,.py-5{padding-bottom:3rem!important}.pl-5,.px-5{padding-left:3rem!important}.m-n1{margin:-.25rem!important}.mt-n1,.my-n1{margin-top:-.25rem!important}.mr-n1,.mx-n1{margin-right:-.25rem!important}.mb-n1,.my-n1{margin-bottom:-.25rem!important}.ml-n1,.mx-n1{margin-left:-.25rem!important}.m-n2{margin:-.5rem!important}.mt-n2,.my-n2{margin-top:-.5rem!important}.mr-n2,.mx-n2{margin-right:-.5rem!important}.mb-n2,.my-n2{margin-bottom:-.5rem!important}.ml-n2,.mx-n2{margin-left:-.5rem!important}.m-n3{margin:-1rem!important}.mt-n3,.my-n3{margin-top:-1rem!important}.mr-n3,.mx-n3{margin-right:-1rem!important}.mb-n3,.my-n3{margin-bottom:-1rem!important}.ml-n3,.mx-n3{margin-left:-1rem!important}.m-n4{margin:-1.5rem!important}.mt-n4,.my-n4{margin-top:-1.5rem!important}.mr-n4,.mx-n4{margin-right:-1.5rem!important}.mb-n4,.my-n4{margin-bottom:-1.5rem!important}.ml-n4,.mx-n4{margin-left:-1.5rem!important}.m-n5{margin:-3rem!important}.mt-n5,.my-n5{margin-top:-3rem!important}.mr-n5,.mx-n5{margin-right:-3rem!important}.mb-n5,.my-n5{margin-bottom:-3rem!important}.ml-n5,.mx-n5{margin-left:-3rem!important}.m-auto{margin:auto!important}.mt-auto,.my-auto{margin-top:auto!important}.mr-auto,.mx-auto{margin-right:auto!important}.mb-auto,.my-auto{margin-bottom:auto!important}.ml-auto,.mx-auto{margin-left:auto!important}@media (min-width:576px){.m-sm-0{margin:0!important}.mt-sm-0,.my-sm-0{margin-top:0!important}.mr-sm-0,.mx-sm-0{margin-right:0!important}.mb-sm-0,.my-sm-0{margin-bottom:0!important}.ml-sm-0,.mx-sm-0{margin-left:0!important}.m-sm-1{margin:.25rem!important}.mt-sm-1,.my-sm-1{margin-top:.25rem!important}.mr-sm-1,.mx-sm-1{margin-right:.25rem!important}.mb-sm-1,.my-sm-1{margin-bottom:.25rem!important}.ml-sm-1,.mx-sm-1{margin-left:.25rem!important}.m-sm-2{margin:.5rem!important}.mt-sm-2,.my-sm-2{margin-top:.5rem!important}.mr-sm-2,.mx-sm-2{margin-right:.5rem!important}.mb-sm-2,.my-sm-2{margin-bottom:.5rem!important}.ml-sm-2,.mx-sm-2{margin-left:.5rem!important}.m-sm-3{margin:1rem!important}.mt-sm-3,.my-sm-3{margin-top:1rem!important}.mr-sm-3,.mx-sm-3{margin-right:1rem!important}.mb-sm-3,.my-sm-3{margin-bottom:1rem!important}.ml-sm-3,.mx-sm-3{margin-left:1rem!important}.m-sm-4{margin:1.5rem!important}.mt-sm-4,.my-sm-4{margin-top:1.5rem!important}.mr-sm-4,.mx-sm-4{margin-right:1.5rem!important}.mb-sm-4,.my-sm-4{margin-bottom:1.5rem!important}.ml-sm-4,.mx-sm-4{margin-left:1.5rem!important}.m-sm-5{margin:3rem!important}.mt-sm-5,.my-sm-5{margin-top:3rem!important}.mr-sm-5,.mx-sm-5{margin-right:3rem!important}.mb-sm-5,.my-sm-5{margin-bottom:3rem!important}.ml-sm-5,.mx-sm-5{margin-left:3rem!important}.p-sm-0{padding:0!important}.pt-sm-0,.py-sm-0{padding-top:0!important}.pr-sm-0,.px-sm-0{padding-right:0!important}.pb-sm-0,.py-sm-0{padding-bottom:0!important}.pl-sm-0,.px-sm-0{padding-left:0!important}.p-sm-1{padding:.25rem!important}.pt-sm-1,.py-sm-1{padding-top:.25rem!important}.pr-sm-1,.px-sm-1{padding-right:.25rem!important}.pb-sm-1,.py-sm-1{padding-bottom:.25rem!important}.pl-sm-1,.px-sm-1{padding-left:.25rem!important}.p-sm-2{padding:.5rem!important}.pt-sm-2,.py-sm-2{padding-top:.5rem!important}.pr-sm-2,.px-sm-2{padding-right:.5rem!important}.pb-sm-2,.py-sm-2{padding-bottom:.5rem!important}.pl-sm-2,.px-sm-2{padding-left:.5rem!important}.p-sm-3{padding:1rem!important}.pt-sm-3,.py-sm-3{padding-top:1rem!important}.pr-sm-3,.px-sm-3{padding-right:1rem!important}.pb-sm-3,.py-sm-3{padding-bottom:1rem!important}.pl-sm-3,.px-sm-3{padding-left:1rem!important}.p-sm-4{padding:1.5rem!important}.pt-sm-4,.py-sm-4{padding-top:1.5rem!important}.pr-sm-4,.px-sm-4{padding-right:1.5rem!important}.pb-sm-4,.py-sm-4{padding-bottom:1.5rem!important}.pl-sm-4,.px-sm-4{padding-left:1.5rem!important}.p-sm-5{padding:3rem!important}.pt-sm-5,.py-sm-5{padding-top:3rem!important}.pr-sm-5,.px-sm-5{padding-right:3rem!important}.pb-sm-5,.py-sm-5{padding-bottom:3rem!important}.pl-sm-5,.px-sm-5{padding-left:3rem!important}.m-sm-n1{margin:-.25rem!important}.mt-sm-n1,.my-sm-n1{margin-top:-.25rem!important}.mr-sm-n1,.mx-sm-n1{margin-right:-.25rem!important}.mb-sm-n1,.my-sm-n1{margin-bottom:-.25rem!important}.ml-sm-n1,.mx-sm-n1{margin-left:-.25rem!important}.m-sm-n2{margin:-.5rem!important}.mt-sm-n2,.my-sm-n2{margin-top:-.5rem!important}.mr-sm-n2,.mx-sm-n2{margin-right:-.5rem!important}.mb-sm-n2,.my-sm-n2{margin-bottom:-.5rem!important}.ml-sm-n2,.mx-sm-n2{margin-left:-.5rem!important}.m-sm-n3{margin:-1rem!important}.mt-sm-n3,.my-sm-n3{margin-top:-1rem!important}.mr-sm-n3,.mx-sm-n3{margin-right:-1rem!important}.mb-sm-n3,.my-sm-n3{margin-bottom:-1rem!important}.ml-sm-n3,.mx-sm-n3{margin-left:-1rem!important}.m-sm-n4{margin:-1.5rem!important}.mt-sm-n4,.my-sm-n4{margin-top:-1.5rem!important}.mr-sm-n4,.mx-sm-n4{margin-right:-1.5rem!important}.mb-sm-n4,.my-sm-n4{margin-bottom:-1.5rem!important}.ml-sm-n4,.mx-sm-n4{margin-left:-1.5rem!important}.m-sm-n5{margin:-3rem!important}.mt-sm-n5,.my-sm-n5{margin-top:-3rem!important}.mr-sm-n5,.mx-sm-n5{margin-right:-3rem!important}.mb-sm-n5,.my-sm-n5{margin-bottom:-3rem!important}.ml-sm-n5,.mx-sm-n5{margin-left:-3rem!important}.m-sm-auto{margin:auto!important}.mt-sm-auto,.my-sm-auto{margin-top:auto!important}.mr-sm-auto,.mx-sm-auto{margin-right:auto!important}.mb-sm-auto,.my-sm-auto{margin-bottom:auto!important}.ml-sm-auto,.mx-sm-auto{margin-left:auto!important}}@media (min-width:768px){.m-md-0{margin:0!important}.mt-md-0,.my-md-0{margin-top:0!important}.mr-md-0,.mx-md-0{margin-right:0!important}.mb-md-0,.my-md-0{margin-bottom:0!important}.ml-md-0,.mx-md-0{margin-left:0!important}.m-md-1{margin:.25rem!important}.mt-md-1,.my-md-1{margin-top:.25rem!important}.mr-md-1,.mx-md-1{margin-right:.25rem!important}.mb-md-1,.my-md-1{margin-bottom:.25rem!important}.ml-md-1,.mx-md-1{margin-left:.25rem!important}.m-md-2{margin:.5rem!important}.mt-md-2,.my-md-2{margin-top:.5rem!important}.mr-md-2,.mx-md-2{margin-right:.5rem!important}.mb-md-2,.my-md-2{margin-bottom:.5rem!important}.ml-md-2,.mx-md-2{margin-left:.5rem!important}.m-md-3{margin:1rem!important}.mt-md-3,.my-md-3{margin-top:1rem!important}.mr-md-3,.mx-md-3{margin-right:1rem!important}.mb-md-3,.my-md-3{margin-bottom:1rem!important}.ml-md-3,.mx-md-3{margin-left:1rem!important}.m-md-4{margin:1.5rem!important}.mt-md-4,.my-md-4{margin-top:1.5rem!important}.mr-md-4,.mx-md-4{margin-right:1.5rem!important}.mb-md-4,.my-md-4{margin-bottom:1.5rem!important}.ml-md-4,.mx-md-4{margin-left:1.5rem!important}.m-md-5{margin:3rem!important}.mt-md-5,.my-md-5{margin-top:3rem!important}.mr-md-5,.mx-md-5{margin-right:3rem!important}.mb-md-5,.my-md-5{margin-bottom:3rem!important}.ml-md-5,.mx-md-5{margin-left:3rem!important}.p-md-0{padding:0!important}.pt-md-0,.py-md-0{padding-top:0!important}.pr-md-0,.px-md-0{padding-right:0!important}.pb-md-0,.py-md-0{padding-bottom:0!important}.pl-md-0,.px-md-0{padding-left:0!important}.p-md-1{padding:.25rem!important}.pt-md-1,.py-md-1{padding-top:.25rem!important}.pr-md-1,.px-md-1{padding-right:.25rem!important}.pb-md-1,.py-md-1{padding-bottom:.25rem!important}.pl-md-1,.px-md-1{padding-left:.25rem!important}.p-md-2{padding:.5rem!important}.pt-md-2,.py-md-2{padding-top:.5rem!important}.pr-md-2,.px-md-2{padding-right:.5rem!important}.pb-md-2,.py-md-2{padding-bottom:.5rem!important}.pl-md-2,.px-md-2{padding-left:.5rem!important}.p-md-3{padding:1rem!important}.pt-md-3,.py-md-3{padding-top:1rem!important}.pr-md-3,.px-md-3{padding-right:1rem!important}.pb-md-3,.py-md-3{padding-bottom:1rem!important}.pl-md-3,.px-md-3{padding-left:1rem!important}.p-md-4{padding:1.5rem!important}.pt-md-4,.py-md-4{padding-top:1.5rem!important}.pr-md-4,.px-md-4{padding-right:1.5rem!important}.pb-md-4,.py-md-4{padding-bottom:1.5rem!important}.pl-md-4,.px-md-4{padding-left:1.5rem!important}.p-md-5{padding:3rem!important}.pt-md-5,.py-md-5{padding-top:3rem!important}.pr-md-5,.px-md-5{padding-right:3rem!important}.pb-md-5,.py-md-5{padding-bottom:3rem!important}.pl-md-5,.px-md-5{padding-left:3rem!important}.m-md-n1{margin:-.25rem!important}.mt-md-n1,.my-md-n1{margin-top:-.25rem!important}.mr-md-n1,.mx-md-n1{margin-right:-.25rem!important}.mb-md-n1,.my-md-n1{margin-bottom:-.25rem!important}.ml-md-n1,.mx-md-n1{margin-left:-.25rem!important}.m-md-n2{margin:-.5rem!important}.mt-md-n2,.my-md-n2{margin-top:-.5rem!important}.mr-md-n2,.mx-md-n2{margin-right:-.5rem!important}.mb-md-n2,.my-md-n2{margin-bottom:-.5rem!important}.ml-md-n2,.mx-md-n2{margin-left:-.5rem!important}.m-md-n3{margin:-1rem!important}.mt-md-n3,.my-md-n3{margin-top:-1rem!important}.mr-md-n3,.mx-md-n3{margin-right:-1rem!important}.mb-md-n3,.my-md-n3{margin-bottom:-1rem!important}.ml-md-n3,.mx-md-n3{margin-left:-1rem!important}.m-md-n4{margin:-1.5rem!important}.mt-md-n4,.my-md-n4{margin-top:-1.5rem!important}.mr-md-n4,.mx-md-n4{margin-right:-1.5rem!important}.mb-md-n4,.my-md-n4{margin-bottom:-1.5rem!important}.ml-md-n4,.mx-md-n4{margin-left:-1.5rem!important}.m-md-n5{margin:-3rem!important}.mt-md-n5,.my-md-n5{margin-top:-3rem!important}.mr-md-n5,.mx-md-n5{margin-right:-3rem!important}.mb-md-n5,.my-md-n5{margin-bottom:-3rem!important}.ml-md-n5,.mx-md-n5{margin-left:-3rem!important}.m-md-auto{margin:auto!important}.mt-md-auto,.my-md-auto{margin-top:auto!important}.mr-md-auto,.mx-md-auto{margin-right:auto!important}.mb-md-auto,.my-md-auto{margin-bottom:auto!important}.ml-md-auto,.mx-md-auto{margin-left:auto!important}}@media (min-width:992px){.m-lg-0{margin:0!important}.mt-lg-0,.my-lg-0{margin-top:0!important}.mr-lg-0,.mx-lg-0{margin-right:0!important}.mb-lg-0,.my-lg-0{margin-bottom:0!important}.ml-lg-0,.mx-lg-0{margin-left:0!important}.m-lg-1{margin:.25rem!important}.mt-lg-1,.my-lg-1{margin-top:.25rem!important}.mr-lg-1,.mx-lg-1{margin-right:.25rem!important}.mb-lg-1,.my-lg-1{margin-bottom:.25rem!important}.ml-lg-1,.mx-lg-1{margin-left:.25rem!important}.m-lg-2{margin:.5rem!important}.mt-lg-2,.my-lg-2{margin-top:.5rem!important}.mr-lg-2,.mx-lg-2{margin-right:.5rem!important}.mb-lg-2,.my-lg-2{margin-bottom:.5rem!important}.ml-lg-2,.mx-lg-2{margin-left:.5rem!important}.m-lg-3{margin:1rem!important}.mt-lg-3,.my-lg-3{margin-top:1rem!important}.mr-lg-3,.mx-lg-3{margin-right:1rem!important}.mb-lg-3,.my-lg-3{margin-bottom:1rem!important}.ml-lg-3,.mx-lg-3{margin-left:1rem!important}.m-lg-4{margin:1.5rem!important}.mt-lg-4,.my-lg-4{margin-top:1.5rem!important}.mr-lg-4,.mx-lg-4{margin-right:1.5rem!important}.mb-lg-4,.my-lg-4{margin-bottom:1.5rem!important}.ml-lg-4,.mx-lg-4{margin-left:1.5rem!important}.m-lg-5{margin:3rem!important}.mt-lg-5,.my-lg-5{margin-top:3rem!important}.mr-lg-5,.mx-lg-5{margin-right:3rem!important}.mb-lg-5,.my-lg-5{margin-bottom:3rem!important}.ml-lg-5,.mx-lg-5{margin-left:3rem!important}.p-lg-0{padding:0!important}.pt-lg-0,.py-lg-0{padding-top:0!important}.pr-lg-0,.px-lg-0{padding-right:0!important}.pb-lg-0,.py-lg-0{padding-bottom:0!important}.pl-lg-0,.px-lg-0{padding-left:0!important}.p-lg-1{padding:.25rem!important}.pt-lg-1,.py-lg-1{padding-top:.25rem!important}.pr-lg-1,.px-lg-1{padding-right:.25rem!important}.pb-lg-1,.py-lg-1{padding-bottom:.25rem!important}.pl-lg-1,.px-lg-1{padding-left:.25rem!important}.p-lg-2{padding:.5rem!important}.pt-lg-2,.py-lg-2{padding-top:.5rem!important}.pr-lg-2,.px-lg-2{padding-right:.5rem!important}.pb-lg-2,.py-lg-2{padding-bottom:.5rem!important}.pl-lg-2,.px-lg-2{padding-left:.5rem!important}.p-lg-3{padding:1rem!important}.pt-lg-3,.py-lg-3{padding-top:1rem!important}.pr-lg-3,.px-lg-3{padding-right:1rem!important}.pb-lg-3,.py-lg-3{padding-bottom:1rem!important}.pl-lg-3,.px-lg-3{padding-left:1rem!important}.p-lg-4{padding:1.5rem!important}.pt-lg-4,.py-lg-4{padding-top:1.5rem!important}.pr-lg-4,.px-lg-4{padding-right:1.5rem!important}.pb-lg-4,.py-lg-4{padding-bottom:1.5rem!important}.pl-lg-4,.px-lg-4{padding-left:1.5rem!important}.p-lg-5{padding:3rem!important}.pt-lg-5,.py-lg-5{padding-top:3rem!important}.pr-lg-5,.px-lg-5{padding-right:3rem!important}.pb-lg-5,.py-lg-5{padding-bottom:3rem!important}.pl-lg-5,.px-lg-5{padding-left:3rem!important}.m-lg-n1{margin:-.25rem!important}.mt-lg-n1,.my-lg-n1{margin-top:-.25rem!important}.mr-lg-n1,.mx-lg-n1{margin-right:-.25rem!important}.mb-lg-n1,.my-lg-n1{margin-bottom:-.25rem!important}.ml-lg-n1,.mx-lg-n1{margin-left:-.25rem!important}.m-lg-n2{margin:-.5rem!important}.mt-lg-n2,.my-lg-n2{margin-top:-.5rem!important}.mr-lg-n2,.mx-lg-n2{margin-right:-.5rem!important}.mb-lg-n2,.my-lg-n2{margin-bottom:-.5rem!important}.ml-lg-n2,.mx-lg-n2{margin-left:-.5rem!important}.m-lg-n3{margin:-1rem!important}.mt-lg-n3,.my-lg-n3{margin-top:-1rem!important}.mr-lg-n3,.mx-lg-n3{margin-right:-1rem!important}.mb-lg-n3,.my-lg-n3{margin-bottom:-1rem!important}.ml-lg-n3,.mx-lg-n3{margin-left:-1rem!important}.m-lg-n4{margin:-1.5rem!important}.mt-lg-n4,.my-lg-n4{margin-top:-1.5rem!important}.mr-lg-n4,.mx-lg-n4{margin-right:-1.5rem!important}.mb-lg-n4,.my-lg-n4{margin-bottom:-1.5rem!important}.ml-lg-n4,.mx-lg-n4{margin-left:-1.5rem!important}.m-lg-n5{margin:-3rem!important}.mt-lg-n5,.my-lg-n5{margin-top:-3rem!important}.mr-lg-n5,.mx-lg-n5{margin-right:-3rem!important}.mb-lg-n5,.my-lg-n5{margin-bottom:-3rem!important}.ml-lg-n5,.mx-lg-n5{margin-left:-3rem!important}.m-lg-auto{margin:auto!important}.mt-lg-auto,.my-lg-auto{margin-top:auto!important}.mr-lg-auto,.mx-lg-auto{margin-right:auto!important}.mb-lg-auto,.my-lg-auto{margin-bottom:auto!important}.ml-lg-auto,.mx-lg-auto{margin-left:auto!important}}@media (min-width:1200px){.m-xl-0{margin:0!important}.mt-xl-0,.my-xl-0{margin-top:0!important}.mr-xl-0,.mx-xl-0{margin-right:0!important}.mb-xl-0,.my-xl-0{margin-bottom:0!important}.ml-xl-0,.mx-xl-0{margin-left:0!important}.m-xl-1{margin:.25rem!important}.mt-xl-1,.my-xl-1{margin-top:.25rem!important}.mr-xl-1,.mx-xl-1{margin-right:.25rem!important}.mb-xl-1,.my-xl-1{margin-bottom:.25rem!important}.ml-xl-1,.mx-xl-1{margin-left:.25rem!important}.m-xl-2{margin:.5rem!important}.mt-xl-2,.my-xl-2{margin-top:.5rem!important}.mr-xl-2,.mx-xl-2{margin-right:.5rem!important}.mb-xl-2,.my-xl-2{margin-bottom:.5rem!important}.ml-xl-2,.mx-xl-2{margin-left:.5rem!important}.m-xl-3{margin:1rem!important}.mt-xl-3,.my-xl-3{margin-top:1rem!important}.mr-xl-3,.mx-xl-3{margin-right:1rem!important}.mb-xl-3,.my-xl-3{margin-bottom:1rem!important}.ml-xl-3,.mx-xl-3{margin-left:1rem!important}.m-xl-4{margin:1.5rem!important}.mt-xl-4,.my-xl-4{margin-top:1.5rem!important}.mr-xl-4,.mx-xl-4{margin-right:1.5rem!important}.mb-xl-4,.my-xl-4{margin-bottom:1.5rem!important}.ml-xl-4,.mx-xl-4{margin-left:1.5rem!important}.m-xl-5{margin:3rem!important}.mt-xl-5,.my-xl-5{margin-top:3rem!important}.mr-xl-5,.mx-xl-5{margin-right:3rem!important}.mb-xl-5,.my-xl-5{margin-bottom:3rem!important}.ml-xl-5,.mx-xl-5{margin-left:3rem!important}.p-xl-0{padding:0!important}.pt-xl-0,.py-xl-0{padding-top:0!important}.pr-xl-0,.px-xl-0{padding-right:0!important}.pb-xl-0,.py-xl-0{padding-bottom:0!important}.pl-xl-0,.px-xl-0{padding-left:0!important}.p-xl-1{padding:.25rem!important}.pt-xl-1,.py-xl-1{padding-top:.25rem!important}.pr-xl-1,.px-xl-1{padding-right:.25rem!important}.pb-xl-1,.py-xl-1{padding-bottom:.25rem!important}.pl-xl-1,.px-xl-1{padding-left:.25rem!important}.p-xl-2{padding:.5rem!important}.pt-xl-2,.py-xl-2{padding-top:.5rem!important}.pr-xl-2,.px-xl-2{padding-right:.5rem!important}.pb-xl-2,.py-xl-2{padding-bottom:.5rem!important}.pl-xl-2,.px-xl-2{padding-left:.5rem!important}.p-xl-3{padding:1rem!important}.pt-xl-3,.py-xl-3{padding-top:1rem!important}.pr-xl-3,.px-xl-3{padding-right:1rem!important}.pb-xl-3,.py-xl-3{padding-bottom:1rem!important}.pl-xl-3,.px-xl-3{padding-left:1rem!important}.p-xl-4{padding:1.5rem!important}.pt-xl-4,.py-xl-4{padding-top:1.5rem!important}.pr-xl-4,.px-xl-4{padding-right:1.5rem!important}.pb-xl-4,.py-xl-4{padding-bottom:1.5rem!important}.pl-xl-4,.px-xl-4{padding-left:1.5rem!important}.p-xl-5{padding:3rem!important}.pt-xl-5,.py-xl-5{padding-top:3rem!important}.pr-xl-5,.px-xl-5{padding-right:3rem!important}.pb-xl-5,.py-xl-5{padding-bottom:3rem!important}.pl-xl-5,.px-xl-5{padding-left:3rem!important}.m-xl-n1{margin:-.25rem!important}.mt-xl-n1,.my-xl-n1{margin-top:-.25rem!important}.mr-xl-n1,.mx-xl-n1{margin-right:-.25rem!important}.mb-xl-n1,.my-xl-n1{margin-bottom:-.25rem!important}.ml-xl-n1,.mx-xl-n1{margin-left:-.25rem!important}.m-xl-n2{margin:-.5rem!important}.mt-xl-n2,.my-xl-n2{margin-top:-.5rem!important}.mr-xl-n2,.mx-xl-n2{margin-right:-.5rem!important}.mb-xl-n2,.my-xl-n2{margin-bottom:-.5rem!important}.ml-xl-n2,.mx-xl-n2{margin-left:-.5rem!important}.m-xl-n3{margin:-1rem!important}.mt-xl-n3,.my-xl-n3{margin-top:-1rem!important}.mr-xl-n3,.mx-xl-n3{margin-right:-1rem!important}.mb-xl-n3,.my-xl-n3{margin-bottom:-1rem!important}.ml-xl-n3,.mx-xl-n3{margin-left:-1rem!important}.m-xl-n4{margin:-1.5rem!important}.mt-xl-n4,.my-xl-n4{margin-top:-1.5rem!important}.mr-xl-n4,.mx-xl-n4{margin-right:-1.5rem!important}.mb-xl-n4,.my-xl-n4{margin-bottom:-1.5rem!important}.ml-xl-n4,.mx-xl-n4{margin-left:-1.5rem!important}.m-xl-n5{margin:-3rem!important}.mt-xl-n5,.my-xl-n5{margin-top:-3rem!important}.mr-xl-n5,.mx-xl-n5{margin-right:-3rem!important}.mb-xl-n5,.my-xl-n5{margin-bottom:-3rem!important}.ml-xl-n5,.mx-xl-n5{margin-left:-3rem!important}.m-xl-auto{margin:auto!important}.mt-xl-auto,.my-xl-auto{margin-top:auto!important}.mr-xl-auto,.mx-xl-auto{margin-right:auto!important}.mb-xl-auto,.my-xl-auto{margin-bottom:auto!important}.ml-xl-auto,.mx-xl-auto{margin-left:auto!important}}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;pointer-events:auto;content:"";background-color:rgba(0,0,0,0)}.text-monospace{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace!important}.text-justify{text-align:justify!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-left{text-align:left!important}.text-right{text-align:right!important}.text-center{text-align:center!important}@media (min-width:576px){.text-sm-left{text-align:left!important}.text-sm-right{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.text-md-left{text-align:left!important}.text-md-right{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.text-lg-left{text-align:left!important}.text-lg-right{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.text-xl-left{text-align:left!important}.text-xl-right{text-align:right!important}.text-xl-center{text-align:center!important}}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.font-weight-light{font-weight:300!important}.font-weight-lighter{font-weight:lighter!important}.font-weight-normal{font-weight:400!important}.font-weight-bold{font-weight:700!important}.font-weight-bolder{font-weight:bolder!important}.font-italic{font-style:italic!important}.text-white{color:#fff!important}.text-primary{color:#007bff!important}a.text-primary:focus,a.text-primary:hover{color:#0056b3!important}.text-secondary{color:#6c757d!important}a.text-secondary:focus,a.text-secondary:hover{color:#494f54!important}.text-success{color:#28a745!important}a.text-success:focus,a.text-success:hover{color:#19692c!important}.text-info{color:#17a2b8!important}a.text-info:focus,a.text-info:hover{color:#0f6674!important}.text-warning{color:#ffc107!important}a.text-warning:focus,a.text-warning:hover{color:#ba8b00!important}.text-danger{color:#dc3545!important}a.text-danger:focus,a.text-danger:hover{color:#a71d2a!important}.text-light{color:#f8f9fa!important}a.text-light:focus,a.text-light:hover{color:#cbd3da!important}.text-dark{color:#343a40!important}a.text-dark:focus,a.text-dark:hover{color:#121416!important}.text-body{color:#212529!important}.text-muted{color:#6c757d!important}.text-black-50{color:rgba(0,0,0,.5)!important}.text-white-50{color:rgba(255,255,255,.5)!important}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.text-decoration-none{text-decoration:none!important}.text-break{word-break:break-word!important;overflow-wrap:break-word!important}.text-reset{color:inherit!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media print{*,::after,::before{text-shadow:none!important;box-shadow:none!important}a:not(.btn){text-decoration:underline}abbr[title]::after{content:" (" attr(title) ")"}pre{white-space:pre-wrap!important}blockquote,pre{border:1px solid #adb5bd;page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}@page{size:a3}body{min-width:992px!important}.container{min-width:992px!important}.navbar{display:none}.badge{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #dee2e6!important}.table-dark{color:inherit}.table-dark tbody+tbody,.table-dark td,.table-dark th,.table-dark thead th{border-color:#dee2e6}.table .thead-dark th{color:inherit;border-color:#dee2e6}} -/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/static/vendor/bootstrap/bootstrap.min.js b/static/vendor/bootstrap/bootstrap.min.js deleted file mode 100644 index ef4d9cbd..00000000 --- a/static/vendor/bootstrap/bootstrap.min.js +++ /dev/null @@ -1,7 +0,0 @@ -/*! - * Bootstrap v4.5.2 (https://getbootstrap.com/) - * Copyright 2011-2020 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - */ -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports,require("jquery"),require("popper.js")):"function"==typeof define&&define.amd?define(["exports","jquery","popper.js"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap={},t.jQuery,t.Popper)}(this,(function(t,e,n){"use strict";function i(t,e){for(var n=0;n=4)throw new Error("Bootstrap's JavaScript requires at least jQuery v1.9.1 but less than v4.0.0")}};a.jQueryDetection(),e.fn.emulateTransitionEnd=r,e.event.special[a.TRANSITION_END]={bindType:"transitionend",delegateType:"transitionend",handle:function(t){if(e(t.target).is(this))return t.handleObj.handler.apply(this,arguments)}};var l="alert",c=e.fn[l],h=function(){function t(t){this._element=t}var n=t.prototype;return n.close=function(t){var e=this._element;t&&(e=this._getRootElement(t)),this._triggerCloseEvent(e).isDefaultPrevented()||this._removeElement(e)},n.dispose=function(){e.removeData(this._element,"bs.alert"),this._element=null},n._getRootElement=function(t){var n=a.getSelectorFromElement(t),i=!1;return n&&(i=document.querySelector(n)),i||(i=e(t).closest(".alert")[0]),i},n._triggerCloseEvent=function(t){var n=e.Event("close.bs.alert");return e(t).trigger(n),n},n._removeElement=function(t){var n=this;if(e(t).removeClass("show"),e(t).hasClass("fade")){var i=a.getTransitionDurationFromElement(t);e(t).one(a.TRANSITION_END,(function(e){return n._destroyElement(t,e)})).emulateTransitionEnd(i)}else this._destroyElement(t)},n._destroyElement=function(t){e(t).detach().trigger("closed.bs.alert").remove()},t._jQueryInterface=function(n){return this.each((function(){var i=e(this),o=i.data("bs.alert");o||(o=new t(this),i.data("bs.alert",o)),"close"===n&&o[n](this)}))},t._handleDismiss=function(t){return function(e){e&&e.preventDefault(),t.close(this)}},o(t,null,[{key:"VERSION",get:function(){return"4.5.2"}}]),t}();e(document).on("click.bs.alert.data-api",'[data-dismiss="alert"]',h._handleDismiss(new h)),e.fn[l]=h._jQueryInterface,e.fn[l].Constructor=h,e.fn[l].noConflict=function(){return e.fn[l]=c,h._jQueryInterface};var u=e.fn.button,d=function(){function t(t){this._element=t}var n=t.prototype;return n.toggle=function(){var t=!0,n=!0,i=e(this._element).closest('[data-toggle="buttons"]')[0];if(i){var o=this._element.querySelector('input:not([type="hidden"])');if(o){if("radio"===o.type)if(o.checked&&this._element.classList.contains("active"))t=!1;else{var s=i.querySelector(".active");s&&e(s).removeClass("active")}t&&("checkbox"!==o.type&&"radio"!==o.type||(o.checked=!this._element.classList.contains("active")),e(o).trigger("change")),o.focus(),n=!1}}this._element.hasAttribute("disabled")||this._element.classList.contains("disabled")||(n&&this._element.setAttribute("aria-pressed",!this._element.classList.contains("active")),t&&e(this._element).toggleClass("active"))},n.dispose=function(){e.removeData(this._element,"bs.button"),this._element=null},t._jQueryInterface=function(n){return this.each((function(){var i=e(this).data("bs.button");i||(i=new t(this),e(this).data("bs.button",i)),"toggle"===n&&i[n]()}))},o(t,null,[{key:"VERSION",get:function(){return"4.5.2"}}]),t}();e(document).on("click.bs.button.data-api",'[data-toggle^="button"]',(function(t){var n=t.target,i=n;if(e(n).hasClass("btn")||(n=e(n).closest(".btn")[0]),!n||n.hasAttribute("disabled")||n.classList.contains("disabled"))t.preventDefault();else{var o=n.querySelector('input:not([type="hidden"])');if(o&&(o.hasAttribute("disabled")||o.classList.contains("disabled")))return void t.preventDefault();("LABEL"!==i.tagName||o&&"checkbox"!==o.type)&&d._jQueryInterface.call(e(n),"toggle")}})).on("focus.bs.button.data-api blur.bs.button.data-api",'[data-toggle^="button"]',(function(t){var n=e(t.target).closest(".btn")[0];e(n).toggleClass("focus",/^focus(in)?$/.test(t.type))})),e(window).on("load.bs.button.data-api",(function(){for(var t=[].slice.call(document.querySelectorAll('[data-toggle="buttons"] .btn')),e=0,n=t.length;e0,this._pointerEvent=Boolean(window.PointerEvent||window.MSPointerEvent),this._addEventListeners()}var n=t.prototype;return n.next=function(){this._isSliding||this._slide("next")},n.nextWhenVisible=function(){!document.hidden&&e(this._element).is(":visible")&&"hidden"!==e(this._element).css("visibility")&&this.next()},n.prev=function(){this._isSliding||this._slide("prev")},n.pause=function(t){t||(this._isPaused=!0),this._element.querySelector(".carousel-item-next, .carousel-item-prev")&&(a.triggerTransitionEnd(this._element),this.cycle(!0)),clearInterval(this._interval),this._interval=null},n.cycle=function(t){t||(this._isPaused=!1),this._interval&&(clearInterval(this._interval),this._interval=null),this._config.interval&&!this._isPaused&&(this._interval=setInterval((document.visibilityState?this.nextWhenVisible:this.next).bind(this),this._config.interval))},n.to=function(t){var n=this;this._activeElement=this._element.querySelector(".active.carousel-item");var i=this._getItemIndex(this._activeElement);if(!(t>this._items.length-1||t<0))if(this._isSliding)e(this._element).one("slid.bs.carousel",(function(){return n.to(t)}));else{if(i===t)return this.pause(),void this.cycle();var o=t>i?"next":"prev";this._slide(o,this._items[t])}},n.dispose=function(){e(this._element).off(g),e.removeData(this._element,"bs.carousel"),this._items=null,this._config=null,this._element=null,this._interval=null,this._isPaused=null,this._isSliding=null,this._activeElement=null,this._indicatorsElement=null},n._getConfig=function(t){return t=s({},p,t),a.typeCheckConfig(f,t,_),t},n._handleSwipe=function(){var t=Math.abs(this.touchDeltaX);if(!(t<=40)){var e=t/this.touchDeltaX;this.touchDeltaX=0,e>0&&this.prev(),e<0&&this.next()}},n._addEventListeners=function(){var t=this;this._config.keyboard&&e(this._element).on("keydown.bs.carousel",(function(e){return t._keydown(e)})),"hover"===this._config.pause&&e(this._element).on("mouseenter.bs.carousel",(function(e){return t.pause(e)})).on("mouseleave.bs.carousel",(function(e){return t.cycle(e)})),this._config.touch&&this._addTouchEventListeners()},n._addTouchEventListeners=function(){var t=this;if(this._touchSupported){var n=function(e){t._pointerEvent&&v[e.originalEvent.pointerType.toUpperCase()]?t.touchStartX=e.originalEvent.clientX:t._pointerEvent||(t.touchStartX=e.originalEvent.touches[0].clientX)},i=function(e){t._pointerEvent&&v[e.originalEvent.pointerType.toUpperCase()]&&(t.touchDeltaX=e.originalEvent.clientX-t.touchStartX),t._handleSwipe(),"hover"===t._config.pause&&(t.pause(),t.touchTimeout&&clearTimeout(t.touchTimeout),t.touchTimeout=setTimeout((function(e){return t.cycle(e)}),500+t._config.interval))};e(this._element.querySelectorAll(".carousel-item img")).on("dragstart.bs.carousel",(function(t){return t.preventDefault()})),this._pointerEvent?(e(this._element).on("pointerdown.bs.carousel",(function(t){return n(t)})),e(this._element).on("pointerup.bs.carousel",(function(t){return i(t)})),this._element.classList.add("pointer-event")):(e(this._element).on("touchstart.bs.carousel",(function(t){return n(t)})),e(this._element).on("touchmove.bs.carousel",(function(e){return function(e){e.originalEvent.touches&&e.originalEvent.touches.length>1?t.touchDeltaX=0:t.touchDeltaX=e.originalEvent.touches[0].clientX-t.touchStartX}(e)})),e(this._element).on("touchend.bs.carousel",(function(t){return i(t)})))}},n._keydown=function(t){if(!/input|textarea/i.test(t.target.tagName))switch(t.which){case 37:t.preventDefault(),this.prev();break;case 39:t.preventDefault(),this.next()}},n._getItemIndex=function(t){return this._items=t&&t.parentNode?[].slice.call(t.parentNode.querySelectorAll(".carousel-item")):[],this._items.indexOf(t)},n._getItemByDirection=function(t,e){var n="next"===t,i="prev"===t,o=this._getItemIndex(e),s=this._items.length-1;if((i&&0===o||n&&o===s)&&!this._config.wrap)return e;var r=(o+("prev"===t?-1:1))%this._items.length;return-1===r?this._items[this._items.length-1]:this._items[r]},n._triggerSlideEvent=function(t,n){var i=this._getItemIndex(t),o=this._getItemIndex(this._element.querySelector(".active.carousel-item")),s=e.Event("slide.bs.carousel",{relatedTarget:t,direction:n,from:o,to:i});return e(this._element).trigger(s),s},n._setActiveIndicatorElement=function(t){if(this._indicatorsElement){var n=[].slice.call(this._indicatorsElement.querySelectorAll(".active"));e(n).removeClass("active");var i=this._indicatorsElement.children[this._getItemIndex(t)];i&&e(i).addClass("active")}},n._slide=function(t,n){var i,o,s,r=this,l=this._element.querySelector(".active.carousel-item"),c=this._getItemIndex(l),h=n||l&&this._getItemByDirection(t,l),u=this._getItemIndex(h),d=Boolean(this._interval);if("next"===t?(i="carousel-item-left",o="carousel-item-next",s="left"):(i="carousel-item-right",o="carousel-item-prev",s="right"),h&&e(h).hasClass("active"))this._isSliding=!1;else if(!this._triggerSlideEvent(h,s).isDefaultPrevented()&&l&&h){this._isSliding=!0,d&&this.pause(),this._setActiveIndicatorElement(h);var f=e.Event("slid.bs.carousel",{relatedTarget:h,direction:s,from:c,to:u});if(e(this._element).hasClass("slide")){e(h).addClass(o),a.reflow(h),e(l).addClass(i),e(h).addClass(i);var g=parseInt(h.getAttribute("data-interval"),10);g?(this._config.defaultInterval=this._config.defaultInterval||this._config.interval,this._config.interval=g):this._config.interval=this._config.defaultInterval||this._config.interval;var m=a.getTransitionDurationFromElement(l);e(l).one(a.TRANSITION_END,(function(){e(h).removeClass(i+" "+o).addClass("active"),e(l).removeClass("active "+o+" "+i),r._isSliding=!1,setTimeout((function(){return e(r._element).trigger(f)}),0)})).emulateTransitionEnd(m)}else e(l).removeClass("active"),e(h).addClass("active"),this._isSliding=!1,e(this._element).trigger(f);d&&this.cycle()}},t._jQueryInterface=function(n){return this.each((function(){var i=e(this).data("bs.carousel"),o=s({},p,e(this).data());"object"==typeof n&&(o=s({},o,n));var r="string"==typeof n?n:o.slide;if(i||(i=new t(this,o),e(this).data("bs.carousel",i)),"number"==typeof n)i.to(n);else if("string"==typeof r){if("undefined"==typeof i[r])throw new TypeError('No method named "'+r+'"');i[r]()}else o.interval&&o.ride&&(i.pause(),i.cycle())}))},t._dataApiClickHandler=function(n){var i=a.getSelectorFromElement(this);if(i){var o=e(i)[0];if(o&&e(o).hasClass("carousel")){var r=s({},e(o).data(),e(this).data()),l=this.getAttribute("data-slide-to");l&&(r.interval=!1),t._jQueryInterface.call(e(o),r),l&&e(o).data("bs.carousel").to(l),n.preventDefault()}}},o(t,null,[{key:"VERSION",get:function(){return"4.5.2"}},{key:"Default",get:function(){return p}}]),t}();e(document).on("click.bs.carousel.data-api","[data-slide], [data-slide-to]",b._dataApiClickHandler),e(window).on("load.bs.carousel.data-api",(function(){for(var t=[].slice.call(document.querySelectorAll('[data-ride="carousel"]')),n=0,i=t.length;n0&&(this._selector=r,this._triggerArray.push(s))}this._parent=this._config.parent?this._getParent():null,this._config.parent||this._addAriaAndCollapsedClass(this._element,this._triggerArray),this._config.toggle&&this.toggle()}var n=t.prototype;return n.toggle=function(){e(this._element).hasClass("show")?this.hide():this.show()},n.show=function(){var n,i,o=this;if(!this._isTransitioning&&!e(this._element).hasClass("show")&&(this._parent&&0===(n=[].slice.call(this._parent.querySelectorAll(".show, .collapsing")).filter((function(t){return"string"==typeof o._config.parent?t.getAttribute("data-parent")===o._config.parent:t.classList.contains("collapse")}))).length&&(n=null),!(n&&(i=e(n).not(this._selector).data("bs.collapse"))&&i._isTransitioning))){var s=e.Event("show.bs.collapse");if(e(this._element).trigger(s),!s.isDefaultPrevented()){n&&(t._jQueryInterface.call(e(n).not(this._selector),"hide"),i||e(n).data("bs.collapse",null));var r=this._getDimension();e(this._element).removeClass("collapse").addClass("collapsing"),this._element.style[r]=0,this._triggerArray.length&&e(this._triggerArray).removeClass("collapsed").attr("aria-expanded",!0),this.setTransitioning(!0);var l="scroll"+(r[0].toUpperCase()+r.slice(1)),c=a.getTransitionDurationFromElement(this._element);e(this._element).one(a.TRANSITION_END,(function(){e(o._element).removeClass("collapsing").addClass("collapse show"),o._element.style[r]="",o.setTransitioning(!1),e(o._element).trigger("shown.bs.collapse")})).emulateTransitionEnd(c),this._element.style[r]=this._element[l]+"px"}}},n.hide=function(){var t=this;if(!this._isTransitioning&&e(this._element).hasClass("show")){var n=e.Event("hide.bs.collapse");if(e(this._element).trigger(n),!n.isDefaultPrevented()){var i=this._getDimension();this._element.style[i]=this._element.getBoundingClientRect()[i]+"px",a.reflow(this._element),e(this._element).addClass("collapsing").removeClass("collapse show");var o=this._triggerArray.length;if(o>0)for(var s=0;s0},i._getOffset=function(){var t=this,e={};return"function"==typeof this._config.offset?e.fn=function(e){return e.offsets=s({},e.offsets,t._config.offset(e.offsets,t._element)||{}),e}:e.offset=this._config.offset,e},i._getPopperConfig=function(){var t={placement:this._getPlacement(),modifiers:{offset:this._getOffset(),flip:{enabled:this._config.flip},preventOverflow:{boundariesElement:this._config.boundary}}};return"static"===this._config.display&&(t.modifiers.applyStyle={enabled:!1}),s({},t,this._config.popperConfig)},t._jQueryInterface=function(n){return this.each((function(){var i=e(this).data("bs.dropdown");if(i||(i=new t(this,"object"==typeof n?n:null),e(this).data("bs.dropdown",i)),"string"==typeof n){if("undefined"==typeof i[n])throw new TypeError('No method named "'+n+'"');i[n]()}}))},t._clearMenus=function(n){if(!n||3!==n.which&&("keyup"!==n.type||9===n.which))for(var i=[].slice.call(document.querySelectorAll('[data-toggle="dropdown"]')),o=0,s=i.length;o0&&r--,40===n.which&&rdocument.documentElement.clientHeight;i||(this._element.style.overflowY="hidden"),this._element.classList.add("modal-static");var o=a.getTransitionDurationFromElement(this._dialog);e(this._element).off(a.TRANSITION_END),e(this._element).one(a.TRANSITION_END,(function(){t._element.classList.remove("modal-static"),i||e(t._element).one(a.TRANSITION_END,(function(){t._element.style.overflowY=""})).emulateTransitionEnd(t._element,o)})).emulateTransitionEnd(o),this._element.focus()}else this.hide()},n._showElement=function(t){var n=this,i=e(this._element).hasClass("fade"),o=this._dialog?this._dialog.querySelector(".modal-body"):null;this._element.parentNode&&this._element.parentNode.nodeType===Node.ELEMENT_NODE||document.body.appendChild(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),e(this._dialog).hasClass("modal-dialog-scrollable")&&o?o.scrollTop=0:this._element.scrollTop=0,i&&a.reflow(this._element),e(this._element).addClass("show"),this._config.focus&&this._enforceFocus();var s=e.Event("shown.bs.modal",{relatedTarget:t}),r=function(){n._config.focus&&n._element.focus(),n._isTransitioning=!1,e(n._element).trigger(s)};if(i){var l=a.getTransitionDurationFromElement(this._dialog);e(this._dialog).one(a.TRANSITION_END,r).emulateTransitionEnd(l)}else r()},n._enforceFocus=function(){var t=this;e(document).off("focusin.bs.modal").on("focusin.bs.modal",(function(n){document!==n.target&&t._element!==n.target&&0===e(t._element).has(n.target).length&&t._element.focus()}))},n._setEscapeEvent=function(){var t=this;this._isShown?e(this._element).on("keydown.dismiss.bs.modal",(function(e){t._config.keyboard&&27===e.which?(e.preventDefault(),t.hide()):t._config.keyboard||27!==e.which||t._triggerBackdropTransition()})):this._isShown||e(this._element).off("keydown.dismiss.bs.modal")},n._setResizeEvent=function(){var t=this;this._isShown?e(window).on("resize.bs.modal",(function(e){return t.handleUpdate(e)})):e(window).off("resize.bs.modal")},n._hideModal=function(){var t=this;this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._showBackdrop((function(){e(document.body).removeClass("modal-open"),t._resetAdjustments(),t._resetScrollbar(),e(t._element).trigger("hidden.bs.modal")}))},n._removeBackdrop=function(){this._backdrop&&(e(this._backdrop).remove(),this._backdrop=null)},n._showBackdrop=function(t){var n=this,i=e(this._element).hasClass("fade")?"fade":"";if(this._isShown&&this._config.backdrop){if(this._backdrop=document.createElement("div"),this._backdrop.className="modal-backdrop",i&&this._backdrop.classList.add(i),e(this._backdrop).appendTo(document.body),e(this._element).on("click.dismiss.bs.modal",(function(t){n._ignoreBackdropClick?n._ignoreBackdropClick=!1:t.target===t.currentTarget&&n._triggerBackdropTransition()})),i&&a.reflow(this._backdrop),e(this._backdrop).addClass("show"),!t)return;if(!i)return void t();var o=a.getTransitionDurationFromElement(this._backdrop);e(this._backdrop).one(a.TRANSITION_END,t).emulateTransitionEnd(o)}else if(!this._isShown&&this._backdrop){e(this._backdrop).removeClass("show");var s=function(){n._removeBackdrop(),t&&t()};if(e(this._element).hasClass("fade")){var r=a.getTransitionDurationFromElement(this._backdrop);e(this._backdrop).one(a.TRANSITION_END,s).emulateTransitionEnd(r)}else s()}else t&&t()},n._adjustDialog=function(){var t=this._element.scrollHeight>document.documentElement.clientHeight;!this._isBodyOverflowing&&t&&(this._element.style.paddingLeft=this._scrollbarWidth+"px"),this._isBodyOverflowing&&!t&&(this._element.style.paddingRight=this._scrollbarWidth+"px")},n._resetAdjustments=function(){this._element.style.paddingLeft="",this._element.style.paddingRight=""},n._checkScrollbar=function(){var t=document.body.getBoundingClientRect();this._isBodyOverflowing=Math.round(t.left+t.right)
',trigger:"hover focus",title:"",delay:0,html:!1,selector:!1,placement:"top",offset:0,container:!1,fallbackPlacement:"flip",boundary:"scrollParent",sanitize:!0,sanitizeFn:null,whiteList:L,popperConfig:null},K={HIDE:"hide.bs.tooltip",HIDDEN:"hidden.bs.tooltip",SHOW:"show.bs.tooltip",SHOWN:"shown.bs.tooltip",INSERTED:"inserted.bs.tooltip",CLICK:"click.bs.tooltip",FOCUSIN:"focusin.bs.tooltip",FOCUSOUT:"focusout.bs.tooltip",MOUSEENTER:"mouseenter.bs.tooltip",MOUSELEAVE:"mouseleave.bs.tooltip"},X=function(){function t(t,e){if("undefined"==typeof n)throw new TypeError("Bootstrap's tooltips require Popper.js (https://popper.js.org/)");this._isEnabled=!0,this._timeout=0,this._hoverState="",this._activeTrigger={},this._popper=null,this.element=t,this.config=this._getConfig(e),this.tip=null,this._setListeners()}var i=t.prototype;return i.enable=function(){this._isEnabled=!0},i.disable=function(){this._isEnabled=!1},i.toggleEnabled=function(){this._isEnabled=!this._isEnabled},i.toggle=function(t){if(this._isEnabled)if(t){var n=this.constructor.DATA_KEY,i=e(t.currentTarget).data(n);i||(i=new this.constructor(t.currentTarget,this._getDelegateConfig()),e(t.currentTarget).data(n,i)),i._activeTrigger.click=!i._activeTrigger.click,i._isWithActiveTrigger()?i._enter(null,i):i._leave(null,i)}else{if(e(this.getTipElement()).hasClass("show"))return void this._leave(null,this);this._enter(null,this)}},i.dispose=function(){clearTimeout(this._timeout),e.removeData(this.element,this.constructor.DATA_KEY),e(this.element).off(this.constructor.EVENT_KEY),e(this.element).closest(".modal").off("hide.bs.modal",this._hideModalHandler),this.tip&&e(this.tip).remove(),this._isEnabled=null,this._timeout=null,this._hoverState=null,this._activeTrigger=null,this._popper&&this._popper.destroy(),this._popper=null,this.element=null,this.config=null,this.tip=null},i.show=function(){var t=this;if("none"===e(this.element).css("display"))throw new Error("Please use show on visible elements");var i=e.Event(this.constructor.Event.SHOW);if(this.isWithContent()&&this._isEnabled){e(this.element).trigger(i);var o=a.findShadowRoot(this.element),s=e.contains(null!==o?o:this.element.ownerDocument.documentElement,this.element);if(i.isDefaultPrevented()||!s)return;var r=this.getTipElement(),l=a.getUID(this.constructor.NAME);r.setAttribute("id",l),this.element.setAttribute("aria-describedby",l),this.setContent(),this.config.animation&&e(r).addClass("fade");var c="function"==typeof this.config.placement?this.config.placement.call(this,r,this.element):this.config.placement,h=this._getAttachment(c);this.addAttachmentClass(h);var u=this._getContainer();e(r).data(this.constructor.DATA_KEY,this),e.contains(this.element.ownerDocument.documentElement,this.tip)||e(r).appendTo(u),e(this.element).trigger(this.constructor.Event.INSERTED),this._popper=new n(this.element,r,this._getPopperConfig(h)),e(r).addClass("show"),"ontouchstart"in document.documentElement&&e(document.body).children().on("mouseover",null,e.noop);var d=function(){t.config.animation&&t._fixTransition();var n=t._hoverState;t._hoverState=null,e(t.element).trigger(t.constructor.Event.SHOWN),"out"===n&&t._leave(null,t)};if(e(this.tip).hasClass("fade")){var f=a.getTransitionDurationFromElement(this.tip);e(this.tip).one(a.TRANSITION_END,d).emulateTransitionEnd(f)}else d()}},i.hide=function(t){var n=this,i=this.getTipElement(),o=e.Event(this.constructor.Event.HIDE),s=function(){"show"!==n._hoverState&&i.parentNode&&i.parentNode.removeChild(i),n._cleanTipClass(),n.element.removeAttribute("aria-describedby"),e(n.element).trigger(n.constructor.Event.HIDDEN),null!==n._popper&&n._popper.destroy(),t&&t()};if(e(this.element).trigger(o),!o.isDefaultPrevented()){if(e(i).removeClass("show"),"ontouchstart"in document.documentElement&&e(document.body).children().off("mouseover",null,e.noop),this._activeTrigger.click=!1,this._activeTrigger.focus=!1,this._activeTrigger.hover=!1,e(this.tip).hasClass("fade")){var r=a.getTransitionDurationFromElement(i);e(i).one(a.TRANSITION_END,s).emulateTransitionEnd(r)}else s();this._hoverState=""}},i.update=function(){null!==this._popper&&this._popper.scheduleUpdate()},i.isWithContent=function(){return Boolean(this.getTitle())},i.addAttachmentClass=function(t){e(this.getTipElement()).addClass("bs-tooltip-"+t)},i.getTipElement=function(){return this.tip=this.tip||e(this.config.template)[0],this.tip},i.setContent=function(){var t=this.getTipElement();this.setElementContent(e(t.querySelectorAll(".tooltip-inner")),this.getTitle()),e(t).removeClass("fade show")},i.setElementContent=function(t,n){"object"!=typeof n||!n.nodeType&&!n.jquery?this.config.html?(this.config.sanitize&&(n=Q(n,this.config.whiteList,this.config.sanitizeFn)),t.html(n)):t.text(n):this.config.html?e(n).parent().is(t)||t.empty().append(n):t.text(e(n).text())},i.getTitle=function(){var t=this.element.getAttribute("data-original-title");return t||(t="function"==typeof this.config.title?this.config.title.call(this.element):this.config.title),t},i._getPopperConfig=function(t){var e=this;return s({},{placement:t,modifiers:{offset:this._getOffset(),flip:{behavior:this.config.fallbackPlacement},arrow:{element:".arrow"},preventOverflow:{boundariesElement:this.config.boundary}},onCreate:function(t){t.originalPlacement!==t.placement&&e._handlePopperPlacementChange(t)},onUpdate:function(t){return e._handlePopperPlacementChange(t)}},this.config.popperConfig)},i._getOffset=function(){var t=this,e={};return"function"==typeof this.config.offset?e.fn=function(e){return e.offsets=s({},e.offsets,t.config.offset(e.offsets,t.element)||{}),e}:e.offset=this.config.offset,e},i._getContainer=function(){return!1===this.config.container?document.body:a.isElement(this.config.container)?e(this.config.container):e(document).find(this.config.container)},i._getAttachment=function(t){return V[t.toUpperCase()]},i._setListeners=function(){var t=this;this.config.trigger.split(" ").forEach((function(n){if("click"===n)e(t.element).on(t.constructor.Event.CLICK,t.config.selector,(function(e){return t.toggle(e)}));else if("manual"!==n){var i="hover"===n?t.constructor.Event.MOUSEENTER:t.constructor.Event.FOCUSIN,o="hover"===n?t.constructor.Event.MOUSELEAVE:t.constructor.Event.FOCUSOUT;e(t.element).on(i,t.config.selector,(function(e){return t._enter(e)})).on(o,t.config.selector,(function(e){return t._leave(e)}))}})),this._hideModalHandler=function(){t.element&&t.hide()},e(this.element).closest(".modal").on("hide.bs.modal",this._hideModalHandler),this.config.selector?this.config=s({},this.config,{trigger:"manual",selector:""}):this._fixTitle()},i._fixTitle=function(){var t=typeof this.element.getAttribute("data-original-title");(this.element.getAttribute("title")||"string"!==t)&&(this.element.setAttribute("data-original-title",this.element.getAttribute("title")||""),this.element.setAttribute("title",""))},i._enter=function(t,n){var i=this.constructor.DATA_KEY;(n=n||e(t.currentTarget).data(i))||(n=new this.constructor(t.currentTarget,this._getDelegateConfig()),e(t.currentTarget).data(i,n)),t&&(n._activeTrigger["focusin"===t.type?"focus":"hover"]=!0),e(n.getTipElement()).hasClass("show")||"show"===n._hoverState?n._hoverState="show":(clearTimeout(n._timeout),n._hoverState="show",n.config.delay&&n.config.delay.show?n._timeout=setTimeout((function(){"show"===n._hoverState&&n.show()}),n.config.delay.show):n.show())},i._leave=function(t,n){var i=this.constructor.DATA_KEY;(n=n||e(t.currentTarget).data(i))||(n=new this.constructor(t.currentTarget,this._getDelegateConfig()),e(t.currentTarget).data(i,n)),t&&(n._activeTrigger["focusout"===t.type?"focus":"hover"]=!1),n._isWithActiveTrigger()||(clearTimeout(n._timeout),n._hoverState="out",n.config.delay&&n.config.delay.hide?n._timeout=setTimeout((function(){"out"===n._hoverState&&n.hide()}),n.config.delay.hide):n.hide())},i._isWithActiveTrigger=function(){for(var t in this._activeTrigger)if(this._activeTrigger[t])return!0;return!1},i._getConfig=function(t){var n=e(this.element).data();return Object.keys(n).forEach((function(t){-1!==M.indexOf(t)&&delete n[t]})),"number"==typeof(t=s({},this.constructor.Default,n,"object"==typeof t&&t?t:{})).delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),a.typeCheckConfig(B,t,this.constructor.DefaultType),t.sanitize&&(t.template=Q(t.template,t.whiteList,t.sanitizeFn)),t},i._getDelegateConfig=function(){var t={};if(this.config)for(var e in this.config)this.constructor.Default[e]!==this.config[e]&&(t[e]=this.config[e]);return t},i._cleanTipClass=function(){var t=e(this.getTipElement()),n=t.attr("class").match(U);null!==n&&n.length&&t.removeClass(n.join(""))},i._handlePopperPlacementChange=function(t){this.tip=t.instance.popper,this._cleanTipClass(),this.addAttachmentClass(this._getAttachment(t.placement))},i._fixTransition=function(){var t=this.getTipElement(),n=this.config.animation;null===t.getAttribute("x-placement")&&(e(t).removeClass("fade"),this.config.animation=!1,this.hide(),this.show(),this.config.animation=n)},t._jQueryInterface=function(n){return this.each((function(){var i=e(this).data("bs.tooltip"),o="object"==typeof n&&n;if((i||!/dispose|hide/.test(n))&&(i||(i=new t(this,o),e(this).data("bs.tooltip",i)),"string"==typeof n)){if("undefined"==typeof i[n])throw new TypeError('No method named "'+n+'"');i[n]()}}))},o(t,null,[{key:"VERSION",get:function(){return"4.5.2"}},{key:"Default",get:function(){return z}},{key:"NAME",get:function(){return B}},{key:"DATA_KEY",get:function(){return"bs.tooltip"}},{key:"Event",get:function(){return K}},{key:"EVENT_KEY",get:function(){return".bs.tooltip"}},{key:"DefaultType",get:function(){return W}}]),t}();e.fn[B]=X._jQueryInterface,e.fn[B].Constructor=X,e.fn[B].noConflict=function(){return e.fn[B]=H,X._jQueryInterface};var Y="popover",$=e.fn[Y],J=new RegExp("(^|\\s)bs-popover\\S+","g"),G=s({},X.Default,{placement:"right",trigger:"click",content:"",template:''}),Z=s({},X.DefaultType,{content:"(string|element|function)"}),tt={HIDE:"hide.bs.popover",HIDDEN:"hidden.bs.popover",SHOW:"show.bs.popover",SHOWN:"shown.bs.popover",INSERTED:"inserted.bs.popover",CLICK:"click.bs.popover",FOCUSIN:"focusin.bs.popover",FOCUSOUT:"focusout.bs.popover",MOUSEENTER:"mouseenter.bs.popover",MOUSELEAVE:"mouseleave.bs.popover"},et=function(t){var n,i;function s(){return t.apply(this,arguments)||this}i=t,(n=s).prototype=Object.create(i.prototype),n.prototype.constructor=n,n.__proto__=i;var r=s.prototype;return r.isWithContent=function(){return this.getTitle()||this._getContent()},r.addAttachmentClass=function(t){e(this.getTipElement()).addClass("bs-popover-"+t)},r.getTipElement=function(){return this.tip=this.tip||e(this.config.template)[0],this.tip},r.setContent=function(){var t=e(this.getTipElement());this.setElementContent(t.find(".popover-header"),this.getTitle());var n=this._getContent();"function"==typeof n&&(n=n.call(this.element)),this.setElementContent(t.find(".popover-body"),n),t.removeClass("fade show")},r._getContent=function(){return this.element.getAttribute("data-content")||this.config.content},r._cleanTipClass=function(){var t=e(this.getTipElement()),n=t.attr("class").match(J);null!==n&&n.length>0&&t.removeClass(n.join(""))},s._jQueryInterface=function(t){return this.each((function(){var n=e(this).data("bs.popover"),i="object"==typeof t?t:null;if((n||!/dispose|hide/.test(t))&&(n||(n=new s(this,i),e(this).data("bs.popover",n)),"string"==typeof t)){if("undefined"==typeof n[t])throw new TypeError('No method named "'+t+'"');n[t]()}}))},o(s,null,[{key:"VERSION",get:function(){return"4.5.2"}},{key:"Default",get:function(){return G}},{key:"NAME",get:function(){return Y}},{key:"DATA_KEY",get:function(){return"bs.popover"}},{key:"Event",get:function(){return tt}},{key:"EVENT_KEY",get:function(){return".bs.popover"}},{key:"DefaultType",get:function(){return Z}}]),s}(X);e.fn[Y]=et._jQueryInterface,e.fn[Y].Constructor=et,e.fn[Y].noConflict=function(){return e.fn[Y]=$,et._jQueryInterface};var nt="scrollspy",it=e.fn[nt],ot={offset:10,method:"auto",target:""},st={offset:"number",method:"string",target:"(string|element)"},rt=function(){function t(t,n){var i=this;this._element=t,this._scrollElement="BODY"===t.tagName?window:t,this._config=this._getConfig(n),this._selector=this._config.target+" .nav-link,"+this._config.target+" .list-group-item,"+this._config.target+" .dropdown-item",this._offsets=[],this._targets=[],this._activeTarget=null,this._scrollHeight=0,e(this._scrollElement).on("scroll.bs.scrollspy",(function(t){return i._process(t)})),this.refresh(),this._process()}var n=t.prototype;return n.refresh=function(){var t=this,n=this._scrollElement===this._scrollElement.window?"offset":"position",i="auto"===this._config.method?n:this._config.method,o="position"===i?this._getScrollTop():0;this._offsets=[],this._targets=[],this._scrollHeight=this._getScrollHeight(),[].slice.call(document.querySelectorAll(this._selector)).map((function(t){var n,s=a.getSelectorFromElement(t);if(s&&(n=document.querySelector(s)),n){var r=n.getBoundingClientRect();if(r.width||r.height)return[e(n)[i]().top+o,s]}return null})).filter((function(t){return t})).sort((function(t,e){return t[0]-e[0]})).forEach((function(e){t._offsets.push(e[0]),t._targets.push(e[1])}))},n.dispose=function(){e.removeData(this._element,"bs.scrollspy"),e(this._scrollElement).off(".bs.scrollspy"),this._element=null,this._scrollElement=null,this._config=null,this._selector=null,this._offsets=null,this._targets=null,this._activeTarget=null,this._scrollHeight=null},n._getConfig=function(t){if("string"!=typeof(t=s({},ot,"object"==typeof t&&t?t:{})).target&&a.isElement(t.target)){var n=e(t.target).attr("id");n||(n=a.getUID(nt),e(t.target).attr("id",n)),t.target="#"+n}return a.typeCheckConfig(nt,t,st),t},n._getScrollTop=function(){return this._scrollElement===window?this._scrollElement.pageYOffset:this._scrollElement.scrollTop},n._getScrollHeight=function(){return this._scrollElement.scrollHeight||Math.max(document.body.scrollHeight,document.documentElement.scrollHeight)},n._getOffsetHeight=function(){return this._scrollElement===window?window.innerHeight:this._scrollElement.getBoundingClientRect().height},n._process=function(){var t=this._getScrollTop()+this._config.offset,e=this._getScrollHeight(),n=this._config.offset+e-this._getOffsetHeight();if(this._scrollHeight!==e&&this.refresh(),t>=n){var i=this._targets[this._targets.length-1];this._activeTarget!==i&&this._activate(i)}else{if(this._activeTarget&&t0)return this._activeTarget=null,void this._clear();for(var o=this._offsets.length;o--;){this._activeTarget!==this._targets[o]&&t>=this._offsets[o]&&("undefined"==typeof this._offsets[o+1]||t li > .active":".active";i=(i=e.makeArray(e(o).find(r)))[i.length-1]}var l=e.Event("hide.bs.tab",{relatedTarget:this._element}),c=e.Event("show.bs.tab",{relatedTarget:i});if(i&&e(i).trigger(l),e(this._element).trigger(c),!c.isDefaultPrevented()&&!l.isDefaultPrevented()){s&&(n=document.querySelector(s)),this._activate(this._element,o);var h=function(){var n=e.Event("hidden.bs.tab",{relatedTarget:t._element}),o=e.Event("shown.bs.tab",{relatedTarget:i});e(i).trigger(n),e(t._element).trigger(o)};n?this._activate(n,n.parentNode,h):h()}}},n.dispose=function(){e.removeData(this._element,"bs.tab"),this._element=null},n._activate=function(t,n,i){var o=this,s=(!n||"UL"!==n.nodeName&&"OL"!==n.nodeName?e(n).children(".active"):e(n).find("> li > .active"))[0],r=i&&s&&e(s).hasClass("fade"),l=function(){return o._transitionComplete(t,s,i)};if(s&&r){var c=a.getTransitionDurationFromElement(s);e(s).removeClass("show").one(a.TRANSITION_END,l).emulateTransitionEnd(c)}else l()},n._transitionComplete=function(t,n,i){if(n){e(n).removeClass("active");var o=e(n.parentNode).find("> .dropdown-menu .active")[0];o&&e(o).removeClass("active"),"tab"===n.getAttribute("role")&&n.setAttribute("aria-selected",!1)}if(e(t).addClass("active"),"tab"===t.getAttribute("role")&&t.setAttribute("aria-selected",!0),a.reflow(t),t.classList.contains("fade")&&t.classList.add("show"),t.parentNode&&e(t.parentNode).hasClass("dropdown-menu")){var s=e(t).closest(".dropdown")[0];if(s){var r=[].slice.call(s.querySelectorAll(".dropdown-toggle"));e(r).addClass("active")}t.setAttribute("aria-expanded",!0)}i&&i()},t._jQueryInterface=function(n){return this.each((function(){var i=e(this),o=i.data("bs.tab");if(o||(o=new t(this),i.data("bs.tab",o)),"string"==typeof n){if("undefined"==typeof o[n])throw new TypeError('No method named "'+n+'"');o[n]()}}))},o(t,null,[{key:"VERSION",get:function(){return"4.5.2"}}]),t}();e(document).on("click.bs.tab.data-api",'[data-toggle="tab"], [data-toggle="pill"], [data-toggle="list"]',(function(t){t.preventDefault(),lt._jQueryInterface.call(e(this),"show")})),e.fn.tab=lt._jQueryInterface,e.fn.tab.Constructor=lt,e.fn.tab.noConflict=function(){return e.fn.tab=at,lt._jQueryInterface};var ct=e.fn.toast,ht={animation:"boolean",autohide:"boolean",delay:"number"},ut={animation:!0,autohide:!0,delay:500},dt=function(){function t(t,e){this._element=t,this._config=this._getConfig(e),this._timeout=null,this._setListeners()}var n=t.prototype;return n.show=function(){var t=this,n=e.Event("show.bs.toast");if(e(this._element).trigger(n),!n.isDefaultPrevented()){this._clearTimeout(),this._config.animation&&this._element.classList.add("fade");var i=function(){t._element.classList.remove("showing"),t._element.classList.add("show"),e(t._element).trigger("shown.bs.toast"),t._config.autohide&&(t._timeout=setTimeout((function(){t.hide()}),t._config.delay))};if(this._element.classList.remove("hide"),a.reflow(this._element),this._element.classList.add("showing"),this._config.animation){var o=a.getTransitionDurationFromElement(this._element);e(this._element).one(a.TRANSITION_END,i).emulateTransitionEnd(o)}else i()}},n.hide=function(){if(this._element.classList.contains("show")){var t=e.Event("hide.bs.toast");e(this._element).trigger(t),t.isDefaultPrevented()||this._close()}},n.dispose=function(){this._clearTimeout(),this._element.classList.contains("show")&&this._element.classList.remove("show"),e(this._element).off("click.dismiss.bs.toast"),e.removeData(this._element,"bs.toast"),this._element=null,this._config=null},n._getConfig=function(t){return t=s({},ut,e(this._element).data(),"object"==typeof t&&t?t:{}),a.typeCheckConfig("toast",t,this.constructor.DefaultType),t},n._setListeners=function(){var t=this;e(this._element).on("click.dismiss.bs.toast",'[data-dismiss="toast"]',(function(){return t.hide()}))},n._close=function(){var t=this,n=function(){t._element.classList.add("hide"),e(t._element).trigger("hidden.bs.toast")};if(this._element.classList.remove("show"),this._config.animation){var i=a.getTransitionDurationFromElement(this._element);e(this._element).one(a.TRANSITION_END,n).emulateTransitionEnd(i)}else n()},n._clearTimeout=function(){clearTimeout(this._timeout),this._timeout=null},t._jQueryInterface=function(n){return this.each((function(){var i=e(this),o=i.data("bs.toast");if(o||(o=new t(this,"object"==typeof n&&n),i.data("bs.toast",o)),"string"==typeof n){if("undefined"==typeof o[n])throw new TypeError('No method named "'+n+'"');o[n](this)}}))},o(t,null,[{key:"VERSION",get:function(){return"4.5.2"}},{key:"DefaultType",get:function(){return ht}},{key:"Default",get:function(){return ut}}]),t}();e.fn.toast=dt._jQueryInterface,e.fn.toast.Constructor=dt,e.fn.toast.noConflict=function(){return e.fn.toast=ct,dt._jQueryInterface},t.Alert=h,t.Button=d,t.Carousel=b,t.Collapse=C,t.Dropdown=I,t.Modal=P,t.Popover=et,t.Scrollspy=rt,t.Tab=lt,t.Toast=dt,t.Tooltip=X,t.Util=a,Object.defineProperty(t,"__esModule",{value:!0})})); -//# sourceMappingURL=bootstrap.min.js.map \ No newline at end of file diff --git a/static/vendor/jquery-ui-slider/jquery-ui-slider-pips.css b/static/vendor/jquery-ui-slider/jquery-ui-slider-pips.css deleted file mode 100644 index 94a1755b..00000000 --- a/static/vendor/jquery-ui-slider/jquery-ui-slider-pips.css +++ /dev/null @@ -1,326 +0,0 @@ -/*! jQuery-ui-Slider-Pips - v1.11.4 - 2016-09-04 -* Copyright (c) 2016 Simon Goellner ; Licensed MIT */ - -/* HORIZONTAL */ -/* increase bottom margin to fit the pips */ -.ui-slider-horizontal.ui-slider-pips { - margin-bottom: 1.4em; -} - -/* default hide the labels and pips that arnt visible */ -/* we just use css to hide incase we want to show certain */ -/* labels/pips individually later */ -.ui-slider-pips .ui-slider-label, -.ui-slider-pips .ui-slider-pip-hide { - display: none; -} - -/* now we show any labels that we've set to show in the options */ -.ui-slider-pips .ui-slider-pip-label .ui-slider-label { - display: block; -} - -/* PIP/LABEL WRAPPER */ -/* position each pip absolutely just below the default slider */ -/* and also prevent accidental selection */ -.ui-slider-pips .ui-slider-pip { - width: 2em; - height: 1em; - line-height: 1em; - position: absolute; - font-size: 0.8em; - color: #999; - overflow: visible; - text-align: center; - top: 20px; - left: 20px; - margin-left: -1em; - cursor: pointer; - -webkit-touch-callout: none; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} - -.ui-state-disabled.ui-slider-pips .ui-slider-pip { - cursor: default; -} - -/* little pip/line position & size */ -.ui-slider-pips .ui-slider-line { - background: #999; - width: 1px; - height: 3px; - position: absolute; - left: 50%; -} - -/* the text label postion & size */ -/* it overflows so no need for width to be accurate */ -.ui-slider-pips .ui-slider-label { - position: absolute; - top: 5px; - left: 50%; - margin-left: -1em; - width: 2em; -} - -/* make it easy to see when we hover a label */ -.ui-slider-pips:not(.ui-slider-disabled) .ui-slider-pip:hover .ui-slider-label { - color: black; - font-weight: bold; -} - -/* VERTICAL */ -/* vertical slider needs right-margin, not bottom */ -.ui-slider-vertical.ui-slider-pips { - margin-bottom: 1em; - margin-right: 2em; -} - -/* align vertical pips left and to right of the slider */ -.ui-slider-vertical.ui-slider-pips .ui-slider-pip { - text-align: left; - top: auto; - left: 20px; - margin-left: 0; - margin-bottom: -0.5em; -} - -/* vertical line/pip should be horizontal instead */ -.ui-slider-vertical.ui-slider-pips .ui-slider-line { - width: 3px; - height: 1px; - position: absolute; - top: 50%; - left: 0; -} - -.ui-slider-vertical.ui-slider-pips .ui-slider-label { - top: 50%; - left: 0.5em; - margin-left: 0; - margin-top: -0.5em; - width: 2em; -} - -/* FLOATING HORIZTONAL TOOLTIPS */ -/* remove the godawful looking focus outline on handle and float */ -.ui-slider-float .ui-slider-handle:focus, -.ui-slider-float .ui-slider-handle.ui-state-focus .ui-slider-tip-label, -.ui-slider-float .ui-slider-handle:focus .ui-slider-tip, -.ui-slider-float .ui-slider-handle.ui-state-focus .ui-slider-tip-label, -.ui-slider-float .ui-slider-handle:focus .ui-slider-tip-label -.ui-slider-float .ui-slider-handle.ui-state-focus .ui-slider-tip-label { - outline: none; -} - -/* style tooltips on handles and on labels */ -/* also has a nice transition */ -.ui-slider-float .ui-slider-tip, -.ui-slider-float .ui-slider-tip-label { - position: absolute; - visibility: hidden; - top: -40px; - display: block; - width: 34px; - margin-left: -18px; - left: 50%; - height: 20px; - line-height: 20px; - background: white; - border-radius: 3px; - border: 1px solid #888; - text-align: center; - font-size: 12px; - opacity: 0; - color: #333; - -webkit-transition-property: opacity, top, visibility; - transition-property: opacity, top, visibility; - -webkit-transition-timing-function: ease-in; - transition-timing-function: ease-in; - -webkit-transition-duration: 200ms, 200ms, 0ms; - transition-duration: 200ms, 200ms, 0ms; - -webkit-transition-delay: 0ms, 0ms, 200ms; - transition-delay: 0ms, 0ms, 200ms; -} - -/* show the tooltip on hover or focus */ -/* also switch transition delay around */ -.ui-slider-float .ui-slider-handle:hover .ui-slider-tip, -.ui-slider-float .ui-slider-handle.ui-state-hover .ui-slider-tip, -.ui-slider-float .ui-slider-handle:focus .ui-slider-tip, -.ui-slider-float .ui-slider-handle.ui-state-focus .ui-slider-tip, -.ui-slider-float .ui-slider-handle.ui-state-active .ui-slider-tip, -.ui-slider-float .ui-slider-pip:hover .ui-slider-tip-label { - opacity: 1; - top: -30px; - visibility: visible; - -webkit-transition-timing-function: ease-out; - transition-timing-function: ease-out; - -webkit-transition-delay: 200ms, 200ms, 0ms; - transition-delay: 200ms, 200ms, 0ms; -} - -/* put label tooltips below slider */ -.ui-slider-float .ui-slider-pip .ui-slider-tip-label { - top: 42px; -} - -.ui-slider-float .ui-slider-pip:hover .ui-slider-tip-label { - top: 32px; - font-weight: normal; -} - -/* give the tooltip a css triangle arrow */ -.ui-slider-float .ui-slider-tip:after, -.ui-slider-float .ui-slider-pip .ui-slider-tip-label:after { - content: " "; - width: 0; - height: 0; - border: 5px solid rgba(255, 255, 255, 0); - border-top-color: white; - position: absolute; - bottom: -10px; - left: 50%; - margin-left: -5px; -} - -/* put a 1px border on the tooltip arrow to match tooltip border */ -.ui-slider-float .ui-slider-tip:before, -.ui-slider-float .ui-slider-pip .ui-slider-tip-label:before { - content: " "; - width: 0; - height: 0; - border: 5px solid rgba(255, 255, 255, 0); - border-top-color: #888; - position: absolute; - bottom: -11px; - left: 50%; - margin-left: -5px; -} - -/* switch the arrow to top on labels */ -.ui-slider-float .ui-slider-pip .ui-slider-tip-label:after { - border: 5px solid rgba(255, 255, 255, 0); - border-bottom-color: white; - top: -10px; -} - -.ui-slider-float .ui-slider-pip .ui-slider-tip-label:before { - border: 5px solid rgba(255, 255, 255, 0); - border-bottom-color: #888; - top: -11px; -} - -/* FLOATING VERTICAL TOOLTIPS */ -/* tooltip floats to left of handle */ -.ui-slider-vertical.ui-slider-float .ui-slider-tip, -.ui-slider-vertical.ui-slider-float .ui-slider-tip-label { - top: 50%; - margin-top: -11px; - width: 34px; - margin-left: 0px; - left: -60px; - color: #333; - -webkit-transition-duration: 200ms, 200ms, 0; - transition-duration: 200ms, 200ms, 0; - -webkit-transition-property: opacity, left, visibility; - transition-property: opacity, left, visibility; - -webkit-transition-delay: 0, 0, 200ms; - transition-delay: 0, 0, 200ms; -} - -.ui-slider-vertical.ui-slider-float .ui-slider-handle:hover .ui-slider-tip, -.ui-slider-vertical.ui-slider-float .ui-slider-handle.ui-state-hover .ui-slider-tip, -.ui-slider-vertical.ui-slider-float .ui-slider-handle:focus .ui-slider-tip, -.ui-slider-vertical.ui-slider-float .ui-slider-handle.ui-state-focus .ui-slider-tip, -.ui-slider-vertical.ui-slider-float .ui-slider-handle.ui-state-active .ui-slider-tip, -.ui-slider-vertical.ui-slider-float .ui-slider-pip:hover .ui-slider-tip-label { - top: 50%; - margin-top: -11px; - left: -50px; -} - -/* put label tooltips to right of slider */ -.ui-slider-vertical.ui-slider-float .ui-slider-pip .ui-slider-tip-label { - left: 47px; -} - -.ui-slider-vertical.ui-slider-float .ui-slider-pip:hover .ui-slider-tip-label { - left: 37px; -} - -/* give the tooltip a css triangle arrow */ -.ui-slider-vertical.ui-slider-float .ui-slider-tip:after, -.ui-slider-vertical.ui-slider-float .ui-slider-pip .ui-slider-tip-label:after { - border: 5px solid rgba(255, 255, 255, 0); - border-left-color: white; - border-top-color: transparent; - position: absolute; - bottom: 50%; - margin-bottom: -5px; - right: -10px; - margin-left: 0; - top: auto; - left: auto; -} - -.ui-slider-vertical.ui-slider-float .ui-slider-tip:before, -.ui-slider-vertical.ui-slider-float .ui-slider-pip .ui-slider-tip-label:before { - border: 5px solid rgba(255, 255, 255, 0); - border-left-color: #888; - border-top-color: transparent; - position: absolute; - bottom: 50%; - margin-bottom: -5px; - right: -11px; - margin-left: 0; - top: auto; - left: auto; -} - -.ui-slider-vertical.ui-slider-float .ui-slider-pip .ui-slider-tip-label:after { - border: 5px solid rgba(255, 255, 255, 0); - border-right-color: white; - right: auto; - left: -10px; -} - -.ui-slider-vertical.ui-slider-float .ui-slider-pip .ui-slider-tip-label:before { - border: 5px solid rgba(255, 255, 255, 0); - border-right-color: #888; - right: auto; - left: -11px; -} - -/* SELECTED STATES */ -/* Comment out this chuck of code if you don't want to have - the new label colours shown */ -.ui-slider-pips [class*=ui-slider-pip-initial] { - font-weight: bold; - color: #14CA82; -} - -.ui-slider-pips .ui-slider-pip-initial-2 { - color: #1897C9; -} - -.ui-slider-pips [class*=ui-slider-pip-selected] { - font-weight: bold; - color: #FF7A00; -} - -.ui-slider-pips .ui-slider-pip-inrange { - color: black; -} - -.ui-slider-pips .ui-slider-pip-selected-2 { - color: #E70081; -} - -.ui-slider-pips [class*=ui-slider-pip-selected] .ui-slider-line, -.ui-slider-pips .ui-slider-pip-inrange .ui-slider-line { - background: black; -} diff --git a/static/vendor/jquery-ui-slider/jquery-ui-slider-pips.js b/static/vendor/jquery-ui-slider/jquery-ui-slider-pips.js deleted file mode 100644 index 8a5c6952..00000000 --- a/static/vendor/jquery-ui-slider/jquery-ui-slider-pips.js +++ /dev/null @@ -1,812 +0,0 @@ -/*! jQuery-ui-Slider-Pips - v1.11.4 - 2016-09-04 -* Copyright (c) 2016 Simon Goellner ; Licensed MIT */ - -(function($) { - - "use strict"; - - var extensionMethods = { - - - - - - // pips - - pips: function( settings ) { - - var slider = this, - i, j, p, - collection = "", - mousedownHandlers, - min = slider._valueMin(), - max = slider._valueMax(), - pips = ( max - min ) / slider.options.step, - $handles = slider.element.find(".ui-slider-handle"), - $pips; - - var options = { - - first: "label", - /* "label", "pip", false */ - - last: "label", - /* "label", "pip", false */ - - rest: "pip", - /* "label", "pip", false */ - - labels: false, - /* [array], { first: "string", rest: [array], last: "string" }, false */ - - prefix: "", - /* "", string */ - - suffix: "", - /* "", string */ - - step: ( pips > 100 ) ? Math.floor( pips * 0.05 ) : 1, - /* number */ - - formatLabel: function(value) { - return this.prefix + value + this.suffix; - } - /* function - must return a value to display in the pip labels */ - - }; - - if ( $.type( settings ) === "object" || $.type( settings ) === "undefined" ) { - - $.extend( options, settings ); - slider.element.data("pips-options", options ); - - } else { - - if ( settings === "destroy" ) { - - destroy(); - - } else if ( settings === "refresh" ) { - - slider.element.slider( "pips", slider.element.data("pips-options") ); - - } - - return; - - } - - - // we don't want the step ever to be a floating point or negative - // (or 0 actually, so we'll set it to 1 in that case). - slider.options.pipStep = Math.abs( Math.round( options.step ) ) || 1; - - // get rid of all pips that might already exist. - slider.element - .off( ".selectPip" ) - .addClass("ui-slider-pips") - .find(".ui-slider-pip") - .remove(); - - // small object with functions for marking pips as selected. - - var selectPip = { - - single: function(value) { - - this.resetClasses(); - - $pips - .filter(".ui-slider-pip-" + this.classLabel(value) ) - .addClass("ui-slider-pip-selected"); - - if ( slider.options.range ) { - - $pips.each(function(k, v) { - - var pipVal = $(v).children(".ui-slider-label").data("value"); - - if (( slider.options.range === "min" && pipVal < value ) || - ( slider.options.range === "max" && pipVal > value )) { - - $(v).addClass("ui-slider-pip-inrange"); - - } - - }); - - } - - }, - - range: function(values) { - - this.resetClasses(); - - for ( i = 0; i < values.length; i++ ) { - - $pips - .filter(".ui-slider-pip-" + this.classLabel(values[i]) ) - .addClass("ui-slider-pip-selected-" + ( i + 1 ) ); - - } - - if ( slider.options.range ) { - - $pips.each(function(k, v) { - - var pipVal = $(v).children(".ui-slider-label").data("value"); - - if ( pipVal > values[0] && pipVal < values[1] ) { - - $(v).addClass("ui-slider-pip-inrange"); - - } - - }); - - } - - }, - - classLabel: function(value) { - - return value.toString().replace(".", "-"); - - }, - - resetClasses: function() { - - var regex = /(^|\s*)(ui-slider-pip-selected|ui-slider-pip-inrange)(-{1,2}\d+|\s|$)/gi; - - $pips.removeClass( function(index, css) { - return ( css.match(regex) || [] ).join(" "); - }); - - } - - }; - - function getClosestHandle( val ) { - - var h, k, - sliderVals, - comparedVals, - closestVal, - tempHandles = [], - closestHandle = 0; - - if ( slider.values() && slider.values().length ) { - - // get the current values of the slider handles - sliderVals = slider.values(); - - // find the offset value from the `val` for each - // handle, and store it in a new array - comparedVals = $.map( sliderVals, function(v) { - return Math.abs( v - val ); - }); - - // figure out the closest handles to the value - closestVal = Math.min.apply( Math, comparedVals ); - - // if a comparedVal is the closestVal, then - // set the value accordingly, and set the closest handle. - for ( h = 0; h < comparedVals.length; h++ ) { - if ( comparedVals[h] === closestVal ) { - tempHandles.push(h); - } - } - - // set the closest handle to the first handle in array, - // just incase we have no _lastChangedValue to compare to. - closestHandle = tempHandles[0]; - - // now we want to find out if any of the closest handles were - // the last changed handle, if so we specify that handle to change - for ( k = 0; k < tempHandles.length; k++ ) { - if ( slider._lastChangedValue === tempHandles[k] ) { - closestHandle = tempHandles[k]; - } - } - - if ( slider.options.range && tempHandles.length === 2 ) { - - if ( val > sliderVals[1] ) { - - closestHandle = tempHandles[1]; - - } else if ( val < sliderVals[0] ) { - - closestHandle = tempHandles[0]; - - } - - } - - } - - return closestHandle; - - } - - function destroy() { - - slider.element - .off(".selectPip") - .on("mousedown.slider", slider.element.data("mousedown-original") ) - .removeClass("ui-slider-pips") - .find(".ui-slider-pip") - .remove(); - - } - - // when we click on a label, we want to make sure the - // slider's handle actually goes to that label! - // so we check all the handles and see which one is closest - // to the label we clicked. If 2 handles are equidistant then - // we move both of them. We also want to trigger focus on the - // handle. - - // without this method the label is just treated like a part - // of the slider and there's no accuracy in the selected value - - function labelClick( label, e ) { - - if (slider.option("disabled")) { - return; - } - - var val = $(label).data("value"), - indexToChange = getClosestHandle( val ); - - if ( slider.values() && slider.values().length ) { - - slider.options.values[ indexToChange ] = slider._trimAlignValue( val ); - - } else { - - slider.options.value = slider._trimAlignValue( val ); - - } - - slider._refreshValue(); - slider._change( e, indexToChange ); - - } - - // method for creating a pip. We loop this for creating all - // the pips. - - function createPip( which ) { - - var label, - percent, - number = which, - classes = "ui-slider-pip", - css = "", - value = slider.value(), - values = slider.values(), - labelValue, - classLabel, - labelIndex; - - if ( which === "first" ) { - - number = 0; - - } else if ( which === "last" ) { - - number = pips; - - } - - // labelValue is the actual value of the pip based on the min/step - labelValue = min + ( slider.options.step * number ); - - // classLabel replaces any decimals with hyphens - classLabel = labelValue.toString().replace(".", "-"); - - // get the index needed for selecting labels out of the array - labelIndex = ( number + min ) - min; - - // we need to set the human-readable label to either the - // corresponding element in the array, or the appropriate - // item in the object... or an empty string. - - if ( $.type(options.labels) === "array" ) { - - label = options.labels[ labelIndex ] || ""; - - } else if ( $.type( options.labels ) === "object" ) { - - if ( which === "first" ) { - - // set first label - label = options.labels.first || ""; - - } else if ( which === "last" ) { - - // set last label - label = options.labels.last || ""; - - } else if ( $.type( options.labels.rest ) === "array" ) { - - // set other labels, but our index should start at -1 - // because of the first pip. - label = options.labels.rest[ labelIndex - 1 ] || ""; - - } else { - - // urrggh, the options must be f**ked, just show nothing. - label = labelValue; - - } - - } else { - - label = labelValue; - - } - - - - - if ( which === "first" ) { - - // first Pip on the Slider - percent = "0%"; - - classes += " ui-slider-pip-first"; - classes += ( options.first === "label" ) ? " ui-slider-pip-label" : ""; - classes += ( options.first === false ) ? " ui-slider-pip-hide" : ""; - - } else if ( which === "last" ) { - - // last Pip on the Slider - percent = "100%"; - - classes += " ui-slider-pip-last"; - classes += ( options.last === "label" ) ? " ui-slider-pip-label" : ""; - classes += ( options.last === false ) ? " ui-slider-pip-hide" : ""; - - } else { - - // all other Pips - percent = (( 100 / pips ) * which ).toFixed(4) + "%"; - - classes += ( options.rest === "label" ) ? " ui-slider-pip-label" : ""; - classes += ( options.rest === false ) ? " ui-slider-pip-hide" : ""; - - } - - classes += " ui-slider-pip-" + classLabel; - - - // add classes for the initial-selected values. - if ( values && values.length ) { - - for ( i = 0; i < values.length; i++ ) { - - if ( labelValue === values[i] ) { - - classes += " ui-slider-pip-initial-" + ( i + 1 ); - classes += " ui-slider-pip-selected-" + ( i + 1 ); - - } - - } - - if ( slider.options.range ) { - - if ( labelValue > values[0] && - labelValue < values[1] ) { - - classes += " ui-slider-pip-inrange"; - - } - - } - - } else { - - if ( labelValue === value ) { - - classes += " ui-slider-pip-initial"; - classes += " ui-slider-pip-selected"; - - } - - if ( slider.options.range ) { - - if (( slider.options.range === "min" && labelValue < value ) || - ( slider.options.range === "max" && labelValue > value )) { - - classes += " ui-slider-pip-inrange"; - - } - - } - - } - - - - css = ( slider.options.orientation === "horizontal" ) ? - "left: " + percent : - "bottom: " + percent; - - - // add this current pip to the collection - return "" + - "" + - "" + options.formatLabel(label) + "" + - ""; - - } - - // create our first pip - collection += createPip("first"); - - // for every stop in the slider where we need a pip; create one. - for ( p = slider.options.pipStep; p < pips; p += slider.options.pipStep ) { - collection += createPip( p ); - } - - // create our last pip - collection += createPip("last"); - - // append the collection of pips. - slider.element.append( collection ); - - // store the pips for setting classes later. - $pips = slider.element.find(".ui-slider-pip"); - - - - // store the mousedown handlers for later, just in case we reset - // the slider, the handler would be lost! - - if ( $._data( slider.element.get(0), "events").mousedown && - $._data( slider.element.get(0), "events").mousedown.length ) { - - mousedownHandlers = $._data( slider.element.get(0), "events").mousedown; - - } else { - - mousedownHandlers = slider.element.data("mousedown-handlers"); - - } - - slider.element.data("mousedown-handlers", mousedownHandlers.slice() ); - - // loop through all the mousedown handlers on the slider, - // and store the original namespaced (.slider) event handler so - // we can trigger it later. - for ( j = 0; j < mousedownHandlers.length; j++ ) { - if ( mousedownHandlers[j].namespace === "slider" ) { - slider.element.data("mousedown-original", mousedownHandlers[j].handler ); - } - } - - // unbind the mousedown.slider event, because it interferes with - // the labelClick() method (stops smooth animation), and decide - // if we want to trigger the original event based on which element - // was clicked. - slider.element - .off("mousedown.slider") - .on("mousedown.selectPip", function(e) { - - var $target = $(e.target), - closest = getClosestHandle( $target.data("value") ), - $handle = $handles.eq( closest ); - - $handle.addClass("ui-state-active"); - - if ( $target.is(".ui-slider-label") ) { - - labelClick( $target, e ); - - slider.element - .one("mouseup.selectPip", function() { - - $handle - .removeClass("ui-state-active") - .focus(); - - }); - - } else { - - var originalMousedown = slider.element.data("mousedown-original"); - originalMousedown(e); - - } - - }); - - - - - slider.element.on( "slide.selectPip slidechange.selectPip", function(e, ui) { - - var $slider = $(this), - value = $slider.slider("value"), - values = $slider.slider("values"); - - if ( ui ) { - - value = ui.value; - values = ui.values; - - } - - if ( slider.values() && slider.values().length ) { - - selectPip.range( values ); - - } else { - - selectPip.single( value ); - - } - - }); - - - - - }, - - - - - - - - - // floats - - float: function( settings ) { - - var i, - slider = this, - min = slider._valueMin(), - max = slider._valueMax(), - value = slider._value(), - values = slider._values(), - tipValues = [], - $handles = slider.element.find(".ui-slider-handle"); - - var options = { - - handle: true, - /* false */ - - pips: false, - /* true */ - - labels: false, - /* [array], { first: "string", rest: [array], last: "string" }, false */ - - prefix: "", - /* "", string */ - - suffix: "", - /* "", string */ - - event: "slidechange slide", - /* "slidechange", "slide", "slidechange slide" */ - - formatLabel: function(value) { - return this.prefix + value + this.suffix; - } - /* function - must return a value to display in the floats */ - - }; - - if ( $.type( settings ) === "object" || $.type( settings ) === "undefined" ) { - - $.extend( options, settings ); - slider.element.data("float-options", options ); - - } else { - - if ( settings === "destroy" ) { - - destroy(); - - } else if ( settings === "refresh" ) { - - slider.element.slider( "float", slider.element.data("float-options") ); - - } - - return; - - } - - - - - if ( value < min ) { - value = min; - } - - if ( value > max ) { - value = max; - } - - if ( values && values.length ) { - - for ( i = 0; i < values.length; i++ ) { - - if ( values[i] < min ) { - values[i] = min; - } - - if ( values[i] > max ) { - values[i] = max; - } - - } - - } - - // add a class for the CSS - slider.element - .addClass("ui-slider-float") - .find(".ui-slider-tip, .ui-slider-tip-label") - .remove(); - - - - function destroy() { - - slider.element - .off(".sliderFloat") - .removeClass("ui-slider-float") - .find(".ui-slider-tip, .ui-slider-tip-label") - .remove(); - - } - - - function getPipLabels( values ) { - - // when checking the array we need to divide - // by the step option, so we store those values here. - - var vals = [], - steppedVals = $.map( values, function(v) { - return Math.ceil(( v - min ) / slider.options.step); - }); - - // now we just get the values we need to return - // by looping through the values array and assigning the - // label if it exists. - - if ( $.type( options.labels ) === "array" ) { - - for ( i = 0; i < values.length; i++ ) { - - vals[i] = options.labels[ steppedVals[i] ] || values[i]; - - } - - } else if ( $.type( options.labels ) === "object" ) { - - for ( i = 0; i < values.length; i++ ) { - - if ( values[i] === min ) { - - vals[i] = options.labels.first || min; - - } else if ( values[i] === max ) { - - vals[i] = options.labels.last || max; - - } else if ( $.type( options.labels.rest ) === "array" ) { - - vals[i] = options.labels.rest[ steppedVals[i] - 1 ] || values[i]; - - } else { - - vals[i] = values[i]; - - } - - } - - } else { - - for ( i = 0; i < values.length; i++ ) { - - vals[i] = values[i]; - - } - - } - - return vals; - - } - - // apply handle tip if settings allows. - if ( options.handle ) { - - // we need to set the human-readable label to either the - // corresponding element in the array, or the appropriate - // item in the object... or an empty string. - - tipValues = ( slider.values() && slider.values().length ) ? - getPipLabels( values ) : - getPipLabels( [ value ] ); - - for ( i = 0; i < tipValues.length; i++ ) { - - $handles - .eq( i ) - .append( $(""+ options.formatLabel(tipValues[i]) +"") ); - - } - - } - - if ( options.pips ) { - - // if this slider also has pip-labels, we make those into tips, too. - slider.element.find(".ui-slider-label").each(function(k, v) { - - var $this = $(v), - val = [ $this.data("value") ], - label, - $tip; - - - label = options.formatLabel( getPipLabels( val )[0] ); - - // create a tip element - $tip = - $("" + label + "") - .insertAfter( $this ); - - }); - - } - - // check that the event option is actually valid against our - // own list of the slider's events. - if ( options.event !== "slide" && - options.event !== "slidechange" && - options.event !== "slide slidechange" && - options.event !== "slidechange slide" ) { - - options.event = "slidechange slide"; - - } - - // when slider changes, update handle tip label. - slider.element - .off(".sliderFloat") - .on( options.event + ".sliderFloat", function( e, ui ) { - - var uiValue = ( $.type( ui.value ) === "array" ) ? ui.value : [ ui.value ], - val = options.formatLabel( getPipLabels( uiValue )[0] ); - - $(ui.handle) - .find(".ui-slider-tip") - .html( val ); - - }); - - } - - }; - - $.extend(true, $.ui.slider.prototype, extensionMethods); - -})(jQuery); diff --git a/static/vendor/leaflet-providers/leaflet-providers.js b/static/vendor/leaflet-providers/leaflet-providers.js deleted file mode 100644 index 6d834983..00000000 --- a/static/vendor/leaflet-providers/leaflet-providers.js +++ /dev/null @@ -1,1011 +0,0 @@ -(function (root, factory) { - if (typeof define === 'function' && define.amd) { - // AMD. Register as an anonymous module. - define(['leaflet'], factory); - } else if (typeof modules === 'object' && module.exports) { - // define a Common JS module that relies on 'leaflet' - module.exports = factory(require('leaflet')); - } else { - // Assume Leaflet is loaded into global object L already - factory(L); - } -}(this, function (L) { - 'use strict'; - - L.TileLayer.Provider = L.TileLayer.extend({ - initialize: function (arg, options) { - var providers = L.TileLayer.Provider.providers; - - var parts = arg.split('.'); - - var providerName = parts[0]; - var variantName = parts[1]; - - if (!providers[providerName]) { - throw 'No such provider (' + providerName + ')'; - } - - var provider = { - url: providers[providerName].url, - options: providers[providerName].options - }; - - // overwrite values in provider from variant. - if (variantName && 'variants' in providers[providerName]) { - if (!(variantName in providers[providerName].variants)) { - throw 'No such variant of ' + providerName + ' (' + variantName + ')'; - } - var variant = providers[providerName].variants[variantName]; - var variantOptions; - if (typeof variant === 'string') { - variantOptions = { - variant: variant - }; - } else { - variantOptions = variant.options; - } - provider = { - url: variant.url || provider.url, - options: L.Util.extend({}, provider.options, variantOptions) - }; - } - - // replace attribution placeholders with their values from toplevel provider attribution, - // recursively - var attributionReplacer = function (attr) { - if (attr.indexOf('{attribution.') === -1) { - return attr; - } - return attr.replace(/\{attribution.(\w*)\}/g, - function (match, attributionName) { - return attributionReplacer(providers[attributionName].options.attribution); - } - ); - }; - provider.options.attribution = attributionReplacer(provider.options.attribution); - - // Compute final options combining provider options with any user overrides - var layerOpts = L.Util.extend({}, provider.options, options); - L.TileLayer.prototype.initialize.call(this, provider.url, layerOpts); - } - }); - - /** - * Definition of providers. - * see http://leafletjs.com/reference.html#tilelayer for options in the options map. - */ - - L.TileLayer.Provider.providers = { - OpenStreetMap: { - url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', - options: { - maxZoom: 19, - attribution: - '© OpenStreetMap contributors' - }, - variants: { - Mapnik: {}, - DE: { - url: 'https://{s}.tile.openstreetmap.de/tiles/osmde/{z}/{x}/{y}.png', - options: { - maxZoom: 18 - } - }, - CH: { - url: 'https://tile.osm.ch/switzerland/{z}/{x}/{y}.png', - options: { - maxZoom: 18, - bounds: [[45, 5], [48, 11]] - } - }, - France: { - url: 'https://{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png', - options: { - maxZoom: 20, - attribution: '© Openstreetmap France | {attribution.OpenStreetMap}' - } - }, - HOT: { - url: 'https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', - options: { - attribution: - '{attribution.OpenStreetMap}, ' + - 'Tiles style by Humanitarian OpenStreetMap Team ' + - 'hosted by OpenStreetMap France' - } - }, - BZH: { - url: 'https://tile.openstreetmap.bzh/br/{z}/{x}/{y}.png', - options: { - attribution: '{attribution.OpenStreetMap}, Tiles courtesy of Breton OpenStreetMap Team', - bounds: [[46.2, -5.5], [50, 0.7]] - } - } - } - }, - OpenSeaMap: { - url: 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', - options: { - attribution: 'Map data: © OpenSeaMap contributors' - } - }, - OpenPtMap: { - url: 'http://openptmap.org/tiles/{z}/{x}/{y}.png', - options: { - maxZoom: 17, - attribution: 'Map data: © OpenPtMap contributors' - } - }, - OpenTopoMap: { - url: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', - options: { - maxZoom: 17, - attribution: 'Map data: {attribution.OpenStreetMap}, SRTM | Map style: © OpenTopoMap (CC-BY-SA)' - } - }, - OpenRailwayMap: { - url: 'https://{s}.tiles.openrailwaymap.org/standard/{z}/{x}/{y}.png', - options: { - maxZoom: 19, - attribution: 'Map data: {attribution.OpenStreetMap} | Map style: © OpenRailwayMap (CC-BY-SA)' - } - }, - OpenFireMap: { - url: 'http://openfiremap.org/hytiles/{z}/{x}/{y}.png', - options: { - maxZoom: 19, - attribution: 'Map data: {attribution.OpenStreetMap} | Map style: © OpenFireMap (CC-BY-SA)' - } - }, - SafeCast: { - url: 'https://s3.amazonaws.com/te512.safecast.org/{z}/{x}/{y}.png', - options: { - maxZoom: 16, - attribution: 'Map data: {attribution.OpenStreetMap} | Map style: © SafeCast (CC-BY-SA)' - } - }, - Stadia: { - url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png', - options: { - maxZoom: 20, - attribution: '© Stadia Maps, © OpenMapTiles © OpenStreetMap contributors' - }, - variants: { - AlidadeSmooth: { - url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' - }, - AlidadeSmoothDark: { - url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}{r}.png' - }, - OSMBright: { - url: 'https://tiles.stadiamaps.com/tiles/osm_bright/{z}/{x}/{y}{r}.png' - }, - Outdoors: { - url: 'https://tiles.stadiamaps.com/tiles/outdoors/{z}/{x}/{y}{r}.png' - } - } - }, - Thunderforest: { - url: 'https://{s}.tile.thunderforest.com/{variant}/{z}/{x}/{y}.png?apikey={apikey}', - options: { - attribution: - '© Thunderforest, {attribution.OpenStreetMap}', - variant: 'cycle', - apikey: '', - maxZoom: 22 - }, - variants: { - OpenCycleMap: 'cycle', - Transport: { - options: { - variant: 'transport' - } - }, - TransportDark: { - options: { - variant: 'transport-dark' - } - }, - SpinalMap: { - options: { - variant: 'spinal-map' - } - }, - Landscape: 'landscape', - Outdoors: 'outdoors', - Pioneer: 'pioneer', - MobileAtlas: 'mobile-atlas', - Neighbourhood: 'neighbourhood' - } - }, - CyclOSM: { - url: 'https://{s}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png', - options: { - maxZoom: 20, - attribution: 'CyclOSM | Map data: {attribution.OpenStreetMap}' - } - }, - Hydda: { - url: 'https://{s}.tile.openstreetmap.se/hydda/{variant}/{z}/{x}/{y}.png', - options: { - maxZoom: 20, - variant: 'full', - attribution: 'Tiles courtesy of OpenStreetMap Sweden — Map data {attribution.OpenStreetMap}' - }, - variants: { - Full: 'full', - Base: 'base', - RoadsAndLabels: 'roads_and_labels' - } - }, - Jawg: { - url: 'https://{s}.tile.jawg.io/{variant}/{z}/{x}/{y}{r}.png?access-token={accessToken}', - options: { - attribution: - '© JawgMaps ' + - '{attribution.OpenStreetMap}', - minZoom: 0, - maxZoom: 22, - subdomains: 'abcd', - variant: 'jawg-terrain', - // Get your own Jawg access token here : https://www.jawg.io/lab/ - // NB : this is a demonstration key that comes with no guarantee - accessToken: '', - }, - variants: { - Streets: 'jawg-streets', - Terrain: 'jawg-terrain', - Sunny: 'jawg-sunny', - Dark: 'jawg-dark', - Light: 'jawg-light', - Matrix: 'jawg-matrix' - } - }, - MapBox: { - url: 'https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/{y}{r}?access_token={accessToken}', - options: { - attribution: - '© Mapbox ' + - '{attribution.OpenStreetMap} ' + - 'Improve this map', - tileSize: 512, - maxZoom: 18, - zoomOffset: -1, - id: 'mapbox/streets-v11', - accessToken: '', - } - }, - MapTiler: { - url: 'https://api.maptiler.com/maps/{variant}/{z}/{x}/{y}{r}.{ext}?key={key}', - options: { - attribution: - '© MapTiler © OpenStreetMap contributors', - variant: 'streets', - ext: 'png', - key: '', - tileSize: 512, - zoomOffset: -1, - minZoom: 0, - maxZoom: 21 - }, - variants: { - Streets: 'streets', - Basic: 'basic', - Bright: 'bright', - Pastel: 'pastel', - Positron: 'positron', - Hybrid: { - options: { - variant: 'hybrid', - ext: 'jpg' - } - }, - Toner: 'toner', - Topo: 'topo', - Voyager: 'voyager' - } - }, - Stamen: { - url: 'https://stamen-tiles-{s}.a.ssl.fastly.net/{variant}/{z}/{x}/{y}{r}.{ext}', - options: { - attribution: - 'Map tiles by Stamen Design, ' + - 'CC BY 3.0 — ' + - 'Map data {attribution.OpenStreetMap}', - subdomains: 'abcd', - minZoom: 0, - maxZoom: 20, - variant: 'toner', - ext: 'png' - }, - variants: { - Toner: 'toner', - TonerBackground: 'toner-background', - TonerHybrid: 'toner-hybrid', - TonerLines: 'toner-lines', - TonerLabels: 'toner-labels', - TonerLite: 'toner-lite', - Watercolor: { - url: 'https://stamen-tiles-{s}.a.ssl.fastly.net/{variant}/{z}/{x}/{y}.{ext}', - options: { - variant: 'watercolor', - ext: 'jpg', - minZoom: 1, - maxZoom: 16 - } - }, - Terrain: { - options: { - variant: 'terrain', - minZoom: 0, - maxZoom: 18 - } - }, - TerrainBackground: { - options: { - variant: 'terrain-background', - minZoom: 0, - maxZoom: 18 - } - }, - TerrainLabels: { - options: { - variant: 'terrain-labels', - minZoom: 0, - maxZoom: 18 - } - }, - TopOSMRelief: { - url: 'https://stamen-tiles-{s}.a.ssl.fastly.net/{variant}/{z}/{x}/{y}.{ext}', - options: { - variant: 'toposm-color-relief', - ext: 'jpg', - bounds: [[22, -132], [51, -56]] - } - }, - TopOSMFeatures: { - options: { - variant: 'toposm-features', - bounds: [[22, -132], [51, -56]], - opacity: 0.9 - } - } - } - }, - TomTom: { - url: 'https://{s}.api.tomtom.com/map/1/tile/{variant}/{style}/{z}/{x}/{y}.{ext}?key={apikey}', - options: { - variant: 'basic', - maxZoom: 22, - attribution: - '© 1992 - ' + new Date().getFullYear() + ' TomTom. ', - subdomains: 'abcd', - style: 'main', - ext: 'png', - apikey: '', - }, - variants: { - Basic: 'basic', - Hybrid: 'hybrid', - Labels: 'labels' - } - }, - Esri: { - url: 'https://server.arcgisonline.com/ArcGIS/rest/services/{variant}/MapServer/tile/{z}/{y}/{x}', - options: { - variant: 'World_Street_Map', - attribution: 'Tiles © Esri' - }, - variants: { - WorldStreetMap: { - options: { - attribution: - '{attribution.Esri} — ' + - 'Source: Esri, DeLorme, NAVTEQ, USGS, Intermap, iPC, NRCAN, Esri Japan, METI, Esri China (Hong Kong), Esri (Thailand), TomTom, 2012' - } - }, - DeLorme: { - options: { - variant: 'Specialty/DeLorme_World_Base_Map', - minZoom: 1, - maxZoom: 11, - attribution: '{attribution.Esri} — Copyright: ©2012 DeLorme' - } - }, - WorldTopoMap: { - options: { - variant: 'World_Topo_Map', - attribution: - '{attribution.Esri} — ' + - 'Esri, DeLorme, NAVTEQ, TomTom, Intermap, iPC, USGS, FAO, NPS, NRCAN, GeoBase, Kadaster NL, Ordnance Survey, Esri Japan, METI, Esri China (Hong Kong), and the GIS User Community' - } - }, - WorldImagery: { - options: { - variant: 'World_Imagery', - attribution: - '{attribution.Esri} — ' + - 'Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community' - } - }, - WorldTerrain: { - options: { - variant: 'World_Terrain_Base', - maxZoom: 13, - attribution: - '{attribution.Esri} — ' + - 'Source: USGS, Esri, TANA, DeLorme, and NPS' - } - }, - WorldShadedRelief: { - options: { - variant: 'World_Shaded_Relief', - maxZoom: 13, - attribution: '{attribution.Esri} — Source: Esri' - } - }, - WorldPhysical: { - options: { - variant: 'World_Physical_Map', - maxZoom: 8, - attribution: '{attribution.Esri} — Source: US National Park Service' - } - }, - OceanBasemap: { - options: { - variant: 'Ocean_Basemap', - maxZoom: 13, - attribution: '{attribution.Esri} — Sources: GEBCO, NOAA, CHS, OSU, UNH, CSUMB, National Geographic, DeLorme, NAVTEQ, and Esri' - } - }, - NatGeoWorldMap: { - options: { - variant: 'NatGeo_World_Map', - maxZoom: 16, - attribution: '{attribution.Esri} — National Geographic, Esri, DeLorme, NAVTEQ, UNEP-WCMC, USGS, NASA, ESA, METI, NRCAN, GEBCO, NOAA, iPC' - } - }, - WorldGrayCanvas: { - options: { - variant: 'Canvas/World_Light_Gray_Base', - maxZoom: 16, - attribution: '{attribution.Esri} — Esri, DeLorme, NAVTEQ' - } - } - } - }, - OpenWeatherMap: { - url: 'http://{s}.tile.openweathermap.org/map/{variant}/{z}/{x}/{y}.png?appid={apiKey}', - options: { - maxZoom: 19, - attribution: 'Map data © OpenWeatherMap', - apiKey:'', - opacity: 0.5 - }, - variants: { - Clouds: 'clouds', - CloudsClassic: 'clouds_cls', - Precipitation: 'precipitation', - PrecipitationClassic: 'precipitation_cls', - Rain: 'rain', - RainClassic: 'rain_cls', - Pressure: 'pressure', - PressureContour: 'pressure_cntr', - Wind: 'wind', - Temperature: 'temp', - Snow: 'snow' - } - }, - HERE: { - /* - * HERE maps, formerly Nokia maps. - * These basemaps are free, but you need an api id and app key. Please sign up at - * https://developer.here.com/plans - */ - url: - 'https://{s}.{base}.maps.api.here.com/maptile/2.1/' + - '{type}/{mapID}/{variant}/{z}/{x}/{y}/{size}/{format}?' + - 'app_id={app_id}&app_code={app_code}&lg={language}', - options: { - attribution: - 'Map © 1987-' + new Date().getFullYear() + ' HERE', - subdomains: '1234', - mapID: 'newest', - 'app_id': '', - 'app_code': '', - base: 'base', - variant: 'normal.day', - maxZoom: 20, - type: 'maptile', - language: 'eng', - format: 'png8', - size: '256' - }, - variants: { - normalDay: 'normal.day', - normalDayCustom: 'normal.day.custom', - normalDayGrey: 'normal.day.grey', - normalDayMobile: 'normal.day.mobile', - normalDayGreyMobile: 'normal.day.grey.mobile', - normalDayTransit: 'normal.day.transit', - normalDayTransitMobile: 'normal.day.transit.mobile', - normalDayTraffic: { - options: { - variant: 'normal.traffic.day', - base: 'traffic', - type: 'traffictile' - } - }, - normalNight: 'normal.night', - normalNightMobile: 'normal.night.mobile', - normalNightGrey: 'normal.night.grey', - normalNightGreyMobile: 'normal.night.grey.mobile', - normalNightTransit: 'normal.night.transit', - normalNightTransitMobile: 'normal.night.transit.mobile', - reducedDay: 'reduced.day', - reducedNight: 'reduced.night', - basicMap: { - options: { - type: 'basetile' - } - }, - mapLabels: { - options: { - type: 'labeltile', - format: 'png' - } - }, - trafficFlow: { - options: { - base: 'traffic', - type: 'flowtile' - } - }, - carnavDayGrey: 'carnav.day.grey', - hybridDay: { - options: { - base: 'aerial', - variant: 'hybrid.day' - } - }, - hybridDayMobile: { - options: { - base: 'aerial', - variant: 'hybrid.day.mobile' - } - }, - hybridDayTransit: { - options: { - base: 'aerial', - variant: 'hybrid.day.transit' - } - }, - hybridDayGrey: { - options: { - base: 'aerial', - variant: 'hybrid.grey.day' - } - }, - hybridDayTraffic: { - options: { - variant: 'hybrid.traffic.day', - base: 'traffic', - type: 'traffictile' - } - }, - pedestrianDay: 'pedestrian.day', - pedestrianNight: 'pedestrian.night', - satelliteDay: { - options: { - base: 'aerial', - variant: 'satellite.day' - } - }, - terrainDay: { - options: { - base: 'aerial', - variant: 'terrain.day' - } - }, - terrainDayMobile: { - options: { - base: 'aerial', - variant: 'terrain.day.mobile' - } - } - } - }, - HEREv3: { - /* - * HERE maps API Version 3. - * These basemaps are free, but you need an API key. Please sign up at - * https://developer.here.com/plans - * Version 3 deprecates the app_id and app_code access in favor of apiKey - * - * Supported access methods as of 2019/12/21: - * @see https://developer.here.com/faqs#access-control-1--how-do-you-control-access-to-here-location-services - */ - url: - 'https://{s}.{base}.maps.ls.hereapi.com/maptile/2.1/' + - '{type}/{mapID}/{variant}/{z}/{x}/{y}/{size}/{format}?' + - 'apiKey={apiKey}&lg={language}', - options: { - attribution: - 'Map © 1987-' + new Date().getFullYear() + ' HERE', - subdomains: '1234', - mapID: 'newest', - apiKey: '', - base: 'base', - variant: 'normal.day', - maxZoom: 20, - type: 'maptile', - language: 'eng', - format: 'png8', - size: '256' - }, - variants: { - normalDay: 'normal.day', - normalDayCustom: 'normal.day.custom', - normalDayGrey: 'normal.day.grey', - normalDayMobile: 'normal.day.mobile', - normalDayGreyMobile: 'normal.day.grey.mobile', - normalDayTransit: 'normal.day.transit', - normalDayTransitMobile: 'normal.day.transit.mobile', - normalNight: 'normal.night', - normalNightMobile: 'normal.night.mobile', - normalNightGrey: 'normal.night.grey', - normalNightGreyMobile: 'normal.night.grey.mobile', - normalNightTransit: 'normal.night.transit', - normalNightTransitMobile: 'normal.night.transit.mobile', - reducedDay: 'reduced.day', - reducedNight: 'reduced.night', - basicMap: { - options: { - type: 'basetile' - } - }, - mapLabels: { - options: { - type: 'labeltile', - format: 'png' - } - }, - trafficFlow: { - options: { - base: 'traffic', - type: 'flowtile' - } - }, - carnavDayGrey: 'carnav.day.grey', - hybridDay: { - options: { - base: 'aerial', - variant: 'hybrid.day' - } - }, - hybridDayMobile: { - options: { - base: 'aerial', - variant: 'hybrid.day.mobile' - } - }, - hybridDayTransit: { - options: { - base: 'aerial', - variant: 'hybrid.day.transit' - } - }, - hybridDayGrey: { - options: { - base: 'aerial', - variant: 'hybrid.grey.day' - } - }, - pedestrianDay: 'pedestrian.day', - pedestrianNight: 'pedestrian.night', - satelliteDay: { - options: { - base: 'aerial', - variant: 'satellite.day' - } - }, - terrainDay: { - options: { - base: 'aerial', - variant: 'terrain.day' - } - }, - terrainDayMobile: { - options: { - base: 'aerial', - variant: 'terrain.day.mobile' - } - } - } - }, - FreeMapSK: { - url: 'https://{s}.freemap.sk/T/{z}/{x}/{y}.jpeg', - options: { - minZoom: 8, - maxZoom: 16, - subdomains: 'abcd', - bounds: [[47.204642, 15.996093], [49.830896, 22.576904]], - attribution: - '{attribution.OpenStreetMap}, vizualization CC-By-SA 2.0 Freemap.sk' - } - }, - MtbMap: { - url: 'http://tile.mtbmap.cz/mtbmap_tiles/{z}/{x}/{y}.png', - options: { - attribution: - '{attribution.OpenStreetMap} & USGS' - } - }, - CartoDB: { - url: 'https://{s}.basemaps.cartocdn.com/{variant}/{z}/{x}/{y}{r}.png', - options: { - attribution: '{attribution.OpenStreetMap} © CARTO', - subdomains: 'abcd', - maxZoom: 19, - variant: 'light_all' - }, - variants: { - Positron: 'light_all', - PositronNoLabels: 'light_nolabels', - PositronOnlyLabels: 'light_only_labels', - DarkMatter: 'dark_all', - DarkMatterNoLabels: 'dark_nolabels', - DarkMatterOnlyLabels: 'dark_only_labels', - Voyager: 'rastertiles/voyager', - VoyagerNoLabels: 'rastertiles/voyager_nolabels', - VoyagerOnlyLabels: 'rastertiles/voyager_only_labels', - VoyagerLabelsUnder: 'rastertiles/voyager_labels_under' - } - }, - HikeBike: { - url: 'https://tiles.wmflabs.org/{variant}/{z}/{x}/{y}.png', - options: { - maxZoom: 19, - attribution: '{attribution.OpenStreetMap}', - variant: 'hikebike' - }, - variants: { - HikeBike: {}, - HillShading: { - options: { - maxZoom: 15, - variant: 'hillshading' - } - } - } - }, - BasemapAT: { - url: 'https://maps{s}.wien.gv.at/basemap/{variant}/{type}/google3857/{z}/{y}/{x}.{format}', - options: { - maxZoom: 19, - attribution: 'Datenquelle: basemap.at', - subdomains: ['', '1', '2', '3', '4'], - type: 'normal', - format: 'png', - bounds: [[46.358770, 8.782379], [49.037872, 17.189532]], - variant: 'geolandbasemap' - }, - variants: { - basemap: { - options: { - maxZoom: 20, // currently only in Vienna - variant: 'geolandbasemap' - } - }, - grau: 'bmapgrau', - overlay: 'bmapoverlay', - terrain: { - options: { - variant: 'bmapgelaende', - type: 'grau', - format: 'jpeg' - } - }, - surface: { - options: { - variant: 'bmapoberflaeche', - type: 'grau', - format: 'jpeg' - } - }, - highdpi: { - options: { - variant: 'bmaphidpi', - format: 'jpeg' - } - }, - orthofoto: { - options: { - maxZoom: 20, // currently only in Vienna - variant: 'bmaporthofoto30cm', - format: 'jpeg' - } - } - } - }, - nlmaps: { - url: 'https://geodata.nationaalgeoregister.nl/tiles/service/wmts/{variant}/EPSG:3857/{z}/{x}/{y}.png', - options: { - minZoom: 6, - maxZoom: 19, - bounds: [[50.5, 3.25], [54, 7.6]], - attribution: 'Kaartgegevens © Kadaster' - }, - variants: { - 'standaard': 'brtachtergrondkaart', - 'pastel': 'brtachtergrondkaartpastel', - 'grijs': 'brtachtergrondkaartgrijs', - 'luchtfoto': { - 'url': 'https://geodata.nationaalgeoregister.nl/luchtfoto/rgb/wmts/2018_ortho25/EPSG:3857/{z}/{x}/{y}.png', - } - } - }, - NASAGIBS: { - url: 'https://map1.vis.earthdata.nasa.gov/wmts-webmerc/{variant}/default/{time}/{tilematrixset}{maxZoom}/{z}/{y}/{x}.{format}', - options: { - attribution: - 'Imagery provided by services from the Global Imagery Browse Services (GIBS), operated by the NASA/GSFC/Earth Science Data and Information System ' + - '(ESDIS) with funding provided by NASA/HQ.', - bounds: [[-85.0511287776, -179.999999975], [85.0511287776, 179.999999975]], - minZoom: 1, - maxZoom: 9, - format: 'jpg', - time: '', - tilematrixset: 'GoogleMapsCompatible_Level' - }, - variants: { - ModisTerraTrueColorCR: 'MODIS_Terra_CorrectedReflectance_TrueColor', - ModisTerraBands367CR: 'MODIS_Terra_CorrectedReflectance_Bands367', - ViirsEarthAtNight2012: { - options: { - variant: 'VIIRS_CityLights_2012', - maxZoom: 8 - } - }, - ModisTerraLSTDay: { - options: { - variant: 'MODIS_Terra_Land_Surface_Temp_Day', - format: 'png', - maxZoom: 7, - opacity: 0.75 - } - }, - ModisTerraSnowCover: { - options: { - variant: 'MODIS_Terra_Snow_Cover', - format: 'png', - maxZoom: 8, - opacity: 0.75 - } - }, - ModisTerraAOD: { - options: { - variant: 'MODIS_Terra_Aerosol', - format: 'png', - maxZoom: 6, - opacity: 0.75 - } - }, - ModisTerraChlorophyll: { - options: { - variant: 'MODIS_Terra_Chlorophyll_A', - format: 'png', - maxZoom: 7, - opacity: 0.75 - } - } - } - }, - NLS: { - // NLS maps are copyright National library of Scotland. - // http://maps.nls.uk/projects/api/index.html - // Please contact NLS for anything other than non-commercial low volume usage - // - // Map sources: Ordnance Survey 1:1m to 1:63K, 1920s-1940s - // z0-9 - 1:1m - // z10-11 - quarter inch (1:253440) - // z12-18 - one inch (1:63360) - url: 'https://nls-{s}.tileserver.com/nls/{z}/{x}/{y}.jpg', - options: { - attribution: 'National Library of Scotland Historic Maps', - bounds: [[49.6, -12], [61.7, 3]], - minZoom: 1, - maxZoom: 18, - subdomains: '0123', - } - }, - JusticeMap: { - // Justice Map (http://www.justicemap.org/) - // Visualize race and income data for your community, county and country. - // Includes tools for data journalists, bloggers and community activists. - url: 'http://www.justicemap.org/tile/{size}/{variant}/{z}/{x}/{y}.png', - options: { - attribution: 'Justice Map', - // one of 'county', 'tract', 'block' - size: 'county', - // Bounds for USA, including Alaska and Hawaii - bounds: [[14, -180], [72, -56]] - }, - variants: { - income: 'income', - americanIndian: 'indian', - asian: 'asian', - black: 'black', - hispanic: 'hispanic', - multi: 'multi', - nonWhite: 'nonwhite', - white: 'white', - plurality: 'plural' - } - }, - Wikimedia: { - url: 'https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}{r}.png', - options: { - attribution: 'Wikimedia', - minZoom: 1, - maxZoom: 19 - } - }, - GeoportailFrance: { - url: 'https://wxs.ign.fr/{apikey}/geoportail/wmts?REQUEST=GetTile&SERVICE=WMTS&VERSION=1.0.0&STYLE={style}&TILEMATRIXSET=PM&FORMAT={format}&LAYER={variant}&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}', - options: { - attribution: 'Geoportail France', - bounds: [[-75, -180], [81, 180]], - minZoom: 2, - maxZoom: 18, - // Get your own geoportail apikey here : http://professionnels.ign.fr/ign/contrats/ - // NB : 'choisirgeoportail' is a demonstration key that comes with no guarantee - apikey: 'choisirgeoportail', - format: 'image/jpeg', - style : 'normal', - variant: 'GEOGRAPHICALGRIDSYSTEMS.MAPS.SCAN-EXPRESS.STANDARD' - }, - variants: { - parcels: { - options : { - variant: 'CADASTRALPARCELS.PARCELS', - maxZoom: 20, - style : 'bdparcellaire', - format: 'image/png' - } - }, - ignMaps: 'GEOGRAPHICALGRIDSYSTEMS.MAPS', - maps: 'GEOGRAPHICALGRIDSYSTEMS.MAPS.SCAN-EXPRESS.STANDARD', - orthos: { - options: { - maxZoom: 19, - variant: 'ORTHOIMAGERY.ORTHOPHOTOS' - } - } - } - }, - OneMapSG: { - url: 'https://maps-{s}.onemap.sg/v3/{variant}/{z}/{x}/{y}.png', - options: { - variant: 'Default', - minZoom: 11, - maxZoom: 18, - bounds: [[1.56073, 104.11475], [1.16, 103.502]], - attribution: ' New OneMap | Map data © contributors, Singapore Land Authority' - }, - variants: { - Default: 'Default', - Night: 'Night', - Original: 'Original', - Grey: 'Grey', - LandLot: 'LandLot' - } - } - }; - - L.tileLayer.provider = function (provider, options) { - return new L.TileLayer.Provider(provider, options); - }; - - return L; -})); diff --git a/templates/attitude-indicator/CNAME b/templates/attitude-indicator/CNAME deleted file mode 100644 index 49238d67..00000000 --- a/templates/attitude-indicator/CNAME +++ /dev/null @@ -1 +0,0 @@ -attitude-indicator.igneosaur.co.uk diff --git a/templates/attitude-indicator/README.md b/templates/attitude-indicator/README.md deleted file mode 100644 index 7283af71..00000000 --- a/templates/attitude-indicator/README.md +++ /dev/null @@ -1,2 +0,0 @@ -https://dannyedwards.gitlab.io/attitude-indicator/ -https://github.com/saasmath/attitude-indicator diff --git a/templates/attitude-indicator/index.html b/templates/attitude-indicator/index.html deleted file mode 100644 index e967bb7d..00000000 --- a/templates/attitude-indicator/index.html +++ /dev/null @@ -1,92 +0,0 @@ - - - - - Attitude Indicator - - - - - - - - - - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
- -
-
-
-
-
- -
-
-
-
- -
-
-
-
- -
-
-
-
-
-
- -
-
-
-
-
- -
-
-
-
-
- -
-
    -
  • STBY
  • -
  • PWR
  • -
  • TEST
  • -
-
    -
  • -
  • -
  • -
-
-
- -
PULL TO CAGE
-
- - - - \ No newline at end of file diff --git a/templates/glass.html b/templates/glass.html deleted file mode 100644 index 33762a76..00000000 --- a/templates/glass.html +++ /dev/null @@ -1,615 +0,0 @@ - - - - - MSFS2020 Cockpit Companion - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - -
-
-
-
- -
- -
- - -
- -
-
-
-
-
-
- Altitude -

?

-
-
-
-
-
-
- V/Speed -

?

-
-
-
-
-
-
- Heading -

?

-
-
-
-
-
-
- Airspeed -

?

-
-
-
-
- -
-
-
- - -
-
-
-
-
-
-
- Fuel % -
-
-
-
-
-
-
-
-
-
- - -
-
-
-
-
-
-
- -
-
-
-
-
- -
- - - - - -
-
-
-
-
-
-
- - -
-
-
-
-
-
-
- - - -
-
-
-
-
-
-
- - -
-
-
-
-
-
-
- Landing gear - -
-
-
-
-
-
- Flaps (%)

- - - - - - - - -
?
- -
-
-
-
-
-
- Elevator Trim (%)

- - - - - - - -
?
-
-
-
- - -
-
-
-
- - -
-
-
-
-
-
-
- Autopilot Master - -
-
-
- -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Wing Leveler
Heading -
- -
- -
-
-
Altitude -
- -
- -
-
-
Vertical Speed -
- -
- -
-
-
Airspeed -
- -
- -
-
-
Attitude
Backcourse
Approach
- -
-
-
-
-
-
-
- - -
-
-
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - -
COM1 Frequency - - - - - -
COM2 Frequency - - - - - -
NAV1 Frequency - - - - - -
NAV2 Frequency - - - - - -
- -
-
-
-
-
-
-
-
-
-
- -
- - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/glass_dummy_server.py b/tests/glass_dummy_server.py deleted file mode 100644 index 10a51570..00000000 --- a/tests/glass_dummy_server.py +++ /dev/null @@ -1,79 +0,0 @@ -# -# This is a dummy server which does not connect to MSFS2020 -# It just serves up random data which allows testing of the front end without MSFS2020 running -# If you want to connect to MSFS2020 then you are looking for glass_server.py - - -from flask import Flask, jsonify, render_template, request -from time import sleep -import random - -app = Flask(__name__) - -latitude = 47.606209 -longitude = -122.332069 - -def thousandify(x): - return f"{x:,}" - - - -@app.route ('/') -def glass(): - return render_template("glass.html") - -@app.route('/ui') -def output_ui_variables(): - global latitude, longitude - - ui_friendly_dictionary = {} - ui_friendly_dictionary["STATUS"] = "success" - ui_friendly_dictionary["ALTITUDE"] = thousandify(random.randint(7995,8005)) - ui_friendly_dictionary["LATITUDE"] = latitude - ui_friendly_dictionary["LONGITUDE"] = longitude - ui_friendly_dictionary["AIRSPEED_INDICATE"] = random.randint(395,405) - ui_friendly_dictionary["MAGNETIC_COMPASS"] = random.randint(89,91) - ui_friendly_dictionary["VERTICAL_SPEED"] = random.randint(-5,5) - ui_friendly_dictionary["FUEL_PERCENTAGE"] = random.randint(79,81) - - ui_friendly_dictionary["AUTOPILOT_HEADING_LOCK"] = random.randint(0,1) - ui_friendly_dictionary["AUTOPILOT_HEADING_LOCK_DIR"] = random.randint(1,360) - - ui_friendly_dictionary["AUTOPILOT_ALTITUDE_LOCK"] = random.randint(0,1) - ui_friendly_dictionary["AUTOPILOT_ALTITUDE_LOCK_VAR"] = thousandify(random.randint(5000,25000)) - - ui_friendly_dictionary["AUTOPILOT_AIRSPEED_HOLD"] = random.randint(0,1) - ui_friendly_dictionary["AUTOPILOT_AIRSPEED_HOLD_VAR"] = thousandify(random.randint(100,350)) - - ui_friendly_dictionary["AUTOPILOT_PITCH_HOLD"] = random.randint(0,1) - ui_friendly_dictionary["AUTOPILOT_PITCH_HOLD_REF"] = thousandify(random.randint(-25,25)) - - if random.randint(0,1) == 1: - ui_friendly_dictionary["GEAR_HANDLE_POSITION"] = "UP" - else: - ui_friendly_dictionary["GEAR_HANDLE_POSITION"] = "DOWN" - - ui_friendly_dictionary["ELEVATOR_TRIM_PCT"] = random.randint(-10,10) - ui_friendly_dictionary["RUDDER_TRIM_PCT"] = random.randint(-10,10) - ui_friendly_dictionary["FLAPS_HANDLE_PERCENT"] = random.randint(0,100) - - longitude = longitude + 0.01 - - return jsonify(ui_friendly_dictionary) - - -@app.route('/datapoint//set', methods=["POST"]) -def set_datapoint(datapoint_name): - - value_to_use = request.form.get('value_to_use') - - if value_to_use == None: - print(datapoint_name + ": " + "No value passed") - else: - print(datapoint_name + ": " + value_to_use) - - status = "success" - return jsonify(status) - - -app.run(host='0.0.0.0', port=5000, debug=True) \ No newline at end of file diff --git a/tests/test_entity_plane.py b/tests/test_entity_plane.py deleted file mode 100644 index afeca062..00000000 --- a/tests/test_entity_plane.py +++ /dev/null @@ -1,62 +0,0 @@ -import SimConnect -from unittest import TestCase -from unittest.mock import Mock, patch, create_autospec - -import logging - -LOGGER = logging.getLogger(__name__) - - -class sData(dict): - __getattr__ = dict.__getitem__ - __delattr__ = dict.__delitem__ - - def __setattr__(self, key, value): - super(sData, self).__setitem__(key, value) - setattr( - self, - key, - value, - ) - - def __init__(self, data=None): - if data is not None: - try: - for key, value in data.items(): - self[key] = value - except: - pass - - -class TestPlane(TestCase): - def test_init(self): - # SimConnect.Plane() - self.assertTrue(True) - - def test_values(self): - - sm = create_autospec(SimConnect.SimConnect) - - def side_effect(*args, **kwargs): - def val(): - v = 100 - x = 100 - while True: - yield x - x += v - - data = sData() - val = val() - data["Altitude"] = next(val) - data["Latitude"] = next(val) - data["Longitude"] = next(val) - data["Kohlsman"] = next(val) - return data - - sm.get_data.side_effect = side_effect - - pl = SimConnect.Plane(sm=sm) - self.assertEqual(100, pl.altitude) - self.assertEqual(200, pl.latitude) - self.assertEqual(300, pl.longitude) - self.assertEqual(400, pl.kohlsman) diff --git a/tests/test_request.py b/tests/test_request.py deleted file mode 100644 index f81c92da..00000000 --- a/tests/test_request.py +++ /dev/null @@ -1,11 +0,0 @@ -from SimConnect import Request -from unittest import TestCase -from unittest.mock import Mock - -import logging - -LOGGER = logging.getLogger(__name__) - -class TestSimple(TestCase): - def test_init_request(self): - self.assertTrue(True) diff --git a/tests/test_simconnect.py b/tests/test_simconnect.py deleted file mode 100644 index 117ed795..00000000 --- a/tests/test_simconnect.py +++ /dev/null @@ -1,11 +0,0 @@ -from SimConnect import * - -from unittest import TestCase - -import logging - -LOGGER = logging.getLogger(__name__) - -class TestSimple(TestCase): - def test_init_simconnect(self): - self.assertTrue(True) diff --git a/window_config.md b/window_config.md new file mode 100644 index 00000000..8dc21e93 --- /dev/null +++ b/window_config.md @@ -0,0 +1,167 @@ +# 任务:Python 桌面应用高 DPI 自适应布局开发 + +## 一、项目背景与硬性约束 + +这是一个运行在 Windows 上的 Python 桌面应用,用于 SimConnect 数据采集与可视化。 + +### 运行环境(不可更改) +- 操作系统:Windows(win32) +- Python:3.10,**必须是 32 位**(因为 SimConnect.dll 是 32 位) +- 架构:x86 + +### 依赖白名单(严禁引入白名单外的任何库) +pymysql>=1.0,<2 +DBUtils>=3.0,<4 +python-dotenv>=1.0,<2 +passlib>=1.7,<2 +numpy>=1.21,<1.25 +pandas>=2.0,<2.1 +PySide2==5.15.2.1; platform_machine == "x86" +pyqtgraph>=0.13,<0.14 +pygame>=2.1,<2.6 + + +**特别注意:** +- 不得建议升级到 PySide6、PyQt5/6,不得引入 qt-material、qdarkstyle 等其他 Qt 相关库。 +- numpy 不得升级到 1.25 及以上。 +- 安装 pyqtgraph 前需设置环境变量 `PYQTGRAPH_QT_LIB=PySide2`。 + +## 二、核心需求 + +在**不同分辨率显示器**和**不同显示缩放比例(100% / 125% / 150% / 175% / 200%)**下,应用主窗口及所有子界面保持稳定、不模糊、不重叠、不截断的布局。 + +## 三、必须遵守的技术规则 + +### 1. 高 DPI 初始化(顺序至关重要) +- 所有 DPI 相关设置**必须写在 QApplication 实例化之前**。 +- 必须使用 `PassThrough` 舍入策略以支持非整数缩放(125%、150%)。 +- 不要用已废弃的 `Qt.AA_EnableHighDpiScaling` 单独设置而不配舍入策略。 +- 给出一个独立的 `app_bootstrap.py` 或在 main 入口中明确标注顺序。 + +### 2. 布局规范 +- **禁止**使用 `setGeometry`、`move`、`resize` 对控件做绝对定位(顶层窗口的初始尺寸除外)。 +- 必须使用 `QVBoxLayout` / `QHBoxLayout` / `QGridLayout` / `QFormLayout` / `QSplitter` 等布局容器。 +- 主窗口结构建议:顶部工具栏(固定高)→ 中部内容区(伸缩)→ 底部状态栏(固定高)。 +- 所有需要按比例分配的空间,用 `setStretchFactor` 或 `addWidget(..., stretch=...)` 明确权重。 + +### 3. 尺寸与字体 +- 所有硬编码像素值(边距、间距、图标尺寸、最小宽高)必须能被 DPI 缩放正确响应。 +- 字体使用 `QFont` 并优先依赖系统默认字体,不写死 pointSize 以外的奇怪单位。 +- 图标优先使用矢量(SVG / QIcon from theme);如必须用位图,需准备多倍图或说明缩放策略。 + +### 4. pyqtgraph 图表专项 +- 图表控件必须随父容器伸缩(设置合适的 sizePolicy)。 +- 散点图若出现高 DPI 模糊,允许设置 `useCache=False` 作为缓解手段,但需在注释中说明原因。 +- 坐标轴、图例字体要随 DPI 一致缩放。 + +### 5. pygame 部分 +- pygame 无跨平台高 DPI 支持。在 Windows 下,如需避免系统拉伸,可用 ctypes 调用 `SetProcessDPIAware()`,但**必须注明这是 Windows 专属方案**,且调用时机需在 pygame 初始化之前。 + +### 6. 已知风险规避(PySide2 5.15.2.1 特有) +- **严禁**通过 `QtWin` + `win32gui.SetWindowLong` 等方式自定义原生窗口控件。 +- 已知在 125% 以上缩放时,自定义原生窗口会导致鼠标点击失效、光标异常。 +- 如需自定义标题栏/无边框窗口,必须使用纯 Qt 方案(`Qt.FramelessWindowHint` + 自绘标题栏),并在注释中标注该限制。 + +## 四、交付物 + +1. **入口文件**:包含正确的 DPI 初始化序列,并附注释说明每一步的作用。 +2. **主窗口布局示例**:一个可运行的最小示例,包含工具栏、内容区(含一个 pyqtgraph 图表)、状态栏,全部用布局管理器实现。 +3. **高 DPI 自检清单**:列出在 100%/125%/150%/200% 下需要人工验证的项目(如:按钮文字是否截断、图表是否模糊、控件是否重叠)。 +4. **风险说明文档**:明确指出哪些部分是 PySide2 的固有限制,无法彻底解决,只能规避。 + +## 五、输出要求 + +- 代码需可直接运行(假设依赖已按白名单安装)。 +- 每个关键决策处写注释,说明"为什么这样做"而非仅"做了什么"。 +- 如遇到白名单依赖无法满足需求的情况,**先说明限制,再给替代方案**,不得擅自引入新库。 +- 不确定的地方明确标注"此处需人工验证",不要编造 API。 + +## 六、禁止事项 + +- 禁止引入白名单外的任何依赖。 +- 禁止建议更换 Qt 绑定版本。 +- 禁止使用绝对定位实现布局。 +- 禁止在 QApplication 创建之后再设置 DPI 相关属性。 +- 禁止使用自定义原生窗口控件。 + +--- + +## 七、本仓库落地说明(A2 + B1 + C1) + +### 7.1 入口与 DPI 序列(交付物 1) + +| 文件 | 作用 | +|------|------| +| `app_bootstrap.py` | `apply_qt_high_dpi()`:PassThrough → AA_EnableHighDpiScaling → AA_UseHighDpiPixmaps;`apply_windows_dpi_aware_for_pygame()`:Windows 专属 | +| `app_run.py` | `main()` 尽早调用 `apply_qt_high_dpi()` | +| `components/Frame/Frame_Theme_UI.py` → `create_app()` | 创建 `QApplication` 前再次幂等调用;再设 SDL dummy / 字体 / QSS | +| `models/hardware_monitor.py` → `_ensure_pygame()` | import pygame 前调用 `SetProcessDPIAware` | + +**双缩放职责(B1,勿混用):** + +- **OS / 显示器 DPI**:由 Qt High DPI(`app_bootstrap`)处理逻辑像素。 +- **窗口相对设计稿**:仍由 `UITheme.apply_scale` / `UITheme.scaled` 处理(`FEATURE_WINDOW_SPECS` 等),不替代、也不取消 Qt DPI。 + +### 7.2 主窗口布局(交付物 2,A2:不另建 demo) + +以现有可运行主壳为准(`python app_run.py` / `--smoke`): + +| 层级 | 实现 | 布局要点 | +|------|------|----------| +| 顶层窗 | `Frame_Shell_AppMain`(`QMainWindow`) | 仅顶层用 `apply_fixed_window` / `setGeometry` 定尺寸与居中(文档允许) | +| 内容栈 | `QStackedWidget` | Feature 页切换;无业务控件绝对铺板 | +| 训练页 | `pages/page_training_assist.py` | 自上而下:指标区 → 圈速 → **pyqtgraph 图表区(stretch)** → 操作/状态条 | +| 图表槽 | `charts/slot.py` → `ChartSlot` | `PlotWidget` + `Expanding` sizePolicy,`layout.addWidget(..., stretch=1)` | +| 航迹图 | `charts/widgets/track_map_pane.py` | 同上;散点 `useCache=False` 缓解高 DPI 模糊 | +| 对话框 | `Frame_Dialog_*` | `QVBoxLayout` / `QFormLayout` + `apply_fixed_window` | + +未使用 `QToolBar` / `QStatusBar` 原生控件:训练页用自研指标条 + `widget_Bar_Status` 承担等价「顶栏固定 / 底栏固定 / 中部伸缩」结构。系统标题栏保留(非 Frameless),仅 `DwmSetWindowAttribute` 着色。 + +### 7.3 高 DPI 人工自检清单(交付物 3) + +在目标机分别设显示缩放 **100% / 125% / 150% / 200%**(建议再抽测 175%),重启应用后逐项勾选: + +**清晰度** + +- [ ] 主窗与对话框整体不发糊(非系统位图拉伸感) +- [ ] 窗口图标、按钮图标边缘可辨 +- [ ] pyqtgraph 曲线/坐标轴文字清晰;航迹散点/光标不发糊 + +**布局与截断** + +- [ ] 按钮/标题/状态栏文字无截断、无被边框裁切 +- [ ] 控件无重叠、无互相遮挡 +- [ ] 训练页中部图表区随窗高伸缩;顶/底区域高度稳定 +- [ ] 各 `Frame_Dialog_*` 内容完整可见(尤其硬件设定、航迹选择) + +**交互命中** + +- [ ] 标题栏拖拽、最小化/最大化/关闭点击正常 +- [ ] 图表拖动/滚轮、按钮/开关/滑块点击落点与光标一致 +- [ ] 全局/本地蒙版覆盖范围与父窗一致 + +**双缩放回归(开启 Qt DPI 后必做)** + +- [ ] `UITheme.apply_scale` 后 QSS/`setFixedSize` 控件尺寸仍合理(不双重放大到溢出) +- [ ] 功能页切换(自检 → 登录 → 训练)窗口尺寸切换无错位 + +**pygame(摇杆设定)** + +- [ ] 硬件设定页枚举/读轴正常;无额外 SDL 闪窗 + +> 标注「此处需人工验证」的代码点:`app_bootstrap.apply_qt_high_dpi`、`apply_window_titlebar_theme`。 + +### 7.4 风险说明(交付物 4)— PySide2 固有限制,只能规避 + +| 风险 | 说明 | 本仓库策略 | +|------|------|------------| +| 自定义原生非客户区 | `QtWin` + `SetWindowLong` 等在 125%+ 易导致点击失效、光标异常 | **禁止**;不用 Frameless 自绘以外的原生改框 | +| DWM 标题栏着色 | `DwmSetWindowAttribute` 改色 ≠ 改窗样式,但仍碰 HWND | 仅着色;高缩放下标题栏命中需人工验证 | +| AA_EnableHighDpiScaling | Qt5/PySide2 依赖属性;Qt6 已变默认行为,**不可靠升级绑定规避** | 固定 PySide2 5.15.2.1 + PassThrough | +| 非整数缩放 | 无 PassThrough 时 125%/150% 布局易错位 | 强制 PassThrough | +| pyqtgraph 散点缓存 | 高 DPI 下缓存符号位图可能模糊 | 航迹相关 `ScatterPlotItem(..., useCache=False)` | +| pygame / SDL | 无跨平台高 DPI;真开窗会被系统拉伸 | 现用 `SDL_VIDEODRIVER=dummy`;仍在 import 前调 Windows `SetProcessDPIAware` | +| 逻辑像素 vs 设计倍率 | Qt DPI 与 `UITheme.apply_scale` 叠加可能「看起来偏大」 | 职责分离保留(B1);靠自检清单回归,不引入白名单外库 | +| 图表叠层几何 | HUD / overlay / 双 ViewBox 同步仍可能用 `setGeometry`(视口像素层) | 仅叠层跟随宿主;业务板式一律 Layout | + +**无法彻底解决、只能规避:** 不升级到 PySide6;不引入额外 Qt 主题库;不通过原生 API 做无边框客户区改造。