First Commit

This commit is contained in:
Eric 2025-05-24 23:33:04 -07:00
parent 99e94bcafc
commit 75e3ed5c12
7 changed files with 896 additions and 0 deletions

108
README.md Normal file
View file

@ -0,0 +1,108 @@
# 🗺️ Offline Map Downloader
<div align="center">
[![Offline Map Downloader](./misc/demo.gif)](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 (1019)
- 🌐 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 MiB

2
requirements.txt Normal file
View file

@ -0,0 +1,2 @@
Flask>=2.2
requests>=2.25

BIN
static/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

167
static/style.css Executable file
View 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
View 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: '&copy; 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>