Offline-capable geomap, meant for storing location bookmarks
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: render map outside of route context

+552 -446
+11 -10
data/README.md
··· 17 17 - [`osmium`](https://osmcode.org/osmium-tool/) — PBF extraction (`extract` command) 18 18 19 19 The following resources are necessary to generate pmtiles: 20 + 20 21 - [Download a planet `.osm.pbf`](https://wiki.openstreetmap.org/wiki/Planet.osm) for the world-level pmtiles (put it in `./osm/planet-latest.osm.pbf`). For me, using a torrentfile has been the most stable way. 21 22 - Download regional .osm.pbf files for regions if you don't want to self-extract (can download with `download:osm`) 22 23 - Coastlines WGS84 projection from https://osmdata.openstreetmap.de/data/coastlines.html (put it in `./cli/shared/tilemaker/coastline`) (todo for me: create a `download:coastlines` cli cmd) 23 24 24 25 ## CLI Commands 25 26 26 - | Command | Description | 27 - | ------------------------------------------ | ------------------------------------------------------------------ | 28 - | `list [--search <term>]` | Browse available region slugs | 29 - | `download:osm <region> [--force]` | Download `.osm.pbf` from Geofabrik | 30 - | `download:poly [region] [--all] [--force]` | Download `.poly` boundary file(s) | 31 - | `extract <region> --from <source>` | Carve a sub-region from a larger PBF using osmium | 32 - | `build <region>` | Convert `.osm.pbf` → `.pmtiles` using tilemaker | 33 - | `trim:world` | Strip planet PBF to only data needed for world tiles (saves memory)| 34 - | `build:world` | Build world-scale basemap tiles | 35 - | `clean --region <slug> \| --all` | Remove intermediate files | 27 + | Command | Description | 28 + | ------------------------------------------ | ------------------------------------------------------------------- | 29 + | `list [--search <term>]` | Browse available region slugs | 30 + | `download:osm <region> [--force]` | Download `.osm.pbf` from Geofabrik | 31 + | `download:poly [region] [--all] [--force]` | Download `.poly` boundary file(s) | 32 + | `extract <region> --from <source>` | Carve a sub-region from a larger PBF using osmium | 33 + | `build <region>` | Convert `.osm.pbf` → `.pmtiles` using tilemaker | 34 + | `trim:world` | Strip planet PBF to only data needed for world tiles (saves memory) | 35 + | `build:world` | Build world-scale basemap tiles | 36 + | `clean --region <slug> \| --all` | Remove intermediate files | 36 37 37 38 ## World Tiles 38 39
+57
deno.lock
··· 4 4 "jsr:@civility/store@^1.0.0-beta.5": "1.0.0-beta.5", 5 5 "jsr:@civility/ui@~0.2.6": "0.2.9", 6 6 "jsr:@civility/workers@~0.2.4": "0.2.5", 7 + "jsr:@cliffy/command@1": "1.0.0", 8 + "jsr:@cliffy/flags@1.0.0": "1.0.0", 9 + "jsr:@cliffy/internal@1.0.0": "1.0.0", 10 + "jsr:@cliffy/table@1.0.0": "1.0.0", 11 + "jsr:@std/fmt@^1.0.9": "1.0.9", 12 + "jsr:@std/fs@1": "1.0.23", 7 13 "jsr:@std/html@^1.0.5": "1.0.5", 14 + "jsr:@std/internal@^1.0.12": "1.0.12", 15 + "jsr:@std/path@^1.1.4": "1.1.4", 8 16 "jsr:@std/semver@^1.0.8": "1.0.8", 17 + "jsr:@std/text@^1.0.17": "1.0.17", 9 18 "jsr:@std/ulid@1": "1.0.0", 10 19 "jsr:@zod/zod@^4.3.6": "4.3.6", 11 20 "npm:cheerio@^1.2.0": "1.2.0", ··· 45 54 "@civility/workers@0.2.5": { 46 55 "integrity": "5a27340c55972cc71042d4b3ce9c6a8d508e31a77fe6133f94ccdb0d48db0e40" 47 56 }, 57 + "@cliffy/command@1.0.0": { 58 + "integrity": "c52a241ea68857fcdaff4f3173eb404f8017d7bc35553b6f533c592b89dde7d2", 59 + "dependencies": [ 60 + "jsr:@cliffy/flags", 61 + "jsr:@cliffy/internal", 62 + "jsr:@cliffy/table", 63 + "jsr:@std/fmt", 64 + "jsr:@std/text" 65 + ] 66 + }, 67 + "@cliffy/flags@1.0.0": { 68 + "integrity": "8b57698adc644da8f90422d58976362d41a4ebca39c312ca1c101585d0148feb", 69 + "dependencies": [ 70 + "jsr:@cliffy/internal", 71 + "jsr:@std/text" 72 + ] 73 + }, 74 + "@cliffy/internal@1.0.0": { 75 + "integrity": "1e17ccbcd5420093c0a93e5b3827bbdc9abac5195bacf187edc44665e54bdde6" 76 + }, 77 + "@cliffy/table@1.0.0": { 78 + "integrity": "3fdaa9e1ef1ea62022108adabd826932bdea8dd05497079896febcd41322907f", 79 + "dependencies": [ 80 + "jsr:@std/fmt" 81 + ] 82 + }, 83 + "@std/fmt@1.0.9": { 84 + "integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0" 85 + }, 86 + "@std/fs@1.0.23": { 87 + "integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37", 88 + "dependencies": [ 89 + "jsr:@std/internal", 90 + "jsr:@std/path" 91 + ] 92 + }, 48 93 "@std/html@1.0.5": { 49 94 "integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e" 50 95 }, 96 + "@std/internal@1.0.12": { 97 + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" 98 + }, 99 + "@std/path@1.1.4": { 100 + "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", 101 + "dependencies": [ 102 + "jsr:@std/internal" 103 + ] 104 + }, 51 105 "@std/semver@1.0.8": { 52 106 "integrity": "dc830e8b8b6a380c895d53fbfd1258dc253704ca57bbe1629ac65fd7830179b7" 107 + }, 108 + "@std/text@1.0.17": { 109 + "integrity": "4b2c4ef67ae5b6c1dfd447c81c83a43718f52e3c7e748d8b33f694aba9895f95" 53 110 }, 54 111 "@std/ulid@1.0.0": { 55 112 "integrity": "d41c3d27a907714413649fee864b7cde8d42ee68437d22b79d5de4f81d808780"
+428
www/components/m-map.ts
··· 1 + import { html, LitElement, type TemplateResult } from 'lit' 2 + import maplibregl from 'maplibre-gl' 3 + import { Protocol } from 'pmtiles' 4 + import { downloadAndSavePMTiles, getCachedPMTiles } from '../utils/fs.ts' 5 + import { getMapNav, setMapNav } from '../utils/nav.ts' 6 + import layers from '../utils/layers.ts' 7 + import worldLayers from '../utils/world_layers.ts' 8 + import app from '../models/app.ts' 9 + import { bookmarkDisplayName } from '../models/schema.ts' 10 + import { from as nominatimToProperties } from '../models/adapters/nominatim.ts' 11 + import { nominatimReverse } from '../utils/nominatim.ts' 12 + 13 + const protocol = new Protocol() 14 + maplibregl.addProtocol('pmtiles', protocol.tile.bind(protocol)) 15 + 16 + const registeredSources = new Set<string>() 17 + 18 + async function fetchTileManifest(): Promise< 19 + { name: string; filename: string }[] 20 + > { 21 + try { 22 + const res = await fetch('/static/tiles/tiles.json') 23 + if (!res.ok) return [] 24 + const entries = await res.json() as { id: string; filename: string }[] 25 + return entries.map((e) => ({ name: e.id, filename: e.filename })) 26 + } catch { 27 + return [] 28 + } 29 + } 30 + 31 + export class MMap extends LitElement { 32 + #map: maplibregl.Map | null = null 33 + #marker: maplibregl.Marker | null = null 34 + #bookmarkPopup: maplibregl.Popup | null = null 35 + #longPressTimer: ReturnType<typeof setTimeout> | null = null 36 + 37 + protected override createRenderRoot() { 38 + return this 39 + } 40 + 41 + override connectedCallback() { 42 + super.connectedCallback() 43 + app.addEventListener(this.#onAppUpdate) 44 + } 45 + 46 + override updated(changedProperties: Map<string, unknown>): void { 47 + if (changedProperties.has('hidden') && !this.hidden && this.#map) { 48 + this.#map.resize() 49 + } 50 + } 51 + 52 + #onAppUpdate = () => { 53 + this.#renderBookmarkMarkers() 54 + } 55 + 56 + override render(): TemplateResult { 57 + return html` 58 + <div id="map"></div> 59 + ` 60 + } 61 + 62 + override firstUpdated(): void { 63 + const container = this.querySelector<HTMLElement>('#map') 64 + if (!container) return 65 + 66 + const lastView = app.lastView 67 + 68 + this.#map = new maplibregl.Map({ 69 + container, 70 + center: lastView ? [lastView.lng, lastView.lat] : [20, 20], 71 + zoom: lastView ? lastView.zoom : 2, 72 + pitchWithRotate: false, 73 + style: { 74 + version: 8, 75 + glyphs: '/static/basemaps-assets/fonts/{fontstack}/{range}.pbf', 76 + layers: [ 77 + { 78 + id: 'background', 79 + type: 'background', 80 + paint: { 'background-color': 'hsl(47, 26%, 88%)' }, 81 + }, 82 + ], 83 + sources: {}, 84 + }, 85 + }) 86 + 87 + this.#map.addControl( 88 + new maplibregl.GeolocateControl({ 89 + positionOptions: { enableHighAccuracy: true }, 90 + trackUserLocation: true, 91 + }), 92 + 'top-right', 93 + ) 94 + 95 + this.#map.on('moveend', () => this.#saveLastView()) 96 + 97 + this.#map.on('contextmenu', (e) => { 98 + this.#placePin(e.lngLat) 99 + }) 100 + 101 + this.#map.on('touchstart', (e) => { 102 + if (e.originalEvent.touches.length !== 1) return 103 + const lngLat = e.lngLat 104 + this.#longPressTimer = setTimeout(() => this.#placePin(lngLat), 500) 105 + }) 106 + this.#map.on('touchend', () => this.#clearLongPress()) 107 + this.#map.on('touchmove', () => this.#clearLongPress()) 108 + this.#map.on('drag', () => this.#clearLongPress()) 109 + 110 + this.#map.on('load', async () => { 111 + await this.#loadWorldTiles() 112 + await this.#loadCachedDetailTiles() 113 + this.#renderBookmarkMarkers() 114 + const target = getMapNav() 115 + if (target) { 116 + setMapNav(null) 117 + this.#map!.flyTo({ 118 + center: [target.lng, target.lat], 119 + zoom: target.zoom, 120 + }) 121 + if (target.marker) { 122 + this.#marker?.remove() 123 + this.#marker = new maplibregl.Marker({ color: '#16a34a' }) 124 + .setLngLat([target.lng, target.lat]) 125 + .setPopup( 126 + this.#buildPopup( 127 + target.name ?? 'Unknown location', 128 + target.lat, 129 + target.lng, 130 + target.zoom, 131 + target.address, 132 + ), 133 + ) 134 + .addTo(this.#map!) 135 + this.#marker.togglePopup() 136 + } else if (target.bookmarkId) { 137 + this.#showBookmarkPopup(target.bookmarkId) 138 + } 139 + } 140 + }) 141 + } 142 + 143 + #saveLastView() { 144 + if (!this.#map) return 145 + const { lat, lng } = this.#map.getCenter() 146 + const zoom = this.#map.getZoom() 147 + app.setLastView({ lat, lng, zoom }) 148 + } 149 + 150 + #buildPopup( 151 + name: string, 152 + lat: number, 153 + lng: number, 154 + zoom: number, 155 + address?: string, 156 + ): maplibregl.Popup { 157 + const container = document.createElement('div') 158 + container.style.cssText = 159 + 'display:flex;flex-direction:column;gap:6px;min-width:180px' 160 + 161 + if (address) { 162 + const addr = document.createElement('p') 163 + addr.style.cssText = 164 + 'margin:0;font-size:0.75em;opacity:0.6;word-break:break-word;max-width:220px' 165 + addr.textContent = address 166 + container.append(addr) 167 + } 168 + 169 + const input = document.createElement('input') 170 + input.type = 'text' 171 + input.value = name 172 + input.placeholder = 'Bookmark name' 173 + input.style.cssText = 'display:block;width:100%;box-sizing:border-box' 174 + 175 + const button = document.createElement('button') 176 + button.textContent = 'Add to bookmarks' 177 + button.style.cssText = 'width:100%' 178 + 179 + button.addEventListener('click', async () => { 180 + button.disabled = true 181 + await app.addBookmark(lat, lng, zoom, { 182 + name: input.value.trim() || name, 183 + address: address ? { displayText: address } : undefined, 184 + }) 185 + button.textContent = 'Saved!' 186 + }) 187 + 188 + container.append(input, button) 189 + return new maplibregl.Popup({ offset: 25 }).setDOMContent(container) 190 + } 191 + 192 + #clearLongPress() { 193 + if (this.#longPressTimer) { 194 + clearTimeout(this.#longPressTimer) 195 + this.#longPressTimer = null 196 + } 197 + } 198 + 199 + #placePin(lngLat: maplibregl.LngLat) { 200 + if (!this.#map) return 201 + const { lat, lng } = lngLat 202 + const zoom = this.#map.getZoom() 203 + 204 + const container = document.createElement('div') 205 + container.style.cssText = 206 + 'display:flex;flex-direction:column;gap:6px;min-width:180px' 207 + 208 + const addrEl = document.createElement('p') 209 + addrEl.style.cssText = 210 + 'margin:0;font-size:0.75em;opacity:0.6;word-break:break-word;max-width:220px' 211 + addrEl.textContent = 'Looking up location…' 212 + container.append(addrEl) 213 + 214 + const input = document.createElement('input') 215 + input.type = 'text' 216 + input.value = `${lat.toFixed(5)}, ${lng.toFixed(5)}` 217 + input.placeholder = 'Bookmark name' 218 + input.style.cssText = 'display:block;width:100%;box-sizing:border-box' 219 + 220 + const button = document.createElement('button') 221 + button.textContent = 'Add to bookmarks' 222 + button.style.cssText = 'width:100%' 223 + 224 + let resolvedProperties: ReturnType<typeof nominatimToProperties> | undefined 225 + 226 + button.addEventListener('click', async () => { 227 + button.disabled = true 228 + const coordsFallback = `${lat.toFixed(5)}, ${lng.toFixed(5)}` 229 + const name = input.value.trim() || coordsFallback 230 + await app.addBookmark(lat, lng, zoom, { 231 + ...(resolvedProperties ?? {}), 232 + name, 233 + }) 234 + button.textContent = 'Saved!' 235 + }) 236 + 237 + container.append(input, button) 238 + 239 + this.#marker?.remove() 240 + this.#marker = new maplibregl.Marker() 241 + .setLngLat([lng, lat]) 242 + .setPopup(new maplibregl.Popup({ offset: 25 }).setDOMContent(container)) 243 + .addTo(this.#map) 244 + this.#marker.togglePopup() 245 + 246 + if (!app.geocodingBookmarksEnabled) { 247 + addrEl.remove() 248 + } else { 249 + nominatimReverse(lat, lng) 250 + .then((result) => { 251 + if (!result?.display_name) { 252 + addrEl.remove() 253 + return 254 + } 255 + resolvedProperties = nominatimToProperties(result) 256 + addrEl.textContent = result.display_name 257 + const coordsPlaceholder = `${lat.toFixed(5)}, ${lng.toFixed(5)}` 258 + if (input.value === coordsPlaceholder) { 259 + input.value = result.display_name.split(',')[0].trim() 260 + } 261 + }) 262 + .catch(() => addrEl.remove()) 263 + } 264 + } 265 + 266 + #buildBookmarkPopup(name: string, id?: string): maplibregl.Popup { 267 + const container = document.createElement('div') 268 + container.style.cssText = 'display:flex;flex-direction:column;gap:4px' 269 + const nameEl = document.createElement('strong') 270 + nameEl.textContent = name 271 + container.append(nameEl) 272 + if (id) { 273 + const editLink = document.createElement('a') 274 + editLink.href = `#!/bookmarks?edit=${encodeURIComponent(id)}` 275 + editLink.textContent = 'Edit bookmark' 276 + editLink.style.cssText = 'font-size:0.8em' 277 + container.append(editLink) 278 + } 279 + return new maplibregl.Popup({ offset: 10 }).setDOMContent(container) 280 + } 281 + 282 + #showBookmarkPopup(bookmarkId: string) { 283 + if (!this.#map) return 284 + const bookmark = app.bookmarks.find((b) => b.id === bookmarkId) 285 + if (!bookmark) return 286 + const [lng, lat] = bookmark.geometry.coordinates 287 + this.#bookmarkPopup?.remove() 288 + this.#bookmarkPopup = this.#buildBookmarkPopup( 289 + bookmarkDisplayName(bookmark), 290 + bookmarkId, 291 + ) 292 + .setLngLat([lng, lat]) 293 + .addTo(this.#map) 294 + } 295 + 296 + #renderBookmarkMarkers() { 297 + if (!this.#map) return 298 + const collectionColors = new Map( 299 + app.bookmarkCollections.map((c) => [c.id, c.color ?? null]), 300 + ) 301 + const geojson = { 302 + type: 'FeatureCollection' as const, 303 + features: app.bookmarks.map((b) => ({ 304 + ...b, 305 + properties: { 306 + ...b.properties, 307 + _id: b.id, 308 + _displayName: bookmarkDisplayName(b), 309 + _color: b.categories[0] 310 + ? (collectionColors.get(b.categories[0]) ?? null) 311 + : null, 312 + }, 313 + })), 314 + } 315 + const source = this.#map.getSource('bookmarks') as 316 + | maplibregl.GeoJSONSource 317 + | undefined 318 + if (source) { 319 + source.setData(geojson) 320 + return 321 + } 322 + this.#map.addSource('bookmarks', { type: 'geojson', data: geojson }) 323 + this.#map.addLayer({ 324 + id: 'bookmarks', 325 + type: 'circle', 326 + source: 'bookmarks', 327 + paint: { 328 + 'circle-radius': 8, 329 + 'circle-color': ['coalesce', ['get', '_color'], '#e05c2a'], 330 + 'circle-stroke-width': 2, 331 + 'circle-stroke-color': '#fff', 332 + }, 333 + }) 334 + this.#map.addLayer({ 335 + id: 'bookmarks-hit', 336 + type: 'circle', 337 + source: 'bookmarks', 338 + paint: { 339 + 'circle-radius': 20, 340 + 'circle-color': 'transparent', 341 + }, 342 + }) 343 + this.#map.on('click', 'bookmarks-hit', (e) => { 344 + if (!e.features?.length || !this.#map) return 345 + const feature = e.features[0] 346 + const coords = (feature.geometry as unknown as { 347 + coordinates: [number, number] 348 + }).coordinates 349 + const id = feature.properties?._id as string | undefined 350 + this.#bookmarkPopup?.remove() 351 + this.#bookmarkPopup = this.#buildBookmarkPopup( 352 + feature.properties?._displayName as string, 353 + id, 354 + ) 355 + .setLngLat(coords) 356 + .addTo(this.#map) 357 + }) 358 + this.#map.on('mouseenter', 'bookmarks-hit', () => { 359 + this.#map!.getCanvas().style.cursor = 'pointer' 360 + }) 361 + this.#map.on('mouseleave', 'bookmarks-hit', () => { 362 + this.#map!.getCanvas().style.cursor = '' 363 + }) 364 + } 365 + 366 + override disconnectedCallback() { 367 + super.disconnectedCallback() 368 + app.removeEventListener(this.#onAppUpdate) 369 + this.#clearLongPress() 370 + this.#bookmarkPopup?.remove() 371 + this.#bookmarkPopup = null 372 + this.#marker?.remove() 373 + this.#marker = null 374 + this.#map?.remove() 375 + this.#map = null 376 + } 377 + 378 + async #loadWorldTiles(): Promise<void> { 379 + if (!this.#map) return 380 + let worldFilename = 'world_z3.pmtiles' 381 + if (!registeredSources.has('world')) { 382 + const z7 = await getCachedPMTiles('world_z7.pmtiles') 383 + const z6 = !z7 ? await getCachedPMTiles('world_z6.pmtiles') : null 384 + const z5 = !z6 ? await getCachedPMTiles('world_z5.pmtiles') : null 385 + if (z7) worldFilename = 'world_z7.pmtiles' 386 + else if (z6) worldFilename = 'world_z6.pmtiles' 387 + else if (z5) worldFilename = 'world_z5.pmtiles' 388 + const pmtiles = z7 ?? z6 ?? z5 ?? await downloadAndSavePMTiles( 389 + '/static/tiles/world/world_z3.pmtiles', 390 + 'world_z3.pmtiles', 391 + ) 392 + protocol.add(pmtiles) 393 + registeredSources.add('world') 394 + } 395 + if (!this.#map.getSource('world')) { 396 + this.#map.addSource('world', { 397 + type: 'vector', 398 + url: `pmtiles://${worldFilename}`, 399 + attribution: '© OpenStreetMap contributors', 400 + }) 401 + worldLayers.forEach((layer) => 402 + this.#map!.addLayer(layer as maplibregl.AddLayerObject) 403 + ) 404 + } 405 + } 406 + 407 + async #loadCachedDetailTiles(): Promise<void> { 408 + if (!this.#map) return 409 + for (const tile of await fetchTileManifest()) { 410 + if (this.#map.getSource(tile.name)) continue 411 + const pmtiles = await getCachedPMTiles(tile.filename) 412 + if (!pmtiles) continue 413 + if (!registeredSources.has(tile.name)) { 414 + protocol.add(pmtiles) 415 + registeredSources.add(tile.name) 416 + } 417 + this.#map.addSource(tile.name, { 418 + type: 'vector', 419 + url: `pmtiles://${tile.filename}`, 420 + }) 421 + layers(tile.name).forEach((layer) => 422 + this.#map!.addLayer(layer as maplibregl.AddLayerObject) 423 + ) 424 + } 425 + } 426 + } 427 + 428 + customElements.define('m-map', MMap)
+1
www/index.html
··· 44 44 45 45 <main id="main"> 46 46 <ui-spinner></ui-spinner> 47 + <m-map hidden></m-map> 47 48 </main> 48 49 49 50 <footer fixed>
+6
www/index.ts
··· 1 1 import { client } from '@civility/workers' 2 2 import { createLayoutRouter } from '@civility/ui' 3 3 import './routes/map.ts' 4 + import './components/m-map.ts' 4 5 import './routes/search.ts' 5 6 import './routes/settings.ts' 6 7 import './routes/settings-downloads.ts' ··· 23 24 main: 'main', 24 25 }, 25 26 afterMount: (_ctx, view) => { 27 + const mapEl = document.querySelector<HTMLElement>('m-map') 28 + if (mapEl) { 29 + mapEl.hidden = (view as { route: string }).route !== '/' 30 + } 31 + 26 32 const titleEl = document.querySelector('#page-title') 27 33 if (titleEl && view.meta?.title !== undefined) { 28 34 titleEl.textContent = view.meta.title
+42 -22
www/models/app.ts
··· 39 39 }) 40 40 41 41 const bookmarksColl = store.collection<Bookmark>('bookmarks') 42 - const collectionsColl = store.collection<BookmarkCollection>('bookmarkCollections') 43 - const preferencesDoc = store.document<Preferences>('preferences', defaultPreferences) 44 - const searchHistoryDoc = store.document<SearchHistoryEntry[]>('searchHistory', []) 42 + const collectionsColl = store.collection<BookmarkCollection>( 43 + 'bookmarkCollections', 44 + ) 45 + const preferencesDoc = store.document<Preferences>( 46 + 'preferences', 47 + defaultPreferences, 48 + ) 49 + const searchHistoryDoc = store.document<SearchHistoryEntry[]>( 50 + 'searchHistory', 51 + [], 52 + ) 45 53 46 54 export class App { 47 55 #subscribers = new Set<() => void>() ··· 85 93 const current = searchHistoryDoc.value ?? [] 86 94 const existing = current.filter((e) => e.query !== trimmed) 87 95 await searchHistoryDoc.set([ 88 - SearchHistoryEntry.parse({ query: trimmed, timestamp: new Date().toISOString() }), 96 + SearchHistoryEntry.parse({ 97 + query: trimmed, 98 + timestamp: new Date().toISOString(), 99 + }), 89 100 ...existing, 90 101 ].slice(0, 20)) 91 102 } ··· 115 126 ): Promise<void> { 116 127 const now = new Date().toISOString() 117 128 const id = crypto.randomUUID() 118 - await bookmarksColl.set(id, Bookmark.parse({ 129 + await bookmarksColl.set( 119 130 id, 120 - type: 'Feature', 121 - geometry: { type: 'Point', coordinates: [lng, lat] }, 122 - properties, 123 - categories, 124 - zoom, 125 - createdAt: now, 126 - updatedAt: now, 127 - })) 131 + Bookmark.parse({ 132 + id, 133 + type: 'Feature', 134 + geometry: { type: 'Point', coordinates: [lng, lat] }, 135 + properties, 136 + categories, 137 + zoom, 138 + createdAt: now, 139 + updatedAt: now, 140 + }), 141 + ) 128 142 } 129 143 130 144 async deleteBookmark(id: string): Promise<void> { ··· 143 157 if (!existing) return 144 158 await bookmarksColl.set(id, { 145 159 ...existing, 146 - ...(updates.categories !== undefined && { categories: updates.categories }), 160 + ...(updates.categories !== undefined && 161 + { categories: updates.categories }), 147 162 ...(updates.zoom !== undefined && { zoom: updates.zoom }), 148 163 properties: updates.properties 149 164 ? { ...existing.properties, ...updates.properties } ··· 157 172 async addCollection(name: string, color?: string): Promise<void> { 158 173 const now = new Date().toISOString() 159 174 const id = crypto.randomUUID() 160 - await collectionsColl.set(id, BookmarkCollection.parse({ 175 + await collectionsColl.set( 161 176 id, 162 - name, 163 - color, 164 - createdAt: now, 165 - updatedAt: now, 166 - })) 177 + BookmarkCollection.parse({ 178 + id, 179 + name, 180 + color, 181 + createdAt: now, 182 + updatedAt: now, 183 + }), 184 + ) 167 185 } 168 186 169 187 async updateCollection( ··· 231 249 try { 232 250 const data = await store.export() 233 251 const name = filename ?? `maps-export_${Date.now()}` 234 - const blob = new Blob([JSON.stringify(data)], { type: 'application/json' }) 252 + const blob = new Blob([JSON.stringify(data)], { 253 + type: 'application/json', 254 + }) 235 255 const url = URL.createObjectURL(blob) 236 256 const a = document.createElement('a') 237 257 a.href = url ··· 248 268 } 249 269 } 250 270 251 - async importStore(): Promise<{ 271 + importStore(): Promise<{ 252 272 success: boolean 253 273 path: string 254 274 error?: string
+4 -3
www/models/migrations/pre-0.js
··· 18 18 * Run in the browser console on either the old or new maps app version. 19 19 * Then use the app's Import function to load the downloaded JSON. 20 20 */ 21 - ; (() => { 21 + ;(() => { 22 22 // --------------------------------------------------------------------------- 23 23 // Config — adjust TARGET_VERSION to match the new app's declared version. 24 24 // --------------------------------------------------------------------------- ··· 46 46 47 47 let _counter = 0 48 48 function makeHLC() { 49 - return `${String(BASE_MS).padStart(15, '0')}-${String(_counter++).padStart(5, '0') 50 - }-${ORIGIN}` 49 + return `${String(BASE_MS).padStart(15, '0')}-${ 50 + String(_counter++).padStart(5, '0') 51 + }-${ORIGIN}` 51 52 } 52 53 53 54 function makeDocumentRecord(scopedId, data, hlc) {
+2 -410
www/routes/map.ts
··· 1 1 import { html, LitElement, type TemplateResult } from 'lit' 2 - import maplibregl from 'maplibre-gl' 3 - import { Protocol } from 'pmtiles' 4 - import { downloadAndSavePMTiles, getCachedPMTiles } from '../utils/fs.ts' 5 - import { getMapNav, setMapNav } from '../utils/nav.ts' 6 - import layers from '../utils/layers.ts' 7 - import worldLayers from '../utils/world_layers.ts' 8 - import app from '../models/app.ts' 9 - import { bookmarkDisplayName } from '../models/schema.ts' 10 - import { from as nominatimToProperties } from '../models/adapters/nominatim.ts' 11 - import { nominatimReverse } from '../utils/nominatim.ts' 12 - 13 - const protocol = new Protocol() 14 - maplibregl.addProtocol('pmtiles', protocol.tile.bind(protocol)) 15 - 16 - // Track which sources have been registered with the protocol across navigations 17 - const registeredSources = new Set<string>() 18 - 19 - async function fetchTileManifest(): Promise< 20 - { name: string; filename: string }[] 21 - > { 22 - try { 23 - const res = await fetch('/static/tiles/tiles.json') 24 - if (!res.ok) return [] 25 - const entries = await res.json() as { id: string; filename: string }[] 26 - return entries.map((e) => ({ name: e.id, filename: e.filename })) 27 - } catch { 28 - return [] 29 - } 30 - } 2 + import '../components/m-map.ts' 31 3 32 4 export class MapPage extends LitElement { 33 - #map: maplibregl.Map | null = null 34 - #marker: maplibregl.Marker | null = null 35 - #bookmarkPopup: maplibregl.Popup | null = null 36 - #longPressTimer: ReturnType<typeof setTimeout> | null = null 37 - 38 5 protected override createRenderRoot() { 39 6 return this 40 7 } 41 8 42 - override connectedCallback() { 43 - super.connectedCallback() 44 - app.addEventListener(this.#onAppUpdate) 45 - } 46 - 47 - #onAppUpdate = () => { 48 - this.#renderBookmarkMarkers() 49 - } 50 - 51 9 override render(): TemplateResult { 52 10 return html` 53 - <div id="map"></div> 11 + <m-map></m-map> 54 12 ` 55 - } 56 - 57 - override firstUpdated(): void { 58 - const container = this.querySelector<HTMLElement>('#map') 59 - if (!container) return 60 - 61 - const lastView = app.lastView 62 - 63 - this.#map = new maplibregl.Map({ 64 - container, 65 - center: lastView ? [lastView.lng, lastView.lat] : [20, 20], 66 - zoom: lastView ? lastView.zoom : 2, 67 - pitchWithRotate: false, 68 - style: { 69 - version: 8, 70 - glyphs: '/static/basemaps-assets/fonts/{fontstack}/{range}.pbf', 71 - layers: [ 72 - { 73 - id: 'background', 74 - type: 'background', 75 - paint: { 'background-color': 'hsl(47, 26%, 88%)' }, 76 - }, 77 - ], 78 - sources: {}, 79 - }, 80 - }) 81 - 82 - this.#map.addControl( 83 - new maplibregl.GeolocateControl({ 84 - positionOptions: { enableHighAccuracy: true }, 85 - trackUserLocation: true, 86 - }), 87 - 'top-right', 88 - ) 89 - 90 - this.#map.on('moveend', () => this.#saveLastView()) 91 - 92 - this.#map.on('contextmenu', (e) => { 93 - this.#placePin(e.lngLat) 94 - }) 95 - 96 - this.#map.on('touchstart', (e) => { 97 - if (e.originalEvent.touches.length !== 1) return 98 - const lngLat = e.lngLat 99 - this.#longPressTimer = setTimeout(() => this.#placePin(lngLat), 500) 100 - }) 101 - this.#map.on('touchend', () => this.#clearLongPress()) 102 - this.#map.on('touchmove', () => this.#clearLongPress()) 103 - this.#map.on('drag', () => this.#clearLongPress()) 104 - 105 - this.#map.on('load', async () => { 106 - await this.#loadWorldTiles() 107 - await this.#loadCachedDetailTiles() 108 - this.#renderBookmarkMarkers() 109 - const target = getMapNav() 110 - if (target) { 111 - setMapNav(null) 112 - this.#map!.flyTo({ 113 - center: [target.lng, target.lat], 114 - zoom: target.zoom, 115 - }) 116 - if (target.marker) { 117 - this.#marker?.remove() 118 - this.#marker = new maplibregl.Marker({ color: '#16a34a' }) 119 - .setLngLat([target.lng, target.lat]) 120 - .setPopup( 121 - this.#buildPopup( 122 - target.name ?? 'Unknown location', 123 - target.lat, 124 - target.lng, 125 - target.zoom, 126 - target.address, 127 - ), 128 - ) 129 - .addTo(this.#map!) 130 - this.#marker.togglePopup() 131 - } else if (target.bookmarkId) { 132 - this.#showBookmarkPopup(target.bookmarkId) 133 - } 134 - } 135 - }) 136 - } 137 - 138 - #saveLastView() { 139 - if (!this.#map) return 140 - const { lat, lng } = this.#map.getCenter() 141 - const zoom = this.#map.getZoom() 142 - app.setLastView({ lat, lng, zoom }) 143 - } 144 - 145 - #buildPopup( 146 - name: string, 147 - lat: number, 148 - lng: number, 149 - zoom: number, 150 - address?: string, 151 - ): maplibregl.Popup { 152 - const container = document.createElement('div') 153 - container.style.cssText = 154 - 'display:flex;flex-direction:column;gap:6px;min-width:180px' 155 - 156 - if (address) { 157 - const addr = document.createElement('p') 158 - addr.style.cssText = 159 - 'margin:0;font-size:0.75em;opacity:0.6;word-break:break-word;max-width:220px' 160 - addr.textContent = address 161 - container.append(addr) 162 - } 163 - 164 - const input = document.createElement('input') 165 - input.type = 'text' 166 - input.value = name 167 - input.placeholder = 'Bookmark name' 168 - input.style.cssText = 'display:block;width:100%;box-sizing:border-box' 169 - 170 - const button = document.createElement('button') 171 - button.textContent = 'Add to bookmarks' 172 - button.style.cssText = 'width:100%' 173 - 174 - button.addEventListener('click', async () => { 175 - button.disabled = true 176 - await app.addBookmark(lat, lng, zoom, { 177 - name: input.value.trim() || name, 178 - address: address ? { displayText: address } : undefined, 179 - }) 180 - button.textContent = 'Saved!' 181 - }) 182 - 183 - container.append(input, button) 184 - return new maplibregl.Popup({ offset: 25 }).setDOMContent(container) 185 - } 186 - 187 - #clearLongPress() { 188 - if (this.#longPressTimer) { 189 - clearTimeout(this.#longPressTimer) 190 - this.#longPressTimer = null 191 - } 192 - } 193 - 194 - #placePin(lngLat: maplibregl.LngLat) { 195 - if (!this.#map) return 196 - const { lat, lng } = lngLat 197 - const zoom = this.#map.getZoom() 198 - 199 - const container = document.createElement('div') 200 - container.style.cssText = 201 - 'display:flex;flex-direction:column;gap:6px;min-width:180px' 202 - 203 - const addrEl = document.createElement('p') 204 - addrEl.style.cssText = 205 - 'margin:0;font-size:0.75em;opacity:0.6;word-break:break-word;max-width:220px' 206 - addrEl.textContent = 'Looking up location…' 207 - container.append(addrEl) 208 - 209 - const input = document.createElement('input') 210 - input.type = 'text' 211 - input.value = `${lat.toFixed(5)}, ${lng.toFixed(5)}` 212 - input.placeholder = 'Bookmark name' 213 - input.style.cssText = 'display:block;width:100%;box-sizing:border-box' 214 - 215 - const button = document.createElement('button') 216 - button.textContent = 'Add to bookmarks' 217 - button.style.cssText = 'width:100%' 218 - 219 - // Resolved nominatim properties to use when saving 220 - let resolvedProperties: ReturnType<typeof nominatimToProperties> | undefined 221 - 222 - button.addEventListener('click', async () => { 223 - button.disabled = true 224 - const coordsFallback = `${lat.toFixed(5)}, ${lng.toFixed(5)}` 225 - const name = input.value.trim() || coordsFallback 226 - await app.addBookmark(lat, lng, zoom, { 227 - ...(resolvedProperties ?? {}), 228 - name, 229 - }) 230 - button.textContent = 'Saved!' 231 - }) 232 - 233 - container.append(input, button) 234 - 235 - this.#marker?.remove() 236 - this.#marker = new maplibregl.Marker() 237 - .setLngLat([lng, lat]) 238 - .setPopup(new maplibregl.Popup({ offset: 25 }).setDOMContent(container)) 239 - .addTo(this.#map) 240 - this.#marker.togglePopup() 241 - 242 - if (!app.geocodingBookmarksEnabled) { 243 - addrEl.remove() 244 - } else { 245 - nominatimReverse(lat, lng) 246 - .then((result) => { 247 - if (!result?.display_name) { 248 - addrEl.remove() 249 - return 250 - } 251 - resolvedProperties = nominatimToProperties(result) 252 - addrEl.textContent = result.display_name 253 - const coordsPlaceholder = `${lat.toFixed(5)}, ${lng.toFixed(5)}` 254 - if (input.value === coordsPlaceholder) { 255 - input.value = result.display_name.split(',')[0].trim() 256 - } 257 - }) 258 - .catch(() => addrEl.remove()) 259 - } 260 - } 261 - 262 - #buildBookmarkPopup(name: string, id?: string): maplibregl.Popup { 263 - const container = document.createElement('div') 264 - container.style.cssText = 'display:flex;flex-direction:column;gap:4px' 265 - const nameEl = document.createElement('strong') 266 - nameEl.textContent = name 267 - container.append(nameEl) 268 - if (id) { 269 - const editLink = document.createElement('a') 270 - editLink.href = `#!/bookmarks?edit=${encodeURIComponent(id)}` 271 - editLink.textContent = 'Edit bookmark' 272 - editLink.style.cssText = 'font-size:0.8em' 273 - container.append(editLink) 274 - } 275 - return new maplibregl.Popup({ offset: 10 }).setDOMContent(container) 276 - } 277 - 278 - #showBookmarkPopup(bookmarkId: string) { 279 - if (!this.#map) return 280 - const bookmark = app.bookmarks.find((b) => b.id === bookmarkId) 281 - if (!bookmark) return 282 - const [lng, lat] = bookmark.geometry.coordinates 283 - this.#bookmarkPopup?.remove() 284 - this.#bookmarkPopup = this.#buildBookmarkPopup( 285 - bookmarkDisplayName(bookmark), 286 - bookmarkId, 287 - ) 288 - .setLngLat([lng, lat]) 289 - .addTo(this.#map) 290 - } 291 - 292 - #renderBookmarkMarkers() { 293 - if (!this.#map) return 294 - const collectionColors = new Map( 295 - app.bookmarkCollections.map((c) => [c.id, c.color ?? null]), 296 - ) 297 - const geojson = { 298 - type: 'FeatureCollection' as const, 299 - features: app.bookmarks.map((b) => ({ 300 - ...b, 301 - properties: { 302 - ...b.properties, 303 - _id: b.id, 304 - _displayName: bookmarkDisplayName(b), 305 - _color: b.categories[0] 306 - ? (collectionColors.get(b.categories[0]) ?? null) 307 - : null, 308 - }, 309 - })), 310 - } 311 - const source = this.#map.getSource('bookmarks') as 312 - | maplibregl.GeoJSONSource 313 - | undefined 314 - if (source) { 315 - source.setData(geojson) 316 - return 317 - } 318 - this.#map.addSource('bookmarks', { type: 'geojson', data: geojson }) 319 - this.#map.addLayer({ 320 - id: 'bookmarks', 321 - type: 'circle', 322 - source: 'bookmarks', 323 - paint: { 324 - 'circle-radius': 8, 325 - 'circle-color': ['coalesce', ['get', '_color'], '#e05c2a'], 326 - 'circle-stroke-width': 2, 327 - 'circle-stroke-color': '#fff', 328 - }, 329 - }) 330 - this.#map.addLayer({ 331 - id: 'bookmarks-hit', 332 - type: 'circle', 333 - source: 'bookmarks', 334 - paint: { 335 - 'circle-radius': 20, 336 - 'circle-color': 'transparent', 337 - }, 338 - }) 339 - this.#map.on('click', 'bookmarks-hit', (e) => { 340 - if (!e.features?.length || !this.#map) return 341 - const feature = e.features[0] 342 - const coords = (feature.geometry as unknown as { 343 - coordinates: [number, number] 344 - }).coordinates 345 - const id = feature.properties?._id as string | undefined 346 - this.#bookmarkPopup?.remove() 347 - this.#bookmarkPopup = this.#buildBookmarkPopup( 348 - feature.properties?._displayName as string, 349 - id, 350 - ) 351 - .setLngLat(coords) 352 - .addTo(this.#map) 353 - }) 354 - this.#map.on('mouseenter', 'bookmarks-hit', () => { 355 - this.#map!.getCanvas().style.cursor = 'pointer' 356 - }) 357 - this.#map.on('mouseleave', 'bookmarks-hit', () => { 358 - this.#map!.getCanvas().style.cursor = '' 359 - }) 360 - } 361 - 362 - override disconnectedCallback() { 363 - super.disconnectedCallback() 364 - app.removeEventListener(this.#onAppUpdate) 365 - this.#clearLongPress() 366 - this.#bookmarkPopup?.remove() 367 - this.#bookmarkPopup = null 368 - this.#marker?.remove() 369 - this.#marker = null 370 - this.#map?.remove() 371 - this.#map = null 372 - } 373 - 374 - async #loadWorldTiles(): Promise<void> { 375 - if (!this.#map) return 376 - let worldFilename = 'world_z3.pmtiles' 377 - if (!registeredSources.has('world')) { 378 - const z7 = await getCachedPMTiles('world_z7.pmtiles') 379 - const z6 = !z7 ? await getCachedPMTiles('world_z6.pmtiles') : null 380 - const z5 = !z6 ? await getCachedPMTiles('world_z5.pmtiles') : null 381 - if (z7) worldFilename = 'world_z7.pmtiles' 382 - else if (z6) worldFilename = 'world_z6.pmtiles' 383 - else if (z5) worldFilename = 'world_z5.pmtiles' 384 - const pmtiles = z7 ?? z6 ?? z5 ?? await downloadAndSavePMTiles( 385 - '/static/tiles/world/world_z3.pmtiles', 386 - 'world_z3.pmtiles', 387 - ) 388 - protocol.add(pmtiles) 389 - registeredSources.add('world') 390 - } 391 - if (!this.#map.getSource('world')) { 392 - this.#map.addSource('world', { 393 - type: 'vector', 394 - url: `pmtiles://${worldFilename}`, 395 - attribution: '© OpenStreetMap contributors', 396 - }) 397 - worldLayers.forEach((layer) => 398 - this.#map!.addLayer(layer as maplibregl.AddLayerObject) 399 - ) 400 - } 401 - } 402 - 403 - async #loadCachedDetailTiles(): Promise<void> { 404 - if (!this.#map) return 405 - for (const tile of await fetchTileManifest()) { 406 - if (this.#map.getSource(tile.name)) continue 407 - const pmtiles = await getCachedPMTiles(tile.filename) 408 - if (!pmtiles) continue 409 - if (!registeredSources.has(tile.name)) { 410 - protocol.add(pmtiles) 411 - registeredSources.add(tile.name) 412 - } 413 - this.#map.addSource(tile.name, { 414 - type: 'vector', 415 - url: `pmtiles://${tile.filename}`, 416 - }) 417 - layers(tile.name).forEach((layer) => 418 - this.#map!.addLayer(layer as maplibregl.AddLayerObject) 419 - ) 420 - } 421 13 } 422 14 } 423 15
+1 -1
www/static/styles/theme.css
··· 652 652 opacity: 0.7; 653 653 } 654 654 655 - @media (max-width: 768px) { 655 + @media (max-width: 768px) { 656 656 input, textarea, select { 657 657 font-size: 1rem; 658 658 }