air-status 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. air_status-0.1.0/LICENSE +21 -0
  2. air_status-0.1.0/PKG-INFO +121 -0
  3. air_status-0.1.0/README.md +95 -0
  4. air_status-0.1.0/pyproject.toml +49 -0
  5. air_status-0.1.0/setup.cfg +4 -0
  6. air_status-0.1.0/src/air_status/__init__.py +3 -0
  7. air_status-0.1.0/src/air_status/__main__.py +4 -0
  8. air_status-0.1.0/src/air_status/braille.py +70 -0
  9. air_status-0.1.0/src/air_status/cli.py +69 -0
  10. air_status-0.1.0/src/air_status/flight_map.py +206 -0
  11. air_status-0.1.0/src/air_status/flightlog.py +134 -0
  12. air_status-0.1.0/src/air_status/geo.py +40 -0
  13. air_status-0.1.0/src/air_status/models.py +54 -0
  14. air_status-0.1.0/src/air_status/providers/__init__.py +41 -0
  15. air_status-0.1.0/src/air_status/providers/aircanada.py +9 -0
  16. air_status-0.1.0/src/air_status/providers/american.py +179 -0
  17. air_status-0.1.0/src/air_status/providers/base.py +38 -0
  18. air_status-0.1.0/src/air_status/providers/gogo.py +111 -0
  19. air_status-0.1.0/src/air_status/tui.py +184 -0
  20. air_status-0.1.0/src/air_status.egg-info/PKG-INFO +121 -0
  21. air_status-0.1.0/src/air_status.egg-info/SOURCES.txt +27 -0
  22. air_status-0.1.0/src/air_status.egg-info/dependency_links.txt +1 -0
  23. air_status-0.1.0/src/air_status.egg-info/entry_points.txt +2 -0
  24. air_status-0.1.0/src/air_status.egg-info/requires.txt +7 -0
  25. air_status-0.1.0/src/air_status.egg-info/top_level.txt +1 -0
  26. air_status-0.1.0/tests/test_flight_map.py +37 -0
  27. air_status-0.1.0/tests/test_flightlog.py +71 -0
  28. air_status-0.1.0/tests/test_geo_and_map.py +58 -0
  29. air_status-0.1.0/tests/test_providers.py +215 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Max
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.
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.4
2
+ Name: air-status
3
+ Version: 0.1.0
4
+ Summary: Live in-flight status from airline Wi-Fi portals, with a terminal map and flight history
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/max2697/air-status
7
+ Project-URL: Issues, https://github.com/max2697/air-status/issues
8
+ Keywords: flight,in-flight wifi,terminal,tui,map,aviation
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: End Users/Desktop
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: Utilities
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: rich>=13
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=8; extra == "dev"
22
+ Requires-Dist: ruff>=0.6; extra == "dev"
23
+ Requires-Dist: build>=1; extra == "dev"
24
+ Requires-Dist: twine>=5; extra == "dev"
25
+ Dynamic: license-file
26
+
27
+ # air-status
28
+
29
+ Live flight status in the terminal while you're on board, read from the airline's
30
+ in-flight Wi-Fi portal: altitude, speed, ETA, a Braille map of the route, and a
31
+ saved history of every flight.
32
+
33
+ ![air-status live view: route bar, flight stats and a Braille map of the flown path](https://raw.githubusercontent.com/max2697/air-status/main/docs/screenshot.png)
34
+
35
+ <sub>Made-up flight. Regenerate with `.venv/bin/python scripts/screenshot.py`.</sub>
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pipx install air-status # or: pip install air-status
41
+ air-status
42
+ ```
43
+
44
+ From a checkout, for development:
45
+
46
+ ```bash
47
+ python3 -m venv .venv
48
+ .venv/bin/pip install -e '.[dev]'
49
+ ```
50
+
51
+ ## Use
52
+
53
+ ```bash
54
+ .venv/bin/air-status # live view, airline detected from the Wi-Fi, saves history
55
+ .venv/bin/air-status live -p aircanada --every 10
56
+ .venv/bin/air-status live -p american # American Airlines (Viasat aircraft)
57
+ .venv/bin/air-status history # list saved flights
58
+ ```
59
+
60
+ Run it in a real terminal for the live full-screen view. In a plain output window
61
+ (e.g. PyCharm's Run tab) it prints one frame per update instead.
62
+
63
+ Files go in the current folder (change with `--data-dir`):
64
+
65
+ - `history/<provider>/` — one file per flight, in that provider's own format.
66
+ Air Canada (Gogo) stores the raw portal data as change-only JSON Lines: the
67
+ first line is the full reading, later lines hold only the fields that changed.
68
+ Your device's IP/MAC address is never saved.
69
+ American Airlines stores the Viasat flight feed plus the route's airport
70
+ locations (looked up once from the portal) in the same change-only format.
71
+ Its feed has no vertical speed, so climb/descent is worked out from the
72
+ altitude change over the last minute (and saved too).
73
+ - `mapdata/` — map outlines from [Natural Earth](https://www.naturalearthdata.com/),
74
+ downloaded on first run.
75
+
76
+ The map draws the flown path from history (solid red). Where there is no history,
77
+ such as before recording started or during gaps over 60 s, it draws an estimated
78
+ shortest-path line between the known points (dashed red).
79
+
80
+ ## Adding an airline
81
+
82
+ Providers live in `src/air_status/providers/`. A provider:
83
+
84
+ - `fetch()` — gets a raw reading from its portal,
85
+ - `parse(reading)` — turns it into the shared `FlightStatus` (`models.py`),
86
+ - `history(directory)` — returns its `FlightHistory` (`flightlog.py`), which
87
+ decides how readings are written and read back. Reuse `ChangeLog` for
88
+ change-only JSON Lines, or implement your own format.
89
+
90
+ Auto-detect (the default, `-p auto`) asks every provider's portal at once via
91
+ `detect()` and uses the first that returns flight data. The default `detect()`
92
+ is "fetch and parse succeed"; override it when portals are shared (Gogo
93
+ providers also check `airline_code`).
94
+
95
+ Same Wi-Fi system as an existing provider (e.g. another Gogo/Intelsat airline):
96
+ subclass it and set `id`, `name` and `airline_code`, like `aircanada.py`. Then add an instance
97
+ to `PROVIDERS` in `providers/__init__.py`.
98
+
99
+ ## Tests and lint
100
+
101
+ ```bash
102
+ .venv/bin/pytest
103
+ .venv/bin/ruff check .
104
+ .venv/bin/ruff format .
105
+ ```
106
+
107
+ GitHub Actions (`.github/workflows/ci.yml`) runs the same lint plus tests on
108
+ Python 3.9–3.14 for every push to `main` and every pull request.
109
+
110
+ ## Releasing
111
+
112
+ 1. Bump `__version__` in `src/air_status/__init__.py` and commit.
113
+ 2. Tag and push: `git tag v0.2.0 && git push origin v0.2.0`.
114
+
115
+ `.github/workflows/release.yml` checks the tag matches the version, runs lint
116
+ and tests, builds the package, publishes it to PyPI (trusted publishing, no
117
+ token) and creates a GitHub release with the built files.
118
+
119
+ ## License
120
+
121
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,95 @@
1
+ # air-status
2
+
3
+ Live flight status in the terminal while you're on board, read from the airline's
4
+ in-flight Wi-Fi portal: altitude, speed, ETA, a Braille map of the route, and a
5
+ saved history of every flight.
6
+
7
+ ![air-status live view: route bar, flight stats and a Braille map of the flown path](https://raw.githubusercontent.com/max2697/air-status/main/docs/screenshot.png)
8
+
9
+ <sub>Made-up flight. Regenerate with `.venv/bin/python scripts/screenshot.py`.</sub>
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pipx install air-status # or: pip install air-status
15
+ air-status
16
+ ```
17
+
18
+ From a checkout, for development:
19
+
20
+ ```bash
21
+ python3 -m venv .venv
22
+ .venv/bin/pip install -e '.[dev]'
23
+ ```
24
+
25
+ ## Use
26
+
27
+ ```bash
28
+ .venv/bin/air-status # live view, airline detected from the Wi-Fi, saves history
29
+ .venv/bin/air-status live -p aircanada --every 10
30
+ .venv/bin/air-status live -p american # American Airlines (Viasat aircraft)
31
+ .venv/bin/air-status history # list saved flights
32
+ ```
33
+
34
+ Run it in a real terminal for the live full-screen view. In a plain output window
35
+ (e.g. PyCharm's Run tab) it prints one frame per update instead.
36
+
37
+ Files go in the current folder (change with `--data-dir`):
38
+
39
+ - `history/<provider>/` — one file per flight, in that provider's own format.
40
+ Air Canada (Gogo) stores the raw portal data as change-only JSON Lines: the
41
+ first line is the full reading, later lines hold only the fields that changed.
42
+ Your device's IP/MAC address is never saved.
43
+ American Airlines stores the Viasat flight feed plus the route's airport
44
+ locations (looked up once from the portal) in the same change-only format.
45
+ Its feed has no vertical speed, so climb/descent is worked out from the
46
+ altitude change over the last minute (and saved too).
47
+ - `mapdata/` — map outlines from [Natural Earth](https://www.naturalearthdata.com/),
48
+ downloaded on first run.
49
+
50
+ The map draws the flown path from history (solid red). Where there is no history,
51
+ such as before recording started or during gaps over 60 s, it draws an estimated
52
+ shortest-path line between the known points (dashed red).
53
+
54
+ ## Adding an airline
55
+
56
+ Providers live in `src/air_status/providers/`. A provider:
57
+
58
+ - `fetch()` — gets a raw reading from its portal,
59
+ - `parse(reading)` — turns it into the shared `FlightStatus` (`models.py`),
60
+ - `history(directory)` — returns its `FlightHistory` (`flightlog.py`), which
61
+ decides how readings are written and read back. Reuse `ChangeLog` for
62
+ change-only JSON Lines, or implement your own format.
63
+
64
+ Auto-detect (the default, `-p auto`) asks every provider's portal at once via
65
+ `detect()` and uses the first that returns flight data. The default `detect()`
66
+ is "fetch and parse succeed"; override it when portals are shared (Gogo
67
+ providers also check `airline_code`).
68
+
69
+ Same Wi-Fi system as an existing provider (e.g. another Gogo/Intelsat airline):
70
+ subclass it and set `id`, `name` and `airline_code`, like `aircanada.py`. Then add an instance
71
+ to `PROVIDERS` in `providers/__init__.py`.
72
+
73
+ ## Tests and lint
74
+
75
+ ```bash
76
+ .venv/bin/pytest
77
+ .venv/bin/ruff check .
78
+ .venv/bin/ruff format .
79
+ ```
80
+
81
+ GitHub Actions (`.github/workflows/ci.yml`) runs the same lint plus tests on
82
+ Python 3.9–3.14 for every push to `main` and every pull request.
83
+
84
+ ## Releasing
85
+
86
+ 1. Bump `__version__` in `src/air_status/__init__.py` and commit.
87
+ 2. Tag and push: `git tag v0.2.0 && git push origin v0.2.0`.
88
+
89
+ `.github/workflows/release.yml` checks the tag matches the version, runs lint
90
+ and tests, builds the package, publishes it to PyPI (trusted publishing, no
91
+ token) and creates a GitHub release with the built files.
92
+
93
+ ## License
94
+
95
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "air-status"
7
+ dynamic = ["version"]
8
+ description = "Live in-flight status from airline Wi-Fi portals, with a terminal map and flight history"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ dependencies = ["rich>=13"]
14
+ keywords = ["flight", "in-flight wifi", "terminal", "tui", "map", "aviation"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Console",
18
+ "Intended Audience :: End Users/Desktop",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3 :: Only",
22
+ "Topic :: Utilities",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/max2697/air-status"
27
+ Issues = "https://github.com/max2697/air-status/issues"
28
+
29
+ [project.optional-dependencies]
30
+ dev = ["pytest>=8", "ruff>=0.6", "build>=1", "twine>=5"]
31
+
32
+ [project.scripts]
33
+ air-status = "air_status.cli:main"
34
+
35
+ [tool.setuptools.dynamic]
36
+ version = { attr = "air_status.__version__" }
37
+
38
+ [tool.setuptools.packages.find]
39
+ where = ["src"]
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]
43
+
44
+ [tool.ruff]
45
+ line-length = 120
46
+ target-version = "py39"
47
+
48
+ [tool.ruff.lint]
49
+ select = ["E", "F", "W", "I", "B", "UP"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Live in-flight status from airline Wi-Fi portals, with a terminal map and flight history."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from air_status.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,70 @@
1
+ """Braille-dot canvas: each terminal cell holds a 2x4 grid of dots."""
2
+
3
+ from typing import Optional
4
+
5
+ from rich.text import Text
6
+
7
+ # Braille dot bit for (x, y) inside a 2x4 cell
8
+ _BITS = ((0x01, 0x02, 0x04, 0x40), (0x08, 0x10, 0x20, 0x80))
9
+
10
+
11
+ class Canvas:
12
+ """Draw dots and lines in dot coordinates (width*2 x height*4), render as rich Text."""
13
+
14
+ def __init__(self, width: int, height: int):
15
+ self.width, self.height = width, height
16
+ self.bits = [[0] * width for _ in range(height)]
17
+ self.style: list = [[None] * width for _ in range(height)]
18
+ self.prio = [[0] * width for _ in range(height)]
19
+ self.chars: dict[tuple[int, int], tuple[str, Optional[str]]] = {}
20
+
21
+ def copy(self) -> "Canvas":
22
+ c = Canvas(self.width, self.height)
23
+ c.bits = [row[:] for row in self.bits]
24
+ c.style = [row[:] for row in self.style]
25
+ c.prio = [row[:] for row in self.prio]
26
+ c.chars = dict(self.chars)
27
+ return c
28
+
29
+ def dot(self, x: float, y: float, style: Optional[str], prio: int = 0) -> None:
30
+ """Set one dot. When layers share a cell, the higher priority picks its colour."""
31
+ x, y = int(x), int(y)
32
+ if 0 <= x < self.width * 2 and 0 <= y < self.height * 4:
33
+ cx, cy = x // 2, y // 4
34
+ self.bits[cy][cx] |= _BITS[x % 2][y % 4]
35
+ if prio >= self.prio[cy][cx]:
36
+ self.style[cy][cx], self.prio[cy][cx] = style, prio
37
+
38
+ def line(self, x0: float, y0: float, x1: float, y1: float, style: Optional[str], prio: int = 0) -> None:
39
+ w, h = self.width * 2, self.height * 4
40
+ if (x0 < 0 and x1 < 0) or (y0 < 0 and y1 < 0) or (x0 >= w and x1 >= w) or (y0 >= h and y1 >= h):
41
+ return # fully off-screen
42
+ n = int(max(abs(x1 - x0), abs(y1 - y0))) + 1
43
+ for i in range(n + 1):
44
+ t = i / n
45
+ self.dot(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, style, prio)
46
+
47
+ def overlay(self, other: "Canvas") -> None:
48
+ """Replace this canvas's cells with every non-empty cell of `other`."""
49
+ for y in range(self.height):
50
+ for x in range(self.width):
51
+ if other.bits[y][x]:
52
+ self.bits[y][x], self.style[y][x] = other.bits[y][x], other.style[y][x]
53
+
54
+ def text(self, cx: int, cy: int, s: str, style: Optional[str]) -> None:
55
+ """Write plain characters in cell coordinates (labels, icons)."""
56
+ for i, ch in enumerate(s):
57
+ if 0 <= cx + i < self.width and 0 <= cy < self.height:
58
+ self.chars[(cx + i, cy)] = (ch, style)
59
+
60
+ def to_text(self) -> Text:
61
+ out = Text()
62
+ for y in range(self.height):
63
+ for x in range(self.width):
64
+ if (x, y) in self.chars:
65
+ out.append(*self.chars[(x, y)])
66
+ else:
67
+ out.append(chr(0x2800 + self.bits[y][x]), self.style[y][x])
68
+ if y < self.height - 1:
69
+ out.append("\n")
70
+ return out
@@ -0,0 +1,69 @@
1
+ """Command line: `air-status` (live view) and `air-status history`."""
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from rich.console import Console
8
+
9
+ from air_status import __version__
10
+ from air_status.flight_map import FlightMap
11
+ from air_status.providers import AUTO, PROVIDERS, Provider, detect_provider, get_provider
12
+ from air_status.tui import Poller, history_table, run_live
13
+
14
+
15
+ def build_parser() -> argparse.ArgumentParser:
16
+ parser = argparse.ArgumentParser(prog="air-status", description="Live in-flight status with map and history.")
17
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
18
+ parser.add_argument(
19
+ "--data-dir", type=Path, default=Path.cwd(), help="folder for history/ and mapdata/ (default: current folder)"
20
+ )
21
+ sub = parser.add_subparsers(dest="command")
22
+ live = sub.add_parser("live", help="live view, saves history (default)")
23
+ live.add_argument(
24
+ "-p",
25
+ "--provider",
26
+ choices=[AUTO, *sorted(PROVIDERS)],
27
+ default=AUTO,
28
+ help="airline / Wi-Fi system (default: auto, detected from the Wi-Fi you're on)",
29
+ )
30
+ live.add_argument(
31
+ "--every", type=int, metavar="SECONDS", help="refresh interval (default: the provider's own update rate)"
32
+ )
33
+ sub.add_parser("history", help="list saved flights")
34
+ return parser
35
+
36
+
37
+ def pick_provider(provider_id: str) -> Optional[Provider]:
38
+ if provider_id != AUTO:
39
+ return get_provider(provider_id)
40
+ print("Looking for an in-flight Wi-Fi portal…", flush=True)
41
+ provider = detect_provider()
42
+ if provider is None:
43
+ names = ", ".join(p.name for p in PROVIDERS.values())
44
+ print(f"No in-flight Wi-Fi portal found (tried {names}). Connect to the plane's Wi-Fi or pick one with -p.")
45
+ else:
46
+ print(f"Detected {provider.name}.", flush=True)
47
+ return provider
48
+
49
+
50
+ def main(argv: Optional[list[str]] = None) -> None:
51
+ args = build_parser().parse_args(argv)
52
+ history_dir = args.data_dir / "history"
53
+ try:
54
+ if args.command == "history":
55
+ table = history_table([p.history(history_dir / p.id) for p in PROVIDERS.values()])
56
+ if table is None:
57
+ print("No saved flights yet.")
58
+ else:
59
+ Console().print(table)
60
+ print(f"Files: {history_dir}")
61
+ else:
62
+ provider = pick_provider(getattr(args, "provider", AUTO))
63
+ if provider is None:
64
+ raise SystemExit(1)
65
+ every = getattr(args, "every", None) or provider.update_interval
66
+ poller = Poller(provider, provider.history(history_dir / provider.id), every)
67
+ run_live(poller, FlightMap(args.data_dir / "mapdata"))
68
+ except KeyboardInterrupt:
69
+ pass
@@ -0,0 +1,206 @@
1
+ """Terminal map of a flight: Natural Earth outlines, route and plane on a Braille canvas."""
2
+
3
+ import json
4
+ import math
5
+ import urllib.request
6
+ from collections.abc import Sequence
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from rich.text import Text
13
+
14
+ from air_status.braille import Canvas
15
+ from air_status.geo import LatLon, bearing_deg, distance_km, great_circle
16
+ from air_status.models import FlightStatus
17
+
18
+ SOURCE_URL = "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson/{}.geojson"
19
+ # (dataset, style, priority) — a higher priority wins the colour of a shared cell
20
+ LAYERS = (
21
+ ("ne_50m_admin_1_states_provinces_lines", "grey27", 1),
22
+ ("ne_50m_admin_0_boundary_lines_land", "grey58", 2),
23
+ ("ne_50m_lakes", "dodger_blue3", 3),
24
+ ("ne_50m_coastline", "dodger_blue3", 3),
25
+ )
26
+ MAX_LAKE_RANK = 2 # only big lakes; small ones are noise at this scale
27
+
28
+ Path_ = list[list[float]]
29
+
30
+
31
+ def _paths(coords) -> list[Path_]:
32
+ """Every [[lon, lat], ...] list inside any GeoJSON geometry."""
33
+ if coords and isinstance(coords[0][0], (int, float)):
34
+ return [coords]
35
+ return [p for c in coords for p in _paths(c)]
36
+
37
+
38
+ def load_layers(map_dir: Path) -> list[tuple[list[Path_], str, int]]:
39
+ """Load outlines, downloading each dataset once into map_dir."""
40
+ map_dir.mkdir(parents=True, exist_ok=True)
41
+ layers = []
42
+ for name, style, prio in LAYERS:
43
+ path = map_dir / f"{name}.geojson"
44
+ if not path.exists():
45
+ urllib.request.urlretrieve(SOURCE_URL.format(name), path)
46
+ features = json.loads(path.read_text())["features"]
47
+ if name == "ne_50m_lakes":
48
+ features = [f for f in features if f["properties"].get("scalerank", 9) <= MAX_LAKE_RANK]
49
+ layers.append(([p for f in features for p in _paths(f["geometry"]["coordinates"])], style, prio))
50
+ return layers
51
+
52
+
53
+ PLANE_ARROWS = "↑↗→↘↓↙←↖" # terminals can't rotate ✈, so the map points with an arrow
54
+
55
+
56
+ def plane_glyph(dx: float, dy: float) -> str:
57
+ """Arrow for a direction on screen (dy grows downwards)."""
58
+ if not dx and not dy:
59
+ return "✈"
60
+ angle = math.degrees(math.atan2(dx, -dy)) % 360 # clockwise from screen-up
61
+ return PLANE_ARROWS[round(angle / 45) % 8]
62
+
63
+
64
+ def heading_of(status: FlightStatus) -> float:
65
+ """Reported heading, else the direction towards the destination."""
66
+ if status.heading is not None:
67
+ return status.heading
68
+ return bearing_deg(status.latitude, status.longitude, status.destination.latitude, status.destination.longitude)
69
+
70
+
71
+ MAX_RECORDED_GAP = 60 # seconds; longer gaps between saved points are drawn as estimated
72
+
73
+
74
+ @dataclass
75
+ class Leg:
76
+ """Part of the flown path: `recorded` legs come from history, the rest are estimated."""
77
+
78
+ points: list[LatLon]
79
+ recorded: bool
80
+
81
+
82
+ def geoline(a: LatLon, b: LatLon, step_km: float = 20) -> list[LatLon]:
83
+ """Shortest path on the globe from a to b, one point per `step_km`."""
84
+ n = max(1, int(distance_km(*a, *b) / step_km))
85
+ return great_circle(*a, *b, n=n)
86
+
87
+
88
+ def flown_legs(status: FlightStatus, track: Sequence[FlightStatus] = ()) -> list[Leg]:
89
+ """Path from the origin to the current position.
90
+
91
+ Consecutive saved points less than MAX_RECORDED_GAP apart are joined as recorded;
92
+ everything else (origin → first point, gaps, no history at all) is a geoline
93
+ between the two known points, marked as estimated.
94
+ """
95
+ known: list[tuple[LatLon, Optional[datetime]]] = [((status.origin.latitude, status.origin.longitude), None)]
96
+ known += [((t.latitude, t.longitude), t.time) for t in track if t.time <= status.time]
97
+ if known[-1][1] != status.time:
98
+ known.append(((status.latitude, status.longitude), status.time))
99
+
100
+ legs: list[Leg] = []
101
+ for (a, ta), (b, tb) in zip(known, known[1:]):
102
+ recorded = ta is not None and tb is not None and (tb - ta).total_seconds() <= MAX_RECORDED_GAP
103
+ if recorded and legs and legs[-1].recorded:
104
+ legs[-1].points.append(b) # extend the current recorded run
105
+ elif recorded:
106
+ legs.append(Leg([a, b], True))
107
+ else:
108
+ legs.append(Leg(geoline(a, b), False))
109
+ return legs
110
+
111
+
112
+ class FlightMap:
113
+ """Draws the base map once per route and size, then overlays route and plane each frame."""
114
+
115
+ def __init__(self, map_dir: Path):
116
+ self.map_dir = map_dir
117
+ self._layers: Optional[list] = None
118
+ self._key: Optional[tuple] = None
119
+ self._base: Optional[Canvas] = None
120
+ self._route_key: Optional[tuple] = None
121
+ self._route: Optional[Canvas] = None
122
+
123
+ def _setup(self, status: FlightStatus, width: int, height: int) -> None:
124
+ if self._layers is None:
125
+ self._layers = load_layers(self.map_dir)
126
+ o, d = status.origin, status.destination
127
+ route = great_circle(o.latitude, o.longitude, d.latitude, d.longitude)
128
+ lats = [p[0] for p in route]
129
+ lons = [p[1] for p in route]
130
+ mid_lat = (max(lats) + min(lats)) / 2
131
+ mid_lon = (max(lons) + min(lons)) / 2
132
+ kx = math.cos(math.radians(mid_lat)) # squash longitude so shapes look right
133
+ pw, ph = width * 2, height * 4
134
+ span_x = (max(lons) - min(lons)) * kx * 1.3 + 1
135
+ span_y = (max(lats) - min(lats)) * 1.3 + 1
136
+ scale = min(pw / span_x, ph / span_y)
137
+
138
+ def project(lat: float, lon: float) -> tuple[float, float]:
139
+ return pw / 2 + (lon - mid_lon) * kx * scale, ph / 2 - (lat - mid_lat) * scale
140
+
141
+ self._project = project
142
+ base = Canvas(width, height)
143
+ for paths, style, prio in self._layers:
144
+ for path in paths:
145
+ pts = [project(lat, lon) for lon, lat, *_ in path]
146
+ for (x0, y0), (x1, y1) in zip(pts, pts[1:]):
147
+ base.line(x0, y0, x1, y1, style, prio)
148
+ self._base = base
149
+
150
+ def _draw_route(self, status: FlightStatus, track: Sequence[FlightStatus], width: int, height: int) -> Canvas:
151
+ project = self._project
152
+ here = (status.latitude, status.longitude)
153
+ dst = (status.destination.latitude, status.destination.longitude)
154
+ route = Canvas(width, height)
155
+
156
+ ahead = [project(*p) for p in geoline(here, dst)]
157
+ for i, ((x0, y0), (x1, y1)) in enumerate(zip(ahead, ahead[1:])):
158
+ if i % 2 == 0: # dashed remaining leg
159
+ route.line(x0, y0, x1, y1, "bright_white", 8)
160
+
161
+ for leg in flown_legs(status, track):
162
+ pts = [project(*p) for p in leg.points]
163
+ for i, ((x0, y0), (x1, y1)) in enumerate(zip(pts, pts[1:])):
164
+ if leg.recorded:
165
+ route.line(x0, y0, x1, y1, "bold red", 10)
166
+ elif i % 2 == 0: # dashed: estimated, not from history
167
+ route.line(x0, y0, x1, y1, "red3", 9)
168
+ return route
169
+
170
+ def render(self, status: FlightStatus, width: int, height: int, track: Sequence[FlightStatus] = ()) -> Text:
171
+ """Map for one status, `width` x `height` terminal cells.
172
+
173
+ `track` is this flight's saved history; without it the flown path is estimated.
174
+ """
175
+ dep_code, dst_code = status.origin.code, status.destination.code
176
+ key = (dep_code, dst_code, width, height)
177
+ if key != self._key:
178
+ self._setup(status, width, height)
179
+ self._key = key
180
+ route_key = (key, status.time, len(track))
181
+ if route_key != self._route_key: # redraw the route only when data changes
182
+ self._route = self._draw_route(status, track, width, height)
183
+ self._route_key = route_key
184
+ canvas = self._base.copy()
185
+ canvas.overlay(self._route) # route stays crisp over borders
186
+ project = self._project
187
+ here = (status.latitude, status.longitude)
188
+ dep = (status.origin.latitude, status.origin.longitude)
189
+ dst = (status.destination.latitude, status.destination.longitude)
190
+
191
+ for code, (lat, lon) in ((dep_code, dep), (dst_code, dst)):
192
+ x, y = project(lat, lon)
193
+ cx, cy = int(x) // 2, int(y) // 4
194
+ canvas.text(cx, cy, "●", "bold white")
195
+ label = f" {code} "
196
+ lx = cx + 2 if cx + 2 + len(label) <= width else cx - 1 - len(label)
197
+ canvas.text(lx, cy, label, "bold white on red")
198
+
199
+ x, y = project(*here)
200
+ # Step a little along the heading and see which way that goes on screen
201
+ h = math.radians(heading_of(status))
202
+ step = 0.1 # degrees
203
+ ahead_lon = status.longitude + step * math.sin(h) / max(0.01, math.cos(math.radians(status.latitude)))
204
+ ax, ay = project(status.latitude + step * math.cos(h), ahead_lon)
205
+ canvas.text(int(x) // 2, int(y) // 4, plane_glyph(ax - x, ay - y), "bold yellow")
206
+ return canvas.to_text()