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

Configure Feed

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

fix: update region download behavior

- Focus on country centroid for determining whether to show
- Show parent region in subregions
- Only start showing download at z6

+425 -47
+68 -1
data/cli/commands/update.ts
··· 13 13 bounds?: [number, number, number, number] 14 14 } 15 15 16 + type GroupEntry = { 17 + id: string 18 + label: string 19 + group: string 20 + bounds?: [number, number, number, number] 21 + } 22 + 16 23 async function bboxFromPoly( 17 24 region: string, 18 25 ): Promise<[number, number, number, number] | null> { ··· 63 70 } 64 71 } 65 72 73 + const groupEntries = buildGroupEntries(tiles) 74 + 75 + const allEntries = [...tiles, ...groupEntries] 76 + 66 77 await Deno.writeTextFile( 67 78 TILES_MANIFEST, 68 - JSON.stringify(tiles, null, 2) + '\n', 79 + JSON.stringify(allEntries, null, 2) + '\n', 69 80 ) 70 81 71 82 console.log(`Updated ${updated} entries with bounds`) 83 + console.log(`Added ${groupEntries.length} group entries`) 84 + } 85 + 86 + function slugToLabel(slug: string): string { 87 + if (slug === 'us') return 'US' 88 + return slug.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ') 89 + } 90 + 91 + function buildGroupEntries(tiles: TileEntry[]): GroupEntry[] { 92 + const childGroups = new Set<string>() 93 + 94 + for (const tile of tiles) { 95 + if (tile.group) { 96 + childGroups.add(tile.group) 97 + } 98 + } 99 + 100 + const groupEntries: GroupEntry[] = [] 101 + const addedGroupPaths = new Set<string>() 102 + 103 + for (const childGroup of childGroups) { 104 + const parts = childGroup.split('/') 105 + if (parts.length < 2) continue 106 + 107 + const parentGroup = parts.slice(0, -1).join('/') 108 + const id = parts[parts.length - 1] 109 + const groupPath = `${parentGroup}/${id}` 110 + 111 + if (addedGroupPaths.has(groupPath)) continue 112 + addedGroupPaths.add(groupPath) 113 + 114 + const groupTiles = tiles.filter((t) => t.group === childGroup) 115 + 116 + let west = 180, south = 90, east = -180, north = -90 117 + let hasBounds = false 118 + 119 + for (const tile of groupTiles) { 120 + if (tile.bounds) { 121 + const [w, s, e, n] = tile.bounds 122 + west = Math.min(west, w) 123 + south = Math.min(south, s) 124 + east = Math.max(east, e) 125 + north = Math.max(north, n) 126 + hasBounds = true 127 + } 128 + } 129 + 130 + groupEntries.push({ 131 + id, 132 + label: slugToLabel(id), 133 + group: parentGroup, 134 + bounds: hasBounds ? [west, south, east, north] : undefined, 135 + }) 136 + } 137 + 138 + return groupEntries 72 139 } 73 140 74 141 export const updateCmd = new Command()
+116 -23
www/components/m-map.ts
··· 22 22 23 23 type TileManifestEntry = { 24 24 id: string 25 - filename: string 25 + filename?: string 26 26 label: string 27 + group: string 27 28 bounds?: [number, number, number, number] 28 29 } 29 30 ··· 61 62 override connectedCallback() { 62 63 super.connectedCallback() 63 64 app.addEventListener(this.#onAppUpdate) 64 - window.addEventListener('pmtiles-updated', this.#onPMTilesUpdated) 65 + globalThis.addEventListener('pmtiles-updated', this.#onPMTilesUpdated) 65 66 } 66 67 67 68 override updated(changedProperties: Map<string, unknown>): void { ··· 535 536 if (!this.#map) return 536 537 const zoom = this.#map.getZoom() 537 538 538 - if (zoom < 7) { 539 + if (zoom < 6) { 539 540 this.#availableTile = null 540 541 this.requestUpdate() 541 542 return ··· 545 546 this.#tileManifest = manifest 546 547 547 548 const viewport = this.#map.getBounds() 549 + const viewportCenter = viewport.getCenter() 550 + 551 + const parentEntries = new Map<string, TileManifestEntry>() 552 + const tileEntries: TileManifestEntry[] = [] 548 553 549 554 for (const tile of manifest) { 555 + if (tile.filename) { 556 + tileEntries.push(tile) 557 + } else { 558 + const parentId = `${tile.group}/${tile.id}` 559 + if (!parentEntries.has(parentId)) { 560 + parentEntries.set(parentId, tile) 561 + } 562 + } 563 + } 564 + 565 + let bestTile: TileManifestEntry | null = null 566 + let bestDistance = Infinity 567 + 568 + for (const tile of tileEntries) { 550 569 if (this.#map.getSource(tile.id)) continue 570 + if (!tile.filename) continue 551 571 if (this.#confirmedCached.has(tile.filename)) continue 552 572 if (!tile.bounds) continue 573 + 553 574 const [w, s, e, n] = tile.bounds 554 - // Only show when the tile's centroid is visible in the current viewport. 555 - // This prevents tiles with large bounds (e.g. Alaska) from showing a 556 - // download prompt when the user is actually looking at a distant region. 557 575 const centroidLng = (w + e) / 2 558 576 const centroidLat = (s + n) / 2 577 + 559 578 if (!viewport.contains([centroidLng, centroidLat])) continue 579 + 560 580 const cached = await isPMTilesCached(tile.filename) 561 581 if (cached) { 562 582 this.#confirmedCached.add(tile.filename) 563 583 continue 564 584 } 565 - this.#availableTile = tile 566 - this.requestUpdate() 567 - return 585 + 586 + const dx = centroidLng - viewportCenter.lng 587 + const dy = centroidLat - viewportCenter.lat 588 + const distance = Math.sqrt(dx * dx + dy * dy) 589 + 590 + if (distance < bestDistance) { 591 + bestDistance = distance 592 + bestTile = tile 593 + } 568 594 } 569 595 570 - this.#availableTile = null 596 + this.#availableTile = bestTile 571 597 this.requestUpdate() 572 598 } 573 599 574 600 #handleDownloadClick = async () => { 575 601 if (!this.#availableTile) return 576 - try { 577 - await downloadAndSavePMTiles( 578 - `/static/tiles/${this.#availableTile.filename}`, 579 - this.#availableTile.filename, 602 + 603 + const parentGroup = this.#availableTile.group 604 + const parentId = this.#availableTile.id 605 + const manifest = this.#tileManifest 606 + 607 + if (this.#availableTile.filename) { 608 + try { 609 + await downloadAndSavePMTiles( 610 + `/static/tiles/${this.#availableTile.filename}`, 611 + this.#availableTile.filename, 612 + ) 613 + await this.#loadCachedDetailTiles(manifest) 614 + this.#ensureBookmarkLayersOnTop() 615 + this.#availableTile = null 616 + this.requestUpdate() 617 + } catch (err) { 618 + console.error('Download failed:', err) 619 + } 620 + } else { 621 + const tilesInGroup = manifest.filter( 622 + (t) => t.group === `${parentGroup}/${parentId}` && t.filename, 580 623 ) 581 - await this.#loadCachedDetailTiles(this.#tileManifest) 582 - this.#ensureBookmarkLayersOnTop() 583 - this.#availableTile = null 584 - this.requestUpdate() 585 - } catch (err) { 586 - console.error('Download failed:', err) 624 + 625 + let failed = false 626 + for (const tile of tilesInGroup) { 627 + if (!tile.filename) continue 628 + try { 629 + await downloadAndSavePMTiles( 630 + `/static/tiles/${tile.filename}`, 631 + tile.filename, 632 + ) 633 + } catch (err) { 634 + console.error(`Download failed for ${tile.label}:`, err) 635 + failed = true 636 + } 637 + } 638 + 639 + if (!failed || failed) { 640 + await this.#loadCachedDetailTiles(manifest) 641 + this.#ensureBookmarkLayersOnTop() 642 + this.#availableTile = null 643 + this.requestUpdate() 644 + } 587 645 } 588 646 } 589 647 648 + #getParentLabel( 649 + tile: TileManifestEntry, 650 + manifest: TileManifestEntry[], 651 + ): string | null { 652 + const groupParts = tile.group.split('/') 653 + if (groupParts.length < 2) return null 654 + 655 + const parentId = groupParts[groupParts.length - 1] 656 + const parentGroup = groupParts.slice(0, -1).join('/') 657 + 658 + const parentEntry = manifest.find( 659 + (t) => t.id === parentId && t.group === parentGroup, 660 + ) 661 + 662 + if (parentEntry) return parentEntry.label 663 + return this.#slugToLabel(parentId) 664 + } 665 + 666 + #slugToLabel(slug: string): string { 667 + if (slug === 'us') return 'US' 668 + return slug.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ') 669 + } 670 + 590 671 override render(): TemplateResult { 672 + const tile = this.#availableTile 673 + const manifest = this.#tileManifest 674 + 675 + let label = '' 676 + if (tile) { 677 + const parentLabel = manifest.length 678 + ? this.#getParentLabel(tile, manifest) 679 + : null 680 + label = parentLabel ? `${tile.label} - ${parentLabel}` : tile.label 681 + } 682 + 591 683 return html` 592 684 <div id="map"></div> 593 685 <div class="zoom-display">Zoom: ${this.#currentZoom.toFixed(1)}</div> 594 - ${this.#availableTile 686 + ${tile 595 687 ? html` 596 688 <button 597 689 class="download-btn" 598 690 @click="${this.#handleDownloadClick}" 599 691 > 600 - Download ${this.#availableTile.label} 692 + Download ${label} 601 693 </button> 602 694 ` 603 695 : null} ··· 607 699 override disconnectedCallback() { 608 700 super.disconnectedCallback() 609 701 app.removeEventListener(this.#onAppUpdate) 610 - window.removeEventListener('pmtiles-updated', this.#onPMTilesUpdated) 702 + globalThis.removeEventListener('pmtiles-updated', this.#onPMTilesUpdated) 611 703 this.#onMoveEnd.clear() 612 704 this.#clearLongPress() 613 705 this.#bookmarkPopup?.remove() ··· 677 769 : undefined 678 770 for (const tile of tiles) { 679 771 if (this.#map.getSource(tile.id)) continue 772 + if (!tile.filename) continue 680 773 const pmtiles = await getCachedPMTiles(tile.filename) 681 774 if (!pmtiles) continue 682 775 if (!registeredSources.has(tile.id)) {
+5 -1
www/index.html
··· 43 43 </header> 44 44 45 45 <main id="main"> 46 - <ui-spinner></ui-spinner> 46 + <div 47 + style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%)" 48 + > 49 + <ui-spinner size="lg"></ui-spinner> 50 + </div> 47 51 </main> 48 52 49 53 <m-map></m-map>
+215 -12
www/routes/settings-downloads.ts
··· 16 16 type TileConfig = { 17 17 id: string 18 18 label: string 19 - description: string 20 - filename: string 21 - path: string 19 + description?: string 20 + filename?: string 21 + path?: string 22 + group: string 23 + } 24 + 25 + type GroupEntry = { 26 + id: string 27 + label: string 22 28 group: string 23 29 } 24 30 25 31 type TreeNode = { 26 32 tiles: TileConfig[] 33 + groups: GroupEntry[] 27 34 children: Map<string, TreeNode> 28 35 } 29 36 ··· 56 63 async #checkStatuses(): Promise<void> { 57 64 await Promise.all( 58 65 this.tiles.map(async (tile) => { 66 + if (!tile.filename) return 59 67 const cached = await isPMTilesCached(tile.filename) 60 68 this.statuses[tile.id] = cached ? 'cached' : 'available' 61 69 }), ··· 64 72 } 65 73 66 74 async #handleDownload(tile: TileConfig): Promise<void> { 75 + if (!tile.filename) return 67 76 this.statuses[tile.id] = 'downloading' 68 77 this.requestUpdate() 69 78 try { 70 - await downloadAndSavePMTiles(tile.path, tile.filename) 79 + await downloadAndSavePMTiles(tile.path!, tile.filename) 71 80 this.statuses[tile.id] = 'cached' 72 - window.dispatchEvent(new CustomEvent('pmtiles-updated')) 81 + globalThis.dispatchEvent(new CustomEvent('pmtiles-updated')) 73 82 } catch (err) { 74 83 this.statuses[tile.id] = 'error' 75 84 this.errors[tile.id] = err instanceof Error ··· 80 89 } 81 90 82 91 async #handleDelete(tile: TileConfig): Promise<void> { 92 + if (!tile.filename) return 83 93 this.statuses[tile.id] = 'deleting' 84 94 this.requestUpdate() 85 95 try { ··· 94 104 this.requestUpdate() 95 105 } 96 106 107 + async #handleDownloadGroup(group: GroupEntry): Promise<void> { 108 + const tilesInGroup = this.tiles.filter( 109 + (t) => t.group === `${group.group}/${group.id}` && t.filename, 110 + ) 111 + 112 + for (const tile of tilesInGroup) { 113 + if (!tile.filename) continue 114 + this.statuses[tile.id] = 'downloading' 115 + } 116 + this.requestUpdate() 117 + 118 + let failed = false 119 + for (const tile of tilesInGroup) { 120 + if (!tile.filename) continue 121 + try { 122 + await downloadAndSavePMTiles(tile.path!, tile.filename) 123 + this.statuses[tile.id] = 'cached' 124 + } catch (err) { 125 + this.statuses[tile.id] = 'error' 126 + this.errors[tile.id] = err instanceof Error 127 + ? err.message 128 + : 'Download failed' 129 + failed = true 130 + } 131 + } 132 + 133 + if (!failed) { 134 + globalThis.dispatchEvent(new CustomEvent('pmtiles-updated')) 135 + } 136 + this.requestUpdate() 137 + } 138 + 139 + async #handleDeleteGroup(group: GroupEntry): Promise<void> { 140 + const tilesInGroup = this.tiles.filter( 141 + (t) => t.group === `${group.group}/${group.id}` && t.filename, 142 + ) 143 + 144 + for (const tile of tilesInGroup) { 145 + if (!tile.filename) continue 146 + this.statuses[tile.id] = 'deleting' 147 + } 148 + this.requestUpdate() 149 + 150 + for (const tile of tilesInGroup) { 151 + if (!tile.filename) continue 152 + try { 153 + await deletePMTiles(tile.filename) 154 + this.statuses[tile.id] = 'available' 155 + } catch (err) { 156 + this.statuses[tile.id] = 'error' 157 + this.errors[tile.id] = err instanceof Error 158 + ? err.message 159 + : 'Delete failed' 160 + } 161 + } 162 + this.requestUpdate() 163 + } 164 + 97 165 #buildTree(tiles: TileConfig[]): Map<string, TreeNode> { 98 166 const root = new Map<string, TreeNode>() 99 - for (const tile of tiles) { 167 + 168 + const tileEntries = tiles.filter((t) => t.filename) 169 + const groupEntries = tiles.filter((t) => !t.filename) as GroupEntry[] 170 + 171 + for (const tile of tileEntries) { 100 172 const segments = tile.group ? tile.group.split('/') : ['other'] 101 173 this.#insertTile(root, segments, tile) 102 174 } 175 + 176 + for (const group of groupEntries) { 177 + const segments = group.group ? group.group.split('/') : ['other'] 178 + this.#insertGroup(root, segments, group) 179 + } 180 + 103 181 return root 104 182 } 105 183 ··· 109 187 tile: TileConfig, 110 188 ): void { 111 189 const [head, ...rest] = segments 112 - if (!map.has(head)) map.set(head, { tiles: [], children: new Map() }) 190 + if (!map.has(head)) { 191 + map.set(head, { tiles: [], groups: [], children: new Map() }) 192 + } 113 193 const node = map.get(head)! 114 194 if (rest.length === 0) { 115 195 node.tiles.push(tile) ··· 118 198 } 119 199 } 120 200 201 + #insertGroup( 202 + map: Map<string, TreeNode>, 203 + segments: string[], 204 + group: GroupEntry, 205 + ): void { 206 + const [head, ...rest] = segments 207 + if (!map.has(head)) { 208 + map.set(head, { tiles: [], groups: [], children: new Map() }) 209 + } 210 + const node = map.get(head)! 211 + if (rest.length === 0) { 212 + node.groups.push(group) 213 + } else { 214 + this.#insertGroup(node.children, rest, group) 215 + } 216 + } 217 + 218 + #getGroupStatus(group: GroupEntry): TileStatus { 219 + const tilesInGroup = this.tiles.filter( 220 + (t) => t.group === `${group.group}/${group.id}` && t.filename, 221 + ) 222 + 223 + if (tilesInGroup.length === 0) return 'available' 224 + 225 + let allCached = true 226 + let anyDownloading = false 227 + for (const tile of tilesInGroup) { 228 + const status = this.statuses[tile.id] ?? 'available' 229 + if (status === 'checking' || status === 'downloading') { 230 + anyDownloading = true 231 + } 232 + if (status !== 'cached') allCached = false 233 + } 234 + 235 + if (anyDownloading) return 'downloading' 236 + return allCached ? 'cached' : 'available' 237 + } 238 + 121 239 #renderTree(map: Map<string, TreeNode>): TemplateResult { 122 240 const sorted = [...map.entries()].sort(([a], [b]) => { 123 241 if (a === 'world') return -1 ··· 129 247 const directTiles = [...node.tiles].sort((a, b) => 130 248 a.label.localeCompare(b.label) 131 249 ) 250 + const directGroups = [...node.groups].sort((a, b) => 251 + a.label.localeCompare(b.label) 252 + ) 132 253 return html` 133 254 <details> 134 255 <summary>${slugToLabel(key)}</summary> 135 256 ${node.children.size > 0 136 257 ? this.#renderTree(node.children) 137 - : ''} ${directTiles.length > 0 258 + : ''} ${directGroups.map((group) => 259 + this.#renderGroupItem(group) 260 + )} ${directTiles.length > 0 138 261 ? html` 139 262 <div class="tile-list"> 140 263 ${directTiles.map((tile) => this.#renderTileItem(tile))} ··· 147 270 ` 148 271 } 149 272 273 + #renderGroupItem(group: GroupEntry): TemplateResult { 274 + const status = this.#getGroupStatus(group) 275 + const tilesInGroup = this.tiles.filter( 276 + (t) => t.group === `${group.group}/${group.id}` && t.filename, 277 + ) 278 + const count = tilesInGroup.length 279 + 280 + return html` 281 + <div class="tile-item"> 282 + <div class="tile-item-info"> 283 + <span class="tile-item-name">${group.label} (${count} regions)</span> 284 + <div class="tile-item-meta">Download all regions at once</div> 285 + ${status === 'error' 286 + ? html` 287 + <div class="tile-item-meta settings-data-status--error"> 288 + ${this.errors[group.id] ?? 'Download failed'} 289 + </div> 290 + ` 291 + : ''} 292 + </div> 293 + <div class="tile-item-action"> 294 + ${this.#renderGroupAction(group, status)} 295 + </div> 296 + </div> 297 + ` 298 + } 299 + 300 + #renderGroupAction(group: GroupEntry, status: TileStatus): TemplateResult { 301 + if (status === 'checking') { 302 + return html` 303 + <ui-spinner></ui-spinner> 304 + ` 305 + } 306 + if (status === 'cached') { 307 + return html` 308 + <span class="tile-status-cached">✓ Downloaded</span> 309 + <button 310 + class="action" 311 + style="width:auto;padding:var(--s2) var(--s3)" 312 + @click="${() => this.#handleDeleteGroup(group)}" 313 + > 314 + <img 315 + src="/static/icons/x.svg" 316 + alt="" 317 + aria-hidden="true" 318 + style="width:16px;height:16px" 319 + > 320 + Remove 321 + </button> 322 + ` 323 + } 324 + if (status === 'downloading') { 325 + return html` 326 + <ui-spinner></ui-spinner> 327 + ` 328 + } 329 + return html` 330 + <button 331 + class="action" 332 + style="width:auto;padding:var(--s2) var(--s3)" 333 + @click="${() => this.#handleDownloadGroup(group)}" 334 + > 335 + <img 336 + src="/static/icons/download.svg" 337 + alt="" 338 + aria-hidden="true" 339 + style="width:16px;height:16px" 340 + > 341 + Download all 342 + </button> 343 + ` 344 + } 345 + 150 346 override render(): TemplateResult { 151 347 const q = this.filter.toLowerCase() 152 348 const filteredTiles = q ··· 176 372 ${filteredTiles 177 373 ? html` 178 374 <div class="tile-list"> 179 - ${filteredTiles.map((tile) => this.#renderTileItem(tile))} 375 + ${filteredTiles.map((tile) => 376 + tile.filename 377 + ? this.#renderTileItem(tile) 378 + : this.#renderGroupItem(tile as GroupEntry) 379 + )} 180 380 </div> 181 381 ` 182 382 : this.#renderTree(this.#buildTree(this.tiles))} ··· 185 385 } 186 386 187 387 #renderTileItem(tile: TileConfig): TemplateResult { 188 - const status = this.statuses[tile.id] 388 + const status = this.statuses[tile.id] ?? 'available' 189 389 return html` 190 390 <div class="tile-item"> 191 391 <div class="tile-item-info"> 192 392 <span class="tile-item-name">${tile.label}</span> 193 - <div class="tile-item-meta">${tile.description}</div> 194 - ${status === 'error' 393 + ${tile.description 394 + ? html` 395 + <div class="tile-item-meta">${tile.description}</div> 396 + ` 397 + : ''} ${status === 'error' 195 398 ? html` 196 399 <div class="tile-item-meta settings-data-status--error"> 197 400 ${this.errors[tile.id] ?? 'Download failed'}
+1 -1
www/routes/settings.ts
··· 257 257 this.requestUpdate() 258 258 if (result.success) { 259 259 this.#setStatus('Data imported successfully.') 260 - window.dispatchEvent(new CustomEvent('pmtiles-updated')) 260 + globalThis.dispatchEvent(new CustomEvent('pmtiles-updated')) 261 261 } else { 262 262 this.#setStatus(result.error ?? 'Import failed.', true) 263 263 }
+1 -1
www/static/styles/theme.css
··· 807 807 808 808 .zoom-display { 809 809 position: absolute; 810 - bottom: 80px; 810 + top: 80px; 811 811 left: 10px; 812 812 background: rgba(255, 255, 255, 0.9); 813 813 padding: 4px 8px;
+19 -8
www/utils/layers.ts
··· 32 32 const mzFade = ( 33 33 zFinish: number, 34 34 zStart: number, 35 + // deno-lint-ignore no-explicit-any 35 36 opacity: any = 1, 36 37 ) => [zStart, ['case', ['<=', MZ, zFinish], opacity, 0]] 37 38 ··· 532 533 'line-color': '#dedede', 533 534 'line-gap-width': { 'base': 1.55, 'stops': [[4, 0.25], [20, 30]] }, 534 535 'line-opacity': [ 535 - 'interpolate', ['linear'], ['zoom'], 536 - 13, [...MATCH, 'tertiary', 0, 'minor', 0, 1], 537 - 14, [...MATCH, 'minor', 0, 1], 538 - 16, 1, 536 + 'interpolate', 537 + ['linear'], 538 + ['zoom'], 539 + 13, 540 + [...MATCH, 'tertiary', 0, 'minor', 0, 1], 541 + 14, 542 + [...MATCH, 'minor', 0, 1], 543 + 16, 544 + 1, 539 545 ], 540 546 'line-width': { 'base': 1.6, 'stops': [[12, 0.5], [20, 10]] }, 541 547 }, ··· 555 561 'paint': { 556 562 'line-color': [...MATCH, 'minor', '#efefef', WHITE], 557 563 'line-opacity': [ 558 - 'interpolate', ['linear'], ['zoom'], 559 - 13, [...MATCH, 'tertiary', 0, 'minor', 0, 1], 560 - 14, [...MATCH, 'minor', 0, 1], 561 - 16, 1, 564 + 'interpolate', 565 + ['linear'], 566 + ['zoom'], 567 + 13, 568 + [...MATCH, 'tertiary', 0, 'minor', 0, 1], 569 + 14, 570 + [...MATCH, 'minor', 0, 1], 571 + 16, 572 + 1, 562 573 ], 563 574 'line-width': { 'base': 1.4, 'stops': [[6, 0.5], [20, 30]] }, 564 575 },