diff --git a/.gitignore b/.gitignore
index 31e3542..9944c52 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,4 +50,7 @@ docs/comparison-*.md
# Local dev tools (mock servers, screenshot automation)
tools/dev/
+
+# PMTiles map data (large binaries, downloaded per-region)
+frontend/public/tiles/*.pmtiles
fleet-dashboard.png
diff --git a/docker-compose.override.yml b/docker-compose.override.yml
index 9bcf228..6e3cb6e 100644
--- a/docker-compose.override.yml
+++ b/docker-compose.override.yml
@@ -135,6 +135,8 @@ services:
container_name: tod_frontend
ports:
- "3000:80"
+ volumes:
+ - ./frontend/public/tiles:/usr/share/nginx/html/tiles:ro
depends_on:
- api
deploy:
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 5e1a8ff..1a31174 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "frontend",
- "version": "9.0.1",
+ "version": "9.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
- "version": "9.0.1",
+ "version": "9.7.0",
"dependencies": {
"@dagrejs/dagre": "^2.0.4",
"@fontsource-variable/manrope": "^5.2.8",
@@ -43,6 +43,7 @@
"framer-motion": "^12.34.3",
"leaflet": "^1.9.4",
"lucide-react": "^0.575.0",
+ "protomaps-leaflet": "^5.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-leaflet": "^5.0.0",
@@ -1416,6 +1417,23 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@mapbox/point-geometry": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
+ "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
+ "license": "ISC"
+ },
+ "node_modules/@mapbox/vector-tile": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz",
+ "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@mapbox/point-geometry": "~1.1.0",
+ "@types/geojson": "^7946.0.16",
+ "pbf": "^4.0.1"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1467,6 +1485,15 @@
"node": ">=18"
}
},
+ "node_modules/@protomaps/basemaps": {
+ "version": "5.7.2",
+ "resolved": "https://registry.npmjs.org/@protomaps/basemaps/-/basemaps-5.7.2.tgz",
+ "integrity": "sha512-K1Yk6bWdULulYg+R2QRVXx4NzJZan5YQhpejEG0c1/sXruJrfPIPZuakpf3jwAgVmjIRVQwAv+yRafDeN0aaUQ==",
+ "license": "BSD-3-Clause",
+ "bin": {
+ "generate_style": "src/cli.ts"
+ }
+ },
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -4331,6 +4358,12 @@
"assertion-error": "^2.0.1"
}
},
+ "node_modules/@types/css-font-loading-module": {
+ "version": "0.0.7",
+ "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz",
+ "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==",
+ "license": "MIT"
+ },
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
@@ -4631,7 +4664,6 @@
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
@@ -4647,6 +4679,12 @@
"undici-types": "~7.16.0"
}
},
+ "node_modules/@types/rbush": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-3.0.4.tgz",
+ "integrity": "sha512-knSt9cCW8jj1ZSFcFeBZaX++OucmfPxxHiRwTahZfJlnQsek7O0bazTJHWD2RVj9LEoejUYF2de3/stf+QXcXw==",
+ "license": "MIT"
+ },
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -5677,6 +5715,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/color2k": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/color2k/-/color2k-2.0.3.tgz",
+ "integrity": "sha512-zW190nQTIoXcGCaU08DvVNFTmQhUpnJfVuAKfWqUQkflXKpaDdpaYoM0iluLS9lgJNHyBF58KKA2FBEwkD7wog==",
+ "license": "MIT"
+ },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -6572,6 +6616,12 @@
}
}
},
+ "node_modules/fflate": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
+ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
+ "license": "MIT"
+ },
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -7652,6 +7702,18 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"license": "MIT"
},
+ "node_modules/pbf": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
+ "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "resolve-protobuf-schema": "^2.1.0"
+ },
+ "bin": {
+ "pbf": "bin/pbf"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -7735,6 +7797,16 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/pmtiles": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-3.2.1.tgz",
+ "integrity": "sha512-3R4fBwwoli5mw7a6t1IGwOtfmcSAODq6Okz0zkXhS1zi9sz1ssjjIfslwPvcWw5TNhdjNBUg9fgfPLeqZlH6ng==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@types/leaflet": "^1.9.8",
+ "fflate": "^0.8.0"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -7891,6 +7963,12 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
+ "node_modules/potpack": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz",
+ "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==",
+ "license": "ISC"
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -7954,6 +8032,30 @@
"license": "MIT",
"peer": true
},
+ "node_modules/protocol-buffers-schema": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
+ "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==",
+ "license": "MIT"
+ },
+ "node_modules/protomaps-leaflet": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/protomaps-leaflet/-/protomaps-leaflet-5.1.0.tgz",
+ "integrity": "sha512-N0YY1Gbw5nZ9n8VUOvwHKm/w3zhTTuaZEeMZl8doHemeM4ujGPp2otI6t4Sgqrz8iM4svx2rpG1Q17rHkOWSaA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@mapbox/point-geometry": "^1.1.0",
+ "@mapbox/vector-tile": "^2.0.2",
+ "@protomaps/basemaps": "^5.0.0",
+ "@types/css-font-loading-module": "^0.0.7",
+ "@types/rbush": "^3.0.0",
+ "color2k": "^2.0.3",
+ "pbf": "^4.0.1",
+ "pmtiles": "^3.1.0",
+ "potpack": "^1.0.2",
+ "rbush": "^3.0.1"
+ }
+ },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -7990,6 +8092,21 @@
],
"license": "MIT"
},
+ "node_modules/quickselect": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
+ "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==",
+ "license": "ISC"
+ },
+ "node_modules/rbush": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz",
+ "integrity": "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==",
+ "license": "MIT",
+ "dependencies": {
+ "quickselect": "^2.0.0"
+ }
+ },
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
@@ -8354,6 +8471,15 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
+ "node_modules/resolve-protobuf-schema": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
+ "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "protocol-buffers-schema": "^3.3.1"
+ }
+ },
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index a44d25a..827f349 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -50,6 +50,7 @@
"framer-motion": "^12.34.3",
"leaflet": "^1.9.4",
"lucide-react": "^0.575.0",
+ "protomaps-leaflet": "^5.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-leaflet": "^5.0.0",
diff --git a/frontend/src/components/map/FleetMap.tsx b/frontend/src/components/map/FleetMap.tsx
index 7287ee6..b6b8202 100644
--- a/frontend/src/components/map/FleetMap.tsx
+++ b/frontend/src/components/map/FleetMap.tsx
@@ -1,7 +1,8 @@
import { useEffect, useMemo } from 'react'
-import { MapContainer, TileLayer, useMap } from 'react-leaflet'
+import { MapContainer, useMap } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster'
import L from 'leaflet'
+import * as protomapsL from 'protomaps-leaflet'
import type { FleetDevice } from '@/lib/api'
import { DeviceMarker } from './DeviceMarker'
@@ -16,6 +17,31 @@ interface FleetMapProps {
const DEFAULT_CENTER: [number, number] = [20, 0]
const DEFAULT_ZOOM = 2
+/**
+ * Self-hosted PMTiles basemap layer via protomaps-leaflet.
+ * Loads regional PMTiles files from /tiles/ (no third-party requests).
+ * Multiple region files are layered — tiles outside downloaded regions show blank.
+ */
+const PMTILES_REGIONS = ['/tiles/wisconsin.pmtiles', '/tiles/florida.pmtiles']
+
+function ProtomapsLayer() {
+ const map = useMap()
+
+ useEffect(() => {
+ const layers: L.Layer[] = []
+ for (const url of PMTILES_REGIONS) {
+ const layer = protomapsL.leafletLayer({ url, flavor: 'dark' })
+ layer.addTo(map)
+ layers.push(layer)
+ }
+ return () => {
+ for (const layer of layers) map.removeLayer(layer)
+ }
+ }, [map])
+
+ return null
+}
+
/**
* Inner component that auto-fits the map to device bounds
* whenever the device list changes.
@@ -111,10 +137,7 @@ export function FleetMap({ devices, tenantId }: FleetMapProps) {
scrollWheelZoom
style={{ background: 'hsl(var(--background))' }}
>
-
+