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: show download button for new regions

+212 -22
+9 -3
data/cli/commands/build.ts
··· 19 19 filename: string 20 20 path: string 21 21 group: string 22 + bounds?: [number, number, number, number] 22 23 } 23 24 24 25 async function bboxFromPbfHeader(pbfPath: string): Promise<string | null> { ··· 38 39 return null 39 40 } 40 41 41 - async function bboxFromPoly(region: string): Promise<string | null> { 42 + async function bboxFromPoly( 43 + region: string, 44 + ): Promise<[number, number, number, number] | null> { 42 45 const parts = region.split('/') 43 46 const polyPath = join( 44 47 POLY_DIR, ··· 59 62 const south = Math.min(...coords.map((c) => c.lat)) 60 63 const east = Math.max(...coords.map((c) => c.lon)) 61 64 const north = Math.max(...coords.map((c) => c.lat)) 62 - return `${west},${south},${east},${north}` 65 + return [west, south, east, north] 63 66 } catch { /* no poly file */ } 64 67 return null 65 68 } ··· 108 111 // Strategy: try PBF header first (fast), fall back to poly file (reliable 109 112 // for osmium-extracted files which often have no bbox in their header). 110 113 const bboxArgs: string[] = [] 111 - const bbox = await bboxFromPbfHeader(pbfPath) ?? await bboxFromPoly(region) 114 + const pbfBbox = await bboxFromPbfHeader(pbfPath) 115 + const polyBbox = await bboxFromPoly(region) 116 + const bbox = pbfBbox ?? (polyBbox ? polyBbox.join(',') : null) 112 117 if (bbox) bboxArgs.push('--bbox', bbox) 113 118 114 119 console.log(`Building tiles for ${region} ...`) ··· 141 146 filename: relativePath, 142 147 path: `/static/tiles/${relativePath}`, 143 148 group: parts.slice(0, -1).join('/'), 149 + bounds: polyBbox ?? undefined, 144 150 }) 145 151 console.log(`Updated tiles manifest`) 146 152 })
+82
data/cli/commands/update.ts
··· 1 + import { Command } from '@cliffy/command' 2 + import { join } from '@std/path' 3 + import { leafName } from '../shared/regions.ts' 4 + import { POLY_DIR, TILES_MANIFEST } from '../shared/paths.ts' 5 + 6 + type TileEntry = { 7 + id: string 8 + label: string 9 + description: string 10 + filename: string 11 + path: string 12 + group: string 13 + bounds?: [number, number, number, number] 14 + } 15 + 16 + async function bboxFromPoly( 17 + region: string, 18 + ): Promise<[number, number, number, number] | null> { 19 + const parts = region.split('/') 20 + const polyPath = join( 21 + POLY_DIR, 22 + ...parts.slice(0, -1), 23 + `${leafName(region)}.poly`, 24 + ) 25 + try { 26 + const text = await Deno.readTextFile(polyPath) 27 + const coords = text.split('\n').flatMap((line) => { 28 + const parts = line.trim().split(/\s+/) 29 + if (parts.length !== 2) return [] 30 + const lon = parseFloat(parts[0]) 31 + const lat = parseFloat(parts[1]) 32 + return isNaN(lon) || isNaN(lat) ? [] : [{ lon, lat }] 33 + }) 34 + if (!coords.length) return null 35 + const west = Math.min(...coords.map((c) => c.lon)) 36 + const south = Math.min(...coords.map((c) => c.lat)) 37 + const east = Math.max(...coords.map((c) => c.lon)) 38 + const north = Math.max(...coords.map((c) => c.lat)) 39 + return [west, south, east, north] 40 + } catch { /* no poly file */ } 41 + return null 42 + } 43 + 44 + async function updateManifestBounds(): Promise<void> { 45 + let tiles: TileEntry[] = [] 46 + try { 47 + tiles = JSON.parse(await Deno.readTextFile(TILES_MANIFEST)) 48 + } catch { 49 + throw new Error(`No tiles manifest found at ${TILES_MANIFEST}`) 50 + } 51 + 52 + let updated = 0 53 + for (const tile of tiles) { 54 + if (tile.bounds) continue 55 + 56 + const groupParts = tile.group ? tile.group.split('/') : [] 57 + const region = [...groupParts, tile.id].join('/') 58 + const bounds = await bboxFromPoly(region) 59 + 60 + if (bounds) { 61 + tile.bounds = bounds 62 + updated++ 63 + } 64 + } 65 + 66 + await Deno.writeTextFile( 67 + TILES_MANIFEST, 68 + JSON.stringify(tiles, null, 2) + '\n', 69 + ) 70 + 71 + console.log(`Updated ${updated} entries with bounds`) 72 + } 73 + 74 + export const updateCmd = new Command() 75 + .name('update') 76 + .description( 77 + 'Update tiles.json with computed bounds from poly files. ' + 78 + 'Does not rebuild tiles.', 79 + ) 80 + .action(async () => { 81 + await updateManifestBounds() 82 + })
+2
data/cli/main.ts
··· 22 22 import { buildCmd } from './commands/build.ts' 23 23 import { buildWorldCmd } from './commands/build-world.ts' 24 24 import { trimWorldCmd } from './commands/trim-world.ts' 25 + import { updateCmd } from './commands/update.ts' 25 26 import { cleanCmd } from './commands/clean.ts' 26 27 27 28 await new Command() ··· 38 39 .command('build', buildCmd) 39 40 .command('build:world', buildWorldCmd) 40 41 .command('trim:world', trimWorldCmd) 42 + .command('update', updateCmd) 41 43 .command('clean', cleanCmd) 42 44 .parse(Deno.args)
+91 -18
www/components/m-map.ts
··· 1 1 import { html, LitElement, type TemplateResult } from 'lit' 2 2 import maplibregl from 'maplibre-gl' 3 3 import { Protocol } from 'pmtiles' 4 - import { downloadAndSavePMTiles, getCachedPMTiles } from '../utils/fs.ts' 4 + import { 5 + downloadAndSavePMTiles, 6 + getCachedPMTiles, 7 + isPMTilesCached, 8 + } from '../utils/fs.ts' 5 9 import { getMapNav, setMapNav } from '../utils/nav.ts' 6 10 import layers from '../utils/layers.ts' 7 11 import worldLayers from '../utils/world_layers.ts' ··· 15 19 16 20 const registeredSources = new Set<string>() 17 21 18 - async function fetchTileManifest(): Promise< 19 - { name: string; filename: string }[] 20 - > { 22 + type TileManifestEntry = { 23 + id: string 24 + filename: string 25 + label: string 26 + bounds?: [number, number, number, number] 27 + } 28 + 29 + async function fetchTileManifest(): Promise<TileManifestEntry[]> { 21 30 try { 22 31 const res = await fetch('/static/tiles/tiles.json') 23 32 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 })) 33 + const entries = await res.json() as TileManifestEntry[] 34 + return entries 26 35 } catch { 27 36 return [] 28 37 } ··· 33 42 #marker: maplibregl.Marker | null = null 34 43 #bookmarkPopup: maplibregl.Popup | null = null 35 44 #longPressTimer: ReturnType<typeof setTimeout> | null = null 45 + #tileManifest: TileManifestEntry[] = [] 46 + #availableTile: TileManifestEntry | null = null 36 47 37 48 protected override createRenderRoot() { 38 49 return this ··· 53 64 this.#renderBookmarkMarkers() 54 65 } 55 66 56 - override render(): TemplateResult { 57 - return html` 58 - <div id="map"></div> 59 - ` 60 - } 61 - 62 67 override firstUpdated(): void { 63 68 const container = this.querySelector<HTMLElement>('#map') 64 69 if (!container) return ··· 92 97 'top-right', 93 98 ) 94 99 95 - this.#map.on('moveend', () => this.#saveLastView()) 100 + this.#map.on('moveend', () => { 101 + this.#saveLastView() 102 + this.#checkAvailableTile() 103 + }) 96 104 97 105 this.#map.on('contextmenu', (e) => { 98 106 this.#placePin(e.lngLat) ··· 363 371 }) 364 372 } 365 373 374 + async #checkAvailableTile(): Promise<void> { 375 + if (!this.#map) return 376 + const center = this.#map.getCenter() 377 + const zoom = this.#map.getZoom() 378 + 379 + if (zoom < 8) { 380 + this.#availableTile = null 381 + this.requestUpdate() 382 + return 383 + } 384 + 385 + const manifest = await fetchTileManifest() 386 + this.#tileManifest = manifest 387 + 388 + for (const tile of manifest) { 389 + if (this.#map.getSource(tile.id)) continue 390 + if (!tile.bounds) continue 391 + const [w, s, e, n] = tile.bounds 392 + if ( 393 + center.lng >= w && center.lng <= e && center.lat >= s && center.lat <= n 394 + ) { 395 + const cached = await isPMTilesCached(tile.filename) 396 + if (!cached) { 397 + this.#availableTile = tile 398 + this.requestUpdate() 399 + return 400 + } 401 + } 402 + } 403 + 404 + this.#availableTile = null 405 + this.requestUpdate() 406 + } 407 + 408 + #handleDownloadClick = async () => { 409 + if (!this.#availableTile) return 410 + try { 411 + await downloadAndSavePMTiles( 412 + `/static/tiles/${this.#availableTile.filename}`, 413 + this.#availableTile.filename, 414 + ) 415 + await this.#loadCachedDetailTiles() 416 + this.#availableTile = null 417 + this.requestUpdate() 418 + } catch (err) { 419 + console.error('Download failed:', err) 420 + } 421 + } 422 + 423 + override render(): TemplateResult { 424 + return html` 425 + <div id="map"></div> 426 + ${this.#availableTile 427 + ? html` 428 + <button 429 + class="download-btn" 430 + @click="${this.#handleDownloadClick}" 431 + > 432 + Download ${this.#availableTile.label} 433 + </button> 434 + ` 435 + : null} 436 + ` 437 + } 438 + 366 439 override disconnectedCallback() { 367 440 super.disconnectedCallback() 368 441 app.removeEventListener(this.#onAppUpdate) ··· 407 480 async #loadCachedDetailTiles(): Promise<void> { 408 481 if (!this.#map) return 409 482 for (const tile of await fetchTileManifest()) { 410 - if (this.#map.getSource(tile.name)) continue 483 + if (this.#map.getSource(tile.id)) continue 411 484 const pmtiles = await getCachedPMTiles(tile.filename) 412 485 if (!pmtiles) continue 413 - if (!registeredSources.has(tile.name)) { 486 + if (!registeredSources.has(tile.id)) { 414 487 protocol.add(pmtiles) 415 - registeredSources.add(tile.name) 488 + registeredSources.add(tile.id) 416 489 } 417 - this.#map.addSource(tile.name, { 490 + this.#map.addSource(tile.id, { 418 491 type: 'vector', 419 492 url: `pmtiles://${tile.filename}`, 420 493 }) 421 - layers(tile.name).forEach((layer) => 494 + layers(tile.id).forEach((layer) => 422 495 this.#map!.addLayer(layer as maplibregl.AddLayerObject) 423 496 ) 424 497 }
+28 -1
www/static/styles/theme.css
··· 69 69 overflow: hidden; 70 70 } 71 71 72 - r-home #map { 72 + m-map { 73 + display: block; 74 + height: 100%; 75 + position: relative; 76 + } 77 + 78 + m-map #map { 73 79 height: 100%; 74 80 background-color: #6baed6; 81 + } 82 + 83 + m-map .download-btn { 84 + position: absolute; 85 + bottom: var(--s4); 86 + left: var(--s3); 87 + padding: var(--s2) var(--s4); 88 + background: var(--primary); 89 + color: white; 90 + border: none; 91 + border-radius: var(--br-base); 92 + font-size: var(--f4); 93 + font-weight: var(--fw-medium); 94 + cursor: pointer; 95 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 96 + z-index: 10; 97 + transition: opacity var(--transition-fast); 98 + } 99 + 100 + m-map .download-btn:hover { 101 + opacity: 0.9; 75 102 } 76 103 77 104 /* ── Header ───────────────────────────────────────────────────────────── */