diff --git a/.prettierignore b/.prettierignore
index db2dc8ed..056c7500 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,3 +1,4 @@
pnpm-lock.yaml
**/manifest.json
-**/translations.json
\ No newline at end of file
+**/translations.json
+**/contrib/pmtiles-server/wwwroot/
diff --git a/dev/docker/csp.yaml b/dev/docker/csp.yaml
index 36657287..8bc455e2 100644
--- a/dev/docker/csp.yaml
+++ b/dev/docker/csp.yaml
@@ -1,9 +1,16 @@
directives:
child-src:
- "'self'"
+ - 'blob:'
+ worker-src:
+ - "'self'"
+ - 'blob:'
connect-src:
- "'self'"
- 'blob:'
+ - 'https://tile.openstreetmap.org/'
+ - 'https://protomaps.github.io/'
+ - 'http://localhost:9205/'
default-src:
- "'none'"
font-src:
diff --git a/packages/web-app-maps/README.md b/packages/web-app-maps/README.md
index ecf1e104..b4896698 100644
--- a/packages/web-app-maps/README.md
+++ b/packages/web-app-maps/README.md
@@ -6,23 +6,79 @@ OpenCloud Maps app can display `.gpx` files and show geo location data for singl
In `apps.yaml` you can override configuration like this:
+### Raster tiles (default)
+
```yaml
maps:
config:
tileLayerUrlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
+ tileLayerAttribution: 'OpenStreetMap'
tileLayerOptions:
maxZoom: 19
- attribution: '© OpenStreetMap'
```
-`tileLayerUrlTemplate` and `tileLayerOptions` have the above as default values, you can override it if you want to use another tile layer provider.
+`tileLayerUrlTemplate` defaults to OpenStreetMap. `tileLayerAttribution` defaults to OpenStreetMap when using raster tiles.
-To enable seamless integration of traffic to the OpenStreetMap servers, the Content Security Policy of OpenCloud has to be adopted.
+### PMTiles (vector tiles)
-In the file `csp.yaml`, add the entry `- 'https://tile.openstreetmap.org/'` in the `img-src:` section.
+For vector tile maps using [PMTiles](https://protomaps.com/docs/pmtiles), point `tileLayerUrlTemplate` to a `.pmtiles` file:
-Please respect the work of [OpenStreetMap](https://openstreetmap.org) and read the [Tile Usage Policy](https://operations.osmfoundation.org/policies/tiles/).
+```yaml
+maps:
+ config:
+ tileLayerUrlTemplate: 'https://example.com/tiles/region.pmtiles'
+ tileLayerAttribution: 'Protomaps | OpenStreetMap'
+```
+
+Vector tile labels require font glyphs. By default, fonts are loaded from `protomaps.github.io`. To use self-hosted fonts instead, set `tileLayerGlyphs`:
+
+```yaml
+maps:
+ config:
+ tileLayerUrlTemplate: 'https://example.com/tiles/region.pmtiles'
+ tileLayerGlyphs: 'https://example.com/fonts/{fontstack}/{range}.pbf'
+```
+
+#### Self-hosting tiles
+
+A self-contained tile server is available in [`contrib/pmtiles-server/`](contrib/pmtiles-server/). A single `docker compose up -d` downloads a world map (~120 GB), fonts, and starts serving them.
+
+### Full style override
+
+For complete control over the map style, provide a URL to a [MapLibre Style JSON](https://maplibre.org/maplibre-style-spec/):
+
+```yaml
+maps:
+ config:
+ mapStyle: 'https://your-server.example.com/style.json'
+```
+
+### Content Security Policy
+
+To enable seamless integration of traffic to tile servers, the Content Security Policy of OpenCloud has to be adopted.
+
+In the file `csp.yaml`, add the tile server URL(s) to the `connect-src:` section. MapLibre also requires web workers, so `worker-src` and `child-src` must allow `blob:`:
+
+```yaml
+directives:
+ worker-src:
+ - "'self'"
+ - 'blob:'
+ child-src:
+ - "'self'"
+ - 'blob:'
+ connect-src:
+ - "'self'"
+ - 'blob:'
+ - 'https://tile.openstreetmap.org/'
+```
+
+When using PMTiles with the default font configuration, also add `https://protomaps.github.io/` to `connect-src`.
## Privacy Notice
The rendered maps are loaded from OpenStreetMap (by default). This allows them to do at least some basic kind of tracking, simply because files are loaded from their servers by your browser.
+
+When using PMTiles with the default font configuration, font glyphs are loaded from `protomaps.github.io`.
+
+Please respect the work of [OpenStreetMap](https://openstreetmap.org) and read the [Tile Usage Policy](https://operations.osmfoundation.org/policies/tiles/).
diff --git a/packages/web-app-maps/contrib/pmtiles-server/README.md b/packages/web-app-maps/contrib/pmtiles-server/README.md
new file mode 100644
index 00000000..e8119f98
--- /dev/null
+++ b/packages/web-app-maps/contrib/pmtiles-server/README.md
@@ -0,0 +1,65 @@
+# PMTiles tile server
+
+A self-contained tile server that serves [PMTiles](https://protomaps.com/docs/pmtiles) vector tiles and the font glyphs needed for label rendering.
+
+## Quick start
+
+```bash
+docker compose up -d
+```
+
+On first run an init container downloads a world map from [Protomaps](https://maps.protomaps.com/builds/) and fonts into `wwwroot/`, then a static file server starts at `http://localhost:9205`.
+
+### What gets downloaded
+
+| Asset | Size | Source |
+| ----------------------------- | ------- | ------------------------------------------------------------------------- |
+| World PMTiles (today's build) | ~120 GB | [protomaps.com/builds](https://maps.protomaps.com/builds/) |
+| Font glyphs (Noto Sans) | ~14 MB | [protomaps/basemaps-assets](https://github.com/protomaps/basemaps-assets) |
+
+The download uses [aria2](https://aria2.github.io/) with 16 connections. If the container is stopped mid-download, the partial `.tmp` file is kept and the download **resumes automatically** on the next `docker compose up -d`.
+
+You can also skip the download entirely by placing your own `.pmtiles` file into `wwwroot/` before starting — the script detects it and only creates the `latest.pmtiles` symlink.
+
+## What gets served
+
+- **PMTiles**: `http://localhost:9205/latest.pmtiles`
+- **Fonts**: `http://localhost:9205/fonts/{fontstack}/{range}.pbf`
+
+## OpenCloud configuration
+
+### apps.yaml
+
+Configure the maps extension to use the local tile server:
+
+```yaml
+maps:
+ config:
+ tileLayerUrlTemplate: 'http://localhost:9205/latest.pmtiles'
+ tileLayerAttribution: 'Protomaps | OpenStreetMap'
+ tileLayerGlyphs: 'http://localhost:9205/fonts/{fontstack}/{range}.pbf'
+```
+
+### csp.yaml
+
+Add `http://localhost:9205/` to the `connect-src` directive:
+
+```yaml
+directives:
+ connect-src:
+ - "'self'"
+ - 'http://localhost:9205/'
+```
+
+After changing either file, restart OpenCloud for the new configuration to take effect.
+
+## Updating tiles
+
+Remove the existing tiles and restart:
+
+```bash
+rm wwwroot/*.pmtiles wwwroot/latest.pmtiles
+docker compose up -d
+```
+
+This downloads a fresh build for the current day.
diff --git a/packages/web-app-maps/contrib/pmtiles-server/docker-compose.yml b/packages/web-app-maps/contrib/pmtiles-server/docker-compose.yml
new file mode 100644
index 00000000..92ff4798
--- /dev/null
+++ b/packages/web-app-maps/contrib/pmtiles-server/docker-compose.yml
@@ -0,0 +1,21 @@
+services:
+ download:
+ image: alpine:3.21
+ command: ['/bin/sh', '/download.sh']
+ volumes:
+ - ./wwwroot:/wwwroot
+ - ./download.sh:/download.sh:ro
+
+ server:
+ image: joseluisq/static-web-server:2
+ depends_on:
+ download:
+ condition: service_completed_successfully
+ environment:
+ SERVER_ROOT: /public
+ SERVER_CORS_ALLOW_ORIGINS: '*'
+ SERVER_CORS_ALLOW_HEADERS: 'range, if-range, origin, content-type'
+ volumes:
+ - ./wwwroot:/public:ro
+ ports:
+ - '9205:80'
diff --git a/packages/web-app-maps/contrib/pmtiles-server/download.sh b/packages/web-app-maps/contrib/pmtiles-server/download.sh
new file mode 100644
index 00000000..57ed4dd8
--- /dev/null
+++ b/packages/web-app-maps/contrib/pmtiles-server/download.sh
@@ -0,0 +1,66 @@
+#!/usr/bin/env sh
+set -eu
+
+DIR="/wwwroot"
+
+# --- Check if everything is already present ---
+
+NEED_PMTILES=true
+NEED_FONTS=true
+
+existing_tmp=$(find "$DIR" -maxdepth 1 -name "*.pmtiles.tmp" -print -quit 2>/dev/null || true)
+existing=$(find "$DIR" -maxdepth 1 -name "*.pmtiles" ! -name "latest.pmtiles" ! -name "*.tmp" -print -quit 2>/dev/null || true)
+
+if [ -z "$existing_tmp" ] && [ -n "$existing" ]; then
+ echo "PMTiles file already exists: $(basename "$existing")"
+ ln -sf "$(basename "$existing")" "$DIR/latest.pmtiles"
+ NEED_PMTILES=false
+fi
+
+if [ -d "$DIR/fonts/Noto Sans Regular" ]; then
+ echo "Font assets already exist."
+ NEED_FONTS=false
+fi
+
+if [ "$NEED_PMTILES" = false ] && [ "$NEED_FONTS" = false ]; then
+ echo "Nothing to download."
+ exit 0
+fi
+
+# --- Install tools only when we actually need to download ---
+
+apk add --no-cache aria2 curl tar
+
+# --- PMTiles ---
+
+if [ "$NEED_PMTILES" = true ]; then
+ if [ -n "$existing_tmp" ]; then
+ # Resume a previous interrupted download
+ base="$(basename "$existing_tmp" .tmp)"
+ echo "Resuming PMTiles download: $base"
+ aria2c -c -x 16 -s 16 -d "$DIR" -o "${base}.tmp" \
+ "https://build.protomaps.com/${base}"
+ mv "$existing_tmp" "$DIR/$base"
+ ln -sf "$base" "$DIR/latest.pmtiles"
+ else
+ # Start a fresh download for today's build
+ TODAY="$(date +%Y%m%d)"
+ TARGET="$DIR/$TODAY.pmtiles"
+ TMP="$TARGET.tmp"
+ echo "Downloading $TODAY.pmtiles (~120 GB)..."
+ aria2c -c -x 16 -s 16 -d "$DIR" -o "$TODAY.pmtiles.tmp" \
+ "https://build.protomaps.com/$TODAY.pmtiles"
+ mv "$TMP" "$TARGET"
+ ln -sf "$TODAY.pmtiles" "$DIR/latest.pmtiles"
+ echo "Done: $TARGET"
+ fi
+fi
+
+# --- Fonts ---
+
+if [ "$NEED_FONTS" = true ]; then
+ echo "Downloading font assets (~14 MB)..."
+ curl -sL https://github.com/protomaps/basemaps-assets/archive/refs/heads/main.tar.gz \
+ | tar xz -C "$DIR" --strip-components=1 basemaps-assets-main/fonts
+ echo "Done: fonts extracted to $DIR/fonts/"
+fi
diff --git a/packages/web-app-maps/contrib/pmtiles-server/wwwroot/.gitignore b/packages/web-app-maps/contrib/pmtiles-server/wwwroot/.gitignore
new file mode 100644
index 00000000..d6b7ef32
--- /dev/null
+++ b/packages/web-app-maps/contrib/pmtiles-server/wwwroot/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/packages/web-app-maps/leaflet.d.ts b/packages/web-app-maps/leaflet.d.ts
deleted file mode 100644
index 269bc6de..00000000
--- a/packages/web-app-maps/leaflet.d.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-// Augmentation for @types/leaflet-gpx to fix incomplete type definitions
-// The official types are missing the 'markers' property and 'clickable' in marker_options
-import 'leaflet'
-
-declare module 'leaflet' {
- interface GPXOptions {
- // The official types are missing this separate 'markers' property
- markers?: {
- startIcon?: string
- endIcon?: string
- wptIcons?: {
- [key: string]: string
- }
- }
- }
-
- interface GPXMarkerOptions {
- // The official types are missing 'clickable' property
- clickable?: boolean
- }
-}
diff --git a/packages/web-app-maps/package.json b/packages/web-app-maps/package.json
index 15ef9120..3a33c91b 100644
--- a/packages/web-app-maps/package.json
+++ b/packages/web-app-maps/package.json
@@ -12,14 +12,14 @@
"test:unit": "NODE_OPTIONS=--unhandled-rejections=throw vitest"
},
"devDependencies": {
- "@types/leaflet": "^1.9.21",
- "@types/leaflet-gpx": "^1.3.8",
"vue": "^3.4.21",
"vue3-gettext": "^2.4.0"
},
"dependencies": {
- "leaflet": "1.9.4",
- "leaflet-gpx": "2.2.0",
+ "@protomaps/basemaps": "^5.7.0",
+ "@tmcw/togeojson": "^6.0.0",
+ "maplibre-gl": "^5.1.1",
+ "pmtiles": "^4.0.1",
"zod": "^4.0.17"
},
"peerDependencies": {
diff --git a/packages/web-app-maps/src/components/GpxMap.vue b/packages/web-app-maps/src/components/GpxMap.vue
index f69a4876..94e44021 100644
--- a/packages/web-app-maps/src/components/GpxMap.vue
+++ b/packages/web-app-maps/src/components/GpxMap.vue
@@ -1,8 +1,8 @@
-
+
- {{ meta.name }}
@@ -23,77 +23,94 @@
diff --git a/packages/web-app-maps/src/components/LocationFolderView.vue b/packages/web-app-maps/src/components/LocationFolderView.vue
index aebf3c5b..3c9a86e5 100644
--- a/packages/web-app-maps/src/components/LocationFolderView.vue
+++ b/packages/web-app-maps/src/components/LocationFolderView.vue
@@ -5,13 +5,14 @@
-
+