From c66fb8f4c023dfb145effe66b4bab7c6e0a0831f Mon Sep 17 00:00:00 2001 From: Jericho Date: Wed, 18 Mar 2026 14:29:26 +0800 Subject: [PATCH 1/2] Updates fix: replace Mapbox GL JS with Leaflet (token expired) fix: add User-Agent header and {s} subdomain support in utils.py feat: add cancel and clear rectangle buttons for draw tool --- src/UI/index.htm | 392 ++++++------- src/UI/main.js | 1269 +++++++++++++++++++++--------------------- src/requirements.txt | 2 +- src/utils.py | 29 +- 4 files changed, 864 insertions(+), 828 deletions(-) diff --git a/src/UI/index.htm b/src/UI/index.htm index f089415..95da286 100644 --- a/src/UI/index.htm +++ b/src/UI/index.htm @@ -1,190 +1,204 @@ - + - - Map Tiles Downloader - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
- -
Map Tiles Downloader
- -
- - - - - - - \ No newline at end of file + + Map Tiles Downloader + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
Map Tiles Downloader
+
+ + + + + + diff --git a/src/UI/main.js b/src/UI/main.js index 471bec1..485ba65 100644 --- a/src/UI/main.js +++ b/src/UI/main.js @@ -1,628 +1,641 @@ -var mapView; - -$(function() { - - var map = null; - var draw = null; - var geocoder = null; - var bar = null; - - var cancellationToken = null; - var requests = []; - - var sources = { - - "Bing Maps": "http://ecn.t0.tiles.virtualearth.net/tiles/r{quad}.jpeg?g=129&mkt=en&stl=H", - "Bing Maps Satellite": "http://ecn.t0.tiles.virtualearth.net/tiles/a{quad}.jpeg?g=129&mkt=en&stl=H", - "Bing Maps Hybrid": "http://ecn.t0.tiles.virtualearth.net/tiles/h{quad}.jpeg?g=129&mkt=en&stl=H", - - "div-1B": "", - - "Google Maps": "https://mt0.google.com/vt?lyrs=m&x={x}&s=&y={y}&z={z}", - "Google Maps Satellite": "https://mt0.google.com/vt?lyrs=s&x={x}&s=&y={y}&z={z}", - "Google Maps Hybrid": "https://mt0.google.com/vt?lyrs=h&x={x}&s=&y={y}&z={z}", - "Google Maps Terrain": "https://mt0.google.com/vt?lyrs=p&x={x}&s=&y={y}&z={z}", - - "div-2": "", - - "Open Street Maps": "https://a.tile.openstreetmap.org/{z}/{x}/{y}.png", - "Open Cycle Maps": "http://a.tile.opencyclemap.org/cycle/{z}/{x}/{y}.png", - "Open PT Transport": "http://openptmap.org/tiles/{z}/{x}/{y}.png", - - "div-3": "", - - "ESRI World Imagery": "http://services.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", - "Wikimedia Maps": "https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png", - "NASA GIBS": "https://map1.vis.earthdata.nasa.gov/wmts-webmerc/MODIS_Terra_CorrectedReflectance_TrueColor/default/GoogleMapsCompatible_Level9/{z}/{y}/{x}.jpg", - - "div-4": "", - - "Carto Light": "http://cartodb-basemaps-c.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png", - "Stamen Toner B&W": "http://a.tile.stamen.com/toner/{z}/{x}/{y}.png", - - }; - - function initializeMap() { - - mapboxgl.accessToken = 'pk.eyJ1IjoiYWxpYXNocmFmIiwiYSI6ImNqdXl5MHV5YTAzNXI0NG51OWFuMGp4enQifQ.zpd2gZFwBTRqiapp1yci9g'; - - map = new mapboxgl.Map({ - container: 'map-view', - style: 'mapbox://styles/aliashraf/ck6lw9nr80lvo1ipj8zovttdx', - center: [-73.983652, 40.755024], - zoom: 12 - }); - - geocoder = new MapboxGeocoder({ accessToken: mapboxgl.accessToken }); - var control = map.addControl(geocoder); - } - - function initializeMaterialize() { - $('select').formSelect(); - $('.dropdown-trigger').dropdown({ - constrainWidth: false, - }); - } - - function initializeSources() { - - var dropdown = $("#sources"); - - for(var key in sources) { - var url = sources[key]; - - if(url == "") { - dropdown.append("
"); - continue; - } - - var item = $("
  • "); - item.attr("data-url", url); - item.find("a").text(key); - - item.click(function() { - var url = $(this).attr("data-url"); - $("#source-box").val(url); - }) - - dropdown.append(item); - } - } - - function initializeSearch() { - $("#search-form").submit(function(e) { - var location = $("#location-box").val(); - geocoder.query(location); - - e.preventDefault(); - }) - } - - function initializeMoreOptions() { - - $("#more-options-toggle").click(function() { - $("#more-options").toggle(); - }) - - var outputFileBox = $("#output-file-box") - $("#output-type").change(function() { - var outputType = $("#output-type").val(); - if(outputType == "mbtiles") { - outputFileBox.val("tiles.mbtiles") - } else if(outputType == "repo") { - outputFileBox.val("tiles.repo") - } else if(outputType == "directory") { - outputFileBox.val("{z}/{x}/{y}.png") - } - }) - - } - - function initializeRectangleTool() { - - var modes = MapboxDraw.modes; - modes.draw_rectangle = DrawRectangle.default; - - draw = new MapboxDraw({ - modes: modes - }); - map.addControl(draw); - - map.on('draw.create', function (e) { - M.Toast.dismissAll(); - }); - - $("#rectangle-draw-button").click(function() { - startDrawing(); - }) - - } - - function startDrawing() { - removeGrid(); - draw.deleteAll(); - draw.changeMode('draw_rectangle'); - - M.Toast.dismissAll(); - M.toast({html: 'Click two points on the map to make a rectangle.', displayLength: 7000}) - } - - function initializeGridPreview() { - $("#grid-preview-button").click(previewGrid); - - map.on('click', showTilePopup); - } - - function showTilePopup(e) { - - if(!e.originalEvent.ctrlKey) { - return; - } - - var maxZoom = getMaxZoom(); - - var x = lat2tile(e.lngLat.lat, maxZoom); - var y = long2tile(e.lngLat.lng, maxZoom); - - var content = "X, Y, Z
    " + x + ", " + y + ", " + maxZoom + "
    "; - content += "Lat, Lng
    " + e.lngLat.lat + ", " + e.lngLat.lng + ""; - - new mapboxgl.Popup() - .setLngLat(e.lngLat) - .setHTML(content) - .addTo(map); - - console.log(e.lngLat) - - } - - function long2tile(lon,zoom) { - return (Math.floor((lon+180)/360*Math.pow(2,zoom))); - } - - function lat2tile(lat,zoom) { - return (Math.floor((1-Math.log(Math.tan(lat*Math.PI/180) + 1/Math.cos(lat*Math.PI/180))/Math.PI)/2 *Math.pow(2,zoom))); - } - - function tile2long(x,z) { - return (x/Math.pow(2,z)*360-180); - } - - function tile2lat(y,z) { - var n=Math.PI-2*Math.PI*y/Math.pow(2,z); - return (180/Math.PI*Math.atan(0.5*(Math.exp(n)-Math.exp(-n)))); - } - - function getTileRect(x, y, zoom) { - - var c1 = new mapboxgl.LngLat(tile2long(x, zoom), tile2lat(y, zoom)); - var c2 = new mapboxgl.LngLat(tile2long(x + 1, zoom), tile2lat(y + 1, zoom)); - - return new mapboxgl.LngLatBounds(c1, c2); - } - - function getMinZoom() { - return Math.min(parseInt($("#zoom-from-box").val()), parseInt($("#zoom-to-box").val())); - } - - function getMaxZoom() { - return Math.max(parseInt($("#zoom-from-box").val()), parseInt($("#zoom-to-box").val())); - } - - function getArrayByBounds(bounds) { - - var tileArray = [ - [ bounds.getSouthWest().lng, bounds.getNorthEast().lat ], - [ bounds.getNorthEast().lng, bounds.getNorthEast().lat ], - [ bounds.getNorthEast().lng, bounds.getSouthWest().lat ], - [ bounds.getSouthWest().lng, bounds.getSouthWest().lat ], - [ bounds.getSouthWest().lng, bounds.getNorthEast().lat ], - ]; - - return tileArray; - } - - function getPolygonByBounds(bounds) { - - var tilePolygonData = getArrayByBounds(bounds); - - var polygon = turf.polygon([tilePolygonData]); - - return polygon; - } - - function isTileInSelection(tileRect) { - - var polygon = getPolygonByBounds(tileRect); - - var areaPolygon = draw.getAll().features[0]; - - if(turf.booleanDisjoint(polygon, areaPolygon) == false) { - return true; - } - - return false; - } - - function getBounds() { - - var coordinates = draw.getAll().features[0].geometry.coordinates[0]; - - var bounds = coordinates.reduce(function(bounds, coord) { - return bounds.extend(coord); - }, new mapboxgl.LngLatBounds(coordinates[0], coordinates[0])); - - return bounds; - } - - function getGrid(zoomLevel) { - - var bounds = getBounds(); - - var rects = []; - - var outputScale = $("#output-scale").val(); - //var thisZoom = zoomLevel - (outputScale-1) - var thisZoom = zoomLevel - - var TY = lat2tile(bounds.getNorthEast().lat, thisZoom); - var LX = long2tile(bounds.getSouthWest().lng, thisZoom); - var BY = lat2tile(bounds.getSouthWest().lat, thisZoom); - var RX = long2tile(bounds.getNorthEast().lng, thisZoom); - - for(var y = TY; y <= BY; y++) { - for(var x = LX; x <= RX; x++) { - - var rect = getTileRect(x, y, thisZoom); - - if(isTileInSelection(rect)) { - rects.push({ - x: x, - y: y, - z: thisZoom, - rect: rect, - }); - } - - } - } - - return rects - } - - function getAllGridTiles() { - var allTiles = []; - - for(var z = getMinZoom(); z <= getMaxZoom(); z++) { - var grid = getGrid(z); - // TODO shuffle grid via a heuristic (hamlet curve? :/) - allTiles = allTiles.concat(grid); - } - - return allTiles; - } - - function removeGrid() { - removeLayer("grid-preview"); - } - - function previewGrid() { - - var maxZoom = getMaxZoom(); - var grid = getGrid(maxZoom); - - var pointsCollection = [] - - for(var i in grid) { - var feature = grid[i]; - var array = getArrayByBounds(feature.rect); - pointsCollection.push(array); - } - - removeGrid(); - - map.addLayer({ - 'id': "grid-preview", - 'type': 'line', - 'source': { - 'type': 'geojson', - 'data': turf.polygon(pointsCollection), - }, - 'layout': {}, - 'paint': { - "line-color": "#fa8231", - "line-width": 3, - } - }); - - var totalTiles = getAllGridTiles().length; - M.toast({html: 'Total ' + totalTiles.toLocaleString() + ' tiles in the region.', displayLength: 5000}) - - } - - function previewRect(rectInfo) { - - var array = getArrayByBounds(rectInfo.rect); - - var id = "temp-" + rectInfo.x + '-' + rectInfo.y + '-' + rectInfo.z; - - map.addLayer({ - 'id': id, - 'type': 'line', - 'source': { - 'type': 'geojson', - 'data': turf.polygon([array]), - }, - 'layout': {}, - 'paint': { - "line-color": "#ff9f1a", - "line-width": 3, - } - }); - - return id; - } - - function removeLayer(id) { - if(map.getSource(id) != null) { - map.removeLayer(id); - map.removeSource(id); - } - } - - function generateQuadKey(x, y, z) { - var quadKey = []; - for (var i = z; i > 0; i--) { - var digit = '0'; - var mask = 1 << (i - 1); - if ((x & mask) != 0) { - digit++; - } - if ((y & mask) != 0) { - digit++; - digit++; - } - quadKey.push(digit); - } - return quadKey.join(''); - } - - function initializeDownloader() { - - bar = new ProgressBar.Circle($('#progress-radial').get(0), { - strokeWidth: 12, - easing: 'easeOut', - duration: 200, - trailColor: '#eee', - trailWidth: 1, - from: {color: '#0fb9b1', a:0}, - to: {color: '#20bf6b', a:1}, - svgStyle: null, - step: function(state, circle) { - circle.path.setAttribute('stroke', state.color); - } - }); - - $("#download-button").click(startDownloading) - $("#stop-button").click(stopDownloading) - - var timestamp = Date.now().toString(); - //$("#output-directory-box").val(timestamp) - } - - function showTinyTile(base64) { - var currentImages = $(".tile-strip img"); - - for(var i = 4; i < currentImages.length; i++) { - $(currentImages[i]).remove(); - } - - var image = $("").attr('src', "data:image/png;base64, " + base64) - - var strip = $(".tile-strip"); - strip.prepend(image) - } - - async function startDownloading() { - - if(draw.getAll().features.length == 0) { - M.toast({html: 'You need to select a region first.', displayLength: 3000}) - return; - } - - cancellationToken = false; - requests = []; - - $("#main-sidebar").hide(); - $("#download-sidebar").show(); - $(".tile-strip").html(""); - $("#stop-button").html("STOP"); - removeGrid(); - clearLogs(); - M.Toast.dismissAll(); - - var timestamp = Date.now().toString(); - - var allTiles = getAllGridTiles(); - updateProgress(0, allTiles.length); - - var numThreads = parseInt($("#parallel-threads-box").val()); - var outputDirectory = $("#output-directory-box").val(); - var outputFile = $("#output-file-box").val(); - var outputType = $("#output-type").val(); - var outputScale = $("#output-scale").val(); - var source = $("#source-box").val() - - var bounds = getBounds(); - var boundsArray = [bounds.getSouthWest().lng, bounds.getSouthWest().lat, bounds.getNorthEast().lng, bounds.getNorthEast().lat] - var centerArray = [bounds.getCenter().lng, bounds.getCenter().lat, getMaxZoom()] - - var data = new FormData(); - data.append('minZoom', getMinZoom()) - data.append('maxZoom', getMaxZoom()) - data.append('outputDirectory', outputDirectory) - data.append('outputFile', outputFile) - data.append('outputType', outputType) - data.append('outputScale', outputScale) - data.append('source', source) - data.append('timestamp', timestamp) - data.append('bounds', boundsArray.join(",")) - data.append('center', centerArray.join(",")) - - var request = await $.ajax({ - url: "/start-download", - async: true, - timeout: 30 * 1000, - type: "post", - contentType: false, - processData: false, - data: data, - dataType: 'json', - }) - - let i = 0; - var iterator = async.eachLimit(allTiles, numThreads, function(item, done) { - - if(cancellationToken) { - return; - } - - var boxLayer = previewRect(item); - - var url = "/download-tile"; - - var data = new FormData(); - data.append('x', item.x) - data.append('y', item.y) - data.append('z', item.z) - data.append('quad', generateQuadKey(item.x, item.y, item.z)) - data.append('outputDirectory', outputDirectory) - data.append('outputFile', outputFile) - data.append('outputType', outputType) - data.append('outputScale', outputScale) - data.append('timestamp', timestamp) - data.append('source', source) - data.append('bounds', boundsArray.join(",")) - data.append('center', centerArray.join(",")) - - var request = $.ajax({ - "url": url, - async: true, - timeout: 30 * 1000, - type: "post", - contentType: false, - processData: false, - data: data, - dataType: 'json', - }).done(function(data) { - - if(cancellationToken) { - return; - } - - if(data.code == 200) { - showTinyTile(data.image) - logItem(item.x, item.y, item.z, data.message); - } else { - logItem(item.x, item.y, item.z, data.code + " Error downloading tile"); - } - - }).fail(function(data, textStatus, errorThrown) { - - if(cancellationToken) { - return; - } - - logItem(item.x, item.y, item.z, "Error while relaying tile"); - //allTiles.push(item); - - }).always(function(data) { - i++; - - removeLayer(boxLayer); - updateProgress(i, allTiles.length); - - done(); - - if(cancellationToken) { - return; - } - }); - - requests.push(request); - - }, async function(err) { - - var request = await $.ajax({ - url: "/end-download", - async: true, - timeout: 30 * 1000, - type: "post", - contentType: false, - processData: false, - data: data, - dataType: 'json', - }) - - updateProgress(allTiles.length, allTiles.length); - logItemRaw("All requests are done"); - - $("#stop-button").html("FINISH"); - }); - - } - - function updateProgress(value, total) { - var progress = value / total; - - bar.animate(progress); - bar.setText(Math.round(progress * 100) + '%'); - - $("#progress-subtitle").html(value.toLocaleString() + " out of " + total.toLocaleString()) - } - - function logItem(x, y, z, text) { - logItemRaw(x + ',' + y + ',' + z + ' : ' + text) - } - - function logItemRaw(text) { - - var logger = $('#log-view'); - logger.val(logger.val() + '\n' + text); - - logger.scrollTop(logger[0].scrollHeight); - } - - function clearLogs() { - var logger = $('#log-view'); - logger.val(''); - } - - function stopDownloading() { - cancellationToken = true; - - for(var i =0 ; i < requests.length; i++) { - var request = requests[i]; - try { - request.abort(); - } catch(e) { - - } - } - - $("#main-sidebar").show(); - $("#download-sidebar").hide(); - removeGrid(); - clearLogs(); - - } - - initializeMaterialize(); - initializeSources(); - initializeMap(); - initializeSearch(); - initializeRectangleTool(); - initializeGridPreview(); - initializeMoreOptions(); - initializeDownloader(); -}); \ No newline at end of file +$(function () { + // ─── State ─────────────────────────────────────────────────────────────── + var map = null; + var tileLayer = null; + var drawnBounds = null; + var drawnLayer = null; + var previewLayers = {}; + var bar = null; + var cancellationToken = false; + var requests = []; + var drawHandler = null; + + // ─── Tile Sources ───────────────────────────────────────────────────────── + var sources = { + // ESRI + "ESRI Street": + "https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}", + "ESRI Satellite": + "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", + "ESRI Topo": + "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}", + "ESRI Light Gray": + "https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}", + "ESRI Dark Gray": + "https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile/{z}/{y}/{x}", + "ESRI National Geographic": + "https://server.arcgisonline.com/ArcGIS/rest/services/NatGeo_World_Map/MapServer/tile/{z}/{y}/{x}", + "ESRI Ocean": + "https://server.arcgisonline.com/ArcGIS/rest/services/Ocean/World_Ocean_Base/MapServer/tile/{z}/{y}/{x}", + "ESRI Shaded Relief": + "https://server.arcgisonline.com/ArcGIS/rest/services/World_Shaded_Relief/MapServer/tile/{z}/{y}/{x}", + // OpenStreetMap + OpenStreetMap: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", + "OSM Humanitarian": "https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png", + OpenTopoMap: "https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", + // CartoDB + "CartoDB Positron": + "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", + "CartoDB Dark Matter": + "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png", + "CartoDB Voyager": + "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png", + // Stadia + "Stadia Smooth": + "https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}.png", + "Stadia Smooth Dark": + "https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}.png", + "Stadia Outdoors": + "https://tiles.stadiamaps.com/tiles/outdoors/{z}/{x}/{y}.png", + // Google + "Google Maps": "https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}", + "Google Satellite": "https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}", + "Google Hybrid": "https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}", + "Google Terrain": "https://mt1.google.com/vt/lyrs=t&x={x}&y={y}&z={z}", + // NASA + "NASA Blue Marble": + "https://map1.vis.earthdata.nasa.gov/wmts-webmerc/BlueMarble_ShadedRelief/default/GoogleMapsCompatible_Level8/{z}/{y}/{x}.jpg", + }; + + // ─── Initialize Map ─────────────────────────────────────────────────────── + function initializeMap() { + map = L.map("map-view").setView([40.755024, -73.983652], 12); + + tileLayer = L.tileLayer( + "https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}", + { attribution: "Tiles © Esri", maxZoom: 20 }, + ).addTo(map); + + // Click popup (Ctrl+Click to show tile coords) + map.on("click", function (e) { + if (!e.originalEvent.ctrlKey) return; + var maxZoom = getMaxZoom(); + var x = long2tile(e.latlng.lng, maxZoom); + var y = lat2tile(e.latlng.lat, maxZoom); + var content = + "X, Y, Z
    " + x + ", " + y + ", " + maxZoom + "
    "; + content += + "Lat, Lng
    " + + e.latlng.lat.toFixed(6) + + ", " + + e.latlng.lng.toFixed(6) + + ""; + L.popup().setLatLng(e.latlng).setContent(content).openOn(map); + }); + } + + // ─── Materialize UI ─────────────────────────────────────────────────────── + function initializeMaterialize() { + $("select").formSelect(); + $(".dropdown-trigger").dropdown({ constrainWidth: false }); + } + + // ─── Source Dropdown ────────────────────────────────────────────────────── + function initializeSources() { + var dropdown = $("#sources"); + for (var key in sources) { + var url = sources[key]; + var item = $("
  • "); + item.attr("data-url", url); + item.find("a").text(key); + item.click(function () { + var url = $(this).attr("data-url"); + $("#source-box").val(url); + // Switch the background preview layer too + if (tileLayer) map.removeLayer(tileLayer); + tileLayer = L.tileLayer(url, { attribution: "", maxZoom: 20 }).addTo( + map, + ); + }); + dropdown.append(item); + } + } + + // ─── Search ─────────────────────────────────────────────────────────────── + function initializeSearch() { + $("#search-form").submit(function (e) { + e.preventDefault(); + var location = $("#location-box").val(); + fetch( + "https://nominatim.openstreetmap.org/search?format=json&q=" + + encodeURIComponent(location), + ) + .then(function (r) { + return r.json(); + }) + .then(function (results) { + if (results.length > 0) { + map.setView( + [parseFloat(results[0].lat), parseFloat(results[0].lon)], + 13, + ); + } else { + M.toast({ html: "Location not found.", displayLength: 3000 }); + } + }) + .catch(function () { + M.toast({ + html: "Search failed. Check your connection.", + displayLength: 3000, + }); + }); + }); + } + + // ─── More Options Toggle ────────────────────────────────────────────────── + function initializeMoreOptions() { + $("#more-options-toggle").click(function () { + $("#more-options").toggle(); + }); + + $("#output-type").change(function () { + var outputType = $(this).val(); + if (outputType === "mbtiles") { + $("#output-file-box").val("tiles.mbtiles"); + } else if (outputType === "repo") { + $("#output-file-box").val("tiles.repo"); + } else { + $("#output-file-box").val("{z}/{x}/{y}.png"); + } + }); + } + + // ─── Rectangle Draw Tool ────────────────────────────────────────────────── + function initializeRectangleTool() { + var drawnItems = new L.FeatureGroup(); + map.addLayer(drawnItems); + + drawHandler = new L.Draw.Rectangle(map, { + shapeOptions: { color: "#fa8231", weight: 2 }, + }); + + map.on(L.Draw.Event.CREATED, function (e) { + if (drawnLayer) map.removeLayer(drawnLayer); + drawnLayer = e.layer; + drawnBounds = e.layer.getBounds(); + map.addLayer(drawnLayer); + M.Toast.dismissAll(); + updateRectangleButtons(); + }); + + // ESC key cancels drawing + $(document).keydown(function (e) { + if (e.key === "Escape") cancelDrawing(); + }); + + // Also cancel if draw is stopped without completing + map.on(L.Draw.Event.DRAWSTOP, function () { + $("#cancel-draw-button").hide(); + }); + + $("#rectangle-draw-button").click(function () { + startDrawing(); + }); + + $("#cancel-draw-button").click(function () { + cancelDrawing(); + }); + + $("#reset-rectangle-button").click(function () { + resetRectangle(); + }); + } + + function updateRectangleButtons() { + if (drawnBounds) { + $("#reset-rectangle-button").show(); + $("#cancel-draw-button").hide(); + $("#rectangle-draw-button").text("Redraw Rectangle"); + } else { + $("#reset-rectangle-button").hide(); + $("#rectangle-draw-button").text("Draw a Rectangle"); + } + } + + function startDrawing() { + removeGrid(); + drawHandler.enable(); + $("#cancel-draw-button").show(); + $("#reset-rectangle-button").hide(); + + M.Toast.dismissAll(); + M.toast({ + html: "Click and drag on the map to draw a rectangle. ESC to cancel.", + displayLength: 7000, + }); + } + + function cancelDrawing() { + drawHandler.disable(); + $("#cancel-draw-button").hide(); + M.Toast.dismissAll(); + updateRectangleButtons(); + } + + function resetRectangle() { + removeGrid(); + if (drawnLayer) { + map.removeLayer(drawnLayer); + drawnLayer = null; + } + drawnBounds = null; + $("#reset-rectangle-button").hide(); + $("#rectangle-draw-button").text("Draw a Rectangle"); + M.toast({ html: "Rectangle cleared.", displayLength: 2000 }); + } + + // ─── Grid Preview ───────────────────────────────────────────────────────── + function initializeGridPreview() { + $("#grid-preview-button").click(previewGrid); + } + + function previewGrid() { + if (!drawnBounds) { + M.toast({ html: "Draw a rectangle first.", displayLength: 3000 }); + return; + } + + var maxZoom = getMaxZoom(); + var grid = getGrid(maxZoom); + removeGrid(); + + for (var i = 0; i < grid.length; i++) { + var feature = grid[i]; + var b = feature.rect; + var rect = L.rectangle(b, { color: "#fa8231", weight: 2, fill: false }); + rect.addTo(map); + previewLayers["grid-" + i] = rect; + } + + var totalTiles = getAllGridTiles().length; + M.toast({ + html: "Total " + totalTiles.toLocaleString() + " tiles in the region.", + displayLength: 5000, + }); + } + + function removeGrid() { + for (var key in previewLayers) { + map.removeLayer(previewLayers[key]); + } + previewLayers = {}; + } + + function previewRect(rectInfo) { + var id = "temp-" + rectInfo.x + "-" + rectInfo.y + "-" + rectInfo.z; + var rect = L.rectangle(rectInfo.rect, { + color: "#ff9f1a", + weight: 2, + fill: false, + }); + rect.addTo(map); + previewLayers[id] = rect; + return id; + } + + function removeLayer(id) { + if (previewLayers[id]) { + map.removeLayer(previewLayers[id]); + delete previewLayers[id]; + } + } + + // ─── Tile Math ──────────────────────────────────────────────────────────── + function long2tile(lon, zoom) { + return Math.floor(((lon + 180) / 360) * Math.pow(2, zoom)); + } + + function lat2tile(lat, zoom) { + return Math.floor( + ((1 - + Math.log( + Math.tan((lat * Math.PI) / 180) + 1 / Math.cos((lat * Math.PI) / 180), + ) / + Math.PI) / + 2) * + Math.pow(2, zoom), + ); + } + + function tile2long(x, z) { + return (x / Math.pow(2, z)) * 360 - 180; + } + + function tile2lat(y, z) { + var n = Math.PI - (2 * Math.PI * y) / Math.pow(2, z); + return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))); + } + + function getTileRect(x, y, zoom) { + var south = tile2lat(y + 1, zoom); + var north = tile2lat(y, zoom); + var west = tile2long(x, zoom); + var east = tile2long(x + 1, zoom); + return L.latLngBounds([ + [south, west], + [north, east], + ]); + } + + // ─── Zoom Helpers ───────────────────────────────────────────────────────── + function getMinZoom() { + return Math.min( + parseInt($("#zoom-from-box").val()), + parseInt($("#zoom-to-box").val()), + ); + } + + function getMaxZoom() { + return Math.max( + parseInt($("#zoom-from-box").val()), + parseInt($("#zoom-to-box").val()), + ); + } + + // ─── Bounds Helpers ─────────────────────────────────────────────────────── + function getBounds() { + return { + getSouthWest: function () { + return { lng: drawnBounds.getWest(), lat: drawnBounds.getSouth() }; + }, + getNorthEast: function () { + return { lng: drawnBounds.getEast(), lat: drawnBounds.getNorth() }; + }, + getCenter: function () { + return { + lng: drawnBounds.getCenter().lng, + lat: drawnBounds.getCenter().lat, + }; + }, + }; + } + + function isTileInSelection(tileRect) { + // Check overlap: tile rect intersects drawn bounds + return drawnBounds.intersects(tileRect); + } + + // ─── Grid Calculation ───────────────────────────────────────────────────── + function getGrid(zoomLevel) { + var TY = lat2tile(drawnBounds.getNorth(), zoomLevel); + var BY = lat2tile(drawnBounds.getSouth(), zoomLevel); + var LX = long2tile(drawnBounds.getWest(), zoomLevel); + var RX = long2tile(drawnBounds.getEast(), zoomLevel); + + var rects = []; + for (var y = TY; y <= BY; y++) { + for (var x = LX; x <= RX; x++) { + var rect = getTileRect(x, y, zoomLevel); + if (isTileInSelection(rect)) { + rects.push({ x: x, y: y, z: zoomLevel, rect: rect }); + } + } + } + return rects; + } + + function getAllGridTiles() { + var allTiles = []; + for (var z = getMinZoom(); z <= getMaxZoom(); z++) { + allTiles = allTiles.concat(getGrid(z)); + } + return allTiles; + } + + // ─── Quad Key ───────────────────────────────────────────────────────────── + function generateQuadKey(x, y, z) { + var quadKey = []; + for (var i = z; i > 0; i--) { + var digit = "0"; + var mask = 1 << (i - 1); + if ((x & mask) !== 0) digit++; + if ((y & mask) !== 0) { + digit++; + digit++; + } + quadKey.push(digit); + } + return quadKey.join(""); + } + + // ─── Downloader ─────────────────────────────────────────────────────────── + function initializeDownloader() { + bar = new ProgressBar.Circle($("#progress-radial").get(0), { + strokeWidth: 12, + easing: "easeOut", + duration: 200, + trailColor: "#eee", + trailWidth: 1, + from: { color: "#0fb9b1", a: 0 }, + to: { color: "#20bf6b", a: 1 }, + svgStyle: null, + step: function (state, circle) { + circle.path.setAttribute("stroke", state.color); + }, + }); + + $("#download-button").click(startDownloading); + $("#stop-button").click(stopDownloading); + } + + function showTinyTile(base64) { + var currentImages = $(".tile-strip img"); + for (var i = 4; i < currentImages.length; i++) { + $(currentImages[i]).remove(); + } + var image = $("").attr("src", "data:image/png;base64, " + base64); + $(".tile-strip").prepend(image); + } + + async function startDownloading() { + if (!drawnBounds) { + M.toast({ + html: "You need to select a region first.", + displayLength: 3000, + }); + return; + } + + cancellationToken = false; + requests = []; + + $("#main-sidebar").hide(); + $("#download-sidebar").show(); + $(".tile-strip").html(""); + $("#stop-button").html("STOP"); + removeGrid(); + clearLogs(); + M.Toast.dismissAll(); + + var timestamp = Date.now().toString(); + var allTiles = getAllGridTiles(); + updateProgress(0, allTiles.length); + + var numThreads = parseInt($("#parallel-threads-box").val()); + var outputDirectory = $("#output-directory-box").val(); + var outputFile = $("#output-file-box").val(); + var outputType = $("#output-type").val(); + var outputScale = $("#output-scale").val(); + var source = $("#source-box").val(); + + var bounds = getBounds(); + var boundsArray = [ + bounds.getSouthWest().lng, + bounds.getSouthWest().lat, + bounds.getNorthEast().lng, + bounds.getNorthEast().lat, + ]; + var centerArray = [ + bounds.getCenter().lng, + bounds.getCenter().lat, + getMaxZoom(), + ]; + + var startData = new FormData(); + startData.append("minZoom", getMinZoom()); + startData.append("maxZoom", getMaxZoom()); + startData.append("outputDirectory", outputDirectory); + startData.append("outputFile", outputFile); + startData.append("outputType", outputType); + startData.append("outputScale", outputScale); + startData.append("source", source); + startData.append("timestamp", timestamp); + startData.append("bounds", boundsArray.join(",")); + startData.append("center", centerArray.join(",")); + + await $.ajax({ + url: "/start-download", + async: true, + timeout: 30 * 1000, + type: "post", + contentType: false, + processData: false, + data: startData, + dataType: "json", + }); + + var i = 0; + async.eachLimit( + allTiles, + numThreads, + function (item, done) { + if (cancellationToken) return; + + var boxLayer = previewRect(item); + var tileData = new FormData(); + tileData.append("x", item.x); + tileData.append("y", item.y); + tileData.append("z", item.z); + tileData.append("quad", generateQuadKey(item.x, item.y, item.z)); + tileData.append("outputDirectory", outputDirectory); + tileData.append("outputFile", outputFile); + tileData.append("outputType", outputType); + tileData.append("outputScale", outputScale); + tileData.append("timestamp", timestamp); + tileData.append("source", source); + tileData.append("bounds", boundsArray.join(",")); + tileData.append("center", centerArray.join(",")); + + var request = $.ajax({ + url: "/download-tile", + async: true, + timeout: 30 * 1000, + type: "post", + contentType: false, + processData: false, + data: tileData, + dataType: "json", + }) + .done(function (data) { + if (cancellationToken) return; + if (data.code === 200) { + showTinyTile(data.image); + logItem(item.x, item.y, item.z, data.message); + } else { + logItem( + item.x, + item.y, + item.z, + data.code + " Error downloading tile", + ); + } + }) + .fail(function () { + if (cancellationToken) return; + logItem(item.x, item.y, item.z, "Error while relaying tile"); + }) + .always(function () { + i++; + removeLayer(boxLayer); + updateProgress(i, allTiles.length); + done(); + }); + + requests.push(request); + }, + async function () { + await $.ajax({ + url: "/end-download", + async: true, + timeout: 30 * 1000, + type: "post", + contentType: false, + processData: false, + data: startData, + dataType: "json", + }); + + updateProgress(allTiles.length, allTiles.length); + logItemRaw("All requests are done"); + $("#stop-button").html("FINISH"); + }, + ); + } + + function stopDownloading() { + cancellationToken = true; + for (var i = 0; i < requests.length; i++) { + try { + requests[i].abort(); + } catch (e) {} + } + $("#main-sidebar").show(); + $("#download-sidebar").hide(); + removeGrid(); + clearLogs(); + } + + // ─── Progress & Logs ───────────────────────────────────────────────────── + function updateProgress(value, total) { + var progress = total === 0 ? 0 : value / total; + bar.animate(progress); + bar.setText(Math.round(progress * 100) + "%"); + $("#progress-subtitle").html( + value.toLocaleString() + " out of " + total.toLocaleString(), + ); + } + + function logItem(x, y, z, text) { + logItemRaw(x + "," + y + "," + z + " : " + text); + } + + function logItemRaw(text) { + var logger = $("#log-view"); + logger.val(logger.val() + "\n" + text); + logger.scrollTop(logger[0].scrollHeight); + } + + function clearLogs() { + $("#log-view").val(""); + } + + // ─── Boot ──────────────────────────────────────────────────────────────── + initializeMaterialize(); + initializeMap(); + initializeSources(); + initializeSearch(); + initializeRectangleTool(); + initializeGridPreview(); + initializeMoreOptions(); + initializeDownloader(); +}); diff --git a/src/requirements.txt b/src/requirements.txt index 61f9de5..5873a22 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1 +1 @@ -Pillow==7.1.2 \ No newline at end of file +Pillow \ No newline at end of file diff --git a/src/utils.py b/src/utils.py index a575b3e..1f88bc5 100644 --- a/src/utils.py +++ b/src/utils.py @@ -46,7 +46,7 @@ def makeQuadKey(tile_x, tile_y, level): for i in range(level): bit = level - i digit = ord('0') - mask = 1 << (bit - 1) # if (bit - 1) > 0 else 1 >> (bit - 1) + mask = 1 << (bit - 1) if (tile_x & mask) != 0: digit += 1 if (tile_y & mask) != 0: @@ -67,10 +67,15 @@ def qualifyURL(url, x, y, z): scale22 = 23 - (z * 2) + # Pick a random subdomain for providers that use {s} (e.g. OSM, Stadia) + subdomains = ["a", "b", "c"] + subdomain = random.choice(subdomains) + replaceMap = { "x": str(x), "y": str(y), "z": str(z), + "s": subdomain, "scale:22": str(scale22), "quad": Utils.makeQuadKey(x, y, z), } @@ -120,11 +125,21 @@ def downloadFile(url, destination, x, y, z): code = 0 # monkey patching SSL certificate issue - # DONT use it in a prod/sensitive environment ssl._create_default_https_context = ssl._create_unverified_context + # Add a browser-like User-Agent so tile servers don't block the request + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "image/png,image/*,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + "Referer": "https://www.openstreetmap.org/", + } + try: - path, response = urllib.request.urlretrieve(url, destination) + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req) as response: + with open(destination, "wb") as out_file: + out_file.write(response.read()) code = 200 except urllib.error.URLError as e: if not hasattr(e, "code"): @@ -165,10 +180,4 @@ def downloadFileScaled(url, destination, x, y, z, outputScale): canvas.save(destination, "PNG") return 200 - - #TODO implement custom scale - - - - - + \ No newline at end of file From 7c40ee78bf32dfbb948f73ca9649a3d7788b6d02 Mon Sep 17 00:00:00 2001 From: Jericho Date: Wed, 18 Mar 2026 14:29:34 +0800 Subject: [PATCH 2/2] feat: add stitch_tiles.py to combine tiles into PNG/PDF --- src/stitch_tiles.py | 202 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 src/stitch_tiles.py diff --git a/src/stitch_tiles.py b/src/stitch_tiles.py new file mode 100644 index 0000000..c18cea0 --- /dev/null +++ b/src/stitch_tiles.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python +""" +stitch_tiles.py — Stitch downloaded map tiles into a single image or PDF. + +Usage: + python stitch_tiles.py --input output/1234567890 --zoom 15 + python stitch_tiles.py --input output/1234567890 --zoom 15 --format pdf + python stitch_tiles.py --input output/1234567890 --zoom 15 --format png + python stitch_tiles.py --input output/1234567890 --zoom 15 --format both + +Options: + --input Path to the tile directory (e.g. output/1234567890) + --zoom Zoom level to stitch (must match downloaded zoom level) + --format Output format: png, pdf, or both (default: both) + --out Output filename without extension (default: stitched_map) + --max-size Max output image size in pixels per side (default: 16000) + Larger areas will be downscaled to fit. Increase for printing. + --dpi DPI for PDF output (default: 150) + +Examples: + # Stitch zoom level 15 tiles from a download session + python stitch_tiles.py --input output/1716000000000 --zoom 15 + + # High-res PDF for A3 printing + python stitch_tiles.py --input output/1716000000000 --zoom 15 --format pdf --dpi 300 --max-size 30000 +""" + +import os +import sys +import argparse +import glob +from PIL import Image + +def find_tiles(input_dir, zoom): + """Scan the directory for all tiles at a given zoom level.""" + pattern = os.path.join(input_dir, str(zoom), "*", "*.png") + files = glob.glob(pattern) + + # Also try jpg + if not files: + pattern = os.path.join(input_dir, str(zoom), "*", "*.jpg") + files = glob.glob(pattern) + + tiles = [] + for f in files: + parts = f.replace("\\", "/").split("/") + try: + # Expect structure: .../zoom/x/y.ext + z = int(parts[-3]) + x = int(parts[-2]) + y = int(os.path.splitext(parts[-1])[0]) + tiles.append((x, y, z, f)) + except (ValueError, IndexError): + continue + + return tiles + + +def stitch(input_dir, zoom, max_size=16000, dpi=150, fmt="both", out_name="stitched_map"): + print(f"\n🗺 Scanning tiles in: {input_dir}") + print(f" Zoom level: {zoom}") + + tiles = find_tiles(input_dir, zoom) + + if not tiles: + print(f"\n❌ No tiles found at zoom {zoom} in {input_dir}") + print(f" Make sure the folder structure is: {input_dir}/{zoom}/x/y.png") + sys.exit(1) + + print(f" Found {len(tiles)} tiles") + + # Get bounds + xs = [t[0] for t in tiles] + ys = [t[1] for t in tiles] + min_x, max_x = min(xs), max(xs) + min_y, max_y = min(ys), max(ys) + + cols = max_x - min_x + 1 + rows = max_y - min_y + 1 + + print(f" Grid: {cols} columns × {rows} rows") + + # Detect tile size from first tile + sample_path = tiles[0][3] + with Image.open(sample_path) as sample: + tile_w, tile_h = sample.size + + print(f" Tile size: {tile_w}×{tile_h}px") + + full_w = cols * tile_w + full_h = rows * tile_h + print(f" Full image size: {full_w}×{full_h}px") + + # Downscale if needed + scale = 1.0 + if full_w > max_size or full_h > max_size: + scale = min(max_size / full_w, max_size / full_h) + out_w = int(full_w * scale) + out_h = int(full_h * scale) + print(f" Downscaling to {out_w}×{out_h}px (scale={scale:.3f}) to stay under --max-size {max_size}") + else: + out_w = full_w + out_h = full_h + + # Build tile lookup + tile_map = {(t[0], t[1]): t[3] for t in tiles} + + # Create canvas + print(f"\n⏳ Stitching...") + canvas = Image.new("RGB", (full_w, full_h), (200, 200, 200)) + + missing = 0 + for i, x in enumerate(range(min_x, max_x + 1)): + for j, y in enumerate(range(min_y, max_y + 1)): + path = tile_map.get((x, y)) + if path: + try: + with Image.open(path) as tile: + canvas.paste(tile.convert("RGB"), (i * tile_w, j * tile_h)) + except Exception as e: + missing += 1 + else: + missing += 1 + + # Progress + pct = int((i + 1) / cols * 100) + bar = "█" * (pct // 5) + "░" * (20 - pct // 5) + print(f"\r [{bar}] {pct}% ({i+1}/{cols} columns)", end="", flush=True) + + print() + + if missing: + print(f" ⚠️ {missing} missing tiles replaced with grey") + + # Downscale if needed + if scale < 1.0: + print(f" Resizing to {out_w}×{out_h}px...") + canvas = canvas.resize((out_w, out_h), Image.LANCZOS) + + saved = [] + + # Save PNG + if fmt in ("png", "both"): + png_path = out_name + ".png" + print(f"\n💾 Saving PNG: {png_path}") + canvas.save(png_path, "PNG", optimize=True) + size_mb = os.path.getsize(png_path) / 1024 / 1024 + print(f" ✅ Saved ({size_mb:.1f} MB)") + saved.append(png_path) + + # Save PDF + if fmt in ("pdf", "both"): + pdf_path = out_name + ".pdf" + print(f"\n📄 Saving PDF: {pdf_path}") + + # Calculate page size in points (1 inch = 72 points) + page_w = out_w / dpi * 72 + page_h = out_h / dpi * 72 + + canvas.save( + pdf_path, + "PDF", + resolution=dpi, + save_all=False, + ) + size_mb = os.path.getsize(pdf_path) / 1024 / 1024 + print(f" ✅ Saved ({size_mb:.1f} MB) at {dpi} DPI") + print(f" 📐 Page size: {page_w/72:.1f}\" × {page_h/72:.1f}\" at {dpi} DPI") + saved.append(pdf_path) + + print(f"\n🎉 Done! Output files:") + for s in saved: + print(f" → {os.path.abspath(s)}") + + +def main(): + parser = argparse.ArgumentParser( + description="Stitch map tiles into a single image or PDF.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__ + ) + parser.add_argument("--input", required=True, help="Path to tile directory (e.g. output/1716000000000)") + parser.add_argument("--zoom", required=True, type=int, help="Zoom level to stitch") + parser.add_argument("--format", default="both", choices=["png", "pdf", "both"], help="Output format (default: both)") + parser.add_argument("--out", default="stitched_map", help="Output filename without extension (default: stitched_map)") + parser.add_argument("--max-size", default=16000, type=int, help="Max pixels per side before downscaling (default: 16000)") + parser.add_argument("--dpi", default=150, type=int, help="DPI for PDF (default: 150)") + + args = parser.parse_args() + + stitch( + input_dir=args.input, + zoom=args.zoom, + max_size=args.max_size, + dpi=args.dpi, + fmt=args.format, + out_name=args.out, + ) + + +if __name__ == "__main__": + main()