File size: 9,298 Bytes
90674b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
"""
map_utils.py - Generate a country outline map from Nominatim GeoJSON.
No tile server dependency - draws directly from polygon data.
"""

import io
import math
import tempfile
from typing import Optional, Tuple

import requests
from PIL import Image, ImageDraw, ImageFont

NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"

_HEADERS = {
    "User-Agent": "OSINTThreatAnalyst/1.0 (HuggingFace Space; non-commercial research)"
}

# Map canvas dimensions
_W = 900
_H = 540
_PAD = 48   # padding around the border polygon

# Colors
_BG          = (240, 244, 250)   # light blue-grey background (ocean-like)
_LAND        = (225, 232, 220)   # muted green-grey for country fill
_BORDER      = (180, 40,  40)    # red border line
_BORDER2     = (255, 255, 255)   # white inner line for contrast
_GRID_COLOR  = (210, 215, 220)   # faint grid lines
_LABEL_COLOR = (30,  30,  30)    # country name label
_ATTR_BG     = (255, 255, 255)
_ATTR_FG     = (100, 100, 100)


# ---------------------------------------------------------------------------
# Coordinate projection
# ---------------------------------------------------------------------------

def _project(lon: float, lat: float,
             lon_min: float, lat_min: float,
             lon_max: float, lat_max: float,
             w: int, h: int, pad: int) -> Tuple[int, int]:
    """Equirectangular projection with padding."""
    lon_span = lon_max - lon_min or 1e-6
    lat_span = lat_max - lat_min or 1e-6
    usable_w = w - 2 * pad
    usable_h = h - 2 * pad
    # Maintain aspect ratio
    scale = min(usable_w / lon_span, usable_h / lat_span)
    offset_x = pad + (usable_w - lon_span * scale) / 2
    offset_y = pad + (usable_h - lat_span * scale) / 2
    px = int(offset_x + (lon - lon_min) * scale)
    py = int(offset_y + (lat_max - lat) * scale)   # Y inverted
    return px, py


def _ring_to_pixels(ring, lon_min, lat_min, lon_max, lat_max):
    return [
        _project(lon, lat, lon_min, lat_min, lon_max, lat_max, _W, _H, _PAD)
        for lon, lat in ring
    ]


# ---------------------------------------------------------------------------
# Drawing helpers
# ---------------------------------------------------------------------------

def _draw_grid(draw: ImageDraw.ImageDraw,
               lon_min, lat_min, lon_max, lat_max) -> None:
    """Draw faint lat/lon grid lines."""
    lon_span = lon_max - lon_min
    lat_span = lat_max - lat_min
    step = 10.0
    for s in [1, 2, 5, 10, 20, 30]:
        if lon_span / s <= 6 and lat_span / s <= 6:
            step = float(s)
            break

    lon_start = math.floor(lon_min / step) * step
    lat_start = math.floor(lat_min / step) * step

    lon = lon_start
    while lon <= lon_max + step:
        x1, _ = _project(lon, lat_min, lon_min, lat_min, lon_max, lat_max, _W, _H, _PAD)
        x2, _ = _project(lon, lat_max, lon_min, lat_min, lon_max, lat_max, _W, _H, _PAD)
        draw.line([(x1, 0), (x2, _H)], fill=_GRID_COLOR, width=1)
        lon += step

    lat = lat_start
    while lat <= lat_max + step:
        _, y1 = _project(lon_min, lat, lon_min, lat_min, lon_max, lat_max, _W, _H, _PAD)
        _, y2 = _project(lon_max, lat, lon_min, lat_min, lon_max, lat_max, _W, _H, _PAD)
        draw.line([(0, y1), (_W, y2)], fill=_GRID_COLOR, width=1)
        lat += step


def _draw_geojson(draw: ImageDraw.ImageDraw, geojson: dict,
                  lon_min, lat_min, lon_max, lat_max) -> None:
    """Fill and stroke the country polygon."""
    geom_type = geojson.get("type", "")
    coords    = geojson.get("coordinates", [])

    def draw_ring(ring):
        pts = _ring_to_pixels(ring, lon_min, lat_min, lon_max, lat_max)
        if len(pts) < 3:
            return
        draw.polygon(pts, fill=_LAND)
        # White inner stroke for contrast, then red outer
        draw.line(pts + [pts[0]], fill=_BORDER2, width=3)
        draw.line(pts + [pts[0]], fill=_BORDER,  width=2)

    if geom_type == "Polygon" and coords:
        draw_ring(coords[0])
    elif geom_type == "MultiPolygon":
        for polygon in coords:
            if polygon:
                draw_ring(polygon[0])


def _add_label(canvas: Image.Image, country: str,
               lon_min, lat_min, lon_max, lat_max) -> None:
    """Add country name label at the centroid."""
    draw = ImageDraw.Draw(canvas)
    cx = (lon_min + lon_max) / 2
    cy = (lat_min + lat_max) / 2
    px, py = _project(cx, cy, lon_min, lat_min, lon_max, lat_max, _W, _H, _PAD)

    # Try to load a font; fall back to default
    font = None
    for size in [18, 14]:
        try:
            font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", size)
            break
        except Exception:
            try:
                font = ImageFont.truetype("arial.ttf", size)
                break
            except Exception:
                font = ImageFont.load_default()
                break

    text = country.upper()
    try:
        bbox = draw.textbbox((0, 0), text, font=font)
        tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
    except Exception:
        tw, th = len(text) * 7, 12

    # White shadow box
    margin = 4
    draw.rectangle(
        [px - tw // 2 - margin, py - th // 2 - margin,
         px + tw // 2 + margin, py + th // 2 + margin],
        fill=(255, 255, 255, 180),
    )
    draw.text((px - tw // 2, py - th // 2), text, fill=_LABEL_COLOR, font=font)


def _add_attribution(canvas: Image.Image) -> None:
    draw = ImageDraw.Draw(canvas)
    text = "© OpenStreetMap contributors  |  Nominatim geocoding"
    try:
        font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 11)
    except Exception:
        font = ImageFont.load_default()
    draw.rectangle([0, _H - 18, _W, _H], fill=_ATTR_BG)
    draw.text((4, _H - 15), text, fill=_ATTR_FG, font=font)


def _add_compass(canvas: Image.Image) -> None:
    """Simple N arrow in the bottom-right corner."""
    draw = ImageDraw.Draw(canvas)
    cx, cy = _W - 30, _H - 40
    r = 12
    # N arrow
    draw.polygon([(cx, cy - r), (cx - 5, cy + 4), (cx + 5, cy + 4)], fill=(60, 60, 60))
    try:
        font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 11)
    except Exception:
        font = ImageFont.load_default()
    draw.text((cx - 4, cy - r - 14), "N", fill=(40, 40, 40), font=font)


# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------

def generate_country_map(country: str) -> Tuple[Optional[str], Optional[str]]:
    """
    Generate a country outline map from Nominatim GeoJSON (no tile server).
    Returns (path, None) on success or (None, error_message) on failure.
    """
    if not country or not country.strip():
        return None, "No country specified."

    # Nominatim lookup with retry
    results = None
    last_err = None
    import time as _t
    for attempt in range(3):
        try:
            resp = requests.get(
                NOMINATIM_URL,
                params={
                    "q":               country,
                    "format":          "json",
                    "limit":           5,
                    "polygon_geojson": 1,
                },
                headers=_HEADERS,
                timeout=20,
            )
            resp.raise_for_status()
            results = resp.json()
            if results:
                break
            last_err = "No geocoding results returned."
        except Exception as e:
            last_err = str(e)
            if attempt < 2:
                _t.sleep(1.5)

    if not results:
        return None, f"Nominatim lookup failed: {last_err}"

    # Pick best result
    entry = None
    for r in results:
        if r.get("type") in ("administrative", "country") or r.get("class") == "boundary":
            entry = r
            break
    if entry is None:
        entry = results[0]

    bb = entry.get("boundingbox")
    if not bb or len(bb) != 4:
        return None, "Bounding box not available from geocoder."

    try:
        south, north, west, east = (float(v) for v in bb)
    except Exception as e:
        return None, f"Invalid bounding box: {e}"

    geojson = entry.get("geojson")
    if not geojson:
        return None, "No GeoJSON polygon available for this country."

    # Add a small margin around the bounding box
    margin_lon = (east - west)   * 0.12
    margin_lat = (north - south) * 0.12
    lon_min = west  - margin_lon
    lon_max = east  + margin_lon
    lat_min = south - margin_lat
    lat_max = north + margin_lat

    # Build canvas
    try:
        canvas = Image.new("RGB", (_W, _H), _BG)
        draw   = ImageDraw.Draw(canvas)

        # Grid
        _draw_grid(draw, lon_min, lat_min, lon_max, lat_max)

        # Country polygon
        _draw_geojson(draw, geojson, lon_min, lat_min, lon_max, lat_max)

        # Labels / decorations
        _add_label(canvas, country, lon_min, lat_min, lon_max, lat_max)
        _add_compass(canvas)
        _add_attribution(canvas)

        tmp = tempfile.mktemp(suffix=".png")
        canvas.save(tmp, format="PNG")
        return tmp, None

    except Exception as e:
        return None, f"Map render failed: {e}"