From 22fa67fa9ae598145cadb64427e30fa667cdb33c Mon Sep 17 00:00:00 2001 From: Ola Lie Date: Mon, 29 Jun 2026 13:28:49 +0200 Subject: [PATCH] feat(map): improve navigation and camera behavior --- README.md | 5 +- src/tapmap/app.py | 88 ++- src/tapmap/assets/keyboard.js | 3 +- src/tapmap/assets/modebar.js | 45 ++ src/tapmap/assets/styles.css | 7 + src/tapmap/insights_persistence.py | 24 +- src/tapmap/state/daily_report.py | 2 +- src/tapmap/state/keyboard.py | 1 + src/tapmap/state/poll.py | 4 + src/tapmap/ui/country_centers.py | 257 -------- src/tapmap/ui/country_info.py | 993 +++++++++++++++++++++++++++++ src/tapmap/ui/help_view.py | 13 +- src/tapmap/ui/insights_view.py | 12 +- src/tapmap/ui/layout_view.py | 1 + src/tapmap/ui/map_view.py | 80 ++- 15 files changed, 1226 insertions(+), 309 deletions(-) create mode 100644 src/tapmap/assets/modebar.js delete mode 100644 src/tapmap/ui/country_centers.py create mode 100644 src/tapmap/ui/country_info.py diff --git a/README.md b/README.md index 4435c9f..0b96221 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ All data is collected locally on your machine. - Click map markers for detailed information - Open the menu in the upper-left corner to access Insights, network views, and application information - Open the Daily Activity Report with D -- Click countries in Insights to zoom to the location +- Click countries in Insights to zoom to a country --- @@ -217,6 +217,7 @@ Inspect connections that could not be geolocated and therefore do not appear on | C | Clear cache | | H | Help | | A | About | +| Z | Fit Connections | | ESC | Close window | --- @@ -376,3 +377,5 @@ Thanks to @desrod for suggesting a solution for configurable port support. Thanks to @hugalafutro for suggesting optional SYS_PTRACE support for process visibility on Linux. Thanks to @mad-tunes for suggesting optional browser launch control when running TapMap as a service, and configurable cache retention to reduce map clutter on busy systems. + +Thanks to @forthrin for detailed first-use feedback and suggestions that improved map navigation and overall usability. diff --git a/src/tapmap/app.py b/src/tapmap/app.py index 7d50f8a..476d843 100644 --- a/src/tapmap/app.py +++ b/src/tapmap/app.py @@ -1,6 +1,6 @@ """Dash application entry point for TapMap. -This module wires together: +This module wires together the main application layers: - model: runtime state and data collection (snapshot, GeoIP reload, caches) - geodb: GeoIP database installation, updates, validation, and status @@ -60,6 +60,7 @@ ACTION_CACHE_TERMINAL, ACTION_CLEAR_CACHE, ACTION_NORMAL_POLL, + ACTION_ZOOM_CONNECTIONS, decide_poll_action, ) from tapmap.state.status_cache import StatusCache @@ -702,7 +703,6 @@ def poll_model( snap, cache, sc_store, view, flash = self._handle_clear_cache(status_cache) return snap, cache, sc_store, view, flash - if decision.action == ACTION_NORMAL_POLL: snap, cache, sc_store, view, _flash = self._handle_normal_poll( tick_n, status_cache, ui_cache @@ -998,9 +998,11 @@ def _register_map_callbacks(self) -> None: Output("map", "figure"), Input("ui_view", "data"), Input("selected_country", "data"), + Input("camera_mode", "data"), ) - def render_map(ui_view: Any, selected_country: Any) -> Any: + def render_map(ui_view: Any, selected_country: Any, camera_mode: Any) -> Any: view = self._ensure_dict(ui_view) + fit_connections = (camera_mode == "connections") if "points" not in view: return no_update @@ -1013,16 +1015,20 @@ def render_map(ui_view: Any, selected_country: Any) -> Any: ([], self.my_location), summaries=summaries, selected_country=selected_country, + fit_connections=fit_connections, ) return self.ui.create_figure( (points, self.my_location), summaries=summaries, selected_country=selected_country, + fit_connections=fit_connections, ) @self.app.callback( Output("selected_country", "data"), + Output("camera_mode", "data"), + Input("key_action", "data"), Input( { "type": "insights-country", @@ -1032,31 +1038,53 @@ def render_map(ui_view: Any, selected_country: Any) -> Any: "n_clicks", ), State("selected_country", "data"), + State("camera_mode", "data"), prevent_initial_call=True, ) - def select_insight(_clicks_country: list[int], current: Any) -> Any: + def update_map_state( + key_action: Any, + _clicks_country: list[int], + current_country: Any, + current_camera_mode: Any, + ) -> tuple[Any, Any]: + + trigger_id = ctx.triggered_id + + # Keyboard + if trigger_id == "key_action": + if ( + isinstance(key_action, dict) + and key_action.get("action") == ACTION_ZOOM_CONNECTIONS + ): + if current_camera_mode == "connections": + return None, None + + return None, "connections" + + return no_update, no_update + + # Country selection + current = current_country if isinstance(current_country, str) else None + if not ctx.triggered: - return no_update + return no_update, no_update trigger = ctx.triggered[0] - if not trigger["value"]: - return no_update - - trigger_id = ctx.triggered_id + return no_update, no_update if not isinstance(trigger_id, dict): - return no_update + return no_update, no_update country_code = trigger_id.get("country_code") if not isinstance(country_code, str): - return no_update + return no_update, no_update if current == country_code: - return None - - return country_code + return None, None + return country_code, None + def _register_status_callbacks(self) -> None: @self.app.callback( Output("status_bar", "children"), @@ -1081,12 +1109,17 @@ def _register_insights_callbacks(self) -> None: @self.app.callback( Output("insights_panel", "children"), Input("insights_cache", "data"), + Input("selected_country", "data"), ) - def update_insights(data: dict[str, Any] | None) -> list[Any]: + def update_insights( + data: dict[str, Any] | None, + selected_country: Any, + ) -> list[Any]: + if not isinstance(data, dict): return [] - return render_insights_panel(data) + return render_insights_panel(data, selected_country=selected_country) @self.app.callback( Output("insights_cache", "data"), @@ -1137,8 +1170,8 @@ def _maybe_save_insights(self) -> None: now = datetime.now().timestamp() if now - self._last_insights_save >= 60: - self._save_insights() self._last_insights_save = now + self._save_insights() def _save_insights(self) -> None: """Write insights data to disk atomically (delegated).""" @@ -1158,19 +1191,22 @@ def _acquire_lock(self) -> None: try: lock = json.loads(self._lock_path.read_text(encoding="utf-8")) - running_process = psutil.Process(int(lock["pid"])) + if ( + isinstance(lock, dict) + and "pid" in lock + and "create_time" in lock + ): + running_process = psutil.Process(int(lock["pid"])) - if running_process.create_time() == float(lock["create_time"]): - self.logger.warning( - "Another TapMap instance is already running (PID %d). Exiting.", - running_process.pid, - ) - sys.exit(1) + if running_process.create_time() == float(lock["create_time"]): + self.logger.warning( + "Another TapMap instance is already running (PID %d). Exiting.", + running_process.pid, + ) + sys.exit(1) except ( OSError, - ValueError, - KeyError, json.JSONDecodeError, psutil.NoSuchProcess, ): diff --git a/src/tapmap/assets/keyboard.js b/src/tapmap/assets/keyboard.js index 7f351dc..e0e2c41 100644 --- a/src/tapmap/assets/keyboard.js +++ b/src/tapmap/assets/keyboard.js @@ -12,6 +12,7 @@ "c", "h", "a", + "z", ]); function isTypingTarget(el) { @@ -66,7 +67,7 @@ setter.call(el, value); } - function sendToken(token) { + window.sendToken = function (token) { const inp = document.getElementById("key_capture"); if (!inp) return; diff --git a/src/tapmap/assets/modebar.js b/src/tapmap/assets/modebar.js new file mode 100644 index 0000000..7f54e06 --- /dev/null +++ b/src/tapmap/assets/modebar.js @@ -0,0 +1,45 @@ +/* Plotly modebar custom buttons and actions. */ + +// Dash creates the Plotly modebar after page load. +// Wait until it exists before inserting the custom button. +const observer = new MutationObserver(() => { + const groups = document.querySelectorAll(".modebar-group"); + + if (groups.length < 3) { + return; + } + + // Already added. + if (document.querySelector('[aria-label="Fit Connections"]')) { + observer.disconnect(); + return; + } + + const fitButton = groups[2].lastElementChild.cloneNode(true); + + fitButton.setAttribute("data-title", "Fit Connections"); + fitButton.setAttribute("aria-label", "Fit Connections"); + + // Replace the Reset icon. + fitButton.querySelector("svg").outerHTML = ` + + + +`; + + fitButton.addEventListener("click", () => { + window.sendToken("__z__"); + }); + + // Insert before Zoom In. + groups[2].insertBefore(fitButton, groups[2].firstChild); + + observer.disconnect(); +}); + +observer.observe(document.body, { + childList: true, + subtree: true, +}); diff --git a/src/tapmap/assets/styles.css b/src/tapmap/assets/styles.css index 74c453b..b1e67b4 100644 --- a/src/tapmap/assets/styles.css +++ b/src/tapmap/assets/styles.css @@ -345,6 +345,13 @@ summary.mx-acc-header:hover { opacity: 0.8; } +.insights-row.selected { + border-left: 4px solid var(--mx-green); + padding-left: 6px; + color: white; + font-weight: 700; +} + /* Cells */ .insights-flag { flex: 0 0 21px; diff --git a/src/tapmap/insights_persistence.py b/src/tapmap/insights_persistence.py index 8b3c1d8..dbe5946 100644 --- a/src/tapmap/insights_persistence.py +++ b/src/tapmap/insights_persistence.py @@ -1,11 +1,15 @@ """Persistence and orchestration helpers for insights data.""" import json +import logging +import time from pathlib import Path from typing import Any from tapmap.state.daily_report import DailyReportData, build_report_data +logger = logging.getLogger(__name__) + def load_insights(path: Path) -> dict[str, Any]: """Load insights from a JSON file, restoring historical contract. @@ -27,8 +31,7 @@ def load_insights(path: Path) -> dict[str, Any]: if not isinstance(insights, dict): raise ValueError("insights is not a dict") normalized = { - k: dict(insights[k]) if isinstance(insights.get(k), dict) else {} - for k in expected_keys + k: dict(insights[k]) if isinstance(insights.get(k), dict) else {} for k in expected_keys } return normalized except Exception: @@ -56,7 +59,22 @@ def save_insights(path: Path, data: dict[str, Any]) -> None: ) f.flush() - tmp_path.replace(path) + # The temporary file has been closed. Retry the atomic replace in + # case another process briefly locks the destination file. + for attempt in range(6): + try: + tmp_path.replace(path) + return + + except OSError: + if attempt == 5: + raise + + logger.info( + "insights.json temporarily locked; retrying in 1 second (%d/5)...", + attempt + 1, + ) + time.sleep(1) def build_daily_report(insights: dict[str, Any]) -> DailyReportData: diff --git a/src/tapmap/state/daily_report.py b/src/tapmap/state/daily_report.py index 0c7a30e..b966135 100644 --- a/src/tapmap/state/daily_report.py +++ b/src/tapmap/state/daily_report.py @@ -439,7 +439,7 @@ def _build_country_map_points( country_state: dict[str, dict[str, Any]], ) -> list[CountryMapPoint]: """Return map point data for all observed countries with known centroids.""" - from tapmap.ui.country_centers import get_center # deferred to avoid import cycle + from tapmap.ui.country_info import get_center # deferred to avoid import cycle points: list[CountryMapPoint] = [] for code, item in country_state.items(): diff --git a/src/tapmap/state/keyboard.py b/src/tapmap/state/keyboard.py index fae1913..cd83f46 100644 --- a/src/tapmap/state/keyboard.py +++ b/src/tapmap/state/keyboard.py @@ -20,6 +20,7 @@ "__c__": "menu_clear_cache", "__h__": "menu_help", "__a__": "menu_about", + "__z__": "zoom_connections", "__esc__": "escape", } diff --git a/src/tapmap/state/poll.py b/src/tapmap/state/poll.py index 7fd572d..97e7b8c 100644 --- a/src/tapmap/state/poll.py +++ b/src/tapmap/state/poll.py @@ -16,6 +16,8 @@ ACTION_CLEAR_CACHE = "clear_cache" ACTION_CACHE_TERMINAL = "cache_terminal" ACTION_NORMAL_POLL = "normal_poll" +ACTION_ZOOM_CONNECTIONS = "zoom_connections" + @dataclass(frozen=True) class PollDecision: """Describe which poll action to execute.""" @@ -48,5 +50,7 @@ def decide_poll_action(*, trigger: Any, key_action: Any) -> PollDecision: return PollDecision(action=ACTION_CLEAR_CACHE) if action == "menu_cache_terminal": return PollDecision(action=ACTION_CACHE_TERMINAL) + if action == "zoom_connections": + return PollDecision(action=ACTION_ZOOM_CONNECTIONS) return PollDecision(action=ACTION_NORMAL_POLL) diff --git a/src/tapmap/ui/country_centers.py b/src/tapmap/ui/country_centers.py deleted file mode 100644 index 3fb8474..0000000 --- a/src/tapmap/ui/country_centers.py +++ /dev/null @@ -1,257 +0,0 @@ -"""Country centroid coordinates. - -Source: https://github.com/gavinr/world-countries-centroids -License: MIT (Copyright (c) 2021 Gavin Rehkemper) -""" - -def get_center(country_code: str) -> tuple[float, float]: - """Return (lat, lon) for ISO country code.""" - lon, lat = COUNTRY_CENTERS[country_code] - return lat, lon - -COUNTRY_CENTERS: dict[str, tuple[float, float]] = { - "AS": (-170.700732, -14.305712), - "UM": (166.638003, 19.302046), - "CK": (-159.787689, -21.222613), - "PF": (-149.400417, -17.674684), - "NU": (-169.868781, -19.052309), - "PN": (-128.314985, -24.366122), - "WS": (-172.441077, -13.634253), - "TK": (-171.852660, -9.195175), - "TO": (-175.204159, -21.159272), - "WF": (-178.127356, -14.283442), - "SV": (-88.859115, 13.758042), - "GT": (-90.312193, 15.820879), - "MX": (-101.553997, 23.874361), - "CA": (-98.416805, 57.550480), - "AR": (-64.532385, -35.697271), - "FK": (-58.746646, -51.759013), - "CL": (-70.768634, -37.829383), - "EC": (-78.463033, -1.564272), - "PE": (-74.114162, -8.522718), - "BO": (-64.452096, -16.731249), - "BR": (-54.355207, -11.524630), - "PY": (-58.389064, -23.421906), - "UY": (-56.019195, -32.781950), - "GS": (-36.775096, -54.376664), - "AQ": (-177.564516, -77.169875), - "FJ": (177.981446, -17.822471), - "SH": (-5.717392, -15.962963), - "AI": (-63.060083, 18.222874), - "AG": (-61.785308, 17.071468), - "AW": (-69.975640, 12.515626), - "BS": (-78.072754, 24.721626), - "BB": (-59.557384, 13.183219), - "BZ": (-88.682735, 17.242525), - "BM": (-64.745850, 32.315067), - "BQ": (-64.836377, 15.768421), - "VG": (-64.624065, 18.421958), - "KY": (-81.252032, 19.311232), - "CO": (-72.644507, 4.187754), - "CR": (-84.146736, 9.863467), - "CU": (-79.698179, 21.476177), - "CW": (-68.969398, 12.199997), - "DM": (-61.360472, 15.429270), - "DO": (-70.434952, 18.779548), - "GF": (-53.322323, 3.857430), - "GD": (-61.679379, 12.112927), - "GP": (-61.543823, 16.244200), - "GY": (-58.913526, 4.682120), - "HT": (-72.892914, 18.883520), - "HN": (-86.492517, 14.740371), - "JM": (-77.303589, 18.122079), - "MQ": (-61.014324, 14.642697), - "MS": (-62.186933, 16.735363), - "NI": (-85.016088, 12.893567), - "PA": (-80.144288, 8.439537), - "PR": (-66.494253, 18.216224), - "BL": (-62.830516, 17.905617), - "KN": (-62.745604, 17.314736), - "LC": (-60.968951, 13.895749), - "MF": (-63.066785, 18.078012), - "PM": (-56.324654, 46.951539), - "VC": (-61.193766, 13.254808), - "SX": (-63.068831, 18.039426), - "SR": (-55.855514, 4.098724), - "TT": (-61.372366, 10.415516), - "TC": (-71.740589, 21.799865), - "VI": (-64.761553, 17.738010), - "VE": (-66.364921, 7.148325), - "BF": (-1.693282, 12.108709), - "CV": (-23.634010, 15.076412), - "CI": (-5.571710, 7.536779), - "GM": (-15.383380, 13.428618), - "GH": (-1.219233, 7.945305), - "GI": (-5.345549, 36.140227), - "GN": (-10.986949, 10.255987), - "GW": (-14.980187, 11.980075), - "LR": (-9.258989, 6.520130), - "ML": (-4.346400, 17.168146), - "MR": (-10.495079, 20.466731), - "MA": (-8.817213, 28.687598), - "PT": (-7.933662, 39.675292), - "SN": (-14.610875, 14.228861), - "SL": (-11.786567, 8.561330), - "GL": (-42.075678, 74.168472), - "GG": (-2.576393, 49.458708), - "IE": (-8.241129, 53.304895), - "IM": (-4.532995, 54.228553), - "JE": (-2.129160, 49.215397), - "GB": (-2.852944, 53.978447), - "IS": (-19.056830, 65.123609), - "FO": (-6.981106, 62.130896), - "SJ": (16.036379, 78.573189), - "BV": (3.411969, -54.423167), - "NZ": (170.690355, -43.827654), - "AO": (17.651769, -12.167424), - "BW": (23.857800, -22.236609), - "BI": (29.885182, -3.261252), - "KM": (43.348266, -11.658861), - "CG": (14.879733, -0.729439), - "CD": (23.419828, -3.338630), - "GA": (11.839411, -0.628448), - "KE": (37.953094, 0.689918), - "LS": (28.244753, -29.601681), - "MW": (34.234412, -13.128986), - "TF": (53.193855, -25.964671), - "MZ": (35.208577, -17.525230), - "NA": (18.164513, -21.908582), - "RW": (29.919440, -2.014687), - "ST": (6.606159, 0.227447), - "ZA": (24.752527, -28.553619), - "SZ": (31.510686, -26.562541), - "TZ": (34.818322, -6.355794), - "ZM": (27.755214, -13.162833), - "ZW": (29.717830, -18.927001), - "IO": (72.435016, -7.323548), - "HM": (73.492986, -53.084170), - "MG": (46.684935, -19.041636), - "MU": (57.564157, -20.281423), - "YT": (45.128142, -12.824468), - "RE": (55.543935, -21.119825), - "SC": (55.472508, -4.660002), - "DZ": (2.655846, 28.350970), - "BJ": (2.305715, 9.503013), - "CM": (12.948474, 6.294168), - "CF": (20.520743, 6.331390), - "TD": (18.427114, 15.283494), - "GQ": (10.425457, 1.595464), - "KI": (-157.390242, 1.867667), - "LY": (17.911334, 27.202916), - "MT": (14.441922, 35.890523), - "NE": (8.868632, 17.081054), - "NG": (8.147715, 9.610294), - "TG": (0.899086, 8.660743), - "TN": (9.655876, 34.086362), - "CY": (33.375346, 35.117004), - "DJ": (42.613497, 11.750236), - "EG": (30.240135, 26.605170), - "ER": (39.267240, 15.005533), - "ET": (39.914903, 8.729390), - "GR": (23.110369, 39.420123), - "IQ": (43.832529, 33.105076), - "IL": (35.027923, 31.513542), - "JO": (36.957289, 31.387065), - "LB": (35.896519, 33.911602), - "PS": (35.242512, 31.930819), - "SS": (30.385186, 7.657782), - "SD": (29.951458, 15.670602), - "SY": (38.511732, 35.097511), - "TR": (35.568868, 38.932074), - "UG": (32.343718, 1.282173), - "AD": (1.580224, 42.548598), - "US": (-96.331617, 38.820809), - "FR": (2.194024, 46.642368), - "LI": (9.547675, 47.146276), - "MC": (7.412821, 43.747982), - "CH": (8.286929, 46.736781), - "BE": (4.675010, 50.618214), - "DE": (10.426171, 51.083045), - "LU": (6.103230, 49.775235), - "NL": (5.554136, 52.134054), - "AL": (20.061083, 41.141659), - "AT": (13.797778, 47.631858), - "BA": (17.834672, 44.144154), - "HR": (16.625761, 44.911921), - "CZ": (15.383273, 49.749174), - "DK": (9.378671, 56.001188), - "HU": (19.396200, 47.225273), - "IT": (12.763657, 42.982011), - "ME": (19.295051, 42.736948), - "PL": (19.435733, 52.068481), - "SM": (12.461278, 43.942821), - "RS": (20.856774, 44.026799), - "SK": (19.581015, 48.698084), - "SI": (14.890637, 46.137592), - "MK": (21.709989, 41.594029), - "VA": (12.451313, 41.904024), - "NO": (16.670259, 64.977759), - "SE": (17.062432, 62.734210), - "BY": (27.964252, 53.467914), - "BG": (25.251739, 42.820437), - "EE": (25.916870, 58.648108), - "FI": (25.657384, 65.015790), - "GE": (43.378867, 42.179863), - "LV": (24.693671, 56.813853), - "LT": (23.946022, 55.294374), - "MD": (28.391112, 47.072567), - "RO": (25.094158, 45.824549), - "UA": (31.273772, 48.657533), - "AF": (66.592161, 34.134026), - "BH": (50.540695, 26.047985), - "IN": (81.173004, 23.586301), - "IR": (54.237077, 32.906024), - "KW": (47.563111, 29.281361), - "MV": (73.100762, -0.606558), - "NP": (84.133890, 28.300921), - "OM": (55.841088, 20.724283), - "PK": (69.088351, 30.116188), - "QA": (51.197949, 25.318528), - "SA": (44.600958, 24.136038), - "SO": (45.400379, 6.524535), - "LK": (80.669312, 7.696631), - "TJ": (70.942153, 38.569331), - "TM": (58.457736, 39.060691), - "AE": (54.279205, 24.182503), - "YE": (47.468158, 16.001393), - "AM": (45.054908, 40.178413), - "AZ": (48.634593, 40.392051), - "KZ": (66.375919, 47.641465), - "KG": (74.175329, 41.356989), - "UZ": (63.854830, 41.487907), - "CX": (105.702095, -10.446441), - "CC": (96.836888, -12.171249), - "ID": (113.965382, 0.155920), - "TL": (125.950240, -8.809895), - "AU": (134.022772, -25.697338), - "NR": (166.929376, -0.522102), - "NC": (165.507670, -21.330034), - "NF": (167.952596, -29.037654), - "PG": (144.834894, -7.156913), - "SB": (160.164758, -9.613105), - "TV": (179.217834, -8.514702), - "VU": (166.849127, -15.189132), - "KH": (105.039731, 12.699187), - "LA": (103.763759, 18.117283), - "MY": (114.633303, 3.671661), - "MM": (97.088923, 19.901228), - "SG": (103.810258, 1.352825), - "TH": (101.086751, 13.662228), - "VN": (105.913388, 16.517347), - "BD": (90.432126, 23.673729), - "BT": (90.467166, 27.421639), - "CN": (104.691139, 38.073255), - "BN": (114.643096, 4.543206), - "PH": (121.822089, 15.586542), - "KR": (127.762246, 36.402387), - "MN": (103.398736, 47.086445), - "KP": (127.337981, 40.191981), - "GU": (144.780245, 13.445430), - "JP": (137.469342, 36.767388), - "MH": (168.720160, 7.307930), - "FM": (158.229190, 6.878945), - "MP": (145.741197, 15.178064), - "PW": (134.579651, 7.534776), - "RU": (98.670499, 59.039434), - "ES": (-3.651625, 40.365008), -} diff --git a/src/tapmap/ui/country_info.py b/src/tapmap/ui/country_info.py new file mode 100644 index 0000000..71d2a7c --- /dev/null +++ b/src/tapmap/ui/country_info.py @@ -0,0 +1,993 @@ +"""Country centers and bounds. + +Generated automatically from ArcGIS World Countries (Generalized) polygons. +Do not edit manually. +""" + +def get_center(country_code: str) -> tuple[float, float]: + """Return the country center as (latitude, longitude).""" + return COUNTRY_INFO[country_code]["center"] + + +def get_bounds(country_code: str) -> tuple[float, float, float, float]: + """Return (min_lon, max_lon, min_lat, max_lat) for the country.""" + return COUNTRY_INFO[country_code]["bounds"] + +COUNTRY_INFO = { +"AD": { + "center": (42.548598, 1.580224), + "bounds": (1.421391, 1.781718, 42.436382, 42.655964), + }, + "AE": { + "center": (24.182503, 54.279205), + "bounds": (51.583327, 56.381663, 22.633327, 26.083882), + }, + "AF": { + "center": (34.134026, 66.592161), + "bounds": (60.504163, 74.915736, 29.406109, 38.471982), + }, + "AG": { + "center": (17.071468, -61.785308), + "bounds": (-61.891109, -61.666946, 16.989718, 17.165554), + }, + "AI": { + "center": (18.222874, -63.060083), + "bounds": (-63.167782, -62.972709, 18.164445, 18.272982), + }, + "AL": { + "center": (41.141659, 20.061083), + "bounds": (19.288536, 21.053327, 39.645000, 42.660345), + }, + "AM": { + "center": (40.178413, 45.054908), + "bounds": (43.454163, 46.620536, 38.841145, 41.297054), + }, + "AO": { + "center": (-12.167424, 17.651769), + "bounds": (11.731245, 24.084445, -18.016391, -5.836391), + }, + "AQ": { + "center": (-77.169875, -177.564516), + "bounds": (-360.000000, -0.000000, -89.000000, -63.205418), + }, + "AR": { + "center": (-35.697271, -64.532385), + "bounds": (-73.582300, -53.650009, -52.377782, -21.780518), + }, + "AS": { + "center": (-14.305712, -170.700732), + "bounds": (-170.823227, -170.561873, -14.375555, -14.254309), + }, + "AT": { + "center": (47.631858, 13.797778), + "bounds": (9.533573, 17.166382, 46.407491, 49.018745), + }, + "AU": { + "center": (-25.697338, 134.022772), + "bounds": (113.224427, 153.624191, -39.147227, -10.706664), + }, + "AW": { + "center": (12.515626, -69.975640), + "bounds": (-70.059664, -69.874864, 12.411109, 12.627773), + }, + "AZ": { + "center": (40.392051, 48.634593), + "bounds": (45.022945, 51.677009, 38.262809, 42.710754), + }, + "BA": { + "center": (44.144154, 17.834672), + "bounds": (15.740591, 19.619782, 42.565827, 45.265945), + }, + "BB": { + "center": (13.183219, -59.557384), + "bounds": (-59.659446, -59.427082, 13.050554, 13.337082), + }, + "BD": { + "center": (23.673729, 90.432126), + "bounds": (88.043872, 92.669345, 20.744818, 26.626136), + }, + "BE": { + "center": (50.618214, 4.675010), + "bounds": (2.541663, 6.398200, 49.508882, 51.501245), + }, + "BF": { + "center": (12.108709, -1.693282), + "bounds": (-5.520837, 2.397927, 9.395691, 15.082773), + }, + "BG": { + "center": (42.820437, 25.251739), + "bounds": (22.365273, 28.605136, 41.243045, 44.224718), + }, + "BH": { + "center": (26.047985, 50.540695), + "bounds": (50.453327, 50.619718, 25.809718, 26.240000), + }, + "BI": { + "center": (-3.261252, 29.885182), + "bounds": (28.985000, 30.853191, -4.448055, -2.301564), + }, + "BJ": { + "center": (9.503013, 2.305715), + "bounds": (0.776663, 3.855000, 6.218718, 12.396654), + }, + "BL": { + "center": (17.905617, -62.830516), + "bounds": (-62.876221, -62.788645, 17.877004, 17.929687), + }, + "BM": { + "center": (32.315067, -64.745850), + "bounds": (-64.823064, -64.676809, 32.260554, 32.379509), + }, + "BN": { + "center": (4.543206, 114.643096), + "bounds": (114.095082, 115.103600, 4.018191, 5.053054), + }, + "BO": { + "center": (-16.731249, -64.452096), + "bounds": (-69.656191, -57.521118, -22.901109, -9.679191), + }, + "BQ": { + "center": (12.180845, -68.293504), + "bounds": (-68.416536, -68.192927, 12.020554, 12.307500), + }, + "BR": { + "center": (-11.524630, -54.355207), + "bounds": (-74.004591, -34.792918, -33.741118, 5.272709), + }, + "BS": { + "center": (24.721626, -78.072754), + "bounds": (-78.440146, -77.718300, 24.274654, 25.203609), + }, + "BT": { + "center": (27.421639, 90.467166), + "bounds": (88.751936, 92.114218, 26.703609, 28.325000), + }, + "BV": { + "center": (-54.423167, 3.411969), + "bounds": (3.342363, 3.484163, -54.462782, -54.383609), + }, + "BW": { + "center": (-22.236609, 23.857800), + "bounds": (19.996109, 29.373618, -26.875555, -17.782082), + }, + "BY": { + "center": (53.467914, 27.964252), + "bounds": (23.165400, 32.740054, 51.251845, 56.167491), + }, + "BZ": { + "center": (17.242525, -88.682735), + "bounds": (-89.216400, -88.077791, 15.889854, 18.489900), + }, + "CA": { + "center": (57.550480, -98.416805), + "bounds": (-141.002991, -55.645591, 41.675554, 71.996936), + }, + "CC": { + "center": (-12.171249, 96.836888), + "bounds": (96.817491, 96.864845, -12.199446, -12.130418), + }, + "CD": { + "center": (-3.338630, 23.419828), + "bounds": (12.214554, 31.302773, -13.458055, 5.380691), + }, + "CF": { + "center": (6.331390, 20.520743), + "bounds": (14.418891, 27.459718, 2.221264, 11.000836), + }, + "CG": { + "center": (-0.729439, 14.879733), + "bounds": (11.140663, 18.643609, -5.015000, 3.711109), + }, + "CH": { + "center": (46.736781, 8.286929), + "bounds": (5.967009, 10.488209, 45.829436, 47.806664), + }, + "CI": { + "center": (7.536779, -5.571710), + "bounds": (-8.606382, -2.487782, 4.344718, 10.735254), + }, + "CK": { + "center": (-21.222613, -159.787689), + "bounds": (-159.840009, -159.732927, -21.256673, -21.187500), + }, + "CL": { + "center": (-37.829383, -70.768634), + "bounds": (-75.717509, -67.000836, -53.886391, -17.505282), + }, + "CM": { + "center": (6.294168, 12.948474), + "bounds": (8.502363, 16.207000, 1.654164, 13.085000), + }, + "CN": { + "center": (38.073255, 104.691139), + "bounds": (73.620045, 134.768463, 20.233609, 53.553745), + }, + "CO": { + "center": (4.187754, -72.644507), + "bounds": (-79.050282, -66.870455, -4.236873, 12.463891), + }, + "CR": { + "center": (9.863467, -84.146736), + "bounds": (-85.911391, -82.561400, 8.025673, 11.212845), + }, + "CU": { + "center": (21.476177, -79.698179), + "bounds": (-84.952927, -74.131255, 19.821945, 23.194027), + }, + "CV": { + "center": (15.076412, -23.634010), + "bounds": (-23.792500, -23.444727, 14.896109, 15.295000), + }, + "CW": { + "center": (12.199997, -68.969398), + "bounds": (-69.163618, -68.746946, 12.040273, 12.383891), + }, + "CX": { + "center": (-10.446441, 105.702095), + "bounds": (105.629000, 105.751900, -10.510973, -10.384082), + }, + "CY": { + "center": (35.117004, 33.375346), + "bounds": (32.269863, 34.586036, 34.640273, 35.688609), + }, + "CZ": { + "center": (49.749174, 15.383273), + "bounds": (12.093700, 18.852218, 48.581382, 51.052491), + }, + "DE": { + "center": (51.083045, 10.426171), + "bounds": (5.865000, 15.033818, 47.274718, 55.056527), + }, + "DJ": { + "center": (11.750236, 42.613497), + "bounds": (41.759854, 43.420409, 10.942218, 12.708327), + }, + "DK": { + "center": (56.001188, 9.378671), + "bounds": (8.092918, 10.961945, 54.810554, 57.071727), + }, + "DM": { + "center": (15.429270, -61.360472), + "bounds": (-61.491391, -61.250700, 15.198054, 15.631945), + }, + "DO": { + "center": (18.779548, -70.434952), + "bounds": (-72.003064, -68.322927, 17.604164, 19.930827), + }, + "DZ": { + "center": (28.350970, 2.655846), + "bounds": (-8.667218, 11.986473, 18.976391, 37.089854), + }, + "EC": { + "center": (-1.564272, -78.463033), + "bounds": (-80.976673, -75.216846, -5.000309, 1.437782), + }, + "EE": { + "center": (58.648108, 25.916870), + "bounds": (23.410409, 28.194091, 57.522636, 59.664718), + }, + "EG": { + "center": (26.605170, 30.240135), + "bounds": (24.706800, 36.895827, 21.994164, 31.646945), + }, + "ER": { + "center": (15.005533, 39.267240), + "bounds": (36.443282, 43.121382, 12.363891, 17.994882), + }, + "ES": { + "center": (40.365008, -3.651625), + "bounds": (-9.293337, 3.317991, 36.006100, 43.764300), + }, + "ET": { + "center": (8.729390, 39.914903), + "bounds": (32.991800, 47.988245, 3.406664, 14.883609), + }, + "FI": { + "center": (65.015790, 25.657384), + "bounds": (20.580927, 31.581963, 59.806800, 70.088609), + }, + "FJ": { + "center": (-17.822471, 177.981446), + "bounds": (177.248563, 178.694836, -18.268336, -17.302500), + }, + "FK": { + "center": (-51.759013, -58.746646), + "bounds": (-59.716118, -57.733200, -52.343055, -51.291391), + }, + "FM": { + "center": (6.878945, 158.229190), + "bounds": (158.120100, 158.335100, 6.780554, 6.977636), + }, + "FO": { + "center": (62.130896, -6.981106), + "bounds": (-7.234237, -6.696255, 61.933327, 62.306245), + }, + "FR": { + "center": (46.642368, 2.194024), + "bounds": (-4.790282, 8.226082, 42.332909, 51.091109), + }, + "GA": { + "center": (-0.628448, 11.839411), + "bounds": (8.700836, 14.519582, -3.925282, 2.317900), + }, + "GB": { + "center": (53.978447, -2.852944), + "bounds": (-6.234518, 1.749445, 49.955273, 58.660554), + }, + "GD": { + "center": (12.112927, -61.679379), + "bounds": (-61.785182, -61.596391, 11.996945, 12.237154), + }, + "GE": { + "center": (42.179863, 43.378867), + "bounds": (40.002963, 46.710818, 41.048045, 43.584718), + }, + "GF": { + "center": (3.857430, -53.322323), + "bounds": (-54.603782, -51.648055, 2.113473, 5.755418), + }, + "GG": { + "center": (49.458708, -2.576393), + "bounds": (-2.668609, -2.500973, 49.422491, 49.508191), + }, + "GH": { + "center": (7.945305, -1.219233), + "bounds": (-3.248891, 1.202782, 4.727082, 11.155691), + }, + "GI": { + "center": (36.140227, -5.345549), + "bounds": (-5.356173, -5.334509, 36.112073, 36.163309), + }, + "GL": { + "center": (74.168472, -42.075678), + "bounds": (-73.053609, -12.157637, 59.981373, 83.623600), + }, + "GM": { + "center": (13.428618, -15.383380), + "bounds": (-16.821664, -13.798609, 13.059973, 13.826391), + }, + "GN": { + "center": (10.255987, -10.986949), + "bounds": (-15.080837, -7.653373, 7.193927, 12.677500), + }, + "GP": { + "center": (16.244200, -61.543823), + "bounds": (-61.796109, -61.205555, 15.947773, 16.512918), + }, + "GQ": { + "center": (1.595464, 10.425457), + "bounds": (9.360273, 11.353891, 0.930154, 2.343700), + }, + "GR": { + "center": (39.420123, 23.110369), + "bounds": (20.010027, 26.636382, 36.386109, 41.747773), + }, + "GS": { + "center": (-54.376664, -36.775096), + "bounds": (-38.023755, -35.793964, -54.890282, -53.989727), + }, + "GT": { + "center": (15.820879, -90.312193), + "bounds": (-92.246782, -88.214736, 13.745836, 17.821109), + }, + "GU": { + "center": (13.445430, 144.780245), + "bounds": (144.634154, 144.953309, 13.235000, 13.652291), + }, + "GW": { + "center": (11.980075, -14.980187), + "bounds": (-16.717773, -13.643891, 10.925100, 12.684718), + }, + "GY": { + "center": (4.682120, -58.913526), + "bounds": (-61.389727, -56.470636, 1.186873, 8.535273), + }, + "HM": { + "center": (-53.084170, 73.492986), + "bounds": (73.234709, 73.773882, -53.199445, -52.965145), + }, + "HN": { + "center": (14.740371, -86.492517), + "bounds": (-89.350491, -83.131855, 12.985173, 16.021627), + }, + "HR": { + "center": (44.911921, 16.625761), + "bounds": (13.504791, 19.425000, 42.943827, 46.535827), + }, + "HT": { + "center": (18.883520, -72.892914), + "bounds": (-74.467791, -71.629182, 18.022782, 19.942773), + }, + "HU": { + "center": (47.225273, 19.396200), + "bounds": (16.111800, 22.894800, 45.748327, 48.576173), + }, + "ID": { + "center": (0.155920, 113.965382), + "bounds": (108.845491, 119.009018, -4.185073, 4.368127), + }, + "IE": { + "center": (53.304895, -8.241129), + "bounds": (-10.474727, -6.013055, 51.445545, 55.379991), + }, + "IL": { + "center": (31.513542, 35.027923), + "bounds": (34.267582, 35.681109, 29.486709, 33.270273), + }, + "IM": { + "center": (54.228553, -4.532995), + "bounds": (-4.787155, -4.308682, 54.055545, 54.416382), + }, + "IN": { + "center": (23.586301, 81.173004), + "bounds": (68.144227, 97.380536, 8.071945, 35.505618), + }, + "IO": { + "center": (-7.323548, 72.435016), + "bounds": (72.357900, 72.494282, -7.436246, -7.233473), + }, + "IQ": { + "center": (33.105076, 43.832529), + "bounds": (38.794700, 48.560691, 29.061664, 37.383673), + }, + "IR": { + "center": (32.906024, 54.237077), + "bounds": (44.034954, 63.330273, 25.075973, 39.779154), + }, + "IS": { + "center": (65.123609, -19.056830), + "bounds": (-24.538400, -13.499446, 63.390000, 66.536100), + }, + "IT": { + "center": (42.982011, 12.763657), + "bounds": (6.623963, 18.514445, 37.916936, 47.094582), + }, + "JE": { + "center": (49.215397, -2.129160), + "bounds": (-2.247364, -2.015000, 49.167773, 49.261109), + }, + "JM": { + "center": (18.122079, -77.303589), + "bounds": (-78.373900, -76.221118, 17.697218, 18.522500), + }, + "JO": { + "center": (31.387065, 36.957289), + "bounds": (34.960418, 39.301109, 29.188891, 33.377591), + }, + "JP": { + "center": (36.767388, 137.469342), + "bounds": (130.879672, 142.069700, 33.454991, 41.529573), + }, + "KE": { + "center": (0.689918, 37.953094), + "bounds": (33.907218, 41.905163, -4.669618, 4.622500), + }, + "KG": { + "center": (41.356989, 74.175329), + "bounds": (69.249500, 80.281582, 39.195473, 43.216900), + }, + "KH": { + "center": (12.699187, 105.039731), + "bounds": (102.346509, 107.636382, 10.422736, 14.708618), + }, + "KI": { + "center": (1.867667, -157.390242), + "bounds": (-157.581700, -157.172545, 1.705000, 2.033054), + }, + "KM": { + "center": (-11.658861, 43.348266), + "bounds": (43.214027, 43.497773, -11.936109, -11.366946), + }, + "KN": { + "center": (17.314736, -62.745604), + "bounds": (-62.862782, -62.622509, 17.208882, 17.410136), + }, + "KP": { + "center": (40.191981, 127.337981), + "bounds": (124.323954, 130.697418, 37.671382, 43.006100), + }, + "KR": { + "center": (36.402387, 127.762246), + "bounds": (126.125818, 129.586872, 34.299854, 38.625245), + }, + "KW": { + "center": (29.281361, 47.563111), + "bounds": (46.546945, 48.416591, 28.538882, 30.084164), + }, + "KY": { + "center": (19.311232, -81.252032), + "bounds": (-81.400836, -81.093064, 19.265000, 19.354164), + }, + "KZ": { + "center": (47.641465, 66.375919), + "bounds": (46.499163, 87.348209, 40.594436, 55.442627), + }, + "LA": { + "center": (18.117283, 103.763759), + "bounds": (100.091372, 107.695254, 13.926664, 22.499927), + }, + "LB": { + "center": (33.911602, 35.896519), + "bounds": (35.100827, 36.623745, 33.062082, 34.647500), + }, + "LC": { + "center": (13.895749, -60.968951), + "bounds": (-61.079582, -60.878064, 13.709445, 14.109309), + }, + "LI": { + "center": (47.146276, 9.547675), + "bounds": (9.474636, 9.633891, 47.057454, 47.274545), + }, + "LK": { + "center": (7.696631, 80.669312), + "bounds": (79.697900, 81.891663, 5.918054, 9.828191), + }, + "LR": { + "center": (6.520130, -9.258989), + "bounds": (-11.492327, -7.368400, 4.343609, 8.512782), + }, + "LS": { + "center": (-29.601681, 28.244753), + "bounds": (27.013973, 29.455554, -30.650527, -28.570691), + }, + "LT": { + "center": (55.294374, 23.946022), + "bounds": (21.044373, 26.813054, 53.890336, 56.449854), + }, + "LU": { + "center": (49.775235, 6.103230), + "bounds": (5.734445, 6.524027, 49.448464, 50.181809), + }, + "LV": { + "center": (56.813853, 24.693671), + "bounds": (20.968609, 28.235963, 55.674836, 58.083254), + }, + "LY": { + "center": (27.202916, 17.911334), + "bounds": (9.311391, 25.151663, 19.499064, 33.171136), + }, + "MA": { + "center": (28.687598, -8.817213), + "bounds": (-17.101527, -1.011809, 20.764100, 35.919164), + }, + "MC": { + "center": (43.747982, 7.412821), + "bounds": (7.390900, 7.439291, 43.727545, 43.768300), + }, + "MD": { + "center": (47.072567, 28.391112), + "bounds": (26.634991, 30.128709, 45.448645, 48.468318), + }, + "ME": { + "center": (42.736948, 19.295051), + "bounds": (18.453327, 20.379027, 41.849000, 43.555973), + }, + "MF": { + "center": (18.078012, -63.066785), + "bounds": (-63.152804, -63.009612, 18.046932, 18.124646), + }, + "MG": { + "center": (-19.041636, 46.684935), + "bounds": (43.236827, 50.501391, -25.588336, -11.945555), + }, + "MH": { + "center": (7.307930, 168.720160), + "bounds": (168.671236, 168.786372, 7.288891, 7.330136), + }, + "MK": { + "center": (41.594029, 21.709989), + "bounds": (20.458818, 23.030973, 40.855891, 42.358954), + }, + "ML": { + "center": (17.168146, -4.346400), + "bounds": (-12.244837, 4.251391, 10.142154, 25.000273), + }, + "MM": { + "center": (19.901228, 97.088923), + "bounds": (92.204991, 101.169427, 9.986909, 28.546527), + }, + "MN": { + "center": (47.086445, 103.398736), + "bounds": (87.761100, 119.931509, 41.586654, 52.142773), + }, + "MP": { + "center": (15.178064, 145.741197), + "bounds": (145.676909, 145.818082, 15.087218, 15.268191), + }, + "MQ": { + "center": (14.642697, -61.014324), + "bounds": (-61.231536, -60.816946, 14.402773, 14.880136), + }, + "MR": { + "center": (20.466731, -10.495079), + "bounds": (-17.075555, -4.806109, 14.725636, 27.290454), + }, + "MS": { + "center": (16.735363, -62.186933), + "bounds": (-62.236946, -62.138891, 16.671391, 16.812354), + }, + "MT": { + "center": (35.890523, 14.441922), + "bounds": (14.329100, 14.570000, 35.800000, 35.991936), + }, + "MU": { + "center": (-20.281423, 57.564157), + "bounds": (57.306309, 57.795409, -20.520555, -19.986391), + }, + "MV": { + "center": (-0.606558, 73.100762), + "bounds": (73.089427, 73.115200, -0.641664, -0.584309), + }, + "MW": { + "center": (-13.128986, 34.234412), + "bounds": (32.681873, 35.920963, -17.135282, -9.376673), + }, + "MX": { + "center": (23.874361, -101.553997), + "bounds": (-117.126955, -86.775282, 14.550545, 32.718454), + }, + "MY": { + "center": (3.671661, 114.633303), + "bounds": (109.547336, 119.275818, 0.852782, 7.023127), + }, + "MZ": { + "center": (-17.525230, 35.208577), + "bounds": (30.213018, 40.846109, -26.860282, -10.471109), + }, + "NA": { + "center": (-21.908582, 18.164513), + "bounds": (11.716391, 25.264427, -28.961873, -16.954173), + }, + "NC": { + "center": (-21.330034, 165.507670), + "bounds": (163.982745, 167.028045, -22.398891, -20.087918), + }, + "NE": { + "center": (17.081054, 8.868632), + "bounds": (0.166663, 15.996663, 11.693273, 23.522309), + }, + "NF": { + "center": (-29.037654, 167.952596), + "bounds": (167.910945, 167.998872, -29.081109, -29.000555), + }, + "NG": { + "center": (9.610294, 8.147715), + "bounds": (2.692500, 14.649654, 4.272845, 13.891500), + }, + "NI": { + "center": (12.893567, -85.016088), + "bounds": (-87.689827, -83.131855, 10.709691, 15.022218), + }, + "NL": { + "center": (52.134054, 5.554136), + "bounds": (3.444236, 7.210973, 50.753882, 53.465827), + }, + "NO": { + "center": (64.977759, 16.670259), + "bounds": (4.931109, 31.073536, 57.987918, 71.113054), + }, + "NP": { + "center": (28.300921, 84.133890), + "bounds": (80.052200, 88.194554, 26.368364, 30.424718), + }, + "NR": { + "center": (-0.522102, 166.929376), + "bounds": (166.904418, 166.957045, -0.552218, -0.493336), + }, + "NU": { + "center": (-19.052309, -169.868781), + "bounds": (-169.952236, -169.781555, -19.145555, -18.963336), + }, + "NZ": { + "center": (-43.827654, 170.690355), + "bounds": (166.464691, 174.323854, -46.680827, -40.495555), + }, + "OM": { + "center": (20.724283, 55.841088), + "bounds": (51.999291, 59.847082, 16.642782, 24.982500), + }, + "PA": { + "center": (8.439537, -80.144288), + "bounds": (-83.030291, -77.198336, 7.206109, 9.620136), + }, + "PE": { + "center": (-8.522718, -74.114162), + "bounds": (-81.355146, -68.673909, -18.348546, -0.036873), + }, + "PF": { + "center": (-17.674684, -149.400417), + "bounds": (-149.641691, -149.146682, -17.870836, -17.493755), + }, + "PG": { + "center": (-7.156913, 144.834894), + "bounds": (140.858854, 150.878018, -10.700555, -2.590000), + }, + "PH": { + "center": (15.586542, 121.822089), + "bounds": (119.752772, 124.196372, 12.531664, 18.627636), + }, + "PK": { + "center": (30.116188, 69.088351), + "bounds": (60.866300, 77.823927, 23.688045, 37.060791), + }, + "PL": { + "center": (52.068481, 19.435733), + "bounds": (14.147636, 24.143473, 49.002918, 54.836036), + }, + "PM": { + "center": (46.951539, -56.324654), + "bounds": (-56.397782, -56.232636, 46.779854, 47.135827), + }, + "PN": { + "center": (-24.366122, -128.314985), + "bounds": (-128.346955, -128.286118, -24.411673, -24.325836), + }, + "PR": { + "center": (18.216224, -66.494253), + "bounds": (-67.266400, -65.602782, 17.922218, 18.519445), + }, + "PS": { + "center": (31.930819, 35.242512), + "bounds": (34.888191, 35.570609, 31.350691, 32.546391), + }, + "PT": { + "center": (39.675292, -7.933662), + "bounds": (-9.490837, -6.190455, 37.008327, 42.150673), + }, + "PW": { + "center": (7.534776, 134.579651), + "bounds": (134.485227, 134.658872, 7.354445, 7.729445), + }, + "PY": { + "center": (-23.421906, -58.389064), + "bounds": (-62.643773, -54.243900, -27.584727, -19.296809), + }, + "QA": { + "center": (25.318528, 51.197949), + "bounds": (50.751936, 51.615827, 24.556045, 26.152500), + }, + "RE": { + "center": (-21.119825, 55.543935), + "bounds": (55.220554, 55.853054, -21.373891, -20.856527), + }, + "RO": { + "center": (45.824549, 25.094158), + "bounds": (20.261027, 29.672218, 43.623309, 48.263882), + }, + "RS": { + "center": (44.026799, 20.856774), + "bounds": (18.817018, 23.005000, 41.856391, 46.181109), + }, + "RU": { + "center": (59.039434, 98.670499), + "bounds": (27.348436, 179.999989, 41.196582, 77.732209), + }, + "RW": { + "center": (-2.014687, 29.919440), + "bounds": (28.854445, 30.893263, -2.825491, -1.054446), + }, + "SA": { + "center": (24.136038, 44.600958), + "bounds": (34.572145, 55.666109, 16.377500, 32.154945), + }, + "SB": { + "center": (-9.613105, 160.164758), + "bounds": (159.601836, 160.825236, -9.930000, -9.255000), + }, + "SC": { + "center": (-4.660002, 55.472508), + "bounds": (55.375409, 55.540554, -4.789164, -4.551664), + }, + "SD": { + "center": (15.670602, 29.951458), + "bounds": (21.829100, 38.607500, 8.684975, 22.232218), + }, + "SE": { + "center": (62.734210, 17.062432), + "bounds": (11.113336, 24.167009, 55.339164, 69.060300), + }, + "SG": { + "center": (1.352825, 103.810258), + "bounds": (103.640945, 103.997945, 1.259027, 1.445282), + }, + "SH": { + "center": (-15.962963, -5.717392), + "bounds": (-5.792782, -5.645282, -16.021946, -15.903755), + }, + "SI": { + "center": (46.137592, 14.890637), + "bounds": (13.383473, 16.607873, 45.425818, 46.876245), + }, + "SJ": { + "center": (78.573189, 16.036379), + "bounds": (10.682354, 21.540554, 76.566373, 80.062763), + }, + "SK": { + "center": (48.698084, 19.581015), + "bounds": (16.844718, 22.558054, 47.737500, 49.600827), + }, + "SL": { + "center": (8.561330, -11.786567), + "bounds": (-13.295609, -10.264309, 6.923609, 9.997500), + }, + "SM": { + "center": (43.942821, 12.461278), + "bounds": (12.406945, 12.511109, 43.898682, 43.986873), + }, + "SN": { + "center": (14.228861, -14.610875), + "bounds": (-17.532782, -11.369927, 12.301745, 16.690618), + }, + "SO": { + "center": (6.524535, 45.400379), + "bounds": (40.988609, 51.411318, -1.674873, 11.979164), + }, + "SR": { + "center": (4.098724, -55.855514), + "bounds": (-58.071400, -53.986118, 1.836245, 6.001809), + }, + "SS": { + "center": (7.657782, 30.385186), + "bounds": (24.140273, 35.940554, 3.493391, 12.101175), + }, + "ST": { + "center": (0.227447, 6.606159), + "bounds": (6.465136, 6.766245, 0.018336, 0.408745), + }, + "SV": { + "center": (13.758042, -88.859115), + "bounds": (-90.108064, -87.694673, 13.156391, 14.431982), + }, + "SX": { + "center": (18.039426, -63.068831), + "bounds": (-63.144847, -63.013087, 18.004198, 18.062725), + }, + "SY": { + "center": (35.097511, 38.511732), + "bounds": (35.614463, 42.378327, 32.313609, 37.290545), + }, + "SZ": { + "center": (-26.562541, 31.510686), + "bounds": (30.798336, 32.133400, -27.316391, -25.728336), + }, + "TC": { + "center": (21.799865, -71.740589), + "bounds": (-71.850573, -71.633618, 21.739718, 21.854718), + }, + "TD": { + "center": (15.283494, 18.427114), + "bounds": (13.461945, 24.002745, 7.458536, 23.450554), + }, + "TF": { + "center": (-49.263297, 69.546870), + "bounds": (68.740272, 70.567491, -49.725009, -48.651391), + }, + "TG": { + "center": (8.660743, 0.899086), + "bounds": (-0.149764, 1.797800, 6.100545, 11.138536), + }, + "TH": { + "center": (13.662228, 101.086751), + "bounds": (97.347272, 105.639291, 5.633473, 20.454582), + }, + "TJ": { + "center": (38.569331, 70.942153), + "bounds": (67.364700, 75.187482, 36.671845, 41.049254), + }, + "TK": { + "center": (-9.195175, -171.852660), + "bounds": (-171.862718, -171.843764, -9.218891, -9.170627), + }, + "TL": { + "center": (-8.809895, 125.950240), + "bounds": (124.946300, 127.308591, -9.435291, -8.324446), + }, + "TM": { + "center": (39.060691, 58.457736), + "bounds": (51.250182, 66.670882, 35.145991, 42.796173), + }, + "TN": { + "center": (34.086362, 9.655876), + "bounds": (7.492218, 11.581663, 30.234391, 37.340409), + }, + "TO": { + "center": (-21.159272, -175.204159), + "bounds": (-175.360000, -175.045991, -21.268064, -21.064173), + }, + "TR": { + "center": (38.932074, 35.568868), + "bounds": (26.070545, 44.820545, 35.818445, 42.091945), + }, + "TT": { + "center": (10.415516, -61.372366), + "bounds": (-61.921600, -60.909236, 10.040345, 10.840273), + }, + "TV": { + "center": (-8.514702, 179.217834), + "bounds": (179.202409, 179.232281, -8.561291, -8.465418), + }, + "TZ": { + "center": (-6.355794, 34.818322), + "bounds": (29.340827, 40.436809, -11.740418, -0.997218), + }, + "UA": { + "center": (48.657533, 31.273772), + "bounds": (22.151445, 40.178745, 44.379154, 52.378600), + }, + "UG": { + "center": (1.282173, 32.343718), + "bounds": (29.574300, 35.009718, -1.476109, 4.222782), + }, + "UM": { + "center": (19.302046, 166.638003), + "bounds": (166.608981, 166.662200, 19.279445, 19.324582), + }, + "US": { + "center": (38.820809, -96.331617), + "bounds": (-124.714309, -66.970836, 25.115554, 49.376654), + }, + "UY": { + "center": (-32.781950, -56.019195), + "bounds": (-58.438609, -53.098300, -34.943818, -30.096673), + }, + "UZ": { + "center": (41.487907, 63.854830), + "bounds": (55.997491, 73.167545, 37.184991, 45.570591), + }, + "VA": { + "center": (41.904024, 12.451313), + "bounds": (12.444473, 12.457718, 41.900891, 41.908391), + }, + "VC": { + "center": (13.254808, -61.193766), + "bounds": (-61.280146, -61.120282, 13.130282, 13.383191), + }, + "VE": { + "center": (7.148325, -66.364921), + "bounds": (-73.378064, -59.803055, 0.649164, 12.197500), + }, + "VG": { + "center": (18.421958, -64.624065), + "bounds": (-64.698482, -64.556946, 18.383891, 18.457636), + }, + "VI": { + "center": (17.738010, -64.761553), + "bounds": (-64.896118, -64.562573, 17.676664, 17.792500), + }, + "VN": { + "center": (16.517347, 105.913388), + "bounds": (102.140745, 109.464845, 8.559236, 23.324164), + }, + "VU": { + "center": (-15.189132, 166.849127), + "bounds": (166.521636, 167.237745, -15.661109, -14.626036), + }, + "WF": { + "center": (-14.283442, -178.127356), + "bounds": (-178.190273, -178.043018, -14.323891, -14.232364), + }, + "WS": { + "center": (-13.634253, -172.441077), + "bounds": (-172.780027, -172.167818, -13.808891, -13.460555), + }, + "YE": { + "center": (16.001393, 47.468158), + "bounds": (42.679991, 53.114436, 12.594654, 18.999345), + }, + "YT": { + "center": (-12.824468, 45.128142), + "bounds": (45.039163, 45.229718, -12.992500, -12.662500), + }, + "ZA": { + "center": (-28.553619, 24.752527), + "bounds": (16.483327, 32.890427, -34.822000, -22.136391), + }, + "ZM": { + "center": (-13.162833, 27.755214), + "bounds": (21.996391, 33.702282, -18.074918, -8.191664), + }, + "ZW": { + "center": (-18.927001, 29.717830), + "bounds": (25.237918, 33.071591, -22.414764, -15.616527), + }, + } diff --git a/src/tapmap/ui/help_view.py b/src/tapmap/ui/help_view.py index 7507139..b31c105 100644 --- a/src/tapmap/ui/help_view.py +++ b/src/tapmap/ui/help_view.py @@ -44,7 +44,8 @@ def render_help() -> list[Any]: "open ports, and additional tools." ), html.Li( - "Use the mouse or Plotly tools (top right) to pan, zoom, or reset the view." + "Use the mouse or Plotly tools (top right) to pan, zoom, reset, or fit all " + "mapped connections." ), ] ), @@ -290,11 +291,19 @@ def render_help() -> list[Any]: html.Tr([html.Td("C"), html.Td("Clear cache"), html.Td("Status")]), html.Tr([html.Td("H"), html.Td("Help"), html.Td("Window")]), html.Tr([html.Td("A"), html.Td("About"), html.Td("Window")]), + html.Tr([html.Td("Z"), html.Td("Fit mapped connections"), + html.Td("Map")]), html.Tr([html.Td("ESC"), html.Td("Close window"), html.Td("Window")]), ] ), ], ), + html.H2("Map navigation"), + html.P( + "Use the Fit Connections button in the Plotly toolbar (top right), or press " + "Z, to fit all mapped connections. Click a country in the Insights panel to " + "zoom to that country." + ), html.H2("Insights"), html.P("The Insights panel highlights activity observed during the last 30 days."), html.Ul( @@ -306,7 +315,7 @@ def render_help() -> list[Any]: "Top 5+ most frequently observed items over the last 30 days." ), html.Li( - "Click countries to zoom to their locations on the map." + "Click countries to zoom to the selected country." ), ] ), diff --git a/src/tapmap/ui/insights_view.py b/src/tapmap/ui/insights_view.py index 624f866..6e78eb6 100644 --- a/src/tapmap/ui/insights_view.py +++ b/src/tapmap/ui/insights_view.py @@ -5,7 +5,10 @@ from dash import html -def render_insights_panel(data: dict[str, Any] | None) -> list[Any]: +def render_insights_panel( + data: dict[str, Any] | None, + selected_country: str | None = None, +) -> list[Any]: """Render insights panel content from data.""" if not isinstance(data, dict): return [] @@ -35,9 +38,14 @@ def build_row(item: dict[str, Any], category: str, section: str) -> Any: ) if is_country and isinstance(value, str): + row_class = "insights-row clickable" + + if value == selected_country: + row_class += " selected" + return html.Div( header, - className="insights-row clickable", + className=row_class, id={ "type": "insights-country", "country_code": value, diff --git a/src/tapmap/ui/layout_view.py b/src/tapmap/ui/layout_view.py index 9b0312f..84faacb 100644 --- a/src/tapmap/ui/layout_view.py +++ b/src/tapmap/ui/layout_view.py @@ -33,6 +33,7 @@ def render_layout( dcc.Store(id="menu_open", data=False), dcc.Store(id="insights_on", data=True), dcc.Store(id="selected_country", data=None), + dcc.Store(id="camera_mode", data=None), dcc.Store(id="key_action", data=None), dcc.Store(id="status_flash", data=None), dcc.Store(id="geodb_event", data=None), diff --git a/src/tapmap/ui/map_view.py b/src/tapmap/ui/map_view.py index 81b21fe..0be434a 100644 --- a/src/tapmap/ui/map_view.py +++ b/src/tapmap/ui/map_view.py @@ -9,7 +9,7 @@ import plotly.graph_objects as go import pycountry -from .country_centers import get_center +from .country_info import get_bounds LonLat: TypeAlias = tuple[float, float] PointSets: TypeAlias = tuple[list[LonLat], list[LonLat]] # (geo_points, my_location) @@ -172,10 +172,10 @@ def _add_geo_point_markers( texts: list[str] = [] for i in range(len(geo_points)): - # Color based on proximity (unchanged logic) + # Color based on proximity colors.append(self.COLOR_ZOOM if zoom_flags[i] else self.COLOR_NORMAL) - # Hover text + # Build hover text for each mapped location. base = summaries.get(str(i), f"Summary {i}") texts.append(base) @@ -219,9 +219,61 @@ def _apply_geos( fig: go.Figure, *, geo_points, + my_location, selected_country, + fit_connections: bool = False, ) -> None: - """Configure projection. Center view on selected country when provided.""" + """Configure map projection and camera.""" + + def camera_from_bounds( + min_lon: float, max_lon: float, min_lat: float, max_lat: float, + *, + scale_factor: float, + ) -> tuple[dict[str, float], float]: + """Return camera center and projection scale for geographic bounds.""" + camera_center = { + "lon": (min_lon + max_lon) / 2, + "lat": (min_lat + max_lat) / 2, + } + + lon_fraction = (max_lon - min_lon) / 360 + lat_fraction = (max_lat - min_lat) / 180 + + span = max(lon_fraction, lat_fraction, 1e-6) + + return camera_center, scale_factor / span + + camera_center = None + camera_scale = None + + # Zoom to include all mapped connections (and my location). + if fit_connections and geo_points: + points = list(geo_points) + points.extend(my_location) + + lons = [lon for lon, _ in points] + lats = [lat for _, lat in points] + + camera_center, camera_scale = camera_from_bounds( + min(lons), max(lons), min(lats), max(lats), + scale_factor=0.95, + ) + + # Zoom to the selected country. + elif isinstance(selected_country, str): + try: + min_lon, max_lon, min_lat, max_lat = get_bounds( + selected_country.upper() + ) + + camera_center, camera_scale = camera_from_bounds( + min_lon, max_lon, min_lat, max_lat, + scale_factor=0.50, + ) + + except Exception: + pass + fig.update_geos( visible=True, projection_type="natural earth", @@ -234,18 +286,11 @@ def _apply_geos( bgcolor="black", ) - if isinstance(selected_country, str): - country_code = selected_country - - try: - lat, lon = get_center(country_code.upper()) - - fig.update_geos( - center=dict(lon=lon, lat=lat), - projection_scale=2, - ) - except Exception: - pass + if camera_center is not None: + fig.update_geos( + center=camera_center, + projection_scale=camera_scale, + ) def _apply_layout(self, fig: go.Figure) -> None: """Apply layout styling and interaction settings.""" @@ -278,6 +323,7 @@ def create_figure( point_sets: PointSets, summaries: dict[str, str] | None = None, selected_country: str | None = None, + fit_connections: bool = False, ) -> go.Figure: """Return map figure with world layer, connections, markers, and layout.""" summaries = summaries or {} @@ -317,7 +363,9 @@ def create_figure( self._apply_geos( fig, geo_points=geo_points, + my_location=my_location, selected_country=selected_country, + fit_connections=fit_connections, ) self._apply_layout(fig)