├── src ├── vite-env.d.ts ├── geomagnetism.d.ts ├── style.css ├── dcs │ ├── terrain │ │ ├── syria.ts │ │ ├── nevada.ts │ │ ├── caucasus.ts │ │ ├── normandy.ts │ │ ├── thechannel.ts │ │ ├── persiangulf.ts │ │ ├── marianaislands.ts │ │ └── index.ts │ └── aircraft.ts ├── main.tsx ├── Spinner.tsx ├── objectSettings.ts ├── hook.ts ├── settings.ts ├── Symbol.tsx ├── App.tsx ├── data │ └── terrain │ │ ├── marianaislands.json │ │ ├── thechannel.json │ │ ├── nevada.json │ │ ├── caucasus.json │ │ ├── persiangulf.json │ │ ├── normandy.json │ │ └── syria.json ├── BraaInfo.tsx ├── tacview │ ├── index.ts │ └── record │ │ └── objectProperty.ts ├── AirportMarker.tsx ├── CursorInfo.tsx ├── ObjectMarker.tsx ├── entity.ts ├── ConnectView.tsx ├── ControlPanel.tsx ├── SettingsModal.tsx ├── util.ts ├── ObjectInfo.tsx └── MainView.tsx ├── src-tauri ├── build.rs ├── icons │ ├── 32x32.png │ ├── icon.icns │ ├── icon.ico │ ├── icon.png │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── StoreLogo.png │ ├── Square30x30Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ └── Square310x310Logo.png ├── .gitignore ├── Cargo.toml ├── tauri.conf.json └── src │ └── main.rs ├── screenshot.png ├── guide-screenshot1.png ├── postcss.config.cjs ├── tsconfig.node.json ├── prettier.config.cjs ├── tailwind.config.cjs ├── .gitignore ├── tools └── terrain-data-generator │ ├── README.md │ ├── pyproject.toml │ ├── terrain_data_generator │ └── __init__.py │ ├── .gitignore │ └── poetry.lock ├── index.html ├── public └── eye.svg ├── .github └── workflows │ ├── rustfmt.yaml │ ├── eslint.yaml │ ├── prettier.yaml │ ├── clippy.yaml │ ├── release.yaml │ └── build.yaml ├── tsconfig.json ├── .eslintrc.yml ├── vite.config.ts ├── LICENSE ├── package.json └── README.md /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | import "vite/client"; 2 | -------------------------------------------------------------------------------- /src/geomagnetism.d.ts: -------------------------------------------------------------------------------- 1 | declare module "geomagnetism"; 2 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbzweihander/peace-eye/HEAD/screenshot.png -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /guide-screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbzweihander/peace-eye/HEAD/guide-screenshot1.png -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbzweihander/peace-eye/HEAD/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbzweihander/peace-eye/HEAD/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbzweihander/peace-eye/HEAD/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbzweihander/peace-eye/HEAD/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbzweihander/peace-eye/HEAD/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbzweihander/peace-eye/HEAD/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbzweihander/peace-eye/HEAD/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbzweihander/peace-eye/HEAD/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbzweihander/peace-eye/HEAD/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbzweihander/peace-eye/HEAD/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbzweihander/peace-eye/HEAD/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbzweihander/peace-eye/HEAD/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbzweihander/peace-eye/HEAD/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbzweihander/peace-eye/HEAD/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbzweihander/peace-eye/HEAD/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbzweihander/peace-eye/HEAD/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src/dcs/terrain/syria.ts: -------------------------------------------------------------------------------- 1 | import { type Terrain } from "."; 2 | import RawData from "../../data/terrain/syria.json"; 3 | 4 | export const Syria: Terrain = RawData as Terrain; 5 | -------------------------------------------------------------------------------- /src/dcs/terrain/nevada.ts: -------------------------------------------------------------------------------- 1 | import { type Terrain } from "."; 2 | import RawData from "../../data/terrain/nevada.json"; 3 | 4 | export const Nevada: Terrain = RawData as Terrain; 5 | -------------------------------------------------------------------------------- /src/dcs/terrain/caucasus.ts: -------------------------------------------------------------------------------- 1 | import { type Terrain } from "."; 2 | import RawData from "../../data/terrain/caucasus.json"; 3 | 4 | export const Caucasus: Terrain = RawData as Terrain; 5 | -------------------------------------------------------------------------------- /src/dcs/terrain/normandy.ts: -------------------------------------------------------------------------------- 1 | import { type Terrain } from "."; 2 | import RawData from "../../data/terrain/normandy.json"; 3 | 4 | export const Normandy: Terrain = RawData as Terrain; 5 | -------------------------------------------------------------------------------- /src/dcs/terrain/thechannel.ts: -------------------------------------------------------------------------------- 1 | import { type Terrain } from "."; 2 | import RawData from "../../data/terrain/thechannel.json"; 3 | 4 | export const TheChannel: Terrain = RawData as Terrain; 5 | -------------------------------------------------------------------------------- /src/dcs/terrain/persiangulf.ts: -------------------------------------------------------------------------------- 1 | import { type Terrain } from "."; 2 | import RawData from "../../data/terrain/persiangulf.json"; 3 | 4 | export const PersianGulf: Terrain = RawData as Terrain; 5 | -------------------------------------------------------------------------------- /src/dcs/terrain/marianaislands.ts: -------------------------------------------------------------------------------- 1 | import { type Terrain } from "."; 2 | import RawData from "../../data/terrain/marianaislands.json"; 3 | 4 | export const MarianaIslands: Terrain = RawData as Terrain; 5 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: "es5", 3 | tabWidth: 2, 4 | semi: true, 5 | singleQuote: false, 6 | importOrder: ["^[./]"], 7 | importOrderSeparation: true, 8 | plugins: [require("prettier-plugin-tailwindcss")], 9 | } 10 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./index.html", 5 | "./src/**/*.{js,jsx,ts,tsx}", 6 | ], 7 | theme: { 8 | extend: {}, 9 | }, 10 | plugins: [ 11 | require("daisyui"), 12 | ], 13 | } 14 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | 4 | import App from "./App"; 5 | import "./style.css"; 6 | 7 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 8 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /src/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import { SpinnerCircularFixed } from "spinners-react"; 2 | 3 | export default function Spinner(): React.ReactElement { 4 | return ( 5 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /tools/terrain-data-generator/README.md: -------------------------------------------------------------------------------- 1 | # peace-eye terrain-data-generator 2 | 3 | This is a Python script that generates terrain data (e.g. airbase position) for peace-eye using [pydcs](https://github.com/pydcs/dcs). 4 | 5 | ## Requirements 6 | 7 | - [poetry](https://python-poetry.org/) 8 | 9 | ## Usage 10 | 11 | ```bash 12 | poetry install 13 | poetry run generate 14 | ``` 15 | -------------------------------------------------------------------------------- /src/objectSettings.ts: -------------------------------------------------------------------------------- 1 | export function defaultObjectSettings(): ObjectSettings { 2 | return { 3 | warningRange: 0, 4 | threatRange: 0, 5 | watch: false, 6 | }; 7 | } 8 | 9 | export interface ObjectSettings { 10 | warningRange: number; 11 | threatRange: number; 12 | watch: boolean; 13 | } 14 | 15 | export type ObjectSettingsInventory = Record; 16 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | peace-eye 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /public/eye.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/hook.ts: -------------------------------------------------------------------------------- 1 | import { invoke } from "@tauri-apps/api"; 2 | import { useMemo, useState } from "react"; 3 | 4 | export function useCurrentVersion(): string { 5 | const [currentVersion, setCurrentVersion] = useState("-.-.-"); 6 | 7 | useMemo(() => { 8 | invoke("get_current_version") 9 | .then((version) => { 10 | setCurrentVersion(version); 11 | }) 12 | .catch((error) => { 13 | console.log(error); 14 | }); 15 | }, []); 16 | 17 | return currentVersion; 18 | } 19 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | export function defaultSettings(): Settings { 2 | return { 3 | view: { 4 | useMagneticHeading: true, 5 | showGround: true, 6 | showSlowAir: false, 7 | showWeapon: false, 8 | showCursorCoords: false, 9 | }, 10 | }; 11 | } 12 | 13 | export interface Settings { 14 | view: ViewSettings; 15 | } 16 | 17 | export interface ViewSettings { 18 | useMagneticHeading: boolean; 19 | showGround: boolean; 20 | showSlowAir: boolean; 21 | showWeapon: boolean; 22 | showCursorCoords: boolean; 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/rustfmt.yaml: -------------------------------------------------------------------------------- 1 | name: rustfmt 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | rustfmt: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions-rs/toolchain@v1 15 | with: 16 | profile: minimal 17 | toolchain: stable 18 | components: rustfmt 19 | - name: Check rustfmt 20 | uses: actions-rs/cargo@v1 21 | with: 22 | command: fmt 23 | args: --manifest-path src-tauri/Cargo.toml -- --check 24 | -------------------------------------------------------------------------------- /.github/workflows/eslint.yaml: -------------------------------------------------------------------------------- 1 | name: eslint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | eslint: 11 | runs-on: ubuntu-latest 12 | container: node:19 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/cache@v3 16 | with: 17 | path: node_modules 18 | key: node-modules-${{ runner.os }}-${{ hashFiles('yarn.lock') }} 19 | restore-keys: | 20 | node-modules-${{ runner.os }}- 21 | - name: Check eslint 22 | run: | 23 | yarn 24 | yarn lint --max-warnings 0 25 | -------------------------------------------------------------------------------- /.github/workflows/prettier.yaml: -------------------------------------------------------------------------------- 1 | name: prettier 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | prettier: 11 | runs-on: ubuntu-latest 12 | container: node:19 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/cache@v3 16 | with: 17 | path: node_modules 18 | key: node-modules-${{ runner.os }}-${{ hashFiles('yarn.lock') }} 19 | restore-keys: | 20 | node-modules-${{ runner.os }}- 21 | - name: Check prettier 22 | run: | 23 | yarn 24 | yarn prettier:check 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | es2021: true 4 | node: true 5 | extends: 6 | - plugin:react/recommended 7 | - standard-with-typescript 8 | - prettier 9 | overrides: [] 10 | parserOptions: 11 | ecmaVersion: latest 12 | sourceType: module 13 | project: tsconfig.json 14 | plugins: 15 | - react 16 | rules: 17 | "react/react-in-jsx-scope": off 18 | "@typescript-eslint/no-misused-promises": 19 | - error 20 | - checksVoidReturn: 21 | arguments: false 22 | attributes: false 23 | "@typescript-eslint/no-dynamic-delete": off 24 | "@typescript-eslint/no-non-null-assertion": off 25 | settings: 26 | react: 27 | version: detect 28 | -------------------------------------------------------------------------------- /tools/terrain-data-generator/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "terrain-data-generator" 3 | version = "0.1.0" 4 | description = "Terrain data generator for peace-eye" 5 | authors = ["Kangwook Lee "] 6 | license = "MIT" 7 | readme = "README.md" 8 | packages = [{include = "terrain_data_generator"}] 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.10" 12 | 13 | pydcs = { git = "https://github.com/pydcs/dcs.git", rev = "e7ed9061187f7c2cb6719ae757bf91e9cfb85441" } # https://github.com/pydcs/dcs/issues/283 14 | pyproj = "^3.4.1" 15 | 16 | [tool.poetry.scripts] 17 | generate = "terrain_data_generator:main" 18 | 19 | [build-system] 20 | requires = ["poetry-core"] 21 | build-backend = "poetry.core.masonry.api" 22 | -------------------------------------------------------------------------------- /src/Symbol.tsx: -------------------------------------------------------------------------------- 1 | import type * as ms from "milsymbol"; 2 | import { useEffect, useRef, type ReactElement } from "react"; 3 | 4 | export interface SymbolProps { 5 | symbol: ms.Symbol; 6 | } 7 | 8 | export default function Symbol({ symbol }: SymbolProps): ReactElement { 9 | const ref = useRef(null); 10 | 11 | useEffect(() => { 12 | if (ref.current == null) { 13 | return; 14 | } 15 | 16 | while (ref.current.firstChild != null) { 17 | ref.current.removeChild(ref.current.firstChild); 18 | } 19 | 20 | ref.current.appendChild(symbol.asDOM()); 21 | }, [ref.current, symbol]); 22 | 23 | return ; 24 | // return 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/clippy.yaml: -------------------------------------------------------------------------------- 1 | name: clippy 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | clippy: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Install dependencies 15 | run: | 16 | sudo apt-get update 17 | sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf 18 | mkdir dist 19 | - uses: actions-rs/toolchain@v1 20 | with: 21 | profile: minimal 22 | toolchain: stable 23 | components: clippy 24 | - uses: Swatinem/rust-cache@v2 25 | with: 26 | workspaces: src-tauri 27 | - name: Check clippy 28 | uses: actions-rs/clippy-check@v1 29 | with: 30 | token: ${{ secrets.GITHUB_TOKEN }} 31 | args: --manifest-path src-tauri/Cargo.toml --no-deps -- -D warnings 32 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | 8 | // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` 9 | // prevent vite from obscuring rust errors 10 | clearScreen: false, 11 | // tauri expects a fixed port, fail if that port is not available 12 | server: { 13 | port: 1420, 14 | strictPort: true, 15 | }, 16 | // to make use of `TAURI_DEBUG` and other env variables 17 | // https://tauri.studio/v1/api/config#buildconfig.beforedevcommand 18 | envPrefix: ["VITE_", "TAURI_"], 19 | build: { 20 | // Tauri supports es2021 21 | target: process.env.TAURI_PLATFORM == "windows" ? "chrome105" : "safari13", 22 | // don't minify for debug builds 23 | minify: !process.env.TAURI_DEBUG ? "esbuild" : false, 24 | // produce sourcemaps for debug builds 25 | sourcemap: !!process.env.TAURI_DEBUG, 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { listen } from "@tauri-apps/api/event"; 2 | import "maplibre-gl/dist/maplibre-gl.css"; 3 | import { useEffect, type ReactElement } from "react"; 4 | import { HashRouter, Route, Routes } from "react-router-dom"; 5 | import { toast, ToastContainer } from "react-toastify"; 6 | import "react-toastify/dist/ReactToastify.css"; 7 | 8 | import ConnectView from "./ConnectView"; 9 | import MainView from "./MainView"; 10 | 11 | export default function App(): ReactElement { 12 | useEffect(() => { 13 | const unlisten = listen("error", (event) => { 14 | toast.error(event.payload); 15 | }); 16 | 17 | return () => { 18 | unlisten 19 | .then((f) => { 20 | f(); 21 | }) 22 | .catch((error) => { 23 | console.log(error); 24 | }); 25 | }; 26 | }, []); 27 | 28 | return ( 29 | <> 30 | 31 | 32 | } /> 33 | } /> 34 | 35 | 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/data/terrain/marianaislands.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MarianaIslands", 3 | "center": [ 4 | 14.179280805461705, 5 | 145.2360627317238 6 | ], 7 | "airports": [ 8 | { 9 | "name": "Rota Intl", 10 | "position": [ 11 | 14.17437266612625, 12 | 145.24109239880622 13 | ] 14 | }, 15 | { 16 | "name": "Saipan Intl", 17 | "position": [ 18 | 15.118931054655585, 19 | 145.72912914447548 20 | ] 21 | }, 22 | { 23 | "name": "Tinian Intl", 24 | "position": [ 25 | 14.999193626218325, 26 | 145.6191816236229 27 | ] 28 | }, 29 | { 30 | "name": "Antonio B. Won Pat Intl", 31 | "position": [ 32 | 13.484779760120237, 33 | 144.79682382088177 34 | ] 35 | }, 36 | { 37 | "name": "Andersen AFB", 38 | "position": [ 39 | 13.581702856654982, 40 | 144.93105129798187 41 | ] 42 | } 43 | ], 44 | "projection": { 45 | "centralMeridian": 147, 46 | "falseEasting": 238417.99999989968, 47 | "falseNorthing": -1491840.000000048, 48 | "scaleFactor": 0.9996 49 | } 50 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Kangwook Lee (pbzweihander) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "peace-eye" 3 | version = "0.4.3" 4 | description = "Desktop GCI/AWACS simulator for Tacview and DCS World" 5 | authors = ["Kangwook Lee "] 6 | license = "MIT" 7 | repository = "https://github.com/pbzweihander/peace-eye" 8 | edition = "2021" 9 | rust-version = "1.57" 10 | 11 | [build-dependencies] 12 | tauri-build = { version = "1.2", features = [] } 13 | 14 | [dependencies] 15 | once_cell = "1.17.2" 16 | reqwest = "0.11.18" 17 | semver = "1.0.17" 18 | serde = { version = "1.0", features = ["derive"] } 19 | serde_json = "1.0" 20 | tacview-realtime-client = { git = "https://github.com/pbzweihander/tacview-realtime-client-rs.git", rev = "bea471ffbc9315f1965d500809ad004765017183" } 21 | tauri = { version = "1.2", features = ["api-all"] } 22 | time = { version = "0.3.17", features = ["parsing", "formatting", "serde"] } 23 | tokio = { version = "1.25.0", features = ["time", "sync", "parking_lot"] } 24 | tracing = "0.1.37" 25 | tracing-subscriber = { version = "0.3.16", features = ["fmt", "env-filter"] } 26 | 27 | [features] 28 | default = ["custom-protocol"] 29 | custom-protocol = ["tauri/custom-protocol"] 30 | -------------------------------------------------------------------------------- /src/BraaInfo.tsx: -------------------------------------------------------------------------------- 1 | import { type ReactElement } from "react"; 2 | import { Marker } from "react-map-gl"; 3 | 4 | import { type Terrain } from "./dcs/terrain"; 5 | import { getBearing, getCardinal, getRange } from "./util"; 6 | 7 | export interface BraaInfoProps { 8 | start: [number, number]; 9 | end: [number, number]; 10 | terrain: Terrain; 11 | geomagnetismModel: any; 12 | useMagneticHeading: boolean; 13 | } 14 | 15 | export default function BraaInfo(props: BraaInfoProps): ReactElement { 16 | const range = Math.round(getRange(props.start, props.end)); 17 | let bearing = getBearing(props.start, props.end, props.terrain); 18 | if (props.useMagneticHeading) { 19 | bearing = 20 | bearing - (props.geomagnetismModel.point(props.start).decl as number); 21 | } 22 | bearing = Math.round((bearing + 360) % 360); 23 | const cardinal = getCardinal(bearing); 24 | 25 | return ( 26 | 27 |
28 | {bearing.toString().padStart(3, "0")} 29 | {cardinal} / {range} 30 |
31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | release: 10 | permissions: 11 | contents: write 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | platform: [macos-latest, ubuntu-latest, windows-latest] 16 | runs-on: ${{ matrix.platform }} 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: actions/setup-node@v3 20 | with: 21 | node-version: 19 22 | - uses: actions-rs/toolchain@v1 23 | with: 24 | profile: minimal 25 | toolchain: stable 26 | - name: Install dependencies (ubuntu only) 27 | if: matrix.platform == 'ubuntu-latest' 28 | run: | 29 | sudo apt-get update 30 | sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf 31 | - name: Install frontend dependencies 32 | run: yarn 33 | - uses: tauri-apps/tauri-action@v0 34 | id: tauri-build 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | with: 38 | tagName: v__VERSION__ 39 | releaseName: peace-eye v__VERSION__ 40 | releaseDraft: true 41 | -------------------------------------------------------------------------------- /src/tacview/index.ts: -------------------------------------------------------------------------------- 1 | import { type Coords, type Tag } from "./record/objectProperty"; 2 | 3 | export interface TacviewObject { 4 | readonly estimatedSpeed: number; 5 | readonly estimatedAltitudeRate: number; 6 | 7 | readonly coords?: Coords; 8 | readonly name?: string; 9 | readonly type?: Tag[]; 10 | readonly callsign?: string; 11 | readonly pilot?: string; 12 | readonly squawk?: string; 13 | readonly group?: string; 14 | readonly coalition?: string; 15 | } 16 | 17 | export interface TacviewState { 18 | readonly header?: { 19 | readonly fileType: string; 20 | readonly fileVersion: string; 21 | }; 22 | readonly globalProperties: { 23 | readonly referenceTime?: string; 24 | readonly author?: string; 25 | readonly title?: string; 26 | readonly comments?: string; 27 | readonly referenceLongitude?: number; 28 | readonly referenceLatitude?: number; 29 | }; 30 | readonly objects: Record; 31 | readonly blueBullseye?: TacviewObject; 32 | readonly redBullseye?: TacviewObject; 33 | } 34 | 35 | export function newTacviewState(): TacviewState { 36 | return { 37 | header: undefined, 38 | globalProperties: {}, 39 | objects: {}, 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /src/dcs/terrain/index.ts: -------------------------------------------------------------------------------- 1 | import { getRange } from "../../util"; 2 | import { Caucasus } from "./caucasus"; 3 | import { MarianaIslands } from "./marianaislands"; 4 | import { Nevada } from "./nevada"; 5 | import { Normandy } from "./normandy"; 6 | import { PersianGulf } from "./persiangulf"; 7 | import { Syria } from "./syria"; 8 | import { TheChannel } from "./thechannel"; 9 | 10 | export interface Terrain { 11 | name: string; 12 | center: [number, number]; 13 | airports: Airport[]; 14 | projection: Projection; 15 | } 16 | 17 | export interface Airport { 18 | name: string; 19 | position: [number, number]; 20 | } 21 | 22 | export interface Projection { 23 | centralMeridian: number; 24 | falseEasting: number; 25 | falseNorthing: number; 26 | scaleFactor: number; 27 | } 28 | 29 | export const Terrains = [ 30 | Caucasus, 31 | Nevada, 32 | Normandy, 33 | PersianGulf, 34 | TheChannel, 35 | Syria, 36 | MarianaIslands, 37 | ]; 38 | 39 | export function getTerrainFromReferencePoint( 40 | refLat: number, 41 | refLng: number 42 | ): Terrain | undefined { 43 | for (const terrain of Terrains) { 44 | if (getRange([refLat, refLng], terrain.center) < 500.0) { 45 | return terrain; 46 | } 47 | } 48 | return undefined; 49 | } 50 | -------------------------------------------------------------------------------- /src/tacview/record/objectProperty.ts: -------------------------------------------------------------------------------- 1 | export interface Coords { 2 | readonly longitude?: number; 3 | readonly latitude?: number; 4 | readonly altitude?: number; 5 | readonly roll?: number; 6 | readonly pitch?: number; 7 | readonly yaw?: number; 8 | readonly u?: number; 9 | readonly v?: number; 10 | readonly heading?: number; 11 | } 12 | 13 | export type Tag = 14 | | "Air" 15 | | "Ground" 16 | | "Sea" 17 | | "Weapon" 18 | | "Sensor" 19 | | "Navaid" 20 | | "Misc" 21 | | "Static" 22 | | "Heavy" 23 | | "Medium" 24 | | "Light" 25 | | "Minor" 26 | | "FixedWing" 27 | | "Rotorcraft" 28 | | "Armor" 29 | | "AntiAircraft" 30 | | "Vehicle" 31 | | "Watercraft" 32 | | "Human" 33 | | "Biologic" 34 | | "Missile" 35 | | "Rocket" 36 | | "Bomb" 37 | | "Torpedo" 38 | | "Projectile" 39 | | "Beam" 40 | | "Decoy" 41 | | "Building" 42 | | "Bullseye" 43 | | "Waypoint" 44 | | "Tank" 45 | | "Warship" 46 | | "AircraftCarrier" 47 | | "Submarine" 48 | | "Infantry" 49 | | "Parachutist" 50 | | "Shell" 51 | | "Bullet" 52 | | "Grenade" 53 | | "Flare" 54 | | "Chaff" 55 | | "SmokeGrenade" 56 | | "Aerodrome" 57 | | "Container" 58 | | "Shrapnel" 59 | | "Explosion" 60 | | { other: string }; 61 | 62 | export function tagToString(tag: Tag): string { 63 | if (typeof tag === "string") { 64 | return tag; 65 | } else { 66 | return tag.other; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/AirportMarker.tsx: -------------------------------------------------------------------------------- 1 | import { type ReactElement } from "react"; 2 | import { Marker } from "react-map-gl"; 3 | 4 | import Symbol from "./Symbol"; 5 | import { type Airport } from "./dcs/terrain"; 6 | import { sidcToSymbol } from "./entity"; 7 | 8 | export interface AirportMarkerProps { 9 | airport: Airport; 10 | selected: boolean; 11 | onClick: () => void; 12 | } 13 | 14 | export default function AirportMarker(props: AirportMarkerProps): ReactElement { 15 | const { airport, selected, onClick } = props; 16 | 17 | const symbol = sidcToSymbol("10012000001213010000"); 18 | const symbolElement = ; 19 | 20 | return ( 21 | <> 22 | { 27 | onClick(); 28 | }} 29 | > 30 | {selected ? ( 31 |
32 | {symbolElement} 33 |
34 | ) : ( 35 | symbolElement 36 | )} 37 |
38 | { 43 | onClick(); 44 | }} 45 | > 46 |
{airport.name}
47 |
48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | platform: [macos-latest, ubuntu-latest, windows-latest] 15 | runs-on: ${{ matrix.platform }} 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: actions/setup-node@v3 19 | with: 20 | node-version: 19 21 | - uses: actions-rs/toolchain@v1 22 | with: 23 | profile: minimal 24 | toolchain: stable 25 | - name: Install dependencies (ubuntu only) 26 | if: matrix.platform == 'ubuntu-latest' 27 | run: | 28 | sudo apt-get update 29 | sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf 30 | - uses: actions/cache@v3 31 | with: 32 | path: node_modules 33 | key: node-modules-${{ runner.os }}-${{ hashFiles('yarn.lock') }} 34 | restore-keys: | 35 | node-modules-${{ runner.os }}- 36 | - uses: Swatinem/rust-cache@v2 37 | with: 38 | workspaces: src-tauri 39 | - name: Install frontend dependencies 40 | run: yarn 41 | - uses: tauri-apps/tauri-action@v0 42 | id: tauri-build 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | - uses: actions/upload-artifact@v3 46 | with: 47 | name: ${{ matrix.platform }} 48 | path: "${{ join(fromJSON(steps.tauri-build.outputs.artifactPaths), '\n') }}" 49 | if-no-files-found: error 50 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "beforeDevCommand": "yarn dev", 4 | "beforeBuildCommand": "yarn build", 5 | "devPath": "http://localhost:1420", 6 | "distDir": "../dist" 7 | }, 8 | "package": { 9 | "productName": "peace-eye", 10 | "version": "0.4.3" 11 | }, 12 | "tauri": { 13 | "allowlist": { 14 | "all": true 15 | }, 16 | "bundle": { 17 | "active": true, 18 | "category": "DeveloperTool", 19 | "copyright": "MIT", 20 | "deb": { 21 | "depends": [] 22 | }, 23 | "externalBin": [], 24 | "icon": [ 25 | "icons/32x32.png", 26 | "icons/128x128.png", 27 | "icons/128x128@2x.png", 28 | "icons/icon.icns", 29 | "icons/icon.ico" 30 | ], 31 | "identifier": "dev.pbzweihander.peace-eye", 32 | "longDescription": "Desktop GCI/AWACS simulator for Tacview and DCS World", 33 | "macOS": { 34 | "entitlements": null, 35 | "exceptionDomain": "", 36 | "frameworks": [], 37 | "providerShortName": null, 38 | "signingIdentity": null 39 | }, 40 | "resources": [], 41 | "shortDescription": "Desktop GCI/AWACS simulator for Tacview and DCS World", 42 | "targets": "all", 43 | "windows": { 44 | "certificateThumbprint": null, 45 | "digestAlgorithm": "sha256", 46 | "timestampUrl": "" 47 | } 48 | }, 49 | "security": { 50 | "csp": null 51 | }, 52 | "updater": { 53 | "active": false 54 | }, 55 | "windows": [ 56 | { 57 | "fullscreen": false, 58 | "height": 600, 59 | "resizable": true, 60 | "title": "peace-eye", 61 | "width": 800 62 | } 63 | ] 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "peace-eye", 3 | "private": true, 4 | "version": "0.4.3", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview", 10 | "tauri": "tauri", 11 | "prettier": "prettier --write --config prettier.config.cjs index.html './src/**/*.{js,jsx,ts,tsx,css}'", 12 | "prettier:check": "prettier --check --config prettier.config.cjs index.html './src/**/*.{js,jsx,ts,tsx,css}'", 13 | "lint": "eslint './src/**/*.{js,jsx,ts,tsx}'", 14 | "lint:fix": "eslint --fix './src/**/*.{js,jsx,ts,tsx}'" 15 | }, 16 | "dependencies": { 17 | "@tauri-apps/api": "^1.2.0", 18 | "@turf/circle": "^6.5.0", 19 | "classnames": "^2.3.2", 20 | "daisyui": "^3.0.3", 21 | "geojson": "^0.5.0", 22 | "geomagnetism": "^0.1.1", 23 | "mapbox-gl": "npm:empty-npm-package@1.0.0", 24 | "maplibre-gl": "^2.4.0", 25 | "mgrs": "^1.0.1-alpha.0", 26 | "milsymbol": "^2.0.0", 27 | "proj4": "^2.9.0", 28 | "react": "^18.2.0", 29 | "react-dom": "^18.2.0", 30 | "react-map-gl": "^7.0.21", 31 | "react-router-dom": "^6.8.0", 32 | "react-toastify": "^9.1.1", 33 | "spinners-react": "^1.0.7" 34 | }, 35 | "devDependencies": { 36 | "@tauri-apps/cli": "^1.2.2", 37 | "@trivago/prettier-plugin-sort-imports": "^4.0.0", 38 | "@types/node": "^18.7.10", 39 | "@types/proj4": "^2.5.2", 40 | "@types/react": "^18.0.15", 41 | "@types/react-dom": "^18.0.6", 42 | "@typescript-eslint/eslint-plugin": "^5.0.0", 43 | "@vitejs/plugin-react": "^3.0.0", 44 | "autoprefixer": "^10.4.13", 45 | "eslint": "^8.0.1", 46 | "eslint-config-prettier": "^8.6.0", 47 | "eslint-config-standard-with-typescript": "^33.0.0", 48 | "eslint-plugin-import": "^2.25.2", 49 | "eslint-plugin-n": "^15.0.0", 50 | "eslint-plugin-promise": "^6.0.0", 51 | "eslint-plugin-react": "^7.32.2", 52 | "eslint-plugin-react-hooks": "^4.6.0", 53 | "postcss": "^8.4.21", 54 | "prettier": "^2.8.3", 55 | "prettier-plugin-tailwindcss": "^0.2.2", 56 | "tailwindcss": "^3.2.4", 57 | "typescript": "*", 58 | "vite": "^4.0.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/data/terrain/thechannel.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "TheChannel", 3 | "center": [ 4 | 50.87512670211899, 5 | 1.587533067925541 6 | ], 7 | "airports": [ 8 | { 9 | "name": "Abbeville Drucat", 10 | "position": [ 11 | 50.14345084634777, 12 | 1.8319462661301362 13 | ] 14 | }, 15 | { 16 | "name": "Merville Calonne", 17 | "position": [ 18 | 50.619514048756535, 19 | 2.6381313603043783 20 | ] 21 | }, 22 | { 23 | "name": "Saint Omer Longuenesse", 24 | "position": [ 25 | 50.7287000838069, 26 | 2.2319331860318994 27 | ] 28 | }, 29 | { 30 | "name": "Dunkirk Mardyck", 31 | "position": [ 32 | 51.029623601861395, 33 | 2.252448845869261 34 | ] 35 | }, 36 | { 37 | "name": "Manston", 38 | "position": [ 39 | 51.34197963294575, 40 | 1.3461414037815638 41 | ] 42 | }, 43 | { 44 | "name": "Hawkinge", 45 | "position": [ 46 | 51.11190610800883, 47 | 1.160257019488335 48 | ] 49 | }, 50 | { 51 | "name": "Lympne", 52 | "position": [ 53 | 51.08066493270035, 54 | 1.0170453533131574 55 | ] 56 | }, 57 | { 58 | "name": "Detling", 59 | "position": [ 60 | 51.30504215948128, 61 | 0.599862622261866 62 | ] 63 | }, 64 | { 65 | "name": "Eastchurch", 66 | "position": [ 67 | 51.39013699729312, 68 | 0.8469003809524815 69 | ] 70 | }, 71 | { 72 | "name": "High Halden", 73 | "position": [ 74 | 51.1216409899785, 75 | 0.6937473331821893 76 | ] 77 | }, 78 | { 79 | "name": "Headcorn", 80 | "position": [ 81 | 51.18261207888572, 82 | 0.6894982481054283 83 | ] 84 | }, 85 | { 86 | "name": "Biggin Hill", 87 | "position": [ 88 | 51.32671673533415, 89 | 0.03110275876742108 90 | ] 91 | } 92 | ], 93 | "projection": { 94 | "centralMeridian": 3, 95 | "falseEasting": 99376.00000000288, 96 | "falseNorthing": -5636889.00000001, 97 | "scaleFactor": 0.9996 98 | } 99 | } -------------------------------------------------------------------------------- /tools/terrain-data-generator/terrain_data_generator/__init__.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import typing 3 | import json 4 | 5 | from dcs.terrain import ( 6 | Caucasus, 7 | Nevada, 8 | Normandy, 9 | PersianGulf, 10 | TheChannel, 11 | Syria, 12 | MarianaIslands, 13 | ) 14 | 15 | terrains = [ 16 | Caucasus(), 17 | Nevada(), 18 | Normandy(), 19 | PersianGulf(), 20 | TheChannel(), 21 | Syria(), 22 | MarianaIslands(), 23 | ] 24 | 25 | 26 | @dataclasses.dataclass(frozen=True) 27 | class ExportProjection: 28 | centralMeridian: int 29 | falseEasting: float 30 | falseNorthing: float 31 | scaleFactor: float 32 | 33 | 34 | @dataclasses.dataclass(frozen=True) 35 | class ExportAirport: 36 | name: str 37 | position: typing.Tuple[float, float] 38 | 39 | 40 | @dataclasses.dataclass(frozen=True) 41 | class ExportTerrain: 42 | name: str 43 | center: typing.Tuple[float, float] 44 | airports: typing.List[ExportAirport] 45 | projection: ExportProjection 46 | 47 | 48 | def main(): 49 | for terrain in terrains: 50 | projection = terrain.projection_parameters 51 | export_projection = ExportProjection( 52 | centralMeridian=projection.central_meridian, 53 | falseEasting=projection.false_easting, 54 | falseNorthing=projection.false_northing, 55 | scaleFactor=projection.scale_factor, 56 | ) 57 | export_airports: typing.List[ExportAirport] = [] 58 | for airport in terrain.airport_list(): 59 | pos = airport.position.latlng() 60 | export_airports.append( 61 | ExportAirport(name=airport.name, position=(pos.lat, pos.lng)) 62 | ) 63 | pos = terrain.map_view_default.position.latlng() 64 | export_terrain = ExportTerrain( 65 | name=terrain.name, 66 | center=(pos.lat, pos.lng), 67 | airports=export_airports, 68 | projection=export_projection, 69 | ) 70 | 71 | with open(f"../../src/data/terrain/{terrain.name.lower()}.json", "w") as file: 72 | file.write(json.dumps(dataclasses.asdict(export_terrain), indent=2)) 73 | 74 | 75 | if __name__ == "__main__": 76 | main() 77 | -------------------------------------------------------------------------------- /src/CursorInfo.tsx: -------------------------------------------------------------------------------- 1 | import * as mgrs from "mgrs"; 2 | import { type ReactElement } from "react"; 3 | 4 | import { type Terrain } from "./dcs/terrain"; 5 | import { 6 | formatDDM, 7 | formatDMS, 8 | getBearing, 9 | getCardinal, 10 | getRange, 11 | } from "./util"; 12 | 13 | export interface CursorInfoProps { 14 | cursorCoords: [number, number]; 15 | bullseyeCoords: [number, number] | undefined; 16 | terrain: Terrain; 17 | geomagnetismModel: any; 18 | useMagneticHeading: boolean; 19 | showCursorCoords: boolean; 20 | } 21 | 22 | export default function CursorInfo(props: CursorInfoProps): ReactElement { 23 | let bullseyeInfo = ""; 24 | if (props.bullseyeCoords !== undefined) { 25 | let bullseyeBearing = getBearing( 26 | props.bullseyeCoords, 27 | props.cursorCoords, 28 | props.terrain 29 | ); 30 | if (props.useMagneticHeading) { 31 | bullseyeBearing = 32 | bullseyeBearing - 33 | (props.geomagnetismModel.point(props.bullseyeCoords).decl as number); 34 | } 35 | bullseyeBearing = Math.round((bullseyeBearing + 360) % 360); 36 | const bullseyeRange = Math.round( 37 | getRange(props.bullseyeCoords, props.cursorCoords) 38 | ); 39 | bullseyeInfo = `${bullseyeBearing.toString().padStart(3, "0")}${getCardinal( 40 | bullseyeBearing 41 | )} / ${bullseyeRange}`; 42 | } 43 | 44 | if (props.showCursorCoords) { 45 | return ( 46 |
47 |
48 | DMS 49 | {formatDMS(props.cursorCoords)} 50 |
51 |
52 | DDM 53 | {formatDDM(props.cursorCoords)} 54 |
55 |
56 | MGRS 57 | 58 | {mgrs.forward([props.cursorCoords[1], props.cursorCoords[0]])} 59 | 60 |
61 | {bullseyeInfo != null && ( 62 |
63 | Bullseye 64 | {bullseyeInfo} 65 |
66 | )} 67 |
68 | ); 69 | } else { 70 | return ( 71 |
72 | {bullseyeInfo} 73 |
74 | ); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # peace-eye 2 | 3 | ![screenshot](./screenshot.png) 4 | 5 | `peace-eye` is desktop GCI/AWACS simulation for Tacview and DCS World. 6 | Inspired by [b1naryth1ef/sneaker](https://github.com/b1naryth1ef/sneaker). 7 | The name `peace-eye` is derived from [Korean version of Boeing 737 AEW&C "Peace Eye"](https://en.wikipedia.org/wiki/Boeing_737_AEW%26C). 8 | Powered by [tauri](https://tauri.app/). 9 | 10 | ## Usage 11 | 12 | > Server should support Tacview realtime telemetry! 13 | 14 | Download the latest release in [Release page](https://github.com/pbzweihander/peace-eye/releases/latest) and install it. 15 | 16 | ![guide screenshot 1](./guide-screenshot1.png) 17 | 18 | Enter the server's domain or IP in `Host` input. 19 | For example, `dcs.hoggitworld.com` for hoggit GAW. 20 | 21 | Enter the Tacview realtime telemetry server's port number in `Port` input. 22 | If you are not sure, just don't touch it. 23 | 24 | You can enter anything you like in `Username` input. 25 | It is not relevant. 26 | 27 | If your server has password for Tacview realtime telemetry server (not DCS server!), enter it in `Password` input. 28 | For example, hoggit GAW has same DCS server password for Tacview realtime telemetry server. (Which I'm not gonna write it here.) 29 | 30 | And press `CONNECT` button. 31 | 32 | ![screenshot](./screenshot.png) 33 | 34 | After waiting some seconds, you can see above screen. 35 | If you are not, check `Host`, `Port`, and `Password` was correct. 36 | 37 | This is your main screen. 38 | Various options can be set or unset in settings menu, located on top right cogwheel button. 39 | 40 | ### Features 41 | 42 | - Drag with left click to pan the view. 43 | - Drag with right click to measure bearing and range. 44 | - On the right bottom screen, you can see the bullseye of the cursor location. 45 | - In settings menu, you can choose to see coordinates of the cursor location. 46 | - In bottom right of each object, you can see altitude (in thousands of feets), estimated ground speed (in knots), estimated altitude rate (in thousands of feets per minutes). 47 | - Click the object to see various information of the object. 48 | - In object information, you can press `CENTER` button to center the view to the object. 49 | - You can set warning radius, and thread radius, which represented in yellow and red circle. 50 | - You can set the object to `Watch` state. Watched objects can be quick-accessed in the top right `WATCHES` button on screen. 51 | - Search the object with name via `SEARCH` button on the top right button on screen. 52 | 53 | ## Development 54 | 55 | ### Prerequisites 56 | 57 | - [Tauri Prerequisites](https://tauri.app/v1/guides/getting-started/prerequisites) 58 | - [Rust](https://www.rust-lang.org/) 59 | - [Node](https://nodejs.org/en/) 60 | 61 | ### Running in development mode 62 | 63 | ``` 64 | yarn tauri dev 65 | ``` 66 | 67 | ## License 68 | 69 | [MIT license](./LICENSE) 70 | -------------------------------------------------------------------------------- /src/data/terrain/nevada.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Nevada", 3 | "center": [ 4 | 36.75761212558849, 5 | -115.45320419673193 6 | ], 7 | "airports": [ 8 | { 9 | "name": "Creech", 10 | "position": [ 11 | 36.583818971692146, 12 | -115.6764424858488 13 | ] 14 | }, 15 | { 16 | "name": "Groom Lake", 17 | "position": [ 18 | 37.23317938433876, 19 | -115.79233552379732 20 | ] 21 | }, 22 | { 23 | "name": "McCarran International", 24 | "position": [ 25 | 36.076410032889456, 26 | -115.14463202319168 27 | ] 28 | }, 29 | { 30 | "name": "Nellis", 31 | "position": [ 32 | 36.23522411088335, 33 | -115.03300055101853 34 | ] 35 | }, 36 | { 37 | "name": "Beatty", 38 | "position": [ 39 | 36.86100291276837, 40 | -116.78641750314196 41 | ] 42 | }, 43 | { 44 | "name": "Boulder City", 45 | "position": [ 46 | 35.948667303860894, 47 | -114.86181818697374 48 | ] 49 | }, 50 | { 51 | "name": "Echo Bay", 52 | "position": [ 53 | 36.31106048427465, 54 | -114.46389937947889 55 | ] 56 | }, 57 | { 58 | "name": "Henderson Executive", 59 | "position": [ 60 | 35.973994398000464, 61 | -115.13305233940493 62 | ] 63 | }, 64 | { 65 | "name": "Jean", 66 | "position": [ 67 | 35.76918684110657, 68 | -115.3295683377749 69 | ] 70 | }, 71 | { 72 | "name": "Laughlin", 73 | "position": [ 74 | 35.156102639982656, 75 | -114.55945187290305 76 | ] 77 | }, 78 | { 79 | "name": "Lincoln County", 80 | "position": [ 81 | 37.787355566372085, 82 | -114.41991121201858 83 | ] 84 | }, 85 | { 86 | "name": "Mesquite", 87 | "position": [ 88 | 36.833119135858084, 89 | -114.05590700996973 90 | ] 91 | }, 92 | { 93 | "name": "Mina", 94 | "position": [ 95 | 38.37978563339467, 96 | -118.0966766611066 97 | ] 98 | }, 99 | { 100 | "name": "North Las Vegas", 101 | "position": [ 102 | 36.2134717794065, 103 | -115.19476766608699 104 | ] 105 | }, 106 | { 107 | "name": "Pahute Mesa", 108 | "position": [ 109 | 37.10199574631143, 110 | -116.31284622428223 111 | ] 112 | }, 113 | { 114 | "name": "Tonopah", 115 | "position": [ 116 | 38.06243087182406, 117 | -117.08328585574216 118 | ] 119 | }, 120 | { 121 | "name": "Tonopah Test Range", 122 | "position": [ 123 | 37.7988749905105, 124 | -116.7807920733868 125 | ] 126 | } 127 | ], 128 | "projection": { 129 | "centralMeridian": -117, 130 | "falseEasting": -193996.80999964548, 131 | "falseNorthing": -4410028.063999966, 132 | "scaleFactor": 0.9996 133 | } 134 | } -------------------------------------------------------------------------------- /src/ObjectMarker.tsx: -------------------------------------------------------------------------------- 1 | import { type ReactElement } from "react"; 2 | import { type MapboxEvent, Marker } from "react-map-gl"; 3 | 4 | import Symbol from "./Symbol"; 5 | import { makeObjectName, objectColor, objectToSymbol } from "./entity"; 6 | import { type TacviewObject } from "./tacview"; 7 | 8 | export interface ObjectMarkerProps { 9 | object: TacviewObject; 10 | referenceLatitude: number; 11 | referenceLongitude: number; 12 | selected: boolean; 13 | onClick?: (evt: MapboxEvent) => void; 14 | } 15 | 16 | export default function ObjectMarker(props: ObjectMarkerProps): ReactElement { 17 | const { object, referenceLatitude, referenceLongitude, selected, onClick } = 18 | props; 19 | if ( 20 | object.coords?.latitude === undefined || 21 | object.coords?.longitude === undefined 22 | ) { 23 | return <>; 24 | } 25 | 26 | const symbol = objectToSymbol(object); 27 | 28 | const symbolElement = ; 29 | 30 | const isAir = object.type?.includes("Air") ?? false; 31 | const isMissile = object.type?.includes("Missile") ?? false; 32 | const isFarp = object.name === "FARP"; 33 | 34 | const altitude = ((object.coords?.altitude ?? 0) * 3.28084) / 1000; 35 | 36 | const color = objectColor(object); 37 | 38 | return ( 39 | <> 40 | 46 | {selected ? ( 47 |
48 | {symbolElement} 49 |
50 | ) : ( 51 | symbolElement 52 | )} 53 |
54 | {isFarp && ( 55 | 61 |
FARP
62 |
63 | )} 64 | {isAir && ( 65 | 71 |
75 | {makeObjectName(object)} 76 |
77 |
78 | {altitude.toFixed(1)} 79 | 80 | {Math.round(object.estimatedSpeed)} 81 | 82 | 83 | {object.estimatedAltitudeRate.toFixed(1)} 84 | 85 |
86 |
87 | )} 88 | {isMissile && ( 89 | 95 |
96 | {makeObjectName(object)} 97 |
98 |
99 | )} 100 | 101 | ); 102 | } 103 | -------------------------------------------------------------------------------- /src/data/terrain/caucasus.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Caucasus", 3 | "center": [ 4 | 42.4400404656358, 5 | 42.48120147530791 6 | ], 7 | "airports": [ 8 | { 9 | "name": "Anapa-Vityazevo", 10 | "position": [ 11 | 45.004948084625696, 12 | 37.34782488305727 13 | ] 14 | }, 15 | { 16 | "name": "Krasnodar-Center", 17 | "position": [ 18 | 45.086949933238785, 19 | 38.93998462664841 20 | ] 21 | }, 22 | { 23 | "name": "Novorossiysk", 24 | "position": [ 25 | 44.668096137080184, 26 | 37.77825782083347 27 | ] 28 | }, 29 | { 30 | "name": "Krymsk", 31 | "position": [ 32 | 44.96793654712032, 33 | 37.995030274403014 34 | ] 35 | }, 36 | { 37 | "name": "Maykop-Khanskaya", 38 | "position": [ 39 | 44.68128522066237, 40 | 40.03525433454862 41 | ] 42 | }, 43 | { 44 | "name": "Gelendzhik", 45 | "position": [ 46 | 44.572900784128336, 47 | 38.01161441703992 48 | ] 49 | }, 50 | { 51 | "name": "Sochi-Adler", 52 | "position": [ 53 | 43.44435414193645, 54 | 39.94107634483106 55 | ] 56 | }, 57 | { 58 | "name": "Krasnodar-Pashkovsky", 59 | "position": [ 60 | 45.03800823169005, 61 | 39.1881352399431 62 | ] 63 | }, 64 | { 65 | "name": "Sukhumi-Babushara", 66 | "position": [ 67 | 42.86109234026534, 68 | 41.12499865194555 69 | ] 70 | }, 71 | { 72 | "name": "Gudauta", 73 | "position": [ 74 | 43.114327084603914, 75 | 40.569741075949 76 | ] 77 | }, 78 | { 79 | "name": "Batumi", 80 | "position": [ 81 | 41.609596985373095, 82 | 41.60023693581312 83 | ] 84 | }, 85 | { 86 | "name": "Senaki-Kolkhi", 87 | "position": [ 88 | 42.24084895478284, 89 | 42.048014241545644 90 | ] 91 | }, 92 | { 93 | "name": "Kobuleti", 94 | "position": [ 95 | 41.92991907051348, 96 | 41.86327450631784 97 | ] 98 | }, 99 | { 100 | "name": "Kutaisi", 101 | "position": [ 102 | 42.1776158278575, 103 | 42.48129219988309 104 | ] 105 | }, 106 | { 107 | "name": "Mineralnye Vody", 108 | "position": [ 109 | 44.2278526037317, 110 | 43.081191738056 111 | ] 112 | }, 113 | { 114 | "name": "Nalchik", 115 | "position": [ 116 | 43.51400171101549, 117 | 43.636451295437 118 | ] 119 | }, 120 | { 121 | "name": "Mozdok", 122 | "position": [ 123 | 43.791710641829006, 124 | 44.60583882218554 125 | ] 126 | }, 127 | { 128 | "name": "Tbilisi-Lochini", 129 | "position": [ 130 | 41.66703366386257, 131 | 44.956288060211804 132 | ] 133 | }, 134 | { 135 | "name": "Soganlug", 136 | "position": [ 137 | 41.649542451455915, 138 | 44.93835774294116 139 | ] 140 | }, 141 | { 142 | "name": "Vaziani", 143 | "position": [ 144 | 41.629025903682106, 145 | 45.02723084928328 146 | ] 147 | }, 148 | { 149 | "name": "Beslan", 150 | "position": [ 151 | 43.20570612823567, 152 | 44.60580814258724 153 | ] 154 | } 155 | ], 156 | "projection": { 157 | "centralMeridian": 33, 158 | "falseEasting": -99516.9999999732, 159 | "falseNorthing": -4998114.999999984, 160 | "scaleFactor": 0.9996 161 | } 162 | } -------------------------------------------------------------------------------- /tools/terrain-data-generator/.gitignore: -------------------------------------------------------------------------------- 1 | ### Python 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # poetry 99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 103 | #poetry.lock 104 | 105 | # pdm 106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 107 | #pdm.lock 108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 109 | # in version control. 110 | # https://pdm.fming.dev/#use-with-ide 111 | .pdm.toml 112 | 113 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 114 | __pypackages__/ 115 | 116 | # Celery stuff 117 | celerybeat-schedule 118 | celerybeat.pid 119 | 120 | # SageMath parsed files 121 | *.sage.py 122 | 123 | # Environments 124 | .env 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | #.idea/ 162 | -------------------------------------------------------------------------------- /src/dcs/aircraft.ts: -------------------------------------------------------------------------------- 1 | export const AircraftToSidcIcon: Record = { 2 | "Tornado GR4": "1101040000", 3 | "Tornado IDS": "1101040000", 4 | "F/A-18A": "1101040000", 5 | "F/A-18C": "1101040000", 6 | "F-14A": "1101040000", 7 | "Tu-22M3": "1101030000", 8 | "F-4E": "1101040000", 9 | "F-4E-45MC": "1101040000", 10 | "B-52H": "1101030000", 11 | "MiG-27K": "1101040000", 12 | "Su-27": "1101040000", 13 | "MiG-23MLD": "1101040000", 14 | "Su-25": "1101020000", 15 | "Su-25TM": "1101020000", 16 | "Su-25T": "1101020000", 17 | "Su-33": "1101040000", 18 | "MiG-25PD": "1101040000", 19 | "MiG-25RBT": "1101040000", 20 | "Su-30": "1101040000", 21 | "Su-17M4": "1101040000", 22 | "MiG-31": "1101040000", 23 | "Tu-95MS": "1101030000", 24 | "Su-24M": "1101040000", 25 | "Su-24MR": "1101040000", 26 | "Tu-160": "1101030000", 27 | "F-117A": "1101020000", 28 | "B-1B": "1101030000", 29 | "S-3B": "1101170000", 30 | "S-3B Tanker": "1101090005", 31 | "Mirage 2000-5": "1101040000", 32 | "F-15C": "1101040000", 33 | "F-15E": "1101040000", 34 | "F-15ESE": "1101040000", 35 | "MiG-29A": "1101040000", 36 | "MiG-29G": "1101040000", 37 | "MiG-29S": "1101040000", 38 | "Tu-142": "1101110000", 39 | "C-130": "1101070000", 40 | "An-26B": "1101070000", 41 | "An-30M": "1101130000", 42 | "C-17A": "1101070000", 43 | "A-50": "1101160000", 44 | "E-3A": "1101160000", 45 | "IL-78M": "1101090005", 46 | "E-2C": "1101160000", 47 | "IL-76MD": "1101090005", 48 | "F-16C bl.50": "1101040000", 49 | "F-16C bl.52d": "1101040000", 50 | "F-16A": "1101040000", 51 | "F-16A MLU": "1101040000", 52 | "RQ-1A Predator": "1103000100", 53 | "Yak-40": "1101040000", 54 | "KC-135": "1101070000", 55 | "FW-190D9": "1101040000", 56 | "FW-190A8": "1101040000", 57 | "Bf-109K-4": "1101040000", 58 | SpitfireLFMkIX: "1101040000", 59 | SpitfireLFMkIXCW: "1101040000", 60 | "P-51D": "1101040000", 61 | "P-51D-30-NA": "1101040000", 62 | "P-47D-30": "1101040000", 63 | "P-47D-30bl1": "1101040000", 64 | "P-47D-40": "1101040000", 65 | "A-20G": "1101030000", 66 | "A-10A": "1101020000", 67 | "A-10C": "1101020000", 68 | "A-10C_2": "1101020000", 69 | AJS37: "1101040000", 70 | AV8BNA: "1101140000", 71 | KC130: "1101090005", 72 | KC135MPRS: "1101090005", 73 | "C-101EB": "1101120000", 74 | "C-101CC": "1101040000", 75 | "J-11A": "1101040000", 76 | "JF-17": "1101040000", 77 | "KJ-2000": "1101160000", 78 | "WingLoong-I": "1101040000", 79 | "Christen Eagle II": "1101040000", 80 | "F-16C_50": "1101040000", 81 | "F-5E": "1101040000", 82 | "F-5E-3": "1101040000", 83 | "F-86F Sabre": "1101040000", 84 | "F-14B": "1101040000", 85 | "F-14A-135-GR": "1101040000", 86 | "FA-18C_hornet": "1101040000", 87 | Hawk: "1101040000", 88 | "I-16": "1101040000", 89 | "L-39C": "1101040000", 90 | "L-39ZA": "1101040000", 91 | "M-2000C": "1101040000", 92 | "MQ-9 Reaper": "1103001100", 93 | "MiG-15bis": "1101040000", 94 | "MiG-19P": "1101040000", 95 | "MiG-21Bis": "1101040000", 96 | "Su-34": "1101040000", 97 | "Yak-52": "1101040000", 98 | "B-17G": "1101030000", 99 | "Ju-88A4": "1101040000", 100 | "TF-51D": "1101040000", 101 | "Ka-50": "1102000100", 102 | "Ka-50_3": "1102000100", 103 | "Mi-24V": "1102000100", 104 | "Mi-8MT": "1102000100", 105 | "Mi-26": "1102000100", 106 | "Ka-27": "1102000100", 107 | "UH-60A": "1102000700", 108 | "CH-53E": "1102000300", 109 | "CH-47D": "1102000300", 110 | "SH-3W": "1102000100", 111 | "AH-64A": "1102000100", 112 | "AH-64D": "1102000100", 113 | "AH-1W": "1102000100", 114 | "SH-60B": "1102000100", 115 | "UH-1H": "1102000100", 116 | "Mi-28N": "1102000100", 117 | "OH-58D": "1102000100", 118 | "Mi-24P": "1102000100", 119 | SA342M: "1102000100", 120 | SA342L: "1102000100", 121 | SA342Mistral: "1102000100", 122 | SA342Minigun: "1102000100", 123 | "AH-64D_BLK_II": "1102000100", 124 | }; 125 | -------------------------------------------------------------------------------- /src/entity.ts: -------------------------------------------------------------------------------- 1 | import * as ms from "milsymbol"; 2 | 3 | import { AircraftToSidcIcon } from "./dcs/aircraft"; 4 | import { type Settings } from "./settings"; 5 | import { type TacviewObject } from "./tacview"; 6 | 7 | export const colorMode: ms.ColorMode = ms.ColorMode( 8 | "#ffffff", 9 | "#17c2f6", 10 | "#ff8080", 11 | "#FDE68A", 12 | "#ffffff" 13 | ); 14 | const symbolCache: Record = {}; 15 | 16 | export function filterObject( 17 | object: TacviewObject, 18 | settings: Settings 19 | ): boolean { 20 | const types = object.type ?? []; 21 | if (types.length === 0) { 22 | return false; 23 | } 24 | if ( 25 | !settings.view.showGround && 26 | types.includes("Ground") && 27 | object.name !== "FARP" 28 | ) { 29 | return false; 30 | } 31 | if (types.includes("Parachutist")) { 32 | return false; 33 | } 34 | if (types.includes("Misc")) { 35 | return false; 36 | } 37 | if (types.includes("Projectile")) { 38 | return false; 39 | } 40 | if (!settings.view.showWeapon && types.includes("Weapon")) { 41 | return false; 42 | } 43 | if ( 44 | types.includes("Air") && 45 | !settings.view.showSlowAir && 46 | object.estimatedSpeed < 25 47 | ) { 48 | return false; 49 | } 50 | return true; 51 | } 52 | 53 | export function objectColor(object: TacviewObject): string { 54 | if (object.coalition === undefined) { 55 | return colorMode.Neutral; 56 | } else if (object.coalition === "Enemies") { 57 | return colorMode.Friend; 58 | } else if (object.coalition === "Allies") { 59 | return colorMode.Hostile; 60 | } else { 61 | return colorMode.Unknown; 62 | } 63 | } 64 | 65 | export function sidcToSymbol(sidc: string): ms.Symbol { 66 | if (symbolCache[sidc] != null) { 67 | return symbolCache[sidc]; 68 | } else { 69 | const symbol = new ms.Symbol(sidc, { 70 | size: 16, 71 | frame: true, 72 | fill: false, 73 | colorMode, 74 | strokeWidth: 8, 75 | infoSize: 100, 76 | }); 77 | symbolCache[sidc] = symbol; 78 | 79 | return symbol; 80 | } 81 | } 82 | 83 | export function objectToSymbol(object: TacviewObject): ms.Symbol { 84 | const sidc = getSidc(object); 85 | return sidcToSymbol(sidc); 86 | } 87 | 88 | // Reference: https://sidc.milsymb.net/ 89 | function getSidc(object: TacviewObject): string { 90 | let ident = "4"; 91 | if (object.coalition === "Allies") { 92 | ident = "6"; 93 | } else if (object.coalition === "Enemies") { 94 | ident = "3"; 95 | } 96 | 97 | const types = object.type ?? []; 98 | 99 | if (types.includes("Bullseye")) { 100 | return `100${ident}2500002102000000`; 101 | } 102 | 103 | let set = "25"; 104 | if (types.includes("Air")) { 105 | set = "01"; 106 | } else if (types.includes("Ground")) { 107 | if (types.includes("Static")) { 108 | set = "20"; 109 | } else { 110 | set = "10"; 111 | } 112 | } else if (types.includes("Sea")) { 113 | set = "30"; 114 | } else if (types.includes("Missile")) { 115 | set = "02"; 116 | } 117 | 118 | let icon = "0000000000"; 119 | if (object.name !== undefined) { 120 | if (AircraftToSidcIcon[object.name] !== undefined) { 121 | icon = AircraftToSidcIcon[object.name]; 122 | } else if (object.name === "FARP") { 123 | icon = "1120000000"; 124 | } 125 | } 126 | if (icon === "0000000000") { 127 | let mainIcon = "000000"; 128 | let modifier = "0000"; 129 | if (types.includes("AntiAircraft")) { 130 | mainIcon = "1301000000"; 131 | } 132 | if (mainIcon === "000000" && types.includes("Infantry")) { 133 | mainIcon = "121100"; 134 | } 135 | if (types.includes("Tank")) { 136 | mainIcon = "120500"; 137 | } else if (types.includes("Vehicle")) { 138 | modifier = "0051"; 139 | } 140 | if (types.includes("Warship")) { 141 | mainIcon = "120000"; 142 | } 143 | if (types.includes("AircraftCarrier")) { 144 | mainIcon = "120100"; 145 | } 146 | if (types.includes("Missile")) { 147 | mainIcon = "110000"; 148 | } 149 | icon = `${mainIcon}${modifier}`; 150 | } 151 | 152 | return `100${ident}${set}0000${icon}`; 153 | } 154 | 155 | export function makeObjectName(object: TacviewObject): string { 156 | let name = object.name ?? ""; 157 | if ( 158 | object.pilot != null && 159 | (object.group == null || !object.pilot.startsWith(object.group)) 160 | ) { 161 | name = `${object.pilot} (${name})`; 162 | } 163 | return name; 164 | } 165 | -------------------------------------------------------------------------------- /src/ConnectView.tsx: -------------------------------------------------------------------------------- 1 | import { invoke } from "@tauri-apps/api"; 2 | import { type ReactElement, useState, type FormEvent, useMemo } from "react"; 3 | import { useNavigate } from "react-router-dom"; 4 | 5 | import { useCurrentVersion } from "./hook"; 6 | 7 | export default function ConnectView(): ReactElement { 8 | const navigate = useNavigate(); 9 | const [host, setHost] = useState(""); 10 | const [port, setPort] = useState(42674); 11 | const [username, setUsername] = useState("peace-eye"); 12 | const [password, setPassword] = useState(""); 13 | const [isNewVersionAvailable, setIsNewVersionAvailable] = useState(false); 14 | 15 | const currentVersion = useCurrentVersion(); 16 | 17 | useMemo(() => { 18 | invoke("check_new_version") 19 | .then((b) => { 20 | setIsNewVersionAvailable(b); 21 | }) 22 | .catch((error) => { 23 | console.log(error); 24 | }); 25 | }, []); 26 | 27 | const onSubmit = async (e: FormEvent): Promise => { 28 | e.preventDefault(); 29 | await invoke("connect", { host, port, username, password }); 30 | navigate("/connected"); 31 | }; 32 | 33 | return ( 34 |
35 |

peace-eye v{currentVersion}

36 |
37 | 38 | { 43 | setHost(e.target.value); 44 | }} 45 | /> 46 | 66 | { 73 | setPort(Number(e.target.value)); 74 | }} 75 | /> 76 | 77 | { 82 | setUsername(e.target.value); 83 | }} 84 | /> 85 | 105 | { 110 | setPassword(e.target.value); 111 | }} 112 | /> 113 | 114 |
115 | {isNewVersionAvailable && ( 116 | 122 | New version available! 123 | 124 | )} 125 |
126 | ); 127 | } 128 | -------------------------------------------------------------------------------- /src/data/terrain/persiangulf.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "PersianGulf", 3 | "center": [ 4 | 26.073313480316006, 5 | 55.947875221508234 6 | ], 7 | "airports": [ 8 | { 9 | "name": "Abu Musa Island", 10 | "position": [ 11 | 25.875991474980953, 12 | 55.03291835274321 13 | ] 14 | }, 15 | { 16 | "name": "Bandar Abbas Intl", 17 | "position": [ 18 | 27.217686874144658, 19 | 56.37896185413795 20 | ] 21 | }, 22 | { 23 | "name": "Bandar Lengeh", 24 | "position": [ 25 | 26.53226376838109, 26 | 54.82466722322185 27 | ] 28 | }, 29 | { 30 | "name": "Al Dhafra AFB", 31 | "position": [ 32 | 24.248287848684924, 33 | 54.547699097178224 34 | ] 35 | }, 36 | { 37 | "name": "Dubai Intl", 38 | "position": [ 39 | 25.256375873365933, 40 | 55.36526121222419 41 | ] 42 | }, 43 | { 44 | "name": "Al Maktoum Intl", 45 | "position": [ 46 | 24.897056657670795, 47 | 55.16023901638744 48 | ] 49 | }, 50 | { 51 | "name": "Fujairah Intl", 52 | "position": [ 53 | 25.11094017504334, 54 | 56.32735119702503 55 | ] 56 | }, 57 | { 58 | "name": "Tunb Island AFB", 59 | "position": [ 60 | 26.258887588131895, 61 | 55.3157816544853 62 | ] 63 | }, 64 | { 65 | "name": "Havadarya", 66 | "position": [ 67 | 27.158224470362896, 68 | 56.172018585052534 69 | ] 70 | }, 71 | { 72 | "name": "Khasab", 73 | "position": [ 74 | 26.169825901810846, 75 | 56.240170078635735 76 | ] 77 | }, 78 | { 79 | "name": "Lar", 80 | "position": [ 81 | 27.6747324431012, 82 | 54.38314474975612 83 | ] 84 | }, 85 | { 86 | "name": "Al Minhad AFB", 87 | "position": [ 88 | 25.02688139311628, 89 | 55.36575776200433 90 | ] 91 | }, 92 | { 93 | "name": "Qeshm Island", 94 | "position": [ 95 | 26.754669193351674, 96 | 55.902384206007824 97 | ] 98 | }, 99 | { 100 | "name": "Sharjah Intl", 101 | "position": [ 102 | 25.330675265213504, 103 | 55.51826295733328 104 | ] 105 | }, 106 | { 107 | "name": "Sirri Island", 108 | "position": [ 109 | 25.909602942746993, 110 | 54.53925448037571 111 | ] 112 | }, 113 | { 114 | "name": "Tunb Kochak", 115 | "position": [ 116 | 26.24332226313404, 117 | 55.14556789952401 118 | ] 119 | }, 120 | { 121 | "name": "Sir Abu Nuayr", 122 | "position": [ 123 | 25.216870595740204, 124 | 54.23364967608486 125 | ] 126 | }, 127 | { 128 | "name": "Kerman", 129 | "position": [ 130 | 30.273110189780596, 131 | 56.95155133335564 132 | ] 133 | }, 134 | { 135 | "name": "Shiraz Intl", 136 | "position": [ 137 | 29.54090996406613, 138 | 52.59107375150158 139 | ] 140 | }, 141 | { 142 | "name": "Sas Al Nakheel", 143 | "position": [ 144 | 24.441109498565442, 145 | 54.51702313971644 146 | ] 147 | }, 148 | { 149 | "name": "Bandar-e-Jask", 150 | "position": [ 151 | 25.65467256513212, 152 | 57.801435692501606 153 | ] 154 | }, 155 | { 156 | "name": "Abu Dhabi Intl", 157 | "position": [ 158 | 24.453707857534184, 159 | 54.65426783604469 160 | ] 161 | }, 162 | { 163 | "name": "Al-Bateen", 164 | "position": [ 165 | 24.42807735277165, 166 | 54.458580277152194 167 | ] 168 | }, 169 | { 170 | "name": "Kish Intl", 171 | "position": [ 172 | 26.5281484863981, 173 | 53.981048990350594 174 | ] 175 | }, 176 | { 177 | "name": "Al Ain Intl", 178 | "position": [ 179 | 24.261427609263, 180 | 55.6092452930864 181 | ] 182 | }, 183 | { 184 | "name": "Lavan Island", 185 | "position": [ 186 | 26.811107783686776, 187 | 53.353293099264455 188 | ] 189 | }, 190 | { 191 | "name": "Jiroft", 192 | "position": [ 193 | 28.723516960665663, 194 | 57.67509513993454 195 | ] 196 | }, 197 | { 198 | "name": "Ras Al Khaimah Intl", 199 | "position": [ 200 | 25.613494250727733, 201 | 55.9387973076312 202 | ] 203 | }, 204 | { 205 | "name": "Liwa AFB", 206 | "position": [ 207 | 23.650775423946882, 208 | 53.82442927400572 209 | ] 210 | } 211 | ], 212 | "projection": { 213 | "centralMeridian": 57, 214 | "falseEasting": 75755.99999999645, 215 | "falseNorthing": -2894933.0000000377, 216 | "scaleFactor": 0.9996 217 | } 218 | } -------------------------------------------------------------------------------- /src/ControlPanel.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { useState, type ReactElement } from "react"; 3 | 4 | import Symbol from "./Symbol"; 5 | import { makeObjectName, objectToSymbol } from "./entity"; 6 | import { type TacviewObject } from "./tacview"; 7 | 8 | export interface ControlPanelProps { 9 | objects: Array<[number, TacviewObject]>; 10 | watchingObjects: Array<[number, TacviewObject]>; 11 | onObjectClick: (id: number) => void; 12 | } 13 | 14 | export default function ControlPanel(props: ControlPanelProps): ReactElement { 15 | const [selectedTab, setSelectedTab] = useState(undefined); 16 | const [search, setSearch] = useState(""); 17 | 18 | const { objects, watchingObjects, onObjectClick } = props; 19 | 20 | return ( 21 |
22 |
23 |
24 | 41 |
42 |
43 | 60 |
61 |
62 | 81 |
82 |
83 | {selectedTab === "search" && ( 84 | <> 85 |
86 | { 91 | setSearch(e.target.value); 92 | }} 93 | /> 94 |
95 | {search !== "" && ( 96 |
97 | 117 |
118 | )} 119 | 120 | )} 121 | {selectedTab === "watches" && ( 122 |
123 | 143 |
144 | )} 145 |
146 | ); 147 | } 148 | -------------------------------------------------------------------------------- /src/data/terrain/normandy.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Normandy", 3 | "center": [ 4 | 49.74877403511513, 5 | -1.846638048300662 6 | ], 7 | "airports": [ 8 | { 9 | "name": "Saint Pierre du Mont", 10 | "position": [ 11 | 49.39050750771512, 12 | -0.9570784402154576 13 | ] 14 | }, 15 | { 16 | "name": "Lignerolles", 17 | "position": [ 18 | 49.17521913769311, 19 | -0.7893505989614358 20 | ] 21 | }, 22 | { 23 | "name": "Cretteville", 24 | "position": [ 25 | 49.33659188101229, 26 | -1.3793558156955996 27 | ] 28 | }, 29 | { 30 | "name": "Maupertus", 31 | "position": [ 32 | 49.649802089218205, 33 | -1.4669543832480483 34 | ] 35 | }, 36 | { 37 | "name": "Brucheville", 38 | "position": [ 39 | 49.368540234441184, 40 | -1.2162617425541298 41 | ] 42 | }, 43 | { 44 | "name": "Meautis", 45 | "position": [ 46 | 49.28320094903039, 47 | -1.3001268487280349 48 | ] 49 | }, 50 | { 51 | "name": "Cricqueville-en-Bessin", 52 | "position": [ 53 | 49.36457488447572, 54 | -1.006884202276612 55 | ] 56 | }, 57 | { 58 | "name": "Lessay", 59 | "position": [ 60 | 49.20163242886947, 61 | -1.5022023827105582 62 | ] 63 | }, 64 | { 65 | "name": "Sainte-Laurent-sur-Mer", 66 | "position": [ 67 | 49.3644633646548, 68 | -0.8734883568799777 69 | ] 70 | }, 71 | { 72 | "name": "Biniville", 73 | "position": [ 74 | 49.43672080905052, 75 | -1.4689704645081887 76 | ] 77 | }, 78 | { 79 | "name": "Cardonville", 80 | "position": [ 81 | 49.3510127807796, 82 | -1.050996829017164 83 | ] 84 | }, 85 | { 86 | "name": "Deux Jumeaux", 87 | "position": [ 88 | 49.34732697015012, 89 | -0.9808090193021751 90 | ] 91 | }, 92 | { 93 | "name": "Chippelle", 94 | "position": [ 95 | 49.24191737381444, 96 | -0.9716503369235594 97 | ] 98 | }, 99 | { 100 | "name": "Beuzeville", 101 | "position": [ 102 | 49.420545812465996, 103 | -1.298551812915091 104 | ] 105 | }, 106 | { 107 | "name": "Azeville", 108 | "position": [ 109 | 49.48099141457365, 110 | -1.3176161475476487 111 | ] 112 | }, 113 | { 114 | "name": "Picauville", 115 | "position": [ 116 | 49.396374369558124, 117 | -1.4111552354791839 118 | ] 119 | }, 120 | { 121 | "name": "Le Molay", 122 | "position": [ 123 | 49.261537487797824, 124 | -0.8816646153432968 125 | ] 126 | }, 127 | { 128 | "name": "Longues-sur-Mer", 129 | "position": [ 130 | 49.34266164800177, 131 | -0.7061597324638667 132 | ] 133 | }, 134 | { 135 | "name": "Carpiquet", 136 | "position": [ 137 | 49.17500425919466, 138 | -0.45422484559316184 139 | ] 140 | }, 141 | { 142 | "name": "Bazenville", 143 | "position": [ 144 | 49.30396407238038, 145 | -0.5647276696292327 146 | ] 147 | }, 148 | { 149 | "name": "Sainte-Croix-sur-Mer", 150 | "position": [ 151 | 49.32027551835111, 152 | -0.5172460435703728 153 | ] 154 | }, 155 | { 156 | "name": "Beny-sur-Mer", 157 | "position": [ 158 | 49.29802146064176, 159 | -0.4266212227877683 160 | ] 161 | }, 162 | { 163 | "name": "Rucqueville", 164 | "position": [ 165 | 49.251425191318766, 166 | -0.5803101001933463 167 | ] 168 | }, 169 | { 170 | "name": "Sommervieu", 171 | "position": [ 172 | 49.30022765348125, 173 | -0.670951343783412 174 | ] 175 | }, 176 | { 177 | "name": "Lantheuil", 178 | "position": [ 179 | 49.27145744743927, 180 | -0.5384030080631071 181 | ] 182 | }, 183 | { 184 | "name": "Evreux", 185 | "position": [ 186 | 49.02621389401841, 187 | 1.2104114167067606 188 | ] 189 | }, 190 | { 191 | "name": "Chailey", 192 | "position": [ 193 | 50.95113261243784, 194 | -0.047238329806234096 195 | ] 196 | }, 197 | { 198 | "name": "Needs Oar Point", 199 | "position": [ 200 | 50.77607968186543, 201 | -1.4234633637622192 202 | ] 203 | }, 204 | { 205 | "name": "Funtington", 206 | "position": [ 207 | 50.86694561507297, 208 | -0.8747044734782443 209 | ] 210 | }, 211 | { 212 | "name": "Tangmere", 213 | "position": [ 214 | 50.84386152510379, 215 | -0.7050329088795172 216 | ] 217 | }, 218 | { 219 | "name": "Ford_AF", 220 | "position": [ 221 | 50.81760329901683, 222 | -0.5893975015374983 223 | ] 224 | }, 225 | { 226 | "name": "Argentan", 227 | "position": [ 228 | 48.76876368465585, 229 | -0.03044951339174528 230 | ] 231 | }, 232 | { 233 | "name": "Goulet", 234 | "position": [ 235 | 48.749691670898386, 236 | -0.11145773542012694 237 | ] 238 | }, 239 | { 240 | "name": "Barville", 241 | "position": [ 242 | 48.479390515541674, 243 | 0.3102543875809955 244 | ] 245 | }, 246 | { 247 | "name": "Essay", 248 | "position": [ 249 | 48.52054703087038, 250 | 0.2576287171188326 251 | ] 252 | }, 253 | { 254 | "name": "Hauterive", 255 | "position": [ 256 | 48.49994057084911, 257 | 0.20009096240001759 258 | ] 259 | }, 260 | { 261 | "name": "Vrigny", 262 | "position": [ 263 | 48.6722820631361, 264 | -0.002163078105177102 265 | ] 266 | }, 267 | { 268 | "name": "Conches", 269 | "position": [ 270 | 48.93496410310911, 271 | 0.9615580146882924 272 | ] 273 | } 274 | ], 275 | "projection": { 276 | "centralMeridian": -3, 277 | "falseEasting": -195526.00000000204, 278 | "falseNorthing": -5484812.999999951, 279 | "scaleFactor": 0.9996 280 | } 281 | } -------------------------------------------------------------------------------- /tools/terrain-data-generator/poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "certifi" 5 | version = "2022.12.7" 6 | description = "Python package for providing Mozilla's CA Bundle." 7 | category = "main" 8 | optional = false 9 | python-versions = ">=3.6" 10 | files = [ 11 | {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, 12 | {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, 13 | ] 14 | 15 | [[package]] 16 | name = "pydcs" 17 | version = "0.13.0" 18 | description = "A Digital Combat Simulator mission builder framework" 19 | category = "main" 20 | optional = false 21 | python-versions = "*" 22 | files = [] 23 | develop = false 24 | 25 | [package.source] 26 | type = "git" 27 | url = "https://github.com/pydcs/dcs.git" 28 | reference = "e7ed9061187f7c2cb6719ae757bf91e9cfb85441" 29 | resolved_reference = "e7ed9061187f7c2cb6719ae757bf91e9cfb85441" 30 | 31 | [[package]] 32 | name = "pyproj" 33 | version = "3.4.1" 34 | description = "Python interface to PROJ (cartographic projections and coordinate transformations library)" 35 | category = "main" 36 | optional = false 37 | python-versions = ">=3.8" 38 | files = [ 39 | {file = "pyproj-3.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e463c687007861a9949909211986850cfc2e72930deda0d06449ef2e315db534"}, 40 | {file = "pyproj-3.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2f87f16b902c8b2af007295c63a435f043db9e40bd45e6f96962c7b8cd08fdb5"}, 41 | {file = "pyproj-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c60d112d8f1621a606b7f2adb0b1582f80498e663413d2ba9f5df1c93d99f432"}, 42 | {file = "pyproj-3.4.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f38dea459e22e86326b1c7d47718a3e10c7a27910cf5eb86ea2679b8084d0c4e"}, 43 | {file = "pyproj-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a53acbde511a7a9e1873c7f93c68f35b8c3653467b77195fe18e847555dcb7a"}, 44 | {file = "pyproj-3.4.1-cp310-cp310-win32.whl", hash = "sha256:0c7b32382ae22a9bf5b690d24c7b4c0fb89ba313c3a91ef1a8c54b50baf10954"}, 45 | {file = "pyproj-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:6bdac3bc1899fcc4021be06d303b342923fb8311fe06f8d862c348a1a0e78b41"}, 46 | {file = "pyproj-3.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cd9f9c409f465834988ce0aa8c1ed496081c6957f2e5ef40ed28de04397d3c0b"}, 47 | {file = "pyproj-3.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0406f64ff59eb3342efb102c9f31536430aa5cde5ef0bfabd5aaccb73dd8cd5a"}, 48 | {file = "pyproj-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a98fe3e53be428e67ae6a9ee9affff92346622e0e3ea0cbc15dce939b318d395"}, 49 | {file = "pyproj-3.4.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0189fdd7aa789542a7a623010dfff066c5849b24397f81f860ec3ee085cbf55c"}, 50 | {file = "pyproj-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f3f75b030cf811f040c90a8758a20115e8746063e4cad0d0e941a4954d1219b"}, 51 | {file = "pyproj-3.4.1-cp311-cp311-win32.whl", hash = "sha256:ef8c30c62fe4e386e523e14e1e83bd460f745bd2c8dfd0d0c327f9460c4d3c0c"}, 52 | {file = "pyproj-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d1e7f42da205e0534831ae9aa9cee0353ab8c1aab2c369474adbb060294d98a"}, 53 | {file = "pyproj-3.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a5eada965e8ac24e783f2493d1d9bcd11c5c93959bd43558224dd31d9faebd1c"}, 54 | {file = "pyproj-3.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:19f5de1a7c3b81b676d846350d4bdf2ae6af13b9a450d1881706f088ecad0e2c"}, 55 | {file = "pyproj-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57ec7d2b7f2773d877927abc72e2229ef8530c09181be0e28217742bae1bc4f5"}, 56 | {file = "pyproj-3.4.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a30d78e619dae5cd1bb69addae2f1e5f8ee1b4a8ab4f3d954e9eaf41948db506"}, 57 | {file = "pyproj-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a32e1d12340ad93232b7ea4dc1a4f4b21fa9fa9efa4b293adad45be7af6b51ec"}, 58 | {file = "pyproj-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ce50126dad7cd4749ab86fc4c8b54ec0898149ce6710ab5c93c76a54a4afa249"}, 59 | {file = "pyproj-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:129234afa179c8293b010ea4f73655ff7b20b5afdf7fac170f223bcf0ed6defd"}, 60 | {file = "pyproj-3.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:231c038c6b65395c41ae3362320f03ce8054cb54dc63556e605695e5d461a27e"}, 61 | {file = "pyproj-3.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e9d82df555cf19001bac40e1de0e40fb762dec785685b77edd6993286c01b7f7"}, 62 | {file = "pyproj-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8c0d1ac9ef5a4d2e6501a4b30136c55f1e1db049d1626cc313855c4f97d196d"}, 63 | {file = "pyproj-3.4.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:97065fe82e80f7e2740e7897a0e36e8defc0a3614927f0276b4f1d1ea1ef66fa"}, 64 | {file = "pyproj-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bd633f3b8ca6eb09135dfaf06f09e2869deb139985aab26d728e8a60c9938b9"}, 65 | {file = "pyproj-3.4.1-cp39-cp39-win32.whl", hash = "sha256:da96319b137cfd66f0bae0e300cdc77dd17af4785b9360a9bdddb1d7176a0bbb"}, 66 | {file = "pyproj-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:7aef19d5a0a3b2d6b17f7dc9a87af722e71139cd1eea7eb82ed062a8a4b0e272"}, 67 | {file = "pyproj-3.4.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8078c90cea07d53e3406c7c84cbf76a2ac0ffc580c365f13801575486b9d558c"}, 68 | {file = "pyproj-3.4.1-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:321b82210dc5271558573d0874b9967c5a25872a28d0168049ddabe8bfecffce"}, 69 | {file = "pyproj-3.4.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:25a5425cd2a0b16f5f944d49165196eebaa60b898a08c404a644c29e6a7a04b3"}, 70 | {file = "pyproj-3.4.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:3d70ca5933cddbe6f51396006fb9fc78bc2b1f9d28775922453c4b04625a7efb"}, 71 | {file = "pyproj-3.4.1-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c240fe6bcb5c325b50fc967d5458d708412633f4f05fefc7fb14c14254ebf421"}, 72 | {file = "pyproj-3.4.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef76abfee1a0676ef973470abe11e22998750f2bd944afaf76d44ad70b538c06"}, 73 | {file = "pyproj-3.4.1.tar.gz", hash = "sha256:261eb29b1d55b1eb7f336127344d9b31284d950a9446d1e0d1c2411f7dd8e3ac"}, 74 | ] 75 | 76 | [package.dependencies] 77 | certifi = "*" 78 | 79 | [metadata] 80 | lock-version = "2.0" 81 | python-versions = "^3.10" 82 | content-hash = "329bbc089aa5edb9374687baa811a2d6650b763be75106126972a4c5230cf82d" 83 | -------------------------------------------------------------------------------- /src/SettingsModal.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { useState, type ReactElement } from "react"; 3 | 4 | import { useCurrentVersion } from "./hook"; 5 | import { type Settings } from "./settings"; 6 | 7 | export interface SettingsModalProps { 8 | settings: Settings; 9 | setSettings: (settings: Settings) => void; 10 | onDisconnect: (() => void) | (() => Promise); 11 | } 12 | 13 | export default function SettingsModal(props: SettingsModalProps): ReactElement { 14 | const [selectedTab, setSelectedTab] = useState("view"); 15 | 16 | const currentVersion = useCurrentVersion(); 17 | 18 | const { settings, setSettings, onDisconnect } = props; 19 | 20 | return ( 21 | <> 22 | 23 |
27 |
28 | Settings 29 |
30 | 40 |
41 |
42 | 77 |
78 | {selectedTab === "view" && ( 79 |
80 | 92 | 104 | 118 | 130 | 142 |
143 | )} 144 | {selectedTab === "connection" && ( 145 |
146 | 154 |
155 | )} 156 | {selectedTab === "about" && ( 157 |
158 | 159 | peace-eye v{currentVersion} 160 | 161 |
162 | Created by pbzweihander 163 |
164 | Contact: pbzweihander@gmail.com 165 |
166 | 172 | https://github.com/pbzweihander/peace-eye 173 | 174 |
175 | Distributed under the terms of MIT license 176 |
177 | )} 178 |
179 |
180 |
181 | 182 |
183 |
184 | 185 | ); 186 | } 187 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import proj4 from "proj4"; 2 | 3 | import { type Terrain } from "./dcs/terrain"; 4 | 5 | function toRadians(n: number): number { 6 | return n * (Math.PI / 180); 7 | } 8 | 9 | function toDegrees(n: number): number { 10 | return n * (180 / Math.PI); 11 | } 12 | 13 | export function meterToFeet(meters: number): number { 14 | return meters * 3.28084; 15 | } 16 | 17 | export function nmToMeter(nm: number): number { 18 | return nm * 1852; 19 | } 20 | 21 | export function getBearing( 22 | startCoords: [number, number], 23 | endCoords: [number, number], 24 | terrain: Terrain 25 | ): number { 26 | const [startX, startY] = toMercProj(startCoords, terrain); 27 | const [endX, endY] = toMercProj(endCoords, terrain); 28 | 29 | return (toDegrees(Math.atan2(endX - startX, endY - startY)) + 360) % 360; 30 | } 31 | 32 | // In nautical miles 33 | export function getRange( 34 | [lat1, lon1]: [number, number], 35 | [lat2, lon2]: [number, number] 36 | ): number { 37 | const R = 6371; // km 38 | const dLat = toRadians(lat2 - lat1); 39 | const dLon = toRadians(lon2 - lon1); 40 | const lat1Rad = toRadians(lat1); 41 | const lat2Rad = toRadians(lat2); 42 | 43 | const a = 44 | Math.sin(dLat / 2) * Math.sin(dLat / 2) + 45 | Math.sin(dLon / 2) * 46 | Math.sin(dLon / 2) * 47 | Math.cos(lat1Rad) * 48 | Math.cos(lat2Rad); 49 | const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); 50 | const d = R * c; 51 | return d * 0.539957; 52 | } 53 | 54 | export function getCardinal(angle: number): string { 55 | const degreePerDirection = 360 / 8; 56 | const offsetAngle = angle + degreePerDirection / 2; 57 | 58 | return offsetAngle >= 0 * degreePerDirection && 59 | offsetAngle < 1 * degreePerDirection 60 | ? "N" 61 | : offsetAngle >= 1 * degreePerDirection && 62 | offsetAngle < 2 * degreePerDirection 63 | ? "NE" 64 | : offsetAngle >= 2 * degreePerDirection && 65 | offsetAngle < 3 * degreePerDirection 66 | ? "E" 67 | : offsetAngle >= 3 * degreePerDirection && 68 | offsetAngle < 4 * degreePerDirection 69 | ? "SE" 70 | : offsetAngle >= 4 * degreePerDirection && 71 | offsetAngle < 5 * degreePerDirection 72 | ? "S" 73 | : offsetAngle >= 5 * degreePerDirection && 74 | offsetAngle < 6 * degreePerDirection 75 | ? "SW" 76 | : offsetAngle >= 6 * degreePerDirection && 77 | offsetAngle < 7 * degreePerDirection 78 | ? "W" 79 | : "NW"; 80 | } 81 | 82 | export function moveCoords( 83 | lat1: number, 84 | lon1: number, 85 | brng: number, 86 | dist: number 87 | ): [number, number] { 88 | const a = 6378137; 89 | const b = 6356752.3142; 90 | const f = 1 / 298.257223563; // WGS-84 ellipsiod 91 | const s = dist; 92 | const alpha1 = toRadians(brng); 93 | const sinAlpha1 = Math.sin(alpha1); 94 | const cosAlpha1 = Math.cos(alpha1); 95 | const tanU1 = (1 - f) * Math.tan(toRadians(lat1)); 96 | const cosU1 = 1 / Math.sqrt(1 + tanU1 * tanU1); 97 | const sinU1 = tanU1 * cosU1; 98 | const sigma1 = Math.atan2(tanU1, cosAlpha1); 99 | const sinAlpha = cosU1 * sinAlpha1; 100 | const cosSqAlpha = 1 - sinAlpha * sinAlpha; 101 | const uSq = (cosSqAlpha * (a * a - b * b)) / (b * b); 102 | const A = 1 + (uSq / 16384) * (4096 + uSq * (-768 + uSq * (320 - 175 * uSq))); 103 | const B = (uSq / 1024) * (256 + uSq * (-128 + uSq * (74 - 47 * uSq))); 104 | let sigma = s / (b * A); 105 | let sigmaP = 2 * Math.PI; 106 | 107 | let sinSigma: number = 0; 108 | let cosSigma: number = 0; 109 | let cos2SigmaM = 0; 110 | 111 | while (Math.abs(sigma - sigmaP) > 1e-12) { 112 | sinSigma = Math.sin(sigma); 113 | cosSigma = Math.cos(sigma); 114 | cos2SigmaM = Math.cos(2 * sigma1 + sigma); 115 | 116 | const deltaSigma = 117 | B * 118 | sinSigma * 119 | (cos2SigmaM + 120 | (B / 4) * 121 | (cosSigma * (-1 + 2 * cos2SigmaM * cos2SigmaM) - 122 | (B / 6) * 123 | cos2SigmaM * 124 | (-3 + 4 * sinSigma * sinSigma) * 125 | (-3 + 4 * cos2SigmaM * cos2SigmaM))); 126 | sigmaP = sigma; 127 | sigma = s / (b * A) + deltaSigma; 128 | } 129 | 130 | const tmp = sinU1 * sinSigma - cosU1 * cosSigma * cosAlpha1; 131 | const lat2 = Math.atan2( 132 | sinU1 * cosSigma + cosU1 * sinSigma * cosAlpha1, 133 | (1 - f) * Math.sqrt(sinAlpha * sinAlpha + tmp * tmp) 134 | ); 135 | const lambda = Math.atan2( 136 | sinSigma * sinAlpha1, 137 | cosU1 * cosSigma - sinU1 * sinSigma * cosAlpha1 138 | ); 139 | const C = (f / 16) * cosSqAlpha * (4 + f * (4 - 3 * cosSqAlpha)); 140 | const L = 141 | lambda - 142 | (1 - C) * 143 | f * 144 | sinAlpha * 145 | (sigma + 146 | C * 147 | sinSigma * 148 | (cos2SigmaM + C * cosSigma * (-1 + 2 * cos2SigmaM * cos2SigmaM))); 149 | return [toDegrees(lat2), lon1 + toDegrees(L)]; 150 | } 151 | 152 | function toDegreesMinutesAndSeconds(coordinate: number, size: number): string { 153 | const absolute = Math.abs(coordinate); 154 | const degrees = Math.floor(absolute); 155 | const minutesNotTruncated = (absolute - degrees) * 60; 156 | const minutes = Math.floor(minutesNotTruncated); 157 | const seconds = Math.floor((minutesNotTruncated - minutes) * 60); 158 | 159 | return ( 160 | degrees.toString().padStart(size, "0") + 161 | "°" + 162 | minutes.toString().padStart(2, "0") + 163 | "'" + 164 | seconds.toString().padStart(2, "0") + 165 | '"' 166 | ); 167 | } 168 | 169 | function toDegreesDecimalMinutes(coordinate: number, size: number): string { 170 | const absolute = Math.abs(coordinate); 171 | const degrees = Math.floor(absolute); 172 | const minutes = (absolute - degrees) * 60; 173 | 174 | return degrees.toString().padStart(size, "0") + "°" + minutes.toFixed(5); 175 | } 176 | 177 | export function formatDMS([lat, lng]: [number, number]): string { 178 | const latitude = toDegreesMinutesAndSeconds(lat, 2); 179 | const latitudeCardinal = lat >= 0 ? "N" : "S"; 180 | 181 | const longitude = toDegreesMinutesAndSeconds(lng, 3); 182 | const longitudeCardinal = lng >= 0 ? "E" : "W"; 183 | 184 | return `${latitudeCardinal}${latitude} ${longitudeCardinal}${longitude}`; 185 | } 186 | 187 | export function formatDDM([lat, lng]: [number, number]): string { 188 | const latitude = toDegreesDecimalMinutes(lat, 2); 189 | const latitudeCardinal = lat >= 0 ? "N" : "S"; 190 | const longitude = toDegreesDecimalMinutes(lng, 3); 191 | const longitudeCardinal = lng >= 0 ? "E" : "W"; 192 | return `${latitudeCardinal}${latitude} ${longitudeCardinal}${longitude}`; 193 | } 194 | 195 | export function toMercProj( 196 | [lat, lng]: [number, number], 197 | terrain: Terrain 198 | ): [number, number] { 199 | const projection = terrain.projection; 200 | // Reference: https://github.com/pydcs/dcs/blob/8fdeda106ba7e847a5d0a1ed358a1463636b513d/dcs/terrain/projections/transversemercator.py 201 | const fromProjection = [ 202 | "+proj=tmerc", 203 | "+lat_0=0", 204 | `+lon_0=${projection.centralMeridian}`, 205 | `+k_0=${projection.scaleFactor}`, 206 | `+x_0=${projection.falseEasting}`, 207 | `+y_0=${projection.falseNorthing}`, 208 | "+towgs84=0,0,0,0,0,0,0", 209 | "+units=m", 210 | "+vunits=m", 211 | "+ellps=WGS84", 212 | "+no_defs", 213 | "+axis=neu", 214 | ].join(" "); 215 | return proj4(fromProjection, [lng, lat]); 216 | } 217 | -------------------------------------------------------------------------------- /src/ObjectInfo.tsx: -------------------------------------------------------------------------------- 1 | import * as mgrs from "mgrs"; 2 | import { useCallback, type ReactElement } from "react"; 3 | import { useMap } from "react-map-gl"; 4 | 5 | import { type Terrain } from "./dcs/terrain"; 6 | import { type ObjectSettings } from "./objectSettings"; 7 | import { type TacviewObject } from "./tacview"; 8 | import { tagToString } from "./tacview/record/objectProperty"; 9 | import { 10 | formatDDM, 11 | formatDMS, 12 | getBearing, 13 | getCardinal, 14 | getRange, 15 | meterToFeet, 16 | } from "./util"; 17 | 18 | export interface ObjectInfoProps { 19 | object: TacviewObject; 20 | referenceLatitude: number; 21 | referenceLongitude: number; 22 | bullseyeCoords: [number, number] | undefined; 23 | onClose: () => void; 24 | objectSettings?: ObjectSettings; 25 | setObjectSettings?: (objectSettings: ObjectSettings) => void; 26 | terrain: Terrain; 27 | geomagnetismModel: any; 28 | useMagneticHeading: boolean; 29 | } 30 | 31 | export default function ObjectInfo(props: ObjectInfoProps): ReactElement { 32 | const map = useMap(); 33 | 34 | const parseRange = useCallback((v: string): number => { 35 | let vv = Number(v); 36 | if (vv < 0) { 37 | vv = 0; 38 | } 39 | return vv; 40 | }, []); 41 | 42 | const { 43 | object, 44 | referenceLatitude, 45 | referenceLongitude, 46 | bullseyeCoords, 47 | onClose, 48 | objectSettings, 49 | setObjectSettings, 50 | terrain, 51 | geomagnetismModel, 52 | useMagneticHeading, 53 | } = props; 54 | 55 | const coords: [number, number] | undefined = 56 | object.coords?.latitude !== undefined && 57 | object.coords?.longitude !== undefined 58 | ? [ 59 | referenceLatitude + object.coords.latitude, 60 | referenceLongitude + object.coords.longitude, 61 | ] 62 | : undefined; 63 | const objectTypes = object.type ?? []; 64 | 65 | let heading = object.coords?.heading; 66 | if (useMagneticHeading && heading != null && coords != null) { 67 | heading = heading - (geomagnetismModel.point(coords).decl as number); 68 | } 69 | if (heading != null) { 70 | heading = Math.round((heading + 360) % 360); 71 | } 72 | 73 | let bullseyeInfo: string = ""; 74 | if (bullseyeCoords !== undefined && coords !== undefined) { 75 | let bullseyeBearing = getBearing(bullseyeCoords, coords, terrain); 76 | if (useMagneticHeading) { 77 | bullseyeBearing = 78 | bullseyeBearing - 79 | (geomagnetismModel.point(bullseyeCoords).decl as number); 80 | } 81 | bullseyeBearing = Math.round((bullseyeBearing + 360) % 360); 82 | const bullseyeRange = Math.round(getRange(bullseyeCoords, coords)); 83 | bullseyeInfo = `${bullseyeBearing.toString().padStart(3, "0")}${getCardinal( 84 | bullseyeBearing 85 | )} / ${bullseyeRange}`; 86 | } 87 | 88 | return ( 89 |
90 |
91 | {object.group} 92 | 100 |
101 |
102 |
103 |
{object.name}
104 |
{object.pilot}
105 | {(object.type?.includes("Air") ?? false) && ( 106 | <> 107 |
108 | Heading:{" "} 109 | {heading != null && 110 | `${Math.round(heading) 111 | .toString() 112 | .padStart(3, "0")}${getCardinal(heading)}`} 113 |
114 |
115 | Altitude:{" "} 116 | {object.coords?.altitude !== undefined && 117 | Math.round(meterToFeet(object.coords.altitude))} 118 |
119 |
120 | GS:{" "} 121 | {object.estimatedSpeed >= 0 && 122 | Math.round(object.estimatedSpeed)} 123 |
124 | 125 | )} 126 | {objectTypes.length > 0 && ( 127 |
128 | Type:{" "} 129 | {objectTypes.map((ty) => { 130 | const tagStr = tagToString(ty); 131 | return ( 132 | 133 | {tagStr} 134 | 135 | ); 136 | })} 137 |
138 | )} 139 |
140 | {objectSettings !== undefined && ( 141 | <> 142 |
143 |
144 | 157 |
158 |
159 | WR 160 |
161 | { 167 | if (setObjectSettings !== undefined) { 168 | objectSettings.warningRange = parseRange(e.target.value); 169 | setObjectSettings(objectSettings); 170 | } 171 | }} 172 | /> 173 |
174 |
175 |
176 | TR 177 |
178 | { 184 | if (setObjectSettings !== undefined) { 185 | objectSettings.threatRange = parseRange(e.target.value); 186 | setObjectSettings(objectSettings); 187 | } 188 | }} 189 | /> 190 |
191 | 205 |
206 | 207 | )} 208 |
209 | {coords !== undefined && ( 210 |
211 |
212 | DMS 213 | {formatDMS(coords)} 214 |
215 |
216 | DDM 217 | {formatDDM(coords)} 218 |
219 |
220 | MGRS 221 | 222 | {mgrs.forward([coords[1], coords[0]])} 223 | 224 |
225 | {bullseyeInfo != null && ( 226 |
227 | Bullseye 228 | {bullseyeInfo} 229 |
230 | )} 231 |
232 | )} 233 |
234 | ); 235 | } 236 | -------------------------------------------------------------------------------- /src/data/terrain/syria.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Syria", 3 | "center": [ 4 | 34.9318773082126, 5 | 35.90401988836524 6 | ], 7 | "airports": [ 8 | { 9 | "name": "Abu al-Duhur", 10 | "position": [ 11 | 35.732306452623504, 12 | 37.10412796442225 13 | ] 14 | }, 15 | { 16 | "name": "Adana Sakirpasa", 17 | "position": [ 18 | 36.981885471338884, 19 | 35.280005978214554 20 | ] 21 | }, 22 | { 23 | "name": "Al Qusayr", 24 | "position": [ 25 | 34.569275588147484, 26 | 36.57145372619807 27 | ] 28 | }, 29 | { 30 | "name": "An Nasiriyah", 31 | "position": [ 32 | 33.91860710788541, 33 | 36.86583993953449 34 | ] 35 | }, 36 | { 37 | "name": "Tha'lah", 38 | "position": [ 39 | 32.704004331496655, 40 | 36.41377537523503 41 | ] 42 | }, 43 | { 44 | "name": "Beirut-Rafic Hariri", 45 | "position": [ 46 | 33.827267866605276, 47 | 35.48768735014539 48 | ] 49 | }, 50 | { 51 | "name": "Damascus", 52 | "position": [ 53 | 33.42550825969442, 54 | 36.51851249963701 55 | ] 56 | }, 57 | { 58 | "name": "Marj as Sultan South", 59 | "position": [ 60 | 33.48722065522809, 61 | 36.47545950920995 62 | ] 63 | }, 64 | { 65 | "name": "Al-Dumayr", 66 | "position": [ 67 | 33.60970072277347, 68 | 36.74915246829127 69 | ] 70 | }, 71 | { 72 | "name": "Eyn Shemer", 73 | "position": [ 74 | 32.44086486203697, 75 | 35.00756947543345 76 | ] 77 | }, 78 | { 79 | "name": "Gaziantep", 80 | "position": [ 81 | 36.94796750958491, 82 | 37.47908415741398 83 | ] 84 | }, 85 | { 86 | "name": "H4", 87 | "position": [ 88 | 32.539282678327474, 89 | 38.19507947894577 90 | ] 91 | }, 92 | { 93 | "name": "Haifa", 94 | "position": [ 95 | 32.81074791241588, 96 | 35.04354035349774 97 | ] 98 | }, 99 | { 100 | "name": "Hama", 101 | "position": [ 102 | 35.118043561893394, 103 | 36.712379497444346 104 | ] 105 | }, 106 | { 107 | "name": "Hatay", 108 | "position": [ 109 | 36.362319563564235, 110 | 36.2874218665952 111 | ] 112 | }, 113 | { 114 | "name": "Incirlik", 115 | "position": [ 116 | 37.00204787246273, 117 | 35.426088730373216 118 | ] 119 | }, 120 | { 121 | "name": "Jirah", 122 | "position": [ 123 | 36.096949539274654, 124 | 37.93599679997463 125 | ] 126 | }, 127 | { 128 | "name": "Khalkhalah", 129 | "position": [ 130 | 33.06619256135471, 131 | 36.57234131580781 132 | ] 133 | }, 134 | { 135 | "name": "King Hussein Air College", 136 | "position": [ 137 | 32.35668017234804, 138 | 36.25985540482178 139 | ] 140 | }, 141 | { 142 | "name": "Kiryat Shmona", 143 | "position": [ 144 | 33.21581481207396, 145 | 35.59583458789445 146 | ] 147 | }, 148 | { 149 | "name": "Bassel Al-Assad", 150 | "position": [ 151 | 35.40167028748972, 152 | 35.950384474948116 153 | ] 154 | }, 155 | { 156 | "name": "Marj as Sultan North", 157 | "position": [ 158 | 33.5002804905775, 159 | 36.4671485008798 160 | ] 161 | }, 162 | { 163 | "name": "Marj Ruhayyil", 164 | "position": [ 165 | 33.28378973339248, 166 | 36.45772117947942 167 | ] 168 | }, 169 | { 170 | "name": "Megiddo", 171 | "position": [ 172 | 32.59710714639151, 173 | 35.23008163589168 174 | ] 175 | }, 176 | { 177 | "name": "Mezzeh", 178 | "position": [ 179 | 33.47779531826564, 180 | 36.224216335566965 181 | ] 182 | }, 183 | { 184 | "name": "Minakh", 185 | "position": [ 186 | 36.52137632639822, 187 | 37.041337200988906 188 | ] 189 | }, 190 | { 191 | "name": "Aleppo", 192 | "position": [ 193 | 36.18061838645065, 194 | 37.22436679814415 195 | ] 196 | }, 197 | { 198 | "name": "Palmyra", 199 | "position": [ 200 | 34.557289520642264, 201 | 38.31670535483638 202 | ] 203 | }, 204 | { 205 | "name": "Qabr as Sitt", 206 | "position": [ 207 | 33.45874112184083, 208 | 36.3577296976732 209 | ] 210 | }, 211 | { 212 | "name": "Ramat David", 213 | "position": [ 214 | 32.66636085779898, 215 | 35.17687456611861 216 | ] 217 | }, 218 | { 219 | "name": "Kuweires", 220 | "position": [ 221 | 36.18749362063371, 222 | 37.58149464556121 223 | ] 224 | }, 225 | { 226 | "name": "Rayak", 227 | "position": [ 228 | 33.85112344453055, 229 | 35.98732362036033 230 | ] 231 | }, 232 | { 233 | "name": "Rene Mouawad", 234 | "position": [ 235 | 34.58929966045551, 236 | 36.01144927396427 237 | ] 238 | }, 239 | { 240 | "name": "Rosh Pina", 241 | "position": [ 242 | 32.982648145581045, 243 | 35.570738354898126 244 | ] 245 | }, 246 | { 247 | "name": "Sayqal", 248 | "position": [ 249 | 33.67984170562965, 250 | 37.21711246581466 251 | ] 252 | }, 253 | { 254 | "name": "Shayrat", 255 | "position": [ 256 | 34.49020163154111, 257 | 36.90761267324771 258 | ] 259 | }, 260 | { 261 | "name": "Tabqa", 262 | "position": [ 263 | 35.75472146103939, 264 | 38.56644780451105 265 | ] 266 | }, 267 | { 268 | "name": "Taftanaz", 269 | "position": [ 270 | 35.97410541839518, 271 | 36.78144203111411 272 | ] 273 | }, 274 | { 275 | "name": "Tiyas", 276 | "position": [ 277 | 34.52262939177835, 278 | 37.63012265147932 279 | ] 280 | }, 281 | { 282 | "name": "Wujah Al Hajar", 283 | "position": [ 284 | 34.281337477581985, 285 | 35.68010640278229 286 | ] 287 | }, 288 | { 289 | "name": "Gazipasa", 290 | "position": [ 291 | 36.298921592175624, 292 | 32.29762787923364 293 | ] 294 | }, 295 | { 296 | "name": "Deir ez-Zor", 297 | "position": [ 298 | 35.28543159736069, 299 | 40.17606095522558 300 | ] 301 | }, 302 | { 303 | "name": "Akrotiri", 304 | "position": [ 305 | 34.59037320804046, 306 | 32.987777396159686 307 | ] 308 | }, 309 | { 310 | "name": "Kingsfield", 311 | "position": [ 312 | 35.01480556827786, 313 | 33.71703282481622 314 | ] 315 | }, 316 | { 317 | "name": "Paphos", 318 | "position": [ 319 | 34.71815167387137, 320 | 32.484788726444044 321 | ] 322 | }, 323 | { 324 | "name": "Larnaca", 325 | "position": [ 326 | 34.87317351159717, 327 | 33.623248842413446 328 | ] 329 | }, 330 | { 331 | "name": "Lakatamia", 332 | "position": [ 333 | 35.104605562013454, 334 | 33.32165535432561 335 | ] 336 | }, 337 | { 338 | "name": "Ercan", 339 | "position": [ 340 | 35.15514871796735, 341 | 33.50169458473209 342 | ] 343 | }, 344 | { 345 | "name": "Gecitkale", 346 | "position": [ 347 | 35.23601063290296, 348 | 33.720895058760995 349 | ] 350 | }, 351 | { 352 | "name": "Pinarbashi", 353 | "position": [ 354 | 35.27382948608504, 355 | 33.26823896322175 356 | ] 357 | }, 358 | { 359 | "name": "Naqoura", 360 | "position": [ 361 | 33.10790532692989, 362 | 35.12728573970673 363 | ] 364 | }, 365 | { 366 | "name": "H3", 367 | "position": [ 368 | 32.93619548302012, 369 | 39.745812982566044 370 | ] 371 | }, 372 | { 373 | "name": "H3 Northwest", 374 | "position": [ 375 | 33.07595556847254, 376 | 39.59657929992454 377 | ] 378 | }, 379 | { 380 | "name": "H3 Southwest", 381 | "position": [ 382 | 32.743448328585124, 383 | 39.60220528161408 384 | ] 385 | }, 386 | { 387 | "name": "Ruwayshid", 388 | "position": [ 389 | 32.40496683442857, 390 | 39.13051807076174 391 | ] 392 | }, 393 | { 394 | "name": "Sanliurfa", 395 | "position": [ 396 | 37.44807146384287, 397 | 38.89837835628554 398 | ] 399 | }, 400 | { 401 | "name": "Kharab Ishk", 402 | "position": [ 403 | 36.546920540732856, 404 | 38.587524391693464 405 | ] 406 | }, 407 | { 408 | "name": "Tal Siman", 409 | "position": [ 410 | 36.262421680177326, 411 | 38.928312545258585 412 | ] 413 | }, 414 | { 415 | "name": "At Tanf", 416 | "position": [ 417 | 33.506450284054054, 418 | 38.61488403210277 419 | ] 420 | } 421 | ], 422 | "projection": { 423 | "centralMeridian": 39, 424 | "falseEasting": 282801.00000003993, 425 | "falseNorthing": -3879865.9999999935, 426 | "scaleFactor": 0.9996 427 | } 428 | } -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr( 2 | all(not(debug_assertions), target_os = "windows"), 3 | windows_subsystem = "windows" 4 | )] 5 | 6 | use std::{ 7 | collections::{HashMap, HashSet, VecDeque}, 8 | time::Duration, 9 | }; 10 | 11 | use once_cell::sync::Lazy; 12 | use semver::Version; 13 | use serde::Serialize; 14 | use tacview_realtime_client::acmi::{ 15 | record::{ 16 | global_property::GlobalProperty, 17 | object_property::{Coords, ObjectProperty, Tag}, 18 | Record, 19 | }, 20 | Header, 21 | }; 22 | use tauri::{AppHandle, Manager}; 23 | use time::OffsetDateTime; 24 | use tokio::{ 25 | spawn, 26 | sync::Mutex, 27 | task::JoinHandle, 28 | time::{interval, sleep}, 29 | }; 30 | use tracing_subscriber::prelude::*; 31 | 32 | static TACVIEW_STATE: Mutex> = Mutex::const_new(None); 33 | static TACVIEW_READER_TASK_HANDLE: Mutex>> = Mutex::const_new(None); 34 | static TACVIEW_STATE_EMIT_TASK_HANDLE: Mutex>> = Mutex::const_new(None); 35 | 36 | const MAX_TRACK_LENGTH: usize = 30; 37 | 38 | /// In nautical miles 39 | fn get_range((lat1, lon1): (f64, f64), (lat2, lon2): (f64, f64)) -> f64 { 40 | const R: f64 = 6371.; 41 | let d_lat = (lat2 - lat1).to_radians(); 42 | let d_lon = (lon2 - lon1).to_radians(); 43 | let lat1_rad = lat1.to_radians(); 44 | let lat2_rad = lat2.to_radians(); 45 | 46 | let d_lat_half_sin = (d_lat / 2.).sin(); 47 | let d_lon_half_sin = (d_lon / 2.).sin(); 48 | 49 | let a = d_lat_half_sin * d_lat_half_sin 50 | + d_lon_half_sin * d_lon_half_sin * lat1_rad.cos() * lat2_rad.cos(); 51 | let c = 2. * a.sqrt().atan2((1. - a).sqrt()); 52 | let d = R * c; 53 | d * 0.539957 54 | } 55 | 56 | #[derive(Debug, Clone, Serialize, Default)] 57 | #[serde(rename_all = "camelCase")] 58 | struct TacviewGlobalProperties { 59 | #[serde(with = "time::serde::rfc3339::option")] 60 | reference_time: Option, 61 | author: Option, 62 | title: Option, 63 | comments: Option, 64 | reference_longitude: Option, 65 | reference_latitude: Option, 66 | } 67 | 68 | #[derive(Debug, Clone, Serialize, Default)] 69 | #[serde(rename_all = "camelCase")] 70 | struct TacviewObject { 71 | #[serde(skip)] 72 | tracks: VecDeque<(f64, Coords)>, 73 | 74 | /// in knot 75 | estimated_speed: f64, 76 | /// in 1000 feet per minute 77 | estimated_altitude_rate: f64, 78 | 79 | coords: Option, 80 | name: Option, 81 | #[serde(rename = "type")] 82 | ty: Option>, 83 | callsign: Option, 84 | pilot: Option, 85 | group: Option, 86 | coalition: Option, 87 | } 88 | 89 | impl TacviewObject { 90 | fn update_estimated_speed(&mut self, reference_latitude: f64, reference_longitude: f64) { 91 | if self.tracks.len() < 2 { 92 | self.estimated_speed = -1.; 93 | return; 94 | } 95 | 96 | let first_track = self.tracks.front().unwrap(); 97 | let last_track = self.tracks.back().unwrap(); 98 | 99 | let seconds = first_track.0 - last_track.0; 100 | let range = get_range( 101 | ( 102 | reference_latitude + first_track.1.latitude.unwrap(), 103 | reference_longitude + first_track.1.longitude.unwrap(), 104 | ), 105 | ( 106 | reference_latitude + last_track.1.latitude.unwrap(), 107 | reference_longitude + last_track.1.longitude.unwrap(), 108 | ), 109 | ); 110 | 111 | self.estimated_speed = range / seconds * 3600.; 112 | } 113 | 114 | fn update_estimated_altitude_rate(&mut self) { 115 | if self.tracks.len() < 2 { 116 | self.estimated_altitude_rate = 0.; 117 | return; 118 | } 119 | 120 | let first_track = self.tracks.front().unwrap(); 121 | let last_track = self.tracks.back().unwrap(); 122 | 123 | let seconds = first_track.0 - last_track.0; 124 | let altitude_delta = first_track.1.altitude.unwrap() - last_track.1.altitude.unwrap(); 125 | 126 | self.estimated_altitude_rate = altitude_delta * 3.28084 / 1000. / seconds * 60.; 127 | } 128 | } 129 | 130 | #[derive(Debug, Clone, Serialize)] 131 | #[serde(rename_all = "camelCase")] 132 | struct TacviewState { 133 | header: Header, 134 | global_properties: TacviewGlobalProperties, 135 | objects: HashMap, 136 | current_timeframe: Option, 137 | blue_bullseye: Option, 138 | red_bullseye: Option, 139 | } 140 | 141 | impl TacviewState { 142 | fn new(header: Header) -> Self { 143 | Self { 144 | header, 145 | global_properties: Default::default(), 146 | objects: HashMap::new(), 147 | current_timeframe: None, 148 | blue_bullseye: None, 149 | red_bullseye: None, 150 | } 151 | } 152 | 153 | fn update(&mut self, record: Record) { 154 | match record { 155 | Record::Remove(id) => { 156 | self.objects.remove(&id); 157 | } 158 | Record::Frame(timeframe) => { 159 | self.current_timeframe = Some(timeframe); 160 | } 161 | Record::Event(_) => { 162 | // TODO: 163 | } 164 | Record::GlobalProperties(global_properties) => { 165 | for global_property in global_properties { 166 | match global_property { 167 | GlobalProperty::ReferenceTime(time) => { 168 | self.global_properties.reference_time = Some(time); 169 | } 170 | GlobalProperty::Author(author) => { 171 | self.global_properties.author = Some(author); 172 | } 173 | GlobalProperty::Title(title) => { 174 | self.global_properties.title = Some(title); 175 | } 176 | GlobalProperty::Comments(comments) => { 177 | self.global_properties.comments = Some(comments); 178 | } 179 | GlobalProperty::ReferenceLongitude(longitude) => { 180 | // When ReferenceLongitude occured, assume new connection was made. 181 | self.objects.clear(); 182 | self.global_properties.reference_longitude = Some(longitude); 183 | } 184 | GlobalProperty::ReferenceLatitude(latitude) => { 185 | // When ReferenceLatitude occured, assume new connection was made. 186 | self.objects.clear(); 187 | self.global_properties.reference_latitude = Some(latitude); 188 | } 189 | _ => {} 190 | } 191 | } 192 | } 193 | Record::Update(id, object_properties) => { 194 | let object = self.objects.entry(id).or_default(); 195 | for object_property in object_properties { 196 | match object_property { 197 | ObjectProperty::T(coords) => { 198 | let object_coords = object.coords.get_or_insert_with(Default::default); 199 | object_coords.update(&coords); 200 | if let Some(timeframe) = self.current_timeframe { 201 | if object 202 | .ty 203 | .as_ref() 204 | .map(|ty| ty.contains(&Tag::Air) || ty.contains(&Tag::Missile)) 205 | .unwrap_or(false) 206 | && object_coords.latitude.is_some() 207 | && object_coords.longitude.is_some() 208 | && object_coords.altitude.is_some() 209 | { 210 | object.tracks.push_front((timeframe, object_coords.clone())); 211 | object.tracks.truncate(MAX_TRACK_LENGTH); 212 | if let Some(reference_latitude) = 213 | self.global_properties.reference_latitude 214 | { 215 | if let Some(reference_longitude) = 216 | self.global_properties.reference_longitude 217 | { 218 | object.update_estimated_speed( 219 | reference_latitude, 220 | reference_longitude, 221 | ); 222 | } 223 | } 224 | object.update_estimated_altitude_rate(); 225 | } 226 | } 227 | } 228 | ObjectProperty::Name(value) => { 229 | object.name = Some(value); 230 | } 231 | ObjectProperty::Type(value) => { 232 | object.ty = Some(value); 233 | } 234 | ObjectProperty::Callsign(value) => { 235 | object.callsign = Some(value); 236 | } 237 | ObjectProperty::Pilot(value) => { 238 | object.pilot = Some(value); 239 | } 240 | ObjectProperty::Group(value) => { 241 | object.group = Some(value); 242 | } 243 | ObjectProperty::Coalition(value) => { 244 | object.coalition = Some(value); 245 | } 246 | _ => {} 247 | } 248 | } 249 | if let Some(ty) = &object.ty { 250 | if ty.contains(&Tag::Bullseye) { 251 | if object.coalition == Some("Enemies".to_string()) { 252 | self.blue_bullseye = Some(object.clone()); 253 | } else if object.coalition == Some("Allies".to_string()) { 254 | self.red_bullseye = Some(object.clone()); 255 | } 256 | } 257 | } 258 | } 259 | } 260 | } 261 | } 262 | 263 | async fn tacview_reader_task( 264 | app: AppHandle, 265 | host: String, 266 | port: u16, 267 | username: String, 268 | password: String, 269 | ) { 270 | loop { 271 | let mut reader = 272 | match tacview_realtime_client::connect((host.as_str(), port), &username, &password) 273 | .await 274 | { 275 | Ok(reader) => reader, 276 | Err(error) => { 277 | tracing::error!(%error, "failed to connect"); 278 | if let Err(error) = 279 | app.emit_all("error", format!("failed to connect: {}", error)) 280 | { 281 | tracing::error!(%error, "failed to emit error"); 282 | } 283 | sleep(Duration::from_secs(5)).await; 284 | continue; 285 | } 286 | }; 287 | loop { 288 | match reader.next().await { 289 | Ok(record) => { 290 | let mut state = TACVIEW_STATE.lock().await; 291 | let state = 292 | state.get_or_insert_with(|| TacviewState::new(reader.header.clone())); 293 | state.update(record); 294 | } 295 | Err(error) => { 296 | tracing::error!(%error, "failed to read from server"); 297 | if let Err(error) = 298 | app.emit_all("error", format!("failed to read from server: {}", error)) 299 | { 300 | tracing::error!(%error, "failed to emit error"); 301 | } 302 | break; 303 | } 304 | } 305 | } 306 | sleep(Duration::from_secs(5)).await; 307 | } 308 | } 309 | 310 | async fn spawn_tacview_reader_task( 311 | app: AppHandle, 312 | host: String, 313 | port: u16, 314 | username: String, 315 | password: String, 316 | ) { 317 | let mut task_handle = TACVIEW_READER_TASK_HANDLE.lock().await; 318 | let new_handle = spawn(tacview_reader_task(app, host, port, username, password)); 319 | if let Some(task_handle) = task_handle.replace(new_handle) { 320 | task_handle.abort(); 321 | } 322 | } 323 | 324 | async fn tacview_state_emit_task(app: AppHandle) { 325 | let mut interval = interval(Duration::from_secs(2)); 326 | loop { 327 | interval.tick().await; 328 | let state = TACVIEW_STATE.lock().await; 329 | if let Some(state) = &*state { 330 | if let Err(error) = app.emit_all("tacview-state", &state) { 331 | tracing::error!(%error, "failed to emit tacview state"); 332 | } 333 | } 334 | } 335 | } 336 | 337 | async fn spawn_tacview_state_emit_task(app: AppHandle) { 338 | let mut task_handle = TACVIEW_STATE_EMIT_TASK_HANDLE.lock().await; 339 | let new_handle = spawn(tacview_state_emit_task(app)); 340 | if let Some(task_handle) = task_handle.replace(new_handle) { 341 | task_handle.abort(); 342 | } 343 | } 344 | 345 | #[tauri::command] 346 | async fn connect(app: AppHandle, host: String, port: u16, username: String, password: String) { 347 | spawn_tacview_reader_task(app.clone(), host, port, username, password).await; 348 | spawn_tacview_state_emit_task(app).await; 349 | } 350 | 351 | #[tauri::command] 352 | async fn disconnect() { 353 | let reader_task_handle = TACVIEW_READER_TASK_HANDLE.lock().await; 354 | if let Some(task_handle) = &*reader_task_handle { 355 | task_handle.abort(); 356 | } 357 | let emit_task_handle = TACVIEW_STATE_EMIT_TASK_HANDLE.lock().await; 358 | if let Some(task_handle) = &*emit_task_handle { 359 | task_handle.abort(); 360 | } 361 | tokio::task::yield_now().await; 362 | let mut state = TACVIEW_STATE.lock().await; 363 | *state = None; 364 | } 365 | 366 | #[tauri::command] 367 | fn get_current_version() -> &'static str { 368 | env!("CARGO_PKG_VERSION") 369 | } 370 | 371 | #[tauri::command] 372 | async fn check_new_version() -> bool { 373 | static HTTP_CLIENT: Lazy = Lazy::new(|| { 374 | reqwest::Client::builder() 375 | .redirect(reqwest::redirect::Policy::none()) 376 | .build() 377 | .expect("failed to build HTTP client") 378 | }); 379 | if let Ok(current) = Version::parse(env!("CARGO_PKG_VERSION")) { 380 | if let Ok(latest_response) = HTTP_CLIENT 381 | .get("https://github.com/pbzweihander/peace-eye/releases/latest") 382 | .send() 383 | .await 384 | { 385 | if (300u16..400).contains(&latest_response.status().as_u16()) { 386 | if let Some(location) = latest_response.headers().get("location") { 387 | if let Ok(location) = location.to_str() { 388 | if let Some((_, version)) = location.rsplit_once('v') { 389 | if let Ok(new_version) = Version::parse(version) { 390 | return current < new_version; 391 | } 392 | } 393 | } 394 | } 395 | } 396 | } 397 | } 398 | false 399 | } 400 | 401 | fn main() { 402 | tracing_subscriber::registry() 403 | .with(tracing_subscriber::EnvFilter::new( 404 | std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), 405 | )) 406 | .with(tracing_subscriber::fmt::layer()) 407 | .init(); 408 | 409 | tauri::Builder::default() 410 | .invoke_handler(tauri::generate_handler![ 411 | connect, 412 | disconnect, 413 | get_current_version, 414 | check_new_version, 415 | ]) 416 | .run(tauri::generate_context!()) 417 | .expect("error while running tauri application"); 418 | } 419 | -------------------------------------------------------------------------------- /src/MainView.tsx: -------------------------------------------------------------------------------- 1 | import { invoke } from "@tauri-apps/api"; 2 | import { listen } from "@tauri-apps/api/event"; 3 | import circle from "@turf/circle"; 4 | import { type Feature, type FeatureCollection } from "geojson"; 5 | import geomagnetism from "geomagnetism"; 6 | import maplibregl from "maplibre-gl"; 7 | import { 8 | useCallback, 9 | useEffect, 10 | useMemo, 11 | useState, 12 | type ReactElement, 13 | } from "react"; 14 | import Map, { AttributionControl, Layer, Source } from "react-map-gl"; 15 | import { useNavigate } from "react-router-dom"; 16 | 17 | import AirportMarker from "./AirportMarker"; 18 | import BraaInfo from "./BraaInfo"; 19 | import ControlPanel from "./ControlPanel"; 20 | import CursorInfo from "./CursorInfo"; 21 | import ObjectInfo from "./ObjectInfo"; 22 | import ObjectMarker from "./ObjectMarker"; 23 | import SettingsModal from "./SettingsModal"; 24 | import Spinner from "./Spinner"; 25 | import { getTerrainFromReferencePoint } from "./dcs/terrain"; 26 | import { colorMode, filterObject } from "./entity"; 27 | import { 28 | defaultObjectSettings, 29 | type ObjectSettings, 30 | type ObjectSettingsInventory, 31 | } from "./objectSettings"; 32 | import { defaultSettings } from "./settings"; 33 | import { 34 | type TacviewState, 35 | newTacviewState, 36 | type TacviewObject, 37 | } from "./tacview"; 38 | import { moveCoords, nmToMeter } from "./util"; 39 | 40 | export default function MainView(): ReactElement { 41 | const navigate = useNavigate(); 42 | const [state, setState] = useState(newTacviewState()); 43 | const [settings, setSettings] = useState(defaultSettings()); 44 | const [objectSettingsInventory, setObjectSettingsInventory] = 45 | useState({}); 46 | const [cursorCoords, setCursorCoords] = useState<[number, number]>([0, 0]); 47 | const [selectedObjectId, setSelectedObjectId] = useState( 48 | undefined 49 | ); 50 | const [selectedAirportIndex, setSelectedAirportIndex] = useState< 51 | number | undefined 52 | >(undefined); 53 | const [rulerStartCoords, setRulerStartCoords] = useState< 54 | [number, number] | undefined 55 | >(undefined); 56 | 57 | const geomagnetismModel = useMemo(() => { 58 | if (state.globalProperties.referenceTime != null) { 59 | let model; 60 | try { 61 | model = geomagnetism.model( 62 | new Date(state.globalProperties.referenceTime) 63 | ); 64 | } catch (e) { 65 | console.log(e); 66 | model = geomagnetism.model(); 67 | } 68 | return model; 69 | } else { 70 | return geomagnetism.model(); 71 | } 72 | }, [state.globalProperties.referenceTime]); 73 | 74 | const onDisconnect = useCallback(async () => { 75 | setState(newTacviewState()); 76 | await invoke("disconnect"); 77 | navigate("/"); 78 | }, []); 79 | 80 | // Spawn tacview-state event listener 81 | useEffect(() => { 82 | const unlisten = listen("tacview-state", (event) => { 83 | setState(event.payload); 84 | }); 85 | 86 | return () => { 87 | unlisten 88 | .then((f) => { 89 | f(); 90 | }) 91 | .catch((error) => { 92 | console.log(error); 93 | }); 94 | }; 95 | }, []); 96 | 97 | const referenceLatitude = state.globalProperties.referenceLatitude; 98 | const referenceLongitude = state.globalProperties.referenceLongitude; 99 | 100 | const terrain = 101 | referenceLatitude !== undefined && referenceLongitude !== undefined 102 | ? getTerrainFromReferencePoint(referenceLatitude, referenceLongitude) 103 | : undefined; 104 | 105 | // TODO: Config coalition for bullseye 106 | const ownedBullseye = state.blueBullseye; 107 | const bullseyeCoords: [number, number] | undefined = 108 | referenceLatitude !== undefined && 109 | referenceLongitude !== undefined && 110 | ownedBullseye?.coords?.latitude !== undefined && 111 | ownedBullseye?.coords?.longitude !== undefined 112 | ? [ 113 | referenceLatitude + ownedBullseye.coords.latitude, 114 | referenceLongitude + ownedBullseye.coords.longitude, 115 | ] 116 | : undefined; 117 | 118 | // Populate GeoJson data for ruler 119 | const rulerGeoJson: FeatureCollection = useMemo(() => { 120 | if (rulerStartCoords === undefined) { 121 | return { 122 | type: "FeatureCollection", 123 | features: [], 124 | }; 125 | } 126 | 127 | return { 128 | type: "FeatureCollection", 129 | features: [ 130 | { 131 | type: "Feature", 132 | properties: [], 133 | geometry: { 134 | type: "LineString", 135 | coordinates: [ 136 | [rulerStartCoords[1], rulerStartCoords[0]], 137 | [cursorCoords[1], cursorCoords[0]], 138 | ], 139 | }, 140 | }, 141 | ], 142 | }; 143 | }, [rulerStartCoords, cursorCoords]); 144 | 145 | // Populate GeoJson for track vector line 146 | const trackVectorLineGeoJson: FeatureCollection = useMemo(() => { 147 | if (referenceLatitude === undefined || referenceLongitude === undefined) { 148 | return { 149 | type: "FeatureCollection", 150 | features: [], 151 | }; 152 | } 153 | 154 | return { 155 | type: "FeatureCollection", 156 | features: Object.values(state.objects) 157 | .filter( 158 | (object) => 159 | filterObject(object, settings) && 160 | (object.type?.includes("Air") === true || 161 | object.type?.includes("Missile") === true) && 162 | object.coords?.latitude !== undefined && 163 | object.coords?.longitude !== undefined && 164 | object.coords?.heading !== undefined 165 | ) 166 | .map((object) => { 167 | const endCoords = moveCoords( 168 | referenceLatitude + object.coords!.latitude!, 169 | referenceLongitude + object.coords!.longitude!, 170 | object.coords!.heading!, 171 | // knot -> meter per second -> 1 minute 172 | object.estimatedSpeed * 0.514444 * 60 173 | ); 174 | return { 175 | type: "Feature", 176 | properties: { 177 | coalition: object.coalition, 178 | }, 179 | geometry: { 180 | type: "LineString", 181 | coordinates: [ 182 | [ 183 | referenceLongitude + object.coords!.longitude!, 184 | referenceLatitude + object.coords!.latitude!, 185 | ], 186 | [endCoords[1], endCoords[0]], 187 | ], 188 | }, 189 | }; 190 | }), 191 | }; 192 | }, [state.objects]); 193 | 194 | // Populate GeoJson data for warning/threat range 195 | // Note that because of limitation of map-gl, this is actually group of lines instead of a circle 196 | const rangeGeoJson: FeatureCollection = useMemo((): FeatureCollection => { 197 | if (referenceLatitude === undefined || referenceLongitude === undefined) { 198 | return { 199 | type: "FeatureCollection", 200 | features: [], 201 | }; 202 | } 203 | 204 | return { 205 | type: "FeatureCollection", 206 | features: Object.entries(objectSettingsInventory) 207 | .map(([id, objectSettings]): [ObjectSettings, TacviewObject] => { 208 | const object = state.objects[Number(id)]; 209 | return [objectSettings, object]; 210 | }) 211 | .filter( 212 | ([_objectSettings, object]) => 213 | object?.coords?.latitude !== undefined && 214 | object?.coords?.longitude !== undefined 215 | ) 216 | .map(([objectSettings, object]): Feature[] => { 217 | const coords = [ 218 | referenceLongitude + object.coords!.longitude!, 219 | referenceLatitude + object.coords!.latitude!, 220 | ]; 221 | const ret: Feature[] = []; 222 | 223 | if (objectSettings.warningRange > 0) { 224 | ret.push( 225 | circle(coords, nmToMeter(objectSettings.warningRange) / 1000.0, { 226 | steps: 50, 227 | units: "kilometers", 228 | properties: { type: "warning" }, 229 | }) 230 | ); 231 | } 232 | if (objectSettings.threatRange > 0) { 233 | ret.push( 234 | circle(coords, nmToMeter(objectSettings.threatRange) / 1000.0, { 235 | steps: 50, 236 | units: "kilometers", 237 | properties: { type: "threat" }, 238 | }) 239 | ); 240 | } 241 | 242 | return ret; 243 | }) 244 | .flat(), 245 | }; 246 | }, [state.objects, objectSettingsInventory]); 247 | 248 | const watchingObjects = useMemo(() => { 249 | return Object.entries(objectSettingsInventory) 250 | .map( 251 | ([id, objectSettingsInventory]): [ 252 | number, 253 | TacviewObject | undefined 254 | ] => { 255 | const nid = Number(id); 256 | if (objectSettingsInventory.watch) { 257 | return [nid, state.objects[nid]]; 258 | } else { 259 | return [nid, undefined]; 260 | } 261 | } 262 | ) 263 | .filter(([_id, object]) => object !== undefined) 264 | .map(([id, object]): [number, TacviewObject] => [id, object!]); 265 | }, [state.objects, objectSettingsInventory]); 266 | 267 | if (referenceLatitude === undefined || referenceLongitude === undefined) { 268 | // Display loading screen 269 | return ( 270 |
271 |
272 | 273 |
274 |
275 | 283 |
284 |
285 | ); 286 | } 287 | 288 | if (terrain === undefined) { 289 | // Display error screen 290 | return ( 291 |
292 | Cannot find terrain from reference point ({referenceLatitude},{" "} 293 | {referenceLongitude}). 294 |
295 | This is a bug.
296 | Please contact to pbzweihander@gmail.com 297 |
298 | ); 299 | } 300 | 301 | const initalViewState = { 302 | latitude: terrain.center[0], 303 | longitude: terrain.center[1], 304 | zoom: 6, 305 | }; 306 | 307 | // Entity = Object + Airport 308 | let selectedEntity: TacviewObject | undefined; 309 | let isObjectSelected = false; 310 | if (selectedObjectId !== undefined) { 311 | selectedEntity = state.objects[selectedObjectId]; 312 | isObjectSelected = true; 313 | } else if (selectedAirportIndex !== undefined) { 314 | const airport = terrain.airports[selectedAirportIndex]; 315 | selectedEntity = { 316 | estimatedSpeed: 0, 317 | estimatedAltitudeRate: 0, 318 | coords: { 319 | latitude: airport.position[0] - referenceLatitude, 320 | longitude: airport.position[1] - referenceLongitude, 321 | }, 322 | name: airport.name, 323 | }; 324 | } 325 | 326 | return ( 327 | <> 328 | { 338 | setCursorCoords([e.lngLat.lat, e.lngLat.lng]); 339 | }} 340 | onMouseDown={(e) => { 341 | if (e.originalEvent.button === 2) { 342 | e.preventDefault(); 343 | setRulerStartCoords([e.lngLat.lat, e.lngLat.lng]); 344 | } 345 | }} 346 | onMouseUp={(e) => { 347 | if (e.originalEvent.button === 2) { 348 | e.preventDefault(); 349 | setRulerStartCoords(undefined); 350 | } 351 | }} 352 | > 353 | 354 |
355 | {selectedEntity !== undefined && ( 356 | { 362 | setSelectedObjectId(undefined); 363 | setSelectedAirportIndex(undefined); 364 | }} 365 | objectSettings={ 366 | isObjectSelected 367 | ? objectSettingsInventory[selectedObjectId!] ?? 368 | defaultObjectSettings() 369 | : undefined 370 | } 371 | setObjectSettings={ 372 | isObjectSelected 373 | ? (objectSettings) => { 374 | setObjectSettingsInventory((objectSettingsInventory) => { 375 | objectSettingsInventory[selectedObjectId!] = 376 | objectSettings; 377 | return { ...objectSettingsInventory }; 378 | }); 379 | } 380 | : undefined 381 | } 382 | terrain={terrain} 383 | geomagnetismModel={geomagnetismModel} 384 | useMagneticHeading={settings.view.useMagneticHeading} 385 | /> 386 | )} 387 |
388 |
389 | [ 391 | Number(id), 392 | object, 393 | ])} 394 | watchingObjects={watchingObjects} 395 | onObjectClick={(id) => { 396 | setSelectedObjectId(id); 397 | }} 398 | /> 399 |
400 | 408 | 409 | 414 | 415 | 420 | 436 | 437 | 438 | 455 | 456 | {rulerStartCoords !== undefined && ( 457 | 464 | )} 465 | {terrain.airports.map((airport, idx) => ( 466 | { 471 | setSelectedObjectId(undefined); 472 | setSelectedAirportIndex(idx); 473 | }} 474 | /> 475 | ))} 476 | {Object.entries(state.objects) 477 | .filter( 478 | ([id, object]) => 479 | selectedObjectId === Number(id) || filterObject(object, settings) 480 | ) 481 | .map(([id, object]) => { 482 | return ( 483 | { 490 | setSelectedObjectId(Number(id)); 491 | setSelectedAirportIndex(undefined); 492 | }} 493 | /> 494 | ); 495 | })} 496 |
497 | { 500 | setSettings({ ...settings }); 501 | }} 502 | onDisconnect={onDisconnect} 503 | /> 504 | 505 | ); 506 | } 507 | --------------------------------------------------------------------------------