web-scanner 2.0.0__py3-none-any.whl
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.
- web_scanner-2.0.0.dist-info/METADATA +803 -0
- web_scanner-2.0.0.dist-info/RECORD +35 -0
- web_scanner-2.0.0.dist-info/WHEEL +4 -0
- web_scanner-2.0.0.dist-info/entry_points.txt +2 -0
- web_scanner-2.0.0.dist-info/licenses/LICENSE +674 -0
- webscanner/__init__.py +3 -0
- webscanner/__main__.py +6 -0
- webscanner/cli.py +24 -0
- webscanner/colors.py +4 -0
- webscanner/core/__init__.py +15 -0
- webscanner/core/context.py +40 -0
- webscanner/core/models.py +79 -0
- webscanner/core/module.py +27 -0
- webscanner/core/scanner.py +103 -0
- webscanner/helpers.py +41 -0
- webscanner/modules/__init__.py +38 -0
- webscanner/modules/dns.py +96 -0
- webscanner/modules/headers.py +16 -0
- webscanner/modules/links.py +69 -0
- webscanner/modules/security.py +124 -0
- webscanner/modules/seo.py +148 -0
- webscanner/modules/ssl.py +57 -0
- webscanner/modules/subdomains.py +56 -0
- webscanner/modules/tech.py +43 -0
- webscanner/modules/whois.py +114 -0
- webscanner/net/__init__.py +5 -0
- webscanner/net/http.py +66 -0
- webscanner/ui/__init__.py +1 -0
- webscanner/ui/app.py +287 -0
- webscanner/ui/app.tcss +93 -0
- webscanner/ui/data/countries.json +1 -0
- webscanner/ui/export.py +113 -0
- webscanner/ui/tables.py +256 -0
- webscanner/ui/widgets.py +101 -0
- webscanner/ui/worldmap.py +197 -0
webscanner/ui/tables.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Rich renderables for the main data table and the fixed status panel."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from rich.console import Group, RenderableType
|
|
8
|
+
from rich import box
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from ..colors import RED
|
|
13
|
+
from ..core.context import ScanContext
|
|
14
|
+
from ..core.models import Grid, Sections
|
|
15
|
+
|
|
16
|
+
# Neutral, non-cyan key styling + a muted header + a very dim divider rule.
|
|
17
|
+
KEY_STYLE = "bold"
|
|
18
|
+
HEADER_STYLE = "bold grey62"
|
|
19
|
+
DIVIDER_STYLE = "grey23"
|
|
20
|
+
#: section-title chip — matches the selected-tab look (bg + 1-char side padding)
|
|
21
|
+
SECTION_STYLE = "bold white on #393939"
|
|
22
|
+
|
|
23
|
+
# Per-tab column headers (col1, col2). Meaningful names beat a generic pair since
|
|
24
|
+
# each tab's data is different. (Tabs with multi-table results, e.g. Security,
|
|
25
|
+
# carry headers per Section instead.)
|
|
26
|
+
TAB_HEADERS: dict[str, tuple[str, str]] = {
|
|
27
|
+
"dns": ("Record", "Value"),
|
|
28
|
+
"whois": ("Field", "Value"),
|
|
29
|
+
"subdomains": ("#", "Subdomain"),
|
|
30
|
+
"ssl": ("Field", "Value"),
|
|
31
|
+
"headers": ("Header", "Value"),
|
|
32
|
+
"content": ("Field", "Value"),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
#: cap on the (content-fit) first-column width
|
|
37
|
+
MAX_KEY_WIDTH = 34
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def render_result(name: str, data: Any) -> RenderableType:
|
|
41
|
+
"""Render a module's result: multi-column Grid, stacked sub-tables (Sections),
|
|
42
|
+
or one key/value table."""
|
|
43
|
+
if isinstance(data, Grid):
|
|
44
|
+
return render_grid(data)
|
|
45
|
+
if isinstance(data, Sections):
|
|
46
|
+
return render_sections(data)
|
|
47
|
+
return render_table(data, TAB_HEADERS.get(name))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def render_grid(grid: Grid) -> Table:
|
|
51
|
+
"""Render a multi-column table (e.g. Tech: Name/Category/Confidence/…).
|
|
52
|
+
|
|
53
|
+
First column is the primary name (bold); the rest are dim, matching the
|
|
54
|
+
key/value tables. Long list columns fold rather than truncate.
|
|
55
|
+
"""
|
|
56
|
+
table = Table(
|
|
57
|
+
show_header=True,
|
|
58
|
+
header_style=HEADER_STYLE,
|
|
59
|
+
border_style=DIVIDER_STYLE,
|
|
60
|
+
box=box.SIMPLE,
|
|
61
|
+
expand=True,
|
|
62
|
+
pad_edge=False,
|
|
63
|
+
show_lines=False,
|
|
64
|
+
padding=(0, 1),
|
|
65
|
+
)
|
|
66
|
+
for i, col in enumerate(grid.columns):
|
|
67
|
+
if i == 0:
|
|
68
|
+
table.add_column(col, style=KEY_STYLE, no_wrap=True, overflow="ellipsis")
|
|
69
|
+
else:
|
|
70
|
+
table.add_column(col, style="dim", no_wrap=False, overflow="fold")
|
|
71
|
+
rows = [[str(cell) for cell in row] for row in grid]
|
|
72
|
+
for i, row in enumerate(rows):
|
|
73
|
+
table.add_row(*row)
|
|
74
|
+
if i != len(rows) - 1:
|
|
75
|
+
table.add_row(*[""] * len(grid.columns)) # blank spacer line
|
|
76
|
+
return table
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def render_sections(sections: Sections) -> Group:
|
|
80
|
+
"""Render several titled sub-tables stacked; content-fit sections share one
|
|
81
|
+
first-column width, ratio sections use their fixed proportions."""
|
|
82
|
+
widths = [_col1_width(s.data, s.headers, upper=False) for s in sections if not s.ratio]
|
|
83
|
+
key_width = max([w for w in widths if w], default=None)
|
|
84
|
+
parts: list[RenderableType] = [Text("")] # space above the first section title
|
|
85
|
+
for i, sec in enumerate(sections):
|
|
86
|
+
if i:
|
|
87
|
+
parts.append(Text(""))
|
|
88
|
+
# chip: 1-char padding inside the bg; the leading space also aligns the
|
|
89
|
+
# title text with the table's cell padding
|
|
90
|
+
parts.append(Text(f" {sec.title.upper()} ", style=SECTION_STYLE))
|
|
91
|
+
parts.append(
|
|
92
|
+
render_table(sec.data, sec.headers, upper=False, spaced=sec.spaced, key_width=key_width, ratio=sec.ratio)
|
|
93
|
+
)
|
|
94
|
+
return Group(*parts)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _stringify(value: Any) -> str:
|
|
98
|
+
if isinstance(value, (list, tuple, set)):
|
|
99
|
+
return "\n".join(str(v) for v in value) if value else "-"
|
|
100
|
+
if isinstance(value, bool):
|
|
101
|
+
return "yes" if value else "no"
|
|
102
|
+
if value is None:
|
|
103
|
+
return "-"
|
|
104
|
+
return str(value)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _label(key: Any, upper: bool) -> str:
|
|
108
|
+
s = str(key).replace("_", " ").replace("-", " ")
|
|
109
|
+
return s.upper() if upper else s
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _is_pairs(data: Any) -> bool:
|
|
113
|
+
"""True for a list of (col1, col2) tuples (e.g. the Links tables)."""
|
|
114
|
+
return (
|
|
115
|
+
isinstance(data, (list, tuple))
|
|
116
|
+
and bool(data)
|
|
117
|
+
and all(isinstance(x, (list, tuple)) and len(x) == 2 for x in data)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _value_cell(value: str) -> Text:
|
|
122
|
+
"""Value cell: keep intentional colour markup (Security), otherwise dim."""
|
|
123
|
+
if "[/]" in value:
|
|
124
|
+
return Text.from_markup(value)
|
|
125
|
+
return Text(value, style="dim")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _col1_width(data: Any, headers: tuple[str, str] | None, upper: bool) -> int | None:
|
|
129
|
+
"""Content-fit width for the first column (incl. its header), or None for a
|
|
130
|
+
fixed index column."""
|
|
131
|
+
c1 = (headers or ("Field", "Value"))[0]
|
|
132
|
+
if isinstance(data, dict):
|
|
133
|
+
labels = [len(_label(k, upper)) for k in data]
|
|
134
|
+
elif _is_pairs(data):
|
|
135
|
+
labels = [len(str(a)) for a, _ in data]
|
|
136
|
+
else:
|
|
137
|
+
return None # list-of-scalars uses the fixed index width
|
|
138
|
+
return min(MAX_KEY_WIDTH, max(labels + [len(c1)], default=len(c1)))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def render_table(
|
|
142
|
+
data: Any,
|
|
143
|
+
headers: tuple[str, str] | None = None,
|
|
144
|
+
upper: bool = True,
|
|
145
|
+
spaced: bool = True,
|
|
146
|
+
key_width: int | None = None,
|
|
147
|
+
ratio: tuple[int, int] | None = None,
|
|
148
|
+
) -> Table:
|
|
149
|
+
"""Generic key/value (dict), pair-list, or indexed (list) table.
|
|
150
|
+
|
|
151
|
+
``headers`` labels the two columns. ``upper`` upper-cases dict keys. ``spaced``
|
|
152
|
+
inserts a blank line between rows. ``key_width`` fixes the first-column width
|
|
153
|
+
(content-fit by default). ``ratio`` (col1, col2) forces proportional columns
|
|
154
|
+
instead of content-fit (e.g. (3, 2) for 60/40).
|
|
155
|
+
"""
|
|
156
|
+
table = Table(
|
|
157
|
+
show_header=headers is not None,
|
|
158
|
+
header_style=HEADER_STYLE,
|
|
159
|
+
border_style=DIVIDER_STYLE, # very dim header rule
|
|
160
|
+
box=box.SIMPLE,
|
|
161
|
+
expand=True,
|
|
162
|
+
pad_edge=False,
|
|
163
|
+
show_lines=False,
|
|
164
|
+
padding=(0, 1), # tight; row spacing added via spacer rows below
|
|
165
|
+
)
|
|
166
|
+
if key_width is None:
|
|
167
|
+
key_width = _col1_width(data, headers, upper)
|
|
168
|
+
|
|
169
|
+
if isinstance(data, dict) or _is_pairs(data):
|
|
170
|
+
if isinstance(data, dict):
|
|
171
|
+
c1, c2 = headers or ("Field", "Value")
|
|
172
|
+
rows = [(_label(k, upper), _value_cell(_stringify(v))) for k, v in data.items()]
|
|
173
|
+
else:
|
|
174
|
+
c1, c2 = headers or ("Name", "Value")
|
|
175
|
+
rows = [(str(a), _value_cell(str(b))) for a, b in data]
|
|
176
|
+
if ratio:
|
|
177
|
+
table.add_column(c1, style=KEY_STYLE, ratio=ratio[0], no_wrap=False, overflow="fold")
|
|
178
|
+
table.add_column(c2, ratio=ratio[1], no_wrap=False, overflow="fold")
|
|
179
|
+
else:
|
|
180
|
+
table.add_column(c1, style=KEY_STYLE, width=key_width, no_wrap=True, overflow="ellipsis")
|
|
181
|
+
table.add_column(c2, no_wrap=False, overflow="fold")
|
|
182
|
+
_add_spaced(table, rows, spaced)
|
|
183
|
+
elif isinstance(data, (list, tuple)):
|
|
184
|
+
c1, c2 = headers or ("#", "Value")
|
|
185
|
+
table.add_column(c1, style="dim", width=5) # left-aligned index
|
|
186
|
+
table.add_column(c2, no_wrap=False, overflow="fold")
|
|
187
|
+
rows = [(str(i), _value_cell(str(row))) for i, row in enumerate(data, 1)]
|
|
188
|
+
_add_spaced(table, rows, spaced)
|
|
189
|
+
else:
|
|
190
|
+
table.add_column(headers[1] if headers else "Value")
|
|
191
|
+
table.add_row(_value_cell(str(data)))
|
|
192
|
+
return table
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _add_spaced(table: Table, rows: list[tuple[str, Any]], spaced: bool = True) -> None:
|
|
196
|
+
"""Add rows, optionally with a blank spacer line between them."""
|
|
197
|
+
for i, (a, b) in enumerate(rows):
|
|
198
|
+
table.add_row(a, b)
|
|
199
|
+
if spaced and i != len(rows) - 1:
|
|
200
|
+
table.add_row("", "")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _flag(country_code: str | None) -> str:
|
|
204
|
+
if not country_code or len(country_code) != 2:
|
|
205
|
+
return ""
|
|
206
|
+
return "".join(chr(0x1F1E6 + ord(c) - ord("A")) for c in country_code.upper())
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def render_status(ctx: ScanContext, tech: list[str] | None = None) -> Table:
|
|
210
|
+
"""Fixed status panel: online state, IP, location, ISP/AS, key tech."""
|
|
211
|
+
geo = ctx.geo or {}
|
|
212
|
+
table = Table(
|
|
213
|
+
show_header=False,
|
|
214
|
+
box=None,
|
|
215
|
+
expand=True,
|
|
216
|
+
pad_edge=False,
|
|
217
|
+
padding=(0, 1, 1, 0), # blank line under each row, matches main table
|
|
218
|
+
)
|
|
219
|
+
table.add_column("k", style=KEY_STYLE, width=10)
|
|
220
|
+
table.add_column("v", overflow="fold")
|
|
221
|
+
|
|
222
|
+
def dim(value: Any) -> Text:
|
|
223
|
+
return Text(str(value), style="dim")
|
|
224
|
+
|
|
225
|
+
if ctx.online:
|
|
226
|
+
state = Text.from_markup(
|
|
227
|
+
f"[green]● online[/] [dim]{ctx.status_code} · {ctx.response_time_ms:.0f}ms[/]"
|
|
228
|
+
)
|
|
229
|
+
elif ctx.fetch_error is not None:
|
|
230
|
+
state = Text.from_markup(f"[{RED}]● offline[/]")
|
|
231
|
+
else:
|
|
232
|
+
state = Text.from_markup("[dim]…[/]")
|
|
233
|
+
table.add_row("Status", state)
|
|
234
|
+
table.add_row("IP", dim(ctx.ip or "-"))
|
|
235
|
+
|
|
236
|
+
# Location: flag emoji stays in colour (not dimmed), the text is dim.
|
|
237
|
+
if geo:
|
|
238
|
+
loc = Text()
|
|
239
|
+
flag = _flag(geo.get("countryCode"))
|
|
240
|
+
if flag:
|
|
241
|
+
loc.append(flag + " ")
|
|
242
|
+
loc.append(f"{geo.get('city', '-')}, {geo.get('country', '-')}", style="dim")
|
|
243
|
+
else:
|
|
244
|
+
loc = dim("-")
|
|
245
|
+
table.add_row("Location", loc)
|
|
246
|
+
|
|
247
|
+
# ip-api's `org` is often the actual host (e.g. "Pantheon") vs the network
|
|
248
|
+
# ISP/AS (e.g. "Fastly"); show it when present and distinct.
|
|
249
|
+
org = geo.get("org")
|
|
250
|
+
if org and org != geo.get("isp"):
|
|
251
|
+
table.add_row("Host", dim(org))
|
|
252
|
+
table.add_row("ISP", dim(geo.get("isp") or "-"))
|
|
253
|
+
table.add_row("AS", dim(geo.get("as") or "-"))
|
|
254
|
+
if tech:
|
|
255
|
+
table.add_row("Tech", dim(", ".join(tech[:8])))
|
|
256
|
+
return table
|
webscanner/ui/widgets.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Custom Textual widgets: tab bar, world-map panel, status panel."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.containers import Horizontal
|
|
6
|
+
from textual.message import Message
|
|
7
|
+
from textual.widgets import Static
|
|
8
|
+
|
|
9
|
+
from ..core.context import ScanContext
|
|
10
|
+
from ..core.models import ModuleStatus
|
|
11
|
+
from .tables import render_status
|
|
12
|
+
from .worldmap import country_name, render as render_map
|
|
13
|
+
|
|
14
|
+
_STATUS_CLASSES = ("-pending", "-running", "-done", "-empty", "-failed")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Tab(Static):
|
|
18
|
+
"""A single tab in the tab bar; colour reflects its module's status."""
|
|
19
|
+
|
|
20
|
+
class Clicked(Message):
|
|
21
|
+
def __init__(self, tab_name: str) -> None:
|
|
22
|
+
self.tab_name = tab_name
|
|
23
|
+
super().__init__()
|
|
24
|
+
|
|
25
|
+
def __init__(self, name: str, label: str) -> None:
|
|
26
|
+
super().__init__(label, id=f"tab-{name}")
|
|
27
|
+
self.tab_name = name
|
|
28
|
+
self.add_class("-pending")
|
|
29
|
+
|
|
30
|
+
def set_status(self, status: ModuleStatus) -> None:
|
|
31
|
+
self.remove_class(*_STATUS_CLASSES)
|
|
32
|
+
self.add_class(f"-{status.value}")
|
|
33
|
+
|
|
34
|
+
def set_selected(self, selected: bool) -> None:
|
|
35
|
+
self.set_class(selected, "-selected")
|
|
36
|
+
|
|
37
|
+
def on_click(self) -> None:
|
|
38
|
+
self.post_message(self.Clicked(self.tab_name))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TabBar(Horizontal):
|
|
42
|
+
def __init__(self, modules, **kwargs) -> None:
|
|
43
|
+
super().__init__(**kwargs)
|
|
44
|
+
self._modules = list(modules)
|
|
45
|
+
|
|
46
|
+
def compose(self):
|
|
47
|
+
for module in self._modules:
|
|
48
|
+
yield Tab(module.name, module.label)
|
|
49
|
+
|
|
50
|
+
def set_status(self, name: str, status: ModuleStatus) -> None:
|
|
51
|
+
self.query_one(f"#tab-{name}", Tab).set_status(status)
|
|
52
|
+
|
|
53
|
+
def set_selected(self, name: str) -> None:
|
|
54
|
+
for tab in self.query(Tab):
|
|
55
|
+
tab.set_selected(tab.tab_name == name)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class MapPanel(Static):
|
|
59
|
+
"""Fixed country-level location map; re-renders on resize to fill the panel."""
|
|
60
|
+
|
|
61
|
+
_geo: dict | None = None
|
|
62
|
+
_zoom: float = 1.0
|
|
63
|
+
|
|
64
|
+
def set_geo(self, geo: dict | None) -> None:
|
|
65
|
+
self._geo = geo
|
|
66
|
+
self._draw()
|
|
67
|
+
|
|
68
|
+
def zoom_by(self, direction: int) -> None:
|
|
69
|
+
factor = 1.3 if direction > 0 else 1 / 1.3
|
|
70
|
+
self._zoom = max(0.25, min(6.0, self._zoom * factor))
|
|
71
|
+
self._draw()
|
|
72
|
+
|
|
73
|
+
def show_loading(self) -> None:
|
|
74
|
+
self.update("[dim]locating…[/]")
|
|
75
|
+
|
|
76
|
+
def on_resize(self) -> None:
|
|
77
|
+
if self._geo is not None:
|
|
78
|
+
self._draw()
|
|
79
|
+
|
|
80
|
+
def _draw(self) -> None:
|
|
81
|
+
geo = self._geo or {}
|
|
82
|
+
lat, lon = geo.get("lat"), geo.get("lon")
|
|
83
|
+
if lat is None:
|
|
84
|
+
self.update("[dim]no location[/]")
|
|
85
|
+
return
|
|
86
|
+
width = max(20, self.content_size.width)
|
|
87
|
+
height = max(6, self.content_size.height)
|
|
88
|
+
country = geo.get("country") or country_name(lat, lon) or "?"
|
|
89
|
+
self.border_title = f"Location — {country}"
|
|
90
|
+
self.border_subtitle = "+/- zoom"
|
|
91
|
+
self.update(render_map(lat, lon, cols=width, rows=height, zoom=self._zoom))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class StatusPanel(Static):
|
|
95
|
+
"""Fixed status summary panel."""
|
|
96
|
+
|
|
97
|
+
def show_loading(self, ctx: ScanContext) -> None:
|
|
98
|
+
self.update(f"[dim]scanning {ctx.domain}…[/]")
|
|
99
|
+
|
|
100
|
+
def set_ctx(self, ctx: ScanContext, tech: list[str] | None = None) -> None:
|
|
101
|
+
self.update(render_status(ctx, tech))
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Country-level location map, rendered from embedded country borders.
|
|
2
|
+
|
|
3
|
+
Re-engineered from the old coarse box model: it loads low-resolution Natural
|
|
4
|
+
Earth country polygons (``data/countries.json``, built once from the 110m
|
|
5
|
+
dataset), auto-frames the view to the server's country plus surrounding
|
|
6
|
+
countries, and rasterises the real border lines into a braille grid with the
|
|
7
|
+
location marked. Projection is equirectangular with a cos(latitude) correction
|
|
8
|
+
so shapes aren't stretched. No zoom — the frame is chosen automatically.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
from functools import lru_cache
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
|
|
20
|
+
_DATA = Path(__file__).parent / "data" / "countries.json"
|
|
21
|
+
_CHAR_ASPECT = 2.0 # terminal cell height:width
|
|
22
|
+
|
|
23
|
+
# braille dot bit for a (row, col) within a 2-wide x 4-tall cell
|
|
24
|
+
_BITS = {
|
|
25
|
+
(0, 0): 0x01, (1, 0): 0x02, (2, 0): 0x04, (3, 0): 0x40,
|
|
26
|
+
(0, 1): 0x08, (1, 1): 0x10, (2, 1): 0x20, (3, 1): 0x80,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
Ring = list[tuple[float, float]]
|
|
30
|
+
BBox = tuple[float, float, float, float] # minlon, minlat, maxlon, maxlat
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@lru_cache(maxsize=1)
|
|
34
|
+
def _countries() -> list[tuple[str, list[tuple[Ring, BBox]]]]:
|
|
35
|
+
"""Load countries as (name, [(ring, bbox), ...]); cached for the process."""
|
|
36
|
+
raw = json.loads(_DATA.read_text())
|
|
37
|
+
out = []
|
|
38
|
+
for country in raw:
|
|
39
|
+
rings = []
|
|
40
|
+
for ring in country["r"]:
|
|
41
|
+
pts = [(p[0], p[1]) for p in ring]
|
|
42
|
+
if len(pts) < 3:
|
|
43
|
+
continue
|
|
44
|
+
lons = [p[0] for p in pts]
|
|
45
|
+
lats = [p[1] for p in pts]
|
|
46
|
+
rings.append((pts, (min(lons), min(lats), max(lons), max(lats))))
|
|
47
|
+
out.append((country["n"], rings))
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _point_in_ring(lon: float, lat: float, ring: Ring) -> bool:
|
|
52
|
+
inside = False
|
|
53
|
+
n = len(ring)
|
|
54
|
+
j = n - 1
|
|
55
|
+
for i in range(n):
|
|
56
|
+
xi, yi = ring[i]
|
|
57
|
+
xj, yj = ring[j]
|
|
58
|
+
if (yi > lat) != (yj > lat):
|
|
59
|
+
x_cross = (xj - xi) * (lat - yi) / (yj - yi) + xi
|
|
60
|
+
if lon < x_cross:
|
|
61
|
+
inside = not inside
|
|
62
|
+
j = i
|
|
63
|
+
return inside
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _containing_ring(lat: float, lon: float) -> tuple[str, BBox] | None:
|
|
67
|
+
"""Return (country_name, bbox) of the specific landmass ring under (lat, lon)."""
|
|
68
|
+
for name, rings in _countries():
|
|
69
|
+
for ring, (mnx, mny, mxx, mxy) in rings:
|
|
70
|
+
if mnx <= lon <= mxx and mny <= lat <= mxy and _point_in_ring(lon, lat, ring):
|
|
71
|
+
return name, (mnx, mny, mxx, mxy)
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def render(
|
|
76
|
+
lat: float | None = None,
|
|
77
|
+
lon: float | None = None,
|
|
78
|
+
cols: int = 56,
|
|
79
|
+
rows: int = 14,
|
|
80
|
+
zoom: float = 1.0,
|
|
81
|
+
marker: str = "●",
|
|
82
|
+
marker_style: str = "bold green",
|
|
83
|
+
border_style: str = "grey58",
|
|
84
|
+
home_style: str = "grey85",
|
|
85
|
+
) -> Text:
|
|
86
|
+
"""Render an outline map framed on the server's country + neighbours.
|
|
87
|
+
|
|
88
|
+
``zoom`` > 1 tightens the frame (closer), < 1 widens it (more surroundings).
|
|
89
|
+
"""
|
|
90
|
+
cols, rows = max(8, cols), max(4, rows)
|
|
91
|
+
if lat is None or lon is None:
|
|
92
|
+
return Text("no location", style="dim")
|
|
93
|
+
|
|
94
|
+
hit = _containing_ring(lat, lon)
|
|
95
|
+
home_name = hit[0] if hit else None
|
|
96
|
+
if hit:
|
|
97
|
+
mnx, mny, mxx, mxy = hit[1]
|
|
98
|
+
else: # ocean / unmatched — frame a default box around the marker
|
|
99
|
+
mnx, mny, mxx, mxy = lon - 6, lat - 6, lon + 6, lat + 6
|
|
100
|
+
|
|
101
|
+
cx, cy = (mnx + mxx) / 2, (mny + mxy) / 2
|
|
102
|
+
# frame span: country size + margin, with a floor so tiny countries aren't over-zoomed
|
|
103
|
+
lon_span = max(mxx - mnx, 6.0) * 1.6
|
|
104
|
+
lat_span = max(mxy - mny, 4.0) * 1.6
|
|
105
|
+
|
|
106
|
+
# aspect fit (cos-corrected) — expand the deficient axis so nothing distorts
|
|
107
|
+
k = max(0.15, math.cos(math.radians(cy)))
|
|
108
|
+
target = cols / (2 * rows) # desired projected width:height for square pixels
|
|
109
|
+
if (lon_span * k) / lat_span < target:
|
|
110
|
+
lon_span = target * lat_span / k
|
|
111
|
+
else:
|
|
112
|
+
lat_span = (lon_span * k) / target
|
|
113
|
+
|
|
114
|
+
lon_span /= zoom # user zoom (both axes equally → aspect preserved)
|
|
115
|
+
lat_span /= zoom
|
|
116
|
+
|
|
117
|
+
# Keep the marker inside the frame (within the central 80%) at any zoom, by
|
|
118
|
+
# nudging the frame centre toward it — so zooming in never loses the point.
|
|
119
|
+
cx = min(max(cx, lon - lon_span * 0.4), lon + lon_span * 0.4)
|
|
120
|
+
cy = min(max(cy, lat - lat_span * 0.4), lat + lat_span * 0.4)
|
|
121
|
+
|
|
122
|
+
lon0, lat_top = cx - lon_span / 2, cy + lat_span / 2
|
|
123
|
+
pw, ph = cols * 2, rows * 4
|
|
124
|
+
view = (lon0, cy - lat_span / 2, lon0 + lon_span, lat_top) # for bbox culling
|
|
125
|
+
|
|
126
|
+
def to_px(lon_: float, lat_: float) -> tuple[float, float]:
|
|
127
|
+
return (lon_ - lon0) / lon_span * pw, (lat_top - lat_) / lat_span * ph
|
|
128
|
+
|
|
129
|
+
grid = [bytearray(pw) for _ in range(ph)] # 0 empty, 1 border, 2 home border
|
|
130
|
+
|
|
131
|
+
def plot(x: int, y: int, v: int) -> None:
|
|
132
|
+
if 0 <= x < pw and 0 <= y < ph and grid[y][x] < v:
|
|
133
|
+
grid[y][x] = v
|
|
134
|
+
|
|
135
|
+
def draw_line(x0: float, y0: float, x1: float, y1: float, v: int) -> None:
|
|
136
|
+
# integer Bresenham over the (possibly out-of-range) endpoints
|
|
137
|
+
xi0, yi0, xi1, yi1 = int(x0), int(y0), int(x1), int(y1)
|
|
138
|
+
dx, dy = abs(xi1 - xi0), -abs(yi1 - yi0)
|
|
139
|
+
sx = 1 if xi0 < xi1 else -1
|
|
140
|
+
sy = 1 if yi0 < yi1 else -1
|
|
141
|
+
err = dx + dy
|
|
142
|
+
while True:
|
|
143
|
+
plot(xi0, yi0, v)
|
|
144
|
+
if xi0 == xi1 and yi0 == yi1:
|
|
145
|
+
break
|
|
146
|
+
e2 = 2 * err
|
|
147
|
+
if e2 >= dy:
|
|
148
|
+
err += dy
|
|
149
|
+
xi0 += sx
|
|
150
|
+
if e2 <= dx:
|
|
151
|
+
err += dx
|
|
152
|
+
yi0 += sy
|
|
153
|
+
|
|
154
|
+
vmnx, vmny, vmxx, vmxy = view
|
|
155
|
+
for name, rings in _countries():
|
|
156
|
+
v = 2 if name == home_name else 1
|
|
157
|
+
for ring, (rmnx, rmny, rmxx, rmxy) in rings:
|
|
158
|
+
if rmxx < vmnx or rmnx > vmxx or rmxy < vmny or rmny > vmxy:
|
|
159
|
+
continue # ring entirely outside view
|
|
160
|
+
prev = ring[-1]
|
|
161
|
+
for cur in ring:
|
|
162
|
+
x0, y0 = to_px(*prev)
|
|
163
|
+
x1, y1 = to_px(*cur)
|
|
164
|
+
draw_line(x0, y0, x1, y1, v)
|
|
165
|
+
prev = cur
|
|
166
|
+
|
|
167
|
+
mpx, mpy = to_px(lon, lat)
|
|
168
|
+
mcx, mcy = int(mpx) // 2, int(mpy) // 4
|
|
169
|
+
|
|
170
|
+
text = Text()
|
|
171
|
+
for cyc in range(rows):
|
|
172
|
+
for cxc in range(cols):
|
|
173
|
+
if cxc == mcx and cyc == mcy:
|
|
174
|
+
text.append(marker, style=marker_style)
|
|
175
|
+
continue
|
|
176
|
+
bits = home = 0
|
|
177
|
+
for (r, c), bit in _BITS.items():
|
|
178
|
+
cell = grid[cyc * 4 + r][cxc * 2 + c]
|
|
179
|
+
if cell:
|
|
180
|
+
bits |= bit
|
|
181
|
+
if cell == 2:
|
|
182
|
+
home = 1
|
|
183
|
+
if bits:
|
|
184
|
+
text.append(chr(0x2800 + bits), style=home_style if home else border_style)
|
|
185
|
+
else:
|
|
186
|
+
text.append(" ")
|
|
187
|
+
if cyc != rows - 1:
|
|
188
|
+
text.append("\n")
|
|
189
|
+
return text
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def country_name(lat: float | None, lon: float | None) -> str | None:
|
|
193
|
+
"""Name of the country at (lat, lon), for the panel title (or None)."""
|
|
194
|
+
if lat is None or lon is None:
|
|
195
|
+
return None
|
|
196
|
+
hit = _containing_ring(lat, lon)
|
|
197
|
+
return hit[0] if hit else None
|