├── pnpm-workspace.yaml ├── docs ├── icons │ ├── favicon.ico │ └── favicon.png ├── screenshot │ ├── citypair.png │ ├── jet1090.png │ ├── process.png │ ├── windfield.png │ ├── tangram_diagram.pdf │ ├── tangram_diagram.png │ ├── tangram_screenshot_fr.png │ └── tangram_screenshot_nl.png ├── .markdownlint.jsonc ├── api │ ├── system.md │ ├── weather.md │ ├── jet1090.md │ ├── ship162.md │ └── core.md ├── css │ └── extra.css ├── plugins │ ├── airports.md │ ├── weather.md │ ├── system.md │ ├── index.md │ ├── examples │ │ ├── sensors.md │ │ └── citypair.md │ ├── ship162.md │ ├── history.md │ ├── frontend.md │ └── jet1090.md ├── configuration.md ├── index.md ├── contribute.md └── architecture │ └── index.md ├── packages ├── tangram_core │ ├── public │ │ ├── favicon.ico │ │ └── favicon.png │ ├── src │ │ └── tangram_core │ │ │ ├── __init__.py │ │ │ ├── main.ts │ │ │ ├── _core.pyi │ │ │ ├── plugin.ts │ │ │ ├── user.css │ │ │ ├── colour.ts │ │ │ ├── vite-plugin-tangram.mjs │ │ │ ├── redis.py │ │ │ ├── config.py │ │ │ └── plugin.py │ ├── rust │ │ ├── src │ │ │ ├── bin │ │ │ │ └── stub_gen.rs │ │ │ ├── channel │ │ │ │ ├── LICENSE │ │ │ │ └── utils.rs │ │ │ └── bbox.rs │ │ ├── readme.md │ │ └── Cargo.toml │ ├── readme.md │ ├── index.html │ ├── package.json │ ├── pyproject.toml │ ├── vite.lib-esm.config.ts │ ├── vite.config.ts │ └── tests │ │ └── test_api.py ├── tangram_airports │ ├── src │ │ └── tangram_airports │ │ │ ├── __init__.py │ │ │ ├── index.ts │ │ │ └── AirportSearchWidget.vue │ ├── vite.config.ts │ ├── package.json │ ├── readme.md │ └── pyproject.toml ├── tangram_example │ ├── src │ │ └── tangram_example │ │ │ ├── ExampleWidget.vue │ │ │ ├── index.ts │ │ │ └── __init__.py │ ├── vite.config.ts │ ├── package.json │ ├── readme.md │ └── pyproject.toml ├── tangram_jet1090 │ ├── rust │ │ ├── src │ │ │ └── bin │ │ │ │ └── stub_gen.rs │ │ ├── build.rs │ │ └── Cargo.toml │ ├── vite.config.ts │ ├── package.json │ ├── readme.md │ ├── src │ │ └── tangram_jet1090 │ │ │ ├── store.ts │ │ │ ├── AircraftCountWidget.vue │ │ │ ├── SensorsLayer.vue │ │ │ └── _planes.pyi │ └── pyproject.toml ├── tangram_ship162 │ ├── rust │ │ ├── src │ │ │ └── bin │ │ │ │ └── stub_gen.rs │ │ ├── build.rs │ │ └── Cargo.toml │ ├── vite.config.ts │ ├── src │ │ └── tangram_ship162 │ │ │ ├── store.ts │ │ │ ├── ShipCountWidget.vue │ │ │ ├── ShipTrailLayer.vue │ │ │ ├── _ships.pyi │ │ │ └── __init__.py │ ├── package.json │ ├── readme.md │ └── pyproject.toml ├── tangram_history │ ├── rust │ │ ├── src │ │ │ ├── bin │ │ │ │ └── stub_gen.rs │ │ │ ├── protocol.rs │ │ │ └── lib.rs │ │ ├── build.rs │ │ └── Cargo.toml │ ├── readme.md │ ├── src │ │ └── tangram_history │ │ │ ├── _history.pyi │ │ │ └── __init__.py │ └── pyproject.toml ├── tangram_globe │ ├── vite.config.ts │ ├── src │ │ └── tangram_globe │ │ │ ├── index.ts │ │ │ ├── __init__.py │ │ │ └── GlobeToggle.vue │ ├── package.json │ ├── readme.md │ └── pyproject.toml ├── tangram_system │ ├── vite.config.ts │ ├── package.json │ ├── src │ │ └── tangram_system │ │ │ ├── index.ts │ │ │ ├── SystemWidget.vue │ │ │ └── __init__.py │ ├── readme.md │ └── pyproject.toml └── tangram_weather │ ├── vite.config.ts │ ├── package.json │ ├── src │ └── tangram_weather │ │ ├── index.ts │ │ ├── __init__.py │ │ └── WindFieldLayer.vue │ ├── readme.md │ └── pyproject.toml ├── .prettierrc.json ├── config_jet1090.example.toml ├── .editorconfig ├── package.json ├── tsconfig.json ├── tangram.example.toml ├── eslint.config.ts ├── compose.yml ├── .github ├── workflows │ ├── build-frontend.yml │ ├── docs.yml │ ├── tests.yml │ ├── ci.yml │ ├── podman.yml │ └── release.yml └── dependabot.yml ├── Cargo.toml ├── citation.cff ├── pyproject.toml ├── .gitignore ├── readme.md ├── justfile └── mkdocs.yml /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/*" -------------------------------------------------------------------------------- /docs/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-aviation/tangram/HEAD/docs/icons/favicon.ico -------------------------------------------------------------------------------- /docs/icons/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-aviation/tangram/HEAD/docs/icons/favicon.png -------------------------------------------------------------------------------- /docs/screenshot/citypair.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-aviation/tangram/HEAD/docs/screenshot/citypair.png -------------------------------------------------------------------------------- /docs/screenshot/jet1090.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-aviation/tangram/HEAD/docs/screenshot/jet1090.png -------------------------------------------------------------------------------- /docs/screenshot/process.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-aviation/tangram/HEAD/docs/screenshot/process.png -------------------------------------------------------------------------------- /docs/screenshot/windfield.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-aviation/tangram/HEAD/docs/screenshot/windfield.png -------------------------------------------------------------------------------- /docs/screenshot/tangram_diagram.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-aviation/tangram/HEAD/docs/screenshot/tangram_diagram.pdf -------------------------------------------------------------------------------- /docs/screenshot/tangram_diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-aviation/tangram/HEAD/docs/screenshot/tangram_diagram.png -------------------------------------------------------------------------------- /packages/tangram_core/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-aviation/tangram/HEAD/packages/tangram_core/public/favicon.ico -------------------------------------------------------------------------------- /packages/tangram_core/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-aviation/tangram/HEAD/packages/tangram_core/public/favicon.png -------------------------------------------------------------------------------- /docs/.markdownlint.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "MD013": false, // Line length 3 | "MD046": false // Code blocks should be fenced with backticks 4 | } 5 | -------------------------------------------------------------------------------- /docs/screenshot/tangram_screenshot_fr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-aviation/tangram/HEAD/docs/screenshot/tangram_screenshot_fr.png -------------------------------------------------------------------------------- /docs/screenshot/tangram_screenshot_nl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-aviation/tangram/HEAD/docs/screenshot/tangram_screenshot_nl.png -------------------------------------------------------------------------------- /docs/api/system.md: -------------------------------------------------------------------------------- 1 | # System 2 | 3 | ::: tangram_system 4 | options: 5 | show_if_no_docstring: true 6 | show_submodules: true 7 | -------------------------------------------------------------------------------- /docs/api/weather.md: -------------------------------------------------------------------------------- 1 | # Weather 2 | 3 | ::: tangram_weather 4 | options: 5 | show_if_no_docstring: true 6 | show_submodules: true 7 | -------------------------------------------------------------------------------- /docs/css/extra.css: -------------------------------------------------------------------------------- 1 | .subtitle { 2 | font-size: 1rem; 3 | font-style: italic; 4 | color: #666; 5 | margin-top: -1em; 6 | margin-bottom: 1em; 7 | } 8 | -------------------------------------------------------------------------------- /packages/tangram_airports/src/tangram_airports/__init__.py: -------------------------------------------------------------------------------- 1 | import tangram_core 2 | 3 | plugin = tangram_core.Plugin( 4 | frontend_path="dist-frontend", 5 | ) 6 | -------------------------------------------------------------------------------- /packages/tangram_example/src/tangram_example/ExampleWidget.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /packages/tangram_jet1090/rust/src/bin/stub_gen.rs: -------------------------------------------------------------------------------- 1 | use _planes::stub_info; 2 | use pyo3_stub_gen::Result; 3 | 4 | fn main() -> Result<()> { 5 | let stub = stub_info()?; 6 | stub.generate()?; 7 | Ok(()) 8 | } 9 | -------------------------------------------------------------------------------- /packages/tangram_ship162/rust/src/bin/stub_gen.rs: -------------------------------------------------------------------------------- 1 | use _ships::stub_info; 2 | use pyo3_stub_gen::Result; 3 | 4 | fn main() -> Result<()> { 5 | let stub = stub_info()?; 6 | stub.generate()?; 7 | Ok(()) 8 | } 9 | -------------------------------------------------------------------------------- /packages/tangram_history/rust/src/bin/stub_gen.rs: -------------------------------------------------------------------------------- 1 | use pyo3_stub_gen::Result; 2 | use tangram_history::stub_info; 3 | 4 | fn main() -> Result<()> { 5 | let stub = stub_info()?; 6 | stub.generate()?; 7 | Ok(()) 8 | } 9 | -------------------------------------------------------------------------------- /packages/tangram_airports/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { tangramPlugin } from "@open-aviation/tangram-core/vite-plugin"; 3 | 4 | export default defineConfig({ 5 | plugins: [tangramPlugin()] 6 | }); 7 | -------------------------------------------------------------------------------- /packages/tangram_example/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { tangramPlugin } from "@open-aviation/tangram-core/vite-plugin"; 3 | 4 | export default defineConfig({ 5 | plugins: [tangramPlugin()] 6 | }); 7 | -------------------------------------------------------------------------------- /packages/tangram_globe/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { tangramPlugin } from "@open-aviation/tangram-core/vite-plugin"; 3 | 4 | export default defineConfig({ 5 | plugins: [tangramPlugin()] 6 | }); 7 | -------------------------------------------------------------------------------- /packages/tangram_jet1090/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { tangramPlugin } from "@open-aviation/tangram-core/vite-plugin"; 3 | 4 | export default defineConfig({ 5 | plugins: [tangramPlugin()] 6 | }); 7 | -------------------------------------------------------------------------------- /packages/tangram_ship162/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { tangramPlugin } from "@open-aviation/tangram-core/vite-plugin"; 3 | 4 | export default defineConfig({ 5 | plugins: [tangramPlugin()] 6 | }); 7 | -------------------------------------------------------------------------------- /packages/tangram_system/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { tangramPlugin } from "@open-aviation/tangram-core/vite-plugin"; 3 | 4 | export default defineConfig({ 5 | plugins: [tangramPlugin()] 6 | }); 7 | -------------------------------------------------------------------------------- /packages/tangram_weather/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { tangramPlugin } from "@open-aviation/tangram-core/vite-plugin"; 3 | 4 | export default defineConfig({ 5 | plugins: [tangramPlugin()] 6 | }); 7 | -------------------------------------------------------------------------------- /packages/tangram_core/src/tangram_core/__init__.py: -------------------------------------------------------------------------------- 1 | from .backend import BackendState, InjectBackendState 2 | from .config import Config 3 | from .plugin import Plugin 4 | 5 | __all__ = ["BackendState", "Config", "InjectBackendState", "Plugin"] 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc", 3 | "trailingComma": "none", 4 | "tabWidth": 2, 5 | "semi": true, 6 | "singleQuote": false, 7 | "arrowParens": "avoid", 8 | "printWidth": 88 9 | } -------------------------------------------------------------------------------- /docs/api/jet1090.md: -------------------------------------------------------------------------------- 1 | # Jet1090 2 | 3 | ::: tangram_jet1090 4 | options: 5 | show_if_no_docstring: true 6 | show_submodules: true 7 | ::: tangram_jet1090._planes 8 | options: 9 | show_if_no_docstring: true 10 | show_source: false 11 | -------------------------------------------------------------------------------- /docs/api/ship162.md: -------------------------------------------------------------------------------- 1 | # Ship162 2 | 3 | ::: tangram_ship162 4 | options: 5 | show_if_no_docstring: true 6 | show_submodules: true 7 | ::: tangram_ship162._ships 8 | options: 9 | show_if_no_docstring: true 10 | show_source: false 11 | -------------------------------------------------------------------------------- /packages/tangram_history/rust/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | // fixes linking issue on macOS 3 | if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") { 4 | println!("cargo:rustc-link-arg=-undefined"); 5 | println!("cargo:rustc-link-arg=dynamic_lookup"); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/tangram_jet1090/rust/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | // fixes linking issue on macOS 3 | if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") { 4 | println!("cargo:rustc-link-arg=-undefined"); 5 | println!("cargo:rustc-link-arg=dynamic_lookup"); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/tangram_ship162/rust/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | // fixes linking issue on macOS 3 | if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") { 4 | println!("cargo:rustc-link-arg=-undefined"); 5 | println!("cargo:rustc-link-arg=dynamic_lookup"); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /config_jet1090.example.toml: -------------------------------------------------------------------------------- 1 | verbose = false 2 | interactive = true 3 | serve_port = 8080 4 | prevent_sleep = false 5 | update_position = false 6 | history_expire = 20 7 | redis_url = "redis://redis:6379" 8 | 9 | [[sources]] 10 | name = "TU Delft" 11 | airport = "EHRD" 12 | websocket = "ws://feedme.mode-s.org:9876/40128" 13 | -------------------------------------------------------------------------------- /packages/tangram_airports/src/tangram_airports/index.ts: -------------------------------------------------------------------------------- 1 | import type { TangramApi } from "@open-aviation/tangram-core/api"; 2 | import AirportSearchWidget from "./AirportSearchWidget.vue"; 3 | 4 | export function install(api: TangramApi) { 5 | api.ui.registerWidget("airport-search-widget", "MapOverlay", AirportSearchWidget); 6 | } 7 | -------------------------------------------------------------------------------- /packages/tangram_example/src/tangram_example/index.ts: -------------------------------------------------------------------------------- 1 | import type { TangramApi } from "@open-aviation/tangram-core/api"; 2 | import ExampleWidget from "./ExampleWidget.vue"; 3 | 4 | export function install(api: TangramApi) { 5 | api.ui.registerWidget("example-widget", "SideBar", ExampleWidget, { 6 | title: "Example Widget" 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /docs/api/core.md: -------------------------------------------------------------------------------- 1 | # Core 2 | 3 | ::: tangram_core 4 | options: 5 | show_if_no_docstring: true 6 | show_submodules: true 7 | filters: 8 | - "!^_" 9 | - "tangram_core.__main__" 10 | ::: tangram_core._core 11 | options: 12 | show_if_no_docstring: true 13 | show_source: false 14 | -------------------------------------------------------------------------------- /packages/tangram_globe/src/tangram_globe/index.ts: -------------------------------------------------------------------------------- 1 | import type { TangramApi } from "@open-aviation/tangram-core/api"; 2 | import GlobeToggle from "./GlobeToggle.vue"; 3 | 4 | export function install(api: TangramApi, config?: SystemConfig) { 5 | api.ui.registerWidget("globe-toggle", "TopBar", GlobeToggle, { 6 | priority: config?.topbar_order 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /packages/tangram_core/rust/src/bin/stub_gen.rs: -------------------------------------------------------------------------------- 1 | use pyo3_stub_gen::Result; 2 | use tangram_core::stub_info; 3 | 4 | // finds the module name (`tangram_core._core`) from pyproject.toml and generates 5 | // the pyi at `packages/tangram_core/src/tangram_core/_core.pyi` 6 | fn main() -> Result<()> { 7 | let stub = stub_info()?; 8 | stub.generate()?; 9 | Ok(()) 10 | } 11 | -------------------------------------------------------------------------------- /packages/tangram_ship162/src/tangram_ship162/store.ts: -------------------------------------------------------------------------------- 1 | import { reactive } from "vue"; 2 | import type { Ship162Vessel } from "."; 3 | 4 | export interface ShipSelectionData { 5 | trajectory: Ship162Vessel[]; 6 | loading: boolean; 7 | error: string | null; 8 | } 9 | 10 | export const shipStore = reactive({ 11 | selected: new Map(), 12 | version: 0 13 | }); 14 | -------------------------------------------------------------------------------- /packages/tangram_globe/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@open-aviation/tangram-globe", 3 | "version": "0.2.1", 4 | "private": true, 5 | "type": "module", 6 | "main": "src/tangram_globe/index.ts", 7 | "scripts": { 8 | "build": "vite build" 9 | }, 10 | "dependencies": { 11 | "@open-aviation/tangram-core": "workspace:*" 12 | }, 13 | "devDependencies": { 14 | "vue": "^3.5.25" 15 | } 16 | } -------------------------------------------------------------------------------- /packages/tangram_system/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@open-aviation/tangram-system", 3 | "version": "0.2.1", 4 | "private": true, 5 | "type": "module", 6 | "main": "src/tangram_system/index.ts", 7 | "scripts": { 8 | "build": "vite build" 9 | }, 10 | "dependencies": { 11 | "@open-aviation/tangram-core": "workspace:*" 12 | }, 13 | "devDependencies": { 14 | "vue": "^3.5.25" 15 | } 16 | } -------------------------------------------------------------------------------- /packages/tangram_example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@open-aviation/tangram-example", 3 | "version": "0.2.1", 4 | "private": true, 5 | "type": "module", 6 | "main": "src/tangram_example/index.ts", 7 | "scripts": { 8 | "build": "vite build" 9 | }, 10 | "dependencies": { 11 | "@open-aviation/tangram-core": "workspace:*" 12 | }, 13 | "devDependencies": { 14 | "vue": "^3.5.25" 15 | } 16 | } -------------------------------------------------------------------------------- /packages/tangram_system/src/tangram_system/index.ts: -------------------------------------------------------------------------------- 1 | import type { TangramApi } from "@open-aviation/tangram-core/api"; 2 | import SystemWidget from "./SystemWidget.vue"; 3 | 4 | interface SystemConfig { 5 | topbar_order: number; 6 | } 7 | 8 | export function install(api: TangramApi, config?: SystemConfig) { 9 | api.ui.registerWidget("system-widget", "TopBar", SystemWidget, { 10 | priority: config?.topbar_order 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /packages/tangram_airports/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@open-aviation/tangram-airports", 3 | "version": "0.2.1", 4 | "private": true, 5 | "type": "module", 6 | "main": "src/tangram_airports/index.ts", 7 | "scripts": { 8 | "build": "vite build" 9 | }, 10 | "dependencies": { 11 | "@open-aviation/tangram-core": "workspace:*" 12 | }, 13 | "devDependencies": { 14 | "rs1090-wasm": "^0.4.14", 15 | "vue": "^3.5.25" 16 | } 17 | } -------------------------------------------------------------------------------- /packages/tangram_weather/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@open-aviation/tangram-weather", 3 | "version": "0.2.1", 4 | "private": true, 5 | "type": "module", 6 | "main": "src/tangram_weather/index.ts", 7 | "scripts": { 8 | "build": "vite build" 9 | }, 10 | "dependencies": { 11 | "@open-aviation/tangram-core": "workspace:*", 12 | "weatherlayers-gl": "^2025.11.0" 13 | }, 14 | "devDependencies": { 15 | "vue": "^3.5.25" 16 | } 17 | } -------------------------------------------------------------------------------- /packages/tangram_weather/src/tangram_weather/index.ts: -------------------------------------------------------------------------------- 1 | import type { TangramApi } from "@open-aviation/tangram-core/api"; 2 | import type { PluginUiConfig } from "@open-aviation/tangram-core/plugin"; 3 | import WindFieldLayer from "./WindFieldLayer.vue"; 4 | 5 | export function install(api: TangramApi, config?: PluginUiConfig) { 6 | api.ui.registerWidget("wind-field-layer", "MapOverlay", WindFieldLayer, { 7 | priority: config?.topbar_order 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /packages/tangram_ship162/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@open-aviation/tangram-ship162", 3 | "version": "0.2.1", 4 | "private": true, 5 | "type": "module", 6 | "main": "src/tangram_ship162/index.ts", 7 | "scripts": { 8 | "build": "vite build" 9 | }, 10 | "dependencies": { 11 | "@open-aviation/tangram-core": "workspace:*" 12 | }, 13 | "devDependencies": { 14 | "@deck.gl/core": "^9.2.5", 15 | "@deck.gl/layers": "^9.2.5", 16 | "vue": "^3.5.25" 17 | } 18 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | max_line_length = 88 9 | indent_style = space 10 | trim_trailing_whitespace = true 11 | 12 | [*.{js, py}] 13 | charset = utf-8 14 | 15 | # TODO(abr): move this to [tool.ruff] in pyproject.toml 16 | [*.py] 17 | indent_size = 4 18 | max_line_length = 88 19 | 20 | [*.toml] 21 | indent_size = 4 22 | 23 | [*.{js, mjs, ts, mts, vue, html, css, yaml, json}] 24 | indent_size = 2 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tangram-monorepo", 3 | "private": true, 4 | "scripts": { 5 | "build": "pnpm --filter \"./packages/*\" build", 6 | "lint": "eslint --fix", 7 | "fmt": "prettier --write --cache \"packages/**/*.{js,ts,mjs,vue}\"" 8 | }, 9 | "devDependencies": { 10 | "@vue/eslint-config-prettier": "^10.2.0", 11 | "@vue/eslint-config-typescript": "^14.6.0", 12 | "eslint": "^9.39.1", 13 | "eslint-plugin-vue": "^10.6.2", 14 | "jiti": "^2.6.1", 15 | "prettier": "^3.7.4", 16 | "typescript": "^5.9.3", 17 | "vite": "^7.2.7" 18 | } 19 | } -------------------------------------------------------------------------------- /packages/tangram_globe/src/tangram_globe/__init__.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any 3 | 4 | import tangram_core 5 | 6 | 7 | @dataclass 8 | class GlobeConfig(tangram_core.config.HasSidebarUiConfig): 9 | topbar_order: int = 1000 10 | 11 | 12 | def transform_config(config_dict: dict[str, Any]) -> GlobeConfig: 13 | from pydantic import TypeAdapter 14 | 15 | return TypeAdapter(GlobeConfig).validate_python(config_dict) 16 | 17 | 18 | plugin = tangram_core.Plugin( 19 | frontend_path="dist-frontend", into_frontend_config_function=transform_config 20 | ) 21 | -------------------------------------------------------------------------------- /packages/tangram_globe/readme.md: -------------------------------------------------------------------------------- 1 | # tangram_globe 2 | 3 | The `tangram_globe` plugin adds a toggle to switch in the top navigatio bar to switch between Mercator and globe view 4 | 5 | 6 | ## About Tangram 7 | 8 | `tangram_globe` is a plugin for `tangram`, an open framework for modular, real-time air traffic management research. 9 | 10 | - Documentation: 11 | - Repository: 12 | 13 | Installation: 14 | 15 | ```sh 16 | # cli via uv 17 | uv tool install --with tangram-globe tangram-core 18 | # with pip 19 | pip install tangram-core tangram-globe 20 | ``` 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ESNext", "DOM"], 7 | "moduleResolution": "bundler", 8 | "strict": true, 9 | "sourceMap": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "esModuleInterop": true, 13 | "noEmit": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "noImplicitReturns": true, 17 | "skipLibCheck": true, 18 | "customConditions": ["source"], 19 | }, 20 | "include": ["packages/**/*.ts", "packages/**/*.d.ts", "packages/**/*.vue"] 21 | } -------------------------------------------------------------------------------- /tangram.example.toml: -------------------------------------------------------------------------------- 1 | [core] 2 | redis_url = "redis://localhost:6379" 3 | plugins = [ 4 | # -- Core functionalities -- 5 | "tangram_jet1090", 6 | "tangram_history", 7 | # -- Extra functionalities -- 8 | "tangram_system", 9 | "tangram_airports", 10 | "tangram_globe", 11 | # "tangram_ship162", 12 | # "tangram_weather", 13 | # "tangram_example", 14 | ] 15 | 16 | [server] 17 | host = "0.0.0.0" 18 | port = 2346 19 | 20 | [channel] 21 | host = "0.0.0.0" 22 | port = 2347 23 | jwt_secret = "a-better-secret-than-this" 24 | jwt_expiration_secs = 315360000 25 | 26 | [plugins.tangram_jet1090] 27 | jet1090_channel = "jet1090" 28 | state_vector_expire = 20 29 | -------------------------------------------------------------------------------- /packages/tangram_jet1090/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@open-aviation/tangram-jet1090", 3 | "version": "0.2.1", 4 | "private": true, 5 | "type": "module", 6 | "main": "src/tangram_jet1090/index.ts", 7 | "scripts": { 8 | "build": "vite build" 9 | }, 10 | "dependencies": { 11 | "@open-aviation/tangram-core": "workspace:*", 12 | "chart.js": "^4.5.1", 13 | "dayjs": "^1.11.19", 14 | "vue-chartjs": "^5.3.3" 15 | }, 16 | "devDependencies": { 17 | "@deck.gl/core": "^9.2.5", 18 | "@deck.gl/extensions": "^9.2.5", 19 | "@deck.gl/layers": "^9.2.5", 20 | "maplibre-gl": "^5.14.0", 21 | "rs1090-wasm": "^0.4.14", 22 | "vue": "^3.5.25" 23 | } 24 | } -------------------------------------------------------------------------------- /eslint.config.ts: -------------------------------------------------------------------------------- 1 | import { globalIgnores } from "eslint/config"; 2 | import { 3 | defineConfigWithVueTs, 4 | vueTsConfigs 5 | } from "@vue/eslint-config-typescript"; 6 | import pluginVue from "eslint-plugin-vue"; 7 | import skipFormatting from "@vue/eslint-config-prettier/skip-formatting"; 8 | 9 | export default defineConfigWithVueTs( 10 | { 11 | name: "app/files-to-lint", 12 | files: ["**/*.{ts,mts,js,mjs,vue}"] 13 | }, 14 | globalIgnores(["**/dist/**", "**/dist-frontend/**", "packages/tangram_core/src/tangram_core/__Timeline.vue", ".venv"]), 15 | pluginVue.configs["flat/recommended"], 16 | vueTsConfigs.recommended, 17 | skipFormatting // use prettier for code formatting, eslint for code quality 18 | ); 19 | -------------------------------------------------------------------------------- /packages/tangram_airports/readme.md: -------------------------------------------------------------------------------- 1 | # tangram_airports 2 | 3 | The `tangram_airports` plugin adds a searchable airport database widget to the tangram frontend. 4 | 5 | It allows users to quickly locate and center the map on airports using their name, IATA, or ICAO codes. 6 | 7 | ## About Tangram 8 | 9 | `tangram_airports` is a plugin for `tangram`, an open framework for modular, real-time air traffic management research. 10 | 11 | - Documentation: 12 | - Repository: 13 | 14 | Installation: 15 | 16 | ```sh 17 | # cli via uv 18 | uv tool install --with tangram-airports tangram-core 19 | # with pip 20 | pip install tangram-core tangram-airports 21 | ``` 22 | -------------------------------------------------------------------------------- /packages/tangram_system/readme.md: -------------------------------------------------------------------------------- 1 | # tangram_system 2 | 3 | The `tangram_system` plugin provides system monitoring capabilities for the tangram framework. 4 | 5 | It includes a background service that broadcasts server metrics (CPU, RAM, Uptime) to connected frontend clients via the realtime channel. 6 | 7 | ## About Tangram 8 | 9 | `tangram_system` is a plugin for `tangram`, an open framework for modular, real-time air traffic management research. 10 | 11 | - Documentation: 12 | - Repository: 13 | 14 | Installation: 15 | 16 | ```sh 17 | # cli via uv 18 | uv tool install --with tangram-system tangram-core 19 | # with pip 20 | pip install tangram-core tangram-system 21 | ``` 22 | -------------------------------------------------------------------------------- /packages/tangram_example/readme.md: -------------------------------------------------------------------------------- 1 | # Example plugin 2 | 3 | This is an example plugin for the tangram framework. 4 | 5 | It serves as a minimal template for creating new backend plugins for API services. 6 | 7 | A plugin is a standard Python package that uses entry points in its `pyproject.toml` to make itself discoverable by `tangram`. See the `[project.entry-points."tangram_core.plugins"]` section for an example. 8 | 9 | The plugin's `__init__.py` should define a `plugin = tangram_core.Plugin()` instance and use its decorators (`@plugin.register_router`, `@plugin.register_service`) to register components. 10 | 11 | You can explore more plugins in the `packages/*` directory to see how they are structured. A more complete documentation is available at 12 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | tangram: 3 | build: 4 | context: . 5 | command: tangram serve --config /etc/tangram_core/config.toml 6 | volumes: 7 | - ${PATH_TANGRAM_TOML:-./tangram.example.toml}:/etc/tangram_core/config.toml:ro 8 | ports: 9 | - "${TANGRAM_PORT_HOST:-2346}:${TANGRAM_PORT_CONTAINER:-2346}" 10 | - "${CHANNEL_PORT_HOST:-2347}:${CHANNEL_PORT_CONTAINER:-2347}" 11 | depends_on: 12 | - redis 13 | restart: unless-stopped 14 | 15 | # TODO: port, and only enable if tangram_toml specifies so 16 | jet1090: 17 | image: ghcr.io/xoolive/jet1090:0.4.13 18 | restart: unless-stopped 19 | 20 | # TODO: port 21 | redis: 22 | image: docker.io/library/redis:8-alpine 23 | restart: unless-stopped 24 | ports: 25 | - "${REDIS_PORT:-6379}:6379" -------------------------------------------------------------------------------- /packages/tangram_history/readme.md: -------------------------------------------------------------------------------- 1 | # tangram_history 2 | 3 | The `tangram_history` plugin provides a generic framework for persisting and querying historical surveillance data within the `tangram` ecosystem. It is designed to work alongside other data ingestion plugins, such as `tangram_jet1090` and `tangram_ship162`, to store time-ordered trajectories of various entities. 4 | 5 | ## About Tangram 6 | 7 | `tangram_history` is a plugin for `tangram`, an open framework for modular, real-time air traffic management research. 8 | 9 | - Documentation: 10 | - Repository: 11 | 12 | Installation: 13 | 14 | ```sh 15 | # cli via uv 16 | uv tool install --with tangram-history tangram-core 17 | # with pip 18 | pip install tangram-core tangram-history 19 | ``` 20 | -------------------------------------------------------------------------------- /packages/tangram_ship162/readme.md: -------------------------------------------------------------------------------- 1 | # tangram_ship162 2 | 3 | The `tangram_ship162` plugin integrates AIS data from a `ship162` instance, enabling real-time visualization and historical analysis of maritime traffic. 4 | 5 | It provides: 6 | 7 | - A background service to ingest AIS messages. 8 | - A REST API endpoint to fetch ship trajectories. 9 | - Frontend widgets for visualizing ships on the map. 10 | 11 | ## About Tangram 12 | 13 | `tangram_ship162` is a plugin for `tangram`, an open framework for modular, real-time air traffic management research. 14 | 15 | - Documentation: 16 | - Repository: 17 | 18 | Installation: 19 | 20 | ```sh 21 | # cli via uv 22 | uv tool install --with tangram-ship162 tangram-core 23 | # with pip 24 | pip install tangram-core tangram-ship162 25 | ``` 26 | -------------------------------------------------------------------------------- /.github/workflows/build-frontend.yml: -------------------------------------------------------------------------------- 1 | name: build frontend 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: checkout repo 11 | uses: actions/checkout@v6 12 | 13 | - uses: pnpm/action-setup@v4 14 | with: 15 | version: 10 16 | 17 | - name: install node 18 | uses: actions/setup-node@v6 19 | with: 20 | node-version: 20 21 | cache: 'pnpm' 22 | 23 | - name: install deps 24 | run: pnpm install --frozen-lockfile 25 | 26 | - name: build 27 | run: pnpm build 28 | 29 | - name: upload repo with built frontend 30 | uses: actions/upload-artifact@v6 31 | with: 32 | name: repo-with-frontend 33 | path: | 34 | . 35 | !node_modules 36 | retention-days: 1 -------------------------------------------------------------------------------- /packages/tangram_jet1090/readme.md: -------------------------------------------------------------------------------- 1 | # tangram_jet1090 2 | 3 | The `tangram_jet1090` plugin enables real-time tracking of aircraft by integrating with a `jet1090` instance, which decodes ADS-B signals received from nearby aircraft. 4 | 5 | It provides: 6 | 7 | - A background service to maintain real-time state of visible aircraft. 8 | - A REST API endpoint to fetch historical trajectories. 9 | - Frontend widgets for the tangram web interface. 10 | 11 | ## About Tangram 12 | 13 | `tangram_jet1090` is a plugin for `tangram`, an open framework for modular, real-time air traffic management research. 14 | 15 | - Documentation: 16 | - Repository: 17 | 18 | Installation: 19 | 20 | ```sh 21 | # cli via uv 22 | uv tool install --with tangram-jet1090 tangram-core 23 | # with pip 24 | pip install tangram-core tangram-jet1090 25 | ``` 26 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | 8 | - package-ecosystem: "npm" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | 13 | - package-ecosystem: cargo 14 | directory: "/" 15 | schedule: 16 | interval: "weekly" 17 | ignore: 18 | # tangram_{jet1090|ship162} ---> tangram_history ---> deltalake-core ---> arrow 19 | # \_______________________________________________________/ 20 | - dependency-name: "arrow-*" 21 | update-types: ["version-update:semver-major"] 22 | # regression in https://redirect.github.com/Jij-Inc/pyo3-stub-gen/pull/345 23 | - dependency-name: "pyo3-stub-gen" 24 | versions: [">=0.17"] 25 | 26 | - package-ecosystem: "github-actions" 27 | directory: "/" 28 | schedule: 29 | interval: "weekly" 30 | -------------------------------------------------------------------------------- /docs/plugins/airports.md: -------------------------------------------------------------------------------- 1 | # Airports Plugin 2 | 3 | The `tangram_airports` plugin adds an airport search widget as an overlay on the top-right corner of the map. This allows users to quickly find and center the map on any airport by name, IATA code, or ICAO code. 4 | 5 | ## How It Works 6 | 7 | This is a frontend-only plugin that requires no additional backend configuration. 8 | 9 | 1. It registers a Vue component, `AirportSearchWidget.vue`, in the `MapOverlay` location of the UI. 10 | 2. The component uses the `rs1090-wasm` library, which is bundled with the core `tangram` application, to perform a fast, client-side search of a comprehensive airport database. 11 | 3. When a user selects an airport from the search results, the plugin uses the `MapApi` to pan and zoom the map to the airport's location. 12 | 13 | ## Configuration 14 | 15 | To enable this plugin, add `"tangram_airports"` to the `plugins` list in your `tangram.toml` file: 16 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "3" 3 | members = [ 4 | "packages/tangram_core/rust", 5 | "packages/tangram_history/rust", 6 | "packages/tangram_jet1090/rust", 7 | "packages/tangram_ship162/rust", 8 | ] 9 | 10 | [workspace.package] 11 | license = "AGPL-3.0" 12 | edition = "2021" 13 | readme = "readme.md" 14 | version = "0.2.1" 15 | repository = "https://github.com/open-aviation/tangram" 16 | homepage = "https://mode-s.org/tangram/" 17 | documentation = "https://mode-s.org/tangram/" 18 | 19 | # on github actions, tangram_history (which effectively compiles datafusion) 20 | # with lto = 'fat' takes a huge amount of time and memory: 21 | # - SIGABRT on manylinux (i686) due to Rust-LLVM OOM 22 | # - SIGKILL on {many,musl}linux (armv7, aarch64) 23 | # we therefore pick a less aggressive lto, resulting in a ~3x shorter compile 24 | # time at the cost of increasing binary size by ~30%. 25 | [profile.release] 26 | lto = "thin" 27 | -------------------------------------------------------------------------------- /packages/tangram_example/src/tangram_example/__init__.py: -------------------------------------------------------------------------------- 1 | import tangram_core 2 | from fastapi import APIRouter 3 | from pydantic import BaseModel 4 | 5 | # Create a router for your plugin 6 | router = APIRouter( 7 | prefix="/example", # All routes will be prefixed with /example 8 | tags=["example"], # For API documentation organization 9 | responses={404: {"description": "Not found"}}, 10 | ) 11 | 12 | 13 | class ExampleResponse(BaseModel): 14 | data: str 15 | 16 | 17 | # Define endpoints on your router 18 | @router.get("/", response_model=ExampleResponse) 19 | async def get_example() -> ExampleResponse: 20 | "An example endpoint that returns some data." 21 | return ExampleResponse(data="This is an example plugin response") 22 | 23 | 24 | plugin = tangram_core.Plugin( 25 | frontend_path="dist-frontend", 26 | routers=[ 27 | # The core will add this to the main FastAPI application. 28 | router 29 | ], 30 | ) 31 | -------------------------------------------------------------------------------- /packages/tangram_weather/readme.md: -------------------------------------------------------------------------------- 1 | # Weather plugin 2 | 3 | This plugin provides an API endpoint to fetch weather data from a third-party service. For now, files with predictions from the Meteo-France Arpege weather service are used, but it is also possible to use any weather service that provides an API. 4 | 5 | Grib files are downloaded in the system temporary directory and then processed to extract the relevant data. The plugin provides an endpoint to fetch the weather data for specific spatio-temporal coordinates. 6 | 7 | ## About Tangram 8 | 9 | `tangram_weather` is a plugin for `tangram`, an open framework for modular, real-time air traffic management research. 10 | 11 | - Documentation: 12 | - Repository: 13 | 14 | Installation: 15 | 16 | ```sh 17 | # cli via uv 18 | uv tool install --with tangram-weather tangram-core 19 | # with pip 20 | pip install tangram-core tangram-weather 21 | ``` 22 | -------------------------------------------------------------------------------- /packages/tangram_jet1090/src/tangram_jet1090/store.ts: -------------------------------------------------------------------------------- 1 | import { reactive } from "vue"; 2 | import type { Jet1090Aircraft } from "."; 3 | 4 | export interface AirportInfo { 5 | lat: number | null; 6 | lon: number | null; 7 | name: string; 8 | city: string; 9 | icao: string; 10 | } 11 | 12 | export interface AircraftSelectionData { 13 | trajectory: Jet1090Aircraft[]; 14 | loading: boolean; 15 | error: string | null; 16 | route: { 17 | origin: AirportInfo | null; 18 | destination: AirportInfo | null; 19 | }; 20 | } 21 | 22 | export interface TrailColorOptions { 23 | by_attribute: "altitude" | "groundspeed" | "vertical_rate" | "track"; 24 | min?: number; 25 | max?: number; 26 | } 27 | 28 | export const aircraftStore = reactive({ 29 | selected: new Map(), 30 | version: 0 31 | }); 32 | 33 | export const pluginConfig = reactive({ 34 | showRouteLines: true, 35 | trailType: "line" as "line" | "curtain", 36 | trailColor: "#600000" as string | TrailColorOptions, 37 | trailAlpha: 0.6 38 | }); 39 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: docs 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | paths: 9 | - "docs/**" 10 | - ".github/workflows/docs.yml" 11 | - "mkdocs.yml" 12 | 13 | permissions: 14 | contents: write 15 | 16 | jobs: 17 | deploy: 18 | # if: startsWith(github.ref, 'refs/tags/') # only deploy pages on release tag 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v6 22 | 23 | - name: Install uv 24 | uses: astral-sh/setup-uv@v7 25 | with: 26 | enable-cache: true 27 | 28 | - name: Set up Python 29 | uses: actions/setup-python@v6 30 | with: 31 | python-version: "3.12" 32 | 33 | - name: Build the site 34 | run: | 35 | uv sync --group docs && uv run mkdocs build 36 | 37 | - name: Deploy pages to www.mode-s.org/tangram 38 | uses: peaceiris/actions-gh-pages@v4 39 | with: 40 | github_token: ${{ secrets.GITHUB_TOKEN }} 41 | publish_dir: site/ 42 | -------------------------------------------------------------------------------- /packages/tangram_example/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "tangram_example" 7 | version = "0.2.1" 8 | readme = "readme.md" 9 | dependencies = ["tangram_core>=0.2.1"] 10 | classifiers = ["Private :: Do Not Upload"] 11 | authors = [ 12 | { name = "Xavier Olive", email = "git@xoolive.org" }, 13 | { name = "Junzi Sun", email = "git@junzis.com" }, 14 | { name = "Abraham Cheung", email = "abraham@ylcheung.com" }, 15 | ] 16 | 17 | [project.entry-points."tangram_core.plugins"] 18 | tangram_example = "tangram_example:plugin" 19 | 20 | [tool.hatch.build.targets.sdist] 21 | ignore-vcs = true 22 | include = ["dist-frontend/*", "src/*", "package.json"] 23 | 24 | [tool.hatch.build.targets.wheel.force-include] 25 | "dist-frontend" = "tangram_example/dist-frontend" 26 | "package.json" = "tangram_example/package.json" # including `package.json` is not required, but allows `uvx tangram check-plugin` to work properly 27 | 28 | [tool.uv.sources] 29 | tangram_core = { workspace = true } 30 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Channel Tests 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | paths: 7 | - 'packages/tangram_core/rust/**' 8 | pull_request: 9 | paths: 10 | - 'packages/tangram_core/rust/**' 11 | workflow_dispatch: 12 | 13 | env: 14 | CARGO_TERM_COLOR: always 15 | 16 | jobs: 17 | test: 18 | runs-on: ubuntu-latest 19 | services: 20 | redis: 21 | image: redis:7 22 | ports: 23 | - 6379:6379 24 | options: >- 25 | --health-cmd "redis-cli ping" 26 | --health-interval 10s 27 | --health-timeout 5s 28 | --health-retries 5 29 | 30 | steps: 31 | - uses: actions/checkout@v6 32 | 33 | - name: Install Rust 34 | uses: dtolnay/rust-toolchain@stable 35 | 36 | - name: Rust Cache 37 | uses: Swatinem/rust-cache@v2 38 | with: 39 | workspaces: packages/tangram_core/rust 40 | 41 | - name: Run Channel Tests 42 | working-directory: packages/tangram_core/rust 43 | run: cargo test --features channel 44 | env: 45 | REDIS_URL: redis://localhost:6379 -------------------------------------------------------------------------------- /packages/tangram_core/rust/src/channel/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Xiaogang Huang 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. -------------------------------------------------------------------------------- /packages/tangram_core/src/tangram_core/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import App from "./App.vue"; 3 | import "maplibre-gl/dist/maplibre-gl.css"; 4 | import "./user.css"; 5 | 6 | import "@fontsource/inconsolata/400.css"; 7 | import "@fontsource/inconsolata/700.css"; 8 | import "@fontsource/roboto-condensed/400.css"; 9 | import "@fontsource/roboto-condensed/400-italic.css"; 10 | import "@fontsource/roboto-condensed/500.css"; 11 | import "@fontsource/roboto-condensed/700.css"; 12 | import "@fontsource/b612/400.css"; 13 | import "@fontsource/b612/400-italic.css"; 14 | import "@fontsource/b612/700.css"; 15 | 16 | import init, { run } from "rs1090-wasm"; 17 | 18 | // initialising wasm manually because esm build doesn't work well in wheels. 19 | // NOTE: `tangram_jet1090` and `tangram_airports` depend on `rs1090` to query 20 | // aircraft and airport information respectively. 21 | // until we figure out how to make one plugin share dependencies with another 22 | // plugin, we will have to forcefully initialise `rs1090` here. 23 | // See: https://github.com/open-aviation/tangram/issues/46 24 | (async () => { 25 | await init("/rs1090_wasm_bg.wasm"); 26 | run(); 27 | createApp(App).mount("#app"); 28 | })(); 29 | -------------------------------------------------------------------------------- /docs/plugins/weather.md: -------------------------------------------------------------------------------- 1 | # Weather Plugin 2 | 3 | The `tangram_weather` plugin provides API endpoints to serve meteorological data, enabling features like wind field visualization on the map. 4 | 5 | ## Overview 6 | 7 | This plugin fetches weather prediction data from Meteo-France's ARPEGE model, processes it, and exposes it via a REST API. The data is provided as GRIB files, which are downloaded and parsed on the backend. 8 | 9 | ## How It Works 10 | 11 | 1. The plugin downloads GRIB files containing ARPEGE weather model predictions from the public [data.gouv.fr](https://www.data.gouv.fr/fr/datasets/donnees-pnt-retention-14-jours/) repository. These files are cached locally in a temporary directory. 12 | 13 | 2. It registers a [`/weather` router][tangram_weather.router] with the main FastAPI application. The key endpoint is [`/weather/wind`][tangram_weather.wind]. 14 | 15 | 3. When a request is made to `/weather/wind?isobaric=`, the plugin: 16 | - Determines the latest available GRIB file for the current time. 17 | - Uses `xarray` and `cfgrib` to open the GRIB file. 18 | - Selects the U and V wind components for the specified isobaric pressure level (e.g., 300 hPa). 19 | - Returns the data as a JSON response. 20 | 21 | 4. The frontend `WindField.vue` component calls this endpoint. -------------------------------------------------------------------------------- /packages/tangram_core/rust/readme.md: -------------------------------------------------------------------------------- 1 | # tangram_core (Rust) 2 | 3 | This crate provides the performance-critical foundations for the [`tangram`](https://github.com/open-aviation/tangram) framework. 4 | 5 | ## Modules 6 | 7 | ### `stream` 8 | 9 | A library for handling geospatial data streams. It provides traits for `Positioned`, `Tracked`, and `Identifiable` entities and utilities for broadcasting state vectors to Redis. 10 | 11 | ### `bbox` 12 | 13 | Manages viewport-based filtering. It efficiently tracks connected client viewports and filters stream data to only send relevant entities to the frontend. 14 | 15 | ### `channel` (Feature: `channel`) 16 | 17 | A high-performance WebSocket server built on Axum and Redis Pub/Sub. 18 | It implements a subset of the [Phoenix Channels](https://hexdocs.pm/phoenix/channels.html) protocol to provide real-time, bidirectional communication between Python plugins, Rust services, and the Vue frontend. 19 | 20 | Note that this component a heavily modified version of and is licensed under the [MIT License](./src/channel/LICENSE). 21 | 22 | ## Testing 23 | 24 | The `channel` module contains integration tests that require a running Redis instance. 25 | 26 | ```bash 27 | podman run -d -p 6379:6379 redis 28 | cargo test --features channel 29 | ``` 30 | -------------------------------------------------------------------------------- /packages/tangram_history/rust/src/protocol.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Serialize, Deserialize, Debug)] 4 | #[serde(tag = "type")] 5 | pub enum ControlMessage { 6 | #[serde(rename = "ping")] 7 | Ping { sender: String }, 8 | #[serde(rename = "register_table")] 9 | RegisterTable(RegisterTable), 10 | } 11 | 12 | #[derive(Serialize, Deserialize, Debug)] 13 | #[serde(tag = "type")] 14 | pub enum ControlResponse { 15 | #[serde(rename = "table_registered")] 16 | TableRegistered { 17 | request_id: String, 18 | table_name: String, 19 | table_uri: String, 20 | }, 21 | #[serde(rename = "registration_failed")] 22 | RegistrationFailed { 23 | request_id: String, 24 | table_name: String, 25 | error: String, 26 | }, 27 | #[serde(rename = "pong")] 28 | Pong { sender: String }, 29 | } 30 | 31 | #[derive(Serialize, Deserialize, Debug)] 32 | pub struct RegisterTable { 33 | pub sender_id: String, 34 | pub table_name: String, 35 | /// base64 encoded arrow ipc schema format 36 | pub schema: String, 37 | pub partition_columns: Vec, 38 | pub optimize_interval_secs: u64, 39 | pub optimize_target_file_size: u64, 40 | pub vacuum_interval_secs: u64, 41 | pub vacuum_retention_period_secs: Option, 42 | } 43 | -------------------------------------------------------------------------------- /docs/plugins/system.md: -------------------------------------------------------------------------------- 1 | # System Plugin 2 | 3 | The `tangram_system` plugin provides a background service that monitors and broadcasts server metrics like CPU load, RAM usage, and uptime. These metrics are displayed in the frontend UI. 4 | 5 | ## How It Works 6 | 7 | 1. The plugin's `pyproject.toml` registers its `plugin` object via the [`tangram_core.plugins` entry point](./backend.md). 8 | 2. This `plugin` object uses the [`@plugin.register_service()`][tangram_core.Plugin.register_service] decorator to mark the `run_system` function as a background service. 9 | 3. When `tangram serve` starts, the core framework discovers and runs the `run_system` service. 10 | 4. It publishes these metrics as JSON payloads to the `to:system:update-node` Redis channel. 11 | 5. The core `tangram` frontend is subscribed to the `system` WebSocket channel. The `channel` service forwards these Redis messages to the UI, where components like `SystemInfo.vue` update to display the live data. 12 | 13 | ## Redis Events 14 | 15 | | Direction | Channel | Event/Command | Payload | 16 | | :-------- | :---------------------- | :------------ | :------------------------------------------------------ | 17 | | Output | `to:system:update-node` | `PUBLISH` | `{"el": "uptime" \| "cpu_load" \| ..., "value": "..."}` | 18 | -------------------------------------------------------------------------------- /packages/tangram_core/readme.md: -------------------------------------------------------------------------------- 1 | # tangram_core 2 | 3 | `tangram_core` is the foundation of the [tangram](https://github.com/open-aviation/tangram) platform. It provides the essential scaffolding for custom geospatial visualisation tools. 4 | 5 | While often used for aviation data, `tangram_core` itself is domain-agnostic. It handles the infrastructure (displaying maps, managing state, handling connections) so plugins can focus on the domain logic (decoding ADS-B, processing maritime signals, simulating weather). 6 | 7 | - Documentation: 8 | - Repository: 9 | 10 | ## Components 11 | 12 | - Backend: A Python application (FastAPI) that manages the lifecycle of plugins and background services. 13 | - Channel: A high-performance Rust service that bridges Redis pub/sub with WebSockets for real-time frontend updates. 14 | - Frontend: A Vue.js + Deck.gl shell that dynamically loads widgets and layers from installed plugins. 15 | 16 | ## Usage 17 | 18 | This package is rarely used alone. It is typically installed alongside plugins: 19 | 20 | ```bash 21 | # with uv 22 | uv tool install --with tangram-jet1090 --with tangram-system tangram-core 23 | # with pip 24 | pip install tangram-core tangram-jet1090 tangram-system 25 | # launch! 26 | tangram serve --config /path/to/your/tangram.toml 27 | ``` 28 | -------------------------------------------------------------------------------- /packages/tangram_ship162/src/tangram_ship162/ShipCountWidget.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 29 | 30 | 51 | -------------------------------------------------------------------------------- /citation.cff: -------------------------------------------------------------------------------- 1 | cff-version: "1.2.0" 2 | authors: 3 | - family-names: Olive 4 | given-names: Xavier 5 | orcid: "https://orcid.org/0000-0002-2335-5774" 6 | - family-names: Sun 7 | given-names: Junzi 8 | orcid: "https://orcid.org/0000-0003-3888-1192" 9 | - family-names: Huang 10 | given-names: Xiaogang 11 | - family-names: Khalaf 12 | given-names: Michel 13 | doi: 10.6084/m9.figshare.30401569 14 | message: If you use this software, please cite our article in the 15 | Journal of Open Source Software. 16 | preferred-citation: 17 | authors: 18 | - family-names: Olive 19 | given-names: Xavier 20 | orcid: "https://orcid.org/0000-0002-2335-5774" 21 | - family-names: Sun 22 | given-names: Junzi 23 | orcid: "https://orcid.org/0000-0003-3888-1192" 24 | - family-names: Huang 25 | given-names: Xiaogang 26 | - family-names: Khalaf 27 | given-names: Michel 28 | date-published: 2025-11-01 29 | doi: 10.21105/joss.08662 30 | issn: 2475-9066 31 | issue: 115 32 | journal: Journal of Open Source Software 33 | publisher: 34 | name: Open Journals 35 | start: 8662 36 | title: tangram, an open platform for modular, real-time air traffic 37 | management research 38 | type: article 39 | url: "https://joss.theoj.org/papers/10.21105/joss.08662" 40 | volume: 10 41 | title: tangram, an open platform for modular, real-time air traffic 42 | management research 43 | -------------------------------------------------------------------------------- /packages/tangram_globe/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "tangram_globe" 7 | version = "0.2.1" 8 | description = "Globe view toggle plugin for tangram" 9 | readme = "readme.md" 10 | requires-python = ">=3.10" 11 | license = "AGPL-3.0" 12 | authors = [{ name = "Andres Morfin Veytia" }] 13 | classifiers = [ 14 | "Development Status :: 4 - Beta", 15 | "Intended Audience :: Science/Research", 16 | "License :: OSI Approved :: GNU Affero General Public License v3", 17 | "Programming Language :: Python :: 3", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | "Programming Language :: Python :: 3.12", 21 | "Programming Language :: Python :: 3.13", 22 | "Programming Language :: Python :: 3.14", 23 | "Topic :: Scientific/Engineering :: Visualization", 24 | ] 25 | dependencies = ["tangram_core>=0.2.1"] 26 | 27 | [project.entry-points."tangram_core.plugins"] 28 | tangram_globe = "tangram_globe:plugin" 29 | 30 | [tool.uv.sources] 31 | tangram_core = { workspace = true } 32 | 33 | [tool.hatch.build.targets.sdist] 34 | ignore-vcs = true 35 | include = ["dist-frontend/*", "src/*", "package.json"] 36 | 37 | [tool.hatch.build.targets.wheel.force-include] 38 | "dist-frontend" = "tangram_globe/dist-frontend" 39 | "package.json" = "tangram_globe/package.json" 40 | -------------------------------------------------------------------------------- /packages/tangram_history/src/tangram_history/_history.pyi: -------------------------------------------------------------------------------- 1 | # This file is automatically generated by pyo3_stub_gen 2 | # ruff: noqa: E501, F401 3 | 4 | import builtins 5 | import typing 6 | 7 | @typing.final 8 | class HistoryConfig: 9 | @property 10 | def redis_url(self) -> builtins.str: ... 11 | @redis_url.setter 12 | def redis_url(self, value: builtins.str) -> None: ... 13 | @property 14 | def control_channel(self) -> builtins.str: ... 15 | @control_channel.setter 16 | def control_channel(self, value: builtins.str) -> None: ... 17 | @property 18 | def base_path(self) -> builtins.str: ... 19 | @base_path.setter 20 | def base_path(self, value: builtins.str) -> None: ... 21 | @property 22 | def redis_read_count(self) -> builtins.int: ... 23 | @redis_read_count.setter 24 | def redis_read_count(self, value: builtins.int) -> None: ... 25 | @property 26 | def redis_read_block_ms(self) -> builtins.int: ... 27 | @redis_read_block_ms.setter 28 | def redis_read_block_ms(self, value: builtins.int) -> None: ... 29 | def __new__(cls, redis_url: builtins.str, control_channel: builtins.str, base_path: builtins.str, redis_read_count: builtins.int, redis_read_block_ms: builtins.int) -> HistoryConfig: ... 30 | 31 | def init_tracing_stderr(filter_str: builtins.str) -> None: ... 32 | 33 | def run_history(config: HistoryConfig) -> typing.Any: ... 34 | 35 | -------------------------------------------------------------------------------- /packages/tangram_jet1090/src/tangram_jet1090/AircraftCountWidget.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 30 | 31 | 52 | -------------------------------------------------------------------------------- /packages/tangram_airports/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "tangram_airports" 7 | version = "0.2.1" 8 | description = "Airport search widget plugin for tangram" 9 | readme = "readme.md" 10 | requires-python = ">=3.10" 11 | license = "AGPL-3.0" 12 | authors = [ 13 | { name = "Xavier Olive", email = "git@xoolive.org" }, 14 | ] 15 | classifiers = [ 16 | "Development Status :: 4 - Beta", 17 | "Intended Audience :: Science/Research", 18 | "License :: OSI Approved :: GNU Affero General Public License v3", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: 3.13", 24 | "Programming Language :: Python :: 3.14", 25 | "Topic :: Scientific/Engineering :: Visualization", 26 | ] 27 | dependencies = ["tangram_core>=0.2.1"] 28 | 29 | [project.entry-points."tangram_core.plugins"] 30 | tangram_airports = "tangram_airports:plugin" 31 | 32 | [tool.uv.sources] 33 | tangram_core = { workspace = true } 34 | 35 | [tool.hatch.build.targets.sdist] 36 | ignore-vcs = true 37 | include = ["dist-frontend/*", "src/*", "package.json"] 38 | 39 | [tool.hatch.build.targets.wheel.force-include] 40 | "dist-frontend" = "tangram_airports/dist-frontend" 41 | "package.json" = "tangram_airports/package.json" 42 | -------------------------------------------------------------------------------- /packages/tangram_core/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | tangram 9 | 10 | 11 | 12 | 31 | 32 | 33 |
34 | 35 | 36 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "tangram_workspace" 3 | version = "0.2.0" 4 | requires-python = ">=3.10" 5 | dependencies = [] 6 | classifiers = [ 7 | "Private :: Do Not Upload" 8 | ] 9 | 10 | [dependency-groups] 11 | dev = [ 12 | "ipykernel>=6.29.5", 13 | { include-group = "build" }, 14 | { include-group = "test" }, 15 | { include-group = "typing" }, 16 | { include-group = "lint" }, 17 | ] 18 | build = ["maturin[patchelf]>=1.9.3"] 19 | test = ["pytest>=8.4.1", "anyio>=4.8.0"] 20 | lint = ["ruff>=0.12.7"] 21 | typing = ["mypy>=1.17.1"] 22 | docs = [ 23 | "mkdocs-material[imaging]>=9.6.18", 24 | "mkdocstrings-python>=1.18.2", 25 | ] 26 | 27 | [tool.uv.workspace] 28 | members = ["packages/tangram_*"] 29 | 30 | [tool.mypy] 31 | python_version = "3.10" 32 | platform = "posix" 33 | 34 | color_output = true 35 | pretty = true 36 | show_column_numbers = true 37 | strict = true 38 | check_untyped_defs = true 39 | ignore_missing_imports = true 40 | warn_no_return = true 41 | warn_redundant_casts = true 42 | warn_unused_configs = true 43 | warn_unused_ignores = true 44 | 45 | [tool.ruff] 46 | line-length = 88 47 | target-version = "py310" 48 | extend-exclude = [ 49 | "*.pyi", # don't format files generated by pyo3_stub_gen 50 | ] 51 | 52 | [tool.ruff.lint] 53 | select = [ 54 | "E", 55 | "W", # pycodestyle 56 | "F", # pyflakes 57 | "I", # isort 58 | "NPY", # numpy 59 | "DTZ", # flake8-datetimez 60 | "RUF", 61 | ] 62 | -------------------------------------------------------------------------------- /packages/tangram_history/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["maturin>=1.0,<2.0"] 3 | build-backend = "maturin" 4 | 5 | [project] 6 | name = "tangram_history" 7 | version = "0.2.1" 8 | description = "Historical data persistence plugin for tangram" 9 | license = "AGPL-3.0" 10 | readme = "readme.md" 11 | requires-python = ">=3.10" 12 | authors = [ 13 | { name = "Abraham Cheung", email = "abraham@ylcheung.com" }, 14 | ] 15 | classifiers = [ 16 | "Development Status :: 4 - Beta", 17 | "Intended Audience :: Science/Research", 18 | "License :: OSI Approved :: GNU Affero General Public License v3", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: 3.13", 24 | "Programming Language :: Python :: 3.14", 25 | "Programming Language :: Rust", 26 | "Topic :: Database", 27 | ] 28 | dependencies = ["tangram_core>=0.2.1"] 29 | 30 | [project.entry-points."tangram_core.plugins"] 31 | tangram_history = "tangram_history:plugin" 32 | 33 | [tool.uv.sources] 34 | tangram_core = { workspace = true } 35 | 36 | [tool.maturin] 37 | python-source = "src" 38 | module-name = "tangram_history._history" 39 | features = ["pyo3", "server"] 40 | 41 | [tool.uv] 42 | cache-keys = [ 43 | { file = "pyproject.toml" }, 44 | { file = "rust/Cargo.toml" }, 45 | { file = "**/*.rs" }, 46 | ] 47 | -------------------------------------------------------------------------------- /docs/plugins/index.md: -------------------------------------------------------------------------------- 1 | # Plugins 2 | 3 | `tangram` is designed to be extended with plugins. This modular approach allows you to tailor the system to your specific needs, whether you are working with real-world ADS-B data, simulation outputs, or other data sources. 4 | 5 | Plugins are developed as standalone packages, enabling them to be versioned, tested, and distributed independently. 6 | 7 | - **[Backend Plugins](backend.md)** are installable Python packages that extend the server's functionality, typically by adding new API endpoints or background data processing services. 8 | - **[Frontend Plugins](frontend.md)** are installable NPM packages that add new Vue.js components and widgets to the web interface. 9 | 10 | A single Python package can provide both backend and frontend components by bundling the pre-built frontend assets within its wheel distribution. This is the recommended approach for creating a cohesive feature. 11 | 12 | ## Official Plugins as Examples 13 | 14 | The best way to learn how to build plugins is to study the official ones: 15 | 16 | - `tangram_example`: A minimal template demonstrating both backend and frontend plugin structure. 17 | - [`tangram_system`](./system.md): A simple plugin that adds a background service. 18 | - [`tangram_jet1090`](./jet1090.md): A complex plugin that adds API routes, a background service for real-time data, and a historical trajectory API. 19 | - [`tangram_weather`](./weather.md): A plugin that adds a new API endpoint for external data. 20 | -------------------------------------------------------------------------------- /packages/tangram_core/src/tangram_core/_core.pyi: -------------------------------------------------------------------------------- 1 | # This file is automatically generated by pyo3_stub_gen 2 | # ruff: noqa: E501, F401 3 | 4 | import builtins 5 | import typing 6 | 7 | @typing.final 8 | class ChannelConfig: 9 | @property 10 | def host(self) -> builtins.str: ... 11 | @host.setter 12 | def host(self, value: builtins.str) -> None: ... 13 | @property 14 | def port(self) -> builtins.int: ... 15 | @port.setter 16 | def port(self, value: builtins.int) -> None: ... 17 | @property 18 | def redis_url(self) -> builtins.str: ... 19 | @redis_url.setter 20 | def redis_url(self, value: builtins.str) -> None: ... 21 | @property 22 | def jwt_secret(self) -> builtins.str: ... 23 | @jwt_secret.setter 24 | def jwt_secret(self, value: builtins.str) -> None: ... 25 | @property 26 | def jwt_expiration_secs(self) -> builtins.int: ... 27 | @jwt_expiration_secs.setter 28 | def jwt_expiration_secs(self, value: builtins.int) -> None: ... 29 | @property 30 | def id_length(self) -> builtins.int: ... 31 | @id_length.setter 32 | def id_length(self, value: builtins.int) -> None: ... 33 | def __new__(cls, host: builtins.str, port: builtins.int, redis_url: builtins.str, jwt_secret: builtins.str, jwt_expiration_secs: builtins.int, id_length: builtins.int) -> ChannelConfig: ... 34 | 35 | def init_tracing_stderr(filter_str: builtins.str) -> None: ... 36 | 37 | def run(config: ChannelConfig) -> typing.Any: ... 38 | 39 | -------------------------------------------------------------------------------- /packages/tangram_system/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "tangram_system" 7 | version = "0.2.1" 8 | description = "System monitoring plugin for tangram" 9 | readme = "readme.md" 10 | requires-python = ">=3.10" 11 | license = "AGPL-3.0" 12 | authors = [ 13 | { name = "Xavier Olive", email = "git@xoolive.org" }, 14 | { name = "Junzi Sun", email = "git@junzis.com" }, 15 | ] 16 | classifiers = [ 17 | "Development Status :: 4 - Beta", 18 | "Intended Audience :: Science/Research", 19 | "License :: OSI Approved :: GNU Affero General Public License v3", 20 | "Programming Language :: Python :: 3", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | "Programming Language :: Python :: 3.13", 25 | "Programming Language :: Python :: 3.14", 26 | "Topic :: System :: Monitoring", 27 | ] 28 | dependencies = ["tangram_core>=0.2.1", "psutil"] 29 | 30 | [project.entry-points."tangram_core.plugins"] 31 | tangram_system = "tangram_system:plugin" 32 | 33 | [tool.uv.sources] 34 | tangram_core = { workspace = true } 35 | 36 | [tool.hatch.build.targets.sdist] 37 | ignore-vcs = true 38 | include = ["dist-frontend/*", "src/*", "package.json"] 39 | 40 | [tool.hatch.build.targets.wheel.force-include] 41 | "dist-frontend" = "tangram_system/dist-frontend" 42 | "package.json" = "tangram_system/package.json" 43 | -------------------------------------------------------------------------------- /docs/plugins/examples/sensors.md: -------------------------------------------------------------------------------- 1 | # Data Receiver Map Layer 2 | 3 | ## Statement of need 4 | 5 | Mode S data is provided by one or more `jet1090` processes, which decode data from various sources like software-defined radios or network streams. Each source corresponds to a receiver at a specific location. 6 | 7 | The `tangram_jet1090` plugin provides a map layer to visualize the positions of these receivers, allowing users to see where their data is coming from. 8 | 9 | ## Implementation 10 | 11 | The implementation is a Vue component, `SensorsLayer.vue`, which is registered as a map overlay by the `tangram_jet1090` frontend plugin. It uses [Deck.gl](https://deck.gl) to render the sensor locations. 12 | 13 | 1. When the map is initialized, the component fetches a list of sensors from the `/sensors` API endpoint. 14 | 2. This endpoint, provided by the `tangram_jet1090` backend, proxies the request to the configured `jet1090` service. The sensor information must be configured within the `jet1090` instance itself. See the [jet1090 configuration guide](https://mode-s.org/jet1090/config/) for details. 15 | 3. The component then maps the sensor data into an array of objects suitable for Deck.gl, with each object containing a `position` array (`[longitude, latitude]`), `name`, and `aircraft_count`. 16 | 4. Finally, it creates a Deck.gl `ScatterplotLayer` to render the sensor locations as points on the map. The layer's `onHover` property is used to display a tooltip with the sensor's name and the number of aircraft it is currently tracking. 17 | -------------------------------------------------------------------------------- /docs/plugins/examples/citypair.md: -------------------------------------------------------------------------------- 1 | # Origin and Destination 2 | 3 | ## Statement of need 4 | 5 | The `tangram_jet1090` plugin includes a widget that displays the origin and destination information for a selected aircraft, showing both the airport ICAO codes and the city names. This makes it easy to quickly understand the flight's route without leaving the main map interface. 6 | 7 | ## Implementation 8 | 9 | The city pair widget is part of the `AircraftInfoWidget.vue` component, which appears in the sidebar when an aircraft is selected. 10 | 11 | It works as follows: 12 | 13 | 1. When an aircraft is selected, the widget uses the aircraft's callsign to make a request to the `/route/{callsign}` API endpoint. 14 | 2. This endpoint, provided by the `tangram_jet1090` backend plugin, proxies the request to the [OpenSky Network's route database](https://flightroutes.opensky-network.org). 15 | 3. If a route is found, the widget displays the origin and destination airport ICAO codes. 16 | 4. It then uses the [`rs1090-wasm` library](https://www.npmjs.com/package/rs1090-wasm) (bundled with `tangram` core) to look up and display the corresponding city names for each airport. 17 | 18 | This functionality is self-contained within the `tangram_jet1090` plugin and requires no extra configuration beyond enabling the plugin itself. 19 | 20 | ![City Pair Plugin Example](../../screenshot/citypair.png) 21 | 22 | !!! warning 23 | The OpenSky Network's route service is not guaranteed to be available for all aircraft. If no route information is found, the widget will display an appropriate message. 24 | -------------------------------------------------------------------------------- /packages/tangram_core/src/tangram_core/plugin.ts: -------------------------------------------------------------------------------- 1 | import type { TangramApi } from "./api"; 2 | 3 | type PluginProgressStage = "manifest" | "plugin" | "done"; 4 | type PluginProgress = { 5 | stage: PluginProgressStage; 6 | pluginName?: string; 7 | }; 8 | 9 | export type PluginConfig = unknown; // to be casted by each plugin who consume it 10 | 11 | export async function loadPlugins( 12 | tangramApi: TangramApi, 13 | onProgress?: (progress: PluginProgress) => void 14 | ) { 15 | onProgress?.({ stage: "manifest" }); 16 | const manifest = await fetch("/manifest.json").then(res => res.json()); 17 | 18 | for (const [pluginName, meta] of Object.entries(manifest.plugins)) { 19 | const pluginMeta = meta as { 20 | main: string; 21 | style?: string; 22 | config?: PluginConfig; 23 | }; 24 | 25 | onProgress?.({ stage: "plugin", pluginName }); 26 | 27 | if (pluginMeta.style) { 28 | const link = document.createElement("link"); 29 | link.rel = "stylesheet"; 30 | link.href = `/plugins/${pluginName}/${pluginMeta.style}`; 31 | document.head.appendChild(link); 32 | } 33 | 34 | const entryPointUrl = `/plugins/${pluginName}/${pluginMeta.main}`; 35 | 36 | try { 37 | const pluginModule = await import(/* @vite-ignore */ entryPointUrl); 38 | if (pluginModule.install) { 39 | pluginModule.install(tangramApi, pluginMeta.config); 40 | } 41 | } catch (e) { 42 | console.error(`failed to load plugin "${pluginName}":`, e); 43 | } 44 | } 45 | 46 | onProgress?.({ stage: "done" }); 47 | } 48 | -------------------------------------------------------------------------------- /packages/tangram_history/src/tangram_history/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | from pathlib import Path 4 | 5 | import platformdirs 6 | import tangram_core 7 | from pydantic import TypeAdapter 8 | 9 | log = logging.getLogger(__name__) 10 | plugin = tangram_core.Plugin() 11 | 12 | 13 | @dataclass(frozen=True) 14 | class HistoryConfig: 15 | control_channel: str = "history:control" 16 | base_path: Path = Path(platformdirs.user_cache_dir("tangram_history")) 17 | log_level: str = "INFO" 18 | redis_read_count: int = 1000 19 | redis_read_block_ms: int = 5000 20 | 21 | 22 | @plugin.register_service() 23 | async def run_history(backend_state: tangram_core.BackendState) -> None: 24 | from . import _history 25 | 26 | plugin_config = backend_state.config.plugins.get("tangram_history", {}) 27 | config_history = TypeAdapter(HistoryConfig).validate_python(plugin_config) 28 | 29 | default_log_level = plugin_config.get( 30 | "log_level", backend_state.config.core.log_level 31 | ) 32 | 33 | _history.init_tracing_stderr(default_log_level) 34 | 35 | config_history.base_path.mkdir(parents=True, exist_ok=True) 36 | rust_config = _history.HistoryConfig( 37 | redis_url=backend_state.config.core.redis_url, 38 | control_channel=config_history.control_channel, 39 | base_path=str(config_history.base_path), 40 | redis_read_count=config_history.redis_read_count, 41 | redis_read_block_ms=config_history.redis_read_block_ms, 42 | ) 43 | await _history.run_history(rust_config) 44 | -------------------------------------------------------------------------------- /packages/tangram_globe/src/tangram_globe/GlobeToggle.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 35 | 36 | 59 | -------------------------------------------------------------------------------- /packages/tangram_ship162/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["maturin>=1.0,<2.0"] 3 | build-backend = "maturin" 4 | 5 | [project] 6 | name = "tangram_ship162" 7 | version = "0.2.1" 8 | description = "AIS maritime data integration plugin for tangram" 9 | license = "AGPL-3.0" 10 | readme = "readme.md" 11 | requires-python = ">=3.10" 12 | authors = [ 13 | { name = "Abraham Cheung", email = "abraham@ylcheung.com" }, 14 | ] 15 | classifiers = [ 16 | "Development Status :: 4 - Beta", 17 | "Intended Audience :: Science/Research", 18 | "License :: OSI Approved :: GNU Affero General Public License v3", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: 3.13", 24 | "Programming Language :: Python :: 3.14", 25 | "Programming Language :: Rust", 26 | "Topic :: Scientific/Engineering :: Visualization", 27 | ] 28 | dependencies = ["tangram_core>=0.2.1"] 29 | 30 | [project.optional-dependencies] 31 | history = ["polars[deltalake]"] 32 | 33 | [project.entry-points."tangram_core.plugins"] 34 | tangram_ship162 = "tangram_ship162:plugin" 35 | 36 | [tool.uv.sources] 37 | tangram_core = { workspace = true } 38 | 39 | [tool.maturin] 40 | python-source = "src" 41 | module-name = "tangram_ship162._ships" 42 | features = ["pyo3"] 43 | include = ["./dist-frontend", "package.json"] 44 | 45 | [tool.uv] 46 | cache-keys = [ 47 | { file = "pyproject.toml" }, 48 | { file = "rust/Cargo.toml" }, 49 | { file = "**/*.rs" }, 50 | ] 51 | -------------------------------------------------------------------------------- /packages/tangram_ship162/src/tangram_ship162/ShipTrailLayer.vue: -------------------------------------------------------------------------------- 1 | 49 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | build-frontend: 16 | uses: ./.github/workflows/build-frontend.yml 17 | 18 | test-python: 19 | name: test python 20 | needs: build-frontend 21 | runs-on: ubuntu-latest 22 | services: 23 | redis: 24 | image: redis 25 | options: >- 26 | --health-cmd "redis-cli ping" 27 | --health-interval 10s 28 | --health-timeout 5s 29 | --health-retries 5 30 | steps: 31 | - name: download repo with built frontend 32 | uses: actions/download-artifact@v7 33 | with: 34 | name: repo-with-frontend 35 | path: . 36 | 37 | - run: rustup toolchain install stable --profile minimal 38 | 39 | - uses: Swatinem/rust-cache@v2 40 | 41 | - name: install uv 42 | uses: astral-sh/setup-uv@v7 43 | with: 44 | enable-cache: true 45 | 46 | - name: install tangram (no plugins) 47 | run: uv sync --all-groups --package tangram_core 48 | 49 | - name: run tests 50 | env: 51 | TANGRAM_CONFIG_PATH: tangram.example.toml 52 | run: uv run pytest packages/tangram_core/tests/ 53 | 54 | - name: install tangram (all plugins) 55 | run: uv sync --all-groups --all-packages 56 | 57 | - name: check plugin dependencies 58 | run: uv run tangram check-plugin --all 59 | 60 | build-wheels: 61 | needs: build-frontend 62 | uses: ./.github/workflows/build-wheels.yml 63 | -------------------------------------------------------------------------------- /packages/tangram_weather/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "tangram_weather" 7 | version = "0.2.1" 8 | description = "Weather data integration plugin for tangram" 9 | readme = "readme.md" 10 | requires-python = ">=3.10" 11 | license = "AGPL-3.0" 12 | authors = [ 13 | { name = "Xavier Olive", email = "git@xoolive.org" } 14 | ] 15 | classifiers = [ 16 | "Development Status :: 4 - Beta", 17 | "Intended Audience :: Science/Research", 18 | "License :: OSI Approved :: GNU Affero General Public License v3", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: 3.13", 24 | "Programming Language :: Python :: 3.14", 25 | "Topic :: Scientific/Engineering :: Atmospheric Science", 26 | "Topic :: Scientific/Engineering :: Visualization", 27 | ] 28 | dependencies = [ 29 | "tangram_core>=0.2.1", 30 | "xarray>=2025.6.1", 31 | "cfgrib>=0.9.15.0", 32 | "tqdm>=4.67.1", 33 | "orjson>=3.10.18", 34 | "pandas", 35 | "numpy>=2", 36 | "pillow>=10", 37 | ] 38 | 39 | [project.entry-points."tangram_core.plugins"] 40 | tangram_weather = "tangram_weather:plugin" 41 | 42 | [tool.uv.sources] 43 | tangram_core = { workspace = true } 44 | 45 | [tool.hatch.build.targets.sdist] 46 | ignore-vcs = true 47 | include = ["dist-frontend/*", "src/*", "package.json"] 48 | 49 | [tool.hatch.build.targets.wheel.force-include] 50 | "dist-frontend" = "tangram_weather/dist-frontend" 51 | "package.json" = "tangram_weather/package.json" 52 | -------------------------------------------------------------------------------- /packages/tangram_core/rust/src/channel/utils.rs: -------------------------------------------------------------------------------- 1 | use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; 2 | use rand::distr::Alphanumeric; 3 | use rand::{rng, Rng}; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | pub fn random_string(length: usize) -> String { 7 | rng() 8 | .sample_iter(&Alphanumeric) 9 | .take(length) 10 | .map(char::from) 11 | .collect() 12 | } 13 | 14 | #[derive(Debug, Serialize, Deserialize)] 15 | pub struct Claims { 16 | pub id: String, 17 | pub channel: String, 18 | pub exp: usize, 19 | } 20 | 21 | pub async fn generate_jwt( 22 | id: String, 23 | channel: String, 24 | jwt_secret: String, 25 | expiration_secs: i64, 26 | ) -> jsonwebtoken::errors::Result { 27 | let expiration = chrono::Utc::now() 28 | .checked_add_signed(chrono::Duration::seconds(expiration_secs)) 29 | .expect("valid timestamp") 30 | .timestamp() as usize; 31 | 32 | let claims = Claims { 33 | id: id.clone(), 34 | channel: channel.clone(), 35 | exp: expiration, 36 | }; 37 | 38 | let header = Header::new(Algorithm::HS256); 39 | let key = EncodingKey::from_secret(jwt_secret.as_bytes()); 40 | encode(&header, &claims, &key) 41 | } 42 | 43 | pub async fn decode_jwt( 44 | token: &str, 45 | jwt_secret: String, 46 | ) -> Result { 47 | let decoding_key = DecodingKey::from_secret(jwt_secret.as_bytes()); 48 | let mut validation = Validation::new(Algorithm::HS256); 49 | validation.validate_exp = true; 50 | 51 | let token_data = decode::(token, &decoding_key, &validation)?; 52 | Ok(token_data.claims) 53 | } 54 | -------------------------------------------------------------------------------- /packages/tangram_jet1090/rust/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tangram_jet1090" 3 | description = "tangram plugin to track aircraft using jet1090" 4 | readme = "../readme.md" 5 | authors = ["Abraham Cheung "] 6 | version.workspace = true 7 | license.workspace = true 8 | edition.workspace = true 9 | repository.workspace = true 10 | homepage.workspace = true 11 | 12 | [lib] 13 | name = "_planes" 14 | crate-type = ["cdylib", "rlib"] 15 | 16 | [dependencies] 17 | tangram_core_rs = { path = "../../tangram_core/rust" } 18 | tangram_history = { path = "../../tangram_history/rust", default-features = false, features = [ 19 | "client", 20 | ] } 21 | arrow-array = "56.2.0" 22 | arrow-schema = "56.2.0" 23 | 24 | anyhow = "1.0" 25 | futures = "0.3" 26 | redis = { version = "0.32", features = ["tokio-comp"] } 27 | rs1090 = "0.4.8" 28 | serde = { version = "1.0", features = ["derive"] } 29 | serde_json = "1.0" 30 | tokio = { version = "1", features = ["full"] } 31 | tokio-stream = "0.1" 32 | tracing = "0.1" 33 | tracing-appender = "0.2" 34 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 35 | 36 | pyo3 = { version = "0.27", features = ["extension-module"], optional = true } 37 | pyo3-async-runtimes = { version = "0.27", features = [ 38 | "attributes", 39 | "tokio-runtime", 40 | ], optional = true } 41 | # NOTE: v0.17 introduced support for overloading but that caused an odd post-compile 42 | # runtime issue, do not upgrade yet. 43 | pyo3-stub-gen = { version = "0.16", optional = true } 44 | 45 | [[bin]] 46 | name = "stub_gen_planes" 47 | path = "src/bin/stub_gen.rs" 48 | required-features = ["stubgen"] 49 | 50 | [features] 51 | pyo3 = ["dep:pyo3", "dep:pyo3-async-runtimes", "tangram_history/pyo3"] 52 | stubgen = ["pyo3", "dep:pyo3-stub-gen"] 53 | -------------------------------------------------------------------------------- /packages/tangram_ship162/rust/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tangram_ship162" 3 | description = "tangram ship162 plugin" 4 | readme = "../readme.md" 5 | authors = ["Abraham Cheung "] 6 | version.workspace = true 7 | license.workspace = true 8 | edition.workspace = true 9 | repository.workspace = true 10 | homepage.workspace = true 11 | 12 | [lib] 13 | name = "_ships" 14 | crate-type = ["cdylib", "rlib"] 15 | 16 | [dependencies] 17 | tangram_core_rs = { path = "../../tangram_core/rust" } 18 | tangram_history = { path = "../../tangram_history/rust", default-features = false, features = [ 19 | "client", 20 | ] } 21 | arrow-array = "56.2.0" 22 | arrow-schema = "56.2.0" 23 | 24 | anyhow = "1.0" 25 | futures = "0.3" 26 | redis = { version = "0.32", features = ["tokio-comp"] } 27 | rs162 = { git = "https://github.com/xoolive/ship162", rev = "3d3eb30de72d112ebc78f7565d274bf3e4b5bf99" } 28 | serde = { version = "1.0", features = ["derive"] } 29 | serde_json = "1.0" 30 | tokio = { version = "1", features = ["full"] } 31 | tokio-stream = "0.1" 32 | tracing = "0.1" 33 | tracing-appender = "0.2" 34 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 35 | 36 | pyo3 = { version = "0.27", features = ["extension-module"], optional = true } 37 | pyo3-async-runtimes = { version = "0.27", features = [ 38 | "attributes", 39 | "tokio-runtime", 40 | ], optional = true } 41 | # NOTE: v0.17 introduced support for overloading but that caused an odd post-compile 42 | # runtime issue, do not upgrade yet. 43 | pyo3-stub-gen = { version = "0.16", optional = true } 44 | 45 | [[bin]] 46 | name = "stub_gen_ships" 47 | path = "src/bin/stub_gen.rs" 48 | required-features = ["stubgen"] 49 | 50 | [features] 51 | pyo3 = ["dep:pyo3", "dep:pyo3-async-runtimes", "tangram_history/pyo3"] 52 | stubgen = ["pyo3", "dep:pyo3-stub-gen"] 53 | -------------------------------------------------------------------------------- /packages/tangram_jet1090/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["maturin>=1.0,<2.0"] 3 | build-backend = "maturin" 4 | 5 | [project] 6 | name = "tangram_jet1090" 7 | version = "0.2.1" 8 | description = "Mode S and ADS-B data integration plugin for tangram" 9 | license = "AGPL-3.0" 10 | readme = "readme.md" 11 | requires-python = ">=3.10" 12 | authors = [ 13 | { name = "Xavier Olive", email = "git@xoolive.org" }, 14 | { name = "Junzi Sun", email = "git@junzis.com" }, 15 | { name = "Xiaogang Huang", email = "maple.hl@gmail.com" }, 16 | { name = "Michel Khalaf", email = "khalafmichel98@gmail.com" }, 17 | { name = "Abraham Cheung", email = "abraham@ylcheung.com" }, 18 | ] 19 | classifiers = [ 20 | "Development Status :: 4 - Beta", 21 | "Intended Audience :: Science/Research", 22 | "License :: OSI Approved :: GNU Affero General Public License v3", 23 | "Programming Language :: Python :: 3", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Programming Language :: Python :: 3.12", 27 | "Programming Language :: Python :: 3.13", 28 | "Programming Language :: Python :: 3.14", 29 | "Programming Language :: Rust", 30 | "Topic :: Scientific/Engineering :: Visualization", 31 | ] 32 | dependencies = ["tangram_core>=0.2.1"] 33 | 34 | [project.optional-dependencies] 35 | history = ["polars[deltalake]"] 36 | 37 | [tool.uv.sources] 38 | tangram_core = { workspace = true } 39 | 40 | [project.entry-points."tangram_core.plugins"] 41 | tangram_jet1090 = "tangram_jet1090:plugin" 42 | 43 | [tool.maturin] 44 | python-source = "src" 45 | module-name = "tangram_jet1090._planes" 46 | features = ["pyo3"] 47 | include = ["./dist-frontend", "package.json"] 48 | 49 | [tool.uv] 50 | cache-keys = [ 51 | { file = "pyproject.toml" }, 52 | { file = "rust/Cargo.toml" }, 53 | { file = "**/*.rs" }, 54 | ] 55 | -------------------------------------------------------------------------------- /packages/tangram_core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@open-aviation/tangram-core", 3 | "version": "0.2.1", 4 | "type": "module", 5 | "license": "AGPL-3.0", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/open-aviation/tangram.git", 9 | "directory": "packages/tangram_core" 10 | }, 11 | "scripts": { 12 | "build": "vite build --config vite.lib-esm.config.ts && vite build" 13 | }, 14 | "files": [ 15 | "src/tangram_core/*.ts", 16 | "src/tangram_core/*.d.ts", 17 | "src/tangram_core/*.mjs", 18 | "src/tangram_core/*.vue", 19 | "src/tangram_core/*.css" 20 | ], 21 | "publishConfig": { 22 | "access": "public", 23 | "provenance": true 24 | }, 25 | "types": "./src/tangram_core/types.d.ts", 26 | "exports": { 27 | "./vite-plugin": "./src/tangram_core/vite-plugin-tangram.mjs", 28 | "./api": "./src/tangram_core/api.ts", 29 | "./types": "./src/tangram_core/types.d.ts", 30 | "./colour": "./src/tangram_core/colour.ts" 31 | }, 32 | "dependencies": { 33 | "@deck.gl/aggregation-layers": "^9.2.5", 34 | "@deck.gl/core": "^9.2.5", 35 | "@deck.gl/extensions": "^9.2.5", 36 | "@deck.gl/geo-layers": "^9.2.5", 37 | "@deck.gl/json": "^9.2.5", 38 | "@deck.gl/layers": "^9.2.5", 39 | "@deck.gl/mapbox": "^9.2.5", 40 | "@deck.gl/mesh-layers": "^9.2.5", 41 | "@deck.gl/widgets": "^9.2.5", 42 | "@fontsource/b612": "^5.2.7", 43 | "@fontsource/inconsolata": "^5.2.8", 44 | "@fontsource/roboto-condensed": "^5.2.8", 45 | "@protomaps/basemaps": "^5.7.0", 46 | "font-awesome": "^4.7.0", 47 | "lit-html": "^3.3.1", 48 | "maplibre-gl": "^5.14.0", 49 | "phoenix": "^1.8.3", 50 | "pmtiles": "^4.3.0", 51 | "rs1090-wasm": "^0.4.14", 52 | "vue": "^3.5.25", 53 | "@vitejs/plugin-vue": "^6.0.2" 54 | }, 55 | "devDependencies": { 56 | "@types/phoenix": "^1.6.7", 57 | "vite-plugin-static-copy": "^3.1.4" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /packages/tangram_history/rust/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tangram_history" 3 | description = "tangram plugin to store and query historical data" 4 | readme = "../readme.md" 5 | authors = ["Abraham Cheung "] 6 | version.workspace = true 7 | license.workspace = true 8 | edition.workspace = true 9 | repository.workspace = true 10 | homepage.workspace = true 11 | 12 | [lib] 13 | name = "tangram_history" 14 | crate-type = ["cdylib", "rlib"] 15 | 16 | [dependencies] 17 | anyhow = "1.0" 18 | futures = "0.3" 19 | tokio = { version = "1", features = ["full"] } 20 | tokio-stream = "0.1" 21 | tracing = "0.1" 22 | serde = { version = "1.0", features = ["derive"] } 23 | serde_json = "1.0" 24 | redis = { version = "0.32", features = ["tokio-comp"] } 25 | base64 = "0.22" 26 | uuid = { version = "1", features = ["v4"] } 27 | 28 | # client feature 29 | arrow-array = { version = "56.2.0", optional = true } 30 | arrow-schema = { version = "56.2.0", optional = true } 31 | arrow-ipc = { version = "56.2.0", optional = true } 32 | 33 | # server feature 34 | dashmap = { version = "6.1", optional = true } 35 | url = { version = "2.5", optional = true } 36 | chrono = { version = "0.4", optional = true } 37 | deltalake = { version = "0.29.4", features = ["datafusion"], optional = true } 38 | 39 | # pyo3 40 | pyo3 = { version = "0.27", features = ["extension-module"], optional = true } 41 | pyo3-async-runtimes = { version = "0.27", features = [ 42 | "attributes", 43 | "tokio-runtime", 44 | ], optional = true } 45 | pyo3-stub-gen = { version = "0.16", optional = true } 46 | tracing-subscriber = { version = "0.3", features = [ 47 | "env-filter", 48 | ], optional = true } 49 | 50 | [[bin]] 51 | name = "stub_gen_history" 52 | path = "src/bin/stub_gen.rs" 53 | required-features = ["stubgen"] 54 | 55 | [features] 56 | client = ["dep:arrow-array", "dep:arrow-schema", "dep:arrow-ipc"] 57 | server = ["client", "dep:dashmap", "dep:url", "dep:chrono", "dep:deltalake"] 58 | pyo3 = ["dep:pyo3", "dep:pyo3-async-runtimes", "dep:tracing-subscriber"] 59 | stubgen = ["pyo3", "server", "dep:pyo3-stub-gen"] 60 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | `tangram` is configured through a single `tangram.toml` file. This provides a centralized and clear way to manage the entire platform, from core services to plugins. 4 | 5 | ## Example `tangram.toml` 6 | 7 | ```toml 8 | [core] 9 | # URL for the Redis instance used for pub/sub messaging. 10 | redis_url = "redis://localhost:6379" 11 | 12 | # a list of installed plugin packages to activate. 13 | # tangram will look for entry points provided by these packages. 14 | plugins = [ 15 | "tangram_system", 16 | "tangram_jet1090", 17 | "my_awesome_package" 18 | ] 19 | 20 | [server] # (1)! 21 | # main FastAPI web server, which serves the 22 | # frontend application and plugin API routes. 23 | host = "127.0.0.1" 24 | port = 2346 25 | 26 | [channel] # (2)! 27 | # integrated real-time WebSocket service. 28 | host = "127.0.0.1" 29 | port = 2347 30 | # (optional) the public-facing base URL for the channel service, e.g., "https://tangram.example.com". 31 | # use this when running behind a reverse proxy. 32 | # public_url = "http://localhost:2347" 33 | # a secret key used to sign JSON Web Tokens (JWTs) for authenticating 34 | # WebSocket connections. Change this to a strong, unique secret. 35 | jwt_secret = "a-better-secret-than-this" 36 | 37 | [plugins.tangram_jet1090] # (3)! 38 | # plugin-specific configuration is defined in its own table, 39 | # following the pattern `[plugins.]`. 40 | # The structure of this table is defined by the plugin itself. 41 | jet1090_channel = "jet1090" 42 | state_vector_expire = 20 43 | # UI positioning 44 | # widgets with higher priority are displayed first (left-to-right or top-to-bottom). 45 | topbar_order = 50 # (4)! 46 | sidebar_order = 50 # (5)! 47 | 48 | [plugins.tangram_ship162] 49 | topbar_order = 100 # will appear to the left of jet1090 (100 > 50) 50 | sidebar_order = 100 51 | ``` 52 | 53 | 1. See [`tangram_core.config.CoreConfig`][]. 54 | 2. See [`tangram_core.config.ServerConfig`][] 55 | 3. See [`tangram_core.config.ChannelConfig`][] 56 | 4. See [`tangram_core.config.HasTopbarUiConfig`][] 57 | 5. See [`tangram_core.config.HasSidebarUiConfig`][] 58 | -------------------------------------------------------------------------------- /packages/tangram_core/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["maturin>=1.0,<2.0"] 3 | build-backend = "maturin" 4 | 5 | [project] 6 | name = "tangram_core" 7 | version = "0.2.1" 8 | description = "A framework for real-time air traffic management research" 9 | authors = [ 10 | { name = "Xavier Olive", email = "git@xoolive.org" }, 11 | { name = "Junzi Sun", email = "git@junzis.com" }, 12 | { name = "Xiaogang Huang", email = "maple.hl@gmail.com" }, 13 | { name = "Michel Khalaf", email = "khalafmichel98@gmail.com" }, 14 | { name = "Abraham Cheung", email = "abraham@ylcheung.com" }, 15 | ] 16 | license = "AGPL-3.0" 17 | readme = "readme.md" 18 | requires-python = ">=3.10" 19 | classifiers = [ 20 | "Development Status :: 4 - Beta", 21 | "Intended Audience :: Science/Research", 22 | "Intended Audience :: Developers", 23 | "License :: OSI Approved :: GNU Affero General Public License v3", 24 | "Programming Language :: Python :: 3", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Programming Language :: Python :: 3.12", 28 | "Programming Language :: Python :: 3.13", 29 | "Programming Language :: Python :: 3.14", 30 | "Programming Language :: Rust", 31 | "Topic :: Scientific/Engineering :: Visualization", 32 | "Topic :: System :: Monitoring", 33 | "Operating System :: OS Independent", 34 | ] 35 | dependencies = [ 36 | "fastapi>=0.115.4", 37 | "uvicorn>=0.32.0", 38 | "redis>=5.2.0", 39 | "httpx[http2]>=0.27.2", 40 | "pydantic>=2.6.1", 41 | "typer", # provides rich 42 | "tomli; python_version < '3.11'", 43 | "platformdirs>=4.5.0", 44 | "orjson>=3", 45 | ] 46 | 47 | [project.scripts] 48 | tangram = "tangram_core.__main__:app" 49 | 50 | [tool.maturin] 51 | python-source = "src" # needed for stubgen to output path correctly. 52 | module-name = "tangram_core._core" 53 | features = ["pyo3", "channel"] 54 | include = ["./dist-frontend", "package.json"] 55 | 56 | [tool.uv] 57 | cache-keys = [ 58 | { file = "pyproject.toml" }, 59 | { file = "rust/Cargo.toml" }, 60 | { file = "**/*.rs" }, 61 | ] 62 | -------------------------------------------------------------------------------- /packages/tangram_core/src/tangram_core/user.css: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | #container-map { 4 | height: 100%; 5 | overflow: hidden; 6 | width: 100%; 7 | } 8 | 9 | #map { 10 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); 11 | height: 100%; 12 | width: auto; 13 | } 14 | 15 | 16 | #chart { 17 | border: none; 18 | } 19 | 20 | div.form { 21 | float: right; 22 | clear: right; 23 | width: 300px; 24 | border: 1px solid #bab0ac; 25 | padding: 10px; 26 | } 27 | 28 | body { 29 | font-family: "B612", sans-serif; 30 | } 31 | 32 | span.monospace { 33 | font-family: "B612", sans-serif; 34 | font-size: 85%; 35 | } 36 | 37 | .smaller td { 38 | font-size: 95%; 39 | } 40 | 41 | table a:not(.btn), 42 | .table a:not(.btn) { 43 | text-decoration: none; 44 | } 45 | 46 | a.flag { 47 | position: relative; 48 | cursor: pointer; 49 | color: #2c3e50; 50 | } 51 | 52 | a.flag:hover::after { 53 | content: attr(data-tooltip); 54 | position: absolute; 55 | bottom: 1.7em; 56 | left: 0.5em; 57 | border: 1px #18bc9c solid; 58 | border-radius: 5px; 59 | padding: 4px; 60 | color: whitesmoke; 61 | background-color: #18bc9c; 62 | text-align: center; 63 | font-size: 90%; 64 | width: max-content; 65 | z-index: 1; 66 | } 67 | 68 | .w20pc { 69 | width: 20%; 70 | } 71 | 72 | .center { 73 | margin-top: 20px; 74 | margin-left: auto; 75 | margin-right: auto; 76 | /* max-width: 1000px; */ 77 | } 78 | 79 | .copyright_label { 80 | display: inline; 81 | padding: 0.2em 0.6em 0.3em; 82 | font-size: 75%; 83 | font-weight: 400; 84 | line-height: 1; 85 | color: #fff; 86 | text-align: center; 87 | white-space: nowrap; 88 | vertical-align: baseline; 89 | border-radius: 0.25em; 90 | position: absolute; 91 | bottom: 0; 92 | z-index: 1; 93 | } 94 | 95 | .turb_selected { 96 | stroke: red; 97 | stroke-width: 15px; 98 | z-index: -1; 99 | } 100 | 101 | .form-popup { 102 | display: none; 103 | position: fixed; 104 | bottom: 0; 105 | right: 15px; 106 | border: 3px solid #f1f1f1; 107 | z-index: 9; 108 | } 109 | 110 | .form-container { 111 | max-width: 300px; 112 | padding: 10px; 113 | background-color: white; 114 | } -------------------------------------------------------------------------------- /.github/workflows/podman.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | check-podman-build: 12 | runs-on: ${{ matrix.runner }} 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | runner: [ubuntu-latest, ubuntu-24.04-arm] 17 | include: 18 | - runner: ubuntu-latest 19 | eccodes_strategy: prebuilt 20 | - runner: ubuntu-24.04-arm 21 | eccodes_strategy: fromsource 22 | steps: 23 | # despite best efforts the rust cache is huge so we try freeing up some space 24 | - name: Maximize build space 25 | uses: AdityaGarg8/remove-unwanted-software@v5 26 | with: 27 | remove-android: 'true' 28 | remove-dotnet: 'true' 29 | remove-haskell: 'true' 30 | remove-codeql: 'true' 31 | remove-large-packages: 'true' 32 | 33 | - name: Checkout repository 34 | uses: actions/checkout@v6 35 | 36 | - name: Set up Podman 37 | run: | 38 | sudo apt-get update 39 | sudo apt-get install -y podman 40 | podman --version 41 | buildah --version 42 | 43 | # see: https://github.com/containers/podman/discussions/17868#discussioncomment-8001857 44 | - name: Tar as root 45 | run: | 46 | sudo mv -fv /usr/bin/tar /usr/bin/tar.orig 47 | echo -e '#!/bin/sh\n\nsudo /usr/bin/tar.orig "$@"' | sudo tee -a /usr/bin/tar 48 | sudo chmod +x /usr/bin/tar 49 | 50 | - name: cache container layers 51 | uses: actions/cache@v5 52 | with: 53 | path: | 54 | ~/.local/share/containers 55 | ~/.config/containers 56 | key: ${{ runner.os }}-${{ runner.arch }}-podman-${{ hashFiles('Containerfile', 'package.json') }} 57 | restore-keys: | 58 | ${{ runner.os }}-${{ runner.arch }}-podman- 59 | 60 | - name: Build Tangram image 61 | run: | 62 | podman build . --tag tangram:latest --build-arg ECCODES_STRATEGY=${{ matrix.eccodes_strategy }} 63 | 64 | - name: Verify tangram image exists 65 | run: | 66 | if podman image exists tangram:latest; then 67 | podman image ls | grep tangram 68 | else 69 | exit 1 70 | fi 71 | -------------------------------------------------------------------------------- /packages/tangram_core/src/tangram_core/colour.ts: -------------------------------------------------------------------------------- 1 | // adapted from: https://github.com/color-js/color.js/blob/main/src/spaces/oklch.js 2 | type Vector3 = [number, number, number]; 3 | 4 | const multiplyMatrices = (A: number[], B: Vector3): Vector3 => { 5 | return [ 6 | A[0] * B[0] + A[1] * B[1] + A[2] * B[2], 7 | A[3] * B[0] + A[4] * B[1] + A[5] * B[2], 8 | A[6] * B[0] + A[7] * B[1] + A[8] * B[2] 9 | ]; 10 | }; 11 | 12 | const oklch2oklab = ([l, c, h]: Vector3): Vector3 => [ 13 | l, 14 | isNaN(h) ? 0 : c * Math.cos((h * Math.PI) / 180), 15 | isNaN(h) ? 0 : c * Math.sin((h * Math.PI) / 180) 16 | ]; 17 | 18 | const srgbLinear2rgb = (rgb: Vector3): Vector3 => 19 | rgb.map(c => 20 | Math.abs(c) > 0.0031308 21 | ? (c < 0 ? -1 : 1) * (1.055 * Math.pow(Math.abs(c), 1 / 2.4) - 0.055) 22 | : 12.92 * c 23 | ) as Vector3; 24 | 25 | const oklab2xyz = (lab: Vector3): Vector3 => { 26 | const LMSg = multiplyMatrices( 27 | [ 28 | 1, 0.3963377773761749, 0.2158037573099136, 1, -0.1055613458156586, 29 | -0.0638541728258133, 1, -0.0894841775298119, -1.2914855480194092 30 | ], 31 | lab 32 | ); 33 | const LMS = LMSg.map(val => val ** 3) as Vector3; 34 | return multiplyMatrices( 35 | [ 36 | 1.2268798758459243, -0.5578149944602171, 0.2813910456659647, -0.0405757452148008, 37 | 1.112286803280317, -0.0717110580655164, -0.0763729366746601, -0.4214933324022432, 38 | 1.5869240198367816 39 | ], 40 | LMS 41 | ); 42 | }; 43 | 44 | const xyz2rgbLinear = (xyz: Vector3): Vector3 => { 45 | return multiplyMatrices( 46 | [ 47 | 3.2409699419045226, -1.537383177570094, -0.4986107602930034, -0.9692436362808796, 48 | 1.8759675015077202, 0.04155505740717559, 0.05563007969699366, 49 | -0.20397695888897652, 1.0569715142428786 50 | ], 51 | xyz 52 | ); 53 | }; 54 | 55 | export const oklch2rgb = (lch: Vector3): Vector3 => 56 | srgbLinear2rgb(xyz2rgbLinear(oklab2xyz(oklch2oklab(lch)))); 57 | 58 | export function oklchToDeckGLColor( 59 | l: number, 60 | c: number, 61 | h: number, 62 | a: number = 255 63 | ): [number, number, number, number] { 64 | const rgb = oklch2rgb([l, c, h]); 65 | return [ 66 | Math.max(0, Math.min(255, Math.round(rgb[0] * 255))), 67 | Math.max(0, Math.min(255, Math.round(rgb[1] * 255))), 68 | Math.max(0, Math.min(255, Math.round(rgb[2] * 255))), 69 | a 70 | ]; 71 | } 72 | -------------------------------------------------------------------------------- /packages/tangram_core/rust/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tangram_core_rs" 3 | description = "A framework for real-time analysis of ADS-B and Mode S surveillance data" 4 | readme = "readme.md" 5 | authors = ["Abraham Cheung ", "Xiaogang Huang"] 6 | version.workspace = true 7 | license = "AGPL-3.0 AND MIT" 8 | edition.workspace = true 9 | repository.workspace = true 10 | homepage.workspace = true 11 | 12 | 13 | [lib] 14 | name = "tangram_core" 15 | crate-type = ["cdylib", "rlib"] 16 | 17 | [dependencies] 18 | # stream 19 | anyhow = "1.0" 20 | futures = "0.3" 21 | redis = { version = "0.32", features = ["tokio-comp"] } 22 | serde = { version = "1.0", features = ["derive"] } 23 | serde_json = "1.0" 24 | tokio = { version = "1", features = ["full"] } 25 | tokio-stream = "0.1" 26 | tracing = "0.1" 27 | 28 | # channel 29 | axum = { version = "0.8", features = ["ws"], optional = true } 30 | tower-http = { version = "0.6", features = [ 31 | "fs", 32 | "trace", 33 | "cors", 34 | ], optional = true } 35 | thiserror = { version = "2.0", optional = true } 36 | serde_tuple = { version = "1.1", optional = true } 37 | nanoid = { version = "0.4", optional = true } 38 | chrono = { version = "0.4", optional = true } 39 | jsonwebtoken = { version = "10.2", features = ["aws_lc_rs"], optional = true } 40 | rand = { version = "0.9", optional = true } 41 | itertools = { version = "0.14", optional = true } 42 | 43 | # pyo3 44 | pyo3 = { version = "0.27", features = ["extension-module"], optional = true } 45 | pyo3-async-runtimes = { version = "0.27", features = [ 46 | "attributes", 47 | "tokio-runtime", 48 | ], optional = true } 49 | pyo3-stub-gen = { version = "0.16", optional = true } 50 | tracing-subscriber = { version = "0.3", features = [ 51 | "env-filter", 52 | ], optional = true } 53 | 54 | [dev-dependencies] 55 | tokio-tungstenite = "0.28" # for unit tests in websocket.rs 56 | 57 | 58 | [[bin]] 59 | name = "stub_gen_core" 60 | path = "src/bin/stub_gen.rs" 61 | required-features = ["stubgen"] 62 | 63 | [features] 64 | default = [] 65 | channel = [ 66 | "dep:axum", 67 | "dep:tower-http", 68 | "dep:thiserror", 69 | "dep:serde_tuple", 70 | "dep:nanoid", 71 | "dep:chrono", 72 | "dep:jsonwebtoken", 73 | "dep:rand", 74 | "dep:itertools", 75 | ] 76 | pyo3 = [ 77 | "dep:pyo3", 78 | "dep:pyo3-async-runtimes", 79 | "dep:tracing-subscriber", 80 | "channel", 81 | ] 82 | stubgen = ["pyo3", "dep:pyo3-stub-gen"] 83 | -------------------------------------------------------------------------------- /packages/tangram_system/src/tangram_system/SystemWidget.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 68 | 69 | 84 | -------------------------------------------------------------------------------- /docs/plugins/ship162.md: -------------------------------------------------------------------------------- 1 | # Ship162 Plugin 2 | 3 | The `tangram_ship162` plugin integrates AIS data from a `ship162` instance, enabling real-time visualization and historical analysis of maritime traffic. 4 | 5 | ## Overview 6 | 7 | - a background service to maintain a real-time state of all visible ships and persist their data. 8 | - a rest api endpoint (`/data/ship/{mmsi}`) to fetch the full, time-ordered trajectory for a specific ship. 9 | - frontend components to render ships on the map and display detailed information. 10 | 11 | ```mermaid 12 | sequenceDiagram 13 | participant S as ship162 14 | participant R as Redis 15 | participant P as ships service 16 | participant C as channel service 17 | participant F as Frontend 18 | 19 | S->>R: PUBLISH ship162 (raw message) 20 | P->>R: SUBSCRIBE ship162 21 | Note over P: Process message, update state vector 22 | P->>R: XADD history:ingest:ship162, *, ... 23 | 24 | loop every second 25 | Note over P: Filter ships by client's bbox 26 | P->>R: PUBLISH to:streaming-client1:new-ship162-data 27 | end 28 | 29 | C->>R: SUBSCRIBE to:streaming-client1:* 30 | C->>F: PUSH new-ship162-data 31 | ``` 32 | 33 | ## Redis Events 34 | 35 | | Direction | Channel | Event/Command | Payload | 36 | | :-------- | :----------------------------------- | :------------ | :---------------------------------------------------------- | 37 | | Input | `ship162` | `PUBLISH` | Raw JSON message from `ship162`. | 38 | | Output | `to:streaming-{id}:new-ship162-data` | `PUBLISH` | `{ "count": 123, "ship": [...] }` containing visible ships. | 39 | | Output | `history:ingest:ship162` | `XADD` | Apache Arrow record batch (binary). | 40 | 41 | ## Configuration 42 | 43 | To use this plugin, you must have a running `ship162` instance publishing data to redis. 44 | 45 | ```toml title="tangram.toml" 46 | [core] 47 | plugins = ["tangram_ship162", "tangram_history"] 48 | 49 | [plugins.tangram_ship162] 50 | # redis channel that ship162 is publishing to. 51 | ship162_channel = "ship162" 52 | 53 | # how long (in seconds) to keep a ship in the state vector table 54 | # after its last message. 55 | state_vector_expire = 600 56 | 57 | # history persistence settings 58 | history_table_name = "ship162" 59 | history_flush_interval_secs = 5 60 | history_buffer_size = 100000 61 | ``` 62 | -------------------------------------------------------------------------------- /packages/tangram_system/src/tangram_system/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from dataclasses import dataclass 4 | from datetime import datetime, timedelta, timezone 5 | from typing import Any, NoReturn 6 | 7 | import orjson 8 | import psutil 9 | import redis.asyncio as redis 10 | import tangram_core 11 | 12 | log = logging.getLogger(__name__) 13 | 14 | 15 | @dataclass 16 | class SystemConfig(tangram_core.config.HasSidebarUiConfig): 17 | topbar_order: int = 0 18 | 19 | 20 | def transform_config(config_dict: dict[str, Any]) -> SystemConfig: 21 | from pydantic import TypeAdapter 22 | 23 | return TypeAdapter(SystemConfig).validate_python(config_dict) 24 | 25 | 26 | def uptime(counter: int) -> dict[str, str]: 27 | return { 28 | "el": "uptime", 29 | "value": f"{timedelta(seconds=counter)}", 30 | } 31 | 32 | 33 | def info_utc() -> dict[str, str | int]: 34 | return { 35 | "el": "info_utc", 36 | "value": 1000 * int(datetime.now(timezone.utc).timestamp()), 37 | } 38 | 39 | 40 | def cpu_load() -> dict[str, str]: 41 | try: 42 | load1, _load5, _load15 = psutil.getloadavg() 43 | cpu_count = psutil.cpu_count(logical=True) or 1 44 | load_percent = (load1 / cpu_count) * 100 45 | return {"el": "cpu_load", "value": f"{load_percent:.2f}%"} 46 | except Exception: 47 | return {"el": "cpu_load", "value": "Unavailable"} 48 | 49 | 50 | def ram_usage() -> dict[str, str]: 51 | try: 52 | mem = psutil.virtual_memory() 53 | return {"el": "ram_usage", "value": f"{mem.percent:.2f}%"} 54 | except Exception: 55 | return {"el": "ram_usage", "value": "Unavailable"} 56 | 57 | 58 | async def server_events(redis_client: redis.Redis) -> NoReturn: 59 | counter = 0 60 | log.info("serving system events...") 61 | 62 | while True: 63 | await redis_client.publish( 64 | "to:system:update-node", orjson.dumps(uptime(counter)) 65 | ) 66 | await redis_client.publish("to:system:update-node", orjson.dumps(info_utc())) 67 | await redis_client.publish("to:system:update-node", orjson.dumps(cpu_load())) 68 | await redis_client.publish("to:system:update-node", orjson.dumps(ram_usage())) 69 | counter += 1 70 | 71 | await asyncio.sleep(1) 72 | 73 | 74 | plugin = tangram_core.Plugin( 75 | frontend_path="dist-frontend", into_frontend_config_function=transform_config 76 | ) 77 | 78 | 79 | @plugin.register_service() 80 | async def run_system(backend_state: tangram_core.BackendState) -> None: 81 | await server_events(backend_state.redis_client) 82 | -------------------------------------------------------------------------------- /packages/tangram_weather/src/tangram_weather/__init__.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import io 3 | import logging 4 | 5 | import numpy as np 6 | import pandas as pd 7 | import tangram_core 8 | from fastapi import APIRouter 9 | from fastapi.responses import ORJSONResponse 10 | from PIL import Image 11 | 12 | from .arpege import latest_data as latest_arpege_data 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | router = APIRouter( 17 | prefix="/weather", 18 | tags=["weather"], 19 | responses={404: {"description": "Not found"}}, 20 | ) 21 | 22 | 23 | @router.get("/") 24 | async def get_weather() -> dict[str, str]: 25 | return {"message": "This is the weather plugin response"} 26 | 27 | 28 | @router.get("/wind") 29 | async def wind( 30 | isobaric: int, backend_state: tangram_core.InjectBackendState 31 | ) -> ORJSONResponse: 32 | logger.info("fetching wind data for %s", isobaric) 33 | 34 | now = pd.Timestamp.now(tz="UTC").floor("1h") 35 | ds = await latest_arpege_data(backend_state.http_client, now) 36 | res = ds.sel(isobaricInhPa=isobaric, time=now.tz_convert(None))[["u", "v"]] 37 | 38 | u_attrs = res.data_vars["u"].attrs 39 | 40 | bounds = [ 41 | u_attrs["GRIB_longitudeOfFirstGridPointInDegrees"], 42 | u_attrs["GRIB_latitudeOfLastGridPointInDegrees"], 43 | u_attrs["GRIB_longitudeOfLastGridPointInDegrees"], 44 | u_attrs["GRIB_latitudeOfFirstGridPointInDegrees"], 45 | ] 46 | 47 | u_data = res["u"].values 48 | v_data = res["v"].values 49 | 50 | valid_data_mask = ~np.isnan(u_data) 51 | 52 | min_val, max_val = -70.0, 70.0 53 | image_unscale = [min_val, max_val] 54 | value_range = max_val - min_val 55 | 56 | u_scaled = (np.nan_to_num(u_data, nan=0.0) - min_val) / value_range * 255 57 | v_scaled = (np.nan_to_num(v_data, nan=0.0) - min_val) / value_range * 255 58 | 59 | rgba_data = np.zeros((*u_data.shape, 4), dtype=np.uint8) 60 | rgba_data[..., 0] = u_scaled.astype(np.uint8) 61 | rgba_data[..., 1] = v_scaled.astype(np.uint8) 62 | rgba_data[..., 3] = np.where(valid_data_mask, 255, 0) 63 | 64 | image = Image.fromarray(rgba_data, "RGBA") 65 | buffer = io.BytesIO() 66 | image.save(buffer, format="PNG") 67 | img_str = base64.b64encode(buffer.getvalue()).decode("utf-8") 68 | image_data_uri = f"data:image/png;base64,{img_str}" 69 | 70 | response_content = { 71 | "imageDataUri": image_data_uri, 72 | "bounds": bounds, 73 | "imageUnscale": image_unscale, 74 | } 75 | 76 | return ORJSONResponse(content=response_content) 77 | 78 | 79 | plugin = tangram_core.Plugin(frontend_path="dist-frontend", routers=[router]) 80 | -------------------------------------------------------------------------------- /packages/tangram_core/vite.lib-esm.config.ts: -------------------------------------------------------------------------------- 1 | /* We want to provide deck.gl to both the core and downstream plugins, 2 | * so we want to share a single ESM build. We cannot copy `dist.min.js` because 3 | * it uses UMD script tag which is incompatible with importmaps. 4 | * 5 | * Fortunately, deck.gl>=9 packages provide ESM builds, but are split into many 6 | * files across dependencies, making it difficult to copy them all. 7 | * 8 | * We therefore try to "compile" it into a single ESM bundle using Vite/Rollup. 9 | * An alternative would be to use [`esbuild`](https://github.com/manzt/anywidget/issues/369#issuecomment-1792376003) 10 | * but we are already using Vite so this is more convenient. 11 | */ 12 | import { defineConfig, type Plugin } from "vite"; 13 | import path from "path"; 14 | 15 | const DECKGL_PACKAGES = [ 16 | "@deck.gl/core", 17 | "@deck.gl/layers", 18 | "@deck.gl/aggregation-layers", 19 | "@deck.gl/geo-layers", 20 | "@deck.gl/mesh-layers", 21 | "@deck.gl/json", 22 | "@deck.gl/mapbox", 23 | "@deck.gl/widgets", 24 | "@deck.gl/extensions" 25 | ]; 26 | 27 | /* Use a virtual module plugin to create explicit re-exports, 28 | * preventing tree-shaking caused by `sideEffects: false` in deck.gl packages. 29 | */ 30 | function virtualDeckGLEntries(): Plugin { 31 | const virtualPrefix = "virtual:deckgl-entry:"; 32 | const resolvedPrefix = "\0" + virtualPrefix; 33 | 34 | return { 35 | name: "vite-plugin-deckgl-virtual-entries", 36 | resolveId(id) { 37 | if (id.startsWith(virtualPrefix)) { 38 | return resolvedPrefix + id.slice(virtualPrefix.length); 39 | } 40 | return null; 41 | }, 42 | load(id) { 43 | if (id.startsWith(resolvedPrefix)) { 44 | const pkgName = id.slice(resolvedPrefix.length); 45 | return `export * from '${pkgName}';`; 46 | } 47 | return null; 48 | } 49 | }; 50 | } 51 | 52 | export default defineConfig({ 53 | // `webgl-developer-tools` is intended for node, perform a direct text replacement 54 | define: { 55 | "process.env.NODE_ENV": JSON.stringify("production") 56 | }, 57 | build: { 58 | outDir: path.resolve(__dirname, "./dist-frontend"), 59 | rollupOptions: { 60 | input: Object.fromEntries( 61 | DECKGL_PACKAGES.map(pkg => [ 62 | pkg.split("/").pop(), 63 | `${"virtual:deckgl-entry:"}${pkg}` 64 | ]) 65 | ), 66 | output: { 67 | format: "es", 68 | entryFileNames: "[name].js" 69 | }, 70 | // required avoid rollup tree-shaking unused exports from entry points 71 | preserveEntrySignatures: "strict" 72 | }, 73 | minify: true, 74 | sourcemap: true, 75 | emptyOutDir: false 76 | }, 77 | plugins: [virtualDeckGLEntries()] 78 | }); 79 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # An open framework for modular, real-time air traffic management research 2 | 3 | **tangram** is an open research framework for ADS-B and Mode S flight surveillance data designed for various **real-time aviation research topics** such as GNSS jamming detection, aviation weather monitoring, emission analysis, and airport performance monitoring. 4 | 5 | web interface 6 | 7 | ## Introduction 8 | 9 | `tangram` is built on a plugin-first architecture. It provides a lightweight core application, and all major functionality, from data processing to new UI widgets, is added through `pip`-installable packages. 10 | 11 | The core framework includes a **JavaScript**-based **web application** and a **backend powered by Python and Rust**. This foundation is designed to be extended, allowing researchers to develop and integrate their own plugins for specific research needs. This modularity enables the community to contribute to the platform, encouraging collaboration and knowledge sharing. 12 | 13 | ## Contents 14 | 15 | - [Quickstart](quickstart.md): A step-by-step guide to get started with tangram 16 | - [Configuration](configuration.md): Information on how to configure the system for your needs 17 | - [Architecture](architecture/index.md): An overview of the system architecture and components 18 | - [Plugins](plugins/index.md): Extend the system with custom functionalities 19 | - [Contribute to tangram](contribute.md): Guidelines for contributing to the project 20 | 21 | ## Funding 22 | 23 | This project is currently funded by the Dutch Research Council (NWO)'s Open Science Fund, **OSF23.1.051**: . 24 | 25 | ## History 26 | 27 | In 2020, [@junzis](https://github.com/junzis) and [@xoolive](https://github.com/xoolive) published a paper [Detecting and Measuring Turbulence from Mode S Surveillance Downlink Data](https://research.tudelft.nl/en/publications/detecting-and-measuring-turbulence-from-mode-s-surveillance-downl-2) on how real-time Mode S data can be used to detect turbulence. 28 | 29 | Based on this method, [@MichelKhalaf](https://github.com/MichelKhalaf) started developing this tool as part of his training with [@xoolive](https://github.com/xoolive) in 2021, which was completed in Summer 2022. After that, the project was then lightly maintained by [@xoolive](https://github.com/xoolive) and [@junzis](https://github.com/junzis), while we have been applying for funding to continue this tool. 30 | 31 | And in 2023, we received funding from NWO to continue the development of this tool. With this funding, [@emctoo](https://github.com/emctoo) from [Shinetech](https://www.shinetechsoftware.com) was hired to work alongside us on this open-source project. 32 | -------------------------------------------------------------------------------- /packages/tangram_core/src/tangram_core/vite-plugin-tangram.mjs: -------------------------------------------------------------------------------- 1 | // not using typescript because of an annoying vite bug: 2 | // - https://github.com/vitejs/vite/issues/5370 3 | // - https://github.com/vitejs/vite/issues/16040 4 | // essentially, vite's on-the-fly transpilation is scoped only to the config 5 | // file itself. therefore vite plugins in a monorepo cannot be typescript. 6 | import vue from "@vitejs/plugin-vue"; 7 | import path from "path"; 8 | import fs from "fs/promises"; 9 | 10 | const DECKGL_PACKAGES = [ 11 | "@deck.gl/core", 12 | "@deck.gl/layers", 13 | "@deck.gl/aggregation-layers", 14 | "@deck.gl/geo-layers", 15 | "@deck.gl/mesh-layers", 16 | "@deck.gl/json", 17 | "@deck.gl/mapbox", 18 | "@deck.gl/widgets", 19 | "@deck.gl/extensions" 20 | ]; 21 | 22 | /** 23 | * @returns {import('vite').Plugin[]} 24 | */ 25 | export function tangramPlugin() { 26 | const projectRoot = process.cwd(); 27 | /** @type {{ name: string; main: string; }} */ 28 | let pkg; 29 | let entryFileName; 30 | 31 | /** @type {import('vite').Plugin} */ 32 | const configInjector = { 33 | name: "tangram-plugin-config-injector", 34 | async config() { 35 | const pkgPath = path.resolve(projectRoot, "package.json"); 36 | pkg = JSON.parse(await fs.readFile(pkgPath, "utf-8")); 37 | 38 | if (!pkg.main) { 39 | throw new Error( 40 | `\`main\` field must be specified in ${pkg.name}'s package.json` 41 | ); 42 | } 43 | entryFileName = path.parse(pkg.main).name; 44 | 45 | /** @type {import('vite').UserConfig} */ 46 | const tangramBuildConfig = { 47 | build: { 48 | sourcemap: true, 49 | lib: { 50 | entry: pkg.main, 51 | fileName: entryFileName, 52 | formats: ["es"] 53 | }, 54 | rollupOptions: { 55 | external: ["vue", "maplibre", ...DECKGL_PACKAGES, "lit-html", "rs1090-wasm"] 56 | }, 57 | outDir: "dist-frontend", 58 | minify: true 59 | } 60 | }; 61 | return tangramBuildConfig; 62 | } 63 | }; 64 | 65 | /** @type {import('vite').Plugin} */ 66 | const manifestGenerator = { 67 | name: "tangram-manifest-generator", 68 | apply: "build", 69 | async writeBundle(outputOptions, bundle) { 70 | const outDir = outputOptions.dir; 71 | const cssAsset = Object.values(bundle).find( 72 | asset => asset.type === "asset" && asset.fileName.endsWith(".css") 73 | ); 74 | 75 | const manifest = { 76 | name: pkg.name, 77 | main: `${entryFileName}.js`, 78 | ...(cssAsset && { style: cssAsset.fileName }) 79 | }; 80 | await fs.writeFile( 81 | path.resolve(outDir, "plugin.json"), 82 | JSON.stringify(manifest, null, 2) 83 | ); 84 | } 85 | }; 86 | 87 | return [vue(), configInjector, manifestGenerator]; 88 | } 89 | -------------------------------------------------------------------------------- /packages/tangram_airports/src/tangram_airports/AirportSearchWidget.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 70 | 71 | 114 | -------------------------------------------------------------------------------- /packages/tangram_ship162/src/tangram_ship162/_ships.pyi: -------------------------------------------------------------------------------- 1 | # This file is automatically generated by pyo3_stub_gen 2 | # ruff: noqa: E501, F401 3 | 4 | import builtins 5 | import typing 6 | 7 | @typing.final 8 | class ShipsConfig: 9 | @property 10 | def redis_url(self) -> builtins.str: ... 11 | @redis_url.setter 12 | def redis_url(self, value: builtins.str) -> None: ... 13 | @property 14 | def ship162_channel(self) -> builtins.str: ... 15 | @ship162_channel.setter 16 | def ship162_channel(self, value: builtins.str) -> None: ... 17 | @property 18 | def history_control_channel(self) -> builtins.str: ... 19 | @history_control_channel.setter 20 | def history_control_channel(self, value: builtins.str) -> None: ... 21 | @property 22 | def state_vector_expire(self) -> builtins.int: ... 23 | @state_vector_expire.setter 24 | def state_vector_expire(self, value: builtins.int) -> None: ... 25 | @property 26 | def stream_interval_secs(self) -> builtins.float: ... 27 | @stream_interval_secs.setter 28 | def stream_interval_secs(self, value: builtins.float) -> None: ... 29 | @property 30 | def history_table_name(self) -> builtins.str: ... 31 | @history_table_name.setter 32 | def history_table_name(self, value: builtins.str) -> None: ... 33 | @property 34 | def history_buffer_size(self) -> builtins.int: ... 35 | @history_buffer_size.setter 36 | def history_buffer_size(self, value: builtins.int) -> None: ... 37 | @property 38 | def history_flush_interval_secs(self) -> builtins.int: ... 39 | @history_flush_interval_secs.setter 40 | def history_flush_interval_secs(self, value: builtins.int) -> None: ... 41 | @property 42 | def history_optimize_interval_secs(self) -> builtins.int: ... 43 | @history_optimize_interval_secs.setter 44 | def history_optimize_interval_secs(self, value: builtins.int) -> None: ... 45 | @property 46 | def history_optimize_target_file_size(self) -> builtins.int: ... 47 | @history_optimize_target_file_size.setter 48 | def history_optimize_target_file_size(self, value: builtins.int) -> None: ... 49 | @property 50 | def history_vacuum_interval_secs(self) -> builtins.int: ... 51 | @history_vacuum_interval_secs.setter 52 | def history_vacuum_interval_secs(self, value: builtins.int) -> None: ... 53 | @property 54 | def history_vacuum_retention_period_secs(self) -> typing.Optional[builtins.int]: ... 55 | @history_vacuum_retention_period_secs.setter 56 | def history_vacuum_retention_period_secs(self, value: typing.Optional[builtins.int]) -> None: ... 57 | def __new__(cls, redis_url: builtins.str, ship162_channel: builtins.str, history_control_channel: builtins.str, state_vector_expire: builtins.int, stream_interval_secs: builtins.float, history_table_name: builtins.str, history_buffer_size: builtins.int, history_flush_interval_secs: builtins.int, history_optimize_interval_secs: builtins.int, history_optimize_target_file_size: builtins.int, history_vacuum_interval_secs: builtins.int, history_vacuum_retention_period_secs: typing.Optional[builtins.int]) -> ShipsConfig: ... 58 | 59 | def init_tracing_stderr(filter_str: builtins.str) -> None: ... 60 | 61 | def run_ships(config: ShipsConfig) -> typing.Any: ... 62 | 63 | -------------------------------------------------------------------------------- /packages/tangram_core/src/tangram_core/redis.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import asyncio 3 | import logging 4 | from typing import Generic, List, TypeVar 5 | 6 | from redis.asyncio import Redis 7 | from redis.asyncio.client import PubSub 8 | from redis.exceptions import RedisError 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | StateT = TypeVar("StateT") 13 | 14 | 15 | class Subscriber(abc.ABC, Generic[StateT]): 16 | redis: Redis 17 | task: asyncio.Task[None] 18 | pubsub: PubSub 19 | 20 | def __init__( 21 | self, name: str, redis_url: str, channels: List[str], initial_state: StateT 22 | ): 23 | self.name = name 24 | self.redis_url: str = redis_url 25 | self.channels: List[str] = channels 26 | self.state: StateT = initial_state 27 | self._running = False 28 | 29 | async def subscribe(self) -> None: 30 | if self._running: 31 | log.warning("%s already running", self.name) 32 | return 33 | 34 | try: 35 | self.redis = await Redis.from_url(self.redis_url) 36 | self.pubsub = self.redis.pubsub() 37 | await self.pubsub.psubscribe(*self.channels) 38 | except RedisError as e: 39 | log.error("%s failed to connect to Redis: %s", self.name, e) 40 | raise 41 | 42 | async def listen() -> None: 43 | try: 44 | log.info("%s listening ...", self.name) 45 | async for message in self.pubsub.listen(): 46 | log.debug("message: %s", message) 47 | if message["type"] == "pmessage": 48 | await self.message_handler( 49 | message["channel"].decode("utf-8"), 50 | message["data"].decode("utf-8"), 51 | message["pattern"].decode("utf-8"), 52 | self.state, 53 | ) 54 | except asyncio.CancelledError: 55 | log.warning("%s cancelled", self.name) 56 | 57 | self._running = True 58 | 59 | self.task = asyncio.create_task(listen()) 60 | log.info("%s task created, running ...", self.name) 61 | 62 | async def cleanup(self) -> None: 63 | if not self._running: 64 | return 65 | 66 | if self.task: 67 | log.debug("%s canceling task ...", self.name) 68 | self.task.cancel() 69 | try: 70 | log.debug("%s await task to finish ...", self.name) 71 | await self.task 72 | log.debug("%s task canceled", self.name) 73 | except asyncio.CancelledError as exc: 74 | log.error("%s task canceling error: %s", self.name, exc) 75 | if self.pubsub: 76 | await self.pubsub.unsubscribe() 77 | if self.redis: 78 | await self.redis.close() 79 | self._running = False 80 | 81 | def is_active(self) -> bool: 82 | """Return True if the subscriber is actively listening.""" 83 | return self._running and self.task is not None and not self.task.done() 84 | 85 | @abc.abstractmethod 86 | async def message_handler( 87 | self, event: str, payload: str, pattern: str, state: StateT 88 | ) -> None: 89 | pass 90 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Configuration file 2 | tangram.toml 3 | 4 | dist-frontend 5 | **/node_modules 6 | 7 | ### Archive ### 8 | archive/ 9 | *.ipynb 10 | .git-old 11 | .vscode/ 12 | .idea/ 13 | 14 | ### Data ### 15 | data/ 16 | 17 | ### Python ### 18 | # Byte-compiled / optimized / DLL files 19 | __pycache__/ 20 | *.py[cod] 21 | *$py.class 22 | 23 | .DS_Store 24 | 25 | # C extensions 26 | *.so 27 | 28 | # Distribution / packaging 29 | .Python 30 | build/ 31 | develop-eggs/ 32 | dist/ 33 | downloads/ 34 | eggs/ 35 | .eggs/ 36 | lib/ 37 | lib64/ 38 | parts/ 39 | sdist/ 40 | var/ 41 | wheels/ 42 | share/python-wheels/ 43 | *.egg-info/ 44 | .installed.cfg 45 | *.egg 46 | MANIFEST 47 | 48 | # PyInstaller 49 | # Usually these files are written by a python script from a template 50 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 51 | *.manifest 52 | *.spec 53 | 54 | # Installer logs 55 | pip-log.txt 56 | pip-delete-this-directory.txt 57 | 58 | # Unit test / coverage reports 59 | htmlcov/ 60 | .tox/ 61 | .nox/ 62 | .coverage 63 | .coverage.* 64 | .cache 65 | nosetests.xml 66 | coverage.xml 67 | *.cover 68 | *.py,cover 69 | .hypothesis/ 70 | .pytest_cache/ 71 | cover/ 72 | 73 | # Translations 74 | *.mo 75 | *.pot 76 | 77 | # Django stuff: 78 | *.log 79 | local_settings.py 80 | db.sqlite3 81 | db.sqlite3-journal 82 | 83 | # Flask stuff: 84 | instance/ 85 | .webassets-cache 86 | 87 | # Scrapy stuff: 88 | .scrapy 89 | 90 | # Sphinx documentation 91 | docs/_build/ 92 | 93 | # PyBuilder 94 | .pybuilder/ 95 | target/ 96 | 97 | # Jupyter Notebook 98 | .ipynb_checkpoints 99 | 100 | # IPython 101 | profile_default/ 102 | ipython_config.py 103 | 104 | # pyenv 105 | # For a library or package, you might want to ignore these files since the code is 106 | # intended to run in multiple environments; otherwise, check them in: 107 | # .python-version 108 | 109 | # pipenv 110 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 111 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 112 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 113 | # install all needed dependencies. 114 | #Pipfile.lock 115 | 116 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 117 | __pypackages__/ 118 | 119 | # Celery stuff 120 | celerybeat-schedule 121 | celerybeat.pid 122 | 123 | # SageMath parsed files 124 | *.sage.py 125 | 126 | # Environments 127 | .env 128 | .venv 129 | .venv_container 130 | .venv_whl 131 | env/ 132 | venv/ 133 | ENV/ 134 | env.bak/ 135 | venv.bak/ 136 | .envrc 137 | .direnv/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # nix 164 | /result 165 | 166 | # tangram plugins 167 | *.sqlite3 168 | *.sqlite3-journal 169 | # End of https://www.toptal.com/developers/gitignore/api/python 170 | 171 | .npm/ 172 | -------------------------------------------------------------------------------- /docs/plugins/history.md: -------------------------------------------------------------------------------- 1 | # History Plugin 2 | 3 | The `tangram_history` plugin provides a centralized, durable persistence layer for tangram. It ingests high-frequency, append-only time-series data from producer plugins (like [`tangram_jet1090`](./jet1090.md) or [`tangram_ship162`](./ship162.md)) and stores it efficiently in [Delta Lake tables](https://delta.io/blog/delta-lake-vs-parquet-comparison/). 4 | 5 | - buffers incoming records in memory and flushes them periodically as larger, optimized parquet files. 6 | - uses Delta Lake for transactional writes, schema enforcement, and compatibility with query engines (datafusion, polars, duckdb...). 7 | - makes no assumptions about data content: producers register their own schemas and data retention policies. 8 | 9 | ```mermaid 10 | sequenceDiagram 11 | participant P as Producer (e.g., planes) 12 | participant R as Redis 13 | participant H as history service 14 | participant C as Consumer (e.g., API endpoint) 15 | participant D as Delta Lake (Filesystem) 16 | 17 | note over P,H: startup 18 | P->>R: PUBLISH history:control, {type: "register_table", ...} 19 | H-->>P: PUBLISH history:control:response:..., {type: "table_registered", ...} 20 | 21 | loop real-time data 22 | P->>R: XADD history:ingest:, *, "data", "base64(arrow_ipc)" 23 | end 24 | 25 | H-->>H: buffer batches in memory 26 | H->>D: periodically flush buffer to delta table 27 | 28 | note over C,D: on-demand query 29 | C->>D: read delta table directly 30 | ``` 31 | 32 | ## Protocol 33 | 34 | ### Control channel (`history:control`) 35 | 36 | Used for managing tables. Producers must register a table and its schema before sending data. 37 | 38 | - message: `register_table` 39 | - payload fields: 40 | - `sender_id`: a unique id for the producer instance. 41 | - `table_name`: a unique name for the table (e.g., `"aircraft_states"`). 42 | - `schema`: base64-encoded arrow ipc **schema** bytes. 43 | - `partition_columns`: list of column names to partition by. 44 | - `optimize_interval_secs`: how often to run `optimize`. 45 | - `vacuum_interval_secs`: how often to run `vacuum`. 46 | - `vacuum_retention_period_secs`: retention for `vacuum`. 47 | 48 | ### Ingest stream (`history:ingest:`) 49 | 50 | A fire-and-forget redis stream for producers to send data. 51 | 52 | - command: `XADD` 53 | - payload: a key-value pair `data` and a base64-encoded arrow ipc **recordbatch** in stream format. 54 | 55 | ## Configuration 56 | 57 | The history service itself has minimal configuration. All per-table settings are provided by the producer plugins that use it. 58 | 59 | ```toml title="tangram.toml" 60 | [core] 61 | plugins = ["tangram_history", "tangram_jet1090"] 62 | 63 | # global settings for the history service 64 | [plugins.tangram_history] 65 | # base path on the local filesystem for storing delta tables. 66 | base_path = "/tmp/tangram_history" 67 | # redis channel for control messages. 68 | control_channel = "history:control" 69 | 70 | # producer-specific settings 71 | [plugins.tangram_jet1090] 72 | history_table_name = "jet1090" 73 | history_flush_interval_secs = 5 74 | # ... other history settings for this table 75 | ``` 76 | 77 | !!! warning 78 | 79 | Note that the [Delta Lake protocol](https://github.com/delta-io/delta/blob/master/PROTOCOL.md#primitive-types) only supports a subset of [Parquet primitive types](https://github.com/apache/parquet-format/blob/master/LogicalTypes.md#numeric-types). 80 | 81 | Notably, **unsigned integers are not supported** and will be implicitly downcasted! 82 | -------------------------------------------------------------------------------- /docs/contribute.md: -------------------------------------------------------------------------------- 1 | # Contribute to tangram 2 | 3 | We aim to provide a quality codebase with documentation, but expect that you will find bugs and issues, and hope you will also imagine very creative plugins. 4 | 5 | We welcome contributions to the project, whether it is code, documentation, or bug reports. 6 | 7 | ## Bug reports 8 | 9 | Please file bug reports on the [GitHub issue tracker](https://github.com/open-aviation/tangram/issues). 10 | 11 | When filing a bug report, please include the following information: 12 | 13 | - A clear description of the issue 14 | - Steps to reproduce the issue 15 | - Expected and actual behaviour 16 | - Any relevant logs or error messages 17 | - Your environment (OS, browser, etc.) 18 | 19 | ## Bug fixes and contributions 20 | 21 | If you want to contribute code, please follow these steps: 22 | 23 | 1. Fork the repository on GitHub 24 | 2. Create a new branch for your feature or bug fix 25 | 3. Make your changes and commit them with a clear message 26 | 4. Push your changes to your forked repository 27 | 5. Create a pull request against the `main` branch of the original repository 28 | 6. Include a clear description of your changes and why they are needed 29 | 7. Ensure your code follows the project's coding standards and passes all tests 30 | 8. If your changes are related to a specific issue, reference that issue in your pull request description 31 | 32 | ## Plugins 33 | 34 | If you want to share a plugin you have developed, please start by sharing a preview in the [Discussions](https://github.com/open-aviation/tangram/discussions) 35 | 36 | ## Style guide 37 | 38 | We do not want to be too strict about the coding standards, but we expect that you will follow the general style guides of the rest of the codebase. Ensure your contribution doesn't reformat existing code unnecessarily, as this can make it harder to review changes. 39 | 40 | Please take into account the `.editorconfig` file in the root of the repository, which defines the coding style for the project. You can find more information about EditorConfig [here](https://editorconfig.org/) and install plugins for your favourite editor. 41 | 42 | ## Development Workflow 43 | 44 | The project is structured as a monorepo with `uv` managing the Python workspaces and `pnpm` managing the frontend workspaces. 45 | 46 | ### Building for Distribution 47 | 48 | Each Python package (the core `tangram` and its plugins) can be built into a standard wheel for distribution. The frontend assets should first be built so downstream users won't have to install npm. 49 | 50 | ```sh 51 | # from the repository root 52 | pnpm i 53 | pnpm build 54 | uv build --all-packages 55 | ``` 56 | 57 | ### Testing Channel Core 58 | 59 | The core WebSocket logic is written in Rust. To run these tests, you need a local Redis instance: 60 | 61 | ```bash 62 | # in packages/tangram_core/rust 63 | cargo test --features channel 64 | ``` 65 | 66 | ### Continuous Integration 67 | 68 | The CI pipeline, defined in GitHub Actions, automates quality checks and builds. The primary steps are: 69 | 70 | 1. **Building Wheel**: The build process above is automated for all versions from Python 3.10 to 3.13, on Linux, MacOS, Windows and processor architectures. 71 | 2. **Testing**: Python tests are executed using `pytest` (scope is limited for now) 72 | 3. **Container Build**: A podman image is built using the root `Containerfile`, serving as an integration test. 73 | 74 | !!! warning 75 | The `tangram_weather` plugin depends on the `eccodes` library, which is problematic on non-`x86_64` systems. You can choose to build the `eccodes` library from source with the `ECCODES_STRATEGY` in the container build argument. 76 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build-frontend: 14 | uses: ./.github/workflows/build-frontend.yml 15 | 16 | build-wheels: 17 | needs: build-frontend 18 | uses: ./.github/workflows/build-wheels.yml 19 | 20 | # see: https://docs.pypi.org/trusted-publishers/ 21 | publish-pypi: 22 | needs: build-wheels 23 | runs-on: ubuntu-latest 24 | permissions: 25 | id-token: write 26 | environment: 27 | name: pypi 28 | url: https://pypi.org/p/tangram-core # purely cosmetic for github, does not affect publishing multiple packages 29 | steps: 30 | - uses: actions/download-artifact@v7 31 | with: 32 | pattern: wheels-* 33 | merge-multiple: true 34 | path: dist 35 | 36 | - name: Publish to PyPI 37 | uses: pypa/gh-action-pypi-publish@release/v1 38 | with: 39 | packages-dir: dist 40 | 41 | # see: https://crates.io/docs/trusted-publishing 42 | publish-cratesio: 43 | needs: build-wheels 44 | runs-on: ubuntu-latest 45 | permissions: 46 | id-token: write 47 | steps: 48 | - uses: actions/checkout@v6 49 | - uses: rust-lang/crates-io-auth-action@v1 50 | id: auth 51 | - name: Publish tangram_core_rs 52 | working-directory: packages/tangram_core/rust 53 | env: 54 | CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} 55 | run: cargo publish 56 | 57 | # see: https://docs.npmjs.com/trusted-publishers 58 | publish-npm: 59 | needs: build-wheels 60 | runs-on: ubuntu-latest 61 | permissions: 62 | id-token: write 63 | steps: 64 | - uses: actions/download-artifact@v7 65 | with: 66 | name: repo-with-frontend 67 | path: . 68 | 69 | - uses: pnpm/action-setup@v4 70 | with: 71 | version: 10 72 | 73 | - uses: actions/setup-node@v6 74 | with: 75 | node-version: 24 76 | registry-url: 'https://registry.npmjs.org' 77 | cache: 'pnpm' 78 | 79 | - name: Publish tangram-core 80 | working-directory: packages/tangram_core 81 | run: pnpm publish --no-git-checks 82 | env: 83 | NPM_CONFIG_PROVENANCE: true 84 | 85 | github-release: 86 | name: >- 87 | Sign the Python distribution with Sigstore 88 | and upload them to GitHub Release 89 | needs: 90 | - publish-pypi 91 | - publish-cratesio 92 | - publish-npm 93 | runs-on: ubuntu-latest 94 | 95 | permissions: 96 | contents: write 97 | id-token: write 98 | steps: 99 | - uses: actions/download-artifact@v7 100 | with: 101 | pattern: wheels-* 102 | merge-multiple: true 103 | path: dist 104 | - name: Sign the dists with Sigstore 105 | uses: sigstore/gh-action-sigstore-python@v3.2.0 106 | with: 107 | inputs: >- 108 | ./dist/*.tar.gz 109 | ./dist/*.whl 110 | - name: Create GitHub Release 111 | env: 112 | GITHUB_TOKEN: ${{ github.token }} 113 | run: >- 114 | gh release create 115 | '${{ github.ref_name }}' 116 | --repo '${{ github.repository }}' 117 | --notes "" 118 | - name: Upload artifact signatures to GitHub Release 119 | env: 120 | GITHUB_TOKEN: ${{ github.token }} 121 | run: >- 122 | gh release upload 123 | '${{ github.ref_name }}' dist/** 124 | --repo '${{ github.repository }}' -------------------------------------------------------------------------------- /packages/tangram_core/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import vue from "@vitejs/plugin-vue"; 3 | import path from "path"; 4 | import { viteStaticCopy } from "vite-plugin-static-copy"; 5 | 6 | const DECKGL_PACKAGES = [ 7 | "@deck.gl/core", 8 | "@deck.gl/layers", 9 | "@deck.gl/aggregation-layers", 10 | "@deck.gl/geo-layers", 11 | "@deck.gl/mesh-layers", 12 | "@deck.gl/json", 13 | "@deck.gl/mapbox", 14 | "@deck.gl/widgets", 15 | "@deck.gl/extensions" 16 | ]; 17 | // when modifying, also update: 18 | // - ./index.html (importmap) 19 | // - ./vite.lib-esm.config.ts 20 | // - ./src/tangram_core/vite-plugin-tangram.mjs 21 | // - ../../tsconfig.json paths 22 | 23 | export default defineConfig({ 24 | plugins: [ 25 | vue(), 26 | viteStaticCopy({ 27 | targets: [ 28 | { 29 | src: [ 30 | path.resolve(__dirname, "node_modules/vue/dist/vue.esm-browser.prod.js"), 31 | path.resolve(__dirname, "node_modules/maplibre-gl/dist/maplibre-gl.js"), 32 | path.resolve(__dirname, "node_modules/maplibre-gl/dist/maplibre-gl.js.map"), 33 | path.resolve(__dirname, "node_modules/lit-html/lit-html.js"), 34 | path.resolve(__dirname, "node_modules/lit-html/lit-html.js.map"), 35 | path.resolve(__dirname, "node_modules/lit-html/lit-html.js.map"), 36 | /* 37 | * HACK: Putting `rs1090` in the core would seem very strange, because `jet1090` 38 | * is the only consumer of it. However, this is necessary to work around 39 | * an annoying bug in `vite` preventing inclusion: 40 | * 41 | * - https://github.com/vitejs/vite/discussions/13172 42 | * - https://github.com/vitejs/vite/issues/4454 43 | * 44 | * Tangram core is built with Vite *application* mode, but tangram plugins are built 45 | * in *library* mode. There are some differences in how assets are handled 46 | * between the two modes. 47 | * 48 | * In `rs1090-wasm/web/rs1090_wasm.js`, it uses 49 | * `fetch(new URL('rs1090_wasm_bg.wasm', import.meta.url))` to load the wasm. 50 | * But vite library mode tries to convert the entire binary into a base64 data URI, 51 | * and for some reason it is utterly broken. 52 | * 53 | * Several workarounds were attempted, including: 54 | * 55 | * - using `?url` and/or `?no-inline` suffixes in the import statement, 56 | * - setting `rollupOptions.external` to `[/\.wasm$/]`, 57 | * - adopting this plugin: https://github.com/laynezh/vite-plugin-lib-assets 58 | * (this seems the most promising: wasm is copied but bindgen code doesn't work) 59 | * 60 | * So we just copy the files over manually for now. 61 | */ 62 | path.resolve(__dirname, "node_modules/rs1090-wasm/web/rs1090_wasm.js"), 63 | path.resolve(__dirname, "node_modules/rs1090-wasm/web/rs1090_wasm_bg.js"), 64 | path.resolve(__dirname, "node_modules/rs1090-wasm/web/rs1090_wasm_bg.wasm"), 65 | path.resolve( 66 | __dirname, 67 | "node_modules/font-awesome/css/font-awesome.min.css" 68 | ) 69 | ], 70 | dest: "." 71 | }, 72 | { 73 | src: path.resolve(__dirname, "node_modules/font-awesome/fonts/*"), 74 | dest: "fonts" 75 | } 76 | ] 77 | }) 78 | ], 79 | build: { 80 | sourcemap: true, 81 | outDir: path.resolve(__dirname, "./dist-frontend"), 82 | emptyOutDir: false, 83 | rollupOptions: { 84 | input: path.resolve(__dirname, "index.html"), 85 | external: ["vue", "maplibre", ...DECKGL_PACKAGES, "lit-html", "rs1090-wasm"] 86 | } 87 | } 88 | }); 89 | -------------------------------------------------------------------------------- /packages/tangram_jet1090/src/tangram_jet1090/SensorsLayer.vue: -------------------------------------------------------------------------------- 1 | 97 | 98 | 109 | 110 | 124 | -------------------------------------------------------------------------------- /packages/tangram_core/tests/test_api.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from dataclasses import dataclass 3 | from pathlib import Path 4 | from tempfile import TemporaryDirectory 5 | from typing import AsyncGenerator, Generator 6 | 7 | import httpx 8 | import pytest 9 | 10 | # TODO(abr): share fixtures across packages with pytest plugins 11 | 12 | 13 | @pytest.fixture(scope="session") 14 | def anyio_backend() -> str: 15 | return "asyncio" 16 | 17 | 18 | @dataclass(frozen=True) 19 | class ServerConfig: 20 | config_path: Path 21 | server_url: str 22 | 23 | 24 | @pytest.fixture(scope="session") 25 | def server_config() -> Generator[ServerConfig, None, None]: 26 | host = "127.0.0.1" 27 | # TODO: find free port 28 | server_port = 2346 29 | channel_port = 8001 30 | 31 | config_content = f""" 32 | [core] 33 | redis_url = "redis://localhost:6379" 34 | plugins = [] 35 | 36 | [server] 37 | host = "{host}" 38 | port = {server_port} 39 | 40 | [channel] 41 | host = "{host}" 42 | port = {channel_port} 43 | jwt_secret = "test-secret" 44 | """ 45 | 46 | with TemporaryDirectory() as config_dir: 47 | config_path = Path(config_dir) / "tangram.toml" 48 | config_path.write_text(config_content) 49 | 50 | yield ServerConfig( 51 | config_path=config_path, server_url=f"http://{host}:{server_port}" 52 | ) 53 | 54 | 55 | @pytest.fixture(scope="session") 56 | async def live_server(server_config: ServerConfig) -> AsyncGenerator[str, None]: 57 | """Starts the tangram server as a subprocess for the test session.""" 58 | 59 | proc = await asyncio.create_subprocess_exec( 60 | "tangram", 61 | "serve", 62 | f"--config={server_config.config_path}", 63 | stdout=asyncio.subprocess.PIPE, 64 | stderr=asyncio.subprocess.PIPE, 65 | ) 66 | max_wait_seconds = 30 67 | poll_interval_seconds = 0.1 68 | async with httpx.AsyncClient() as client: 69 | for _ in range(int(max_wait_seconds / poll_interval_seconds)): 70 | try: 71 | response = await client.get( 72 | server_config.server_url, timeout=poll_interval_seconds 73 | ) 74 | if response.status_code == 200: 75 | await asyncio.sleep(1) # give it a moment to settle 76 | break 77 | except (httpx.ConnectError, httpx.TimeoutException): 78 | await asyncio.sleep(poll_interval_seconds) 79 | else: 80 | proc.terminate() 81 | stdout, stderr = await proc.communicate() 82 | pytest.fail( 83 | f"server did not start within {max_wait_seconds} seconds.\n" 84 | f"{stdout.decode()=}\n{stderr.decode()=}" 85 | ) 86 | 87 | yield server_config.server_url 88 | 89 | proc.terminate() 90 | await proc.wait() 91 | 92 | 93 | @pytest.fixture(scope="session") 94 | def server_url(live_server: str) -> str: 95 | """Provides the URL of the live server started by the live_server fixture.""" 96 | return live_server 97 | 98 | 99 | @pytest.fixture(scope="session") 100 | async def client() -> AsyncGenerator[httpx.AsyncClient, None]: 101 | async with httpx.AsyncClient() as client: 102 | yield client 103 | 104 | 105 | @pytest.mark.anyio 106 | async def test_static(client: httpx.AsyncClient, server_url: str) -> None: 107 | response = await client.get(f"{server_url}") 108 | response.raise_for_status() 109 | assert response.content.startswith(b"") 110 | 111 | 112 | @pytest.mark.anyio 113 | async def test_api(client: httpx.AsyncClient, server_url: str) -> None: 114 | response = await client.get(f"{server_url}/manifest.json") 115 | response.raise_for_status() 116 | manifest = response.json() 117 | assert "plugins" in manifest 118 | -------------------------------------------------------------------------------- /packages/tangram_core/rust/src/bbox.rs: -------------------------------------------------------------------------------- 1 | use crate::stream::Positioned; 2 | use serde::{Deserialize, Serialize}; 3 | use std::collections::{HashMap, HashSet}; 4 | use tracing::info; 5 | 6 | #[derive(Debug, Clone, Serialize, Deserialize)] 7 | pub struct BoundingBox { 8 | pub north_east_lat: f64, 9 | pub north_east_lng: f64, 10 | pub south_west_lat: f64, 11 | pub south_west_lng: f64, 12 | } 13 | 14 | #[derive(Debug, Clone, Serialize, Deserialize)] 15 | pub struct SelectedEntity { 16 | pub id: String, 17 | #[serde(rename = "typeName")] 18 | pub type_name: String, 19 | } 20 | 21 | #[derive(Debug, Clone, Serialize, Deserialize)] 22 | pub struct BoundingBoxMessage { 23 | #[serde(rename = "connectionId")] 24 | pub connection_id: String, 25 | #[serde(rename = "northEastLat")] 26 | pub north_east_lat: f64, 27 | #[serde(rename = "northEastLng")] 28 | pub north_east_lng: f64, 29 | #[serde(rename = "southWestLat")] 30 | pub south_west_lat: f64, 31 | #[serde(rename = "southWestLng")] 32 | pub south_west_lng: f64, 33 | #[serde(rename = "selectedEntities")] 34 | pub selected_entities: Vec, 35 | } 36 | 37 | #[derive(Default)] 38 | pub struct BoundingBoxState { 39 | pub bboxes: HashMap, 40 | pub selections: HashMap>, 41 | pub clients: HashSet, 42 | } 43 | 44 | impl BoundingBoxState { 45 | pub fn new() -> Self { 46 | Self { 47 | bboxes: HashMap::new(), 48 | selections: HashMap::new(), 49 | clients: HashSet::new(), 50 | } 51 | } 52 | 53 | pub fn set_bbox(&mut self, connection_id: &str, bbox: BoundingBox) { 54 | self.bboxes.insert(connection_id.to_string(), bbox.clone()); 55 | info!( 56 | "Updated {} bounding box: NE({}, {}), SW({}, {})", 57 | connection_id, 58 | bbox.north_east_lat, 59 | bbox.north_east_lng, 60 | bbox.south_west_lat, 61 | bbox.south_west_lng 62 | ); 63 | } 64 | 65 | pub fn set_selected(&mut self, connection_id: &str, entities: Vec) { 66 | if entities.is_empty() { 67 | self.selections.remove(connection_id); 68 | } else { 69 | self.selections.insert(connection_id.to_string(), entities); 70 | } 71 | } 72 | 73 | pub fn has_bbox(&self, connection_id: &str) -> bool { 74 | self.bboxes.contains_key(connection_id) 75 | } 76 | 77 | pub fn get_bbox(&self, connection_id: &str) -> Option<&BoundingBox> { 78 | self.bboxes.get(connection_id) 79 | } 80 | 81 | pub fn get_selected(&self, connection_id: &str) -> Option<&Vec> { 82 | self.selections.get(connection_id) 83 | } 84 | 85 | pub fn remove_client(&mut self, connection_id: &str) { 86 | self.bboxes.remove(connection_id); 87 | self.selections.remove(connection_id); 88 | self.clients.remove(connection_id); 89 | } 90 | } 91 | 92 | pub fn is_within_bbox( 93 | item: &T, 94 | state: &BoundingBoxState, 95 | connection_id: &str, 96 | ) -> bool { 97 | if !state.has_bbox(connection_id) { 98 | return true; 99 | } 100 | 101 | let bbox = match state.get_bbox(connection_id) { 102 | Some(bbox) => bbox, 103 | None => return true, 104 | }; 105 | 106 | let lat = match item.latitude() { 107 | Some(lat) => lat, 108 | None => return false, 109 | }; 110 | 111 | let lng = match item.longitude() { 112 | Some(lng) => lng, 113 | None => return false, 114 | }; 115 | 116 | bbox.south_west_lat <= lat 117 | && lat <= bbox.north_east_lat 118 | && bbox.south_west_lng <= lng 119 | && lng <= bbox.north_east_lng 120 | } 121 | -------------------------------------------------------------------------------- /packages/tangram_jet1090/src/tangram_jet1090/_planes.pyi: -------------------------------------------------------------------------------- 1 | # This file is automatically generated by pyo3_stub_gen 2 | # ruff: noqa: E501, F401 3 | 4 | import builtins 5 | import typing 6 | 7 | @typing.final 8 | class Aircraft: 9 | @property 10 | def typecode(self) -> typing.Optional[builtins.str]: ... 11 | @typecode.setter 12 | def typecode(self, value: typing.Optional[builtins.str]) -> None: ... 13 | @property 14 | def registration(self) -> typing.Optional[builtins.str]: ... 15 | @registration.setter 16 | def registration(self, value: typing.Optional[builtins.str]) -> None: ... 17 | def __new__(cls, typecode: typing.Optional[builtins.str], registration: typing.Optional[builtins.str]) -> Aircraft: ... 18 | 19 | @typing.final 20 | class PlanesConfig: 21 | @property 22 | def redis_url(self) -> builtins.str: ... 23 | @redis_url.setter 24 | def redis_url(self, value: builtins.str) -> None: ... 25 | @property 26 | def jet1090_channel(self) -> builtins.str: ... 27 | @jet1090_channel.setter 28 | def jet1090_channel(self, value: builtins.str) -> None: ... 29 | @property 30 | def history_table_name(self) -> builtins.str: ... 31 | @history_table_name.setter 32 | def history_table_name(self, value: builtins.str) -> None: ... 33 | @property 34 | def history_control_channel(self) -> builtins.str: ... 35 | @history_control_channel.setter 36 | def history_control_channel(self, value: builtins.str) -> None: ... 37 | @property 38 | def state_vector_expire(self) -> builtins.int: ... 39 | @state_vector_expire.setter 40 | def state_vector_expire(self, value: builtins.int) -> None: ... 41 | @property 42 | def stream_interval_secs(self) -> builtins.float: ... 43 | @stream_interval_secs.setter 44 | def stream_interval_secs(self, value: builtins.float) -> None: ... 45 | @property 46 | def aircraft_db(self) -> builtins.dict[builtins.str, Aircraft]: ... 47 | @aircraft_db.setter 48 | def aircraft_db(self, value: builtins.dict[builtins.str, Aircraft]) -> None: ... 49 | @property 50 | def history_buffer_size(self) -> builtins.int: ... 51 | @history_buffer_size.setter 52 | def history_buffer_size(self, value: builtins.int) -> None: ... 53 | @property 54 | def history_flush_interval_secs(self) -> builtins.int: ... 55 | @history_flush_interval_secs.setter 56 | def history_flush_interval_secs(self, value: builtins.int) -> None: ... 57 | @property 58 | def history_optimize_interval_secs(self) -> builtins.int: ... 59 | @history_optimize_interval_secs.setter 60 | def history_optimize_interval_secs(self, value: builtins.int) -> None: ... 61 | @property 62 | def history_optimize_target_file_size(self) -> builtins.int: ... 63 | @history_optimize_target_file_size.setter 64 | def history_optimize_target_file_size(self, value: builtins.int) -> None: ... 65 | @property 66 | def history_vacuum_interval_secs(self) -> builtins.int: ... 67 | @history_vacuum_interval_secs.setter 68 | def history_vacuum_interval_secs(self, value: builtins.int) -> None: ... 69 | @property 70 | def history_vacuum_retention_period_secs(self) -> typing.Optional[builtins.int]: ... 71 | @history_vacuum_retention_period_secs.setter 72 | def history_vacuum_retention_period_secs(self, value: typing.Optional[builtins.int]) -> None: ... 73 | def __new__(cls, redis_url: builtins.str, jet1090_channel: builtins.str, history_table_name: builtins.str, history_control_channel: builtins.str, state_vector_expire: builtins.int, stream_interval_secs: builtins.float, aircraft_db: typing.Mapping[builtins.str, Aircraft], history_buffer_size: builtins.int, history_flush_interval_secs: builtins.int, history_optimize_interval_secs: builtins.int, history_optimize_target_file_size: builtins.int, history_vacuum_interval_secs: builtins.int, history_vacuum_retention_period_secs: typing.Optional[builtins.int]) -> PlanesConfig: ... 74 | 75 | def init_tracing_stderr(filter_str: builtins.str) -> None: ... 76 | 77 | def run_planes(config: PlanesConfig) -> typing.Any: ... 78 | 79 | -------------------------------------------------------------------------------- /docs/plugins/frontend.md: -------------------------------------------------------------------------------- 1 | # Frontend 2 | 3 | Frontend plugins are standalone NPM packages that add new widgets and functionality to the `tangram` web interface. This system is designed for modularity, allowing you to build and share custom UI components. 4 | 5 | ## 1. Project Structure 6 | 7 | A frontend plugin is a standard TypeScript/Vue project that produces a library build. 8 | 9 | ```text 10 | my-tangram-frontend-plugin/ 11 | ├── package.json 12 | ├── vite.config.ts 13 | └── src/ 14 | ├── MyWidget.vue 15 | └── index.ts 16 | ``` 17 | 18 | ## 2. Plugin Entry Point (`index.ts`) 19 | 20 | The `main` file specified in your `package.json` must export an `install` function. This function is the plugin's entry point and receives the `TangramApi` object, which provides methods for interacting with the core application. 21 | 22 | 23 | ```typescript title="src/index.ts" 24 | import type { TangramApi } from "@open-aviation/tangram-core/api"; 25 | import MyWidget from "./MyWidget.vue"; 26 | 27 | export function install(api: TangramApi) { 28 | // use the API to register a new widget component. 29 | // the first argument is a unique ID for your widget. 30 | // the second is the Vue component itself. 31 | api.registerWidget("my-widget", MyWidget); 32 | } 33 | ``` 34 | 35 | The `TangramApi` provides two main functions: 36 | 37 | - `registerWidget(id: string, component: Component)`: Makes your component available to the core UI. 38 | - `getVueApp(): App`: Provides access to the core Vue application instance for advanced use cases. 39 | 40 | ## 3. `vite` configuration 41 | 42 | To simplify the build process, `tangram` provides a shared Vite plugin. This handles the complex configuration needed to build your plugin as a library and generate a `plugin.json` manifest file. 43 | 44 | ```typescript title="vite.config.ts" 45 | import { defineConfig } from "vite"; 46 | import { tangramPlugin } from "@open-aviation/tangram-core/vite-plugin"; 47 | 48 | export default defineConfig({ 49 | plugins: [tangramPlugin()], 50 | }); 51 | ``` 52 | 53 | This standardized build produces a `dist-frontend` directory containing your compiled JavaScript and the manifest file. `tangram` uses this manifest to discover and load your plugin. 54 | 55 | ## 4. Building and using your plugin 56 | 57 | First, build your frontend assets. If you are in the monorepo, `pnpm build` will handle this. 58 | 59 | Next, ensure the generated `dist-frontend` directory is included in your Python package's wheel. This is typically done in `pyproject.toml`. 60 | 61 | === "hatchling" 62 | 63 | ```toml 64 | [tool.hatch.build.targets.wheel.force-include] 65 | "dist-frontend" = "my_plugin/dist-frontend" 66 | ``` 67 | 68 | === "maturin" 69 | 70 | Configuring `vite` to output to a subdirectory of your python source (e.g. `src/my_plugin/dist-frontend`) ensures `maturin` includes it automatically. 71 | 72 | ```typescript title="vite.config.ts" 73 | build: { 74 | outDir: path.resolve(__dirname, "./src/my_plugin/dist-frontend"), 75 | } 76 | ``` 77 | 78 | Finally, install your Python package and enable it in your `tangram.toml`: 79 | 80 | ```toml 81 | [core] 82 | plugins = ["my_tangram_plugin"] 83 | ``` 84 | 85 | When `tangram serve` runs, it will: 86 | 87 | 1. Read the `plugin.json` manifest from every enabled plugin at startup. 88 | 2. Amalgamate these into a single cached response for `/manifest.json`. 89 | 3. The core web app fetches this single manifest and dynamically loads resources. 90 | 91 | ```mermaid 92 | sequenceDiagram 93 | participant P as Plugin Module 94 | participant B as Browser 95 | participant S as Tangram Server 96 | 97 | B->>S: GET /manifest.json 98 | S-->>B: Respond with {"plugins": {"my_plugin": {"main": "index.js"}}} 99 | B->>S: GET /plugins/my_plugin/index.js 100 | S-->>B: Serve plugin's JS entry point 101 | Note over B, P: Browser executes plugin code 102 | P->>B: install(tangramApi) 103 | Note over B: Plugin registers its widgets 104 | ``` 105 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # tangram 2 | 3 | [![image](https://img.shields.io/pypi/v/tangram-core.svg)](https://pypi.python.org/pypi/tangram-core) 4 | [![image](https://img.shields.io/pypi/l/tangram-core.svg)](https://pypi.python.org/pypi/tangram-core) 5 | 6 | 7 | 8 | **tangram** is a modular platform for real-time geospatial and air traffic management research. Built on a plugin-first architecture, it enables researchers to visualize and analyze moving entities, from aircraft to ships to weather patterns, in a unified web interface. 9 | 10 | The system combines a high-performance backend (Python & Rust) with a modern web frontend (Vue & Deck.gl) to handle massive datasets with low latency. While the official plugins focus on air traffic management, the core framework is generic and adaptable to any domain. 11 | 12 | ![preview](./docs/screenshot/tangram_screenshot_fr.png) 13 | 14 | ## Highlights 15 | 16 | - **Plugin-first**: Everything from data decoding to UI widgets is a plugin. Customize your stack by installing only what you need. 17 | - **Real-time**: Built on Redis and WebSockets for instant streaming of state vectors and events. 18 | - **Performance**: Critical data paths are written in Rust. Historical data is managed efficiently using Apache Arrow and Delta Lake. 19 | 20 | ## Documentation 21 | 22 | [![docs](https://github.com/open-aviation/tangram/actions/workflows/docs.yml/badge.svg)](https://github.com/open-aviation/tangram/actions/workflows/docs.yml) 23 | 24 | Full documentation, including quickstart guides and API references, is available at . 25 | 26 | ## Tests 27 | 28 | [![build](https://github.com/open-aviation/tangram/actions/workflows/podman.yml/badge.svg)](https://github.com/open-aviation/tangram/actions/workflows/podman.yml) 29 | 30 | The system is designed to be modular, so each component is tested independently. Integration testing is currently limited to the construction of the container image, via the container build process (`just c-build`). 31 | 32 | ## Cite this work 33 | 34 | [![DOI](https://joss.theoj.org/papers/10.21105/joss.08662/status.svg)](https://doi.org/10.21105/joss.08662) 35 | 36 | If you find this work useful and use it in your academic research, you may use the following BibTeX entry. 37 | 38 | ```bibtex 39 | @article{tangram_2025, 40 | author = {Olive, Xavier and Sun, Junzi and Huang, Xiaogang and Khalaf, Michel}, 41 | doi = {10.21105/joss.08662}, 42 | journal = {Journal of Open Source Software}, 43 | month = nov, 44 | number = {115}, 45 | pages = {8662}, 46 | title = {{tangram, an open platform for modular, real-time air traffic management research}}, 47 | url = {https://joss.theoj.org/papers/10.21105/joss.08662}, 48 | volume = {10}, 49 | year = {2025} 50 | } 51 | ``` 52 | 53 | ## Funding 54 | 55 | This project is currently funded by the Dutch Research Council (NWO)'s Open Science Fund, **OSF23.1.051**: . 56 | 57 | ## History 58 | 59 | In 2020, @junzis and @xoolive published a paper [Detecting and Measuring Turbulence from Mode S Surveillance Downlink Data](https://research.tudelft.nl/en/publications/detecting-and-measuring-turbulence-from-mode-s-surveillance-downl-2) on how real-time Mode S data can be used to detect turbulence. 60 | 61 | Based on this method, @MichelKhalaf started developing this tool as part of his training with @xoolive in 2021, which was completed in Summer 2022. After that, the project was then lightly maintained by @xoolive and @junzis, while we have been applying for funding to continue this tool. 62 | 63 | Then in 2023, we received funding from NWO to continue the development of this tool. With this funding, @emctoo from [Shinetech](https://www.shinetechsoftware.com) was hired to work alongside us on this open-source project and helped to improve the codebase and documentation, making it more accessible, improving the design with a component-based architecture. (version 0.1) 64 | 65 | After reviewing the existing project for the JOSS submission, @abc8747 kindly contributed and helped to improve the software engineering practices so that all components can be packaged as simple-to-install Python packages. (version 0.2) 66 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | # to use docker, install `docker-buildx` and run: 2 | # `export DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 CONTAINER_RUNTIME=docker` 3 | container_runtime := env("CONTAINER_RUNTIME", "podman") 4 | 5 | # Install the project in development mode. 6 | install: 7 | # NOTE: git cleaning and running this again fails because uv cache doesn't understand the .so is gone 8 | # see: https://github.com/astral-sh/uv/issues/11390#issuecomment-3436401449 9 | pnpm i 10 | pnpm build 11 | uv sync --all-groups --all-extras --all-packages 12 | 13 | # 14 | # External dependencies 15 | # 16 | 17 | # NOTE: the following `c-` commands are for temporary testing with hardcoded ports. 18 | # a future version of tangram will use podman networks properly. 19 | 20 | # Launch redis in a container. 21 | c-redis: 22 | #!/usr/bin/env bash 23 | if {{container_runtime}} container exists redis; then 24 | echo "container redis exists" 25 | exit 0 26 | fi 27 | 28 | {{container_runtime}} run -d --rm -p 6379:6379 --name redis docker.io/library/redis:latest 29 | 30 | jet1090_sources := '"ws://feedme.mode-s.org:9876/40128@EHRD"' 31 | 32 | # Launch `jet1090` for the `tangram_jet1090` plugin in a container. 33 | c-jet1090 sources=jet1090_sources: 34 | {{container_runtime}} run -d --rm --name jet1090 \ 35 | --network=host \ 36 | ghcr.io/xoolive/jet1090:latest \ 37 | jet1090 --serve-port 8080 --history-expire 5 --redis-url "redis://127.0.0.1:6379" {{sources}} 38 | 39 | # Launch `ship162` for the `tangram_ship162` plugin locally. 40 | # Make sure you have cloned https://github.com/xoolive/ship162 and ran `cargo install --path crates/ship162` 41 | ship162: 42 | ship162 --redis-url "redis://127.0.0.1:6379" tcp://153.44.253.27:5631 43 | 44 | # 45 | # Build tangram in podman (experimental) 46 | # 47 | 48 | # Build tangram with all plugins in a container. 49 | # For non x86_64 architectures, set eccodes_strategy to `fromsource`. 50 | c-build eccodes_strategy='prebuilt': 51 | {{container_runtime}} build . \ 52 | --build-arg ECCODES_STRATEGY={{eccodes_strategy}} \ 53 | --tag tangram:latest \ 54 | -f Containerfile 55 | 56 | # Run tangram with all plugins in a container. 57 | c-run path_config='./tangram.example.toml': 58 | {{container_runtime}} run -d --rm --name tangram \ 59 | -p 2346:2346 \ 60 | -v {{path_config}}:/app/tangram.toml \ 61 | --network=host \ 62 | localhost/tangram:latest \ 63 | tangram serve --config /app/tangram.toml 64 | 65 | # 66 | # Misc development utilities 67 | # 68 | 69 | # Regenerate `.pyi` stub files from Rust code. 70 | stubgen: 71 | cargo run --package tangram_core --bin stub_gen_core --features pyo3,stubgen || true 72 | cargo run --package jet1090_planes --bin stub_gen_planes --features pyo3,stubgen || true 73 | cargo run --package ship162_ships --bin stub_gen_ships --features pyo3,stubgen || true 74 | cargo run --package tangram_history --bin stub_gen_history --features pyo3,stubgen || true 75 | 76 | # Fix code quality (eslint, ruff, clippy) and formatting (prettier, ruff, rustfmt). 77 | fmt: 78 | uv run ruff check packages --fix || true 79 | uv run ruff format packages || true 80 | pnpm i || true 81 | pnpm fmt || true 82 | pnpm lint || true 83 | cargo fmt --all || true 84 | cargo clippy --all-targets --fix --allow-dirty --allow-staged --all-features || true 85 | 86 | _rmi name: 87 | {{container_runtime}} images --filter "reference={{name}}" -q | xargs -r {{container_runtime}} rmi --force 88 | 89 | # nukes tangram and its build cache, keeping redis and jet1090 intact 90 | # removes virtually ALL non-running containers, images and build cache, be careful!! 91 | _clean: 92 | git clean -Xdf 93 | {{container_runtime}} kill tangram || true 94 | just _rmi tangram 95 | {{container_runtime}} system prune --all --volumes --force 96 | {{container_runtime}} system prune --external --force 97 | {{container_runtime}} container rm \ 98 | --force \ 99 | --depend='1' \ 100 | --volumes='1' \ 101 | $({{container_runtime}} container list \ 102 | --external='1' \ 103 | --filter='status=created' \ 104 | --filter='status=exited' \ 105 | --filter='status=paused' \ 106 | --filter='status=unknown' \ 107 | --no-trunc='1' \ 108 | --quiet='1' \ 109 | ) || true 110 | -------------------------------------------------------------------------------- /packages/tangram_core/src/tangram_core/config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from dataclasses import dataclass, field 5 | from pathlib import Path 6 | from typing import Any, Literal, Protocol, runtime_checkable 7 | 8 | 9 | @runtime_checkable 10 | class HasTopbarUiConfig(Protocol): 11 | topbar_order: int 12 | 13 | 14 | @runtime_checkable 15 | class HasSidebarUiConfig(Protocol): 16 | sidebar_order: int 17 | 18 | 19 | @dataclass 20 | class ServerConfig: 21 | host: str = "127.0.0.1" 22 | port: int = 2346 23 | 24 | 25 | @dataclass 26 | class ChannelConfig: 27 | # TODO: we should make it clear that host:port is for the *backend* to 28 | # listen on, and not to be confused with the frontend. 29 | host: str = "127.0.0.1" 30 | port: int = 2347 31 | public_url: str | None = None 32 | jwt_secret: str = "secret" 33 | jwt_expiration_secs: int = 315360000 # 10 years 34 | id_length: int = 8 35 | 36 | 37 | @dataclass 38 | class UrlConfig: 39 | url: str 40 | type: str = "vector" 41 | 42 | 43 | @dataclass 44 | class SourceSpecification: 45 | carto: UrlConfig | None = None 46 | protomaps: UrlConfig | None = None 47 | 48 | 49 | @dataclass 50 | class StyleSpecification: 51 | sources: SourceSpecification | None = None 52 | glyphs: str = "https://cdn.protomaps.com/fonts/pbf/{fontstack}/{range}.pbf" 53 | layers: list[Any] | None = None 54 | version: Literal[8] = 8 55 | 56 | 57 | @dataclass 58 | class MapConfig: 59 | style: str | StyleSpecification = ( 60 | "https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json" 61 | ) 62 | attribution: str = ( 63 | '© ' 64 | "OpenStreetMap contributors © " 65 | 'CARTO' 66 | ) 67 | center_lat: float = 48.0 68 | center_lon: float = 7.0 69 | zoom: float = 4 70 | pitch: float = 0 71 | bearing: float = 0 72 | lang: str = "en" 73 | min_zoom: float = 0 74 | max_zoom: float = 24 75 | max_pitch: float = 70 76 | allow_pitch: bool = True 77 | allow_bearing: bool = True 78 | enable_3d: bool = False 79 | 80 | 81 | @dataclass 82 | class CoreConfig: 83 | redis_url: str = "redis://127.0.0.1:6379" 84 | plugins: list[str] = field(default_factory=list) 85 | log_level: str = "INFO" 86 | 87 | 88 | @dataclass 89 | class CacheEntry: 90 | origin: str | None = None 91 | """Origin URL. If None, the local file is served directly.""" 92 | local_path: Path | None = None 93 | """Local path to cache the file.""" 94 | serve_route: str = "" 95 | """Where to serve the file in FastAPI.""" 96 | media_type: str = "application/octet-stream" 97 | """Media type for the response.""" 98 | 99 | 100 | @dataclass 101 | class CacheConfig: 102 | entries: list[CacheEntry] = field(default_factory=list) 103 | 104 | 105 | @dataclass 106 | class Config: 107 | core: CoreConfig = field(default_factory=CoreConfig) 108 | server: ServerConfig = field(default_factory=ServerConfig) 109 | channel: ChannelConfig = field(default_factory=ChannelConfig) 110 | map: MapConfig = field(default_factory=MapConfig) 111 | plugins: dict[str, Any] = field(default_factory=dict) 112 | cache: CacheConfig = field(default_factory=CacheConfig) 113 | 114 | @classmethod 115 | def from_file(cls, config_path: Path) -> Config: 116 | if sys.version_info < (3, 11): 117 | import tomli as tomllib 118 | else: 119 | import tomllib 120 | from pydantic import TypeAdapter 121 | 122 | with open(config_path, "rb") as f: 123 | cfg_data = tomllib.load(f) 124 | 125 | config_adapter = TypeAdapter(cls) 126 | config = config_adapter.validate_python(cfg_data) 127 | return config 128 | 129 | 130 | # 131 | # when served over reverse proxies, we do not want to simply expose the entire 132 | # backend config to the frontend. the following structs are used to selectively 133 | # expose a subset of the config to the frontend. 134 | # 135 | 136 | 137 | @dataclass 138 | class FrontendChannelConfig: 139 | url: str 140 | 141 | 142 | @dataclass 143 | class FrontendConfig: 144 | channel: FrontendChannelConfig 145 | map: MapConfig 146 | -------------------------------------------------------------------------------- /packages/tangram_core/src/tangram_core/plugin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import functools 5 | import importlib.metadata 6 | import logging 7 | import traceback 8 | from collections.abc import AsyncGenerator, Awaitable, Callable 9 | from dataclasses import dataclass, field 10 | from typing import TYPE_CHECKING, Any, TypeAlias 11 | 12 | from fastapi import APIRouter 13 | 14 | if TYPE_CHECKING: 15 | from .backend import BackendState 16 | 17 | ServiceAsyncFunc: TypeAlias = Callable[[BackendState], Awaitable[None]] 18 | ServiceFunc: TypeAlias = ServiceAsyncFunc | Callable[[BackendState], None] 19 | Priority: TypeAlias = int 20 | IntoFrontendConfigFunction: TypeAlias = Callable[[dict[str, Any]], Any] 21 | Lifespan: TypeAlias = Callable[[BackendState], AsyncGenerator[None, None]] 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | 26 | @dataclass 27 | class Plugin: 28 | """Stores the metadata and registered API routes, background services and 29 | frontend assets for a tangram plugin. 30 | 31 | Packages should declare an entry point in the `tangram_core.plugins` group 32 | in their `pyproject.toml` pointing to an instance of this class. 33 | """ 34 | 35 | frontend_path: str | None = None 36 | """Path to the compiled frontend assets, *relative* to the distribution root 37 | (editable) or package root (wheel). 38 | """ 39 | routers: list[APIRouter] = field(default_factory=list) 40 | into_frontend_config_function: IntoFrontendConfigFunction | None = None 41 | """Function to parse plugin-scoped backend configuration (within the 42 | `tangram.toml`) into a frontend-safe configuration object. 43 | 44 | If not specified, the backend configuration dict is passed as-is.""" 45 | lifespan: Lifespan | None = None 46 | """Async context manager for plugin initialization and teardown.""" 47 | services: list[tuple[Priority, ServiceAsyncFunc]] = field( 48 | default_factory=list, init=False 49 | ) 50 | dist_name: str = field(init=False) 51 | """Name of the distribution (package) that provided this plugin, populated 52 | automatically during loading. 53 | """ # we do this so plugins can know their own package name if needed 54 | 55 | def register_service( 56 | self, priority: Priority = 0 57 | ) -> Callable[[ServiceFunc], ServiceFunc]: 58 | """Decorator to register a background service function. 59 | 60 | Services are long-running async functions that receive the BackendState 61 | and are started when the application launches. 62 | """ 63 | 64 | def decorator(func: ServiceFunc) -> ServiceFunc: 65 | @functools.wraps(func) 66 | async def async_wrapper(backend_state: BackendState) -> None: 67 | if asyncio.iscoroutinefunction(func): 68 | await func(backend_state) 69 | else: 70 | await asyncio.to_thread(func, backend_state) 71 | 72 | self.services.append((priority, async_wrapper)) 73 | return func 74 | 75 | return decorator 76 | 77 | 78 | def scan_plugins() -> importlib.metadata.EntryPoints: 79 | return importlib.metadata.entry_points(group="tangram_core.plugins") 80 | 81 | 82 | def load_plugin( 83 | entry_point: importlib.metadata.EntryPoint, 84 | ) -> Plugin | None: 85 | """Instantiates the plugin object defined in the entry point 86 | and injects the name of the distribution into it.""" 87 | try: 88 | plugin_instance = entry_point.load() 89 | except Exception as e: 90 | tb = traceback.format_exc() 91 | logger.error( 92 | f"failed to load plugin {entry_point.name}: {e}. {tb}" 93 | f"\n= help: does {entry_point.value} exist?" 94 | ) 95 | return None 96 | if not isinstance(plugin_instance, Plugin): 97 | logger.error(f"entry point {entry_point.name} is not an instance of `Plugin`") 98 | return None 99 | if entry_point.dist is None: 100 | logger.error(f"could not determine distribution for plugin {entry_point.name}") 101 | return None 102 | # NOTE: we ignore `entry_point.name` for now and simply use the distribution's name 103 | # should we raise an error if they differ? not for now 104 | 105 | plugin_instance.dist_name = entry_point.dist.name 106 | return plugin_instance 107 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: tangram 2 | site_description: an open platform for modular, real-time air traffic management research 3 | repo_name: open-aviation/tangram 4 | repo_url: https://github.com/open-aviation/tangram 5 | site_url: https://mode-s.org/tangram/ 6 | nav: 7 | - Home: "index.md" 8 | - Quickstart: "quickstart.md" 9 | - Configuration: "configuration.md" 10 | - Architecture: 11 | - "architecture/index.md" 12 | - channel: "architecture/channel.md" 13 | - Plugins: 14 | - "plugins/index.md" 15 | - "plugins/backend.md" 16 | - "plugins/frontend.md" 17 | - Built-in: 18 | - "plugins/system.md" 19 | - "plugins/history.md" 20 | - "plugins/jet1090.md" 21 | - "plugins/ship162.md" 22 | - "plugins/weather.md" 23 | - "plugins/airports.md" 24 | - Examples: 25 | - "plugins/examples/citypair.md" 26 | - "plugins/examples/sensors.md" 27 | - "plugins/examples/windfield.md" 28 | - API Reference: 29 | - "api/core.md" 30 | - "api/system.md" 31 | # for some reason history is not detected by mkdocstrings, so we exclude it 32 | - "api/jet1090.md" 33 | - "api/ship162.md" 34 | - "api/weather.md" 35 | - Contribute: "contribute.md" 36 | 37 | theme: 38 | name: material 39 | logo: icons/favicon.png 40 | favicon: icons/favicon.ico 41 | features: 42 | - content.code.annotate 43 | - content.code.copy 44 | - navigation.sections 45 | - navigation.top 46 | - navigation.footer 47 | - navigation.indexes 48 | - search.highlight 49 | - search.suggest 50 | palette: 51 | - media: "(prefers-color-scheme)" 52 | toggle: 53 | icon: material/link 54 | name: Switch to light mode 55 | - media: "(prefers-color-scheme: light)" 56 | scheme: default 57 | primary: indigo 58 | accent: indigo 59 | toggle: 60 | icon: material/toggle-switch 61 | name: Switch to dark mode 62 | - media: "(prefers-color-scheme: dark)" 63 | scheme: slate 64 | primary: black 65 | accent: indigo 66 | toggle: 67 | icon: material/toggle-switch-off 68 | name: Switch to system preference 69 | font: 70 | text: Roboto 71 | code: Roboto Mono 72 | icon: 73 | repo: fontawesome/brands/github 74 | 75 | extra_css: 76 | - css/extra.css 77 | 78 | plugins: 79 | - search 80 | - mkdocstrings: 81 | handlers: 82 | python: 83 | # explicitly specifying the paths so we can do `uv sync --group docs && uv run mkdocs build` 84 | # without installing the entire project 85 | paths: 86 | - packages/tangram_core/src 87 | - packages/tangram_jet1090/src 88 | - packages/tangram_weather/src 89 | - packages/tangram_globe/src 90 | - packages/tangram_system/src 91 | - packages/tangram_ship162/src 92 | inventories: 93 | - https://docs.python.org/3/objects.inv 94 | - https://fastapi.tiangolo.com/objects.inv 95 | options: 96 | # member 97 | inherited_members: true 98 | members_order: source 99 | # signature 100 | separate_signature: true 101 | show_signature_annotations: true 102 | # docstring 103 | docstring_style: sphinx 104 | docstring_section_style: table 105 | signature_crossrefs: true 106 | show_source: true 107 | # heading 108 | show_symbol_type_heading: true 109 | show_symbol_type_toc: true 110 | show_root_heading: true 111 | 112 | markdown_extensions: 113 | - admonition 114 | - attr_list 115 | - footnotes 116 | - pymdownx.emoji: 117 | emoji_index: !!python/name:material.extensions.emoji.twemoji 118 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 119 | - pymdownx.blocks.tab: 120 | alternate_style: true 121 | slugify: !!python/object/apply:pymdownx.slugs.slugify 122 | kwds: 123 | case: lower 124 | - pymdownx.highlight: 125 | pygments_lang_class: true 126 | - pymdownx.superfences: 127 | custom_fences: 128 | - name: mermaid 129 | class: mermaid 130 | format: !!python/name:pymdownx.superfences.fence_code_format 131 | - pymdownx.tabbed: 132 | alternate_style: true 133 | - toc: 134 | permalink: true 135 | baselevel: 1 136 | 137 | extra: 138 | social: 139 | - icon: fontawesome/brands/github 140 | link: https://github.com/open-aviation/tangram -------------------------------------------------------------------------------- /docs/plugins/jet1090.md: -------------------------------------------------------------------------------- 1 | # Jet1090 Plugin 2 | 3 | The `tangram_jet1090` plugin is the primary tool for integrating Mode S and ADS-B data into the `tangram` framework. It processes raw aviation surveillance data from a `jet1090` instance and makes it available for both real-time visualization and historical analysis. 4 | 5 | ## Overview 6 | 7 | This plugin consists of: 8 | 9 | - **A background service ([planes][tangram_jet1090.run_planes])** to maintain a real-time state of all visible aircraft and persist their data for historical queries. 10 | - **A REST API endpoint ([`/data/{icao24}`][tangram_jet1090.get_trajectory_data])** to fetch the full, time-ordered trajectory for a specific aircraft. 11 | - **A frontend widget** to display a placeholder in the UI. 12 | 13 | ## `planes` Service 14 | 15 | The `planes` service is the core of the real-time functionality. It is a Rust-based component, wrapped with PyO3 for integration into the Python ecosystem. 16 | 17 | ```mermaid 18 | sequenceDiagram 19 | participant J as jet1090 20 | participant R as Redis 21 | participant P as planes service 22 | participant C as channel service 23 | participant F as Frontend 24 | 25 | J->>R: PUBLISH jet1090 (raw message) 26 | P->>R: SUBSCRIBE jet1090 27 | Note over P: Process message, update state vector 28 | P->>R: XADD history:ingest:jet1090, *, ... 29 | 30 | F->>C: PUSH system:bound-box 31 | C->>R: PUBLISH from:system:bound-box 32 | P->>R: SUBSCRIBE from:system:bound-box 33 | Note over P: Update client's visible area 34 | 35 | loop Every second 36 | Note over P: Filter aircraft by each client's bbox 37 | P->>R: PUBLISH to:streaming-client1:new-jet1090-data 38 | end 39 | 40 | C->>R: SUBSCRIBE to:streaming-client1:* 41 | C->>F: PUSH new-jet1090-data 42 | ``` 43 | 44 | - **Continuous Tracking**: It subscribes to the `jet1090` Redis channel to receive decoded aircraft messages. 45 | - **State Vector Maintenance**: It maintains a comprehensive in-memory view of the current air traffic situation by collecting and processing state vectors for all active aircraft. 46 | - **History Persistence**: It acts as a producer for the [`tangram_history` plugin](./history.md). It batches raw messages into arrow Recordbatches and sends them to a redis stream. The history service consumes this stream and persists the data into a delta lake table. 47 | - **Client-Specific Filtering**: The service listens for bounding box updates from each connected frontend client. It filters the aircraft data for each client, sending only the aircraft visible within their map view. 48 | - **Data Publishing**: Once per second, it publishes the filtered state vectors to a dedicated Redis channel for each client (e.g., `to:streaming-:new-jet1090-data`), which are then relayed to the browser via the WebSocket `channel` service. 49 | 50 | ## Redis Events 51 | 52 | | Direction | Channel | Event/Command | Payload | 53 | | :-------- | :----------------------------------- | :------------ | :----------------------------------------------------------------- | 54 | | Input | `jet1090` | `PUBLISH` | Raw JSON message from `jet1090`. | 55 | | Output | `to:streaming-{id}:new-jet1090-data` | `PUBLISH` | `{ "count": 123, "aircraft": [...] }` containing visible aircraft. | 56 | | Output | `history:ingest:jet1090` | `XADD` | Apache Arrow record batch (binary). | 57 | 58 | ## Trajectory API 59 | 60 | The plugin provides an API for querying the historical data persisted by the `planes` service. 61 | 62 | **Endpoint**: `GET /jet1090/data/{icao24}` 63 | 64 | Retrieves all historical data points for the given aircraft `icao24` by querying the delta lake table managed by the history service. 65 | 66 | This endpoint is used by the frontend to draw historical flight paths and populate data charts when an aircraft is selected. 67 | 68 | ## Configuration 69 | 70 | To use this plugin, you must have a running `jet1090` instance publishing data to Redis. You can also configure the plugin in your [tangram.toml](../configuration.md):: 71 | 72 | ```toml 73 | [core] 74 | plugins = ["tangram_jet1090", "tangram_history"] 75 | 76 | [plugins.tangram_jet1090] 77 | # the redis channel that jet1090 is publishing to. 78 | jet1090_channel = "jet1090" 79 | # how long (in seconds) to keep an aircraft in the state vector table 80 | # after its last message. 81 | state_vector_expire = 20 82 | 83 | # history persistence settings (requires `tangram_history` plugin) 84 | history_table_name = "jet1090" 85 | history_flush_interval_secs = 5 86 | history_buffer_size = 100000 87 | ``` 88 | 89 | See [tangram_jet1090.PlanesConfig] for more information. 90 | -------------------------------------------------------------------------------- /packages/tangram_history/rust/src/lib.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "client")] 2 | pub mod client; 3 | pub mod protocol; 4 | #[cfg(feature = "server")] 5 | mod service; 6 | 7 | #[cfg(feature = "server")] 8 | use service::{start_ingest_service, IngestConfig}; 9 | 10 | #[cfg(all(feature = "pyo3", feature = "server"))] 11 | use pyo3::exceptions::PyRuntimeError; 12 | #[cfg(feature = "pyo3")] 13 | use pyo3::{exceptions::PyOSError, prelude::*}; 14 | 15 | #[cfg(feature = "stubgen")] 16 | use pyo3_stub_gen::derive::*; 17 | 18 | #[cfg(feature = "pyo3")] 19 | use tracing_subscriber::{fmt, prelude::*, EnvFilter}; 20 | 21 | #[cfg(feature = "pyo3")] 22 | #[cfg_attr(feature = "stubgen", gen_stub_pyfunction)] 23 | #[pyfunction] 24 | fn init_tracing_stderr(filter_str: String) -> PyResult<()> { 25 | tracing_subscriber::registry() 26 | .with(EnvFilter::new(filter_str)) 27 | .with(fmt::layer().with_writer(std::io::stderr)) 28 | .try_init() 29 | .map_err(|e| PyOSError::new_err(e.to_string())) 30 | } 31 | 32 | #[cfg(feature = "server")] 33 | #[cfg(feature = "pyo3")] 34 | #[cfg_attr(feature = "stubgen", gen_stub_pyclass)] 35 | #[pyclass(get_all, set_all)] 36 | #[derive(Debug, Clone)] 37 | pub struct HistoryConfig { 38 | pub redis_url: String, 39 | pub control_channel: String, 40 | pub base_path: String, 41 | pub redis_read_count: usize, 42 | pub redis_read_block_ms: usize, 43 | } 44 | 45 | #[cfg(feature = "server")] 46 | #[cfg(feature = "pyo3")] 47 | #[cfg_attr(feature = "stubgen", gen_stub_pymethods)] 48 | #[pymethods] 49 | impl HistoryConfig { 50 | #[new] 51 | fn new( 52 | redis_url: String, 53 | control_channel: String, 54 | base_path: String, 55 | redis_read_count: usize, 56 | redis_read_block_ms: usize, 57 | ) -> Self { 58 | Self { 59 | redis_url, 60 | control_channel, 61 | base_path, 62 | redis_read_count, 63 | redis_read_block_ms, 64 | } 65 | } 66 | } 67 | 68 | // NOTE: right now we do a super convoluted way to get plugins to control 69 | // history. for each plugin, they: 70 | // 1. define *flattened* `history_*` in dataclasses AND rust structs 71 | // 2. which are then parsed by Pydantic TypeAdapter on the python side 72 | // 3. before manually constructing this struct in rust. 73 | // 74 | // one solution would be to directly expose this struct with `pyo3(signature)`, 75 | // but: 76 | // - it is unclear how to make pyo3-exposed objects compatible with pydantic 77 | // TypeAdapter: https://github.com/pydantic/pydantic/discussions/8854 78 | // - for some reason pyo3-stubgen has linking issues when generating the .pyi: 79 | // https://github.com/Jij-Inc/pyo3-stub-gen/issues/161 (not exact the same 80 | // but similar) 81 | // 82 | // so for sadly we have to accept manually duplicating `history_*` in all plugin 83 | // configs. 84 | #[derive(Debug, Clone)] 85 | pub struct HistoryProducerConfig { 86 | pub table_name: String, 87 | pub buffer_size: usize, 88 | pub flush_interval_secs: u64, 89 | pub optimize_interval_secs: u64, 90 | pub optimize_target_file_size: u64, 91 | pub vacuum_interval_secs: u64, 92 | pub vacuum_retention_period_secs: Option, 93 | } 94 | 95 | #[cfg(feature = "server")] 96 | async fn _run_service(config: IngestConfig) -> anyhow::Result<()> { 97 | start_ingest_service(config).await 98 | } 99 | 100 | #[cfg(feature = "server")] 101 | #[cfg(feature = "pyo3")] 102 | #[cfg_attr(feature = "stubgen", gen_stub_pyfunction)] 103 | #[pyfunction] 104 | fn run_history(py: Python<'_>, config: HistoryConfig) -> PyResult> { 105 | pyo3_async_runtimes::tokio::future_into_py(py, async move { 106 | let ingest_config = IngestConfig { 107 | redis_url: config.redis_url.clone(), 108 | control_channel: config.control_channel.clone(), 109 | base_path: config.base_path.clone(), 110 | redis_read_count: config.redis_read_count, 111 | redis_read_block_ms: config.redis_read_block_ms, 112 | }; 113 | _run_service(ingest_config) 114 | .await 115 | .map_err(|e| PyRuntimeError::new_err(e.to_string())) 116 | }) 117 | } 118 | 119 | #[cfg(feature = "pyo3")] 120 | #[pymodule] 121 | fn _history(m: &Bound<'_, PyModule>) -> PyResult<()> { 122 | m.add_function(wrap_pyfunction!(init_tracing_stderr, m)?)?; 123 | #[cfg(feature = "server")] 124 | { 125 | m.add_function(wrap_pyfunction!(run_history, m)?)?; 126 | m.add_class::()?; 127 | } 128 | Ok(()) 129 | } 130 | 131 | #[cfg(feature = "stubgen")] 132 | pub fn stub_info() -> pyo3_stub_gen::Result { 133 | let manifest_dir: &::std::path::Path = env!("CARGO_MANIFEST_DIR").as_ref(); 134 | let pyproject_path = manifest_dir.parent().unwrap().join("pyproject.toml"); 135 | pyo3_stub_gen::StubInfo::from_pyproject_toml(pyproject_path) 136 | } 137 | -------------------------------------------------------------------------------- /packages/tangram_ship162/src/tangram_ship162/__init__.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any 3 | 4 | import tangram_core 5 | from fastapi import APIRouter, HTTPException 6 | from fastapi.responses import Response 7 | from pydantic import TypeAdapter 8 | 9 | try: 10 | import polars as pl 11 | 12 | _HISTORY_AVAILABLE = True 13 | except ImportError: 14 | _HISTORY_AVAILABLE = False 15 | 16 | 17 | router = APIRouter( 18 | prefix="/ship162", 19 | tags=["ship162"], 20 | responses={404: {"description": "Not found"}}, 21 | ) 22 | 23 | 24 | @router.get("/data/{mmsi}") 25 | async def get_trajectory_data( 26 | mmsi: int, backend_state: tangram_core.InjectBackendState 27 | ) -> list[dict[str, Any]]: 28 | """Get the full trajectory for a given ship MMSI.""" 29 | if not _HISTORY_AVAILABLE: 30 | raise HTTPException( 31 | status_code=501, 32 | detail="History feature is not installed. " 33 | "Install with `pip install 'tangram_ship162[history]'`", 34 | ) 35 | 36 | redis_key = "tangram:history:table_uri:ship162" 37 | table_uri_bytes = await backend_state.redis_client.get(redis_key) 38 | 39 | if not table_uri_bytes: 40 | raise HTTPException( 41 | status_code=404, 42 | detail=( 43 | "Table 'ship162' not found.\nhelp: is the history service running?" 44 | ), 45 | ) 46 | table_uri = table_uri_bytes.decode("utf-8") 47 | 48 | try: 49 | df = ( 50 | pl.scan_delta(table_uri) 51 | .filter(pl.col("mmsi") == mmsi) 52 | .with_columns(pl.col("timestamp").dt.epoch(time_unit="s")) 53 | .sort("timestamp") 54 | .collect() 55 | ) 56 | return Response(df.write_json(), media_type="application/json") 57 | except Exception as e: 58 | raise HTTPException( 59 | status_code=500, detail=f"Failed to query trajectory data: {e}" 60 | ) 61 | 62 | 63 | @dataclass(frozen=True) 64 | class ShipsConfig( 65 | tangram_core.config.HasTopbarUiConfig, tangram_core.config.HasSidebarUiConfig 66 | ): 67 | ship162_channel: str = "ship162" 68 | history_table_name: str = "ship162" 69 | history_control_channel: str = "history:control" 70 | state_vector_expire: int = 600 # 10 minutes 71 | stream_interval_secs: float = 1.0 72 | log_level: str = "INFO" 73 | history_buffer_size: int = 100_000 74 | history_flush_interval_secs: int = 5 75 | history_optimize_interval_secs: int = 120 76 | history_optimize_target_file_size: int = 134217728 77 | history_vacuum_interval_secs: int = 120 78 | history_vacuum_retention_period_secs: int | None = 120 79 | topbar_order: int = 100 80 | sidebar_order: int = 100 81 | 82 | 83 | @dataclass(frozen=True) 84 | class FrontendShipsConfig( 85 | tangram_core.config.HasTopbarUiConfig, tangram_core.config.HasSidebarUiConfig 86 | ): 87 | topbar_order: int 88 | sidebar_order: int 89 | 90 | 91 | def transform_config(config_dict: dict[str, Any]) -> FrontendShipsConfig: 92 | config = TypeAdapter(ShipsConfig).validate_python(config_dict) 93 | return FrontendShipsConfig( 94 | topbar_order=config.topbar_order, 95 | sidebar_order=config.sidebar_order, 96 | ) 97 | 98 | 99 | plugin = tangram_core.Plugin( 100 | frontend_path="dist-frontend", 101 | routers=[router], 102 | into_frontend_config_function=transform_config, 103 | ) 104 | 105 | 106 | @plugin.register_service() 107 | async def run_ships(backend_state: tangram_core.BackendState) -> None: 108 | from . import _ships 109 | 110 | plugin_config = backend_state.config.plugins.get("tangram_ship162", {}) 111 | config_ships = TypeAdapter(ShipsConfig).validate_python(plugin_config) 112 | 113 | default_log_level = plugin_config.get( 114 | "log_level", backend_state.config.core.log_level 115 | ) 116 | 117 | _ships.init_tracing_stderr(default_log_level) 118 | 119 | rust_config = _ships.ShipsConfig( 120 | redis_url=backend_state.config.core.redis_url, 121 | ship162_channel=config_ships.ship162_channel, 122 | history_control_channel=config_ships.history_control_channel, 123 | state_vector_expire=config_ships.state_vector_expire, 124 | stream_interval_secs=config_ships.stream_interval_secs, 125 | history_table_name=config_ships.history_table_name, 126 | history_buffer_size=config_ships.history_buffer_size, 127 | history_flush_interval_secs=config_ships.history_flush_interval_secs, 128 | history_optimize_interval_secs=config_ships.history_optimize_interval_secs, 129 | history_optimize_target_file_size=config_ships.history_optimize_target_file_size, 130 | history_vacuum_interval_secs=config_ships.history_vacuum_interval_secs, 131 | history_vacuum_retention_period_secs=config_ships.history_vacuum_retention_period_secs, 132 | ) 133 | await _ships.run_ships(rust_config) 134 | -------------------------------------------------------------------------------- /packages/tangram_weather/src/tangram_weather/WindFieldLayer.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 152 | 153 | 178 | -------------------------------------------------------------------------------- /docs/architecture/index.md: -------------------------------------------------------------------------------- 1 | # Architecture of the tangram framework 2 | 3 | The `tangram` framework consists of a lightweight core and a suite of independent, installable plugins that can be combined to create a powerful and flexible aviation data processing and visualization system. 4 | 5 | The system consists of a web-based frontend (in Javascript :simple-javascript: based on [Vite](https://vite.dev/)), a backend service (in Python :simple-python:), and performance-critical components in Rust :simple-rust:. 6 | 7 | Communication between the frontend and backend is done through a **REST API**, while real-time data streaming is handled via **WebSockets**. A **Redis :simple-redis: pub/sub system** is used for efficient data distribution between backend components. 8 | 9 | ## A Plugin-First Architecture 10 | 11 | The core `tangram` package provides the essential scaffolding: a web server, a plugin loader, and a frontend API. All domain-specific functionality, including data decoding and processing, is implemented in separate, `pip`-installable plugins. 12 | 13 | This design allows you to: 14 | 15 | - install only the functionality you need. 16 | - develop, version, and distribute your own extensions (e.g., `my-simulation-plugin`) without modifying the core `tangram` codebase. 17 | 18 | | Component | Technology | 19 | | ------------------ | ----------------------------------------------------------------------- | 20 | | Frontend | :simple-javascript: JavaScript (Vue.js, Vite) | 21 | | Backend | :simple-python: Python for most applications (FastAPI for the REST API) | 22 | | | :simple-rust: Rust for performance critical components | 23 | | Data communication | :simple-redis: Redis (pub/sub messaging system) | 24 | 25 | ## System Overview 26 | 27 | When you run `tangram serve`, it starts a single Python process that manages multiple asynchronous tasks for the application's core components and enabled plugins. 28 | 29 | ```mermaid 30 | graph LR 31 | subgraph User 32 | B[Browser/Frontend] 33 | end 34 | 35 | subgraph "Tangram Process (Python)" 36 | direction TB 37 | FAS[FastAPI Server] 38 | CS[Channel Service] 39 | PS[Plugin Services e.g., planes] 40 | end 41 | 42 | subgraph "External Services" 43 | J[jet1090 Container] 44 | end 45 | 46 | R[Redis Pub/Sub] 47 | 48 | B -- HTTP API Requests --> FAS 49 | B <-- WebSocket --> CS 50 | FAS -- Serves Frontend Assets --> B 51 | FAS <-- Reads/Writes --> R 52 | CS <-- Relays Messages --> R 53 | PS -- ◀ Subscribes to --> R 54 | J -- Publishes ▶ --> R 55 | ``` 56 | 57 | 58 | 59 | 60 | | **Component** | **Provided By** | **Description** | 61 | | ------------------------- | ------------------------------------------------- | -------------------------------------------------------------- | 62 | | `tangram` (Core) | `tangram_core` package | REST API server, CLI, and frontend shell. | 63 | | [`channel`](./channel.md) | (Bundled with `tangram`) | WebSocket bridge between the frontend and Redis pub/sub. | 64 | | `jet1090` integration | [`tangram_jet1090` plugin](../plugins/jet1090.md) | Decodes Mode S/ADS-B messages and provides data streams. | 65 | | State Vectors & History | [`tangram_jet1090` plugin](../plugins/jet1090.md) | Maintains real-time state and stores historical aircraft data. | 66 | | System Info | [`tangram_system` plugin](../plugins/system.md) | Provides backend server metrics like CPU and memory usage. | 67 | | Weather Layers | [`tangram_weather` plugin](../plugins/weather.md) | Provides API endpoints for meteorological data. | 68 | 69 | ## Backend Plugin System 70 | 71 | The backend discovers plugins using Python's standard **[entry point mechanism](https://packaging.python.org/en/latest/specifications/entry-points/)**. When you `pip install tangram_jet1090`, it registers itself under the `tangram_core.plugins` group in its `pyproject.toml`. The core `tangram` application queries these groups at startup to find and load all available plugins, allowing them to add their own [API routes](../plugins/backend.md#adding-api-endpoints) and [background tasks](../plugins/backend.md#creating-background-services). 72 | 73 | For a detailed guide on creating your own backend extensions, see the [Backend Plugin Guide](../plugins/backend.md). 74 | 75 | ## Frontend Plugin System 76 | 77 | The frontend loads plugins dynamically. The backend serves a `/manifest.json` file listing all enabled frontend plugins. The core `tangram` web application fetches this manifest and dynamically imports the JavaScript entry point for each plugin. The plugin's entry point then calls the [`tangramApi.registerWidget()`](../plugins/frontend.md) function to add its Vue components to the main application. 78 | 79 | For more details, see the [Frontend Plugin Guide](../plugins/frontend.md). 80 | --------------------------------------------------------------------------------