This commit is contained in:
Nils
2025-08-27 10:54:55 +02:00
parent 4ca51d3115
commit 0c158f83bb
24 changed files with 2216 additions and 241 deletions

View File

@@ -2,7 +2,7 @@ First off, thanks for taking the time to contribute!
## Please Complete the Following
- [ ] I read [CONTRIBUTING.md](https://github.com/Cyclenerd/template/blob/master/CONTRIBUTING.md)
- [ ] I read [CONTRIBUTING.md](https://github.com/Cyclenerd/offline-map-tile-downloader/blob/master/CONTRIBUTING.md)
## Notes

View File

@@ -4,3 +4,7 @@ updates:
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"

View File

@@ -1,4 +1,4 @@
name: "CI"
name: CI
on:
push:
@@ -7,25 +7,23 @@ on:
jobs:
test:
name: CI/CD Test
name: Test
# https://github.com/actions/virtual-environments/
runs-on: ubuntu-latest
steps:
- name: 🛎️ Checkout
uses: actions/checkout@v5
# Test
# https://github.com/marketplace/actions/setup-go-environment
- name: 🔧 Setup go
uses: actions/setup-go@v5
# https://github.com/marketplace/actions/run-golangci-lint
- name: 🌡️ Lint
uses: golangci/golangci-lint-action@v8
- name: 🍳 Build
run: make native
- name: 🌡️ Test
run: uname -a
# Test Linux operating systems
- name: 🐧 Test Debian 11 (Bullseye)
run: |
docker pull debian:11
docker run -v $PWD:/temp/test debian:11 uname -a
- name: 🐧 Test Rocky Linux 8
run: |
docker pull rockylinux:8
docker run -v $PWD:/temp/test rockylinux:8 uname -a
run: ./offline-map-tile-downloader --help

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
offline-map-tile-downloader*
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when run with the
# -o option to specify an output file.
*.out
# Dependency directories (remove the comment below if you do not vendor)
# vendor/
# Go workspace file
go.work
# Log files
*.log
# OS-specific files
.DS_Store
# Tile cache
maps/

View File

@@ -24,7 +24,7 @@ in your GitHub account.
Clone your newly created fork of the repository to your local machine with the following command:
```bash
git clone https://github.com/your-username/template.git
git clone https://github.com/your-username/offline-map-tile-downloader.git
```
## Create a New Branch 🌿
@@ -37,6 +37,7 @@ git checkout -b "feature-or-issue-name"
```
## Submitting Changes 🚀
Make your desired changes to the codebase.
Stage your changes using the following command:
@@ -58,12 +59,20 @@ This will open a new pull request to the original repository.
## Coding Style 📝
Start reading the code, and you'll get the hang of it. It is optimized for readability:
This project follows standard Go coding conventions. Before submitting your changes, please ensure that your code is formatted with `gofmt`.
- Variables must be uppercase and should begin with `MY_`.
- Functions must be lowercase.
- Check your shell scripts with ShellCheck before submitting.
- Please use tabs to indent.
Most editors for Go will format your code automatically on save. You can also run `gofmt` manually:
```bash
gofmt -w .
```
In addition to `gofmt`, please follow these guidelines:
- Write clear and concise comments where necessary.
- Follow the naming conventions established in the existing code.
- Keep functions and methods short and focused on a single task.
- Handle errors explicitly and avoid panicking.
## Keep It Simple 👍

214
LICENSE
View File

@@ -1,201 +1,21 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
MIT License
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
Copyright (c) 2025 Matthew Drummonds
1. Definitions.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

37
Makefile Normal file
View File

@@ -0,0 +1,37 @@
# Name of the binary output
BINARY = offline-map-tile-downloader
# Builds the project
build: native linux macos windows
native:
go build -o ${BINARY}
@echo
linux:
@echo "Linux"
GOOS=linux GOARCH=amd64 go build -o ${BINARY}-linux-x86_64
GOOS=linux GOARCH=arm64 go build -o ${BINARY}-linux-arm64
@echo
macos:
@echo "macOS"
GOOS=darwin GOARCH=amd64 go build -o ${BINARY}-macos-x86_64
GOOS=darwin GOARCH=arm64 go build -o ${BINARY}-macos-arm64
@echo
windows:
@echo "Windows"
GOOS=windows GOARCH=amd64 go build -o ${BINARY}-windows-x86_64.exe
GOOS=windows GOARCH=arm64 go build -o ${BINARY}-windows-arm64.exe
@echo
# Clean the project: deletes binaries
clean:
if [ -f ${BINARY} ] ; then rm ${BINARY} ; fi
if [ -f ${BINARY}-linux-x86_64 ] ; then rm ${BINARY}-linux-x86_64 ; fi
if [ -f ${BINARY}-linux-arm64 ] ; then rm ${BINARY}-linux-arm64 ; fi
if [ -f ${BINARY}-macos-x86_64 ] ; then rm ${BINARY}-macos-x86_64 ; fi
if [ -f ${BINARY}-macos-arm64 ] ; then rm ${BINARY}-macos-arm64 ; fi
if [ -f ${BINARY}-windows-x86_64.exe ] ; then rm ${BINARY}-windows-x86_64.exe ; fi
if [ -f ${BINARY}-windows-arm64.exe ] ; then rm ${BINARY}-windows-arm64.exe ; fi

125
README.md
View File

@@ -1,34 +1,117 @@
# My Template
# Offline Map Tile Downloader
This is my template repository to generate new repositories with the same directory structure and files.
**Create your own offline maps for any location on Earth!** This tool allows you to download map tiles from various sources and use them in your offline applications, with a special focus on the needs of the **Off-Grid** and **Meshtastic** communities.
1. Replace `template` with new repo name (<kbd>Crtl</kbd>+<kbd>Shift</kbd>+<kbd>H</kbd>)
Whether you're a hiker, prepper, sailor, or just someone who wants to be prepared, having access to maps when you're disconnected from the internet is crucial. This tool makes it easy to create your own custom map sets for your specific needs.
[![Badge: CI](https://github.com/Cyclenerd/template/actions/workflows/ci.yml/badge.svg)](https://github.com/Cyclenerd/template/actions/workflows/ci.yml)
[![Badge: GitHub](https://img.shields.io/github/license/cyclenerd/template)](https://github.com/Cyclenerd/template/blob/master/LICENSE)
## Why?
## 🧑‍💻 Development
In a world that's increasingly reliant on internet connectivity, being able to access information offline is a superpower. This is especially true for:
### Requirements
* **The Off-Grid Community:** When you're living off the grid, you can't rely on a stable internet connection. This tool allows you to have detailed maps of your surroundings, which is essential for navigation, resource management, and safety.
* **The Meshtastic Community:** Meshtastic is a fantastic open-source, off-grid, decentralized, mesh networking project. It allows you to send messages and other data over long distances without needing the internet. This tool allows you to create custom map tiles that can be used with the Meshtastic UI, giving you a visual representation of your mesh network on a map, even when you're completely offline.
* A
* B
* C
## Features
## ❤️ Contributing
* **Web Interface:** A user-friendly web UI for selecting download areas and monitoring progress.
* **Polygon & Bounding Box Selection:** Define download areas using polygons or bounding boxes.
* **Concurrent Downloads:** Downloads multiple tiles concurrently for faster performance.
* **Rate Limiting:** Limits the number of tile downloads per second to avoid overloading the tile server.
* **Cancellable Downloads:** Cancel ongoing downloads at any time.
* **8-bit PNG Conversion:** Option to convert downloaded tiles to 8-bit PNGs, ideal for devices with limited color palettes like the Meshtastic UI.
* **Offline Tile Server:** Serve downloaded tiles directly from the application, allowing you to use them in offline map applications.
* **Cross-platform:** Works on Windows, macOS, and Linux.
Have a patch that will benefit this project?
Awesome! Follow these steps to have it accepted.
## Getting Started
1. Please read [how to contribute](CONTRIBUTING.md).
1. Fork this Git repository and make your changes.
1. Create a Pull Request.
1. Incorporate review feedback to your changes.
1. Accepted!
You can either download a pre-built binary for your operating system or build the application from source.
### Pre-built Binaries (Recommended for most users)
## 📜 License
1. Go to the [Releases page](https://github.com/Cyclenerd/offline-map-tile-downloader/releases) of this repository.
2. Download the latest release for your operating system (Windows, macOS, or Linux).
3. Unzip the downloaded file.
4. Run the `offline-map-tile-downloader` executable.
5. Open your web browser and go to `http://localhost:8080`.
All files in this repository are under the [Apache License, Version 2.0](LICENSE) unless noted otherwise.
## File Storage
Please note: No warranty
The downloaded map tiles are stored in the local filesystem. The default directory is `maps`, but you can change this using the `-maps-directory` command-line option. The tiles are organized by map style, zoom level, and tile coordinates.
### Command-line Options
You can also use command-line options to configure the application:
* `-port`: The port number for the server (default: `8080`).
* `-maps-directory`: The directory for cached map tiles (default: `maps`).
* `-max-workers`: The number of concurrent download workers (default: `10`).
* `-rate-limit`: The maximum number of tiles to download per second (default: `50`).
* `-max-retries`: The maximum number of retries for downloading a tile (default: `3`).
* `-help`: Show the help message.
Example:
```bash
./offline-map-tile-downloader -port 8081 -maps-directory my-tile-cache -max-workers 5 -rate-limit 25
```
## Meshtastic Integration
This tool is perfect for creating offline maps for the Meshtastic UI. Here's how to do it:
1. **Download the tiles:**
* Select the area you want to download.
* **Crucially, check the "Convert to 8-bit" checkbox.** This is required for Meshtastic.
* Click "Download Tiles".
2. **Use with Meshtastic:**
* The downloaded tiles are stored in the `maps` directory. You can now use these tiles with the Meshtastic UI. For more information on how to do this, please refer to the [Meshtastic documentation](https://meshtastic.org/docs/software/meshtastic-ui/#map).
### Building from Source
If you're a developer or want to modify the code, you can build the application from source.
1. **Clone the repository:**
```bash
git clone https://github.com/Cyclenerd/offline-map-tile-downloader.git
cd offline-map-tile-downloader
```
2. **Build the application:**
```bash
go build .
```
3. **Run the application:**
```bash
./offline-map-tile-downloader
```
4. **Open your web browser** and go to `http://localhost:8080`.
## Configuration
You can add your own map sources by editing the `config/map_sources.json` file. The format is simple:
```json
{
"Map Source Name": "https://tile.server.url/{z}/{x}/{y}.png",
"Another Map Source": "https://another.tile.server/{z}/{x}/{y}.png"
}
```
## Contributing
If you have an idea for a new feature or have found a bug, please open an issue or submit a pull request.
## License
This project is licensed under the MIT License. See the `LICENSE` file for details.
The emoji graphics are from the open source project [Twemoji](https://twemoji.twitter.com/). The graphics are copyright 2020 Twitter, Inc and other contributors. The graphics are licensed under [CC-BY 4.0](https://creativecommons.org/licenses/by/4.0/). You should review the license before usage in your project.
Leaflet is licensed under the BSD 2-Clause "Simplified" License. See the [https://github.com/Leaflet/Leaflet/blob/main/LICENSE](https://github.com/Leaflet/Leaflet/blob/main/LICENSE) file for details.
## Acknowledgements
* **[Leaflet](https://github.com/Leaflet/Leaflet):** For the interactive map interface.
* **[Gorilla WebSocket](https://github.com/gorilla/websocket):** For real-time communication.
* **[mattdrum](https://github.com/mattdrum/map-tile-downloader):** For the original idea and a Python implementation.
* **[Google Gemini CLI](https://github.com/google-gemini/gemini-cli):** For providing invaluable assistance with code generation, debugging, and project documentation.

9
config/map_sources.json Normal file
View File

@@ -0,0 +1,9 @@
{
"OSM": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
"OSM Germany": "https://{s}.tile.openstreetmap.de/{z}/{x}/{y}.png",
"OpenTopoMap Outdoors": "https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png",
"Carto Positron": "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png",
"Carto Dark Matter": "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
"Esri World Imagery Satellite": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
"Google Satellite": "https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}"
}

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module offline-map-tile-downloader
go 1.24.5
require github.com/gorilla/websocket v1.5.3

2
go.sum Normal file
View File

@@ -0,0 +1,2 @@
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=

790
main.go Normal file
View File

@@ -0,0 +1,790 @@
// The main package is the entry point for the application.
package main
// Import necessary libraries.
import (
"bytes"
"context"
"embed" // Used for embedding files into the binary.
"encoding/json"
"flag"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"io"
"io/fs"
"log"
"math"
"math/rand"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/gorilla/websocket" // WebSocket library for real-time communication.
)
//go:embed templates/index.html
var indexHTML []byte // Embeds the index.html file into the binary.
//go:embed config/map_sources.json
var mapSourcesJSON []byte // Embeds the map_sources.json file into the binary.
//go:embed static/*
var staticFiles embed.FS
// upgrader is used to upgrade HTTP connections to WebSocket connections.
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024, // Size of the read buffer.
WriteBufferSize: 1024, // Size of the write buffer.
}
// Global variables used throughout the application.
var (
mapSources map[string]string // Stores the available map sources.
downloadCancel context.CancelFunc // Function to cancel an ongoing download.
downloading bool // Flag to indicate if a download is in progress.
downloadingMutex sync.Mutex // Mutex to protect access to the downloading flag.
cacheDir *string
maxWorkers *int
rateLimit *int
maxRetries *int
)
// Tile represents a single map tile with X, Y coordinates and zoom level Z.
type Tile struct {
X, Y, Z uint32
}
// BoundingBox represents a geographical area with North, South, East, and West boundaries.
type BoundingBox struct {
North, South, East, West float64
}
// LatLng represents a geographical point with latitude and longitude.
type LatLng struct {
Lat float64 `json:"lat"` // Latitude
Lng float64 `json:"lng"` // Longitude
}
// DownloadRequest represents a request to download map tiles for a specific area.
type DownloadRequest struct {
Polygons [][]LatLng `json:"polygons"` // The polygons defining the download area.
MinZoom int `json:"min_zoom"` // The minimum zoom level to download.
MaxZoom int `json:"max_zoom"` // The maximum zoom level to download.
MapStyle string `json:"map_style"` // The URL of the map tile server.
ConvertTo8Bit bool `json:"convert_to_8bit"` // Whether to convert images to 8-bit PNG.
}
// WorldDownloadRequest represents a request to download map tiles for the entire world.
type WorldDownloadRequest struct {
MapStyle string `json:"map_style"` // The URL of the map tile server.
ConvertTo8Bit bool `json:"convert_to_8bit"` // Whether to convert images to 8-bit PNG.
}
// WSMessage represents a WebSocket message with a type and data.
type WSMessage struct {
Type string `json:"type"` // The type of the message (e.g., "start_download").
Data interface{} `json:"data"` // The data associated with the message.
}
// main is the entry point of the application.
func main() {
// Command line flags
port := flag.Int("port", 8080, "Port number for the server")
cacheDir = flag.String("maps-directory", "maps", "Directory for storing map tiles. This is where the downloaded tiles will be saved.")
maxWorkers = flag.Int("max-workers", 10, "Number of concurrent download workers")
rateLimit = flag.Int("rate-limit", 50, "Maximum number of tiles to download per second")
maxRetries = flag.Int("max-retries", 3, "Maximum number of retries for downloading a tile")
help := flag.Bool("help", false, "Show help message")
flag.Parse()
if *help {
flag.Usage()
return
}
// Create cache directory if it doesn't exist.
if err := os.MkdirAll(*cacheDir, 0755); err != nil {
log.Fatalf("Failed to create cache directory: %v", err)
}
// Load map sources from the embedded JSON file.
if err := json.Unmarshal(mapSourcesJSON, &mapSources); err != nil {
log.Fatalf("Failed to load map sources: %v", err)
}
// Register HTTP handlers for different routes.
http.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
r.URL.Path = "/static/favicon.ico"
http.FileServer(http.FS(staticFiles)).ServeHTTP(w, r)
})
http.HandleFunc("/", serveHome)
http.HandleFunc("/get_map_sources", getMapSources)
http.HandleFunc("/ws", wsHandler)
http.HandleFunc("/tiles/", serveTile)
http.HandleFunc("/get_cached_tiles/", getCachedTiles)
staticFS, err := fs.Sub(staticFiles, "static")
if err != nil {
log.Fatal(err)
}
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
// Start the HTTP server on port 8080.
addr := fmt.Sprintf(":%d", *port)
log.Printf("Starting server on %s", addr)
if err := http.ListenAndServe(addr, nil); err != nil {
log.Fatalf("Error starting server: %v", err)
}
}
// serveHome serves the main HTML page.
func serveHome(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write(indexHTML)
}
// getMapSources serves the available map sources as JSON.
func getMapSources(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write(mapSourcesJSON)
}
// wsHandler handles WebSocket connections.
func wsHandler(w http.ResponseWriter, r *http.Request) {
// Upgrade the HTTP connection to a WebSocket connection.
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println(err)
return
}
defer conn.Close()
// Loop to read messages from the WebSocket connection.
for {
messageType, p, err := conn.ReadMessage()
if err != nil {
log.Println(err)
return
}
if messageType == websocket.TextMessage {
var msg WSMessage
if err := json.Unmarshal(p, &msg); err != nil {
log.Println("Error unmarshalling message:", err)
continue
}
// Handle different message types.
switch msg.Type {
case "start_download":
var req DownloadRequest
b, _ := json.Marshal(msg.Data)
if err := json.Unmarshal(b, &req); err != nil {
sendError(conn, "Invalid download request")
continue
}
go handleStartDownload(conn, req)
case "start_world_download":
var req WorldDownloadRequest
b, _ := json.Marshal(msg.Data)
if err := json.Unmarshal(b, &req); err != nil {
sendError(conn, "Invalid world download request")
continue
}
go handleStartWorldDownload(conn, req)
case "cancel_download":
handleCancelDownload(conn)
}
}
}
}
// handleStartDownload starts a new download process for a defined area.
func handleStartDownload(conn *websocket.Conn, req DownloadRequest) {
// Lock the mutex to ensure only one download runs at a time.
downloadingMutex.Lock()
if downloading {
sendError(conn, "Another download is already in progress.")
downloadingMutex.Unlock()
return
}
downloading = true
downloadingMutex.Unlock()
// Defer setting the downloading flag to false.
defer func() {
downloadingMutex.Lock()
downloading = false
downloadingMutex.Unlock()
}()
log.Printf("Starting download for area: %v, zoom: %d-%d, map style: %s", req.Polygons, req.MinZoom, req.MaxZoom, req.MapStyle)
// Create a new context to allow for cancellation.
var ctx context.Context
ctx, downloadCancel = context.WithCancel(context.Background())
// Get the style name and cache directory.
styleName := getStyleName(req.MapStyle)
styleCacheDir := getStyleCacheDir(styleName)
// Validate the zoom range.
if req.MinZoom < 0 || req.MaxZoom > 19 || req.MinZoom > req.MaxZoom {
sendError(conn, "Invalid zoom range (must be 0-19, min <= max)")
return
}
// Validate the polygons.
if len(req.Polygons) == 0 {
sendError(conn, "No polygons provided")
return
}
// Get the list of tiles to download.
tilesToDownload := getTilesForPolygons(req.Polygons, req.MinZoom, req.MaxZoom)
// Start the tile download process.
downloadTiles(ctx, conn, tilesToDownload, req.MapStyle, styleCacheDir, req.ConvertTo8Bit)
// If the download was not cancelled
if ctx.Err() == nil {
sendMessage(conn, "download_complete", nil)
}
}
// handleStartWorldDownload starts a new download process for the entire world.
func handleStartWorldDownload(conn *websocket.Conn, req WorldDownloadRequest) {
// Lock the mutex to ensure only one download runs at a time.
downloadingMutex.Lock()
if downloading {
sendError(conn, "Another download is already in progress.")
downloadingMutex.Unlock()
return
}
downloading = true
downloadingMutex.Unlock()
// Defer setting the downloading flag to false.
defer func() {
downloadingMutex.Lock()
downloading = false
downloadingMutex.Unlock()
}()
log.Printf("Starting world download, map style: %s", req.MapStyle)
// Create a new context to allow for cancellation.
var ctx context.Context
ctx, downloadCancel = context.WithCancel(context.Background())
// Get the style name and cache directory.
styleName := getStyleName(req.MapStyle)
styleCacheDir := getStyleCacheDir(styleName)
// Get the list of tiles to download for the world.
tilesToDownload := getWorldTiles()
// Start the tile download process.
downloadTiles(ctx, conn, tilesToDownload, req.MapStyle, styleCacheDir, req.ConvertTo8Bit)
// If the download was not cancelled
if ctx.Err() == nil {
sendMessage(conn, "download_complete", nil)
}
}
// handleCancelDownload cancels an ongoing download.
func handleCancelDownload(conn *websocket.Conn) {
if downloadCancel != nil {
downloadCancel()
log.Printf("Download cancelled by user")
sendMessage(conn, "download_cancelled", nil)
}
}
// downloadTiles downloads a list of tiles concurrently.
func downloadTiles(ctx context.Context, conn *websocket.Conn, tilesToDownload []Tile, mapStyle, styleCacheDir string, convertTo8Bit bool) {
// Create a channel for WebSocket messages.
msgChan := make(chan WSMessage)
var writerWg sync.WaitGroup
writerWg.Add(1)
// Start a goroutine to send messages from the channel to the WebSocket connection.
go func() {
defer writerWg.Done()
for msg := range msgChan {
if err := conn.WriteJSON(msg); err != nil {
log.Println("Error writing JSON to websocket:", err)
return
}
}
}()
// Send a message indicating the download has started.
msgChan <- WSMessage{Type: "download_started", Data: map[string]int{"total_tiles": len(tilesToDownload)}}
// Use a WaitGroup to wait for all download goroutines to finish.
var downloadWg sync.WaitGroup
tileChan := make(chan Tile)
// Start the download workers.
for i := 0; i < *maxWorkers; i++ {
downloadWg.Add(1)
go func() {
defer downloadWg.Done()
for tile := range tileChan {
select {
case <-ctx.Done(): // Check if the download has been cancelled.
return
default:
downloadTile(ctx, msgChan, tile, mapStyle, styleCacheDir, convertTo8Bit, *maxRetries)
}
}
}()
}
// Rate limit the download of tiles.
ticker := time.NewTicker(time.Second / time.Duration(*rateLimit))
defer ticker.Stop()
for _, tile := range tilesToDownload {
select {
case <-ctx.Done():
break
case <-ticker.C:
tileChan <- tile
}
}
close(tileChan)
// Wait for all downloads to complete.
downloadWg.Wait()
close(msgChan)
writerWg.Wait()
// If the download was not cancelled, send a completion message.
if ctx.Err() == nil {
log.Printf("Download finished successfully")
sendMessage(conn, "tiles_downloaded", nil)
} else {
log.Printf("Download failed or was cancelled")
}
}
// downloadTile downloads a single map tile.
func downloadTile(ctx context.Context, msgChan chan<- WSMessage, tile Tile, mapStyle, styleCacheDir string, convertTo8Bit bool, maxRetries int) {
// Construct the path to the tile file.
tileDir := filepath.Join(styleCacheDir, fmt.Sprintf("%d/%d", tile.Z, tile.X))
tilePath := filepath.Join(tileDir, fmt.Sprintf("%d.png", tile.Y))
// Check if the tile already exists in the cache.
if _, err := os.Stat(tilePath); err == nil {
bounds := tileBounds(tile)
msgChan <- WSMessage{Type: "tile_skipped", Data: map[string]float64{
"west": bounds.West,
"south": bounds.South,
"east": bounds.East,
"north": bounds.North,
}}
return
}
// Construct the URL for the tile.
subdomain := string('a' + rand.Intn(3))
url := strings.Replace(mapStyle, "{s}", subdomain, -1)
url = strings.Replace(url, "{z}", fmt.Sprintf("%d", tile.Z), -1)
url = strings.Replace(url, "{x}", fmt.Sprintf("%d", tile.X), -1)
url = strings.Replace(url, "{y}", fmt.Sprintf("%d", tile.Y), -1)
var err error
for attempt := 0; attempt < maxRetries; attempt++ {
select {
case <-ctx.Done(): // Check for cancellation.
return
default:
}
var req *http.Request
req, err = http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
log.Printf("Error creating request for tile %v: %v. Retrying...", tile, err)
time.Sleep(time.Second * time.Duration(math.Pow(2, float64(attempt))))
continue
}
req.Header.Set("User-Agent", "MapTileDownloader/1.0 (Go)")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Printf("Error downloading tile %v: %v. Retrying...", tile, err)
time.Sleep(time.Second * time.Duration(math.Pow(2, float64(attempt))))
continue
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
log.Printf("Unexpected status code %d for tile %v. Retrying...", resp.StatusCode, tile)
time.Sleep(time.Second * time.Duration(math.Pow(2, float64(attempt))))
continue
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
log.Printf("Error reading tile body for tile %v: %v. Retrying...", tile, err)
time.Sleep(time.Second * time.Duration(math.Pow(2, float64(attempt))))
continue
}
if err := os.MkdirAll(tileDir, 0755); err != nil {
log.Printf("Error creating tile directory for tile %v: %v", tile, err)
return // No point in retrying if we can't create the directory
}
// Convert the image to 8-bit PNG if requested.
if convertTo8Bit {
img, _, err := image.Decode(bytes.NewReader(body))
if err == nil {
paletted := image.NewPaletted(img.Bounds(), color.Palette{})
draw.Draw(paletted, paletted.Rect, img, img.Bounds().Min, draw.Src)
var buf bytes.Buffer
if err := png.Encode(&buf, paletted); err == nil {
body = buf.Bytes()
}
}
}
if err := os.WriteFile(tilePath, body, 0644); err != nil {
log.Printf("Error writing tile %v: %v", tile, err)
return // No point in retrying if we can't write the file
}
bounds := tileBounds(tile)
msgChan <- WSMessage{Type: "tile_downloaded", Data: map[string]float64{
"west": bounds.West,
"south": bounds.South,
"east": bounds.East,
"north": bounds.North,
}}
return // Success!
}
// If all retries fail, send a failure message.
log.Printf("Failed to download tile %v after %d attempts.", tile, maxRetries)
msgChan <- WSMessage{Type: "tile_failed", Data: map[string]string{"tile": fmt.Sprintf("%d/%d/%d", tile.Z, tile.X, tile.Y)}}
}
// getTilesForPolygons calculates the tiles needed to cover the given polygons.
func getTilesForPolygons(polygonsData [][]LatLng, minZoom, maxZoom int) []Tile {
var allTiles []Tile
tileMap := make(map[Tile]bool)
for _, polyData := range polygonsData {
if len(polyData) < 3 {
continue
}
minLat, minLon := 90.0, 180.0
maxLat, maxLon := -90.0, -180.0
for _, p := range polyData {
if p.Lat < minLat {
minLat = p.Lat
}
if p.Lat > maxLat {
maxLat = p.Lat
}
if p.Lng < minLon {
minLon = p.Lng
}
if p.Lng > maxLon {
maxLon = p.Lng
}
}
for z := minZoom; z <= maxZoom; z++ {
tlx, tly := latLonToTile(maxLat, minLon, uint32(z))
brx, bry := latLonToTile(minLat, maxLon, uint32(z))
for x := tlx; x <= brx; x++ {
for y := tly; y <= bry; y++ {
tile := Tile{X: x, Y: y, Z: uint32(z)}
if _, exists := tileMap[tile]; exists {
continue
}
bounds := tileBounds(tile)
// Check if the tile is completely inside the polygon
if polygonContains(polyData, LatLng{Lat: bounds.North, Lng: bounds.West}) &&
polygonContains(polyData, LatLng{Lat: bounds.North, Lng: bounds.East}) &&
polygonContains(polyData, LatLng{Lat: bounds.South, Lng: bounds.West}) &&
polygonContains(polyData, LatLng{Lat: bounds.South, Lng: bounds.East}) {
allTiles = append(allTiles, tile)
tileMap[tile] = true
continue
}
// Check if the polygon is completely inside the tile
polyInTile := true
for _, p := range polyData {
if !tileContains(bounds, p) {
polyInTile = false
break
}
}
if polyInTile {
allTiles = append(allTiles, tile)
tileMap[tile] = true
continue
}
// Check for intersection
if polygonIntersects(polyData, bounds) {
allTiles = append(allTiles, tile)
tileMap[tile] = true
}
}
}
}
}
return allTiles
}
// tileContains checks if a tile contains a point.
func tileContains(bounds BoundingBox, point LatLng) bool {
return point.Lat <= bounds.North && point.Lat >= bounds.South && point.Lng >= bounds.West && point.Lng <= bounds.East
}
// polygonIntersects checks if a polygon intersects with a tile.
func polygonIntersects(poly []LatLng, bounds BoundingBox) bool {
// Check if any of the polygon's vertices are inside the tile
for _, p := range poly {
if tileContains(bounds, p) {
return true
}
}
// Check if any of the tile's corners are inside the polygon
if polygonContains(poly, LatLng{Lat: bounds.North, Lng: bounds.West}) ||
polygonContains(poly, LatLng{Lat: bounds.North, Lng: bounds.East}) ||
polygonContains(poly, LatLng{Lat: bounds.South, Lng: bounds.West}) ||
polygonContains(poly, LatLng{Lat: bounds.South, Lng: bounds.East}) {
return true
}
// Check if any of the polygon's edges intersect with the tile's edges
for i := 0; i < len(poly); i++ {
p1 := poly[i]
p2 := poly[(i+1)%len(poly)]
if lineIntersects(p1, p2, LatLng{Lat: bounds.North, Lng: bounds.West}, LatLng{Lat: bounds.North, Lng: bounds.East}) ||
lineIntersects(p1, p2, LatLng{Lat: bounds.North, Lng: bounds.East}, LatLng{Lat: bounds.South, Lng: bounds.East}) ||
lineIntersects(p1, p2, LatLng{Lat: bounds.South, Lng: bounds.East}, LatLng{Lat: bounds.South, Lng: bounds.West}) ||
lineIntersects(p1, p2, LatLng{Lat: bounds.South, Lng: bounds.West}, LatLng{Lat: bounds.North, Lng: bounds.West}) {
return true
}
}
return false
}
// lineIntersects checks if two line segments intersect.
func lineIntersects(p1, q1, p2, q2 LatLng) bool {
o1 := orientation(p1, q1, p2)
o2 := orientation(p1, q1, q2)
o3 := orientation(p2, q2, p1)
o4 := orientation(p2, q2, q1)
if o1 != o2 && o3 != o4 {
return true
}
// Special Cases for colinear points
if o1 == 0 && onSegment(p1, p2, q1) {
return true
}
if o2 == 0 && onSegment(p1, q2, q1) {
return true
}
if o3 == 0 && onSegment(p2, p1, q2) {
return true
}
if o4 == 0 && onSegment(p2, q1, q2) {
return true
}
return false
}
// orientation finds the orientation of the ordered triplet (p, q, r).
func orientation(p, q, r LatLng) int {
val := (q.Lng-p.Lng)*(r.Lat-q.Lat) - (q.Lat-p.Lat)*(r.Lng-q.Lng)
if val == 0 {
return 0 // Collinear
}
if val > 0 {
return 1 // Clockwise
}
return 2 // Counterclockwise
}
// onSegment checks if point q lies on segment pr.
func onSegment(p, q, r LatLng) bool {
if q.Lat <= math.Max(p.Lat, r.Lat) && q.Lat >= math.Min(p.Lat, r.Lat) &&
q.Lng <= math.Max(p.Lng, r.Lng) && q.Lng >= math.Min(p.Lng, r.Lng) {
return true
}
return false
}
// getWorldTiles returns a list of all tiles for the world up to zoom level 7.
func getWorldTiles() []Tile {
var worldTiles []Tile
for z := 0; z <= 7; z++ {
max := 1 << z
for x := 0; x < max; x++ {
for y := 0; y < max; y++ {
worldTiles = append(worldTiles, Tile{X: uint32(x), Y: uint32(y), Z: uint32(z)})
}
}
}
return worldTiles
}
// serveTile serves a single cached tile.
func serveTile(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/tiles/"), "/")
if len(parts) != 4 {
http.NotFound(w, r)
return
}
styleName := parts[0]
z := parts[1]
x := parts[2]
y := strings.TrimSuffix(parts[3], ".png")
tilePath := filepath.Join(*cacheDir, sanitizeStyleName(styleName), z, x, y+".png")
http.ServeFile(w, r, tilePath)
}
// getCachedTiles returns a list of cached tiles for a specific map style.
func getCachedTiles(w http.ResponseWriter, r *http.Request) {
styleName := strings.TrimPrefix(r.URL.Path, "/get_cached_tiles/")
styleCacheDir := getStyleCacheDir(styleName)
var cachedTiles [][3]uint32
err := filepath.Walk(styleCacheDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(info.Name(), ".png") {
parts := strings.Split(strings.TrimSuffix(path, ".png"), string(filepath.Separator))
if len(parts) >= 4 {
z, zErr := strToUint32(parts[len(parts)-3])
x, xErr := strToUint32(parts[len(parts)-2])
y, yErr := strToUint32(parts[len(parts)-1])
if zErr == nil && xErr == nil && yErr == nil {
cachedTiles = append(cachedTiles, [3]uint32{z, x, y})
}
}
}
return nil
})
if err != nil {
http.Error(w, fmt.Sprintf("Error reading cache: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(cachedTiles)
}
// getStyleName returns the name of the map style for a given URL.
func getStyleName(mapStyleURL string) string {
for name, url := range mapSources {
if url == mapStyleURL {
return name
}
}
return "default"
}
// getStyleCacheDir returns the cache directory for a given style name.
func getStyleCacheDir(styleName string) string {
return filepath.Join(*cacheDir, sanitizeStyleName(styleName))
}
// nonAlphanumeric is a regular expression to match any character that is not a letter, number, hyphen, or underscore.
var nonAlphanumeric = regexp.MustCompile(`[^a-zA-Z0-9-_]+`)
// sanitizeStyleName sanitizes the style name to be used as a directory name.
func sanitizeStyleName(styleName string) string {
return nonAlphanumeric.ReplaceAllString(strings.ReplaceAll(styleName, " ", "-"), "")
}
// sendMessage sends a WebSocket message.
func sendMessage(conn *websocket.Conn, msgType string, data interface{}) {
msg := WSMessage{Type: msgType, Data: data}
if err := conn.WriteJSON(msg); err != nil {
log.Println("Error sending message:", err)
}
}
// sendError sends an error message over the WebSocket connection.
func sendError(conn *websocket.Conn, message string) {
sendMessage(conn, "error", map[string]string{"message": message})
}
// strToUint32 converts a string to a uint32.
func strToUint32(s string) (uint32, error) {
var i uint32
_, err := fmt.Sscanf(s, "%d", &i)
return i, err
}
// latLonToTile converts latitude and longitude to tile coordinates.
func latLonToTile(lat, lon float64, zoom uint32) (x, y uint32) {
latRad := lat * math.Pi / 180
n := math.Pow(2, float64(zoom))
x = uint32(n * ((lon + 180) / 360))
y = uint32(n * (1 - (math.Log(math.Tan(latRad)+1/math.Cos(latRad)) / math.Pi)) / 2)
return
}
// tileBounds calculates the geographical bounding box of a tile.
func tileBounds(tile Tile) BoundingBox {
n := math.Pow(2.0, float64(tile.Z))
lonDeg := float64(tile.X)/n*360.0 - 180.0
latRad := math.Atan(math.Sinh(math.Pi * (1 - 2*float64(tile.Y)/n)))
latDeg := latRad * 180.0 / math.Pi
lon2Deg := float64(tile.X+1)/n*360.0 - 180.0
lat2Rad := math.Atan(math.Sinh(math.Pi * (1 - 2*float64(tile.Y+1)/n)))
lat2Deg := lat2Rad * 180.0 / math.Pi
return BoundingBox{
North: latDeg,
South: lat2Deg,
East: lon2Deg,
West: lonDeg,
}
}
// polygonContains checks if a point is inside a polygon using the ray casting algorithm.
func polygonContains(poly []LatLng, point LatLng) bool {
in := false
for i, j := 0, len(poly)-1; i < len(poly); j, i = i, i+1 {
if (poly[i].Lat > point.Lat) != (poly[j].Lat > point.Lat) &&
(point.Lng < (poly[j].Lng-poly[i].Lng)*(point.Lat-poly[i].Lat)/(poly[j].Lat-poly[i].Lat)+poly[i].Lng) {
in = !in
}
}
return in
}

BIN
static/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
static/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 B

BIN
static/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,156 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
viewBox="0 0 600 60"
height="60"
width="600"
id="svg4225"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="spritesheet.svg"
inkscape:export-filename="/home/fpuga/development/upstream/icarto.Leaflet.draw/src/images/spritesheet-2x.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<metadata
id="metadata4258">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs4256" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1056"
id="namedview4254"
showgrid="false"
inkscape:zoom="1.3101852"
inkscape:cx="237.56928"
inkscape:cy="7.2419621"
inkscape:window-x="1920"
inkscape:window-y="24"
inkscape:window-maximized="1"
inkscape:current-layer="svg4225" />
<g
id="enabled"
style="fill:#464646;fill-opacity:1">
<g
id="polyline"
style="fill:#464646;fill-opacity:1">
<path
d="m 18,36 0,6 6,0 0,-6 -6,0 z m 4,4 -2,0 0,-2 2,0 0,2 z"
id="path4229"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
d="m 36,18 0,6 6,0 0,-6 -6,0 z m 4,4 -2,0 0,-2 2,0 0,2 z"
id="path4231"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
d="m 23.142,39.145 -2.285,-2.29 16,-15.998 2.285,2.285 z"
id="path4233"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
</g>
<path
id="polygon"
d="M 100,24.565 97.904,39.395 83.07,42 76,28.773 86.463,18 Z"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
id="rectangle"
d="m 140,20 20,0 0,20 -20,0 z"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
id="circle"
d="m 221,30 c 0,6.078 -4.926,11 -11,11 -6.074,0 -11,-4.922 -11,-11 0,-6.074 4.926,-11 11,-11 6.074,0 11,4.926 11,11 z"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
id="marker"
d="m 270,19 c -4.971,0 -9,4.029 -9,9 0,4.971 5.001,12 9,14 4.001,-2 9,-9.029 9,-14 0,-4.971 -4.029,-9 -9,-9 z m 0,12.5 c -2.484,0 -4.5,-2.014 -4.5,-4.5 0,-2.484 2.016,-4.5 4.5,-4.5 2.485,0 4.5,2.016 4.5,4.5 0,2.486 -2.015,4.5 -4.5,4.5 z"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<g
id="edit"
style="fill:#464646;fill-opacity:1">
<path
d="m 337,30.156 0,0.407 0,5.604 c 0,1.658 -1.344,3 -3,3 l -10,0 c -1.655,0 -3,-1.342 -3,-3 l 0,-10 c 0,-1.657 1.345,-3 3,-3 l 6.345,0 3.19,-3.17 -9.535,0 c -3.313,0 -6,2.687 -6,6 l 0,10 c 0,3.313 2.687,6 6,6 l 10,0 c 3.314,0 6,-2.687 6,-6 l 0,-8.809 -3,2.968"
id="path4240"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
d="m 338.72,24.637 -8.892,8.892 -2.828,0 0,-2.829 8.89,-8.89 z"
id="path4242"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
d="m 338.697,17.826 4,0 0,4 -4,0 z"
transform="matrix(-0.70698336,-0.70723018,0.70723018,-0.70698336,567.55917,274.78273)"
id="path4244"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
</g>
<g
id="remove"
style="fill:#464646;fill-opacity:1">
<path
d="m 381,42 18,0 0,-18 -18,0 0,18 z m 14,-16 2,0 0,14 -2,0 0,-14 z m -4,0 2,0 0,14 -2,0 0,-14 z m -4,0 2,0 0,14 -2,0 0,-14 z m -4,0 2,0 0,14 -2,0 0,-14 z"
id="path4247"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
d="m 395,20 0,-4 -10,0 0,4 -6,0 0,2 22,0 0,-2 -6,0 z m -2,0 -6,0 0,-2 6,0 0,2 z"
id="path4249"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
</g>
</g>
<g
id="disabled"
transform="translate(120,0)"
style="fill:#bbbbbb">
<use
xlink:href="#edit"
id="edit-disabled"
x="0"
y="0"
width="100%"
height="100%" />
<use
xlink:href="#remove"
id="remove-disabled"
x="0"
y="0"
width="100%"
height="100%" />
</g>
<path
style="fill:none;stroke:#464646;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle-3"
d="m 581.65725,30 c 0,6.078 -4.926,11 -11,11 -6.074,0 -11,-4.922 -11,-11 0,-6.074 4.926,-11 11,-11 6.074,0 11,4.926 11,11 z"
inkscape:connector-curvature="0" />
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

661
static/leaflet.css Normal file
View File

@@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}

10
static/leaflet.draw.css vendored Normal file
View File

@@ -0,0 +1,10 @@
.leaflet-draw-section{position:relative}.leaflet-draw-toolbar{margin-top:12px}.leaflet-draw-toolbar-top{margin-top:0}.leaflet-draw-toolbar-notop a:first-child{border-top-right-radius:0}.leaflet-draw-toolbar-nobottom a:last-child{border-bottom-right-radius:0}.leaflet-draw-toolbar a{background-image:url('images/spritesheet.png');background-image:linear-gradient(transparent,transparent),url('images/spritesheet.svg');background-repeat:no-repeat;background-size:300px 30px;background-clip:padding-box}.leaflet-retina .leaflet-draw-toolbar a{background-image:url('images/spritesheet-2x.png');background-image:linear-gradient(transparent,transparent),url('images/spritesheet.svg')}
.leaflet-draw a{display:block;text-align:center;text-decoration:none}.leaflet-draw a .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.leaflet-draw-actions{display:none;list-style:none;margin:0;padding:0;position:absolute;left:26px;top:0;white-space:nowrap}.leaflet-touch .leaflet-draw-actions{left:32px}.leaflet-right .leaflet-draw-actions{right:26px;left:auto}.leaflet-touch .leaflet-right .leaflet-draw-actions{right:32px;left:auto}.leaflet-draw-actions li{display:inline-block}
.leaflet-draw-actions li:first-child a{border-left:0}.leaflet-draw-actions li:last-child a{-webkit-border-radius:0 4px 4px 0;border-radius:0 4px 4px 0}.leaflet-right .leaflet-draw-actions li:last-child a{-webkit-border-radius:0;border-radius:0}.leaflet-right .leaflet-draw-actions li:first-child a{-webkit-border-radius:4px 0 0 4px;border-radius:4px 0 0 4px}.leaflet-draw-actions a{background-color:#919187;border-left:1px solid #AAA;color:#FFF;font:11px/19px "Helvetica Neue",Arial,Helvetica,sans-serif;line-height:28px;text-decoration:none;padding-left:10px;padding-right:10px;height:28px}
.leaflet-touch .leaflet-draw-actions a{font-size:12px;line-height:30px;height:30px}.leaflet-draw-actions-bottom{margin-top:0}.leaflet-draw-actions-top{margin-top:1px}.leaflet-draw-actions-top a,.leaflet-draw-actions-bottom a{height:27px;line-height:27px}.leaflet-draw-actions a:hover{background-color:#a0a098}.leaflet-draw-actions-top.leaflet-draw-actions-bottom a{height:26px;line-height:26px}.leaflet-draw-toolbar .leaflet-draw-draw-polyline{background-position:-2px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-draw-polyline{background-position:0 -1px}
.leaflet-draw-toolbar .leaflet-draw-draw-polygon{background-position:-31px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-draw-polygon{background-position:-29px -1px}.leaflet-draw-toolbar .leaflet-draw-draw-rectangle{background-position:-62px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-draw-rectangle{background-position:-60px -1px}.leaflet-draw-toolbar .leaflet-draw-draw-circle{background-position:-92px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-draw-circle{background-position:-90px -1px}
.leaflet-draw-toolbar .leaflet-draw-draw-marker{background-position:-122px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-draw-marker{background-position:-120px -1px}.leaflet-draw-toolbar .leaflet-draw-draw-circlemarker{background-position:-273px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-draw-circlemarker{background-position:-271px -1px}.leaflet-draw-toolbar .leaflet-draw-edit-edit{background-position:-152px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-edit-edit{background-position:-150px -1px}
.leaflet-draw-toolbar .leaflet-draw-edit-remove{background-position:-182px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-edit-remove{background-position:-180px -1px}.leaflet-draw-toolbar .leaflet-draw-edit-edit.leaflet-disabled{background-position:-212px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-edit-edit.leaflet-disabled{background-position:-210px -1px}.leaflet-draw-toolbar .leaflet-draw-edit-remove.leaflet-disabled{background-position:-242px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-edit-remove.leaflet-disabled{background-position:-240px -2px}
.leaflet-mouse-marker{background-color:#fff;cursor:crosshair}.leaflet-draw-tooltip{background:#363636;background:rgba(0,0,0,0.5);border:1px solid transparent;-webkit-border-radius:4px;border-radius:4px;color:#fff;font:12px/18px "Helvetica Neue",Arial,Helvetica,sans-serif;margin-left:20px;margin-top:-21px;padding:4px 8px;position:absolute;visibility:hidden;white-space:nowrap;z-index:6}.leaflet-draw-tooltip:before{border-right:6px solid black;border-right-color:rgba(0,0,0,0.5);border-top:6px solid transparent;border-bottom:6px solid transparent;content:"";position:absolute;top:7px;left:-7px}
.leaflet-error-draw-tooltip{background-color:#f2dede;border:1px solid #e6b6bd;color:#b94a48}.leaflet-error-draw-tooltip:before{border-right-color:#e6b6bd}.leaflet-draw-tooltip-single{margin-top:-12px}.leaflet-draw-tooltip-subtext{color:#f8d5e4}.leaflet-draw-guide-dash{font-size:1%;opacity:.6;position:absolute;width:5px;height:5px}.leaflet-edit-marker-selected{background-color:rgba(254,87,161,0.1);border:4px dashed rgba(254,87,161,0.6);-webkit-border-radius:4px;border-radius:4px;box-sizing:content-box}
.leaflet-edit-move{cursor:move}.leaflet-edit-resize{cursor:pointer}.leaflet-oldie .leaflet-draw-toolbar{border:1px solid #999}

10
static/leaflet.draw.js Normal file

File diff suppressed because one or more lines are too long

6
static/leaflet.js Normal file

File diff suppressed because one or more lines are too long

346
templates/index.html Normal file
View File

@@ -0,0 +1,346 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Offline Map Tile Downloader</title>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
<link rel="stylesheet" href="/static/leaflet.css" />
<link rel="stylesheet" href="/static/leaflet.draw.css" />
<script src="/static/leaflet.js"></script>
<script src="/static/leaflet.draw.js"></script>
<style>
body, html {
margin: 0;
padding: 0;
height: 100%;
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
}
#map {
height: 100vh;
width: 100%;
}
.form-container {
position: absolute;
top: 8px;
right: 10px;
z-index: 1000;
background: rgba(234, 233, 233, 0.822);
padding: 10px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0,0,0,0.5);
}
#progress {
position: absolute;
top: 200px;
right: 11px;
z-index: 1000;
background: rgba(241, 242, 243, 0.736);
padding: 10px;
border-radius: 5px;
box-shadow: 2px 4px 8px rgba(0,0.2,0,0.5);
}
</style>
</head>
<body>
<div style="position: relative;">
<div id="map"></div>
<div class="form-container">
<form id="downloadForm">
<label for="map_style">Map Style:</label>
<select id="map_style" name="map_style"></select><br>
<label for="min_zoom">Min. Zoom:</label>
<input type="number" id="min_zoom" name="min_zoom" min="0" max="19" value="8"><br>
<label for="max_zoom">Max. Zoom:</label>
<input type="number" id="max_zoom" name="max_zoom" min="0" max="19" value="12"><br>
<input type="checkbox" id="view_cached_tiles">
<label for="view_cached_tiles">Show offline map coverage</label><br>
<input type="checkbox" id="use_cache" name="use_cache">
<label for="use_cache">Enable offline mode</label><br>
<input type="checkbox" id="convert_to_8bit" checked>
<label for="convert_to_8bit">Convert to 8-bit for Meshtastic UI</label><br>
<button type="button" id="downloadBtn">💾 Download Tiles</button>
<button type="button" id="downloadWorldBtn">🗺️ Download World Basemap</button>
<button type="button" id="cancelBtn" disabled>❌ Cancel Download</button>
</form>
</div>
<div id="progress">Ready</div>
</div>
<script>
const socket = new WebSocket(`ws://${window.location.host}/ws`);
socket.onopen = () => {
console.log("WebSocket connection established");
};
socket.onclose = () => {
console.log("WebSocket connection closed");
};
socket.onerror = (error) => {
console.error("WebSocket error:", error);
};
var map = L.map('map');
var tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19
}).addTo(map);
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(function(position) {
var lat = position.coords.latitude;
var lng = position.coords.longitude;
map.setView([lat, lng], 13);
}, function() {
map.setView([53.55, 10], 11);
});
} else {
map.setView([53.55, 10], 11);
}
var drawnItems = new L.FeatureGroup();
map.addLayer(drawnItems);
var drawControl = new L.Control.Draw({
draw: { polygon: true, marker: false, circle: false, circlemarker: false, polyline: false, rectangle: true },
edit: { featureGroup: drawnItems }
});
map.addControl(drawControl);
map.on('draw:created', function(e) {
drawnItems.addLayer(e.layer);
document.getElementById('downloadBtn').disabled = drawnItems.getLayers().length === 0;
});
map.on('draw:deleted', function() {
document.getElementById('downloadBtn').disabled = drawnItems.getLayers().length === 0;
});
var missingTilesLayer = L.layerGroup().addTo(map);
var cachedTilesLayer = L.layerGroup().addTo(map);
var downloadProgressLayer = L.layerGroup().addTo(map);
function onTileError(e) {
var coords = e.coords;
var z = coords.z;
var x = coords.x;
var y = coords.y;
var topLeft = map.unproject([x * 256, y * 256], z);
var bottomRight = map.unproject([(x + 1) * 256, (y + 1) * 256], z);
var bounds = L.latLngBounds(topLeft, bottomRight);
L.rectangle(bounds, { color: "red", weight: 1, fill: false }).addTo(missingTilesLayer);
}
function sanitizeStyleName(name) {
name = name.replace(/\s+/g, '-');
name = name.replace(/[^a-zA-Z0-9-_]/g, '');
return name;
}
function updateTileLayer() {
var mapStyleSelect = document.getElementById('map_style');
var mapStyleUrl = mapStyleSelect.value;
var styleName = sanitizeStyleName(mapStyleSelect.options[mapStyleSelect.selectedIndex].text);
var useCache = document.getElementById('use_cache').checked;
var tileUrl = useCache ? `/tiles/${styleName}/{z}/{x}/{y}.png` : mapStyleUrl;
tileLayer.setUrl(tileUrl);
tileLayer.off('loading');
tileLayer.off('tileerror', onTileError);
missingTilesLayer.clearLayers();
if (useCache) {
tileLayer.on('loading', function() {
missingTilesLayer.clearLayers();
});
tileLayer.on('tileerror', onTileError);
}
}
fetch('/get_map_sources')
.then(response => response.json())
.then(data => {
var select = document.getElementById('map_style');
for (var name in data) {
var option = document.createElement('option');
option.value = data[name];
option.text = name;
select.appendChild(option);
}
updateTileLayer();
});
document.getElementById('map_style').addEventListener('change', function() {
updateTileLayer();
if (document.getElementById('view_cached_tiles').checked) {
showCachedTiles();
}
});
document.getElementById('use_cache').addEventListener('change', updateTileLayer);
const minZoomInput = document.getElementById('min_zoom');
const maxZoomInput = document.getElementById('max_zoom');
minZoomInput.addEventListener('change', function() {
if (parseInt(this.value) > parseInt(maxZoomInput.value)) {
maxZoomInput.value = this.value;
}
});
maxZoomInput.addEventListener('change', function() {
if (parseInt(this.value) < parseInt(minZoomInput.value)) {
minZoomInput.value = this.value;
}
});
document.getElementById('downloadBtn').addEventListener('click', function() {
var polygons = [];
drawnItems.eachLayer(function(layer) {
if (layer instanceof L.Polygon || layer instanceof L.Rectangle) {
var latlngs = layer.getLatLngs()[0];
polygons.push(latlngs.map(function(latlng) { return {lat: latlng.lat, lng: latlng.lng}; }));
}
});
if (polygons.length === 0) {
alert('Please draw at least one shape.');
return;
}
var data = {
type: 'start_download',
data: {
polygons: polygons,
min_zoom: parseInt(document.getElementById('min_zoom').value),
max_zoom: parseInt(document.getElementById('max_zoom').value),
map_style: document.getElementById('map_style').value,
convert_to_8bit: document.getElementById('convert_to_8bit').checked
}
};
console.log('Sending download request with data:', JSON.stringify(data, null, 2));
socket.send(JSON.stringify(data));
});
document.getElementById('downloadWorldBtn').addEventListener('click', function() {
var data = {
type: 'start_world_download',
data: {
map_style: document.getElementById('map_style').value,
convert_to_8bit: document.getElementById('convert_to_8bit').checked
}
};
socket.send(JSON.stringify(data));
});
document.getElementById('cancelBtn').addEventListener('click', function() {
socket.send(JSON.stringify({type: 'cancel_download'}));
});
document.getElementById('view_cached_tiles').addEventListener('change', function() {
if (this.checked) {
showCachedTiles();
} else {
cachedTilesLayer.clearLayers();
}
});
function showCachedTiles() {
cachedTilesLayer.clearLayers();
var mapStyleSelect = document.getElementById('map_style');
var styleName = sanitizeStyleName(mapStyleSelect.options[mapStyleSelect.selectedIndex].text);
fetch(`/get_cached_tiles/${styleName}`)
.then(response => response.json())
.then(data => {
if (!data) return;
data.forEach(function(tile) {
var z = tile[0], x = tile[1], y = tile[2];
var topLeft = map.unproject([x * 256, y * 256], z);
var bottomRight = map.unproject([(x + 1) * 256, (y + 1) * 256], z);
var bounds = L.latLngBounds(topLeft, bottomRight);
L.rectangle(bounds, { color: "#0000ff", weight: 1, fill: false }).addTo(cachedTilesLayer);
});
});
}
var totalTiles = 0;
var downloadedTiles = 0;
var skippedTiles = 0;
var failedTiles = 0;
socket.onmessage = (event) => {
const message = JSON.parse(event.data);
const data = message.data;
switch (message.type) {
case 'download_started':
totalTiles = data.total_tiles;
downloadedTiles = 0;
skippedTiles = 0;
failedTiles = 0;
downloadProgressLayer.clearLayers();
document.getElementById('downloadBtn').disabled = true;
document.getElementById('downloadWorldBtn').disabled = true;
document.getElementById('cancelBtn').disabled = false;
updateProgress();
break;
case 'tile_downloaded':
downloadedTiles++;
updateProgress();
var bounds = [[data.south, data.west], [data.north, data.east]];
L.rectangle(bounds, { color: "#ff7800", weight: 1, fill: false }).addTo(downloadProgressLayer);
break;
case 'tile_skipped':
skippedTiles++;
updateProgress();
var bounds = [[data.south, data.west], [data.north, data.east]];
L.rectangle(bounds, { color: "#00ff00", weight: 1, fill: false }).addTo(downloadProgressLayer);
break;
case 'tile_failed':
failedTiles++;
updateProgress();
break;
case 'download_complete':
document.getElementById('downloadBtn').disabled = false;
document.getElementById('downloadWorldBtn').disabled = false;
document.getElementById('cancelBtn').disabled = true;
downloadProgressLayer.clearLayers();
var summary = `✅ Download complete!<br>` +
`Downloaded: ${downloadedTiles}<br>` +
`Skipped: ${skippedTiles}<br>` +
`Failed: ${failedTiles}<br>` +
`Total queued: ${totalTiles}`;
document.getElementById('progress').innerHTML = summary;
break;
case 'download_cancelled':
document.getElementById('downloadBtn').disabled = false;
document.getElementById('downloadWorldBtn').disabled = false;
document.getElementById('cancelBtn').disabled = true;
downloadProgressLayer.clearLayers();
document.getElementById('progress').innerHTML = 'Ready';
alert('Download cancelled');
break;
case 'error':
document.getElementById('downloadBtn').disabled = false;
document.getElementById('downloadWorldBtn').disabled = false;
document.getElementById('cancelBtn').disabled = true;
document.getElementById('progress').innerHTML = 'Ready';
alert(data.message);
break;
}
};
function updateProgress() {
if (totalTiles === 0) {
document.getElementById('progress').innerHTML = 'Starting...';
return;
}
var progress = ((downloadedTiles + skippedTiles + failedTiles) / totalTiles * 100).toFixed(2);
var progressText = `⏳ Downloading: ${progress}%<br>` +
`Downloaded: ${downloadedTiles}<br>` +
`Skipped: ${skippedTiles}<br>` +
`Failed: ${failedTiles}<br>` +
`Total queued: ${totalTiles}`;
document.getElementById('progress').innerHTML = progressText;
}
</script>
</body>
</html>