diff --git a/RFCP-Phase-2.2-Offline-Data-Caching.md b/RFCP-Phase-2.2-Offline-Data-Caching.md new file mode 100644 index 0000000..0ba41f1 --- /dev/null +++ b/RFCP-Phase-2.2-Offline-Data-Caching.md @@ -0,0 +1,1052 @@ +# RFCP Phase 2.2: Offline Data & Caching + +**Date:** January 31, 2025 +**Type:** Data Management & Performance +**Estimated:** 10-14 hours +**Priority:** HIGH โ€” enables true offline mode +**Depends on:** Phase 2.1 (Desktop App) + +--- + +## ๐ŸŽฏ Goal + +Enable fully offline operation by caching SRTM terrain data and OSM data locally. Add a region download wizard for first-run setup. + +--- + +## ๐Ÿ“Š Current Problem + +| Data Source | Current | Problem | +|-------------|---------|---------| +| SRTM (terrain) | Downloaded on-demand from NASA | Slow, requires internet | +| OSM Buildings | Overpass API query each time | Very slow, rate limited | +| OSM Water | Overpass API query each time | Slow | +| OSM Vegetation | Overpass API query each time | Slow | +| Map Tiles | Online from OpenStreetMap | Requires internet | + +**Result:** 10km calculation takes 2+ minutes mostly waiting for network. + +--- + +## ๐Ÿ—๏ธ Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Local Data Store โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ โ”‚ +โ”‚ ~/.rfcp/data/ (or %APPDATA%\RFCP\data\) โ”‚ +โ”‚ โ”‚ โ”‚ +โ”‚ โ”œโ”€โ”€ terrain/ โ”‚ +โ”‚ โ”‚ โ”œโ”€โ”€ N48E034.hgt # SRTM tile โ”‚ +โ”‚ โ”‚ โ”œโ”€โ”€ N48E035.hgt # ~25MB each โ”‚ +โ”‚ โ”‚ โ””โ”€โ”€ ... # ~120 for Ukraine โ”‚ +โ”‚ โ”‚ โ”‚ +โ”‚ โ”œโ”€โ”€ osm/ โ”‚ +โ”‚ โ”‚ โ”œโ”€โ”€ buildings/ โ”‚ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ 48.0_34.0_49.0_35.0.json โ”‚ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ ... # Cached by bbox โ”‚ +โ”‚ โ”‚ โ”œโ”€โ”€ water/ โ”‚ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ ... โ”‚ +โ”‚ โ”‚ โ””โ”€โ”€ vegetation/ โ”‚ +โ”‚ โ”‚ โ””โ”€โ”€ ... โ”‚ +โ”‚ โ”‚ โ”‚ +โ”‚ โ”œโ”€โ”€ tiles/ # Map tiles (future) โ”‚ +โ”‚ โ”‚ โ””โ”€โ”€ ... โ”‚ +โ”‚ โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€ regions.json # Downloaded regions โ”‚ +โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +--- + +## โœ… Tasks + +### Task 2.2.1: SRTM Local Cache (3-4 hours) + +**Update backend/app/services/terrain_service.py:** + +```python +import os +import struct +import numpy as np +from pathlib import Path +import httpx +import asyncio +from typing import Optional + +class TerrainService: + """SRTM terrain data with local caching""" + + # SRTM data sources (in order of preference) + SRTM_SOURCES = [ + "https://elevation-tiles-prod.s3.amazonaws.com/skadi/{lat_dir}/{tile}.hgt.gz", + "https://srtm.csi.cgiar.org/wp-content/uploads/files/srtm_5x5/TIFF/{tile}.zip", + ] + + def __init__(self): + self.data_path = Path(os.environ.get('RFCP_DATA_PATH', './data')) + self.terrain_path = self.data_path / 'terrain' + self.terrain_path.mkdir(parents=True, exist_ok=True) + + # In-memory cache for loaded tiles + self._tile_cache: dict[str, np.ndarray] = {} + self._max_cache_tiles = 20 # Keep max 20 tiles in memory (~500MB) + + def _get_tile_name(self, lat: float, lon: float) -> str: + """Get SRTM tile name for coordinates""" + lat_dir = 'N' if lat >= 0 else 'S' + lon_dir = 'E' if lon >= 0 else 'W' + lat_int = int(abs(lat // 1)) + lon_int = int(abs(lon // 1)) + return f"{lat_dir}{lat_int:02d}{lon_dir}{lon_int:03d}" + + def _get_tile_path(self, tile_name: str) -> Path: + """Get local path for tile""" + return self.terrain_path / f"{tile_name}.hgt" + + async def _download_tile(self, tile_name: str) -> bool: + """Download SRTM tile if not cached""" + tile_path = self._get_tile_path(tile_name) + + if tile_path.exists(): + return True + + # Try each source + lat_dir = tile_name[0:3] # N48 or S48 + + for source_url in self.SRTM_SOURCES: + url = source_url.format( + lat_dir=lat_dir, + tile=tile_name + ) + + try: + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.get(url) + + if response.status_code == 200: + # Handle gzip or raw + data = response.content + + if url.endswith('.gz'): + import gzip + data = gzip.decompress(data) + elif url.endswith('.zip'): + import zipfile + import io + with zipfile.ZipFile(io.BytesIO(data)) as zf: + # Find .hgt file in zip + for name in zf.namelist(): + if name.endswith('.hgt'): + data = zf.read(name) + break + + # Save to cache + tile_path.write_bytes(data) + print(f"[Terrain] Downloaded {tile_name}") + return True + + except Exception as e: + print(f"[Terrain] Failed to download from {url}: {e}") + continue + + print(f"[Terrain] Could not download {tile_name}") + return False + + def _load_tile(self, tile_name: str) -> Optional[np.ndarray]: + """Load tile from disk into memory""" + # Check memory cache first + if tile_name in self._tile_cache: + return self._tile_cache[tile_name] + + tile_path = self._get_tile_path(tile_name) + + if not tile_path.exists(): + return None + + try: + # SRTM HGT format: 1201x1201 or 3601x3601 big-endian int16 + data = tile_path.read_bytes() + + if len(data) == 1201 * 1201 * 2: + size = 1201 # SRTM3 (90m) + elif len(data) == 3601 * 3601 * 2: + size = 3601 # SRTM1 (30m) + else: + print(f"[Terrain] Unknown tile size: {len(data)}") + return None + + # Parse as numpy array + tile = np.frombuffer(data, dtype='>i2').reshape((size, size)) + + # Cache in memory + if len(self._tile_cache) >= self._max_cache_tiles: + # Remove oldest + oldest = next(iter(self._tile_cache)) + del self._tile_cache[oldest] + + self._tile_cache[tile_name] = tile + return tile + + except Exception as e: + print(f"[Terrain] Failed to load {tile_name}: {e}") + return None + + async def get_elevation(self, lat: float, lon: float) -> float: + """Get elevation at point, downloading tile if needed""" + tile_name = self._get_tile_name(lat, lon) + + # Ensure tile is downloaded + await self._download_tile(tile_name) + + # Load tile + tile = self._load_tile(tile_name) + + if tile is None: + return 0.0 + + # Calculate position within tile + size = tile.shape[0] + + # SRTM tiles start at SW corner + lat_frac = lat - int(lat) + lon_frac = lon - int(lon) + + row = int((1 - lat_frac) * (size - 1)) + col = int(lon_frac * (size - 1)) + + row = max(0, min(row, size - 1)) + col = max(0, min(col, size - 1)) + + elevation = tile[row, col] + + # Handle void values + if elevation == -32768: + return 0.0 + + return float(elevation) + + async def ensure_tiles_for_bbox( + self, + min_lat: float, min_lon: float, + max_lat: float, max_lon: float + ) -> list[str]: + """Pre-download all tiles needed for bounding box""" + tiles_needed = [] + + for lat in range(int(min_lat), int(max_lat) + 1): + for lon in range(int(min_lon), int(max_lon) + 1): + tile_name = self._get_tile_name(lat, lon) + tiles_needed.append(tile_name) + + # Download in parallel + results = await asyncio.gather(*[ + self._download_tile(tile) for tile in tiles_needed + ]) + + downloaded = [t for t, ok in zip(tiles_needed, results) if ok] + return downloaded + + def get_cached_tiles(self) -> list[str]: + """List all cached tiles""" + return [f.stem for f in self.terrain_path.glob("*.hgt")] + + def get_cache_size_mb(self) -> float: + """Get total cache size in MB""" + total = sum(f.stat().st_size for f in self.terrain_path.glob("*.hgt")) + return total / (1024 * 1024) + + +terrain_service = TerrainService() +``` + +--- + +### Task 2.2.2: OSM Local Cache (3-4 hours) + +**Update backend/app/services/buildings_service.py (and water, vegetation):** + +```python +import os +import json +import hashlib +from pathlib import Path +from datetime import datetime, timedelta +import httpx +from typing import Optional + +class OSMCache: + """Local cache for OSM data""" + + CACHE_EXPIRY_DAYS = 30 # Re-download after 30 days + + def __init__(self, cache_type: str): + self.data_path = Path(os.environ.get('RFCP_DATA_PATH', './data')) + self.cache_path = self.data_path / 'osm' / cache_type + self.cache_path.mkdir(parents=True, exist_ok=True) + + def _get_cache_key(self, min_lat: float, min_lon: float, max_lat: float, max_lon: float) -> str: + """Generate cache key from bbox""" + # Round to 0.1 degree grid for better cache hits + min_lat = round(min_lat, 1) + min_lon = round(min_lon, 1) + max_lat = round(max_lat, 1) + max_lon = round(max_lon, 1) + return f"{min_lat}_{min_lon}_{max_lat}_{max_lon}" + + def _get_cache_path(self, cache_key: str) -> Path: + """Get file path for cache key""" + return self.cache_path / f"{cache_key}.json" + + def get(self, min_lat: float, min_lon: float, max_lat: float, max_lon: float) -> Optional[dict]: + """Get cached data if available and not expired""" + cache_key = self._get_cache_key(min_lat, min_lon, max_lat, max_lon) + cache_file = self._get_cache_path(cache_key) + + if not cache_file.exists(): + return None + + try: + data = json.loads(cache_file.read_text()) + + # Check expiry + cached_at = datetime.fromisoformat(data.get('_cached_at', '2000-01-01')) + if datetime.now() - cached_at > timedelta(days=self.CACHE_EXPIRY_DAYS): + return None # Expired + + return data.get('data') + + except Exception as e: + print(f"[OSMCache] Failed to read cache: {e}") + return None + + def set(self, min_lat: float, min_lon: float, max_lat: float, max_lon: float, data: dict): + """Save data to cache""" + cache_key = self._get_cache_key(min_lat, min_lon, max_lat, max_lon) + cache_file = self._get_cache_path(cache_key) + + try: + cache_data = { + '_cached_at': datetime.now().isoformat(), + '_bbox': [min_lat, min_lon, max_lat, max_lon], + 'data': data + } + cache_file.write_text(json.dumps(cache_data)) + + except Exception as e: + print(f"[OSMCache] Failed to write cache: {e}") + + def clear(self): + """Clear all cached data""" + for f in self.cache_path.glob("*.json"): + f.unlink() + + def get_size_mb(self) -> float: + """Get cache size in MB""" + total = sum(f.stat().st_size for f in self.cache_path.glob("*.json")) + return total / (1024 * 1024) + + +class BuildingsService: + """Buildings service with local caching""" + + OVERPASS_URL = "https://overpass-api.de/api/interpreter" + + def __init__(self): + self.cache = OSMCache('buildings') + + async def fetch_buildings( + self, + min_lat: float, min_lon: float, + max_lat: float, max_lon: float + ) -> list[dict]: + """Fetch buildings, using cache if available""" + + # Check cache first + cached = self.cache.get(min_lat, min_lon, max_lat, max_lon) + if cached is not None: + print(f"[Buildings] Cache hit for bbox") + return cached + + # Fetch from Overpass + print(f"[Buildings] Fetching from Overpass API...") + + query = f""" + [out:json][timeout:60]; + ( + way["building"]({min_lat},{min_lon},{max_lat},{max_lon}); + ); + out body; + >; + out skel qt; + """ + + try: + async with httpx.AsyncClient(timeout=90.0) as client: + response = await client.post( + self.OVERPASS_URL, + data={"data": query} + ) + response.raise_for_status() + data = response.json() + + buildings = self._parse_response(data) + + # Save to cache + self.cache.set(min_lat, min_lon, max_lat, max_lon, buildings) + + return buildings + + except Exception as e: + print(f"[Buildings] Fetch error: {e}") + return [] + + def _parse_response(self, data: dict) -> list[dict]: + """Parse Overpass response into building list""" + # ... existing parsing code ... + pass + + +# Apply same pattern to WaterService and VegetationService +``` + +--- + +### Task 2.2.3: Region Download API (2-3 hours) + +**backend/app/api/routes/regions.py:** + +```python +from fastapi import APIRouter, BackgroundTasks, HTTPException +from pydantic import BaseModel +from typing import Optional +import asyncio + +router = APIRouter(prefix="/api/regions", tags=["regions"]) + +# Predefined regions +REGIONS = { + "ukraine": { + "name": "Ukraine", + "bbox": [44.0, 22.0, 52.5, 40.5], # min_lat, min_lon, max_lat, max_lon + "srtm_tiles": 120, + "estimated_size_gb": 3.0, + }, + "ukraine_east": { + "name": "Eastern Ukraine (Donbas)", + "bbox": [47.0, 34.0, 50.5, 40.5], + "srtm_tiles": 24, + "estimated_size_gb": 0.6, + }, + "ukraine_central": { + "name": "Central Ukraine", + "bbox": [48.0, 30.0, 51.0, 36.0], + "srtm_tiles": 18, + "estimated_size_gb": 0.5, + }, + "kyiv_region": { + "name": "Kyiv Region", + "bbox": [49.5, 29.5, 51.5, 32.5], + "srtm_tiles": 6, + "estimated_size_gb": 0.15, + }, +} + +# Download progress tracking +_download_tasks: dict[str, dict] = {} + + +class RegionInfo(BaseModel): + id: str + name: str + bbox: list[float] + srtm_tiles: int + estimated_size_gb: float + downloaded: bool = False + download_progress: float = 0.0 + + +class DownloadProgress(BaseModel): + task_id: str + region_id: str + status: str # queued, downloading_terrain, downloading_osm, done, error + progress: float # 0-100 + current_step: str + downloaded_mb: float + error: Optional[str] = None + + +@router.get("/available") +async def list_regions() -> list[RegionInfo]: + """List available regions for download""" + from app.services.terrain_service import terrain_service + + cached_tiles = set(terrain_service.get_cached_tiles()) + + result = [] + for region_id, info in REGIONS.items(): + # Check how many tiles are downloaded + min_lat, min_lon, max_lat, max_lon = info["bbox"] + needed_tiles = set() + for lat in range(int(min_lat), int(max_lat) + 1): + for lon in range(int(min_lon), int(max_lon) + 1): + tile = terrain_service._get_tile_name(lat, lon) + needed_tiles.add(tile) + + downloaded_tiles = needed_tiles & cached_tiles + progress = len(downloaded_tiles) / len(needed_tiles) * 100 if needed_tiles else 0 + + result.append(RegionInfo( + id=region_id, + name=info["name"], + bbox=info["bbox"], + srtm_tiles=info["srtm_tiles"], + estimated_size_gb=info["estimated_size_gb"], + downloaded=progress >= 100, + download_progress=progress + )) + + return result + + +@router.post("/download/{region_id}") +async def start_download(region_id: str, background_tasks: BackgroundTasks) -> dict: + """Start downloading a region""" + if region_id not in REGIONS: + raise HTTPException(404, f"Region '{region_id}' not found") + + # Check if already downloading + for task_id, task in _download_tasks.items(): + if task["region_id"] == region_id and task["status"] not in ["done", "error"]: + return {"task_id": task_id, "status": "already_downloading"} + + # Create task + import uuid + task_id = str(uuid.uuid4())[:8] + + _download_tasks[task_id] = { + "region_id": region_id, + "status": "queued", + "progress": 0.0, + "current_step": "Starting...", + "downloaded_mb": 0.0, + "error": None + } + + # Start background download + background_tasks.add_task(download_region_task, task_id, region_id) + + return {"task_id": task_id, "status": "started"} + + +async def download_region_task(task_id: str, region_id: str): + """Background task to download region data""" + from app.services.terrain_service import terrain_service + from app.services.buildings_service import buildings_service + from app.services.water_service import water_service + from app.services.vegetation_service import vegetation_service + + task = _download_tasks[task_id] + region = REGIONS[region_id] + min_lat, min_lon, max_lat, max_lon = region["bbox"] + + try: + # Phase 1: Download SRTM tiles (0-70%) + task["status"] = "downloading_terrain" + task["current_step"] = "Downloading terrain data..." + + tiles = await terrain_service.ensure_tiles_for_bbox( + min_lat, min_lon, max_lat, max_lon + ) + + task["progress"] = 70.0 + task["downloaded_mb"] = terrain_service.get_cache_size_mb() + + # Phase 2: Pre-cache OSM data (70-100%) + task["status"] = "downloading_osm" + task["current_step"] = "Downloading building data..." + + # Download OSM in grid chunks + grid_size = 1.0 # 1 degree chunks + total_chunks = 0 + done_chunks = 0 + + for lat in range(int(min_lat), int(max_lat) + 1): + for lon in range(int(min_lon), int(max_lon) + 1): + total_chunks += 1 + + for lat in range(int(min_lat), int(max_lat) + 1): + for lon in range(int(min_lon), int(max_lon) + 1): + chunk_min_lat = lat + chunk_min_lon = lon + chunk_max_lat = lat + grid_size + chunk_max_lon = lon + grid_size + + # Buildings + await buildings_service.fetch_buildings( + chunk_min_lat, chunk_min_lon, + chunk_max_lat, chunk_max_lon + ) + + # Water (smaller, faster) + await water_service.fetch_water_bodies( + chunk_min_lat, chunk_min_lon, + chunk_max_lat, chunk_max_lon + ) + + # Vegetation + await vegetation_service.fetch_vegetation( + chunk_min_lat, chunk_min_lon, + chunk_max_lat, chunk_max_lon + ) + + done_chunks += 1 + task["progress"] = 70 + (done_chunks / total_chunks) * 30 + task["current_step"] = f"OSM data: {done_chunks}/{total_chunks} chunks" + + # Small delay to avoid rate limiting + await asyncio.sleep(1.0) + + task["status"] = "done" + task["progress"] = 100.0 + task["current_step"] = "Complete!" + + except Exception as e: + task["status"] = "error" + task["error"] = str(e) + task["current_step"] = f"Error: {e}" + + +@router.get("/download/{task_id}/progress") +async def get_download_progress(task_id: str) -> DownloadProgress: + """Get download progress""" + if task_id not in _download_tasks: + raise HTTPException(404, "Task not found") + + task = _download_tasks[task_id] + return DownloadProgress( + task_id=task_id, + region_id=task["region_id"], + status=task["status"], + progress=task["progress"], + current_step=task["current_step"], + downloaded_mb=task["downloaded_mb"], + error=task["error"] + ) + + +@router.delete("/cache") +async def clear_cache() -> dict: + """Clear all cached data""" + from app.services.terrain_service import terrain_service + from app.services.buildings_service import buildings_service + + # Clear OSM caches + buildings_service.cache.clear() + # water_service.cache.clear() + # vegetation_service.cache.clear() + + # Don't clear SRTM - too expensive to re-download + + return {"status": "ok", "message": "OSM cache cleared"} + + +@router.get("/cache/stats") +async def get_cache_stats() -> dict: + """Get cache statistics""" + from app.services.terrain_service import terrain_service + from app.services.buildings_service import buildings_service + + return { + "terrain_mb": terrain_service.get_cache_size_mb(), + "terrain_tiles": len(terrain_service.get_cached_tiles()), + "buildings_mb": buildings_service.cache.get_size_mb(), + # "water_mb": water_service.cache.get_size_mb(), + # "vegetation_mb": vegetation_service.cache.get_size_mb(), + } +``` + +--- + +### Task 2.2.4: First-Run Region Wizard (Frontend) (2-3 hours) + +**frontend/src/components/RegionWizard.tsx:** + +```typescript +import { useState, useEffect } from 'react'; +import { apiService } from '../services/api'; + +interface Region { + id: string; + name: string; + bbox: number[]; + srtm_tiles: number; + estimated_size_gb: number; + downloaded: boolean; + download_progress: number; +} + +interface DownloadProgress { + task_id: string; + status: string; + progress: number; + current_step: string; + downloaded_mb: number; + error?: string; +} + +export function RegionWizard({ onComplete }: { onComplete: () => void }) { + const [regions, setRegions] = useState([]); + const [selectedRegion, setSelectedRegion] = useState(null); + const [downloading, setDownloading] = useState(false); + const [progress, setProgress] = useState(null); + + useEffect(() => { + loadRegions(); + }, []); + + const loadRegions = async () => { + const data = await apiService.getRegions(); + setRegions(data); + }; + + const startDownload = async () => { + if (!selectedRegion) return; + + setDownloading(true); + const { task_id } = await apiService.downloadRegion(selectedRegion); + + // Poll for progress + const interval = setInterval(async () => { + const prog = await apiService.getDownloadProgress(task_id); + setProgress(prog); + + if (prog.status === 'done' || prog.status === 'error') { + clearInterval(interval); + setDownloading(false); + + if (prog.status === 'done') { + onComplete(); + } + } + }, 1000); + }; + + const skipDownload = () => { + // Store preference to not show again + localStorage.setItem('rfcp_region_wizard_skipped', 'true'); + onComplete(); + }; + + return ( +
+
+

Welcome to RFCP

+

RF Coverage Planner

+ +

+ Select a region to download for offline use. + This includes terrain elevation and building data. +

+ + {!downloading ? ( + <> +
+ {regions.map(region => ( +
setSelectedRegion(region.id)} + > +
{region.name}
+
~{region.estimated_size_gb} GB
+ {region.downloaded &&
โœ“ Downloaded
} + {region.download_progress > 0 && region.download_progress < 100 && ( +
{region.download_progress.toFixed(0)}%
+ )} +
+ ))} +
+ +
+ + +
+ + ) : ( +
+
+
+
+
+ {progress?.current_step || 'Starting...'} +
+
+ {progress?.downloaded_mb.toFixed(1)} MB downloaded +
+
+ )} +
+
+ ); +} +``` + +**CSS (add to styles):** + +```css +.region-wizard-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.9); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +.region-wizard { + background: #1a1a2e; + border-radius: 12px; + padding: 40px; + max-width: 500px; + width: 90%; + color: white; +} + +.region-wizard h1 { + font-size: 32px; + background: linear-gradient(90deg, #00d4ff, #00ff88); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + margin-bottom: 8px; +} + +.region-wizard h2 { + font-size: 14px; + color: #888; + margin-bottom: 24px; +} + +.region-list { + display: flex; + flex-direction: column; + gap: 8px; + margin: 24px 0; +} + +.region-item { + padding: 16px; + background: #252540; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + gap: 16px; + border: 2px solid transparent; + transition: all 0.2s; +} + +.region-item:hover { + background: #303050; +} + +.region-item.selected { + border-color: #00d4ff; +} + +.region-item.downloaded { + opacity: 0.7; +} + +.region-name { + flex: 1; + font-weight: 500; +} + +.region-size { + color: #888; + font-size: 14px; +} + +.region-badge { + background: #00ff88; + color: #000; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; +} + +.wizard-actions { + display: flex; + gap: 12px; + margin-top: 24px; +} + +.btn-primary { + flex: 1; + padding: 12px 24px; + background: linear-gradient(90deg, #00d4ff, #00ff88); + border: none; + border-radius: 8px; + color: #000; + font-weight: 600; + cursor: pointer; +} + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-secondary { + padding: 12px 24px; + background: transparent; + border: 1px solid #444; + border-radius: 8px; + color: #888; + cursor: pointer; +} + +.download-progress { + margin-top: 24px; +} + +.progress-bar { + height: 8px; + background: #333; + border-radius: 4px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, #00d4ff, #00ff88); + transition: width 0.3s; +} + +.progress-text { + margin-top: 12px; + text-align: center; + color: #888; +} + +.progress-stats { + margin-top: 8px; + text-align: center; + font-size: 14px; + color: #666; +} +``` + +--- + +### Task 2.2.5: Integration & Testing (1-2 hours) + +**Update App.tsx to show wizard on first run:** + +```typescript +function App() { + const [showWizard, setShowWizard] = useState(false); + + useEffect(() => { + // Check if first run or no region downloaded + const skipped = localStorage.getItem('rfcp_region_wizard_skipped'); + if (!skipped && isDesktop()) { + checkRegionStatus(); + } + }, []); + + const checkRegionStatus = async () => { + const regions = await apiService.getRegions(); + const hasDownloaded = regions.some(r => r.downloaded); + if (!hasDownloaded) { + setShowWizard(true); + } + }; + + return ( + <> + {showWizard && setShowWizard(false)} />} + {/* ... rest of app */} + + ); +} +``` + +--- + +## ๐Ÿงช Testing + +```bash +# Test cache stats +curl http://127.0.0.1:8888/api/regions/cache/stats + +# Test available regions +curl http://127.0.0.1:8888/api/regions/available + +# Start region download +curl -X POST http://127.0.0.1:8888/api/regions/download/kyiv_region + +# Check progress +curl http://127.0.0.1:8888/api/regions/download/{task_id}/progress +``` + +**Test Scenarios:** +1. First run shows wizard +2. Can skip wizard (online mode) +3. Region download shows progress +4. After download, calculations are faster +5. Offline mode works (disconnect internet) + +--- + +## โœ… Success Criteria + +- [ ] SRTM tiles cached locally in `data/terrain/` +- [ ] OSM data cached locally in `data/osm/` +- [ ] Region wizard shows on first run +- [ ] Can download Ukraine East (~0.6GB) in ~10 min +- [ ] Calculations 10x faster with cached data +- [ ] Works fully offline after region download +- [ ] Cache stats API works + +--- + +## ๐Ÿ“Š Expected Performance Improvement + +| Scenario | Before (Online) | After (Cached) | +|----------|-----------------|----------------| +| 5km Fast | ~30 sec | ~2 sec | +| 5km Full | ~3 min | ~15 sec | +| 10km Fast | ~2 min | ~5 sec | +| 10km Full | ~10 min | ~1 min | + +--- + +## ๐Ÿ”œ Next: Phase 2.3 + +- [ ] GPU acceleration (CUDA/OpenCL) +- [ ] Offline map tiles (MBTiles) +- [ ] Auto-updater +- [ ] Linux/Mac builds + +--- + +**Ready for Claude Code** ๐Ÿš€ diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index b608676..cb3f091 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,6 +4,7 @@ import tailwindcss from '@tailwindcss/vite' import path from 'path' export default defineConfig({ + base: './', plugins: [react(), tailwindcss()], resolve: { alias: {