├── src ├── Constants.ts ├── react-app-env.d.ts ├── sounds │ └── warning-beep.mp3 ├── worker.ts ├── types │ ├── alltypes.d.ts │ ├── airbase.ts │ └── entity.ts ├── mgrs.d.ts ├── dcs │ ├── maps │ │ ├── Marianas.ts │ │ ├── Kola.ts │ │ ├── Sinai.ts │ │ ├── Syria.ts │ │ ├── Nevada.ts │ │ ├── Afghanistan.ts │ │ ├── Normandy.ts │ │ ├── Caucasus.ts │ │ ├── Falklands.ts │ │ ├── TheChannel.ts │ │ ├── PersianGulf.ts │ │ └── DCSMap.ts │ └── aircraft.ts ├── index.css ├── index.tsx ├── stores │ ├── HackStore.tsx │ ├── SettingsStore.tsx │ ├── EntityMetadataStore.tsx │ ├── ProfileStore.tsx │ ├── ServerStore.tsx │ ├── TrackStore.tsx │ └── AlertStore.tsx ├── index.html ├── hooks │ ├── useKeyPress.tsx │ ├── useRenderCombatZones.tsx │ └── useRenderGroundUnits.tsx ├── components │ ├── MapIcon.tsx │ ├── ScratchPad.tsx │ ├── DetailedCoords.tsx │ ├── ProfileTagList.tsx │ ├── MissionTimer.tsx │ ├── QuestConsoleTab.tsx │ ├── ProfileSettings.tsx │ ├── MapSettings.tsx │ ├── MapEntity.tsx │ └── DrawConsoleTab.tsx ├── DCSBattlegroundClient.ts ├── tacview │ └── client.go ├── data │ └── airbases │ │ ├── marianaislands.json │ │ ├── kola.json │ │ ├── afghanistan.json │ │ ├── thechannel.json │ │ ├── nevada.json │ │ └── caucasus.json ├── DataSaver.ts ├── App.tsx └── util.tsx ├── dist ├── .gitignore ├── favicon.ico ├── Dot Cross.cur ├── connected.png ├── notconnected.png ├── images │ └── control │ │ ├── 1.png │ │ ├── 2.png │ │ ├── 3.png │ │ ├── 5.png │ │ ├── 2_2.png │ │ ├── 5_1.png │ │ ├── kedu.png │ │ ├── close-2.png │ │ ├── ico-dot.png │ │ ├── layer.png │ │ ├── infownd-close.png │ │ └── infownd-close-hover.png └── Map-Marker-Ball-Chartreuse-icon.png ├── winres ├── icon.png ├── icon16.png └── winres.json ├── rsrc_windows_386.syso ├── rsrc_windows_amd64.syso ├── assets.go ├── postcss.config.js ├── .ci ├── init.ts └── entrypoint.ts ├── .gitignore ├── tailwind.config.js ├── dep └── maptalks │ ├── README.md │ ├── package.json │ └── LICENSE ├── server ├── postgres_client.go ├── tacview_client.go ├── config.go ├── static.go └── state.go ├── tsconfig.json ├── go.mod ├── cmd └── dcsbg-server │ └── main.go ├── webpack.config.js ├── example.config.json ├── docs └── API.md ├── tacview └── client.go ├── package.json ├── README.md └── go.sum /src/Constants.ts: -------------------------------------------------------------------------------- 1 | export const FONT_FAMILY = "arial"; 2 | -------------------------------------------------------------------------------- /dist/.gitignore: -------------------------------------------------------------------------------- 1 | /index.html 2 | /*.css 3 | /*.js 4 | /*.txt 5 | /*.mp3 6 | -------------------------------------------------------------------------------- /dist/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Frigondin/DCSBattleground/HEAD/dist/favicon.ico -------------------------------------------------------------------------------- /winres/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Frigondin/DCSBattleground/HEAD/winres/icon.png -------------------------------------------------------------------------------- /dist/Dot Cross.cur: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Frigondin/DCSBattleground/HEAD/dist/Dot Cross.cur -------------------------------------------------------------------------------- /dist/connected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Frigondin/DCSBattleground/HEAD/dist/connected.png -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare module "*.mp3"; 3 | -------------------------------------------------------------------------------- /winres/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Frigondin/DCSBattleground/HEAD/winres/icon16.png -------------------------------------------------------------------------------- /dist/notconnected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Frigondin/DCSBattleground/HEAD/dist/notconnected.png -------------------------------------------------------------------------------- /rsrc_windows_386.syso: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Frigondin/DCSBattleground/HEAD/rsrc_windows_386.syso -------------------------------------------------------------------------------- /rsrc_windows_amd64.syso: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Frigondin/DCSBattleground/HEAD/rsrc_windows_amd64.syso -------------------------------------------------------------------------------- /dist/images/control/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Frigondin/DCSBattleground/HEAD/dist/images/control/1.png -------------------------------------------------------------------------------- /dist/images/control/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Frigondin/DCSBattleground/HEAD/dist/images/control/2.png -------------------------------------------------------------------------------- /dist/images/control/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Frigondin/DCSBattleground/HEAD/dist/images/control/3.png -------------------------------------------------------------------------------- /dist/images/control/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Frigondin/DCSBattleground/HEAD/dist/images/control/5.png -------------------------------------------------------------------------------- /assets.go: -------------------------------------------------------------------------------- 1 | package sneaker 2 | 3 | import ( 4 | "embed" 5 | ) 6 | 7 | //go:embed dist/* 8 | var Static embed.FS 9 | -------------------------------------------------------------------------------- /dist/images/control/2_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Frigondin/DCSBattleground/HEAD/dist/images/control/2_2.png -------------------------------------------------------------------------------- /dist/images/control/5_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Frigondin/DCSBattleground/HEAD/dist/images/control/5_1.png -------------------------------------------------------------------------------- /dist/images/control/kedu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Frigondin/DCSBattleground/HEAD/dist/images/control/kedu.png -------------------------------------------------------------------------------- /src/sounds/warning-beep.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Frigondin/DCSBattleground/HEAD/src/sounds/warning-beep.mp3 -------------------------------------------------------------------------------- /dist/images/control/close-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Frigondin/DCSBattleground/HEAD/dist/images/control/close-2.png -------------------------------------------------------------------------------- /dist/images/control/ico-dot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Frigondin/DCSBattleground/HEAD/dist/images/control/ico-dot.png -------------------------------------------------------------------------------- /dist/images/control/layer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Frigondin/DCSBattleground/HEAD/dist/images/control/layer.png -------------------------------------------------------------------------------- /dist/images/control/infownd-close.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Frigondin/DCSBattleground/HEAD/dist/images/control/infownd-close.png -------------------------------------------------------------------------------- /dist/Map-Marker-Ball-Chartreuse-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Frigondin/DCSBattleground/HEAD/dist/Map-Marker-Ball-Chartreuse-icon.png -------------------------------------------------------------------------------- /src/worker.ts: -------------------------------------------------------------------------------- 1 | const ctx: Worker = self as any; 2 | 3 | ctx.onmessage = ({ data }) => { 4 | console.log("Worker data: ", data); 5 | }; 6 | -------------------------------------------------------------------------------- /dist/images/control/infownd-close-hover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Frigondin/DCSBattleground/HEAD/dist/images/control/infownd-close-hover.png -------------------------------------------------------------------------------- /src/types/alltypes.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-rounded-image'; 2 | declare module 'maptalks.animatemarker'; 3 | //declare module 'maptalks'; 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/mgrs.d.ts: -------------------------------------------------------------------------------- 1 | declare module "mgrs" { 2 | export function forward(ll: [number, number], accuracy?: number): string; 3 | export function toPoint(mgrs: string): [number, number]; 4 | } 5 | -------------------------------------------------------------------------------- /src/dcs/maps/Marianas.ts: -------------------------------------------------------------------------------- 1 | import { DCSMap } from "./DCSMap"; 2 | 3 | export const Marianas: DCSMap = { 4 | name: "Marianas", 5 | center: [16.21, 145.40], 6 | magDec: 0, 7 | airports: [], 8 | }; 9 | -------------------------------------------------------------------------------- /src/dcs/maps/Kola.ts: -------------------------------------------------------------------------------- 1 | import KolaAirBases from "../../data/airbases/kola.json"; 2 | import { convertRawAirBaseData, DCSMap } from "./DCSMap"; 3 | 4 | export const Kola: DCSMap = { 5 | name: "Kola", 6 | center: [67, 26], 7 | magDec: 13, 8 | airports: convertRawAirBaseData(KolaAirBases), 9 | }; -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "postcss-fontpath": { checkFiles: true, ie8Fix: true }, 4 | tailwindcss: "tailwind.config.js", 5 | //'@fullhuman/postcss-purgecss': process.env.NODE_ENV === 'development', 6 | autoprefixer: {}, 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /src/dcs/maps/Sinai.ts: -------------------------------------------------------------------------------- 1 | import SinaiAirBases from "../../data/airbases/sinaimap.json"; 2 | import { convertRawAirBaseData, DCSMap } from "./DCSMap"; 3 | 4 | export const Sinai: DCSMap = { 5 | name: "Sinai", 6 | center: [30, 32], 7 | magDec: 5, 8 | airports: convertRawAirBaseData(SinaiAirBases), 9 | }; 10 | -------------------------------------------------------------------------------- /src/dcs/maps/Syria.ts: -------------------------------------------------------------------------------- 1 | import SyriaAirBases from "../../data/airbases/syria.json"; 2 | import { convertRawAirBaseData, DCSMap } from "./DCSMap"; 3 | 4 | export const Syria: DCSMap = { 5 | name: "Syria", 6 | center: [35.57, 35.69], 7 | magDec: 5, 8 | airports: convertRawAirBaseData(SyriaAirBases), 9 | }; 10 | -------------------------------------------------------------------------------- /src/dcs/maps/Nevada.ts: -------------------------------------------------------------------------------- 1 | import NevadaAirBases from "../../data/airbases/nevada.json"; 2 | import { convertRawAirBaseData, DCSMap } from "./DCSMap"; 3 | 4 | export const Nevada: DCSMap = { 5 | name: "Nevada", 6 | center: [37.5, -115.15], 7 | magDec: 11, 8 | airports: convertRawAirBaseData(NevadaAirBases), 9 | }; 10 | -------------------------------------------------------------------------------- /src/dcs/maps/Afghanistan.ts: -------------------------------------------------------------------------------- 1 | import AfghanistanAirBases from "../../data/airbases/afghanistan.json"; 2 | import { convertRawAirBaseData, DCSMap } from "./DCSMap"; 3 | 4 | export const Afghanistan: DCSMap = { 5 | name: "Afghanistan", 6 | center: [33, 64], 7 | magDec: 3, 8 | airports: convertRawAirBaseData(AfghanistanAirBases), 9 | }; -------------------------------------------------------------------------------- /src/dcs/maps/Normandy.ts: -------------------------------------------------------------------------------- 1 | import NormandyAirBases from "../../data/airbases/normandy.json"; 2 | import { convertRawAirBaseData, DCSMap } from "./DCSMap"; 3 | 4 | export const Normandy: DCSMap = { 5 | name: "Normandy", 6 | center: [50.12, 0.3], 7 | magDec: 1, 8 | airports: convertRawAirBaseData(NormandyAirBases), 9 | }; 10 | -------------------------------------------------------------------------------- /src/dcs/maps/Caucasus.ts: -------------------------------------------------------------------------------- 1 | import CaucasusAirBases from "../../data/airbases/caucasus.json"; 2 | import { convertRawAirBaseData, DCSMap } from "./DCSMap"; 3 | 4 | export const Caucasus: DCSMap = { 5 | name: "Caucasus", 6 | center: [43.53, 41.11], 7 | magDec: -6, 8 | airports: convertRawAirBaseData(CaucasusAirBases), 9 | }; 10 | -------------------------------------------------------------------------------- /.ci/init.ts: -------------------------------------------------------------------------------- 1 | import { GithubCheckRunPlugin } from "pkg/buildy/github@1/plugins.ts"; 2 | import { registerPlugin, Workspace } from "runtime/core.ts"; 3 | 4 | export async function setup(ws: Workspace) { 5 | registerPlugin( 6 | new GithubCheckRunPlugin({ 7 | repositorySlug: "b1naryth1ef/sneaker", 8 | }), 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/dcs/maps/Falklands.ts: -------------------------------------------------------------------------------- 1 | import FalklandsAirBases from "../../data/airbases/falklands.json"; 2 | import { convertRawAirBaseData, DCSMap } from "./DCSMap"; 3 | 4 | export const Falklands: DCSMap = { 5 | name: "Falklands", 6 | center: [-52.05, -64.42], 7 | magDec: 6, 8 | airports: convertRawAirBaseData(FalklandsAirBases), 9 | }; 10 | -------------------------------------------------------------------------------- /src/dcs/maps/TheChannel.ts: -------------------------------------------------------------------------------- 1 | import TheChannelAirBases from "../../data/airbases/thechannel.json"; 2 | import { convertRawAirBaseData, DCSMap } from "./DCSMap"; 3 | 4 | export const TheChannel: DCSMap = { 5 | name: "TheChannel", 6 | center: [50.52, 1.35], 7 | magDec: 1, 8 | airports: convertRawAirBaseData(TheChannelAirBases), 9 | }; 10 | -------------------------------------------------------------------------------- /src/dcs/maps/PersianGulf.ts: -------------------------------------------------------------------------------- 1 | import PersianGulfAirBases from "../../data/airbases/persiangulf.json"; 2 | import { convertRawAirBaseData, DCSMap } from "./DCSMap"; 3 | 4 | export const PersianGulf: DCSMap = { 5 | name: "Persian Gulf", 6 | center: [26.10, 55.48], 7 | magDec: 2, 8 | airports: convertRawAirBaseData(PersianGulfAirBases), 9 | }; 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /.pnp 3 | .pnp.js 4 | /build 5 | .DS_Store 6 | .env.local 7 | .env.development.local 8 | .env.test.local 9 | .env.production.local 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | /scratch.txt 14 | /config.json 15 | /DCSBattleground.exe 16 | /DCSBattleground 17 | /sneaker-server 18 | /state.json 19 | *.bak 20 | *.exe -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* Chrome, Safari and Opera */ 6 | .no-scrollbar::-webkit-scrollbar { 7 | display: none; 8 | } 9 | 10 | .no-scrollbar { 11 | -ms-overflow-style: none; /* IE and Edge */ 12 | scrollbar-width: none; /* Firefox */ 13 | } 14 | 15 | .ReactCollapse--collapse { 16 | transition: height 500ms; 17 | } -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { BrowserRouter } from "react-router-dom"; 4 | import App from "./App"; 5 | import { dataSaverLoop } from "./DataSaver"; 6 | import "./index.css"; 7 | 8 | setTimeout(dataSaverLoop, 5000); 9 | 10 | ReactDOM.render( 11 | 12 | 13 | , 14 | document.getElementById("root") 15 | ); 16 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | module.exports = { 3 | content: ["./src/**/*.{js,jsx,ts,tsx}", "./src/index.html"], 4 | safelist: { 5 | standard: [], 6 | }, 7 | darkMode: 'media', // or 'media' or 'class' 8 | theme: {}, 9 | variants: { 10 | extend: { 11 | "border-b": ["hover"], 12 | }, 13 | }, 14 | plugins: [ 15 | require('tailwind-scrollbar'), 16 | ], 17 | }; 18 | 19 | -------------------------------------------------------------------------------- /src/types/airbase.ts: -------------------------------------------------------------------------------- 1 | export type RawRunwayData = { 2 | Name: number; 3 | course: number; 4 | length: number; 5 | width: number; 6 | }; 7 | 8 | export type RawAirbaseData = { 9 | callsign: string; 10 | cat: number; 11 | desc: { 12 | attributes: Record; 13 | category: number; 14 | displayName: string; 15 | life: number; 16 | typeName: string; 17 | }; 18 | point: [number, number, number]; 19 | id: number; 20 | runways: Array; 21 | }; 22 | -------------------------------------------------------------------------------- /dep/maptalks/README.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | > `npm install --save @types/maptalks` 3 | 4 | # Summary 5 | This package contains type definitions for maptalks (https://github.com/maptalks/maptalks.js). 6 | 7 | # Details 8 | Files were exported from https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/maptalks. 9 | 10 | ### Additional Details 11 | * Last updated: Thu, 08 Jul 2021 16:23:58 GMT 12 | * Dependencies: none 13 | * Global values: none 14 | 15 | # Credits 16 | These definitions were written by [Yu Yan](https://github.com/yanyu510). 17 | -------------------------------------------------------------------------------- /server/postgres_client.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "database/sql" 6 | //"net/http" 7 | //"log" 8 | _ "github.com/lib/pq" 9 | ) 10 | 11 | var db *sql.DB 12 | 13 | func initDB(config *Config) { 14 | var err error 15 | 16 | if config.Serverbot { 17 | connStr := config.Database 18 | db, err = sql.Open("postgres", connStr) 19 | 20 | if err != nil { 21 | panic(err) 22 | } 23 | 24 | if err = db.Ping(); err != nil { 25 | panic(err) 26 | } 27 | // this will be printed in the terminal, confirming the connection to the database 28 | fmt.Println("The database is connected") 29 | } 30 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext", "webworker"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": false, 16 | "noEmit": false, 17 | "jsx": "react-jsx", 18 | "downlevelIteration": true 19 | }, 20 | "include": ["src"] 21 | } 22 | -------------------------------------------------------------------------------- /src/stores/HackStore.tsx: -------------------------------------------------------------------------------- 1 | import Immutable from "immutable"; 2 | import create from "zustand"; 3 | 4 | type HackStoreData = { 5 | hacks: Immutable.Set; 6 | }; 7 | export const hackStore = create(() => { 8 | return { hacks: Immutable.Set() }; 9 | }); 10 | 11 | export function pushHack(): number { 12 | const startAt = new Date().getTime(); 13 | hackStore.setState((state) => { 14 | return { hacks: state.hacks.add(startAt) }; 15 | }); 16 | return startAt; 17 | } 18 | 19 | export function popHack(hackTime: number) { 20 | hackStore.setState((state) => { 21 | return { hacks: state.hacks.remove(hackTime) }; 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | DCS Battleground 9 | 10 | 11 | 12 | 13 | 14 | You need to enable JavaScript to run this app. 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/hooks/useKeyPress.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export function useKeyPress(targetKey: string) { 4 | const [keyPressed, setKeyPressed] = useState(false); 5 | 6 | const onDown = ({ key }: KeyboardEvent) => { 7 | if (key === targetKey) { 8 | setKeyPressed(true); 9 | } 10 | }; 11 | 12 | const onUp = ({ key }: KeyboardEvent) => { 13 | if (key === targetKey) { 14 | setKeyPressed(false); 15 | } 16 | }; 17 | 18 | useEffect(() => { 19 | window.addEventListener("keydown", onDown); 20 | window.addEventListener("keyup", onUp); 21 | return () => { 22 | window.removeEventListener("keydown", onDown); 23 | window.removeEventListener("keyup", onUp); 24 | }; 25 | }, []); 26 | return keyPressed; 27 | } 28 | -------------------------------------------------------------------------------- /dep/maptalks/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@types/maptalks", 3 | "version": "0.49.1", 4 | "description": "TypeScript definitions for maptalks", 5 | "homepage": "https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/maptalks", 6 | "license": "MIT", 7 | "contributors": [ 8 | { 9 | "name": "Yu Yan", 10 | "url": "https://github.com/yanyu510", 11 | "githubUsername": "yanyu510" 12 | } 13 | ], 14 | "main": "", 15 | "types": "index.d.ts", 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/DefinitelyTyped/DefinitelyTyped.git", 19 | "directory": "types/maptalks" 20 | }, 21 | "scripts": {}, 22 | "dependencies": {}, 23 | "typesPublisherContentHash": "1d0e094c9a2ede9ab0a4cb92a892c4a7a799b507c1f5545c89035606e154b66c", 24 | "typeScriptVersion": "3.6" 25 | } -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/b1naryth1ef/sneaker 2 | 3 | go 1.23.4 4 | 5 | require ( 6 | github.com/alioygur/gores v1.2.2 7 | github.com/b1naryth1ef/jambon v0.0.4-0.20220527200438-d39a4cc60cbe 8 | github.com/bwmarrin/discordgo v0.23.3-0.20211228023845-29269347e820 9 | github.com/go-chi/chi/v5 v5.0.7 10 | github.com/go-chi/cors v1.2.0 11 | github.com/google/uuid v1.3.0 12 | github.com/lib/pq v1.10.7 13 | github.com/realTristan/disgoauth v1.0.2 14 | github.com/spkg/bom v1.0.0 15 | github.com/urfave/cli/v2 v2.3.0 16 | ) 17 | 18 | require ( 19 | github.com/akavel/rsrc v0.10.2 // indirect 20 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect 21 | github.com/gorilla/websocket v1.4.2 // indirect 22 | github.com/russross/blackfriday/v2 v2.0.1 // indirect 23 | github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect 24 | golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect 25 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect 26 | ) 27 | -------------------------------------------------------------------------------- /src/components/MapIcon.tsx: -------------------------------------------------------------------------------- 1 | import ms from "milsymbol"; 2 | import React from "react"; 3 | import { Entity } from "../types/entity"; 4 | 5 | export const colorMode: ms.ColorMode = ms.ColorMode( 6 | "#ffffff", 7 | "#17c2f6", 8 | "#ff8080", 9 | "#FDE68A", 10 | "#ffffff" 11 | ); 12 | 13 | export function MapIcon({ 14 | obj, 15 | className, 16 | size, 17 | }: { 18 | obj: Entity | string; 19 | className?: string; 20 | size?: number; 21 | }): JSX.Element { 22 | if (typeof obj === "object" && obj.types.length === 0) { 23 | return <>>; 24 | } 25 | 26 | const svg = new ms.Symbol(typeof obj === "object" ? obj.sidc : obj, { 27 | size: size || 26, 28 | frame: true, 29 | fill: false, 30 | colorMode: colorMode, 31 | strokeWidth: 8, 32 | }).asSVG(); 33 | return ( 34 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/dcs/maps/DCSMap.ts: -------------------------------------------------------------------------------- 1 | import { RawAirbaseData } from "../../types/airbase"; 2 | 3 | export type DCSMap = { 4 | name: string; 5 | center: [number, number]; 6 | magDec: number; 7 | airports: Array; 8 | }; 9 | 10 | export type Airport = { 11 | name: string; 12 | // latitude, longitude, elevation (ft) 13 | position: [number, number, number]; 14 | runways?: Array; 15 | }; 16 | 17 | export type Runway = { 18 | heading: number; 19 | ils?: number; 20 | }; 21 | 22 | export function convertRawAirBaseData( 23 | airBaseData: Record, 24 | ): Array { 25 | return (Object.values(airBaseData) as Array) 26 | .map( 27 | (it) => { 28 | return { 29 | name: it.callsign, 30 | position: it.point, 31 | runways: it.runways.map((rw) => { 32 | return { 33 | heading: Math.round(rw.course), 34 | }; 35 | }), 36 | }; 37 | }, 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /server/tacview_client.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | //"strconv" 6 | 7 | "github.com/b1naryth1ef/jambon/tacview" 8 | //"./tacview" 9 | ) 10 | 11 | type TacViewClient struct { 12 | host string 13 | port int 14 | password string 15 | } 16 | 17 | func NewTacViewClient(host string, port int, password string) *TacViewClient { 18 | if port == 0 { 19 | port = 42674 20 | } 21 | //fmt.Println(host) 22 | //fmt.Println(strconv.Itoa(port)) 23 | //fmt.Println(password) 24 | 25 | return &TacViewClient{host: host, port: port, password: password} 26 | } 27 | 28 | 29 | func (c *TacViewClient) Start() (*tacview.Header, chan *tacview.TimeFrame, error) { 30 | reader, err := tacview.NewRealTimeReader(fmt.Sprintf("%s:%d", c.host, c.port), "dcsbgserver", c.password) 31 | if err != nil { 32 | //fmt.Println(err) 33 | return nil, nil, err 34 | } 35 | 36 | data := make(chan *tacview.TimeFrame, 1) 37 | go reader.ProcessTimeFrames(1, data) 38 | return &reader.Header, data, nil 39 | } 40 | -------------------------------------------------------------------------------- /src/stores/SettingsStore.tsx: -------------------------------------------------------------------------------- 1 | import create from "zustand"; 2 | 3 | export enum GroundUnitMode { 4 | FRIENDLY = "friendly", 5 | ENEMY = "enemy", 6 | } 7 | 8 | export enum FlightUnitMode { 9 | FRIENDLY = "friendly", 10 | ENEMY = "enemy", 11 | } 12 | 13 | export type SettingsStoreData = { 14 | map: { 15 | showTrackIcons?: boolean; 16 | showTrackLabels?: boolean; 17 | trackTrailLength?: number; 18 | groundUnitMode?: GroundUnitMode; 19 | }; 20 | }; 21 | 22 | export const settingsStore = create(() => { 23 | const localData = localStorage.getItem("settings"); 24 | if (localData) { 25 | return JSON.parse(localData) as SettingsStoreData; 26 | } 27 | return { 28 | map: { 29 | showTrackIcons: true, 30 | showTrackLabels: true, 31 | trackTrailLength: 9, 32 | groundUnitMode: GroundUnitMode.ENEMY, 33 | }, 34 | }; 35 | }); 36 | 37 | settingsStore.subscribe((state) => { 38 | localStorage.setItem("settings", JSON.stringify(state)); 39 | }); 40 | -------------------------------------------------------------------------------- /src/components/ScratchPad.tsx: -------------------------------------------------------------------------------- 1 | import { throttle } from "lodash"; 2 | import { useEffect, useState } from "react"; 3 | import { BiX } from "react-icons/bi"; 4 | 5 | export default function ScratchPad({ close }: { close: () => void }) { 6 | const [contents, setContents] = useState( 7 | localStorage.getItem("scratchpad") || "" 8 | ); 9 | 10 | useEffect( 11 | throttle(() => { 12 | localStorage.setItem("scratchpad", contents); 13 | }, 250), 14 | [contents] 15 | ); 16 | 17 | return ( 18 | 19 | 20 | Scratch Pad 21 | 22 | 23 | 24 | 25 | setContents(e.target.value)} 28 | value={contents} 29 | /> 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/components/DetailedCoords.tsx: -------------------------------------------------------------------------------- 1 | import * as mgrs from "mgrs"; 2 | import React from "react"; 3 | import { formatDDM, formatDMS } from "../util"; 4 | 5 | 6 | function parseMgrs(coords:[number, number]){ 7 | var val:string = mgrs.forward([coords[1], coords[0]]) 8 | //console.log("test") 9 | return val.slice(0, 3) + " " + val.slice(3, 5) + " " + val.slice(5, 10) + " " + val.slice(10) 10 | } 11 | 12 | export default function DetailedCoords({ 13 | coords, 14 | }: { 15 | coords: [number, number]; 16 | }) { 17 | return ( 18 | <> 19 | 20 | DMS 21 | {formatDMS(coords)} 22 | 23 | 24 | DDM 25 | {formatDDM(coords)} 26 | 27 | 28 | MGRS 29 | 30 | {parseMgrs(coords)} 31 | 32 | 33 | > 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /dep/maptalks/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. 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 | -------------------------------------------------------------------------------- /cmd/dcsbg-server/main.go: -------------------------------------------------------------------------------- 1 | // go: generate goversioninfo -icon = ../../dist/favicon.ico 2 | 3 | package main 4 | 5 | import ( 6 | "encoding/json" 7 | "io/ioutil" 8 | "log" 9 | "os" 10 | 11 | //"fmt" 12 | //"net/http" 13 | "github.com/b1naryth1ef/sneaker/server" 14 | "github.com/urfave/cli/v2" 15 | ) 16 | 17 | func main() { 18 | app := &cli.App{ 19 | Name: "dcsbg-server", 20 | Usage: "tacview realtime telemetry relay server", 21 | Flags: []cli.Flag{ 22 | &cli.StringFlag{ 23 | Name: "bind", 24 | Usage: "the server bind address", 25 | }, 26 | &cli.PathFlag{ 27 | Name: "config", 28 | Usage: "path to configuration file", 29 | Required: true, 30 | }, 31 | }, 32 | Action: func(c *cli.Context) error { 33 | var config server.Config 34 | 35 | configData, err := ioutil.ReadFile(c.Path("config")) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | err = json.Unmarshal(configData, &config) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | if c.IsSet("bind") { 46 | config.Bind = c.String("bind") 47 | } 48 | if config.Bind == "" { 49 | config.Bind = "localhost:7788" 50 | } 51 | 52 | return server.Run(&config) 53 | }, 54 | } 55 | 56 | err := app.Run(os.Args) 57 | if err != nil { 58 | log.Fatal(err) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 2 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 3 | const path = require("path"); 4 | 5 | module.exports = { 6 | mode: "production", 7 | entry: "./src/index.tsx", 8 | output: { 9 | filename: "[name].[contenthash].js", 10 | path: path.resolve(__dirname, "dist"), 11 | publicPath: "/static/", 12 | }, 13 | resolve: { 14 | extensions: [".tsx", ".ts", ".js"], 15 | }, 16 | plugins: [ 17 | new HtmlWebpackPlugin({ 18 | template: "./src/index.html", 19 | hash: true, 20 | }), 21 | new MiniCssExtractPlugin({ 22 | filename: "[name].[contenthash].css", 23 | }), 24 | ], 25 | module: { 26 | rules: [ 27 | { 28 | test: /\.tsx?$/, 29 | loader: "ts-loader", 30 | }, 31 | { 32 | test: /\.css$/i, 33 | use: [MiniCssExtractPlugin.loader, "css-loader", "postcss-loader"], 34 | }, 35 | { 36 | test: /\.json$/i, 37 | loader: "json5-loader", 38 | type: "javascript/auto", 39 | }, 40 | { 41 | test: /\.(png|jpg|jpeg|gif|mp3)$/i, 42 | type: "asset/resource", 43 | } 44 | ], 45 | }, 46 | devServer: { 47 | static: { 48 | directory: path.join(__dirname, "public"), 49 | publicPath: "/static", 50 | }, 51 | historyApiFallback: { 52 | rewrites: [ 53 | { from: /./, to: "/static/index.html" }, 54 | ], 55 | }, 56 | port: 3030, 57 | }, 58 | }; 59 | -------------------------------------------------------------------------------- /server/config.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | type Config struct { 4 | Bind string `json:"bind"` 5 | Servers []TacViewServerConfig `json:"servers"` 6 | AssetsPath *string `json:"assets_path"` 7 | AssetsPathExternal *string `json:"assets_path_external"` 8 | Serverbot bool `json:"serverbot"` 9 | Database string `json:"database"` 10 | ClientID string `json:"discord_client_id"` 11 | ClientSecret string `json:"discord_client_secret"` 12 | RedirectURI string `json:"redirect_url"` 13 | } 14 | 15 | type TacViewServerConfig struct { 16 | Name string `json:"name"` 17 | DcsName string `json:"dcsname"` 18 | Hostname string `json:"hostname"` 19 | 20 | RadarRefreshRate int64 `json:"radar_refresh_rate"` 21 | Port int `json:"port"` 22 | Password string `json:"password"` 23 | 24 | EnableFriendlyGroundUnits bool `json:"enable_friendly_ground_units"` 25 | EnableEnemyGroundUnits bool `json:"enable_enemy_ground_units"` 26 | EnemyGroundUnitsRatio int `json:"enemy_ground_units_ratio"` 27 | EnemyGroundUnitsMaxQuantity int `json:"enemy_ground_units_max_quantity"` 28 | EnableFriendlyFlightUnits bool `json:"enable_friendly_flight_units"` 29 | EnableEnemyFlightUnits bool `json:"enable_enemy_flight_units"` 30 | ViewAircraftWhenInFlight bool `json:"view_aircraft_when_in_flight"` 31 | DefaultCoalition string `json:"default_coalition"` 32 | ZonesSize [][]interface{} `json:"zones_size"` 33 | EditorId []string `json:"editor_id"` 34 | Enabled bool `json:"enabled"` 35 | } 36 | -------------------------------------------------------------------------------- /winres/winres.json: -------------------------------------------------------------------------------- 1 | { 2 | "RT_GROUP_ICON": { 3 | "APP": { 4 | "0000": [ 5 | "icon.png", 6 | "icon16.png" 7 | ] 8 | } 9 | }, 10 | "RT_MANIFEST": { 11 | "#1": { 12 | "0409": { 13 | "identity": { 14 | "name": "", 15 | "version": "" 16 | }, 17 | "description": "", 18 | "minimum-os": "win7", 19 | "execution-level": "as invoker", 20 | "ui-access": false, 21 | "auto-elevate": false, 22 | "dpi-awareness": "system", 23 | "disable-theming": false, 24 | "disable-window-filtering": false, 25 | "high-resolution-scrolling-aware": false, 26 | "ultra-high-resolution-scrolling-aware": false, 27 | "long-path-aware": false, 28 | "printer-driver-isolation": false, 29 | "gdi-scaling": false, 30 | "segment-heap": false, 31 | "use-common-controls-v6": false 32 | } 33 | } 34 | }, 35 | "RT_VERSION": { 36 | "#1": { 37 | "0000": { 38 | "fixed": { 39 | "file_version": "1.0.0", 40 | "product_version": "1.0.0" 41 | }, 42 | "info": { 43 | "0409": { 44 | "Comments": "", 45 | "CompanyName": "", 46 | "FileDescription": "", 47 | "FileVersion": "", 48 | "InternalName": "", 49 | "LegalCopyright": "", 50 | "LegalTrademarks": "", 51 | "OriginalFilename": "", 52 | "PrivateBuild": "", 53 | "ProductName": "", 54 | "ProductVersion": "", 55 | "SpecialBuild": "" 56 | } 57 | } 58 | } 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /example.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "servers": [ 3 | { 4 | "name": "ServAlias", //Don't compliant with special characters 5 | "dcsname": "Name of your server on DCS ", //To link the DCS Battleground's server with DCS's server 6 | "hostname": "XXX.XXX.XXX.XXX", //Hostname or IP address of the Tacview server 7 | "port": 1234, //Port of the Tacview server 8 | "password": "password", //Password of the Tacview server 9 | "radar_refresh_rate": 5, //Ping time of collecting data from tacview 10 | "serverbot_coalition_system": true, //Plug and play Special K's coalition system (need the "database" data) 11 | "default_coalition": "", //Default coalition if the user have not coalition (can be "blue", "red", "GM" or "") 12 | "enable_friendly_ground_units": true, //Show friendly ground units 13 | "enable_enemy_ground_units": true, //Show enemy ground units 14 | "enemy_ground_units_ratio": 40, //Show a enemy ground unit every 40 units 15 | "enemy_ground_units_max_quantity": 10, //Show max 10 enemy units on the map (-1 to deactivate this feature) 16 | "enable_friendly_flight_units": true, //Show friendly aircraft 17 | "enable_enemy_flight_units": true, //Show enemy aircraft 18 | "view_aircraft_when_in_flight": true //Hide enemy aircraft when the user is connected to DCS (need to link the discord account with DCS account with .link command's) 19 | } 20 | ], 21 | "serverbot": true, //Use Special K's server bot 22 | "database": "postgres://user:password@hostname:5432/postgres?sslmode=disable", //Special K's server bot Database 23 | "discord_client_id": "1564564564421", //Client ID of the discord application 24 | "discord_client_secret": "azrfdsflkdsfokdsklfjdskfj", //Client secret of the discord application 25 | "redirect_url": "http://my-url.kaboom/redirect/" //The url used to access DCS Battleground (/redirect/ needed) 26 | } -------------------------------------------------------------------------------- /src/components/ProfileTagList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { BiX } from "react-icons/bi"; 3 | import { Profile, updateProfile } from "../stores/ProfileStore"; 4 | 5 | export default function ProfileTagList({ 6 | profile, 7 | }: { 8 | profile: Profile; 9 | }): JSX.Element { 10 | const [addTagText, setAddTagText] = useState(""); 11 | 12 | return ( 13 | 14 | 15 | 16 | 17 | Tags 18 | 19 | setAddTagText(e.target.value)} 23 | /> 24 | 25 | { 27 | if (addTagText === "") return; 28 | updateProfile({ 29 | name: profile.name, 30 | tags: [...profile.tags, addTagText], 31 | }); 32 | setAddTagText(""); 33 | }} 34 | className="bg-green-100 border-green-300 border ml-2 p-1" 35 | > 36 | Add 37 | 38 | 39 | 40 | 41 | 42 | {profile.tags.map((tag) => ( 43 | 47 | {tag} 48 | 50 | updateProfile({ 51 | name: profile.name, 52 | tags: profile.tags.filter((it) => it !== tag), 53 | }) 54 | } 55 | className="text-red-500" 56 | > 57 | 58 | 59 | 60 | ))} 61 | 62 | 63 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/DCSBattlegroundClient.ts: -------------------------------------------------------------------------------- 1 | import { RawEntityData } from "./types/entity"; 2 | import { Geometry } from "./stores/GeometryStore"; 3 | import { Player } from "./stores/ServerStore"; 4 | 5 | export type DCSBattlegroundRadarSnapshotEvent = { 6 | e: "SESSION_RADAR_SNAPSHOT"; 7 | d: { 8 | offset: number; 9 | created: Array; 10 | updated: Array; 11 | deleted: Array; 12 | }; 13 | }; 14 | 15 | export type DCSBattlegroundInitialStateEvent = { 16 | e: "SESSION_STATE"; 17 | d: { 18 | session_id: string; 19 | offset: number; 20 | objects?: Array; 21 | }; 22 | }; 23 | 24 | export type DCSBattlegroundInitialSharedGeometryEvent = { 25 | e: "SESSION_SHARED_GEOMETRY"; 26 | d: { 27 | Add: Array; 28 | Recon: Array; 29 | Delete?: Array; 30 | }; 31 | }; 32 | 33 | export type DCSBattlegroundInitialDeletedGeometryEvent = { 34 | e: "SESSION_DELETED_GEOMETRY"; 35 | d: { 36 | Add: Array; 37 | Delete: Array; 38 | }; 39 | }; 40 | 41 | export type DCSBattlegroundPlayerInSlotEvent = { 42 | e: "SESSION_PLAYERS_IN_SLOT"; 43 | d: { 44 | Inflight: Array; 45 | }; 46 | }; 47 | 48 | export type DCSBattlegroundSessionEvent = 49 | | DCSBattlegroundRadarSnapshotEvent 50 | | DCSBattlegroundInitialStateEvent 51 | | DCSBattlegroundInitialSharedGeometryEvent 52 | | DCSBattlegroundPlayerInSlotEvent; 53 | 54 | export class DCSBattlegroundClient { 55 | private url: string; 56 | private eventSource: EventSource | null; 57 | 58 | constructor(url: string) { 59 | this.url = url; 60 | this.eventSource = null; 61 | } 62 | 63 | close() { 64 | this.eventSource?.close(); 65 | } 66 | 67 | run(onEvent: (event: DCSBattlegroundSessionEvent) => void) { 68 | 69 | this.eventSource = new EventSource(this.url); 70 | this.eventSource.onmessage = (event) => { 71 | const dcsbattlegroundEvent = JSON.parse(event.data) as DCSBattlegroundSessionEvent; 72 | onEvent(dcsbattlegroundEvent); 73 | }; 74 | this.eventSource.onerror = () => { 75 | // TODO: we can back-off here, but for now we delay 5 seconds 76 | console.error( 77 | "[DCSBattlegroundClient] event source error, reopening in 5 seconds", 78 | ); 79 | //setTimeout(() => this.run(onEvent), 5000); 80 | window.location.replace(window.location.href) 81 | }; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/components/MissionTimer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { hackStore, popHack, pushHack } from "../stores/HackStore"; 3 | import { serverStore } from "../stores/ServerStore"; 4 | import { formatCounter } from "../util"; 5 | 6 | export function MissionTimer(): JSX.Element { 7 | const [currentTime, setCurrentTime] = useState(""); 8 | const [hackTimes, setHackTimes] = useState>([]); 9 | const hacks = hackStore((state) => state.hacks); 10 | 11 | const update = () => { 12 | const serverState = serverStore.getState(); 13 | const globalObject = serverState.entities.get(0); 14 | if (!globalObject) return; 15 | 16 | const referenceTime = Date.parse( 17 | globalObject.properties["ReferenceTime"] as string 18 | ); 19 | const actualTime = Date.parse( 20 | globalObject.properties["RecordingTime"] as string 21 | ); 22 | const currentTime = new Date().getTime(); 23 | 24 | const elapsedTime = currentTime - actualTime; 25 | 26 | setCurrentTime(new Date(referenceTime + elapsedTime).toUTCString()); 27 | 28 | // Don't bind to hacks here because we're being called externally in a timer 29 | setHackTimes( 30 | hackStore 31 | .getState() 32 | .hacks.toArray() 33 | .map((it) => [it, formatCounter(Math.round((currentTime - it) / 1000))]) 34 | ); 35 | }; 36 | 37 | useEffect(update, [hacks]); 38 | 39 | useEffect(() => { 40 | const timer = setInterval(update, 900); 41 | return () => clearInterval(timer); 42 | }, []); 43 | 44 | return ( 45 | <> 46 | 47 | {hackTimes.map(([id, fmt]) => ( 48 | 49 | {fmt} 50 | 51 | popHack(id)} 53 | className="text-red-500 hover:text-red-600 font-bold" 54 | > 55 | X 56 | 57 | 58 | 59 | ))} 60 | {currentTime && ( 61 | pushHack()} 64 | > 65 | {currentTime} 66 | 67 | )} 68 | 69 | > 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /src/stores/EntityMetadataStore.tsx: -------------------------------------------------------------------------------- 1 | import Immutable from "immutable"; 2 | import create from "zustand"; 3 | import { getProfilesForTags } from "./ProfileStore"; 4 | import { setTrackOptions } from "./TrackStore"; 5 | 6 | export type EntityMetadata = { 7 | tags: Immutable.Set; 8 | }; 9 | 10 | type EntityMetadataStoreData = { 11 | entities: Immutable.Map; 12 | }; 13 | export const entityMetadataStore = create(() => { 14 | return { entities: Immutable.Map() }; 15 | }); 16 | 17 | export function useEntityMetadata( 18 | entityId: number 19 | ): EntityMetadata | undefined { 20 | return entityMetadataStore((state) => state.entities.get(entityId)); 21 | } 22 | 23 | export function pushEntityTag(entityId: number, tag: string) { 24 | return entityMetadataStore.setState((state) => { 25 | const existing = state.entities.get(entityId); 26 | const tags = existing ? existing.tags.add(tag) : Immutable.Set.of(tag); 27 | 28 | const profiles = getProfilesForTags(tags) 29 | .map((it) => [it.defaultThreatRadius, it.defaultWarningRadius]) 30 | .reduce((a, b) => [a[0] || b[0], a[1] || b[1]], [undefined, undefined]); 31 | 32 | setTrackOptions(entityId, { 33 | profileThreatRadius: profiles[0], 34 | profileWarningRadius: profiles[1], 35 | }); 36 | 37 | if (!existing) { 38 | return { 39 | entities: state.entities.set(entityId, { 40 | tags, 41 | }), 42 | }; 43 | } else if (!existing.tags.includes(tag)) { 44 | return { 45 | entities: state.entities.set(entityId, { 46 | ...existing, 47 | tags, 48 | }), 49 | }; 50 | } 51 | }); 52 | } 53 | 54 | export function popEntityTag(entityId: number, label: string) { 55 | return entityMetadataStore.setState((state) => { 56 | const existing = state.entities.get(entityId); 57 | if (!existing) { 58 | return state; 59 | } else { 60 | const tags = existing.tags.remove(label); 61 | const profiles = getProfilesForTags(tags) 62 | .map((it) => [it.defaultThreatRadius, it.defaultWarningRadius]) 63 | .reduce((a, b) => [a[0] || b[0], a[1] || b[1]], [undefined, undefined]); 64 | 65 | setTrackOptions(entityId, { 66 | profileThreatRadius: profiles[0], 67 | profileWarningRadius: profiles[1], 68 | }); 69 | 70 | return { 71 | entities: state.entities.set(entityId, { 72 | ...existing, 73 | tags, 74 | }), 75 | }; 76 | } 77 | }); 78 | } 79 | -------------------------------------------------------------------------------- /.ci/entrypoint.ts: -------------------------------------------------------------------------------- 1 | import * as Docker from "pkg/buildy/docker@1/mod.ts"; 2 | import { uploadArtifact } from "runtime/artifacts.ts"; 3 | import { print, pushStep, spawnChildJob, Workspace } from "runtime/core.ts"; 4 | 5 | export async function build( 6 | ws: Workspace, 7 | { os, arch, version }: { os?: string; arch?: string; version?: string }, 8 | ) { 9 | pushStep("Build Sneaker UI"); 10 | const yarnRes = await Docker.run( 11 | "yarn && yarn build", 12 | { 13 | image: "node:16", 14 | copy: [ 15 | "dist/**", 16 | "src/**", 17 | "*.js", 18 | "package.json", 19 | "tsconfig.json", 20 | "yarn.lock", 21 | ], 22 | }, 23 | ); 24 | 25 | await yarnRes.copy("dist/"); 26 | 27 | pushStep("Build Sneaker Binary"); 28 | const res = await Docker.run( 29 | "mkdir /tmp/build && mv cmd dist server assets.go go.mod go.sum /tmp/build && cd /tmp/build && go build -o sneaker cmd/sneaker-server/main.go && mv sneaker /", 30 | { 31 | image: `golang:1.17`, 32 | copy: ["cmd/**", "dist/**", "server/**", "assets.go", "go.mod", "go.sum"], 33 | env: [`GOOS=${os || "linux"}`, `GOARCH=${arch || "amd64"}`], 34 | }, 35 | ); 36 | 37 | if (version !== undefined) { 38 | await res.copy("/sneaker"); 39 | 40 | pushStep("Upload Sneaker Binary"); 41 | const uploadRes = await uploadArtifact("sneaker", { 42 | name: `sneaker-${os}-${arch}-${version}`, 43 | published: true, 44 | labels: [ 45 | "sneaker", 46 | `arch:${arch}`, 47 | `os:${os}`, 48 | `version:${version}`, 49 | ], 50 | }); 51 | print( 52 | `Uploaded binary to ${ 53 | uploadRes.generatePublicURL( 54 | ws.org, 55 | ws.repository, 56 | ) 57 | }`, 58 | ); 59 | } 60 | } 61 | 62 | const semVerRe = 63 | /v([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+)?/; 64 | 65 | export async function githubPush(ws: Workspace) { 66 | let version; 67 | 68 | const versionTags = ws.commit.tags.filter((tag) => semVerRe.test(tag)); 69 | if (versionTags.length == 1) { 70 | print(`Found version tag ${versionTags[0]}, will build release artifacts.`); 71 | version = versionTags[0]; 72 | } else if (versionTags.length > 1) { 73 | throw new Error(`Found too many version tags: ${versionTags}`); 74 | } 75 | 76 | await spawnChildJob(".ci/entrypoint.ts:build", { 77 | alias: "Build Linux amd64", 78 | args: { os: "linux", arch: "amd64", version: version }, 79 | }); 80 | 81 | await spawnChildJob(".ci/entrypoint.ts:build", { 82 | alias: "Build Windows amd64", 83 | args: { os: "windows", arch: "amd64", version: version }, 84 | }); 85 | } 86 | -------------------------------------------------------------------------------- /docs/API.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | ## Available Endpoints 4 | 5 | ### Server List 6 | 7 | ``` 8 | $ curl https://sneaker.example.com/api/servers 9 | [ 10 | { 11 | "name": "saw", 12 | "ground_unit_modes": [ 13 | "enemy", 14 | "friendly" 15 | ], 16 | "players": [ 17 | { 18 | "name": "[TFP] Ghost", 19 | "type": "F-16C_50" 20 | }, 21 | { 22 | "name": "Legacy 1-1 | Zanax116", 23 | "type": "F-16C_50" 24 | }, 25 | { 26 | "name": "Barack 1-1", 27 | "type": "F-16C_50" 28 | }, 29 | { 30 | "name": "Hesgad", 31 | "type": "Mi-24P" 32 | } 33 | ], 34 | "gcis": [ 35 | { 36 | "id": "80351110224678912", 37 | "notes": "asfd", 38 | "expires_at": "2022-01-26T18:11:50.948081071Z" 39 | } 40 | ] 41 | } 42 | ] 43 | ``` 44 | 45 | ### Server Information 46 | 47 | ``` 48 | $ curl https://sneaker.example.com/api/servers/saw 49 | { 50 | "name": "saw", 51 | "ground_unit_modes": [ 52 | "enemy", 53 | "friendly" 54 | ], 55 | "players": [ 56 | { 57 | "name": "[TFP] Ghost", 58 | "type": "F-16C_50" 59 | }, 60 | { 61 | "name": "Legacy 1-1 | Zanax116", 62 | "type": "F-16C_50" 63 | }, 64 | { 65 | "name": "Barack 1-1", 66 | "type": "F-16C_50" 67 | }, 68 | { 69 | "name": "Hesgad", 70 | "type": "Mi-24P" 71 | } 72 | ], 73 | "gcis": [ 74 | { 75 | "id": "80351110224678912", 76 | "notes": "asfd", 77 | "expires_at": "2022-01-26T18:11:50.948081071Z" 78 | } 79 | ] 80 | } 81 | ``` 82 | 83 | ### Server Events 84 | 85 | This is a long-poll SSE HTTP connection. 86 | 87 | ``` 88 | $ curl https://sneaker.example.com/api/servers/saw/events 89 | data: { 90 | "d": { 91 | "session_id": "2022-01-26T17:22:03.013Z", 92 | "offset": 17975, 93 | "objects": null 94 | }, 95 | "e": "SESSION_STATE" 96 | }\n\n 97 | data: { 98 | "d": { 99 | "updated": [], 100 | "deleted": [], 101 | "created": [ 102 | { 103 | "id": 62210, 104 | "types": [ 105 | "Ground", 106 | "Vehicle" 107 | ], 108 | "properties": { 109 | "Coalition": "Enemies", 110 | "Color": "Blue", 111 | "Country": "us", 112 | "Group": "Ground-3", 113 | "Name": "Patriot AMG", 114 | "Pilot": "Ground-2-3-1" 115 | }, 116 | "latitude": 34.5961321, 117 | "longitude": 32.9832006, 118 | "altitude": 13.04, 119 | "heading": 90, 120 | "updated_at": 17844, 121 | "created_at": 17844 122 | } 123 | ] 124 | }, 125 | "e": "SESSION_RADAR_SNAPSHOT" 126 | }\n\n 127 | ``` -------------------------------------------------------------------------------- /server/static.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "net/http" 7 | "path/filepath" 8 | "time" 9 | "log" 10 | 11 | "github.com/b1naryth1ef/sneaker" 12 | "github.com/go-chi/chi/v5" 13 | ) 14 | 15 | func (h *httpServer) serveEmbeddedFile(path string, w http.ResponseWriter, r *http.Request) { 16 | if h.config.AssetsPath != nil { 17 | path := filepath.Join(*h.config.AssetsPath, path) 18 | 19 | contents, err := ioutil.ReadFile(path) 20 | if err != nil { 21 | http.Error(w, "Error reading file", http.StatusInternalServerError) 22 | return 23 | } 24 | 25 | fileName := filepath.Base(path) 26 | http.ServeContent(w, r, fileName, time.Now(), bytes.NewReader(contents)) 27 | } else { 28 | f, err := sneaker.Static.ReadFile("dist/" + path) 29 | if err != nil { 30 | http.Error(w, "Not Found", http.StatusNotFound) 31 | return 32 | } 33 | 34 | fileName := filepath.Base(path) 35 | http.ServeContent(w, r, fileName, time.Now(), bytes.NewReader(f)) 36 | } 37 | } 38 | 39 | // Serves static assets from the embedded filesystem 40 | func (h *httpServer) serveEmbeddedStaticAssets(w http.ResponseWriter, r *http.Request) { 41 | param := chi.URLParam(r, "*") 42 | 43 | if h.config.AssetsPath != nil { 44 | path := filepath.Join(*h.config.AssetsPath, param) 45 | _, err := filepath.Rel(*h.config.AssetsPath, path) 46 | if err != nil { 47 | http.Error(w, "Not Found", http.StatusNotFound) 48 | return 49 | } 50 | 51 | contents, err := ioutil.ReadFile(path) 52 | if err != nil { 53 | http.Error(w, "Error reading file", http.StatusInternalServerError) 54 | return 55 | } 56 | 57 | fileName := filepath.Base(param) 58 | http.ServeContent(w, r, fileName, time.Now(), bytes.NewReader(contents)) 59 | } else { 60 | f, err := sneaker.Static.ReadFile("dist/" + param) 61 | if err != nil { 62 | http.Error(w, "Not Found", http.StatusNotFound) 63 | return 64 | } 65 | 66 | fileName := filepath.Base(param) 67 | log.Printf(fileName) 68 | http.ServeContent(w, r, fileName, time.Now(), bytes.NewReader(f)) 69 | } 70 | } 71 | 72 | 73 | // Serves static assets from the embedded filesystem 74 | func (h *httpServer) serveEmbeddedStaticAssetsExternal(w http.ResponseWriter, r *http.Request) { 75 | param := chi.URLParam(r, "*") 76 | log.Printf(*h.config.AssetsPathExternal) 77 | log.Printf(param) 78 | 79 | if h.config.AssetsPathExternal != nil { 80 | path := filepath.Join(*h.config.AssetsPathExternal, param) 81 | _, err := filepath.Rel(*h.config.AssetsPathExternal, path) 82 | if err != nil { 83 | http.Error(w, "Not Found", http.StatusNotFound) 84 | return 85 | } 86 | 87 | contents, err := ioutil.ReadFile(path) 88 | if err != nil { 89 | http.Error(w, "Error reading file", http.StatusInternalServerError) 90 | return 91 | } 92 | 93 | fileName := filepath.Base(param) 94 | http.ServeContent(w, r, fileName, time.Now(), bytes.NewReader(contents)) 95 | } 96 | } -------------------------------------------------------------------------------- /src/types/entity.ts: -------------------------------------------------------------------------------- 1 | import { planes } from "../dcs/aircraft"; 2 | 3 | export type RawEntityData = { 4 | id: number; 5 | types: Array; 6 | properties: Record; 7 | longitude: number; 8 | latitude: number; 9 | altitude: number; 10 | heading: number; 11 | updated_at: number; 12 | created_at: number; 13 | visible: boolean; 14 | ratio_long: number; 15 | ratio_lat: number; 16 | }; 17 | 18 | export class Entity { 19 | id: number; 20 | types: Array; 21 | properties: Record; 22 | longitude: number; 23 | latitude: number; 24 | altitude: number; 25 | heading: number; 26 | updatedAt: number; 27 | createdAt: number; 28 | visible: boolean; 29 | ratioLong: number; 30 | ratioLat: number; 31 | 32 | constructor(data: RawEntityData) { 33 | this.id = data.id; 34 | this.types = data.types; 35 | this.properties = data.properties; 36 | this.longitude = data.longitude; 37 | this.latitude = data.latitude; 38 | this.altitude = data.altitude; 39 | this.heading = data.heading; 40 | this.updatedAt = data.updated_at; 41 | this.createdAt = data.created_at; 42 | this.visible = data.visible; 43 | this.ratioLong = data.ratio_long; 44 | this.ratioLat = data.ratio_lat; 45 | } 46 | 47 | get coalition(): string { 48 | return this.properties["Coalition"] as string; 49 | } 50 | 51 | get name(): string { 52 | return this.properties["Name"] as string; 53 | } 54 | 55 | get pilot(): string { 56 | return this.properties["Pilot"] as string; 57 | } 58 | 59 | get group(): string { 60 | return this.properties["Group"] as string; 61 | } 62 | 63 | get sidc(): string { 64 | const ident = this.coalition === "Allies" ? "H" : "F"; 65 | if (this.types.includes("Bullseye")) { 66 | return `G${ident}G-GPWA--`; 67 | } 68 | 69 | let battleDimension = "z"; 70 | if (this.types.includes("Air")) { 71 | battleDimension = "a"; 72 | } else if (this.types.includes("Sea")) { 73 | battleDimension = "s"; 74 | } else if (this.types.includes("Ground")) { 75 | battleDimension = "g"; 76 | } 77 | 78 | const plane = planes[this.name]; 79 | if (plane !== undefined) { 80 | return `S${ident}${battleDimension}-${plane.sidcPlatform}--`; 81 | } else if (this.types.includes("Air")) { 82 | console.log( 83 | `Missing AIR SIDC platform definition: ${this.name} (${ 84 | this.types.join(", ") 85 | })`, 86 | ); 87 | } 88 | 89 | return `S${ident}${battleDimension}-------`; 90 | } 91 | } 92 | 93 | export function getCoalitionColor(coalition: string) { 94 | if (coalition === "Allies") { 95 | return "#e63e3e" //"#e63e3e"; //"#ff8080"; 96 | } else if (coalition === "Enemies") { 97 | return "#0049FF"; //"#17c2f6"; 98 | } else { 99 | return "#FBBF24"; //"#FBBF24"; 100 | } 101 | } 102 | 103 | export function getCoalitionIdentity(coalition: string) { 104 | if (coalition === "Allies") { 105 | return "H"; 106 | } else if (coalition === "Enemies") { 107 | return "F"; 108 | } else { 109 | return "U"; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/tacview/client.go: -------------------------------------------------------------------------------- 1 | package tacview 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "hash/crc32" 8 | "hash/crc64" 9 | "io" 10 | "math/bits" 11 | "net" 12 | "unicode/utf16" 13 | ) 14 | 15 | func hashPassword32(password string) string { 16 | password_utf16 := utf16.Encode([]rune(password)) 17 | 18 | password_bytes := make([]byte, 2*len(password_utf16)) 19 | for i, r := range password_utf16 { 20 | password_bytes[2*i+0] = byte(r >> 0) 21 | password_bytes[2*i+1] = byte(r >> 8) 22 | } 23 | 24 | hash := crc32.ChecksumIEEE(password_bytes) 25 | return fmt.Sprintf("%x", hash) 26 | } 27 | 28 | func hashPassword64(password string) string { 29 | password_utf16 := utf16.Encode([]rune(password)) 30 | 31 | password_bytes := make([]byte, 2*len(password_utf16)) 32 | for i, r := range password_utf16 { 33 | password_bytes[2*i+0] = bits.Reverse8(byte(r >> 0)) 34 | password_bytes[2*i+1] = bits.Reverse8(byte(r >> 8)) 35 | } 36 | 37 | hash := bits.Reverse64(crc64.Checksum(password_bytes, crc64.MakeTable(crc64.ECMA))) 38 | return fmt.Sprintf("%x", hash) 39 | } 40 | 41 | /// Creates a new Reader from a TacView Real Time server 42 | func NewRealTimeReader(connStr string, username string, password string) (*Reader, error) { 43 | reader, err := newRealTimeReaderHash(connStr, username, password, hashPassword64) 44 | 45 | if err == io.EOF && password != "" { 46 | reader, err = newRealTimeReaderHash(connStr, username, password, hashPassword32) 47 | if err == io.EOF { 48 | err = errors.New("EOF (possible incorrect password)") 49 | } 50 | } 51 | 52 | return reader, err 53 | } 54 | 55 | func newRealTimeReaderHash(connStr string, username string, password string, hashFunc func(string) string) (*Reader, error) { 56 | conn, err := net.Dial("tcp", connStr) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | reader := bufio.NewReader(conn) 62 | 63 | headerProtocol, err := reader.ReadString('\n') 64 | if err != nil { 65 | return nil, err 66 | } 67 | if headerProtocol != "XtraLib.Stream.0\n" { 68 | return nil, fmt.Errorf("bad header protocol: %v", headerProtocol) 69 | } 70 | 71 | headerVersion, err := reader.ReadString('\n') 72 | if err != nil { 73 | return nil, err 74 | } 75 | if headerVersion != "Tacview.RealTimeTelemetry.0\n" { 76 | return nil, fmt.Errorf("bad header version %v", headerVersion) 77 | } 78 | 79 | // Read remote hostname 80 | _, err = reader.ReadString('\n') 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | eoh, err := reader.ReadByte() 86 | if err != nil { 87 | return nil, err 88 | } 89 | 90 | if eoh != '\x00' { 91 | return nil, errors.New("bad or missing end of header") 92 | } 93 | 94 | _, err = conn.Write([]byte("XtraLib.Stream.0\n")) 95 | if err != nil { 96 | return nil, err 97 | } 98 | _, err = conn.Write([]byte("Tacview.RealTimeTelemetry.0\n")) 99 | if err != nil { 100 | return nil, err 101 | } 102 | _, err = conn.Write([]byte(fmt.Sprintf("%s\n", username))) 103 | if err != nil { 104 | return nil, err 105 | } 106 | 107 | hash := hashFunc(password) 108 | _, err = conn.Write([]byte(fmt.Sprintf("%s\x00", hash))) 109 | if err != nil { 110 | return nil, err 111 | } 112 | 113 | return NewReader(reader) 114 | } -------------------------------------------------------------------------------- /tacview/client.go: -------------------------------------------------------------------------------- 1 | package tacview 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "hash/crc32" 8 | "hash/crc64" 9 | "io" 10 | "math/bits" 11 | "net" 12 | "unicode/utf16" 13 | ) 14 | 15 | func hashPassword32(password string) string { 16 | password_utf16 := utf16.Encode([]rune(password)) 17 | 18 | password_bytes := make([]byte, 2*len(password_utf16)) 19 | for i, r := range password_utf16 { 20 | password_bytes[2*i+0] = byte(r >> 0) 21 | password_bytes[2*i+1] = byte(r >> 8) 22 | } 23 | 24 | hash := crc32.ChecksumIEEE(password_bytes) 25 | return fmt.Sprintf("%x", hash) 26 | } 27 | 28 | func hashPassword64(password string) string { 29 | password_utf16 := utf16.Encode([]rune(password)) 30 | 31 | password_bytes := make([]byte, 2*len(password_utf16)) 32 | for i, r := range password_utf16 { 33 | password_bytes[2*i+0] = bits.Reverse8(byte(r >> 0)) 34 | password_bytes[2*i+1] = bits.Reverse8(byte(r >> 8)) 35 | } 36 | 37 | hash := bits.Reverse64(crc64.Checksum(password_bytes, crc64.MakeTable(crc64.ECMA))) 38 | return fmt.Sprintf("%x", hash) 39 | } 40 | 41 | /// Creates a new Reader from a TacView Real Time server 42 | func NewRealTimeReader(connStr string, username string, password string) (*Reader, error) { 43 | reader, err := newRealTimeReaderHash(connStr, username, password, hashPassword64) 44 | 45 | if err == io.EOF && password != "" { 46 | reader, err = newRealTimeReaderHash(connStr, username, password, hashPassword32) 47 | if err == io.EOF { 48 | err = errors.New("EOF (possible incorrect password)") 49 | } 50 | } 51 | 52 | return reader, err 53 | } 54 | 55 | func newRealTimeReaderHash(connStr string, username string, password string, hashFunc func(string) string) (*Reader, error) { 56 | conn, err := net.Dial("tcp", connStr) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | reader := bufio.NewReader(conn) 62 | 63 | headerProtocol, err := reader.ReadString('\n') 64 | if err != nil { 65 | return nil, err 66 | } 67 | if headerProtocol != "XtraLib.Stream.0\n" { 68 | return nil, fmt.Errorf("bad header protocol: %v", headerProtocol) 69 | } 70 | 71 | headerVersion, err := reader.ReadString('\n') 72 | if err != nil { 73 | return nil, err 74 | } 75 | if headerVersion != "Tacview.RealTimeTelemetry.0\n" { 76 | return nil, fmt.Errorf("bad header version %v", headerVersion) 77 | } 78 | 79 | // Read remote hostname 80 | _, err = reader.ReadString('\n') 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | eoh, err := reader.ReadByte() 86 | if err != nil { 87 | return nil, err 88 | } 89 | 90 | if eoh != '\x00' { 91 | return nil, errors.New("bad or missing end of header") 92 | } 93 | 94 | _, err = conn.Write([]byte("XtraLib.Stream.0\n")) 95 | if err != nil { 96 | return nil, err 97 | } 98 | _, err = conn.Write([]byte("Tacview.RealTimeTelemetry.0\n")) 99 | if err != nil { 100 | return nil, err 101 | } 102 | _, err = conn.Write([]byte(fmt.Sprintf("%s\n", username))) 103 | if err != nil { 104 | return nil, err 105 | } 106 | 107 | hash := hashFunc(password) 108 | _, err = conn.Write([]byte(fmt.Sprintf("%s\x00", hash))) 109 | if err != nil { 110 | return nil, err 111 | } 112 | 113 | return NewReader(reader) 114 | } -------------------------------------------------------------------------------- /src/components/QuestConsoleTab.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import * as maptalks from "maptalks"; 3 | import React, { useState } from "react"; 4 | import { BiMapPin } from "react-icons/bi"; 5 | import { 6 | addQuest, 7 | geometryStore, 8 | setSelectedGeometry 9 | } from "../stores/GeometryStore"; 10 | import { iconCache } from "../components/MapEntity"; 11 | import { setSelectedEntityId } from "../stores/ServerStore"; 12 | import { ColorPicker, useColor } from "react-color-palette"; 13 | import "react-color-palette/css"; 14 | 15 | export default function QuestConsoleTab({ map }: { map: maptalks.Map }) { 16 | const [geometry, selectedId] = geometryStore((state) => [ 17 | state.geometry, 18 | state.selectedGeometry, 19 | ]); 20 | const [color, setColor] = useColor("#0068FF"); 21 | const [draw, setDraw] = useState(""); 22 | 23 | return ( 24 | 25 | 26 | 27 | 28 | 29 | { 35 | setDraw("Quest"); 36 | var drawTool = new maptalks.DrawTool({ 37 | mode: 'Point', 38 | once: true, 39 | symbol:{ 40 | 'markerFile' : '/static/Map-Marker-Ball-Chartreuse-icon.png', 41 | 'markerWidth' : 28, 42 | 'markerHeight' : 28, 43 | 'markerDx' : 0, 44 | 'markerDy' : 0, 45 | 'markerOpacity': 1 46 | } 47 | }).addTo(map).disable(); 48 | drawTool.on('drawend', function(param) { 49 | setDraw("") 50 | const pos = param!.geometry!.getFirstCoordinate(); 51 | addQuest([pos.y, pos.x], color.hex); 52 | }); 53 | drawTool.setMode('Point').enable(); 54 | 55 | }} 56 | > 57 | Mission 58 | 59 | 60 | 61 | 62 | {geometry.valueSeq().sort((a, b) => a.id > b.id ? 1 : -1).map((it) => { 63 | if (it.type === "quest") { 64 | return ( 65 | { 72 | setSelectedGeometry(it.id); 73 | setSelectedEntityId(null); 74 | 75 | let position; 76 | position = [it.position[1], it.position[0]]; 77 | 78 | if (position) { 79 | map.animateTo( 80 | { 81 | center: position, 82 | zoom: 10, 83 | }, 84 | { 85 | duration: 250, 86 | easing: "out", 87 | } 88 | ); 89 | } 90 | }} 91 | > 92 | {it.name || `${it.type} #${it.id}`} 93 | 94 | ); 95 | } 96 | })} 97 | 98 | 99 | ); 100 | } 101 | 102 | 103 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "DCSBattleground", 3 | "version": "2.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/lodash": "^4.14.178", 7 | "@types/maptalks": "file:./dep/maptalks", 8 | "coordinate-parser": "^1.0.7", 9 | "immutable": "^4.0.0", 10 | "lodash": "^4.17.21", 11 | "maptalks": "^1.0.0", 12 | "maptalks.animatemarker": "^0.4.1", 13 | "mgrs": "^2.0.0", 14 | "milsymbol": "^2.2.0", 15 | "react": "^17.0.2", 16 | "react-collapse": "^5.0.4", 17 | "react-color-palette": "^7.3.0", 18 | "react-dom": "^17.0.2", 19 | "react-icons": "^4.12.0", 20 | "react-image-lightbox": "^5.1.4", 21 | "yet-another-react-lightbox": "^3.21.7", 22 | "react-rounded-image": "^2.0.15", 23 | "react-router-dom": "^5.2.0", 24 | "use-http": "^1.0.24", 25 | "uuid": "^11.0.3", 26 | "zustand": "^3.6.7", 27 | "tailwind-scrollbar": "^3.1.0" 28 | }, 29 | "scripts": { 30 | "build": "yarn webpack build", 31 | "start": "webpack serve --env=local --mode=development" 32 | }, 33 | "eslintConfig": { 34 | "extends": [ 35 | "react-app", 36 | "react-app/jest" 37 | ] 38 | }, 39 | "browserslist": { 40 | "production": [ 41 | ">0.2%", 42 | "not dead", 43 | "not op_mini all" 44 | ], 45 | "development": [ 46 | "last 1 chrome version", 47 | "last 1 firefox version", 48 | "last 1 safari version" 49 | ] 50 | }, 51 | "devDependencies": { 52 | "@babel/core": "^7.14.8", 53 | "@babel/plugin-transform-flow-strip-types": "^7.14.5", 54 | "@babel/plugin-transform-react-inline-elements": "^7.14.5", 55 | "@babel/plugin-transform-react-jsx": "^7.14.5", 56 | "@babel/plugin-transform-typescript": "^7.14.6", 57 | "@babel/preset-env": "^7.14.8", 58 | "@babel/preset-react": "^7.14.5", 59 | "@babel/preset-typescript": "^7.14.5", 60 | "@babel/runtime": "^7.14.8", 61 | "@chialab/esbuild-plugin-meta-url": "^0.11.29", 62 | "@fullhuman/postcss-purgecss": "^4.0.3", 63 | "@types/immutable": "^3.8.7", 64 | "@types/marked": "^2.0.4", 65 | "@types/react": "^17.0.14", 66 | "@types/react-collapse": "^5.0.4", 67 | "@types/react-dom": "^17.0.9", 68 | "@types/react-icons": "^3.0.0", 69 | "@types/react-router-dom": "^5.1.8", 70 | "@typescript-eslint/eslint-plugin": "^4.28.2", 71 | "@typescript-eslint/parser": "^4.28.2", 72 | "autoprefixer": "^10.4.16", 73 | "babel-loader": "^8.2.2", 74 | "babel-plugin-transform-runtime": "^6.23.0", 75 | "css-loader": "^6.2.0", 76 | "cssnano": "^5.0.6", 77 | "esbuild": "^0.12.25", 78 | "esbuild-loader": "^2.15.1", 79 | "eslint": "^7.30.0", 80 | "eslint-plugin-react": "^7.24.0", 81 | "html-webpack-plugin": "^5.3.2", 82 | "json5-loader": "^4.0.1", 83 | "mini-css-extract-plugin": "^2.1.0", 84 | "postcss": "^8.3.6", 85 | "postcss-cli": "^8.3.1", 86 | "postcss-fontpath": "^1.0.0", 87 | "postcss-loader": "^6.1.1", 88 | "postcss-preset-env": "^9.3.0", 89 | "posthtml-expressions": "^1.7.1", 90 | "posthtml-include": "^1.7.1", 91 | "posthtml-load-config": "^2.0.0", 92 | "style-loader": "^3.2.1", 93 | "stylelint-config-standard": "^36.0.1", 94 | "tailwindcss": "^3.4.13", 95 | "ts-loader": "^9.2.4", 96 | "typescript": "^4.3.5", 97 | "webpack": "^5.51.2", 98 | "webpack-bundle-analyzer": "^4.10.1", 99 | "webpack-cli": "^4.7.2", 100 | "webpack-dev-server": "^4.7.1" 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/data/airbases/marianaislands.json: -------------------------------------------------------------------------------- 1 | {"Rota Intl": {"callsign": "Rota Intl", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Rota Intl", "life": 3600, "typeName": "Rota Intl"}, "id": 1, "point": [14.174905098823, 145.23248560973, 173.38017272949], "runways": [{"Name": 9, "course": -1.6094254255295, "length": 1861.0954589844, "position": {"x": 75884.859375, "y": 173.38017272949, "z": 48589.875}, "width": 60}]}, "Saipan Intl": {"callsign": "Saipan Intl", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Saipan Intl", "life": 3600, "typeName": "Saipan Intl"}, "id": 2, "point": [15.115121634075, 145.71879827825, 65.000068664551], "runways": [{"Name": 7, "course": -1.1885718107224, "length": 2374.4230957031, "position": {"x": 180035.4375, "y": 65.000068664551, "z": 101855.9609375}, "width": 60}]}, "Tinian Intl": {"callsign": "Tinian Intl", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Tinian Intl", "life": 3600, "typeName": "Tinian Intl"}, "id": 3, "point": [14.997429376954, 145.60830605924, 73.154266357422], "runways": [{"Name": 8, "course": -1.3863915205002, "length": 2370.6965332031, "position": {"x": 166859.859375, "y": 73.154266357422, "z": 89956.625}, "width": 60}]}, "Antonio B. Won Pat Intl": {"callsign": "Antonio B. Won Pat Intl", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Antonio B. Won Pat Intl", "life": 3600, "typeName": "Antonio B. Won Pat Intl"}, "id": 4, "point": [13.479569370694, 144.78477470703, 77.79207611084], "runways": [{"Name": 6, "course": -1.14269053936, "length": 2852.7629394531, "position": {"x": -23.656127929688, "y": 77.79207611084, "z": -77.940307617188}, "width": 60}, {"Name": 24, "course": -1.1422345638275, "length": 2852.7629394531, "position": {"x": -294.1178894043, "y": 77.79207611084, "z": -154.8974609375}, "width": 60}]}, "Olf Orote": {"callsign": "Olf Orote", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Olf Orote", "life": 3600, "typeName": "Olf Orote"}, "id": 5, "point": [13.4398309259, 144.64680955858, 28.506044387817], "runways": [{"Name": 25, "course": 1.9677163362503, "length": 1052.1625976562, "position": {"x": -5023.3046875, "y": 28.506044387817, "z": -16869.435546875}, "width": 45}]}, "Andersen AFB": {"callsign": "Andersen AFB", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Andersen AFB", "life": 3600, "typeName": "Andersen AFB"}, "id": 6, "point": [13.57604707035, 144.91745889539, 166.12214660645], "runways": [{"Name": 6, "course": -1.1583703756332, "length": 3197.4418945312, "position": {"x": 10574.990234375, "y": 166.12214660645, "z": 14548.833984375}, "width": 60}, {"Name": 24, "course": -1.1584078073502, "length": 3197.4418945312, "position": {"x": 11089.849609375, "y": 166.12214660645, "z": 14365.798828125}, "width": 60}]}, "Pagan Airstrip": {"callsign": "Pagan Airstrip", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Pagan Airstrip", "life": 3600, "typeName": "Pagan Airstrip"}, "id": 7, "point": [18.12420628516, 145.76106359949, 15.308205604553], "runways": [{"Name": 11, "course": -1.9427622556686, "length": 495.34295654297, "position": {"x": 512410.25, "y": 15.308205604553, "z": 107564.609375}, "width": 50}]}, "North West Field": {"callsign": "North West Field", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "North West Field", "life": 3600, "typeName": "North West Field"}, "id": 8, "point": [13.629365787313, 144.86661164669, 159.00015258789], "runways": []}} -------------------------------------------------------------------------------- /src/components/ProfileSettings.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { 3 | addProfile, 4 | deleteProfile, 5 | Profile, 6 | profileStore, 7 | updateProfile, 8 | } from "../stores/ProfileStore"; 9 | import ProfileTagList from "./ProfileTagList"; 10 | 11 | function ProfileDetails({ profile }: { profile: Profile }) { 12 | return ( 13 | 14 | 15 | {profile.name} 16 | 17 | { 19 | if (confirm(`Delete profile ${profile.name}?`)) { 20 | deleteProfile(profile.name); 21 | } 22 | }} 23 | className="inline-block p-1 bg-red-100 border-red-300 border rounded-sm shadow-sm text-xs" 24 | > 25 | Delete 26 | 27 | 28 | 29 | 30 | 31 | Default Threat Radius 32 | { 36 | updateProfile({ 37 | name: profile.name, 38 | defaultThreatRadius: 39 | e.target.value !== "" ? parseInt(e.target.value) : undefined, 40 | }); 41 | }} 42 | /> 43 | 44 | 45 | Default Warning Radius 46 | { 50 | updateProfile({ 51 | name: profile.name, 52 | defaultWarningRadius: 53 | e.target.value !== "" ? parseInt(e.target.value) : undefined, 54 | }); 55 | }} 56 | /> 57 | 58 | 59 | 60 | 61 | ); 62 | } 63 | 64 | export function ProfileSettings(): JSX.Element { 65 | const [newProfileName, setNewProfileName] = useState(""); 66 | const profiles = profileStore((state) => state.profiles); 67 | 68 | return ( 69 | 70 | 71 | 72 | Profiles 73 | 74 | 75 | setNewProfileName(e.target.value)} 79 | placeholder="Profile name" 80 | /> 81 | { 84 | addProfile(newProfileName); 85 | }} 86 | className="flex-grow inline-block ml-auto p-1 bg-green-100 border-green-300 border rounded-sm shadow-sm text-xs" 87 | > 88 | Create 89 | 90 | 91 | 92 | 93 | {profiles.valueSeq().map((it) => ( 94 | 95 | ))} 96 | 97 | 98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DCS Battleground 2 | 3 | Originally created for the french community Liaison-16, DCS Battleground is an open source tool for visualizing the battlefield of DCS servers and sharing scouting elements and flight plans between pilots. 4 | It was designed from [sneaker](https://github.com/Special-K-s-Flightsim-Bots/sneaker) and uses the Tacview protocol to communicate with DCS. 5 | It has Discord's authentication and is interfaced with the [Special K's Server bot](https://github.com/Special-K-s-Flightsim-Bots/DCSServerBot). 6 | 7 | A live example of DCS Battlegound can be viewed [here](http://map.liaison16.eu/). 8 | 9 | 10 | 11 |  12 | 13 | ## Installation 14 | 15 | 1. Download the latest released version [from here](https://github.com/Frigondin/DCSBattleground/releases). 16 | 2. Create a configuration file based off the [example](/example.config.json), replacing the required information (and optionally adding multiple servers to the array) 17 | 3. Run the executable with the configuration path: `DCSBattleground.exe --config config.json --bind 0.0.0.0:yourport` 18 | 19 | ## Configuration 20 | 21 | DCS Battleground features a built-in Discord authentication 22 | 23 | 1. Create a new [Discord Application](https://discord.com/developers/applications) 24 | 2. Configure the redirect url (used later) and copy the client id and client secret 25 |  26 | 4. Add the following to your `config.json`: 27 | ```json 28 | { 29 | "servers": [ 30 | { 31 | "name": "ServAlias", //Don't compliant with special characters 32 | "dcsname": "Name of your server on DCS ", //To link the DCS Battleground's server with DCS's server 33 | "hostname": "XXX.XXX.XXX.XXX", //Hostname or IP address of the Tacview server 34 | "port": 1234, //Port of the Tacview server 35 | "password": "password", //Password of the Tacview server 36 | "radar_refresh_rate": 5, //Ping time of collecting data from tacview 37 | "serverbot_coalition_system": true, //Plug and play Special K's coalition system (need the "database" data) 38 | "default_coalition": "", //Default coalition if the user have not coalition (can be "blue", "red", "GM" or "") 39 | "enable_friendly_ground_units": true, //Show friendly ground units 40 | "enable_enemy_ground_units": true, //Show enemy ground units 41 | "enemy_ground_units_ratio": 40, //Show a enemy ground unit every 40 units 42 | "enemy_ground_units_max_quantity": 10, //Show max 10 enemy units on the map (-1 to deactivate this feature) 43 | "enable_friendly_flight_units": true, //Show friendly aircraft 44 | "enable_enemy_flight_units": true, //Show enemy aircraft 45 | "view_aircraft_when_in_flight": true //Hide enemy aircraft when the user is connected to DCS (need to link the discord account with DCS account with .link command's) 46 | } 47 | ], 48 | "serverbot": true, //Use Special K's server bot 49 | "database": "postgres://user:password@hostname:5432/postgres?sslmode=disable", //Special K's server bot Database 50 | "discord_client_id": "1564564564421", //Client ID of the discord application 51 | "discord_client_secret": "azrfdsflkdsfokdsklfjdskfj", //Client secret of the discord application 52 | "redirect_url": "http://my-url.kaboom/redirect/" //The url used to access DCS Battleground (/redirect/ needed) 53 | } 54 | ``` 55 | 56 | ## Web UI 57 | 58 | DCS Battleground UI presents an emulated radar scope over top a [Open Street Map](https://openstreetmap.org) rendered via [maptalks](https://maptalks.org). The web UI is updated at a configurable simulated refresh rate (by default 5 seconds). 59 | It use Flappie's work to display the Caucasus Layer ! (thanks for is work) 60 | 61 | -------------------------------------------------------------------------------- /src/DataSaver.ts: -------------------------------------------------------------------------------- 1 | import Immutable from "immutable"; 2 | import { 3 | EntityMetadata, 4 | entityMetadataStore, 5 | } from "./stores/EntityMetadataStore"; 6 | import { Geometry, geometryStore } from "./stores/GeometryStore"; 7 | import { hackStore } from "./stores/HackStore"; 8 | import { serverStore } from "./stores/ServerStore"; 9 | import { TrackOptions, trackStore } from "./stores/TrackStore"; 10 | 11 | export type SavedDataV1 = { 12 | version: 0; 13 | sessionId: string; 14 | hacks: Array; 15 | entityMetadata: Array<[number, { tags: Array }]>; 16 | trackOptions: Array<[number, TrackOptions]>; 17 | geometry: Array; 18 | }; 19 | 20 | export type SavedData = SavedDataV1; 21 | 22 | export function saveData() { 23 | const serverState = serverStore.getState(); 24 | if (serverState.sessionId === null || !serverState.server) { 25 | return; 26 | } 27 | 28 | // Save hacks 29 | const hackState = hackStore.getState(); 30 | 31 | const data: SavedDataV1 = { 32 | version: 0, 33 | sessionId: serverState.sessionId, 34 | hacks: hackState.hacks.toArray(), 35 | entityMetadata: entityMetadataStore.getState().entities.entrySeq() 36 | .toJS() as any, 37 | trackOptions: trackStore.getState().trackOptions.entrySeq().toJS() as any, 38 | geometry: geometryStore.getState().geometry.valueSeq().toJS() as any, 39 | }; 40 | 41 | localStorage.setItem( 42 | `saved-data-${serverState.server.name}`, 43 | JSON.stringify(data), 44 | ); 45 | } 46 | 47 | export function restoreData(serverName: string, sessionId: string): boolean { 48 | const data = localStorage.getItem(`saved-data-${serverName}`); 49 | if (!data) { 50 | console.log(`[DataSaver] no saved data`); 51 | return false; 52 | } 53 | 54 | const payloadRaw = JSON.parse(data) as Partial; 55 | if (payloadRaw.version === 0) { 56 | if (payloadRaw.sessionId !== sessionId) { 57 | console.log(`[DataSaver] outdated session id, not restoring`); 58 | return false; 59 | } 60 | 61 | hackStore.setState({ 62 | hacks: Immutable.Set(payloadRaw.hacks!), 63 | }); 64 | entityMetadataStore.setState({ 65 | entities: Immutable.Map( 66 | payloadRaw.entityMetadata!.map(([entityId, data]) => { 67 | return [entityId, { 68 | tags: Immutable.Set(data.tags), 69 | }]; 70 | }), 71 | ), 72 | }); 73 | trackStore.setState({ 74 | trackOptions: Immutable.Map( 75 | payloadRaw.trackOptions!, 76 | ), 77 | }); 78 | 79 | if (payloadRaw.geometry && payloadRaw.geometry.length > 0) { 80 | const maxId = Math.max(...payloadRaw.geometry.map((it) => { 81 | if (it.id > 10000) 82 | return 0 83 | else 84 | return it.id 85 | })) + 1; 86 | geometryStore.setState({ 87 | geometry: Immutable.Map( 88 | payloadRaw.geometry.map((it) => [it.id, it]), 89 | ), 90 | id: maxId, 91 | }); 92 | } 93 | console.log(`[DataSaver] restored data for session ${sessionId}`); 94 | return true; 95 | } else { 96 | console.log(`[DataSaver] unsupported data version: ${payloadRaw.version}`); 97 | return false; 98 | } 99 | } 100 | 101 | serverStore.subscribe( 102 | ( 103 | [name, sessionId]: [string | null, string | null], 104 | [_, lastSessionId]: [string | null, string | null], 105 | ) => { 106 | if (name && sessionId && !lastSessionId) { 107 | restoreData(name, sessionId); 108 | } 109 | }, 110 | (state) => 111 | [state.server?.name || null, state.sessionId] as [ 112 | string | null, 113 | string | null, 114 | ], 115 | ); 116 | 117 | export function dataSaverLoop() { 118 | try { 119 | saveData(); 120 | } finally { 121 | setTimeout(dataSaverLoop, 1500); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/stores/ProfileStore.tsx: -------------------------------------------------------------------------------- 1 | import Immutable from "immutable"; 2 | import { throttle } from "lodash"; 3 | import create from "zustand"; 4 | import { entityMetadataStore } from "./EntityMetadataStore"; 5 | import { trackStore } from "./TrackStore"; 6 | 7 | export type Profile = { 8 | name: string; 9 | tags: Array; 10 | defaultThreatRadius?: number; 11 | defaultWarningRadius?: number; 12 | 13 | // The format of track labels 14 | trackLabelFormat?: string; 15 | }; 16 | 17 | type ProfileStoreData = { 18 | profiles: Immutable.Map; 19 | }; 20 | export const profileStore = create(() => { 21 | let profiles = Immutable.Map(); 22 | 23 | const profilesRaw = localStorage.getItem("profiles"); 24 | if (profilesRaw) { 25 | profiles = Immutable.Map(JSON.parse(profilesRaw)); 26 | } 27 | return { profiles }; 28 | }); 29 | 30 | export function getProfilesForTags( 31 | tags: Immutable.Set, 32 | profiles?: Immutable.Map 33 | ): Array { 34 | return (profiles || profileStore.getState().profiles) 35 | .valueSeq() 36 | .filter((it) => tags.intersect(it.tags).size > 0) 37 | .sort((a, b) => (a.name > b.name ? -1 : 1)) 38 | .toArray(); 39 | } 40 | 41 | export function addProfile(name: string) { 42 | return profileStore.setState((state) => { 43 | if (state.profiles.has(name)) return; 44 | return { profiles: state.profiles.set(name, { name, tags: [] }) }; 45 | }); 46 | } 47 | 48 | export function deleteProfile(name: string) { 49 | return profileStore.setState((state) => { 50 | if (!state.profiles.has(name)) return; 51 | const profiles = state.profiles.remove(name); 52 | 53 | trackStore.setState((state) => { 54 | let trackOptions = state.trackOptions; 55 | for (const [entityId, metadata] of entityMetadataStore.getState() 56 | .entities) { 57 | const profile = getProfilesForTags(metadata.tags, profiles) 58 | .map((it) => [it.defaultThreatRadius, it.defaultWarningRadius]) 59 | .reduce( 60 | (a, b) => [a[0] || b[0], a[1] || b[1]], 61 | [undefined, undefined] 62 | ); 63 | 64 | const opts = trackOptions.get(entityId) || {}; 65 | trackOptions = trackOptions.set(entityId, { 66 | ...opts, 67 | profileThreatRadius: profile[0], 68 | profileWarningRadius: profile[1], 69 | }); 70 | } 71 | return { ...state, trackOptions }; 72 | }); 73 | 74 | return { profiles }; 75 | }); 76 | } 77 | 78 | export function updateProfile(profile: { name: string } & Partial) { 79 | return profileStore.setState((state) => { 80 | const existing = state.profiles.get(profile.name); 81 | if (!existing) return; 82 | 83 | const profiles = state.profiles.set(profile.name, { 84 | ...existing, 85 | ...profile, 86 | }); 87 | 88 | trackStore.setState((state) => { 89 | let trackOptions = state.trackOptions; 90 | for (const [entityId, metadata] of entityMetadataStore.getState() 91 | .entities) { 92 | const profile = getProfilesForTags(metadata.tags, profiles) 93 | .map((it) => [it.defaultThreatRadius, it.defaultWarningRadius]) 94 | .reduce( 95 | (a, b) => [a[0] || b[0], a[1] || b[1]], 96 | [undefined, undefined] 97 | ); 98 | 99 | const opts = trackOptions.get(entityId) || {}; 100 | trackOptions = trackOptions.set(entityId, { 101 | ...opts, 102 | profileThreatRadius: profile[0], 103 | profileWarningRadius: profile[1], 104 | }); 105 | } 106 | return { ...state, trackOptions }; 107 | }); 108 | 109 | return { 110 | profiles, 111 | }; 112 | }); 113 | } 114 | 115 | profileStore.subscribe( 116 | throttle((profiles: ProfileStoreData["profiles"]) => { 117 | localStorage.setItem("profiles", JSON.stringify(profiles.toJSON())); 118 | }, 100), 119 | (state) => state.profiles 120 | ); 121 | -------------------------------------------------------------------------------- /src/stores/ServerStore.tsx: -------------------------------------------------------------------------------- 1 | import Immutable from "immutable"; 2 | import create from "zustand"; 3 | import { DCSBattlegroundClient } from "../DCSBattlegroundClient"; 4 | import { Entity } from "../types/entity"; 5 | import { route } from "../util"; 6 | import { GroundUnitMode, FlightUnitMode } from "./SettingsStore"; 7 | import { createTracks, updateTracks } from "./TrackStore"; 8 | import {addGlobalGeometry, deleteGlobalGeometry} from "./GeometryStore"; 9 | 10 | export type Player = { 11 | DiscordId: string; 12 | PlayerName: string; 13 | } 14 | 15 | export type Server = { 16 | name: string; 17 | ground_unit_modes: Array; 18 | ground_unit_ratio: number; 19 | ground_unit_max_qty: number; 20 | flight_unit_modes: Array; 21 | coalition: string; 22 | map: string; 23 | discord_name: string; 24 | avatar: string; 25 | discord_id: string; 26 | is_editor: string[]; 27 | editor_mode_on: boolean; 28 | view_aircraft_when_in_flight: string; 29 | player_is_connected: boolean; 30 | player_name: string; 31 | toggle_connection: boolean; 32 | zones_size: any[][]; 33 | }; 34 | 35 | export type ServerStoreData = { 36 | editor_mode_on: boolean; 37 | server: Server | null; 38 | entities: Immutable.Map; 39 | offset: number; 40 | sessionId: string | null; 41 | selectedEntityId: number | null; 42 | }; 43 | 44 | export const serverStore = create(() => { 45 | return { 46 | editor_mode_on: false, 47 | server: null, 48 | entities: Immutable.Map(), 49 | offset: 0, 50 | sessionId: null, 51 | selectedEntityId: null 52 | }; 53 | }); 54 | 55 | (window as any).serverStore = serverStore; 56 | 57 | let dcsBattlegroundClient: DCSBattlegroundClient | null = null; 58 | 59 | export function setSelectedEntityId(selectedEntityId: number | null) { 60 | serverStore.setState({ selectedEntityId }); 61 | } 62 | 63 | export function updateServerStore(value: Partial) { 64 | serverStore.setState((state) => { 65 | return { 66 | ...state, 67 | ...value 68 | }; 69 | }); 70 | } 71 | 72 | 73 | function runDCSBattlegroundClient(server: Server | null) { 74 | dcsBattlegroundClient?.close(); 75 | 76 | if (server !== null) { 77 | setTimeout(() => { 78 | dcsBattlegroundClient = new DCSBattlegroundClient( 79 | route(`/servers/${server.name}/events`) 80 | ); 81 | dcsBattlegroundClient?.run((event) => { 82 | if (event.e === "SESSION_STATE") { 83 | serverStore.setState((state) => { 84 | return { 85 | ...state, 86 | sessionId: event.d.session_id, 87 | entities: Immutable.Map( 88 | event.d.objects?.map((obj) => [obj.id, new Entity(obj)]) || [] 89 | ), 90 | }; 91 | }); 92 | createTracks(event); 93 | } else if (event.e === "SESSION_RADAR_SNAPSHOT") { 94 | serverStore.setState((state) => { 95 | return { 96 | ...state, 97 | offset: event.d.offset, 98 | entities: state.entities.withMutations((obj) => { 99 | for (const object of event.d.created) { 100 | obj = obj.set(object.id, new Entity(object)); 101 | } 102 | for (const object of event.d.updated) { 103 | obj = obj.set(object.id, new Entity(object)); 104 | } 105 | for (const objectId of event.d.deleted) { 106 | obj = obj.remove(objectId); 107 | } 108 | }), 109 | }; 110 | }); 111 | updateTracks(event); 112 | } else if (event.e === "SESSION_SHARED_GEOMETRY") { 113 | var data = event.d as any 114 | addGlobalGeometry(data.Add, server.coalition); 115 | addGlobalGeometry(data.Recon, server.coalition); 116 | addGlobalGeometry(data.Quest, server.coalition); 117 | deleteGlobalGeometry(data.Delete, server.coalition); 118 | } else if (event.e === "SESSION_PLAYERS_IN_SLOT") { 119 | var player_is_connected = false 120 | for (const player of event.d.Inflight) { 121 | if (player.DiscordId === server.discord_id) { 122 | player_is_connected = true 123 | server.player_name = player.PlayerName 124 | } 125 | } 126 | if (server.player_is_connected !== player_is_connected) { 127 | server.player_is_connected = player_is_connected 128 | server.toggle_connection = true 129 | } 130 | } 131 | }); 132 | }); 133 | } else { 134 | serverStore.setState({ 135 | entities: Immutable.Map(), 136 | offset: 0, 137 | }); 138 | } 139 | } 140 | 141 | serverStore.subscribe(runDCSBattlegroundClient, (state) => state.server); 142 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/akavel/rsrc v0.10.2 h1:Zxm8V5eI1hW4gGaYsJQUhxpjkENuG91ki8B4zCrvEsw= 3 | github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= 4 | github.com/alioygur/gores v1.2.2 h1:y5mzo3R5cNWz1LBTIwAEU6DmB8grIC7iJwfc91kmBVU= 5 | github.com/alioygur/gores v1.2.2/go.mod h1:z9GuicgNf03HUIQ5aPbiQGshig430sMeaFFfqfRacl8= 6 | github.com/b1naryth1ef/jambon v0.0.4-0.20220109012622-92223168294c h1:mWNvev5C4sPVOWGrjoAQ8FHi+fJ0SmU6qeNak/UYHYI= 7 | github.com/b1naryth1ef/jambon v0.0.4-0.20220109012622-92223168294c/go.mod h1:jVzH5viOXvJXHc1DgMKauvOfR96UY7Zu49jIdUM6GX8= 8 | github.com/b1naryth1ef/jambon v0.0.4-0.20220527200438-d39a4cc60cbe h1:mVhSRttO9tb2LR1r2udwT0mMzLUkSMu8cGVMr1tRWV0= 9 | github.com/b1naryth1ef/jambon v0.0.4-0.20220527200438-d39a4cc60cbe/go.mod h1:jVzH5viOXvJXHc1DgMKauvOfR96UY7Zu49jIdUM6GX8= 10 | github.com/bwmarrin/discordgo v0.23.3-0.20211228023845-29269347e820 h1:MIW5DnBVJAgAy4LYBqWwIMBB0ezklvh8b7DsYvHZHb0= 11 | github.com/bwmarrin/discordgo v0.23.3-0.20211228023845-29269347e820/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= 12 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= 13 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 14 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 15 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8= 17 | github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 18 | github.com/go-chi/cors v1.2.0 h1:tV1g1XENQ8ku4Bq3K9ub2AtgG+p16SmzeMSGTwrOKdE= 19 | github.com/go-chi/cors v1.2.0/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= 20 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 21 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 22 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= 23 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 24 | github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw= 25 | github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 26 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 27 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 28 | github.com/realTristan/disgoauth v1.0.2 h1:dfto2Kf1gFlZsf8XuwRNoemLgk+hGn/TJpSdtMrEh8E= 29 | github.com/realTristan/disgoauth v1.0.2/go.mod h1:t72aRaWMq2gknUZcKONReJlEYFod5sHC86WCJ0X9GxA= 30 | github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= 31 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 32 | github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= 33 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 34 | github.com/spkg/bom v1.0.0 h1:S939THe0ukL5WcTGiGqkgtaW5JW+O6ITaIlpJXTYY64= 35 | github.com/spkg/bom v1.0.0/go.mod h1:lAz2VbTuYNcvs7iaFF8WW0ufXrHShJ7ck1fYFFbVXJs= 36 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 37 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 38 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 39 | github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= 40 | github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= 41 | golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= 42 | golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= 43 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 44 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= 45 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 46 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 47 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 48 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 49 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 50 | gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 51 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 52 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 53 | -------------------------------------------------------------------------------- /src/data/airbases/kola.json: -------------------------------------------------------------------------------- 1 | {"Lakselv": {"callsign": "Lakselv", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Lakselv", "life": 3600, "typeName": "Lakselv"}, "id": 1, "point": [70.057759938201, 24.975276128064, 5.2753481864929], "runways": [{"Name": 34, "course": 0.14419163763523, "length": 2470.1184082031, "position": {"x": 234850.03125, "y": 5.2753481864929, "z": 88378.3359375}, "width": 45}]}, "Rovaniemi": {"callsign": "Rovaniemi", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Rovaniemi", "life": 3600, "typeName": "Rovaniemi"}, "id": 2, "point": [66.574566219304, 25.849959054083, 186.5428314209], "runways": [{"Name": 21, "course": 2.5676310062408, "length": 2772.607421875, "position": {"x": -152462.09375, "y": 186.5428314209, "z": 151503.71875}, "width": 60}]}, "Kemi Tornio": {"callsign": "Kemi Tornio", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Kemi Tornio", "life": 3600, "typeName": "Kemi Tornio"}, "id": 3, "point": [65.792105051259, 24.586346222607, 14.260339736938], "runways": [{"Name": 18, "course": 3.0679059028625, "length": 2396.6000976562, "position": {"x": -243395.96875, "y": 14.260339736938, "z": 101204.6328125}, "width": 65}]}, "Bas 100": {"callsign": "Bas 100", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Bas 100", "life": 3600, "typeName": "Bas 100"}, "id": 4, "point": [67.075946867342, 26.560454027496, 224.3041229248], "runways": [{"Name": 22, "course": 2.3878312110901, "length": 2352.8156738281, "position": {"x": -93814.8046875, "y": 224.3041229248, "z": 177899.3125}, "width": 45}]}, "Kiruna": {"callsign": "Kiruna", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Kiruna", "life": 3600, "typeName": "Kiruna"}, "id": 5, "point": [67.830309878359, 20.352317498997, 437.3486328125], "runways": [{"Name": 21, "course": 2.5431251525879, "length": 2265.5014648438, "position": {"x": -20455.625, "y": 437.3486328125, "z": -90638.921875}, "width": 65}]}, "Severomorsk3": {"callsign": "Severomorsk3", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Severomorsk3", "life": 3600, "typeName": "Severomorsk3"}, "id": 6, "point": [68.85677411136, 33.715938376222, 172.87286376953], "runways": [{"Name": 35, "course": 0.15725308656693, "length": 2285.2131347656, "position": {"x": 148828.296875, "y": 172.87286376953, "z": 445899.6875}, "width": 65}]}, "Bodo": {"callsign": "Bodo", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Bodo", "life": 3600, "typeName": "Bodo"}, "id": 7, "point": [67.266972958478, 14.335477876481, 7.8878412246704], "runways": [{"Name": 7, "course": -1.4644631147385, "length": 2626.8718261719, "position": {"x": -66958.8828125, "y": 7.8878412246704, "z": -348337.3125}, "width": 65}]}, "Severomorsk1": {"callsign": "Severomorsk1", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Severomorsk1", "life": 3600, "typeName": "Severomorsk1"}, "id": 8, "point": [69.041038945947, 33.404833413526, 79.83479309082], "runways": [{"Name": 14, "course": -2.3641948699951, "length": 2768.8662109375, "position": {"x": 164318.28125, "y": 79.83479309082, "z": 430555.5625}, "width": 65}]}, "Olenegorsk": {"callsign": "Olenegorsk", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Olenegorsk", "life": 3600, "typeName": "Olenegorsk"}, "id": 9, "point": [68.165641193625, 33.478248615765, 220.00268554688], "runways": [{"Name": 19, "course": 3.0036821365356, "length": 3311.314453125, "position": {"x": 68386.7109375, "y": 220.00268554688, "z": 451986.375}, "width": 65}]}, "Monchegorsk": {"callsign": "Monchegorsk", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Monchegorsk", "life": 3600, "typeName": "Monchegorsk"}, "id": 10, "point": [67.980068235154, 33.00554585351, 166.96284484863], "runways": [{"Name": 1, "course": -0.2809699177742, "length": 2288.9135742188, "position": {"x": 46867.5625, "y": 166.96284484863, "z": 437312.6875}, "width": 65}]}, "Jokkmokk": {"callsign": "Jokkmokk", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Jokkmokk", "life": 3600, "typeName": "Jokkmokk"}, "id": 11, "point": [66.503506337049, 20.134963877353, 271.79202270508], "runways": [{"Name": 14, "course": -2.5300014019012, "length": 1922.2287597656, "position": {"x": -168128.5625, "y": 271.79202270508, "z": -100659.1484375}, "width": 65}, {"Name": 32, "course": -2.6577680110931, "length": 1922.2287597656, "position": {"x": -167979.21875, "y": 271.79202270508, "z": -101916.2265625}, "width": 65}, {"Name": 15, "course": 3.0833442211151, "length": 1166.818359375, "position": {"x": -170687.75, "y": 271.79202270508, "z": -100980.8671875}, "width": 45}, {"Name": 33, "course": 0.82040852308273, "length": 1166.818359375, "position": {"x": -169760.6875, "y": 271.79202270508, "z": -102914.65625}, "width": 45}]}, "Murmansk International": {"callsign": "Murmansk International", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Murmansk International", "life": 3600, "typeName": "Murmansk International"}, "id": 12, "point": [68.790779721545, 32.735392362593, 76.017112731934], "runways": [{"Name": 13, "course": -2.3607330322266, "length": 2415.9519042969, "position": {"x": 131730.5, "y": 76.017112731934, "z": 409479}, "width": 65}]}, "Kalixfors": {"callsign": "Kalixfors", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Kalixfors", "life": 3600, "typeName": "Kalixfors"}, "id": 13, "point": [67.769859948236, 20.249609559672, 473.07836914062], "runways": [{"Name": 17, "course": -2.9912338256836, "length": 1096.6549072266, "position": {"x": -26773.458984375, "y": 473.07836914062, "z": -94330.125}, "width": 45}]}} -------------------------------------------------------------------------------- /src/stores/TrackStore.tsx: -------------------------------------------------------------------------------- 1 | import Immutable from "immutable"; 2 | import create from "zustand"; 3 | import { 4 | DCSBattlegroundInitialStateEvent, 5 | DCSBattlegroundRadarSnapshotEvent, 6 | } from "../DCSBattlegroundClient"; 7 | import { RawEntityData } from "../types/entity"; 8 | import { getFlyDistance } from "../util"; 9 | import { entityMetadataStore } from "./EntityMetadataStore"; 10 | import { getProfilesForTags } from "./ProfileStore"; 11 | 12 | const DEFAULT_NUM_PREVIOUS_PINGS = 16; 13 | 14 | export type TrackOptions = { 15 | warningRadius?: number; 16 | threatRadius?: number; 17 | profileWarningRadius?: number; 18 | profileThreatRadius?: number; 19 | hideInfo?: boolean; 20 | watching?: boolean; 21 | }; 22 | 23 | export type EntityTrackPing = { 24 | time: number; 25 | position: [number, number]; 26 | altitude: number; 27 | heading: number; 28 | }; 29 | 30 | export type TrackProfileData = { 31 | warningRadius?: number; 32 | threatRadius?: number; 33 | }; 34 | 35 | export type TrackStoreData = { 36 | tracks: Immutable.Map>; 37 | trackOptions: Immutable.Map; 38 | alertTriggers: Immutable.Set; 39 | config: { numPreviousPings: number }; 40 | }; 41 | 42 | export function isTrackVisible(track: Array): boolean { 43 | return track.length >= 3 && estimatedSpeed(track) >= 15; 44 | } 45 | 46 | // Returns the estimated speed (in knots) of an entity based on its track 47 | export function estimatedSpeed(pings: Array): number { 48 | if (pings.length < 2) { 49 | return -1; 50 | } 51 | 52 | const seconds = (pings[0].time - pings[pings.length - 1].time) / 1000; 53 | return ( 54 | (getFlyDistance(pings[0].position, pings[pings.length - 1].position) / 55 | seconds) * 56 | 3600 57 | ); 58 | } 59 | 60 | // Returns the estimated altitude rate (in fpm) of an entity based on its track 61 | export function estimatedAltitudeRate(track: Array): number { 62 | if (track.length < 2) { 63 | return -1; 64 | } 65 | 66 | const seconds = (track[0].time - track[track.length - 1].time) / 1000; 67 | return ( 68 | ((track[0].altitude - track[track.length - 1].altitude) / seconds) * 60 69 | ); 70 | } 71 | 72 | function entityTrackPing(entity: RawEntityData): EntityTrackPing { 73 | return { 74 | time: new Date().getTime(), 75 | position: [entity.latitude, entity.longitude], 76 | altitude: entity.altitude, 77 | heading: entity.heading, 78 | }; 79 | } 80 | 81 | export const trackStore = create(() => { 82 | return { 83 | tracks: Immutable.Map>(), 84 | trackOptions: Immutable.Map(), 85 | alertTriggers: Immutable.Set(), 86 | config: { 87 | numPreviousPings: DEFAULT_NUM_PREVIOUS_PINGS, 88 | }, 89 | }; 90 | }); 91 | 92 | (window as any).trackStore = trackStore; 93 | 94 | function isEntityTrackable(entity: RawEntityData) { 95 | return entity.types.includes("Air") && !entity.types.includes("Parachutist") || entity.types.includes("Ground") && !entity.types.includes("Static") || entity.types.includes("Sea"); 96 | } 97 | 98 | export function createTracks(event: DCSBattlegroundInitialStateEvent) { 99 | trackStore.setState((state) => { 100 | return { 101 | ...state, 102 | tracks: Immutable.Map>( 103 | event.d.objects 104 | ?.filter((obj) => isEntityTrackable(obj)) 105 | .map((obj) => [obj.id, [entityTrackPing(obj)]]) || [] 106 | ), 107 | }; 108 | }); 109 | } 110 | 111 | export function updateTracks(event: DCSBattlegroundRadarSnapshotEvent) { 112 | trackStore.setState((state) => { 113 | return { 114 | ...state, 115 | tracks: state.tracks.withMutations((obj) => { 116 | for (const entity of event.d.created) { 117 | if (!isEntityTrackable(entity)) continue; 118 | obj.set(entity.id, [entityTrackPing(entity)]); 119 | } 120 | for (const entity of event.d.updated) { 121 | if (!isEntityTrackable(entity)) continue; 122 | 123 | const existingPings = obj.get(entity.id) || []; 124 | obj.set(entity.id, [ 125 | entityTrackPing(entity), 126 | ...existingPings.slice(0, state.config.numPreviousPings), 127 | ]); 128 | } 129 | 130 | obj.deleteAll(event.d.deleted); 131 | }), 132 | trackOptions: state.trackOptions.deleteAll(event.d.deleted), 133 | }; 134 | }); 135 | } 136 | 137 | export function setTrackOptions(entityId: number, opts: TrackOptions) { 138 | trackStore.setState((state) => { 139 | return { 140 | ...state, 141 | trackOptions: state.trackOptions.set(entityId, { 142 | ...(state.trackOptions.get(entityId) || {}), 143 | ...opts, 144 | }), 145 | }; 146 | }); 147 | } 148 | 149 | setTimeout(() => { 150 | const state = entityMetadataStore.getState(); 151 | trackStore.setState((trackState) => { 152 | return { 153 | ...trackState, 154 | trackOptions: trackState.trackOptions.withMutations((obj) => { 155 | for (const [entityId, metadata] of state.entities) { 156 | const profile = getProfilesForTags(metadata.tags) 157 | .map((it) => [it.defaultThreatRadius, it.defaultWarningRadius]) 158 | .reduce( 159 | (a, b) => [a[0] || b[0], a[1] || b[1]], 160 | [undefined, undefined] 161 | ); 162 | if (profile[0] || profile[1]) { 163 | const current = obj.get(entityId); 164 | obj = obj.set(entityId, { 165 | ...current, 166 | profileThreatRadius: profile[0], 167 | profileWarningRadius: profile[1], 168 | }); 169 | } 170 | } 171 | }), 172 | }; 173 | }); 174 | }, 1000); 175 | -------------------------------------------------------------------------------- /src/components/MapSettings.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import React from "react"; 3 | import { BiCheckbox, BiCheckboxChecked } from "react-icons/bi"; 4 | import { serverStore } from "../stores/ServerStore"; 5 | import { GroundUnitMode, settingsStore } from "../stores/SettingsStore"; 6 | 7 | function BigCheckbox({ 8 | checked, 9 | toggle, 10 | className, 11 | }: { 12 | checked: boolean; 13 | toggle: () => void; 14 | className?: string; 15 | }): JSX.Element { 16 | return checked ? ( 17 | { 20 | toggle(); 21 | }} 22 | /> 23 | ) : ( 24 | { 27 | toggle(); 28 | }} 29 | /> 30 | ); 31 | } 32 | 33 | export function MapSettings(): JSX.Element { 34 | const server = serverStore((state) => state.server); 35 | const mapSettings = settingsStore((state) => state.map); 36 | 37 | return ( 38 | 39 | 40 | 41 | Map Settings 42 | 43 | 44 | 45 | 46 | 47 | Enable Track Icons 48 | 49 | Displays NATO symbology based on the type of radar track. 50 | 51 | 52 | { 54 | settingsStore.setState({ 55 | map: { 56 | ...mapSettings, 57 | showTrackIcons: !mapSettings.showTrackIcons, 58 | }, 59 | }); 60 | }} 61 | checked={mapSettings.showTrackIcons !== false} 62 | className="ml-auto" 63 | /> 64 | 65 | 66 | 67 | Enable Track Labels 68 | 69 | Displays airframe type and NATO designation based on type of radar 70 | track. 71 | 72 | 73 | 75 | settingsStore.setState({ 76 | map: { 77 | ...mapSettings, 78 | showTrackLabels: !mapSettings.showTrackLabels, 79 | }, 80 | }) 81 | } 82 | checked={mapSettings.showTrackLabels !== false} 83 | className="ml-auto" 84 | /> 85 | 86 | 87 | 88 | 89 | Previous Ping Display Count 90 | 91 | 92 | Number of previous radar-sweep pings to display in-trail for a 93 | tracks. 94 | 95 | 96 | 100 | settingsStore.setState((state) => ({ 101 | ...state, 102 | map: { 103 | ...state.map, 104 | trackTrailLength: parseInt(e.target.value), 105 | }, 106 | })) 107 | } 108 | /> 109 | 110 | 111 | 112 | Ground Unit Display Mode 113 | 114 | Select which ground units to display. Some options may not be 115 | available based on server settings. 116 | 117 | 118 | { 122 | const value = 123 | e.target.value === "none" 124 | ? undefined 125 | : (e.target.value as GroundUnitMode); 126 | settingsStore.setState({ 127 | map: { 128 | ...mapSettings, 129 | groundUnitMode: value, 130 | }, 131 | }); 132 | }} 133 | > 134 | 135 | None 136 | 137 | 145 | Friendly 146 | 147 | 155 | Enemy (JTAC-Mode) 156 | 157 | 158 | 159 | 160 | 161 | ); 162 | } 163 | -------------------------------------------------------------------------------- /src/dcs/aircraft.ts: -------------------------------------------------------------------------------- 1 | class Aircraft { 2 | id: string; 3 | sidcPlatform: string; 4 | natoName?: string; 5 | 6 | constructor(id: string, sidcPlatform: string, natoName?: string) { 7 | this.id = id; 8 | this.sidcPlatform = sidcPlatform || "MFF-"; 9 | this.natoName = natoName; 10 | } 11 | } 12 | 13 | class Plane extends Aircraft { 14 | constructor(id: string, sidcPlatform?: string, natoName?: string) { 15 | super(id, sidcPlatform || "MFF-", natoName); 16 | } 17 | } 18 | 19 | class Helicopter extends Aircraft { 20 | constructor(id: string, sidcPlatform?: string, natoName?: string) { 21 | super(id, sidcPlatform || "MHA-", natoName); 22 | } 23 | } 24 | 25 | export const planes: Record = { 26 | "Tornado GR4": new Plane("Tornado_GR4"), 27 | "Tornado IDS": new Plane("Tornado_IDS"), 28 | "F/A-18A": new Plane("F_A_18A"), 29 | "F/A-18C": new Plane("F_A_18C"), 30 | "F-14A": new Plane("F_14A"), 31 | "Tu-22M3": new Plane("Tu_22M3", "MFB-"), 32 | "F-4E": new Plane("F_4E"), 33 | "B-52H": new Plane("B_52H", "MFB-"), 34 | "MiG-27K": new Plane("MiG_27K", undefined, "Flanker"), 35 | "Su-27": new Plane("Su_27", undefined, "Flanker"), 36 | "MiG-23MLD": new Plane("MiG_23MLD", undefined, "Flogger"), 37 | "Su-25": new Plane("Su_25", "MFA-"), 38 | "Su-25TM": new Plane("Su_25TM", "MFA-"), 39 | "Su-25T": new Plane("Su_25T", "MFA-"), 40 | "Su-33": new Plane("Su_33", undefined, "Flanker"), 41 | "MiG-25PD": new Plane("MiG_25PD", undefined, "Foxbat"), 42 | "MiG-25RBT": new Plane("MiG_25RBT", undefined, "Foxbat"), 43 | "Su-30": new Plane("Su_30", undefined, "Flanker"), 44 | "Su-17M4": new Plane("Su_17M4", undefined, "Fitter"), 45 | "MiG-31": new Plane("MiG_31", undefined, "Foxhound"), 46 | "Tu-95MS": new Plane("Tu_95MS", "MFB-"), 47 | "Su-24M": new Plane("Su_24M", undefined, "Fencer"), 48 | "Su-24MR": new Plane("Su_24MR", undefined, "Fencer"), 49 | "Tu-160": new Plane("Tu_160", "MFB-"), 50 | "F-117A": new Plane("F_117A", "MFA-"), 51 | "B-1B": new Plane("B_1B", "MFB-"), 52 | "S-3B": new Plane("S_3B", "MFPN"), 53 | "S-3B Tanker": new Plane("S_3B_Tanker", "MFKD"), 54 | "Mirage 2000-5": new Plane("Mirage_2000_5"), 55 | "F-15C": new Plane("F_15C"), 56 | "F-15E": new Plane("F_15E"), 57 | "MiG-29A": new Plane("MiG_29A", undefined, "Fulcrum"), 58 | "MiG-29G": new Plane("MiG_29G", undefined, "Fulcrum"), 59 | "MiG-29S": new Plane("MiG_29S", undefined, "Fulcrum"), 60 | "Tu-142": new Plane("Tu_142", "MFR-"), 61 | "C-130": new Plane("C_130", "MFC-"), 62 | "An-26B": new Plane("An_26B", "MFC-"), 63 | "An-30M": new Plane("An_30M", "MFU-"), 64 | "C-17A": new Plane("C_17A", "MFC-"), 65 | "A-50": new Plane("A_50", "MFRW"), 66 | "E-3A": new Plane("E_3A", "MFRW"), 67 | "IL-78M": new Plane("IL_78M", "MFKD"), 68 | "E-2C": new Plane("E_2C", "MFRW"), 69 | "IL-76MD": new Plane("IL_76MD", "MFKD"), 70 | "F-16C bl.50": new Plane("F_16C_bl_50"), 71 | "F-16C bl.52d": new Plane("F_16C_bl_52d"), 72 | "F-16A": new Plane("F_16A"), 73 | "F-16A MLU": new Plane("F_16A_MLU"), 74 | "RQ-1A Predator": new Plane("RQ_1A_Predator", "MFQA"), 75 | "Yak-40": new Plane("Yak_40"), 76 | "KC-135": new Plane("KC_135", "MFC-"), 77 | "FW-190D9": new Plane("FW_190D9"), 78 | "FW-190A8": new Plane("FW_190A8"), 79 | "Bf-109K-4": new Plane("Bf_109K_4"), 80 | "SpitfireLFMkIX": new Plane("SpitfireLFMkIX"), 81 | "SpitfireLFMkIXCW": new Plane("SpitfireLFMkIXCW"), 82 | "P-51D": new Plane("P_51D"), 83 | "P-51D-30-NA": new Plane("P_51D_30_NA"), 84 | "P-47D-30": new Plane("P_47D_30"), 85 | "P-47D-30bl1": new Plane("P_47D_30bl1"), 86 | "P-47D-40": new Plane("P_47D_40"), 87 | "A-20G": new Plane("A_20G", "MFB-"), 88 | "A-10A": new Plane("A_10A", "MFA-"), 89 | "A-10C": new Plane("A_10C", "MFA-"), 90 | "A-10C_2": new Plane("A_10C_2", "MFA-"), 91 | "AJS37": new Plane("AJS37"), 92 | "AV8BNA": new Plane("AV8BNA", "MFL-"), 93 | "KC130": new Plane("KC130", "MFKD"), 94 | "KC135MPRS": new Plane("KC135MPRS", "MFKD"), 95 | "C-101EB": new Plane("C_101EB", "MFT-"), 96 | "C-101CC": new Plane("C_101CC"), 97 | "J-11A": new Plane("J_11A", undefined, "Flanker"), 98 | "JF-17": new Plane("JF_17"), 99 | "KJ-2000": new Plane("KJ_2000", "MFRW"), 100 | "WingLoong-I": new Plane("WingLoong_I"), 101 | "Christen Eagle II": new Plane("Christen_Eagle_II"), 102 | "F-16C_50": new Plane("F_16C_50"), 103 | "F-5E": new Plane("F_5E"), 104 | "F-5E-3": new Plane("F_5E_3"), 105 | "F-86F Sabre": new Plane("F_86F_Sabre"), 106 | "F-14B": new Plane("F_14B"), 107 | "F-14A-135-GR": new Plane("F_14A_135_GR"), 108 | "FA-18C_hornet": new Plane("FA_18C_hornet"), 109 | "Hawk": new Plane("Hawk"), 110 | "I-16": new Plane("I_16"), 111 | "L-39C": new Plane("L_39C"), 112 | "L-39ZA": new Plane("L_39ZA"), 113 | "M-2000C": new Plane("M_2000C"), 114 | "MQ-9 Reaper": new Plane("MQ_9_Reaper", "MFQD"), 115 | "MiG-15bis": new Plane("MiG_15bis"), 116 | "MiG-19P": new Plane("MiG_19P", undefined, "Farmer"), 117 | "MiG-21Bis": new Plane("MiG_21Bis", undefined, "Fishbed"), 118 | "Su-34": new Plane("Su_34", undefined, "Fullback"), 119 | "Yak-52": new Plane("Yak_52"), 120 | "B-17G": new Plane("B_17G", "MFB-"), 121 | "Ju-88A4": new Plane("Ju_88A4"), 122 | "TF-51D": new Plane("TF_51D"), 123 | "Ka-50": new Helicopter("Ka_50"), 124 | "Mi-24V": new Helicopter("Mi_24V"), 125 | "Mi-8MT": new Helicopter("Mi_8MT"), 126 | "Mi-26": new Helicopter("Mi_26"), 127 | "Ka-27": new Helicopter("Ka_27"), 128 | "UH-60A": new Helicopter("UH_60A", "MHU-"), 129 | "CH-53E": new Helicopter("CH_53E", "MHC-"), 130 | "CH-47D": new Helicopter("CH_47D", "MHC-"), 131 | "SH-3W": new Helicopter("SH_3W"), 132 | "AH-64A": new Helicopter("AH_64A"), 133 | "AH-64D": new Helicopter("AH_64D"), 134 | "AH-1W": new Helicopter("AH_1W"), 135 | "SH-60B": new Helicopter("SH_60B"), 136 | "UH-1H": new Helicopter("UH_1H"), 137 | "Mi-28N": new Helicopter("Mi_28N"), 138 | "OH-58D": new Helicopter("OH_58D"), 139 | "Mi-24P": new Helicopter("Mi_24P"), 140 | "SA342M": new Helicopter("SA342M"), 141 | "SA342L": new Helicopter("SA342L"), 142 | "SA342Mistral": new Helicopter("SA342Mistral"), 143 | "SA342Minigun": new Helicopter("SA342Minigun"), 144 | }; 145 | -------------------------------------------------------------------------------- /src/data/airbases/afghanistan.json: -------------------------------------------------------------------------------- 1 | {"Herat": {"callsign": "Herat", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Herat", "life": 3600, "typeName": "Herat"}, "id": 1, "point": [34.220031212738, 62.230129665702, 969.9853515625], "runways": [{"Name": 18, "course": 3.007217168808, "length": 2718.8166503906, "position": {"x": 25820.93359375, "y": 969.9853515625, "z": -371274.625}, "width": 60}]}, "Farah": {"callsign": "Farah", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Farah", "life": 3600, "typeName": "Farah"}, "id": 2, "point": [32.354876963981, 62.173144056959, 682.77307128906], "runways": [{"Name": 33, "course": 0.51833838224411, "length": 2130.6022949219, "position": {"x": -178644.125, "y": 682.77307128906, "z": -378451.53125}, "width": 45}]}, "Shindand": {"callsign": "Shindand", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Shindand", "life": 3600, "typeName": "Shindand"}, "id": 3, "point": [33.383007536994, 62.260362260821, 1132.6168212891], "runways": [{"Name": 36, "course": -0.040249649435282, "length": 2143.525390625, "position": {"x": -64594.5234375, "y": 1132.6168212891, "z": -368871.46875}, "width": 50}]}, "Maymana Zahiraddin Faryabi": {"callsign": "Maymana Zahiraddin Faryabi", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Maymana Zahiraddin Faryabi", "life": 3600, "typeName": "Maymana Zahiraddin Faryabi"}, "id": 4, "point": [35.924391322221, 64.76619558641, 840.72406005859], "runways": [{"Name": 32, "course": 0.65543735027313, "length": 1689.3109130859, "position": {"x": 218034.484375, "y": 840.72406005859, "z": -141298.265625}, "width": 20}]}, "Chaghcharan": {"callsign": "Chaghcharan", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Chaghcharan", "life": 3600, "typeName": "Chaghcharan"}, "id": 5, "point": [34.529394368153, 65.280155710858, 2271.1975097656], "runways": [{"Name": 25, "course": 2.0022814273834, "length": 1740.2584228516, "position": {"x": 63224.14453125, "y": 2271.1975097656, "z": -91680.8125}, "width": 20}]}, "Qala i Naw": {"callsign": "Qala i Naw", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Qala i Naw", "life": 3600, "typeName": "Qala i Naw"}, "id": 6, "point": [34.992086349078, 63.124538116342, 889.08715820312], "runways": [{"Name": 22, "course": 2.4480218887329, "length": 1863.7202148438, "position": {"x": 111818.1875, "y": 889.08715820312, "z": -289403.375}, "width": 20}]}, "Kandahar": {"callsign": "Kandahar", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Kandahar", "life": 3600, "typeName": "Kandahar"}, "id": 7, "point": [31.513217729616, 65.860714738417, 1016.9556884766], "runways": [{"Name": 23, "course": 2.2040691375732, "length": 2980.9267578125, "position": {"x": -270486.3125, "y": 1016.9556884766, "z": -29690.017578125}, "width": 60}]}, "Bost": {"callsign": "Bost", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Bost", "life": 3600, "typeName": "Bost"}, "id": 8, "point": [31.55201665208, 64.363255135089, 776.12280273438], "runways": [{"Name": 1, "course": -0.1213381960988, "length": 1755.09375, "position": {"x": -267202.0625, "y": 776.12280273438, "z": -170619.5625}, "width": 45}]}, "Tarinkot": {"callsign": "Tarinkot", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Tarinkot", "life": 3600, "typeName": "Tarinkot"}, "id": 9, "point": [32.609904004956, 65.856509652753, 1331.7247314453], "runways": [{"Name": 30, "course": -2.1439402103424, "length": 1799.5621337891, "position": {"x": -148524.9375, "y": 1331.7247314453, "z": -31352.18359375}, "width": 60}]}, "Camp Bastion": {"callsign": "Camp Bastion", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Camp Bastion", "life": 3600, "typeName": "Camp Bastion"}, "id": 10, "point": [31.835819789783, 64.22020303609, 879.70733642578], "runways": [{"Name": 1, "course": -0.17365676164627, "length": 3245.2705078125, "position": {"x": -235177.5625, "y": 879.70733642578, "z": -184376.59375}, "width": 65}]}, "Dwyer": {"callsign": "Dwyer", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Dwyer", "life": 3600, "typeName": "Dwyer"}, "id": 11, "point": [31.098253445118, 64.076360572204, 735.65515136719], "runways": [{"Name": 23, "course": 2.2689085006714, "length": 2273.4562988281, "position": {"x": -319375.65625, "y": 735.65515136719, "z": -198386.84375}, "width": 60}]}, "Nimroz": {"callsign": "Nimroz", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Nimroz", "life": 3600, "typeName": "Nimroz"}, "id": 12, "point": [30.970914944055, 62.053757073272, 483.00051879883], "runways": [{"Name": 14, "course": -2.4408674240112, "length": 2116.6396484375, "position": {"x": -333722.6875, "y": 483.00051879883, "z": -389854}, "width": 45}]}, "Camp Bastion Heliport": {"callsign": "Camp Bastion Heliport", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Camp Bastion Heliport", "life": 3600, "typeName": "Camp Bastion Heliport"}, "id": 13, "point": [31.853777562069, 64.212513895988, 884.36590576172], "runways": [{"Name": 1, "course": -0.17367441952229, "length": 387.45718383789, "position": {"x": -234602.5, "y": 884.36590576172, "z": -185373.34375}, "width": 45}]}, "Shindand Heliport": {"callsign": "Shindand Heliport", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Shindand Heliport", "life": 3600, "typeName": "Shindand Heliport"}, "id": 14, "point": [33.406894837978, 62.267975429692, 1157.8818359375], "runways": [{"Name": 36, "course": -0.040119472891092, "length": 209.60963439941, "position": {"x": -62917.2578125, "y": 1157.8818359375, "z": -368183.625}, "width": 45}]}, "Kandahar Heliport": {"callsign": "Kandahar Heliport", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Kandahar Heliport", "life": 3600, "typeName": "Kandahar Heliport"}, "id": 15, "point": [31.521070343338, 65.84696137969, 1017.0010375977], "runways": [{"Name": 23, "course": 2.2045719623566, "length": 218.94323730469, "position": {"x": -268832.3125, "y": 1017.0010375977, "z": -29906.06640625}, "width": 45}]}} -------------------------------------------------------------------------------- /src/data/airbases/thechannel.json: -------------------------------------------------------------------------------- 1 | {"Abbeville Drucat": {"callsign": "Abbeville Drucat", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Abbeville Drucat", "life": 3600, "typeName": "Abbeville Drucat"}, "id": 1, "point": [50.149531917406, 1.8362860929554, 56.028057098389], "runways": [{"Name": 20, "course": 2.7363274097443, "length": 1486.6141357422, "position": {"x": -81655.46875, "y": 56.028057098389, "z": 15915.376953125}, "width": 50}, {"Name": 2, "course": 1.5706750154495, "length": 1486.6141357422, "position": {"x": -82671.40625, "y": 56.028057098389, "z": 16885.53515625}, "width": 50}]}, "Merville Calonne": {"callsign": "Merville Calonne", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Merville Calonne", "life": 3600, "typeName": "Merville Calonne"}, "id": 2, "point": [50.615302099484, 2.6433201424095, 16.000015258789], "runways": [{"Name": 32, "course": 0.71035009622574, "length": 1188.6251220703, "position": {"x": -29311.71484375, "y": 16.000015258789, "z": 73776.75}, "width": 50}, {"Name": 14, "course": 1.7795081138611, "length": 1188.6251220703, "position": {"x": -29160.00390625, "y": 16.000015258789, "z": 74920.1328125}, "width": 50}]}, "Saint Omer Longuenesse": {"callsign": "Saint Omer Longuenesse", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Saint Omer Longuenesse", "life": 3600, "typeName": "Saint Omer Longuenesse"}, "id": 3, "point": [50.728779428768, 2.2275923344968, 67.000068664551], "runways": [{"Name": 8, "course": -1.5119603872299, "length": 610.06732177734, "position": {"x": -16951.69140625, "y": 67.000068664551, "z": 45167.6875}, "width": 45}]}, "Dunkirk Mardyck": {"callsign": "Dunkirk Mardyck", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Dunkirk Mardyck", "life": 3600, "typeName": "Dunkirk Mardyck"}, "id": 4, "point": [51.029470822466, 2.2484347681531, 5.0000047683716], "runways": [{"Name": 8, "course": -1.4140980243683, "length": 560.81640625, "position": {"x": 16496.0390625, "y": 5.0000047683716, "z": 46954.35546875}, "width": 45}]}, "Manston": {"callsign": "Manston", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Manston", "life": 3600, "typeName": "Manston"}, "id": 5, "point": [51.344455587934, 1.3279982667496, 49.00025177002], "runways": [{"Name": 10, "course": -1.7848118543625, "length": 2586.3937988281, "position": {"x": 52264.83984375, "y": 49.00025177002, "z": -15815.91796875}, "width": 45}, {"Name": 28, "course": -0.98665797710419, "length": 2586.3937988281, "position": {"x": 52938.58984375, "y": 49.00025177002, "z": -15348.859375}, "width": 45}, {"Name": 4, "course": -1.7855516672134, "length": 1611.9527587891, "position": {"x": 52328.65234375, "y": 49.00025177002, "z": -15800.0703125}, "width": 45}]}, "Hawkinge": {"callsign": "Hawkinge", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Hawkinge", "life": 3600, "typeName": "Hawkinge"}, "id": 6, "point": [51.115113231725, 1.1605724302277, 160.00015258789], "runways": [{"Name": 19, "course": 3.1388094425201, "length": 712.29180908203, "position": {"x": 26989.935546875, "y": 160.00015258789, "z": -29402.578125}, "width": 60}, {"Name": 1, "course": 2.4576451778412, "length": 712.29180908203, "position": {"x": 27003.00390625, "y": 160.00015258789, "z": -29571.40234375}, "width": 60}]}, "Lympne": {"callsign": "Lympne", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Lympne", "life": 3600, "typeName": "Lympne"}, "id": 7, "point": [51.083691076152, 1.0124369664514, 107.00010681152], "runways": [{"Name": 13, "course": -2.3396072387695, "length": 930.91259765625, "position": {"x": 23776.552734375, "y": 107.00010681152, "z": -39519.90625}, "width": 60}]}, "Detling": {"callsign": "Detling", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Detling", "life": 3600, "typeName": "Detling"}, "id": 8, "point": [51.308241263359, 0.60552410588841, 190.00018310547], "runways": [{"Name": 23, "course": 2.3282477855682, "length": 1061.3370361328, "position": {"x": 49594.2421875, "y": 190.00018310547, "z": -67923.3046875}, "width": 60}]}, "Eastchurch": {"callsign": "Eastchurch", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Eastchurch", "life": 3600, "typeName": "Eastchurch"}, "id": 10, "point": [51.390814422965, 0.84044302594886, 12.261972427368], "runways": [{"Name": 28, "course": -1.7004579305649, "length": 909.41394042969, "position": {"x": 58521.96875, "y": 12.261972427368, "z": -50427.828125}, "width": 60}, {"Name": 10, "course": 2.7447440624237, "length": 909.41394042969, "position": {"x": 58594.84375, "y": 12.261972427368, "z": -49947.2734375}, "width": 60}]}, "High Halden": {"callsign": "High Halden", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "High Halden", "life": 3600, "typeName": "High Halden"}, "id": 12, "point": [51.122666449012, 0.68734669867666, 32.000030517578], "runways": [{"Name": 11, "course": -1.7865359783173, "length": 922.82232666016, "position": {"x": 28992.265625, "y": 32.000030517578, "z": -62020.10546875}, "width": 45}, {"Name": 29, "course": 2.5968663692474, "length": 922.82232666016, "position": {"x": 29387.9453125, "y": 32.000030517578, "z": -61144.9140625}, "width": 45}]}, "Headcorn": {"callsign": "Headcorn", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Headcorn", "life": 3600, "typeName": "Headcorn"}, "id": 13, "point": [51.182949227001, 0.68148244699714, 35.000034332275], "runways": [{"Name": 10, "course": -1.6156657934189, "length": 1121.7657470703, "position": {"x": 35780.8203125, "y": 35.000034332275, "z": -62104.40625}, "width": 45}, {"Name": 29, "course": -0.21711808443069, "length": 1121.7657470703, "position": {"x": 35681.921875, "y": 35.000034332275, "z": -62265.03125}, "width": 45}]}, "Biggin Hill": {"callsign": "Biggin Hill", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Biggin Hill", "life": 3600, "typeName": "Biggin Hill"}, "id": 14, "point": [51.328410172904, 0.034490518670974, 168.41766357422], "runways": [{"Name": 23, "course": 2.3032472133636, "length": 601.25360107422, "position": {"x": 53454.53515625, "y": 168.41766357422, "z": -107463.625}, "width": 45}, {"Name": 5, "course": 1.2604933977127, "length": 601.25360107422, "position": {"x": 52952.609375, "y": 168.41766357422, "z": -107296.578125}, "width": 45}, {"Name": 30, "course": 2.6528425216675, "length": 684.08502197266, "position": {"x": 53871.83984375, "y": 168.41766357422, "z": -107363.125}, "width": 45}]}} -------------------------------------------------------------------------------- /src/hooks/useRenderCombatZones.tsx: -------------------------------------------------------------------------------- 1 | import Immutable from "immutable"; 2 | import * as maptalks from "maptalks"; 3 | import ms from "milsymbol"; 4 | import { useEffect } from "react"; 5 | import GroundUnitData from "../data/units/ground.json"; 6 | import { 7 | Server, 8 | serverStore, 9 | setSelectedEntityId, 10 | } from "../stores/ServerStore"; 11 | import { GroundUnitMode, settingsStore } from "../stores/SettingsStore"; 12 | import { 13 | Entity, 14 | getCoalitionColor, 15 | getCoalitionIdentity, 16 | } from "../types/entity"; 17 | 18 | type UnitData = { 19 | code: string; 20 | dcs_codes: Array; 21 | mil_std_2525_d: number; 22 | name: string; 23 | sidc?: string; 24 | }; 25 | 26 | export const groundIconCache: Record = {}; 27 | export const groundUnitData = Immutable.Map( 28 | GroundUnitData.map((it) => [it.dcs_codes[0], it] as [string, UnitData]) 29 | ); 30 | 31 | function renderCombatZone(layer: maptalks.VectorLayer, unit: Entity, zoneSize: number) { 32 | const collection = layer.getGeometryById( 33 | unit.id 34 | ) as maptalks.GeometryCollection; 35 | if (collection) { 36 | return; 37 | } 38 | 39 | const unitData = groundUnitData.get(unit.name); 40 | let sidc; 41 | if (unitData && unitData.sidc) { 42 | sidc = `${unitData.sidc[0]}${getCoalitionIdentity( 43 | unit.coalition 44 | )}${unitData.sidc.slice(2)}`; 45 | } else { 46 | sidc = `S${getCoalitionIdentity(unit.coalition)}G-E-----`; 47 | } 48 | 49 | if (sidc && !groundIconCache[sidc]) { 50 | groundIconCache[sidc] = new ms.Symbol(sidc, { 51 | size: 32, 52 | frame: true, 53 | fill: true, 54 | strokeWidth: 8, 55 | monoColor: getCoalitionColor(unit.coalition), 56 | }).toDataURL(); 57 | } 58 | 59 | const icon = new maptalks.Circle([(unit.longitude+((unit.ratioLong * 0.04)-0.02)*zoneSize/4000), (unit.latitude+((unit.ratioLat * 0.04)-0.02)*zoneSize/4000)], zoneSize, { 60 | draggable: false, 61 | visible: true, 62 | editable: false, 63 | symbol: { 64 | lineColor: getCoalitionColor(unit.coalition), 65 | lineWidth: 0, 66 | polygonFill: getCoalitionColor(unit.coalition) 67 | }, 68 | }); 69 | 70 | const col = new maptalks.GeometryCollection([icon], { 71 | id: unit.id, 72 | draggable: false, 73 | }); 74 | 75 | layer.addGeometry(col); 76 | } 77 | 78 | function renderCombatZones( 79 | map: maptalks.Map, 80 | [entities, offset, server]: [ 81 | Immutable.Map, 82 | number, 83 | Server | null 84 | ], 85 | [_x, lastOffset, _y]: [unknown, number, unknown] 86 | ) { 87 | const groundUnitMode = settingsStore.getState().map.groundUnitMode; 88 | const coalition = server?.coalition; 89 | const { editor_mode_on } = serverStore.getState(); 90 | const isVisible = (target: Entity) => { 91 | if (coalition === "GM" || editor_mode_on) { 92 | return true 93 | } else if (coalition === "blue" && target.coalition === "Enemies") { 94 | return server?.ground_unit_modes.includes(GroundUnitMode.FRIENDLY) 95 | } else if (coalition === "blue" && target.coalition === "Allies") { 96 | return server?.ground_unit_modes.includes(GroundUnitMode.ENEMY) 97 | } else if (coalition === "red" && target.coalition === "Enemies") { 98 | return server?.ground_unit_modes.includes(GroundUnitMode.ENEMY) 99 | } else if (coalition === "red" && target.coalition === "Allies") { 100 | return server?.ground_unit_modes.includes(GroundUnitMode.FRIENDLY) 101 | } 102 | return false; 103 | }; 104 | 105 | var layer 106 | layer = map.getLayer("combat-zones-blue") as maptalks.VectorLayer; 107 | for (const geo of layer.getGeometries()) { 108 | const entity = entities.get((geo as any)._id as number); 109 | if (!entity || !isVisible(entity)) { 110 | geo.remove(); 111 | } 112 | } 113 | 114 | layer = map.getLayer("combat-zones-red") as maptalks.VectorLayer; 115 | for (const geo of layer.getGeometries()) { 116 | const entity = entities.get((geo as any)._id as number); 117 | if (!entity || !isVisible(entity)) { 118 | geo.remove(); 119 | } 120 | } 121 | 122 | layer = map.getLayer("combat-zones") as maptalks.VectorLayer; 123 | for (const geo of layer.getGeometries()) { 124 | const entity = entities.get((geo as any)._id as number); 125 | if (!entity || !isVisible(entity)) { 126 | geo.remove(); 127 | } 128 | } 129 | 130 | for (const entity of entities.valueSeq()) { 131 | if ( 132 | isVisible(entity) && 133 | (lastOffset === 0 || entity.updatedAt > lastOffset) 134 | ) { 135 | if (entity.coalition === "Allies") { 136 | layer = map.getLayer("combat-zones-red") as maptalks.VectorLayer; 137 | } else if (entity.coalition === "Enemies") { 138 | layer = map.getLayer("combat-zones-blue") as maptalks.VectorLayer; 139 | } else { 140 | layer = map.getLayer("combat-zones") as maptalks.VectorLayer; 141 | } 142 | 143 | var zoneSize = 10; 144 | if (server?.zones_size && server?.zones_size.length > 0) { 145 | for(var i = 0;i < server?.zones_size.length;i += 1) { 146 | if (entity.types.includes(server?.zones_size[i][0])) { 147 | zoneSize = server?.zones_size[i][1]; 148 | break; 149 | } else if ( server?.zones_size[i][0] === "default") { 150 | zoneSize = server?.zones_size[i][1]; 151 | } 152 | } 153 | } 154 | renderCombatZone(layer, entity, zoneSize); 155 | } 156 | } 157 | 158 | lastOffset = offset; 159 | } 160 | 161 | export default function useRenderCombatZones(map: maptalks.Map | null) { 162 | const [groundUnitMode] = settingsStore((state) => [state.map.groundUnitMode]); 163 | 164 | useEffect(() => { 165 | if (!map) return; 166 | const { entities, offset, server } = serverStore.getState(); 167 | if (!server) return; 168 | renderCombatZones( 169 | map, 170 | [ 171 | entities.filter( 172 | (it) => 173 | it.types.includes("Ground") && 174 | !it.types.includes("Air") && 175 | !it.types.includes("Static") 176 | ), 177 | offset, 178 | server, 179 | ], 180 | [null, 0, null] 181 | ); 182 | }, [map, groundUnitMode]); 183 | 184 | useEffect(() => { 185 | if (!map) return; 186 | 187 | return serverStore.subscribe( 188 | (a: [Immutable.Map, number, Server | null], b) => 189 | renderCombatZones(map, a, b), 190 | (state) => 191 | [ 192 | state.entities.filter( 193 | (it) => 194 | it.types.includes("Ground") && 195 | !it.types.includes("Air") && 196 | !it.types.includes("Static") 197 | ), 198 | state.offset, 199 | state.server, 200 | ] as [Immutable.Map, number, Server | null] 201 | ); 202 | }, [map]); 203 | } 204 | -------------------------------------------------------------------------------- /server/state.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "strconv" 7 | "strings" 8 | "sync" 9 | "math/rand" 10 | 11 | "github.com/b1naryth1ef/jambon/tacview" 12 | ) 13 | 14 | type StateObject struct { 15 | Id uint64 `json:"id"` 16 | Types []string `json:"types"` 17 | Properties map[string]string `json:"properties"` 18 | Latitude float64 `json:"latitude"` 19 | Longitude float64 `json:"longitude"` 20 | Altitude float64 `json:"altitude"` 21 | Heading float64 `json:"heading"` 22 | UpdatedAt int64 `json:"updated_at"` 23 | CreatedAt int64 `json:"created_at"` 24 | 25 | Deleted bool `json:"-"` 26 | 27 | Visible bool `json:"visible"` 28 | RatioLong float64 `json:"ratio_long"` 29 | RatioLat float64 `json:"ratio_lat"` 30 | } 31 | 32 | func NewStateObject(ts int64, sourceObj *tacview.Object, coordBase [2]float64, visible bool) (*StateObject, error) { 33 | obj := &StateObject{ 34 | Id: sourceObj.Id, 35 | Types: []string{}, 36 | Properties: make(map[string]string), 37 | Deleted: false, 38 | CreatedAt: ts, 39 | Visible: visible, 40 | RatioLong: rand.Float64(), 41 | RatioLat: rand.Float64(), 42 | } 43 | //obj.RatioLong = rand.Float64() 44 | //obj.RatioLat = rand.Float64() 45 | 46 | err := obj.update(ts, sourceObj, coordBase, visible) 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | return obj, nil 52 | } 53 | 54 | func (obj *StateObject) updateLocation(data string, coordBase [2]float64) error { 55 | parts := strings.Split(data, "|") 56 | 57 | if len(parts) >= 3 { 58 | if parts[0] != "" { 59 | lng, err := strconv.ParseFloat(parts[0], 64) 60 | if err != nil { 61 | return err 62 | } 63 | obj.Longitude = lng + coordBase[1] 64 | } 65 | 66 | if parts[1] != "" { 67 | lat, err := strconv.ParseFloat(parts[1], 64) 68 | if err != nil { 69 | return err 70 | } 71 | obj.Latitude = lat + coordBase[0] 72 | } 73 | 74 | if parts[2] != "" { 75 | alt, err := strconv.ParseFloat(parts[2], 64) 76 | if err != nil { 77 | return err 78 | } 79 | obj.Altitude = alt 80 | } 81 | 82 | if len(parts) == 9 && parts[8] != "" { 83 | heading, err := strconv.ParseFloat(parts[8], 64) 84 | if err != nil { 85 | return err 86 | } 87 | obj.Heading = heading 88 | } 89 | } 90 | return nil 91 | } 92 | 93 | func (obj *StateObject) update(ts int64, sourceObj *tacview.Object, coordBase [2]float64, visible bool) error { 94 | if sourceObj.Deleted { 95 | obj.Deleted = true 96 | } else { 97 | for _, prop := range sourceObj.Properties { 98 | if prop.Key == "T" { 99 | err := obj.updateLocation(prop.Value, coordBase) 100 | if err != nil { 101 | return err 102 | } 103 | } else if prop.Key == "Type" { 104 | obj.Types = strings.Split(prop.Value, "+") 105 | } else { 106 | obj.Properties[prop.Key] = prop.Value 107 | } 108 | } 109 | } 110 | obj.Visible = visible 111 | obj.UpdatedAt = ts 112 | return nil 113 | } 114 | 115 | type sessionState struct { 116 | sync.RWMutex 117 | 118 | // Session ID (the tacview recording time) 119 | sessionId string 120 | 121 | // Base to use for all incoming coordinates 122 | coordBase [2]float64 123 | 124 | // Tracked objects 125 | objects map[uint64]*StateObject 126 | 127 | serverConfig *TacViewServerConfig 128 | idVisibleBlueList map[uint64]bool 129 | idVisibleRedList map[uint64]bool 130 | 131 | offset int64 132 | active bool 133 | } 134 | 135 | // Called when our connection is interrupted 136 | func (s *sessionState) reset() { 137 | s.Lock() 138 | defer s.Unlock() 139 | s.objects = make(map[uint64]*StateObject) 140 | s.active = false 141 | } 142 | 143 | // Called when the tacview stream starts 144 | func (s *sessionState) initialize(header *tacview.Header, serverConfig *TacViewServerConfig) error { 145 | s.reset() 146 | 147 | s.Lock() 148 | defer s.Unlock() 149 | globalObj := header.InitialTimeFrame.Get(0) 150 | if globalObj == nil { 151 | return errors.New("TacView initial time frame is missing global object") 152 | } 153 | 154 | sessionId := globalObj.Get("RecordingTime") 155 | if sessionId != nil { 156 | s.sessionId = sessionId.Value 157 | } 158 | 159 | refLat := globalObj.Get("ReferenceLatitude") 160 | refLng := globalObj.Get("ReferenceLongitude") 161 | 162 | if refLat != nil && refLng != nil { 163 | refLatF, err := strconv.ParseFloat(refLat.Value, 64) 164 | if err != nil { 165 | return err 166 | } 167 | refLngF, err := strconv.ParseFloat(refLng.Value, 64) 168 | if err != nil { 169 | return err 170 | } 171 | 172 | s.coordBase = [2]float64{refLatF, refLngF} 173 | } else { 174 | s.coordBase = [2]float64{0.0, 0.0} 175 | } 176 | 177 | s.active = true 178 | s.serverConfig = serverConfig 179 | s.idVisibleBlueList = make(map[uint64]bool) 180 | s.idVisibleRedList = make(map[uint64]bool) 181 | 182 | s.update(&header.InitialTimeFrame) 183 | return nil 184 | } 185 | 186 | func (s *sessionState) update(tf *tacview.TimeFrame) { 187 | s.offset = int64(tf.Offset) 188 | EnableEnemyGroundUnits := s.serverConfig.EnableEnemyGroundUnits 189 | EnemyGroundUnitsRatio := s.serverConfig.EnemyGroundUnitsRatio 190 | EnemyGroundUnitsMaxQuantity := s.serverConfig.EnemyGroundUnitsMaxQuantity 191 | 192 | for i, object := range tf.Objects { 193 | Coalition := "" 194 | for _, prop := range object.Properties { 195 | if prop.Key == "Coalition" { 196 | Coalition = prop.Value 197 | } 198 | } 199 | 200 | Visible := false 201 | if !EnableEnemyGroundUnits { 202 | Visible = false 203 | } else if EnemyGroundUnitsMaxQuantity == -1 { 204 | Visible = true 205 | } else if object.Deleted { 206 | Visible = false 207 | delete(s.idVisibleRedList,object.Id) 208 | delete(s.idVisibleBlueList,object.Id) 209 | } else if s.idVisibleRedList[object.Id] || s.idVisibleBlueList[object.Id] { 210 | Visible = true 211 | } else if i%EnemyGroundUnitsRatio != 0 { 212 | Visible = false 213 | } else if (Coalition == "Allies" && len(s.idVisibleRedList) < EnemyGroundUnitsMaxQuantity) { 214 | Visible = true 215 | s.idVisibleRedList[object.Id] = true 216 | } else if (Coalition == "Enemies" && len(s.idVisibleBlueList) < EnemyGroundUnitsMaxQuantity) { 217 | Visible = true 218 | s.idVisibleBlueList[object.Id] = true 219 | } else { 220 | Visible = false 221 | } 222 | 223 | if _, exists := s.objects[object.Id]; exists { 224 | s.objects[object.Id].update(int64(tf.Offset), object, s.coordBase, Visible) 225 | } else { 226 | stateObj, err := NewStateObject(int64(tf.Offset), object, s.coordBase, Visible) 227 | if err != nil { 228 | log.Printf("Error processing object: %v", err) 229 | continue 230 | } 231 | 232 | s.objects[object.Id] = stateObj 233 | } 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import React, { useEffect } from "react"; 3 | import { BiLoader } from "react-icons/bi"; 4 | import { Link, Redirect, Route, Switch } from "react-router-dom"; 5 | import useFetch, { CachePolicies } from "use-http"; 6 | import { Map } from "./components/Map"; 7 | import { Caucasus } from "./dcs/maps/Caucasus"; 8 | import { DCSMap } from "./dcs/maps/DCSMap"; 9 | import { Marianas } from "./dcs/maps/Marianas"; 10 | import { PersianGulf } from "./dcs/maps/PersianGulf"; 11 | import { Sinai } from "./dcs/maps/Sinai"; 12 | import { Syria } from "./dcs/maps/Syria"; 13 | import { Falklands } from "./dcs/maps/Falklands"; 14 | import { Normandy } from "./dcs/maps/Normandy"; 15 | import { TheChannel } from "./dcs/maps/TheChannel"; 16 | import { Nevada } from "./dcs/maps/Nevada"; 17 | import { Kola } from "./dcs/maps/Kola"; 18 | import { Afghanistan } from "./dcs/maps/Afghanistan"; 19 | import { Server, serverStore } from "./stores/ServerStore"; 20 | import { route } from "./util"; 21 | 22 | type ServerMetadata = { 23 | name: string; 24 | enabled: boolean; 25 | players: Array<{ name: string; type: string }>; 26 | }; 27 | 28 | function ServerOption({ server }: { server: ServerMetadata }) { 29 | return ( 30 | 34 | {server.name} 35 | 36 | ({server.players.length} online) 37 | 38 | 39 | ); 40 | } 41 | 42 | function ServerConnectModal() { 43 | const { 44 | loading, 45 | error, 46 | data: servers, 47 | get, 48 | } = useFetch>( 49 | process.env.NODE_ENV === "production" 50 | ? `/api/servers` 51 | : `http://localhost:7789/api/servers`, 52 | [] 53 | ); 54 | 55 | return ( 56 | 64 | 65 | Select Server 66 | 67 | 68 | {loading && ( 69 | 70 | )} 71 | {error && ( 72 | 73 | Something went wrong accessing the backend server. Please check your 74 | connection and get()}>try again. 75 | 76 | )} 77 | {servers && ( 78 | 79 | {servers.map((it) => {if (it.enabled) { 80 | return 81 | 82 | }})} 83 | 84 | )} 85 | 86 | 87 | ); 88 | } 89 | 90 | function ServerContainer({ serverName }: { serverName: string }) { 91 | const [refLat, refLng] = serverStore((state) => { 92 | const globalObj = state.entities.get(0); 93 | if (!globalObj) return [undefined, undefined]; 94 | return [ 95 | globalObj.properties.ReferenceLatitude as number | undefined, 96 | globalObj.properties.ReferenceLongitude as number | undefined, 97 | ]; 98 | }); 99 | 100 | const { 101 | response, 102 | data: server, 103 | loading, 104 | error, 105 | } = useFetch( 106 | route(`/servers/${serverName}`), 107 | { cachePolicy: CachePolicies.NO_CACHE }, 108 | [serverName] 109 | ); 110 | 111 | useEffect(() => { 112 | if (server && !error && !loading) { 113 | serverStore.setState({ server: server }); 114 | return () => serverStore.setState({ server: null }); 115 | } 116 | }, [server, error, loading]); 117 | 118 | if (response.status === 404) { 119 | return ; 120 | } 121 | 122 | if (error) { 123 | return ( 124 | 125 | Error: {error.toString()} 126 | 127 | ); 128 | } 129 | 130 | if (loading || !refLat || !refLng) { 131 | return ( 132 | 133 | ); 134 | } 135 | 136 | let dcsMap: DCSMap | null = null; 137 | if (server && server.map === "Caucasus") { 138 | dcsMap = Caucasus; 139 | } else if (server && server.map === "Sinai") { 140 | dcsMap = Sinai; 141 | } else if (server && server.map === "Syria") { 142 | dcsMap = Syria; 143 | } else if (server && server.map === "PersianGulf") { 144 | dcsMap = PersianGulf; 145 | } else if (server && server.map === "Marianas") { 146 | dcsMap = Marianas; 147 | } else if (server && server.map === "Falklands") { 148 | dcsMap = Falklands; 149 | } else if (server && server.map === "Normandy") { 150 | dcsMap = Normandy; 151 | } else if (server && server.map === "Nevada") { 152 | dcsMap = Nevada; 153 | } else if (server && server.map === "Kola") { 154 | dcsMap = Kola; 155 | } else if (server && server.map === "Afghanistan") { 156 | dcsMap = Afghanistan; 157 | } else if (refLat >= 38 && refLat <= 48 && refLng >= 26 && refLng <= 48) { 158 | dcsMap = Caucasus; 159 | } else if (refLat >= 25 && refLat < 34 && refLng >= 28 && refLng <= 37) { 160 | dcsMap = Sinai; 161 | } else if (refLat >= 28 && refLat < 38 && refLng >= 27 && refLng <= 42) { 162 | dcsMap = Syria; 163 | } else if (refLat >= 20 && refLat <= 33 && refLng >= 46 && refLng <= 64) { 164 | dcsMap = PersianGulf; 165 | } else if (refLat >= 7 && refLat <= 23 && refLng >= 136 && refLng <= 153) { 166 | dcsMap = Marianas; 167 | } else if (refLat >= -59 && refLat <= -45 && refLng >= -88 && refLng <= -38) { 168 | dcsMap = Falklands; 169 | } else if (refLat >= 45 && refLat <= 52 && refLng >= -5 && refLng <= 4) { 170 | dcsMap = Normandy; 171 | } else if (refLat >= 32 && refLat <= 40 && refLng >= -121 && refLng <= -112) { 172 | dcsMap = Nevada; 173 | } else if (refLat >= 62 && refLat <= 73 && refLng >= -4 && refLng <= 40) { 174 | dcsMap = Kola; 175 | } else if (refLat >= 28 && refLat <= 39 && refLng >= 60 && refLng <= 74) { 176 | dcsMap = Afghanistan; 177 | } else { 178 | console.log(refLat, refLng); 179 | return ( 180 | 181 | Failed to detect map. Please include the following in a bug report: ( 182 | {refLat}, {refLng}) 183 | 184 | ); 185 | } 186 | 187 | return ; 188 | } 189 | 190 | function App() { 191 | return ( 192 | 193 | 194 | 195 | { 203 | return ; 204 | }} 205 | /> 206 | 207 | 208 | ); 209 | } 210 | 211 | export default App; 212 | -------------------------------------------------------------------------------- /src/util.tsx: -------------------------------------------------------------------------------- 1 | import { DCSMap } from "./dcs/maps/DCSMap"; 2 | 3 | const toRad = (n: number) => { 4 | return (n * Math.PI) / 180; 5 | }; 6 | 7 | const toDeg = (rad: number) => (rad * 180) / Math.PI; 8 | 9 | export function computeBRAA( 10 | lat1: number, 11 | lon1: number, 12 | brng: number, 13 | dist: number 14 | ): [number, number] { 15 | var a = 6378137, 16 | b = 6356752.3142, 17 | f = 1 / 298.257223563, // WGS-84 ellipsiod 18 | s = dist, 19 | alpha1 = toRad(brng), 20 | sinAlpha1 = Math.sin(alpha1), 21 | cosAlpha1 = Math.cos(alpha1), 22 | tanU1 = (1 - f) * Math.tan(toRad(lat1)), 23 | cosU1 = 1 / Math.sqrt(1 + tanU1 * tanU1), 24 | sinU1 = tanU1 * cosU1, 25 | sigma1 = Math.atan2(tanU1, cosAlpha1), 26 | sinAlpha = cosU1 * sinAlpha1, 27 | cosSqAlpha = 1 - sinAlpha * sinAlpha, 28 | uSq = (cosSqAlpha * (a * a - b * b)) / (b * b), 29 | A = 1 + (uSq / 16384) * (4096 + uSq * (-768 + uSq * (320 - 175 * uSq))), 30 | B = (uSq / 1024) * (256 + uSq * (-128 + uSq * (74 - 47 * uSq))), 31 | sigma = s / (b * A), 32 | sigmaP = 2 * Math.PI; 33 | 34 | let sinSigma: number = 0; 35 | let cosSigma: number = 0; 36 | let cos2SigmaM = 0; 37 | 38 | while (Math.abs(sigma - sigmaP) > 1e-12) { 39 | sinSigma = Math.sin(sigma); 40 | cosSigma = Math.cos(sigma); 41 | cos2SigmaM = Math.cos(2 * sigma1 + sigma); 42 | 43 | var deltaSigma = 44 | B * 45 | sinSigma * 46 | (cos2SigmaM + 47 | (B / 4) * 48 | (cosSigma * (-1 + 2 * cos2SigmaM * cos2SigmaM) - 49 | (B / 6) * 50 | cos2SigmaM * 51 | (-3 + 4 * sinSigma * sinSigma) * 52 | (-3 + 4 * cos2SigmaM * cos2SigmaM))); 53 | sigmaP = sigma; 54 | sigma = s / (b * A) + deltaSigma; 55 | } 56 | 57 | var tmp = sinU1 * sinSigma - cosU1 * cosSigma * cosAlpha1, 58 | lat2 = Math.atan2( 59 | sinU1 * cosSigma + cosU1 * sinSigma * cosAlpha1, 60 | (1 - f) * Math.sqrt(sinAlpha * sinAlpha + tmp * tmp) 61 | ), 62 | lambda = Math.atan2( 63 | sinSigma * sinAlpha1, 64 | cosU1 * cosSigma - sinU1 * sinSigma * cosAlpha1 65 | ), 66 | C = (f / 16) * cosSqAlpha * (4 + f * (4 - 3 * cosSqAlpha)), 67 | L = 68 | lambda - 69 | (1 - C) * 70 | f * 71 | sinAlpha * 72 | (sigma + 73 | C * 74 | sinSigma * 75 | (cos2SigmaM + C * cosSigma * (-1 + 2 * cos2SigmaM * cos2SigmaM))), 76 | revAz = Math.atan2(sinAlpha, -tmp); // final bearing 77 | return [toDeg(lat2), lon1 + toDeg(L)]; 78 | } 79 | 80 | function radians(n: number) { 81 | return n * (Math.PI / 180); 82 | } 83 | function degrees(n: number) { 84 | return n * (180 / Math.PI); 85 | } 86 | 87 | function getBearing( 88 | [startLat, startLong]: [number, number], 89 | [endLat, endLong]: [number, number] 90 | ) { 91 | startLat = radians(startLat); 92 | startLong = radians(startLong); 93 | endLat = radians(endLat); 94 | endLong = radians(endLong); 95 | 96 | var dLong = endLong - startLong; 97 | 98 | var dPhi = Math.log( 99 | Math.tan(endLat / 2.0 + Math.PI / 4.0) / 100 | Math.tan(startLat / 2.0 + Math.PI / 4.0) 101 | ); 102 | if (Math.abs(dLong) > Math.PI) { 103 | if (dLong > 0.0) { 104 | dLong = -(2.0 * Math.PI - dLong); 105 | } else { 106 | dLong = 2.0 * Math.PI + dLong; 107 | } 108 | } 109 | 110 | return (degrees(Math.atan2(dLong, dPhi)) + 360.0) % 360.0; 111 | } 112 | 113 | export function getCardinal(angle: number) { 114 | const degreePerDirection = 360 / 8; 115 | const offsetAngle = angle + degreePerDirection / 2; 116 | 117 | return offsetAngle >= 0 * degreePerDirection && 118 | offsetAngle < 1 * degreePerDirection 119 | ? "N" 120 | : offsetAngle >= 1 * degreePerDirection && 121 | offsetAngle < 2 * degreePerDirection 122 | ? "NE" 123 | : offsetAngle >= 2 * degreePerDirection && 124 | offsetAngle < 3 * degreePerDirection 125 | ? "E" 126 | : offsetAngle >= 3 * degreePerDirection && 127 | offsetAngle < 4 * degreePerDirection 128 | ? "SE" 129 | : offsetAngle >= 4 * degreePerDirection && 130 | offsetAngle < 5 * degreePerDirection 131 | ? "S" 132 | : offsetAngle >= 5 * degreePerDirection && 133 | offsetAngle < 6 * degreePerDirection 134 | ? "SW" 135 | : offsetAngle >= 6 * degreePerDirection && 136 | offsetAngle < 7 * degreePerDirection 137 | ? "W" 138 | : "NW"; 139 | } 140 | 141 | export function getFlyDistance( 142 | [lat1, lon1]: [number, number], 143 | [lat2, lon2]: [number, number] 144 | ) { 145 | var R = 6371; // km 146 | var dLat = toRad(lat2 - lat1); 147 | var dLon = toRad(lon2 - lon1); 148 | lat1 = toRad(lat1); 149 | lat2 = toRad(lat2); 150 | 151 | var a = 152 | Math.sin(dLat / 2) * Math.sin(dLat / 2) + 153 | Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2); 154 | var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); 155 | var d = R * c; 156 | return d * 0.539957; 157 | } 158 | 159 | export function formatCounter(seconds: number): string { 160 | const hours = Math.floor(seconds / 3600), 161 | minutes = Math.floor((seconds - hours * 3600) / 60), 162 | outSeconds = seconds - hours * 3600 - minutes * 60; 163 | 164 | return `${hours.toString().padStart(2, "0")}:${minutes 165 | .toString() 166 | .padStart(2, "0")}:${outSeconds.toString().padStart(2, "0")}`; 167 | } 168 | 169 | export function route(path: string): string { 170 | return process.env.NODE_ENV === "production" 171 | ? `/api${path}` 172 | : `http://localhost:7789/api${path}`; 173 | } 174 | 175 | export function getBearingMap( 176 | start: [number, number], 177 | end: [number, number], 178 | map: DCSMap 179 | ) { 180 | const bearing = Math.round(getBearing(start, end)) + map.magDec; 181 | if (bearing > 360) { 182 | return bearing - 360; 183 | } else if (bearing < 0) { 184 | return bearing + 360; 185 | } 186 | return bearing; 187 | } 188 | 189 | function toDegreesMinutesAndSeconds(coordinate: number, size: number) { 190 | var absolute = Math.abs(coordinate); 191 | var degrees = Math.floor(absolute); 192 | var minutesNotTruncated = (absolute - degrees) * 60; 193 | var minutes = Math.floor(minutesNotTruncated); 194 | var seconds = Math.floor((minutesNotTruncated - minutes) * 60); 195 | 196 | return ( 197 | degrees.toString().padStart(size, "0") + 198 | "°" + 199 | minutes.toString().padStart(2, "0") + 200 | "'" + 201 | seconds.toString().padStart(2, "0") + 202 | '"' 203 | ); 204 | } 205 | 206 | function toDegreesDecimalMinutes(coordinate: number, size: number) { 207 | var absolute = Math.abs(coordinate); 208 | var degrees = Math.floor(absolute); 209 | var minutes = (absolute - degrees) * 60; 210 | 211 | return degrees.toString().padStart(size, "0") + "°" + minutes.toFixed(5); 212 | } 213 | 214 | export function formatDMS([lat, lng]: [number, number]) { 215 | var latitude = toDegreesMinutesAndSeconds(lat, 2); 216 | var latitudeCardinal = lat >= 0 ? "N" : "S"; 217 | 218 | var longitude = toDegreesMinutesAndSeconds(lng, 3); 219 | var longitudeCardinal = lng >= 0 ? "E" : "W"; 220 | 221 | return `${latitudeCardinal}${latitude} ${longitudeCardinal}${longitude}`; 222 | } 223 | 224 | export function formatDDM([lat, lng]: [number, number]) { 225 | const latitude = toDegreesDecimalMinutes(lat, 2); 226 | const latitudeCardinal = lat >= 0 ? "N" : "S"; 227 | const longitude = toDegreesDecimalMinutes(lng, 3); 228 | const longitudeCardinal = lng >= 0 ? "E" : "W"; 229 | return `${latitudeCardinal}${latitude} ${longitudeCardinal}${longitude}`; 230 | } 231 | -------------------------------------------------------------------------------- /src/hooks/useRenderGroundUnits.tsx: -------------------------------------------------------------------------------- 1 | import Immutable from "immutable"; 2 | import * as maptalks from "maptalks"; 3 | import ms from "milsymbol"; 4 | import { useEffect } from "react"; 5 | import GroundUnitData from "../data/units/ground.json"; 6 | import { 7 | Server, 8 | serverStore, 9 | setSelectedEntityId, 10 | } from "../stores/ServerStore"; 11 | import { setSelectedGeometry } from "../stores/GeometryStore"; 12 | import { GroundUnitMode, FlightUnitMode, settingsStore } from "../stores/SettingsStore"; 13 | import { 14 | Entity, 15 | getCoalitionColor, 16 | getCoalitionIdentity, 17 | } from "../types/entity"; 18 | import { colorMode } from "../components/MapIcon"; 19 | type UnitData = { 20 | code: string; 21 | dcs_codes: Array; 22 | mil_std_2525_d: number; 23 | name: string; 24 | sidc?: string; 25 | }; 26 | 27 | export const groundIconCache: Record = {}; 28 | export const groundUnitData = Immutable.Map( 29 | GroundUnitData.map((it) => [it.dcs_codes[0], it] as [string, UnitData]) 30 | ); 31 | 32 | var counter=0; 33 | function renderGroundUnit(layer: maptalks.VectorLayer, unit: Entity, coalition: string | undefined, guRatio: number | undefined, guMaxQty: number | undefined) { 34 | const collection = layer.getGeometryById( 35 | unit.id 36 | ) as maptalks.GeometryCollection; 37 | if (collection) { 38 | return; 39 | } 40 | 41 | if (!guRatio) return; 42 | if (!guMaxQty) return; 43 | 44 | const unitData = groundUnitData.get(unit.name); 45 | let sidc; 46 | if (unitData && unitData.sidc) { 47 | sidc = `${unitData.sidc[0]}${getCoalitionIdentity( 48 | unit.coalition 49 | )}${unitData.sidc.slice(2)}`; 50 | } else { 51 | sidc = `S${getCoalitionIdentity(unit.coalition)}G-E-----`; 52 | } 53 | 54 | if (sidc && !groundIconCache[sidc]) { 55 | groundIconCache[sidc] = new ms.Symbol(sidc, { 56 | size: 16, 57 | frame: true, 58 | fill: true, 59 | fillOpacity: 1, 60 | strokeWidth: 8, 61 | colorMode: "Light", 62 | }).toDataURL(); 63 | } 64 | 65 | const icon = new maptalks.Marker([unit.longitude, unit.latitude], { 66 | draggable: false, 67 | visible: true, 68 | editable: false, 69 | symbol: { 70 | markerFile: sidc && groundIconCache[sidc], 71 | markerDy: { 72 | stops: [ 73 | [8, 6], 74 | [10, 12], 75 | [12, 18], 76 | [14, 24], 77 | ], 78 | }, 79 | markerOpacity: 1, 80 | markerWidth: { 81 | stops: [ 82 | [8, 12], 83 | [10, 24], 84 | [12, 36], 85 | [14, 48], 86 | ], 87 | }, 88 | markerHeight: { 89 | stops: [ 90 | [8, 12], 91 | [10, 24], 92 | [12, 36], 93 | [14, 48], 94 | ], 95 | }, 96 | }, 97 | }); 98 | 99 | var displayUnit; 100 | const { editor_mode_on } = serverStore.getState(); 101 | if (coalition === "GM" || guMaxQty === -1 || editor_mode_on) { 102 | displayUnit = true; 103 | } else if (coalition === "blue" && unit.coalition === "Enemies") { 104 | displayUnit = true; 105 | } else if (coalition === "blue" && unit.coalition === "Allies") { 106 | displayUnit = unit.visible; 107 | } else if (coalition === "red" && unit.coalition === "Allies") { 108 | displayUnit = true; 109 | } else if (coalition === "red" && unit.coalition === "Enemies") { 110 | displayUnit = unit.visible; 111 | } else { 112 | displayUnit = unit.visible; 113 | } 114 | 115 | if (displayUnit) { 116 | const col = new maptalks.GeometryCollection([icon], { 117 | id: unit.id, 118 | draggable: false, 119 | }); 120 | col.on("click", (e) => { 121 | setSelectedEntityId(unit.id); 122 | setSelectedGeometry(null); 123 | }); 124 | layer.addGeometry(col); 125 | } 126 | } 127 | 128 | function renderGroundUnits( 129 | map: maptalks.Map, 130 | [entities, offset, server]: [ 131 | Immutable.Map, 132 | number, 133 | Server | null 134 | ], 135 | [_x, lastOffset, _y]: [unknown, number, unknown] 136 | ) { 137 | const groundUnitMode = settingsStore.getState().map.groundUnitMode; 138 | const coalition = server?.coalition; 139 | const guRatio = server?.ground_unit_ratio; 140 | const guMaxQty = server?.ground_unit_max_qty; 141 | const { editor_mode_on } = serverStore.getState(); 142 | const isVisible = (target: Entity) => { 143 | if (coalition === "GM" || editor_mode_on) { 144 | return true 145 | } else if (coalition === "blue" && target.coalition === "Enemies") { 146 | return server?.ground_unit_modes.includes(GroundUnitMode.FRIENDLY) 147 | } else if (coalition === "blue" && target.coalition === "Allies" && target.visible) { 148 | return server?.ground_unit_modes.includes(GroundUnitMode.ENEMY) 149 | } else if (coalition === "red" && target.coalition === "Enemies" && target.visible) { 150 | return server?.ground_unit_modes.includes(GroundUnitMode.ENEMY) 151 | } else if (coalition === "red" && target.coalition === "Allies") { 152 | return server?.ground_unit_modes.includes(GroundUnitMode.FRIENDLY) 153 | } 154 | return false; 155 | }; 156 | 157 | var layer 158 | layer = map.getLayer("ground-units-blue") as maptalks.VectorLayer; 159 | for (const geo of layer.getGeometries()) { 160 | const entity = entities.get((geo as any)._id as number); 161 | if (!entity || !isVisible(entity)) { 162 | geo.remove(); 163 | } 164 | } 165 | 166 | layer = map.getLayer("ground-units-red") as maptalks.VectorLayer; 167 | for (const geo of layer.getGeometries()) { 168 | const entity = entities.get((geo as any)._id as number); 169 | if (!entity || !isVisible(entity)) { 170 | geo.remove(); 171 | } 172 | } 173 | 174 | layer = map.getLayer("ground-units") as maptalks.VectorLayer; 175 | for (const geo of layer.getGeometries()) { 176 | const entity = entities.get((geo as any)._id as number); 177 | if (!entity || !isVisible(entity)) { 178 | geo.remove(); 179 | } 180 | } 181 | 182 | for (const entity of entities.valueSeq()) { 183 | if ( 184 | isVisible(entity) && 185 | (lastOffset === 0 || entity.updatedAt > lastOffset || editor_mode_on) 186 | ) { 187 | if (entity.coalition === "Allies") { 188 | layer = map.getLayer("ground-units-red") as maptalks.VectorLayer; 189 | } else if (entity.coalition === "Enemies") { 190 | layer = map.getLayer("ground-units-blue") as maptalks.VectorLayer; 191 | } else { 192 | layer = map.getLayer("ground-units") as maptalks.VectorLayer; 193 | } 194 | renderGroundUnit(layer, entity, coalition, guRatio, guMaxQty); 195 | } 196 | } 197 | 198 | lastOffset = offset; 199 | } 200 | 201 | export default function useRenderGroundUnit(map: maptalks.Map | null) { 202 | const [groundUnitMode] = settingsStore((state) => [state.map.groundUnitMode]); 203 | 204 | useEffect(() => { 205 | if (!map) return; 206 | const { entities, offset, server } = serverStore.getState(); 207 | if (!server) return; 208 | renderGroundUnits( 209 | map, 210 | [ 211 | entities.filter( 212 | (it) => 213 | (it.types.includes("Ground") || it.types.includes("Sea")) && 214 | !it.types.includes("Air") && 215 | !it.types.includes("Static") 216 | ), 217 | offset, 218 | server, 219 | ], 220 | [null, 0, null] 221 | ); 222 | }, [map, groundUnitMode]); 223 | 224 | useEffect(() => { 225 | if (!map) return; 226 | 227 | return serverStore.subscribe( 228 | (a: [Immutable.Map, number, Server | null], b) => 229 | renderGroundUnits(map, a, b), 230 | (state) => 231 | [ 232 | state.entities.filter( 233 | (it) => 234 | (it.types.includes("Ground") || it.types.includes("Sea")) && 235 | !it.types.includes("Air") && 236 | !it.types.includes("Static") 237 | ), 238 | state.offset, 239 | state.server, 240 | ] as [Immutable.Map, number, Server | null] 241 | ); 242 | }, [map]); 243 | } 244 | -------------------------------------------------------------------------------- /src/components/MapEntity.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import * as maptalks from "maptalks"; 3 | import ms from "milsymbol"; 4 | import React, { useEffect, useMemo, useRef, useState } from "react"; 5 | import { BiX, BiExit, BiMapPin } from "react-icons/bi"; 6 | import { DCSMap } from "../dcs/maps/DCSMap"; 7 | import { useKeyPress } from "../hooks/useKeyPress"; 8 | import { Alert, alertStore } from "../stores/AlertStore"; 9 | import { 10 | popEntityTag, 11 | pushEntityTag, 12 | useEntityMetadata, 13 | } from "../stores/EntityMetadataStore"; 14 | import { serverStore, setSelectedEntityId } from "../stores/ServerStore"; 15 | import { 16 | EntityTrackPing, 17 | estimatedSpeed, 18 | setTrackOptions, 19 | trackStore, 20 | } from "../stores/TrackStore"; 21 | import { Entity } from "../types/entity"; 22 | import { getBearingMap, getCardinal, getFlyDistance } from "../util"; 23 | import DetailedCoords from "./DetailedCoords"; 24 | import { colorMode } from "./MapIcon"; 25 | import { setSelectedGeometry } from "../stores/GeometryStore"; 26 | 27 | export const iconCache: Record = {}; 28 | 29 | export function EntityInfo({ 30 | map, 31 | dcsMap, 32 | entity, 33 | track, 34 | }: { 35 | map: maptalks.Map; 36 | dcsMap: DCSMap; 37 | entity: Entity; 38 | track: Array | null; 39 | }) { 40 | const trackOptions = trackStore((state) => state.trackOptions.get(entity.id)); 41 | const alerts = alertStore((state) => state.alerts.get(entity.id)); 42 | const entities = serverStore((state) => state.entities); 43 | const metadata = useEntityMetadata(entity.id); 44 | const [addTagText, setAddTagText] = useState(""); 45 | const inputRef = useRef(null); 46 | const isEnterPressed = useKeyPress("Enter"); 47 | 48 | 49 | useEffect(() => { 50 | if (!inputRef.current) return; 51 | if ( 52 | isEnterPressed && 53 | addTagText !== "" && 54 | document.activeElement === inputRef.current 55 | ) { 56 | pushEntityTag(entity.id, addTagText); 57 | setAddTagText(""); 58 | } 59 | }, [isEnterPressed, inputRef, addTagText]); 60 | 61 | let alertEntities = useMemo( 62 | () => 63 | alerts 64 | ?.map((it) => { 65 | const targetEntity = entities.get(it.targetEntityId); 66 | if (!targetEntity) { 67 | return; 68 | } 69 | 70 | const distance = getFlyDistance( 71 | [entity.latitude, entity.longitude], 72 | [targetEntity.latitude, targetEntity.longitude] 73 | ); 74 | 75 | return [it, targetEntity, distance]; 76 | }) 77 | .filter((it): it is [Alert, Entity, number] => it !== undefined) 78 | .sort(([x, y, a], [e, f, b]) => a - b), 79 | [alerts, entities] 80 | ); 81 | 82 | return ( 83 | 84 | 85 | {entity.name} 86 | { 89 | map.animateTo( 90 | { 91 | center: [entity.longitude, entity.latitude], 92 | zoom: 10, 93 | }, 94 | { 95 | duration: 250, 96 | easing: "out", 97 | } 98 | ); 99 | }} 100 | > 101 | 102 | 103 | { 106 | setSelectedEntityId(null); 107 | }} 108 | > 109 | 110 | 111 | 112 | 113 | 114 | {track && entity.types.includes("Air") && ( 115 | <> 116 | {entity.pilot} 117 | 118 | Heading:{" "} 119 | {Math.round(entity.heading).toString().padStart(3, "0")} 120 | {getCardinal(entity.heading)} 121 | 122 | GS: {Math.max(Math.round(estimatedSpeed(track)),0)} 123 | > 124 | )} 125 | Altitude: {Math.max(Math.round(entity.altitude * 3.28084),0)} 126 | ID: {entity.id} 127 | 128 | 129 | 130 | 131 | 132 | {metadata && ( 133 | 134 | 135 | {metadata.tags.map((it) => ( 136 | 140 | {it} 141 | popEntityTag(entity.id, it)} 143 | className="text-red-500" 144 | > 145 | 146 | 147 | 148 | ))} 149 | 150 | 151 | )} 152 | {alertEntities && ( 153 | 154 | {alertEntities.map(([alert, threatEntity, distance]) => { 155 | const bearing = getBearingMap( 156 | [entity.latitude, entity.longitude], 157 | [threatEntity.latitude, threatEntity.longitude], 158 | dcsMap 159 | ); 160 | 161 | return ( 162 | { 172 | map.animateTo( 173 | { 174 | center: [threatEntity.longitude, threatEntity.latitude], 175 | zoom: 10, 176 | }, 177 | { 178 | duration: 250, 179 | easing: "out", 180 | } 181 | ); 182 | }} 183 | > 184 | {threatEntity.name} 185 | 186 | {bearing} {getCardinal(bearing)} 187 | 188 | {Math.round(distance)}NM 189 | 190 | {Math.floor((threatEntity.altitude * 3.28084) / 1000)} 191 | 192 | 193 | ); 194 | })} 195 | 196 | )} 197 | 198 | ); 199 | } 200 | 201 | export function MapSimpleEntity({ 202 | map, 203 | entity, 204 | size, 205 | strokeWidth, 206 | }: { 207 | map: maptalks.Map; 208 | entity: Entity; 209 | size?: number; 210 | strokeWidth?: number; 211 | }) { 212 | useEffect(() => { 213 | const iconLayer = map.getLayer("track-icons") as maptalks.VectorLayer; 214 | let marker = iconLayer.getGeometryById(entity.id) as maptalks.Marker; 215 | if (!marker) { 216 | if (iconCache[entity.sidc] === undefined) { 217 | iconCache[entity.sidc] = new ms.Symbol(entity.sidc, { 218 | size: size || 16, 219 | frame: true, 220 | fill: false, 221 | colorMode: colorMode, 222 | strokeWidth: strokeWidth || 8, 223 | }).toDataURL(); 224 | } 225 | marker = new maptalks.Marker([entity.longitude, entity.latitude], { 226 | id: entity.id, 227 | draggable: false, 228 | visible: true, 229 | editable: false, 230 | symbol: { 231 | markerFile: iconCache[entity.sidc], 232 | markerDy: 10, 233 | }, 234 | }); 235 | marker.on("click", () => {setSelectedEntityId(entity.id);setSelectedGeometry(null);}); 236 | iconLayer.addGeometry(marker); 237 | } else { 238 | marker.setCoordinates([entity.longitude, entity.latitude]); 239 | } 240 | }, [entity]); 241 | 242 | return <>>; 243 | } 244 | -------------------------------------------------------------------------------- /src/data/airbases/nevada.json: -------------------------------------------------------------------------------- 1 | {"Creech": {"callsign": "Creech", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Creech", "life": 3600, "typeName": "Creech"}, "id": 1, "point": [36.584429875554, -115.68681060569, 952.94458007812], "runways": [{"Name": 8, "course": -1.597741484642, "length": 1859.3155517578, "position": {"x": -360507.1875, "y": 952.94458007812, "z": -75590.0703125}, "width": 60}, {"Name": 26, "course": -2.5331676006317, "length": 1859.3155517578, "position": {"x": -359739.875, "y": 952.94458007812, "z": -75289.5078125}, "width": 60}]}, "Groom Lake": {"callsign": "Groom Lake", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Groom Lake", "life": 3600, "typeName": "Groom Lake"}, "id": 2, "point": [37.21913894289, -115.78531067518, 1369.8676757812], "runways": [{"Name": 32, "course": 0.41125798225403, "length": 3355.2507324219, "position": {"x": -288604.6875, "y": 1369.8676757812, "z": -86870.4453125}, "width": 60}]}, "McCarran International": {"callsign": "McCarran International", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "McCarran International", "life": 3600, "typeName": "McCarran International"}, "id": 3, "point": [36.076670073498, -115.16219006293, 661.37609863281], "runways": [{"Name": 7, "course": -1.5510839223862, "length": 3163.0314941406, "position": {"x": -416011.375, "y": 661.37609863281, "z": -26929.3359375}, "width": 60}, {"Name": 25, "course": -1.5512491464615, "length": 3163.0314941406, "position": {"x": -416316.0625, "y": 661.37609863281, "z": -26833.4921875}, "width": 60}]}, "Nellis": {"callsign": "Nellis", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Nellis", "life": 3600, "typeName": "Nellis"}, "id": 4, "point": [36.225662018016, -115.04380774353, 561.30914306641], "runways": [{"Name": 3, "course": -0.70031100511551, "length": 2876.6762695312, "position": {"x": -398195.375, "y": 561.30914306641, "z": -17233.236328125}, "width": 60}, {"Name": 21, "course": -0.70050585269928, "length": 2876.6762695312, "position": {"x": -398000.375, "y": 561.30914306641, "z": -17467.23828125}, "width": 60}]}, "Beatty": {"callsign": "Beatty", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Beatty", "life": 3600, "typeName": "Beatty"}, "id": 5, "point": [36.868394298081, -116.78608313042, 967.13366699219], "runways": [{"Name": 16, "course": -3.1391150951385, "length": 1639.951171875, "position": {"x": -330553.625, "y": 967.13366699219, "z": -174958.53125}, "width": 20}]}, "Boulder City": {"callsign": "Boulder City", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Boulder City", "life": 3600, "typeName": "Boulder City"}, "id": 6, "point": [35.943681644578, -114.86055178579, 646.66363525391], "runways": [{"Name": 33, "course": 0.27873066067696, "length": 1128.5063476562, "position": {"x": -429660.09375, "y": 646.66363525391, "z": -1148.7244873047}, "width": 20}, {"Name": 15, "course": 1.4227820634842, "length": 1128.5063476562, "position": {"x": -429914.8125, "y": 646.66363525391, "z": -1078.7415771484}, "width": 20}]}, "Echo Bay": {"callsign": "Echo Bay", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Echo Bay", "life": 3600, "typeName": "Echo Bay"}, "id": 7, "point": [36.310471394437, -114.46933764146, 472.03369140625], "runways": [{"Name": 6, "course": -1.3506362438202, "length": 984.02569580078, "position": {"x": -388592.34375, "y": 472.03369140625, "z": 33697.3125}, "width": 20}]}, "Henderson Executive": {"callsign": "Henderson Executive", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Henderson Executive", "life": 3600, "typeName": "Henderson Executive"}, "id": 8, "point": [35.96746878808, -115.13347108341, 759.43688964844], "runways": [{"Name": 35, "course": 0.0084149222820997, "length": 1448.8039550781, "position": {"x": -427352.71875, "y": 759.43688964844, "z": -25668.716796875}, "width": 60}, {"Name": 17, "course": 0.0087440079078078, "length": 1448.8039550781, "position": {"x": -427605.40625, "y": 759.43688964844, "z": -25878.82421875}, "width": 60}]}, "Jean": {"callsign": "Jean", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Jean", "life": 3600, "typeName": "Jean"}, "id": 9, "point": [35.773785550964, -115.32570272555, 860.87591552734], "runways": [{"Name": 20, "course": 2.6065623760223, "length": 1235.3557128906, "position": {"x": -450392.875, "y": 860.87591552734, "z": -43000.4609375}, "width": 20}, {"Name": 2, "course": 2.6064193248749, "length": 1235.3557128906, "position": {"x": -450523.59375, "y": 860.87591552734, "z": -42971.171875}, "width": 20}]}, "Laughlin": {"callsign": "Laughlin", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Laughlin", "life": 3600, "typeName": "Laughlin"}, "id": 10, "point": [35.165905267476, -114.55986431857, 200.00019836426], "runways": [{"Name": 16, "course": -3.0549499988556, "length": 2176.0114746094, "position": {"x": -516946.4375, "y": 200.00019836426, "z": 28306.30859375}, "width": 60}]}, "Lincoln County": {"callsign": "Lincoln County", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Lincoln County", "life": 3600, "typeName": "Lincoln County"}, "id": 11, "point": [37.793389585399, -114.41923699635, 1467.6899414062], "runways": [{"Name": 17, "course": 3.1253848075867, "length": 1343.6921386719, "position": {"x": -224670.34375, "y": 1467.6899414062, "z": 33199.9375}, "width": 20}]}, "Mesquite": {"callsign": "Mesquite", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Mesquite", "life": 3600, "typeName": "Mesquite"}, "id": 13, "point": [36.827345248734, -114.06033514493, 566.48736572266], "runways": [{"Name": 1, "course": -0.48182135820389, "length": 1504.9615478516, "position": {"x": -329622.09375, "y": 566.48736572266, "z": 68561.1484375}, "width": 20}]}, "Mina": {"callsign": "Mina", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Mina", "life": 3600, "typeName": "Mina"}, "id": 14, "point": [38.374546852088, -118.09349931622, 1390.5122070312], "runways": [{"Name": 13, "course": 0.4801319539547, "length": 1287.0180664062, "position": {"x": -161504.46875, "y": 1390.5122070312, "z": -289784.75}, "width": 60}]}, "North Las Vegas": {"callsign": "North Las Vegas", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "North Las Vegas", "life": 3600, "typeName": "North Las Vegas"}, "id": 15, "point": [36.213364566271, -115.18673756618, 679.27984619141], "runways": [{"Name": 25, "course": 1.6145349740982, "length": 1442.9757080078, "position": {"x": -400891.5, "y": 679.27984619141, "z": -31726.953125}, "width": 20}, {"Name": 7, "course": 0.81430453062057, "length": 1442.9757080078, "position": {"x": -401271.71875, "y": 679.27984619141, "z": -31906.73046875}, "width": 20}, {"Name": 30, "course": -2.3266966342926, "length": 1362.8685302734, "position": {"x": -401445.4375, "y": 679.27984619141, "z": -31412.177734375}, "width": 20}]}, "Pahute Mesa": {"callsign": "Pahute Mesa", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Pahute Mesa", "life": 3600, "typeName": "Pahute Mesa"}, "id": 16, "point": [37.094831346541, -116.31539962949, 1541.3635253906], "runways": [{"Name": 36, "course": -0.23457318544388, "length": 1652.0565185547, "position": {"x": -303620, "y": 1541.3635253906, "z": -132937.9375}, "width": 60}]}, "Tonopah": {"callsign": "Tonopah", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Tonopah", "life": 3600, "typeName": "Tonopah"}, "id": 17, "point": [38.058024445654, -117.07591711356, 1644.1396484375], "runways": [{"Name": 29, "course": 0.95942312479019, "length": 1619.8765869141, "position": {"x": -197282.90625, "y": 1644.1396484375, "z": -201302.875}, "width": 20}, {"Name": 11, "course": 0.26130217313766, "length": 1619.8765869141, "position": {"x": -197779.3125, "y": 1644.1396484375, "z": -201854.859375}, "width": 20}]}, "Tonopah Test Range": {"callsign": "Tonopah Test Range", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Tonopah Test Range", "life": 3600, "typeName": "Tonopah Test Range"}, "id": 18, "point": [37.78403952166, -116.77330290767, 1686.8143310547], "runways": [{"Name": 32, "course": 0.40033161640167, "length": 3545.7802734375, "position": {"x": -226505.28125, "y": 1686.8143310547, "z": -174698.484375}, "width": 60}]}} -------------------------------------------------------------------------------- /src/data/airbases/caucasus.json: -------------------------------------------------------------------------------- 1 | {"Anapa-Vityazevo": {"callsign": "Anapa-Vityazevo", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Anapa-Vityazevo", "life": 3600, "typeName": "Anapa-Vityazevo"}, "id": 12, "point": [45.013174733772, 37.359783477556, 43.00004196167], "runways": [{"Name": 22, "course": 2.4174582958221, "length": 2628.5646972656, "position": {"x": -5412.4096679688, "y": 43.00004196167, "z": 243128.8125}, "width": 60}]}, "Krasnodar-Center": {"callsign": "Krasnodar-Center", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Krasnodar-Center", "life": 3600, "typeName": "Krasnodar-Center"}, "id": 13, "point": [45.087429883845, 38.925202300775, 30.01003074646], "runways": [{"Name": 9, "course": -1.5174421072006, "length": 2334.5407714844, "position": {"x": 11685.205078125, "y": 30.01003074646, "z": 367933.5}, "width": 60}]}, "Novorossiysk": {"callsign": "Novorossiysk", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Novorossiysk", "life": 3600, "typeName": "Novorossiysk"}, "id": 14, "point": [44.673329604127, 37.78622606048, 40.010040283203], "runways": [{"Name": 22, "course": 2.4084095954895, "length": 1718.8388671875, "position": {"x": -40917.53515625, "y": 40.010040283203, "z": 279256.0625}, "width": 60}]}, "Krymsk": {"callsign": "Krymsk", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Krymsk", "life": 3600, "typeName": "Krymsk"}, "id": 15, "point": [44.961383022734, 37.985886938697, 20.010303497314], "runways": [{"Name": 4, "course": -0.68974810838699, "length": 2052.3518066406, "position": {"x": -6576.5244140625, "y": 20.010303497314, "z": 294388.125}, "width": 60}]}, "Maykop-Khanskaya": {"callsign": "Maykop-Khanskaya", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Maykop-Khanskaya", "life": 3600, "typeName": "Maykop-Khanskaya"}, "id": 16, "point": [44.671440257355, 40.021427482236, 180.01019287109], "runways": [{"Name": 4, "course": -0.68067389726639, "length": 3107.6345214844, "position": {"x": -26437.275390625, "y": 180.01019287109, "z": 458048.84375}, "width": 60}]}, "Gelendzhik": {"callsign": "Gelendzhik", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Gelendzhik", "life": 3600, "typeName": "Gelendzhik"}, "id": 17, "point": [44.567674586004, 38.004146350528, 22.009923934937], "runways": [{"Name": 1, "course": -0.69851189851761, "length": 1661.8295898438, "position": {"x": -50378.609375, "y": 22.009923934937, "z": 298406.15625}, "width": 60}]}, "Sochi-Adler": {"callsign": "Sochi-Adler", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Sochi-Adler", "life": 3600, "typeName": "Sochi-Adler"}, "id": 18, "point": [43.439378434051, 39.924231880466, 30.010034561157], "runways": [{"Name": 6, "course": -1.0819113254547, "length": 2952.4521484375, "position": {"x": -164496.46875, "y": 30.010034561157, "z": 462218.9375}, "width": 60}]}, "Krasnodar-Pashkovsky": {"callsign": "Krasnodar-Pashkovsky", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Krasnodar-Pashkovsky", "life": 3600, "typeName": "Krasnodar-Pashkovsky"}, "id": 19, "point": [45.046099641543, 39.203066906325, 34.010036468506], "runways": [{"Name": 23, "course": 2.3202996253967, "length": 2968.3833007812, "position": {"x": 7717.6372070312, "y": 34.010036468506, "z": 387878.8125}, "width": 60}]}, "Sukhumi-Babushara": {"callsign": "Sukhumi-Babushara", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Sukhumi-Babushara", "life": 3600, "typeName": "Sukhumi-Babushara"}, "id": 20, "point": [42.852741071635, 41.142447588488, 13.338506698608], "runways": [{"Name": 30, "course": 1.1084893941879, "length": 3419.2446289062, "position": {"x": -220592.328125, "y": 13.338506698608, "z": 564392}, "width": 60}]}, "Gudauta": {"callsign": "Gudauta", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Gudauta", "life": 3600, "typeName": "Gudauta"}, "id": 21, "point": [43.124233340197, 40.564175768401, 21.01003074646], "runways": [{"Name": 15, "course": -2.6355042457581, "length": 2389.5874023438, "position": {"x": -196710.09375, "y": 21.01003074646, "z": 516451.6875}, "width": 60}]}, "Batumi": {"callsign": "Batumi", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Batumi", "life": 3600, "typeName": "Batumi"}, "id": 22, "point": [41.603279859649, 41.60927548351, 10.044037818909], "runways": [{"Name": 31, "course": 0.95011872053146, "length": 2070.4143066406, "position": {"x": -355810.6875, "y": 10.044037818909, "z": 617386.1875}, "width": 60}]}, "Senaki-Kolkhi": {"callsign": "Senaki-Kolkhi", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Senaki-Kolkhi", "life": 3600, "typeName": "Senaki-Kolkhi"}, "id": 23, "point": [42.238728081573, 42.061021312856, 13.239942550659], "runways": [{"Name": 27, "course": 1.4886384010315, "length": 2211.6474609375, "position": {"x": -281782.46875, "y": 13.239942550659, "z": 647279.5}, "width": 60}]}, "Kobuleti": {"callsign": "Kobuleti", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Kobuleti", "life": 3600, "typeName": "Kobuleti"}, "id": 24, "point": [41.932105353453, 41.876483823101, 18.01001739502], "runways": [{"Name": 25, "course": 1.9193958044052, "length": 2257.6027832031, "position": {"x": -317962.3125, "y": 18.01001739502, "z": 635633}, "width": 60}]}, "Kutaisi": {"callsign": "Kutaisi", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Kutaisi", "life": 3600, "typeName": "Kutaisi"}, "id": 25, "point": [42.17915393769, 42.4956840774, 45.010047912598], "runways": [{"Name": 25, "course": 1.8500648736954, "length": 2419.0322265625, "position": {"x": -284887.375, "y": 45.010047912598, "z": 683858.75}, "width": 60}]}, "Mineralnye Vody": {"callsign": "Mineralnye Vody", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Mineralnye Vody", "life": 3600, "typeName": "Mineralnye Vody"}, "id": 26, "point": [44.218646823807, 43.100679733081, 320.01031494141], "runways": [{"Name": 30, "course": 1.1290608644485, "length": 3754.0329589844, "position": {"x": -51259.984375, "y": 320.01031494141, "z": 705734}, "width": 60}]}, "Nalchik": {"callsign": "Nalchik", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Nalchik", "life": 3600, "typeName": "Nalchik"}, "id": 27, "point": [43.51007143853, 43.625108736098, 430.01040649414], "runways": [{"Name": 6, "course": -0.96863442659378, "length": 2048.4140625, "position": {"x": -124932.171875, "y": 430.01040649414, "z": 760421.1875}, "width": 60}]}, "Mozdok": {"callsign": "Mozdok", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Mozdok", "life": 3600, "typeName": "Mozdok"}, "id": 28, "point": [43.791303250938, 44.620327262102, 154.61184692383], "runways": [{"Name": 26, "course": 1.6987290382385, "length": 2357.453125, "position": {"x": -83450.421875, "y": 154.61184692383, "z": 834461.75}, "width": 60}]}, "Tbilisi-Lochini": {"callsign": "Tbilisi-Lochini", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Tbilisi-Lochini", "life": 3600, "typeName": "Tbilisi-Lochini"}, "id": 29, "point": [41.674720064437, 44.946875226153, 479.69479370117], "runways": [{"Name": 13, "course": -2.2334115505219, "length": 2344.5847167969, "position": {"x": -315671.0625, "y": 479.69479370117, "z": 896629.75}, "width": 60}]}, "Soganlug": {"callsign": "Soganlug", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Soganlug", "life": 3600, "typeName": "Soganlug"}, "id": 30, "point": [41.641163266787, 44.947183065317, 449.41024780273], "runways": [{"Name": 14, "course": 0.83329755067825, "length": 2399.349609375, "position": {"x": -317828.0625, "y": 449.41024780273, "z": 895407.1875}, "width": 60}]}, "Vaziani": {"callsign": "Vaziani", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Vaziani", "life": 3600, "typeName": "Vaziani"}, "id": 31, "point": [41.637735936262, 45.01909093846, 464.50045776367], "runways": [{"Name": 13, "course": -2.364458322525, "length": 2390.5002441406, "position": {"x": -319064.875, "y": 464.50045776367, "z": 903148.5}, "width": 60}]}, "Beslan": {"callsign": "Beslan", "cat": 4, "desc": {"_origin": "", "attributes": {"Airfields": true}, "category": 0, "displayName": "Beslan", "life": 3600, "typeName": "Beslan"}, "id": 32, "point": [43.208500987381, 44.588922553543, 524.00579833984], "runways": [{"Name": 10, "course": -1.6326225996017, "length": 2842.931640625, "position": {"x": -148590.171875, "y": 524.00579833984, "z": 843668.625}, "width": 60}]}} -------------------------------------------------------------------------------- /src/stores/AlertStore.tsx: -------------------------------------------------------------------------------- 1 | import Immutable from "immutable"; 2 | import create from "zustand"; 3 | import WarningBeep from "../sounds/warning-beep.mp3"; 4 | import { Entity } from "../types/entity"; 5 | import { getFlyDistance } from "../util"; 6 | import { serverStore } from "./ServerStore"; 7 | import { EntityTrackPing, estimatedSpeed, trackStore } from "./TrackStore"; 8 | 9 | export type Alert = { 10 | type: string; 11 | state?: string; 12 | 13 | // The entity id triggering this alert 14 | targetEntityId: number; 15 | }; 16 | 17 | type AlertStoreData = { 18 | alerts: Immutable.Map>; 19 | triggeredEntities: Immutable.Map; 20 | }; 21 | 22 | export const alertStore = create(() => { 23 | return { 24 | alerts: Immutable.Map>(), 25 | triggeredEntities: Immutable.Map(), 26 | }; 27 | }); 28 | 29 | (window as any).alertStore = alertStore; 30 | 31 | function isTrackInViolation( 32 | entity: Entity, 33 | triggerEntity: Entity, 34 | track: Array | null, 35 | radius: number 36 | ): boolean { 37 | const trackVisible = track === null || estimatedSpeed(track) >= 25; 38 | if (!trackVisible) { 39 | return false; 40 | } 41 | 42 | const d0 = Math.floor( 43 | getFlyDistance( 44 | [entity.latitude, entity.longitude], 45 | [triggerEntity.latitude, triggerEntity.longitude] 46 | ) 47 | ); 48 | 49 | return d0 <= radius; 50 | } 51 | 52 | export function checkAlerts() { 53 | const entities = serverStore.getState().entities; 54 | const trackState = trackStore.getState(); 55 | 56 | for (let [entityId, opts] of trackState.trackOptions.entrySeq()) { 57 | const entity = entities.get(entityId); 58 | if (!entity || !entity.types.includes("Air")) { 59 | continue; 60 | } 61 | 62 | const ourTrack = trackState.tracks.get(entityId); 63 | if (!ourTrack || estimatedSpeed(ourTrack) < 25) { 64 | continue; 65 | } 66 | 67 | if ( 68 | !opts.warningRadius && 69 | !opts.threatRadius && 70 | !opts.profileThreatRadius && 71 | !opts.profileThreatRadius 72 | ) { 73 | continue; 74 | } 75 | 76 | for (const [triggerEntityId, track] of trackState.tracks.entrySeq()) { 77 | if (triggerEntityId === entity.id) { 78 | continue; 79 | } 80 | 81 | const triggerEntity = entities.get(triggerEntityId); 82 | if (!triggerEntity || triggerEntity.coalition === entity.coalition) { 83 | continue; 84 | } 85 | 86 | const warningRadius = opts.warningRadius || opts.profileWarningRadius; 87 | if ( 88 | warningRadius && 89 | isTrackInViolation(entity, triggerEntity, track, warningRadius) 90 | ) { 91 | upsertAlert(entityId, { 92 | type: "warning", 93 | targetEntityId: triggerEntityId, 94 | }); 95 | } 96 | 97 | const threatRadius = opts.threatRadius || opts.profileThreatRadius; 98 | if ( 99 | threatRadius && 100 | isTrackInViolation(entity, triggerEntity, track, threatRadius) 101 | ) { 102 | upsertAlert(entityId, { 103 | type: "threat", 104 | targetEntityId: triggerEntityId, 105 | }); 106 | } 107 | } 108 | } 109 | } 110 | 111 | function clearAlerts() { 112 | alertStore.setState((state) => { 113 | let result = state.alerts; 114 | let triggeredEntities = state.triggeredEntities; 115 | const trackState = trackStore.getState(); 116 | const entities = serverStore.getState().entities; 117 | 118 | for (let [entityId, alerts] of state.alerts) { 119 | const ourEntity = entities.get(entityId); 120 | const existingTrack = trackState.tracks.get(entityId); 121 | if (!existingTrack || !ourEntity || estimatedSpeed(existingTrack) < 25) { 122 | result = result.remove(entityId); 123 | 124 | for (const alert of alerts) { 125 | triggeredEntities = decrementTriggeredEntity( 126 | triggeredEntities, 127 | alert.targetEntityId 128 | ); 129 | } 130 | 131 | continue; 132 | } 133 | 134 | if (!alerts.size) { 135 | // Leave this around until the entity is deleted 136 | continue; 137 | } 138 | 139 | const trackOpts = trackState.trackOptions.get(entityId); 140 | for (const [index, alert] of alerts.toKeyedSeq()) { 141 | let radius: number | undefined = undefined; 142 | 143 | const warningRadius = 144 | trackOpts && 145 | (trackOpts.warningRadius || trackOpts.profileWarningRadius); 146 | const threatRadius = 147 | trackOpts && 148 | (trackOpts.threatRadius || trackOpts.profileThreatRadius); 149 | if (alert.type === "warning" && warningRadius) { 150 | radius = warningRadius; 151 | } else if (alert.type === "threat" && threatRadius) { 152 | radius = threatRadius; 153 | } 154 | 155 | if (radius !== undefined) { 156 | const triggerEntity = entities.get(alert.targetEntityId); 157 | if (triggerEntity) { 158 | if (isTrackInViolation(ourEntity, triggerEntity, null, radius)) { 159 | continue; 160 | } 161 | } 162 | } 163 | 164 | alerts = alerts.remove(index); 165 | triggeredEntities = decrementTriggeredEntity( 166 | triggeredEntities, 167 | alert.targetEntityId 168 | ); 169 | } 170 | 171 | result = result.set(entityId, alerts); 172 | } 173 | 174 | return { 175 | ...state, 176 | alerts: result, 177 | triggeredEntities: triggeredEntities, 178 | }; 179 | }); 180 | } 181 | 182 | export function deleteAlert( 183 | entityId: number, 184 | type: string, 185 | triggerEntityId: number 186 | ) { 187 | alertStore.setState((state) => { 188 | let existingAlerts = state.alerts.get(entityId); 189 | if (!existingAlerts) { 190 | return; 191 | } 192 | 193 | let found = false; 194 | for (const [index, alert] of existingAlerts.entries()) { 195 | if (alert.type !== type || alert.targetEntityId !== triggerEntityId) { 196 | continue; 197 | } 198 | 199 | found = true; 200 | existingAlerts = existingAlerts.remove(index); 201 | break; 202 | } 203 | 204 | if (!found) return; 205 | 206 | return { 207 | ...state, 208 | triggeredEntities: decrementTriggeredEntity( 209 | state.triggeredEntities, 210 | triggerEntityId 211 | ), 212 | alerts: state.alerts.set(entityId, existingAlerts), 213 | }; 214 | }); 215 | } 216 | 217 | function incrementTriggeredEntity( 218 | triggeredEntities: Immutable.Map, 219 | entityId: number 220 | ): Immutable.Map { 221 | const existing = triggeredEntities.get(entityId); 222 | if (!existing) { 223 | return triggeredEntities.set(entityId, 1); 224 | } 225 | return triggeredEntities.set(entityId, existing + 1); 226 | } 227 | 228 | function decrementTriggeredEntity( 229 | triggeredEntities: Immutable.Map, 230 | entityId: number 231 | ): Immutable.Map { 232 | const existing = triggeredEntities.get(entityId); 233 | if (!existing) { 234 | console.error( 235 | "decrementTriggeredEntity for non-stored triggered entity", 236 | entityId 237 | ); 238 | return triggeredEntities; 239 | } else if (existing == 1) { 240 | return triggeredEntities.remove(entityId); 241 | } 242 | return triggeredEntities.set(entityId, existing - 1); 243 | } 244 | 245 | export function upsertAlert(entityId: number, alert: Alert) { 246 | alertStore.setState((state) => { 247 | const alerts = state.alerts; 248 | let existingAlerts = alerts.get(entityId); 249 | if (existingAlerts) { 250 | for (const [index, existing] of existingAlerts.toKeyedSeq()) { 251 | if ( 252 | existing.type === alert.type && 253 | existing.targetEntityId === alert.targetEntityId 254 | ) { 255 | if (existing.state === alert.state) { 256 | // Great, its a noop 257 | return state; 258 | } 259 | 260 | existingAlerts = existingAlerts.set(index, alert); 261 | return { 262 | ...state, 263 | alerts: alerts.set(entityId, existingAlerts), 264 | }; 265 | } 266 | } 267 | 268 | if (alert.type === "threat") { 269 | const audio = new Audio(WarningBeep); 270 | audio.play(); 271 | } 272 | 273 | existingAlerts = existingAlerts.push(alert); 274 | 275 | return { 276 | ...state, 277 | triggeredEntities: incrementTriggeredEntity( 278 | state.triggeredEntities, 279 | alert.targetEntityId 280 | ), 281 | alerts: alerts.set(entityId, existingAlerts), 282 | }; 283 | } 284 | 285 | if (alert.type === "threat") { 286 | const audio = new Audio(WarningBeep); 287 | audio.play(); 288 | } 289 | 290 | return { 291 | ...state, 292 | triggeredEntities: incrementTriggeredEntity( 293 | state.triggeredEntities, 294 | alert.targetEntityId 295 | ), 296 | alerts: alerts.set(entityId, Immutable.List.of(alert)), 297 | }; 298 | }); 299 | } 300 | 301 | function loopClearAlerts() { 302 | try { 303 | clearAlerts(); 304 | checkAlerts(); 305 | } finally { 306 | setTimeout(loopClearAlerts, 1000); 307 | } 308 | } 309 | 310 | setTimeout(loopClearAlerts, 5000); 311 | -------------------------------------------------------------------------------- /src/components/DrawConsoleTab.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import * as maptalks from "maptalks"; 3 | import React, { useState } from "react"; 4 | import { BiShapeCircle, BiShapeSquare, BiRadioCircle, BiPencil, BiShareAlt, BiRuler, BiMinus } from "react-icons/bi"; 5 | import { 6 | addMarkPoint, 7 | addZone, 8 | addWaypoints, 9 | addCircle, 10 | addLine, 11 | geometryStore, 12 | setSelectedGeometry 13 | } from "../stores/GeometryStore"; 14 | import { iconCache } from "../components/MapEntity"; 15 | import { setSelectedEntityId, serverStore} from "../stores/ServerStore"; 16 | import { ColorPicker, useColor } from "react-color-palette"; 17 | import "react-color-palette/css"; 18 | 19 | export default function DrawConsoleTab({ map }: { map: maptalks.Map }) { 20 | const [geometry, selectedId] = geometryStore((state) => [ 21 | state.geometry, 22 | state.selectedGeometry, 23 | ]); 24 | const [color, setColor] = useColor("#0068FF"); 25 | const [draw, setDraw] = useState(""); 26 | 27 | return ( 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | { 42 | setDraw("Mark"); 43 | var drawTool = new maptalks.DrawTool({ 44 | mode: 'Point', 45 | once: true, 46 | symbol: { 47 | markerFile: iconCache["GHG-GPRN--"], 48 | markerDy: 10, 49 | } 50 | }).addTo(map).disable(); 51 | drawTool.on('drawend', function(param) { 52 | setDraw("") 53 | const pos = param!.geometry!.getFirstCoordinate(); 54 | addMarkPoint([pos.y, pos.x], color.hex); 55 | }); 56 | drawTool.setMode('Point').enable(); 57 | 58 | }} 59 | > 60 | Mark 61 | 62 | 63 | 64 | 65 | { 70 | setDraw("Zone"); 71 | var drawTool = new maptalks.DrawTool({ 72 | mode: 'Point', 73 | once: true, 74 | symbol: { 75 | 'lineColor': "#FBBF24", 76 | 'lineWidth': 2, 77 | 'polygonFill': "#D97706", 78 | 'polygonOpacity': 0.1 79 | } 80 | }).addTo(map).disable(); 81 | drawTool.on('drawend', function(param) { 82 | setDraw("") 83 | let coordsTmp = param!.geometry!.getCoordinates()[0] as Array<{x:number,y:number}>; 84 | let coords:[number, number][] = []; 85 | coordsTmp.forEach((coord) => { 86 | coords.push([ coord.y, coord.x]); 87 | }); 88 | addZone(coords, color.hex); 89 | }); 90 | drawTool.setMode('Polygon').enable(); 91 | }} 92 | > 93 | Zone 94 | 95 | 96 | 97 | 98 | { 103 | setDraw("Wpts"); 104 | var drawTool = new maptalks.DrawTool({ 105 | mode: 'Point', 106 | once: true, 107 | symbol: { 108 | 'lineColor' : '#1bbc9b', 109 | 'lineWidth' : 3 110 | } 111 | }).addTo(map).disable(); 112 | 113 | drawTool.on('drawend', function(param) { 114 | setDraw("") 115 | let coordsTmp = param!.geometry!.getCoordinates() as Array<{x:number,y:number}>; 116 | let coords:[number, number][] = []; 117 | coordsTmp.forEach((coord) => { 118 | coords.push([ coord.y, coord.x]); 119 | }); 120 | addWaypoints(coords, color.hex) 121 | }); 122 | drawTool.setMode('LineString').enable(); 123 | 124 | }} 125 | > 126 | Wpts. 127 | 128 | 129 | 130 | 131 | 132 | 133 | { 138 | setDraw("Circle"); 139 | var drawTool = new maptalks.DrawTool({ 140 | mode: 'Point', 141 | once: true, 142 | symbol: { 143 | 'lineColor': "#FBBF24", 144 | 'lineWidth': 2, 145 | 'polygonFill': "#D97706", 146 | 'polygonOpacity': 0.1 147 | } 148 | }).addTo(map).disable(); 149 | 150 | drawTool.on('drawend', function(param) { 151 | setDraw("") 152 | const pos = param!.geometry!.getCoordinates(); 153 | const radius = param!.geometry!.getRadius(); 154 | addCircle([pos.y, pos.x], radius, color.hex) 155 | }); 156 | drawTool.setMode('Circle').enable(); 157 | }} 158 | > 159 | Circle 160 | 161 | 162 | 163 | 164 | { 169 | setDraw("Line"); 170 | var drawTool = new maptalks.DrawTool({ 171 | mode: 'Point', 172 | once: true, 173 | symbol: { 174 | 'lineColor' : '#FBBF24', 175 | 'lineWidth' : 2 176 | } 177 | }).addTo(map).disable(); 178 | 179 | drawTool.on('drawend', function(param) { 180 | setDraw("") 181 | let coordsTmp = param!.geometry!.getCoordinates() as Array<{x:number,y:number}>; 182 | let coords:[number, number][] = []; 183 | coordsTmp.forEach((coord) => { 184 | coords.push([ coord.y, coord.x]); 185 | }); 186 | addLine(coords, color.hex) 187 | }); 188 | drawTool.setMode('LineString').enable(); 189 | 190 | }} 191 | > 192 | Line. 193 | 194 | 195 | 196 | 197 | { 202 | setDraw("Free"); 203 | var drawTool = new maptalks.DrawTool({ 204 | mode: 'Point', 205 | once: true, 206 | symbol: { 207 | 'lineColor': "#FBBF24", 208 | 'lineWidth': 2, 209 | 'polygonFill': "#D97706", 210 | 'polygonOpacity': 0.1 211 | } 212 | }).addTo(map).disable(); 213 | 214 | drawTool.on('drawend', function(param) { 215 | setDraw("") 216 | let coordsTmp = param!.geometry!.getCoordinates() as Array<{x:number,y:number}>; 217 | let coords:[number, number][] = []; 218 | coordsTmp.forEach((coord) => { 219 | coords.push([ coord.y, coord.x]); 220 | }); 221 | addLine(coords, color.hex) 222 | }); 223 | drawTool.setMode('FreeHandLineString').enable(); 224 | }} 225 | > 226 | Free 227 | 228 | 229 | 230 | 231 | 232 | 233 | { 238 | setDraw("Dist"); 239 | addMeasure({map}); 240 | }} 241 | > 242 | Dist. 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | {(draw === "Mark" || draw === "Circle" || draw === "Free" || draw === "") && ()} 251 | {(draw === "Zone" || draw === "Wpts" || draw === "Line" || draw === "Dist") && (Double click to end drawing)} 252 | 253 | 254 | {geometry.valueSeq().map((it) => { 255 | const { editor_mode_on } = serverStore.getState(); 256 | if (it.type !== "quest" && (it.clickable || editor_mode_on)) { 257 | return ( 258 | { 265 | setSelectedGeometry(it.id); 266 | setSelectedEntityId(null); 267 | 268 | let position; 269 | if (it.type === "markpoint") { 270 | position = [it.position[1], it.position[0]]; 271 | } else if (it.type === "recon") { 272 | position = [it.position[1], it.position[0]]; 273 | } else if (it.type === "zone") { 274 | position = [it.points[0][1], it.points[0][0]]; 275 | } else if (it.type === "waypoints") { 276 | position = [it.points[0][1], it.points[0][0]]; 277 | } else if (it.type === "circle") { 278 | position = [it.center[1], it.center[0]]; 279 | } else if (it.type === "line") { 280 | position = [it.points[0][1], it.points[0][0]]; 281 | } 282 | 283 | if (position) { 284 | map.animateTo( 285 | { 286 | center: position, 287 | zoom: 10, 288 | }, 289 | { 290 | duration: 250, 291 | easing: "out", 292 | } 293 | ); 294 | } 295 | }} 296 | > 297 | {it.name || `${it.type} #${it.id}`} 298 | 299 | ); 300 | } 301 | })} 302 | 303 | 304 | ); 305 | } 306 | 307 | function addMeasure({ map }: { map: maptalks.Map }){ 308 | var distanceTool = new maptalks.DistanceTool({ 309 | 'symbol': { 310 | 'lineColor': '#34495e', 311 | 'lineWidth': 2 312 | }, 313 | 'vertexSymbol': { 314 | 'markerType': 'ellipse', 315 | 'markerFill': '#1bbc9b', 316 | 'markerLineColor': '#000', 317 | 'markerLineWidth': 3, 318 | 'markerWidth': 10, 319 | 'markerHeight': 10 320 | }, 321 | 322 | 'labelOptions': { 323 | 'textSymbol': { 324 | 'textFaceName': 'monospace', 325 | 'textFill': '#fff', 326 | 'textLineSpacing': 1, 327 | 'textHorizontalAlignment': 'right', 328 | 'textDx': 15, 329 | 'markerLineColor': '#b4b3b3', 330 | 'markerFill': '#000' 331 | }, 332 | 'boxStyle': { 333 | 'padding': [6, 2], 334 | 'symbol': { 335 | 'markerType': 'square', 336 | 'markerFill': '#000', 337 | 'markerFillOpacity': 0.9, 338 | 'markerLineColor': '#b4b3b3' 339 | } 340 | } 341 | }, 342 | 'clearButtonSymbol': [{ 343 | 'markerType': 'square', 344 | 'markerFill': '#000', 345 | 'markerLineColor': '#b4b3b3', 346 | 'markerLineWidth': 2, 347 | 'markerWidth': 15, 348 | 'markerHeight': 15, 349 | 'markerDx': 20 350 | }, { 351 | 'markerType': 'x', 352 | 'markerWidth': 10, 353 | 'markerHeight': 10, 354 | 'markerLineColor': '#fff', 355 | 'markerDx': 20 356 | }], 357 | 'language': 'en-US', 358 | 'once' : true, 359 | 'metric' : true, 360 | 'imperial' : true 361 | }).addTo(map); 362 | } 363 | --------------------------------------------------------------------------------
49 | Displays NATO symbology based on the type of radar track. 50 |
69 | Displays airframe type and NATO designation based on type of radar 70 | track. 71 |
92 | Number of previous radar-sweep pings to display in-trail for a 93 | tracks. 94 |
114 | Select which ground units to display. Some options may not be 115 | available based on server settings. 116 |