mirror of
https://github.com/0015/OfflineMapDownloader.git
synced 2026-07-03 03:38:38 +00:00
First Commit
This commit is contained in:
parent
99e94bcafc
commit
75e3ed5c12
7 changed files with 896 additions and 0 deletions
108
README.md
Normal file
108
README.md
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# 🗺️ Offline Map Downloader
|
||||
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://youtu.be/uJirSqlyhA4)
|
||||
<p>Simple Python-Flask App</p>
|
||||
</div>
|
||||
|
||||
|
||||
This is a Flask web application that allows you to **select a geographic area on a map** and download OpenStreetMap or Satellite tiles as a `.zip` or `.mbtiles` file for offline use.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Important Notice
|
||||
|
||||
This project is intended for **personal, educational, or experimental use only**.
|
||||
|
||||
It does **not use any API keys or authenticated tile services**, and fetches tiles directly from public endpoints like OpenStreetMap and ArcGIS. As such:
|
||||
|
||||
> **Do not use this tool for commercial applications or large-scale automated downloads.**
|
||||
> Please respect the tile providers' usage policies.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Features
|
||||
|
||||
- 📍 Select area with a rectangle on the map
|
||||
- 🔍 Choose zoom level range (10–19)
|
||||
- 🌐 Switch between OpenStreetMap and Satellite view
|
||||
- 🧮 Preview tile count before download
|
||||
- 🎨 Live preview of selected area using actual map tiles
|
||||
- 💾 Export to `.zip` or `.mbtiles`
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/0015/OfflineMapDownloader.git
|
||||
cd OfflineMapDownloader
|
||||
```
|
||||
|
||||
### 2. Create & Activate Virtual Environment
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate # macOS/Linux
|
||||
# OR
|
||||
.venv\Scripts\activate # Windows
|
||||
```
|
||||
|
||||
### 3. Install Dependencies
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 4. Run the App
|
||||
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
Then open your browser and go to:
|
||||
👉 [http://127.0.0.1:5000](http://127.0.0.1:5000)
|
||||
|
||||
---
|
||||
|
||||
## 📁 Output Formats
|
||||
|
||||
- `tiles.zip` – folder structure with PNG tiles by zoom/x/y
|
||||
- `tiles.mbtiles` – SQLite-based format (flat database file)
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Dependencies
|
||||
|
||||
- `Flask`
|
||||
- `requests`
|
||||
|
||||
See [`requirements.txt`](./requirements.txt)
|
||||
|
||||
---
|
||||
|
||||
## 📝 License
|
||||
|
||||
MIT License
|
||||
(c) 2025 Eric Nam / ThatProject
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Attribution
|
||||
|
||||
Map tiles provided by:
|
||||
- [OpenStreetMap](https://www.openstreetmap.org)
|
||||
- [Esri Satellite Imagery](https://www.arcgis.com/home/item.html?id=10df2279f9684e4a9f6a7f08febac2a9)
|
||||
|
||||
---
|
||||
|
||||
## 🙌 Credits & Reference
|
||||
|
||||
This project was inspired by [AliFlux/MapTilesDownloader](https://github.com/AliFlux/MapTilesDownloader)
|
||||
Special thanks to their work on simplifying tile downloading logic.
|
||||
|
||||
Created by [@ThatProject](https://github.com/0015)
|
||||
249
app.py
Executable file
249
app.py
Executable file
|
|
@ -0,0 +1,249 @@
|
|||
"""
|
||||
Offline Tile Downloader (Flask-based Web App)
|
||||
---------------------------------------------
|
||||
|
||||
This Python Flask application allows users to download OpenStreetMap tiles for offline use,
|
||||
based on a selected geographic bounding box and zoom levels.
|
||||
|
||||
Key Features:
|
||||
- Supports two download formats: ZIP and MBTiles.
|
||||
- Automatically fetches and stores map tiles from the public OSM tile server.
|
||||
- Caches downloaded tiles to avoid redundant requests.
|
||||
- Includes an adjustable TILE_MARGIN option to download extra rows/columns of tiles around the selected area,
|
||||
useful to prevent missing edge tiles on display devices.
|
||||
- Enforces a maximum tile count to prevent excessive server load (default: 20,000).
|
||||
|
||||
Endpoints:
|
||||
- `/`: Renders the HTML UI.
|
||||
- `/preview_tile_count`: Calculates how many tiles will be downloaded (with margin).
|
||||
- `/download_tiles`: Downloads the tiles in the specified format (ZIP or MBTiles).
|
||||
|
||||
Note:
|
||||
- Be respectful to the OpenStreetMap tile server (includes custom User-Agent).
|
||||
- If using this for heavy downloads, consider setting up your own tile server.
|
||||
|
||||
"""
|
||||
import os
|
||||
import math
|
||||
import sqlite3
|
||||
import requests
|
||||
import zipfile
|
||||
import io
|
||||
import uuid
|
||||
import threading
|
||||
import time
|
||||
import tempfile
|
||||
from flask import Flask, request, send_file, render_template, jsonify
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
TILE_SERVERS = {
|
||||
"map": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
"satellite": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
|
||||
}
|
||||
|
||||
USER_AGENT = "OfflineTileDownloader/1.0"
|
||||
MAX_TILE_COUNT = 20000
|
||||
TILE_MARGIN = 1
|
||||
|
||||
download_progress = {}
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render_template('index.html')
|
||||
|
||||
@app.route('/preview_tile_count', methods=['POST'])
|
||||
def preview_tile_count():
|
||||
data = request.get_json()
|
||||
bounds = data.get('bounds')
|
||||
zoom_levels = data.get('zoom_levels')
|
||||
if not bounds or not zoom_levels:
|
||||
return jsonify({"error": "Missing bounds or zoom levels"}), 400
|
||||
|
||||
total = 0
|
||||
for z in zoom_levels:
|
||||
x1, y1 = deg2num(bounds['north'], bounds['west'], z)
|
||||
x2, y2 = deg2num(bounds['south'], bounds['east'], z)
|
||||
x_min, x_max = sorted([x1, x2])
|
||||
y_min, y_max = sorted([y1, y2])
|
||||
total += (x_max - x_min + 1 + TILE_MARGIN * 2) * (y_max - y_min + 1 + TILE_MARGIN * 2)
|
||||
|
||||
if total > MAX_TILE_COUNT:
|
||||
return jsonify({"error": f"Too many tiles: {total}"}), 400
|
||||
|
||||
return jsonify({"tile_count": total})
|
||||
|
||||
@app.route('/download_tiles', methods=['POST'])
|
||||
def download_tiles():
|
||||
data = request.get_json()
|
||||
bounds = data['bounds']
|
||||
zoom_levels = data['zoom_levels']
|
||||
fmt = data.get('format', 'zip')
|
||||
map_style = data.get('map_style', 'map') #
|
||||
job_id = str(uuid.uuid4())
|
||||
download_progress[job_id] = {"progress": 0, "total": 1, "done": False, "error": None, "file": None}
|
||||
|
||||
def worker():
|
||||
try:
|
||||
tiles = []
|
||||
for z in zoom_levels:
|
||||
x1, y1 = deg2num(bounds['north'], bounds['west'], z)
|
||||
x2, y2 = deg2num(bounds['south'], bounds['east'], z)
|
||||
x_min, x_max = sorted([x1, x2])
|
||||
y_min, y_max = sorted([y1, y2])
|
||||
for x in range(x_min - TILE_MARGIN, x_max + 1 + TILE_MARGIN):
|
||||
for y in range(y_min - TILE_MARGIN, y_max + 1 + TILE_MARGIN):
|
||||
tiles.append((z, x, y))
|
||||
|
||||
download_progress[job_id]["total"] = len(tiles)
|
||||
if fmt == "mbtiles":
|
||||
result = create_mbtiles(tiles, job_id, map_style)
|
||||
else:
|
||||
result = create_zip(tiles, job_id, map_style)
|
||||
|
||||
download_progress[job_id]["done"] = True
|
||||
download_progress[job_id]["file"] = result
|
||||
download_progress[job_id]["format"] = fmt
|
||||
download_progress[job_id]["style"] = map_style
|
||||
|
||||
except Exception as e:
|
||||
download_progress[job_id]["error"] = str(e)
|
||||
|
||||
threading.Thread(target=worker).start()
|
||||
return jsonify({"job_id": job_id})
|
||||
|
||||
@app.route('/progress/<job_id>')
|
||||
def progress(job_id):
|
||||
def generate():
|
||||
while True:
|
||||
prog = download_progress.get(job_id)
|
||||
if not prog:
|
||||
yield "data: error\n\n"
|
||||
break
|
||||
|
||||
yield f"data: {prog['progress']} / {prog['total']}\n\n"
|
||||
if prog["done"] or prog["error"]:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
|
||||
return app.response_class(generate(), mimetype='text/event-stream')
|
||||
|
||||
@app.route('/get_file/<job_id>')
|
||||
def get_file(job_id):
|
||||
prog = download_progress.get(job_id)
|
||||
if not prog:
|
||||
print(f"get_file: Job {job_id} not found")
|
||||
return "Job not found", 404
|
||||
if not prog.get("file"):
|
||||
print(f"get_file: File for job {job_id} not ready")
|
||||
return "File not ready", 404
|
||||
|
||||
file_obj = prog["file"]
|
||||
file_type = prog.get("format", "zip")
|
||||
style = prog.get("style", "map")
|
||||
|
||||
style_prefix = "map" if style == "map" else "satellite"
|
||||
filename = f"{style_prefix}_tiles.{file_type}"
|
||||
|
||||
return send_file(
|
||||
file_obj,
|
||||
as_attachment=True,
|
||||
download_name=filename,
|
||||
mimetype="application/octet-stream"
|
||||
)
|
||||
|
||||
def create_zip(tiles, job_id, map_style):
|
||||
zip_buffer = io.BytesIO()
|
||||
tile_base_path = f'tiles/{map_style}'
|
||||
|
||||
url_template = TILE_SERVERS.get(map_style, TILE_SERVERS["map"])
|
||||
with zipfile.ZipFile(zip_buffer, 'w') as zip_file:
|
||||
for idx, (z, x, y) in enumerate(tiles):
|
||||
download_progress[job_id]["progress"] = idx + 1
|
||||
tile_path = f'{tile_base_path}/{z}/{x}/{y}.png'
|
||||
if os.path.exists(tile_path):
|
||||
with open(tile_path, 'rb') as f:
|
||||
zip_file.writestr(f'{z}/{x}/{y}.png', f.read())
|
||||
continue
|
||||
|
||||
url = url_template.format(z=z, x=x, y=y)
|
||||
headers = {"User-Agent": USER_AGENT}
|
||||
try:
|
||||
r = requests.get(url, headers=headers, timeout=10)
|
||||
if r.status_code == 200:
|
||||
os.makedirs(os.path.dirname(tile_path), exist_ok=True)
|
||||
with open(tile_path, 'wb') as f:
|
||||
f.write(r.content)
|
||||
zip_file.writestr(f'{z}/{x}/{y}.png', r.content)
|
||||
except Exception as e:
|
||||
print(f"Download error {z}/{x}/{y}: {e}")
|
||||
|
||||
zip_buffer.seek(0)
|
||||
return io.BytesIO(zip_buffer.read())
|
||||
|
||||
def create_mbtiles(tiles, job_id, map_style):
|
||||
tmpfile = tempfile.NamedTemporaryFile(delete=False, suffix=".mbtiles")
|
||||
conn = sqlite3.connect(tmpfile.name)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.executescript("""
|
||||
CREATE TABLE metadata (name TEXT, value TEXT);
|
||||
CREATE TABLE tiles (zoom_level INTEGER, tile_column INTEGER, tile_row INTEGER, tile_data BLOB);
|
||||
CREATE UNIQUE INDEX tile_index ON tiles (zoom_level, tile_column, tile_row);
|
||||
""")
|
||||
cursor.execute("INSERT INTO metadata (name, value) VALUES (?, ?)", ("name", "Offline Map"))
|
||||
cursor.execute("INSERT INTO metadata (name, value) VALUES (?, ?)", ("type", "baselayer"))
|
||||
cursor.execute("INSERT INTO metadata (name, value) VALUES (?, ?)", ("format", "png"))
|
||||
|
||||
tile_base_path = f'tiles/{map_style}'
|
||||
url_template = TILE_SERVERS.get(map_style, TILE_SERVERS["map"])
|
||||
|
||||
for idx, (z, x, y) in enumerate(tiles):
|
||||
download_progress[job_id]["progress"] = idx + 1
|
||||
tms_y = (2 ** z - 1) - y
|
||||
tile_path = f'{tile_base_path}/{z}/{x}/{y}.png'
|
||||
|
||||
if os.path.exists(tile_path):
|
||||
with open(tile_path, 'rb') as f:
|
||||
data = f.read()
|
||||
else:
|
||||
url = url_template.format(z=z, x=x, y=y)
|
||||
headers = {"User-Agent": USER_AGENT}
|
||||
try:
|
||||
r = requests.get(url, headers=headers, timeout=10)
|
||||
if r.status_code == 200:
|
||||
data = r.content
|
||||
os.makedirs(os.path.dirname(tile_path), exist_ok=True)
|
||||
with open(tile_path, 'wb') as f:
|
||||
f.write(data)
|
||||
else:
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"Failed to fetch {z}/{x}/{y}: {e}")
|
||||
continue
|
||||
|
||||
cursor.execute(
|
||||
"INSERT INTO tiles (zoom_level, tile_column, tile_row, tile_data) VALUES (?, ?, ?, ?)",
|
||||
(z, x, tms_y, sqlite3.Binary(data))
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
with open(tmpfile.name, 'rb') as f:
|
||||
mb_data = f.read()
|
||||
tmpfile.close()
|
||||
os.unlink(tmpfile.name)
|
||||
|
||||
return io.BytesIO(mb_data)
|
||||
|
||||
|
||||
def deg2num(lat_deg, lon_deg, zoom):
|
||||
lat_rad = math.radians(lat_deg)
|
||||
n = 2.0 ** zoom
|
||||
x = int((lon_deg + 180.0) / 360.0 * n)
|
||||
y = int((1.0 - math.log(math.tan(lat_rad) + 1 / math.cos(lat_rad)) / math.pi) / 2.0 * n)
|
||||
return x, y
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True)
|
||||
BIN
misc/demo.gif
Normal file
BIN
misc/demo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.6 MiB |
2
requirements.txt
Normal file
2
requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
Flask>=2.2
|
||||
requests>=2.25
|
||||
BIN
static/favicon.ico
Executable file
BIN
static/favicon.ico
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
167
static/style.css
Executable file
167
static/style.css
Executable file
|
|
@ -0,0 +1,167 @@
|
|||
html,
|
||||
body,
|
||||
#map {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
#app-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#map {
|
||||
flex: 3;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
#overlay {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
max-width: 360px;
|
||||
min-width: 280px;
|
||||
background-color: #1e1e1e;
|
||||
color: #f0f0f0;
|
||||
padding: 24px 20px;
|
||||
border-left: 1px solid #333;
|
||||
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.5);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#overlay h3,
|
||||
#overlay label {
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
#overlay input[type="text"],
|
||||
#overlay input[type="number"],
|
||||
#overlay input[type="range"],
|
||||
#overlay select {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
background-color: #2b2b2b;
|
||||
border: 1px solid #555;
|
||||
color: #f0f0f0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#overlay input[type="radio"] {
|
||||
accent-color: #007acc;
|
||||
}
|
||||
|
||||
#overlay button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background-color: #007acc;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
#overlay button:hover {
|
||||
background-color: #005fa3;
|
||||
}
|
||||
|
||||
#overlay button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
#overlay input[type="radio"] {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
|
||||
#status {
|
||||
margin-top: 4px;
|
||||
font-weight: 500;
|
||||
color: #ccc;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
#tile-preview {
|
||||
background-color: transparent !important;
|
||||
border: 1px solid #444;
|
||||
border-radius: 0px !important;
|
||||
margin-top: 0 !important;
|
||||
padding: 0 !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#zoom-slider {
|
||||
margin-top: 4px;
|
||||
background-color: #2b2b2b;
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
box-shadow: inset 0 0 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
#zoom-slider .noUi-connect {
|
||||
background: #007acc;
|
||||
}
|
||||
|
||||
#zoom-slider .noUi-handle {
|
||||
background: #f0f0f0;
|
||||
border: 2px solid #007acc;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
|
||||
#prediction {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
|
||||
#loading-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
#loading-content {
|
||||
background: white;
|
||||
padding: 20px 40px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 4px solid #ccc;
|
||||
border-top: 4px solid #0066cc;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 12px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
370
templates/index.html
Executable file
370
templates/index.html
Executable file
|
|
@ -0,0 +1,370 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Offline Map Downloader</title>
|
||||
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw/dist/leaflet.draw.css" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/nouislider@15.6.1/dist/nouislider.min.css" />
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app-container">
|
||||
<div id="map"></div>
|
||||
<div id="overlay">
|
||||
<h3>Offline Map Downloader</h3>
|
||||
<label for="address">Search Address or ZIP:</label>
|
||||
<input type="text" id="address" placeholder="Enter address or ZIP" />
|
||||
<button id="search-btn">Go to Location</button>
|
||||
|
||||
<div><strong>Center:</strong> <span id="coords">-</span></div>
|
||||
<div><strong>Zoom:</strong> <span id="zoom">-</span></div>
|
||||
<label>Zoom Range: <span id="zoom-range-value">14 - 16</span></label>
|
||||
<div id="zoom-slider" style="margin-bottom: 10px;"></div>
|
||||
|
||||
<label>Format:</label>
|
||||
<label><input type="radio" name="format" value="zip" checked> ZIP</label>
|
||||
<label><input type="radio" name="format" value="mbtiles"> MBTiles</label>
|
||||
<label>Map Style:</label>
|
||||
<select id="map-style">
|
||||
<option value="map">Map</option>
|
||||
<option value="satellite">Satellite</option>
|
||||
</select>
|
||||
<div id="prediction" style="font-size: 13px; color: #555; min-height: 20px;"></div>
|
||||
<canvas id="tile-preview" width="280" height="180"
|
||||
style="border-radius: 8px; margin-top: 10px; background: #f0f0f0;"></canvas>
|
||||
<button id="download">Download Tiles</button>
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="loading-modal">
|
||||
<div id="loading-content">
|
||||
<div class="spinner"></div>
|
||||
<p id="loading-status">Preparing download...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
|
||||
<script src="https://unpkg.com/leaflet-draw/dist/leaflet.draw.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/nouislider@15.6.1/dist/nouislider.min.js"></script>
|
||||
<script>
|
||||
let currentMapStyle = "map";
|
||||
|
||||
const osmLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
});
|
||||
|
||||
const satelliteLayer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
|
||||
maxZoom: 19,
|
||||
attribution: 'Tiles © Esri'
|
||||
});
|
||||
|
||||
const baseMaps = {
|
||||
"Map": osmLayer,
|
||||
"Satellite": satelliteLayer
|
||||
};
|
||||
|
||||
const map = L.map('map', {
|
||||
center: [37.7749, -122.4194],
|
||||
zoom: 13,
|
||||
layers: [osmLayer]
|
||||
});
|
||||
|
||||
L.control.layers(baseMaps).addTo(map);
|
||||
|
||||
const drawnItems = new L.FeatureGroup();
|
||||
map.addLayer(drawnItems);
|
||||
|
||||
const drawControl = new L.Control.Draw({
|
||||
draw: {
|
||||
polygon: false,
|
||||
polyline: false,
|
||||
circle: false,
|
||||
marker: false,
|
||||
circlemarker: false,
|
||||
rectangle: true
|
||||
},
|
||||
edit: { featureGroup: drawnItems }
|
||||
});
|
||||
map.addControl(drawControl);
|
||||
|
||||
let selectedBounds = null;
|
||||
map.on('draw:created', function (e) {
|
||||
drawnItems.clearLayers();
|
||||
drawnItems.addLayer(e.layer);
|
||||
selectedBounds = e.layer.getBounds();
|
||||
});
|
||||
|
||||
const coordsEl = document.getElementById("coords");
|
||||
const zoomEl = document.getElementById("zoom");
|
||||
function updateInfo() {
|
||||
const center = map.getCenter();
|
||||
coordsEl.textContent = `${center.lat.toFixed(5)}, ${center.lng.toFixed(5)}`;
|
||||
zoomEl.textContent = map.getZoom();
|
||||
}
|
||||
map.on("move zoom", updateInfo);
|
||||
updateInfo();
|
||||
|
||||
const zoomSlider = document.getElementById("zoom-slider");
|
||||
const zoomRangeValue = document.getElementById("zoom-range-value");
|
||||
noUiSlider.create(zoomSlider, {
|
||||
start: [14, 16],
|
||||
connect: true,
|
||||
range: { min: 10, max: 19 },
|
||||
step: 1,
|
||||
tooltips: true,
|
||||
format: {
|
||||
to: value => Math.round(value),
|
||||
from: value => Math.round(value)
|
||||
}
|
||||
});
|
||||
zoomSlider.noUiSlider.on('update', function (values) {
|
||||
zoomRangeValue.textContent = `${values[0]} - ${values[1]}`;
|
||||
});
|
||||
|
||||
async function updateTilePrediction() {
|
||||
const predictionEl = document.getElementById("prediction");
|
||||
if (!selectedBounds) {
|
||||
predictionEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const [zmin, zmax] = zoomSlider.noUiSlider.get().map(v => parseInt(v));
|
||||
const zoomLevels = [];
|
||||
for (let z = zmin; z <= zmax; z++) zoomLevels.push(z);
|
||||
|
||||
const data = {
|
||||
bounds: {
|
||||
north: selectedBounds.getNorth(),
|
||||
south: selectedBounds.getSouth(),
|
||||
east: selectedBounds.getEast(),
|
||||
west: selectedBounds.getWest()
|
||||
},
|
||||
zoom_levels: zoomLevels
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch("/preview_tile_count", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.error) {
|
||||
predictionEl.textContent = result.error;
|
||||
predictionEl.style.color = "red";
|
||||
} else {
|
||||
predictionEl.textContent = `Estimated tile count: ${result.tile_count}`;
|
||||
predictionEl.style.color = "#555";
|
||||
}
|
||||
} catch (err) {
|
||||
predictionEl.textContent = "Prediction failed.";
|
||||
predictionEl.style.color = "red";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function updateTilePreview() {
|
||||
const canvas = document.getElementById("tile-preview");
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
if (!selectedBounds) return;
|
||||
|
||||
const TILE_SIZE = 256;
|
||||
const TILE_MARGIN = 0;
|
||||
const zoomSlider = document.getElementById("zoom-slider");
|
||||
const zoom = parseInt(zoomSlider.noUiSlider.get()[1]);
|
||||
|
||||
const [x1, y1] = deg2num(selectedBounds.getNorth(), selectedBounds.getWest(), zoom);
|
||||
const [x2, y2] = deg2num(selectedBounds.getSouth(), selectedBounds.getEast(), zoom);
|
||||
const x_min = Math.min(x1, x2) - TILE_MARGIN;
|
||||
const x_max = Math.max(x1, x2) + TILE_MARGIN;
|
||||
const y_min = Math.min(y1, y2) - TILE_MARGIN;
|
||||
const y_max = Math.max(y1, y2) + TILE_MARGIN;
|
||||
|
||||
const tileCols = x_max - x_min + 1;
|
||||
const tileRows = y_max - y_min + 1;
|
||||
|
||||
const previewTileWidth = tileCols * TILE_SIZE;
|
||||
const previewTileHeight = tileRows * TILE_SIZE;
|
||||
|
||||
const scale = Math.min(canvas.width / previewTileWidth, canvas.height / previewTileHeight);
|
||||
const totalWidth = previewTileWidth * scale;
|
||||
const totalHeight = previewTileHeight * scale;
|
||||
const offsetX = (canvas.width - totalWidth) / 2;
|
||||
const offsetY = (canvas.height - totalHeight) / 2;
|
||||
|
||||
for (let x = x_min; x <= x_max; x++) {
|
||||
for (let y = y_min; y <= y_max; y++) {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => {
|
||||
const dx = offsetX + (x - x_min) * TILE_SIZE * scale;
|
||||
const dy = offsetY + (y - y_min) * TILE_SIZE * scale;
|
||||
ctx.drawImage(img, dx, dy, TILE_SIZE * scale, TILE_SIZE * scale);
|
||||
};
|
||||
img.src = currentMapStyle === "map"
|
||||
? `https://tile.openstreetmap.org/${zoom}/${x}/${y}.png`
|
||||
: `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/${zoom}/${y}/${x}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deg2num(lat, lon, zoom) {
|
||||
const latRad = lat * Math.PI / 180;
|
||||
const n = 2 ** zoom;
|
||||
const x = Math.floor((lon + 180) / 360 * n);
|
||||
const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n);
|
||||
return [x, y];
|
||||
}
|
||||
|
||||
map.on('draw:created', () => {
|
||||
updateTilePrediction();
|
||||
updateTilePreview();
|
||||
});
|
||||
|
||||
map.on('baselayerchange', function (e) {
|
||||
if (e.name === "Map") {
|
||||
currentMapStyle = "map";
|
||||
styleSelector.value = "map";
|
||||
} else if (e.name === "Satellite") {
|
||||
currentMapStyle = "satellite";
|
||||
styleSelector.value = "satellite";
|
||||
}
|
||||
|
||||
updateTilePreview();
|
||||
});
|
||||
|
||||
zoomSlider.noUiSlider.on('update', () => {
|
||||
updateTilePrediction();
|
||||
updateTilePreview();
|
||||
});
|
||||
|
||||
const styleSelector = document.getElementById("map-style");
|
||||
|
||||
styleSelector.addEventListener("change", () => {
|
||||
const style = styleSelector.value;
|
||||
currentMapStyle = style;
|
||||
|
||||
if (style === "map") {
|
||||
map.removeLayer(satelliteLayer);
|
||||
map.addLayer(osmLayer);
|
||||
} else {
|
||||
map.removeLayer(osmLayer);
|
||||
map.addLayer(satelliteLayer);
|
||||
}
|
||||
|
||||
updateTilePreview();
|
||||
});
|
||||
|
||||
document.getElementById("search-btn").addEventListener("click", async () => {
|
||||
const query = document.getElementById("address").value.trim();
|
||||
if (!query) return;
|
||||
try {
|
||||
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}`);
|
||||
const results = await res.json();
|
||||
if (results.length > 0) {
|
||||
const lat = parseFloat(results[0].lat);
|
||||
const lon = parseFloat(results[0].lon);
|
||||
map.setView([lat, lon], 15);
|
||||
} else {
|
||||
alert("Location not found.");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Geocoding failed:", err);
|
||||
alert("Failed to fetch location.");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("download").addEventListener("click", async () => {
|
||||
const statusEl = document.getElementById("status");
|
||||
const [zmin, zmax] = zoomSlider.noUiSlider.get().map(v => parseInt(v));
|
||||
const format = document.querySelector('input[name="format"]:checked').value;
|
||||
if (!selectedBounds) {
|
||||
statusEl.textContent = "Please draw a rectangle to select an area.";
|
||||
statusEl.style.color = "red";
|
||||
return;
|
||||
}
|
||||
const zoomLevels = [];
|
||||
|
||||
for (let z = zmin; z <= zmax; z++) zoomLevels.push(z);
|
||||
const data = {
|
||||
bounds: {
|
||||
north: selectedBounds.getNorth(),
|
||||
south: selectedBounds.getSouth(),
|
||||
east: selectedBounds.getEast(),
|
||||
west: selectedBounds.getWest()
|
||||
},
|
||||
zoom_levels: zoomLevels,
|
||||
format: format,
|
||||
map_style: currentMapStyle
|
||||
};
|
||||
|
||||
try {
|
||||
statusEl.textContent = "Calculating tile count...";
|
||||
|
||||
const preview = await fetch("/preview_tile_count", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const { tile_count, error } = await preview.json();
|
||||
if (error) {
|
||||
statusEl.textContent = error;
|
||||
statusEl.style.color = "red";
|
||||
|
||||
return;
|
||||
}
|
||||
document.getElementById("loading-modal").style.display = "flex";
|
||||
document.getElementById("loading-status").textContent = "Preparing download...";
|
||||
|
||||
statusEl.textContent = `Starting download of ${tile_count} tiles...`;
|
||||
const job = await fetch("/download_tiles", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const { job_id } = await job.json();
|
||||
const evtSource = new EventSource(`/progress/${job_id}`);
|
||||
evtSource.onmessage = function (e) {
|
||||
const msg = e.data;
|
||||
|
||||
if (msg === "error") {
|
||||
statusEl.textContent = "An error occurred.";
|
||||
statusEl.style.color = "red";
|
||||
evtSource.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const [done, total] = msg.split(" / ").map(Number);
|
||||
statusEl.textContent = `Progress: ${done} / ${total}`;
|
||||
document.getElementById("loading-status").textContent = `Downloading: ${done} / ${total}`;
|
||||
|
||||
if (done >= total) {
|
||||
evtSource.close();
|
||||
setTimeout(() => {
|
||||
statusEl.textContent = "Download ready...";
|
||||
document.getElementById("loading-modal").style.display = "none";
|
||||
window.location.href = `/get_file/${job_id}`;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
statusEl.textContent = "Download failed.";
|
||||
statusEl.style.color = "red";
|
||||
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Loading…
Add table
Reference in a new issue