""" 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}"