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: add welcome dialog

+364
+240
www/components/m-welcome.ts
··· 1 + import { html, LitElement, nothing, type TemplateResult } from 'lit' 2 + import app from '../models/app.ts' 3 + import { 4 + fetchTileManifest, 5 + type TileManifestEntry, 6 + } from '../utils/tile-manifest.ts' 7 + import { downloadAndSavePMTiles } from '../utils/fs.ts' 8 + import { setMapNav } from '../utils/nav.ts' 9 + 10 + const REGIONAL_ZOOM = 12 11 + const MAX_MATCHES = 3 12 + 13 + type LocationStatus = 'idle' | 'requesting' | 'granted' | 'denied' 14 + type DownloadStatus = 'idle' | 'downloading' | 'done' | 'error' 15 + 16 + export class MWelcome extends LitElement { 17 + #locationStatus: LocationStatus = 'idle' 18 + #downloadStatuses: Record<string, DownloadStatus> = {} 19 + #matchingTiles: TileManifestEntry[] = [] 20 + #manifest: TileManifestEntry[] = [] 21 + #coords: { lat: number; lng: number } | null = null 22 + #dismissed = false 23 + 24 + protected override createRenderRoot() { 25 + return this 26 + } 27 + 28 + override connectedCallback() { 29 + super.connectedCallback() 30 + app.addEventListener(this.#onAppUpdate) 31 + } 32 + 33 + override disconnectedCallback() { 34 + super.disconnectedCallback() 35 + app.removeEventListener(this.#onAppUpdate) 36 + } 37 + 38 + #onAppUpdate = () => this.requestUpdate() 39 + 40 + get #open(): boolean { 41 + return app.preferencesLoaded && !app.hasSeenWelcome && !this.#dismissed 42 + } 43 + 44 + #requestLocation = () => { 45 + if (!('geolocation' in navigator)) { 46 + this.#locationStatus = 'denied' 47 + this.requestUpdate() 48 + return 49 + } 50 + this.#locationStatus = 'requesting' 51 + this.requestUpdate() 52 + 53 + navigator.geolocation.getCurrentPosition( 54 + async (pos) => { 55 + const { latitude: lat, longitude: lng } = pos.coords 56 + this.#coords = { lat, lng } 57 + const manifest = await fetchTileManifest() 58 + this.#manifest = manifest 59 + const matches = manifest.filter((t) => 60 + t.filename && t.bounds && 61 + lng >= t.bounds[0] && lat >= t.bounds[1] && 62 + lng <= t.bounds[2] && lat <= t.bounds[3] 63 + ) 64 + // Sort by bounding-box area (smallest first = most specific region). 65 + matches.sort((a, b) => { 66 + const [aw, as, ae, an] = a.bounds! 67 + const [bw, bs, be, bn] = b.bounds! 68 + return (ae - aw) * (an - as) - (be - bw) * (bn - bs) 69 + }) 70 + this.#matchingTiles = matches.slice(0, MAX_MATCHES) 71 + this.#locationStatus = 'granted' 72 + this.requestUpdate() 73 + }, 74 + () => { 75 + this.#locationStatus = 'denied' 76 + this.requestUpdate() 77 + }, 78 + { enableHighAccuracy: false, timeout: 10000 }, 79 + ) 80 + } 81 + 82 + #downloadRegion = async (tile: TileManifestEntry) => { 83 + if (!tile.filename) return 84 + this.#setStatus(tile.id, 'downloading') 85 + try { 86 + await downloadAndSavePMTiles( 87 + `/static/tiles/${tile.filename}`, 88 + tile.filename, 89 + ) 90 + this.#setStatus(tile.id, 'done') 91 + globalThis.dispatchEvent(new CustomEvent('pmtiles-updated')) 92 + if (this.#coords) { 93 + setMapNav({ 94 + lat: this.#coords.lat, 95 + lng: this.#coords.lng, 96 + zoom: REGIONAL_ZOOM, 97 + }) 98 + } 99 + } catch { 100 + this.#setStatus(tile.id, 'error') 101 + } 102 + } 103 + 104 + #setStatus(id: string, status: DownloadStatus): void { 105 + this.#downloadStatuses = { ...this.#downloadStatuses, [id]: status } 106 + this.requestUpdate() 107 + } 108 + 109 + #dismiss = () => { 110 + this.#dismissed = true 111 + this.requestUpdate() 112 + app.setHasSeenWelcome(true) 113 + } 114 + 115 + #tileDisplayLabel(tile: TileManifestEntry): string { 116 + const parts = tile.group.split('/') 117 + if (parts.length < 2) return tile.label 118 + const parentId = parts[parts.length - 1] 119 + const parentGroup = parts.slice(0, -1).join('/') 120 + const parent = this.#manifest.find( 121 + (t) => t.id === parentId && t.group === parentGroup, 122 + ) 123 + const parentLabel = parent?.label ?? 124 + parentId.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ') 125 + return `${tile.label} (${parentLabel})` 126 + } 127 + 128 + override render(): TemplateResult { 129 + return html` 130 + <ui-dialog ?open="${this.#open}" @dismiss="${this.#dismiss}"> 131 + <dialog> 132 + <article class="welcome-dialog"> 133 + <h2 class="welcome-title">Welcome to MapsApp</h2> 134 + <p class="welcome-intro"> 135 + MapsApp is an offline-capable world map. 136 + </p> 137 + 138 + <section class="welcome-section"> 139 + <h3>Use your location (Optional)</h3> 140 + <p class="welcome-muted">Your location stays on your device.</p> 141 + ${this.#renderLocationAction()} 142 + </section> 143 + 144 + <section class="welcome-section"> 145 + <h3>Detailed regional maps</h3> 146 + <p class="welcome-muted"> 147 + The world map is low detail. Download detailed regions for 148 + street-level maps and for offline use. 149 + </p> 150 + ${this.#matchingTiles.length > 0 151 + ? html` 152 + <ul class="welcome-tile-list"> 153 + ${this.#matchingTiles.map((tile) => 154 + this.#renderTileRow(tile) 155 + )} 156 + </ul> 157 + ` 158 + : nothing} 159 + <a 160 + href="#!/settings/downloads" 161 + class="welcome-link" 162 + @click="${this.#dismiss}" 163 + > 164 + Browse all regions → 165 + </a> 166 + </section> 167 + 168 + <div class="welcome-actions"> 169 + <button @click="${this.#dismiss}">Get started</button> 170 + </div> 171 + </article> 172 + </dialog> 173 + </ui-dialog> 174 + ` 175 + } 176 + 177 + #renderLocationAction(): TemplateResult { 178 + if (this.#locationStatus === 'requesting') { 179 + return html` 180 + <ui-spinner></ui-spinner> 181 + ` 182 + } 183 + if (this.#locationStatus === 'granted') { 184 + const count = this.#matchingTiles.length 185 + if (count === 0) { 186 + return html` 187 + <p class="welcome-muted"> 188 + Location detected, but no matching regional map was found. 189 + </p> 190 + ` 191 + } 192 + return html` 193 + <p class="welcome-done"> 194 + ✓ Detected ${count} matching region${count === 1 ? '' : 's'} 195 + </p> 196 + ` 197 + } 198 + if (this.#locationStatus === 'denied') { 199 + return html` 200 + <p class="welcome-error"> 201 + Location unavailable. You can still choose regions manually. 202 + </p> 203 + ` 204 + } 205 + return html` 206 + <button class="action" @click="${this.#requestLocation}"> 207 + Allow location 208 + </button> 209 + ` 210 + } 211 + 212 + #renderTileRow(tile: TileManifestEntry): TemplateResult { 213 + const status = this.#downloadStatuses[tile.id] ?? 'idle' 214 + return html` 215 + <li class="welcome-tile-row"> 216 + <span class="welcome-tile-label"> 217 + ${this.#tileDisplayLabel(tile)} 218 + </span> 219 + ${status === 'done' 220 + ? html` 221 + <span class="welcome-done">✓ Downloaded</span> 222 + ` 223 + : status === 'downloading' 224 + ? html` 225 + <ui-spinner></ui-spinner> 226 + ` 227 + : html` 228 + <button 229 + class="action welcome-tile-action" 230 + @click="${() => this.#downloadRegion(tile)}" 231 + > 232 + ${status === 'error' ? 'Retry' : 'Download'} 233 + </button> 234 + `} 235 + </li> 236 + ` 237 + } 238 + } 239 + 240 + customElements.define('m-welcome', MWelcome)
+2
www/index.html
··· 47 47 48 48 <m-map></m-map> 49 49 50 + <m-welcome></m-welcome> 51 + 50 52 <footer fixed> 51 53 <ui-bottom-bar role="navigation" aria-label="Bottom navigation"> 52 54 <a href="/" data-route aria-current="page">
+1
www/index.ts
··· 2 2 import { createLayoutRouter } from '@civility/ui' 3 3 import './routes/map.ts' 4 4 import './components/m-map.ts' 5 + import './components/m-welcome.ts' 5 6 import './routes/search.ts' 6 7 import './routes/settings.ts' 7 8 import './routes/settings-downloads.ts'
+14
www/models/app.ts
··· 16 16 geocodingBookmarksEnabled: boolean 17 17 onlineSearchEnabled: boolean 18 18 lastView: LastView | null 19 + hasSeenWelcome: boolean 19 20 } 20 21 21 22 const defaultPreferences: Preferences = { 22 23 geocodingBookmarksEnabled: true, 23 24 onlineSearchEnabled: true, 24 25 lastView: null, 26 + hasSeenWelcome: false, 25 27 } 26 28 27 29 const backend = new IDBStorage({ dbName: 'bpev-maps' }) ··· 259 261 260 262 async setLastView(value: LastView): Promise<void> { 261 263 await preferencesDoc.update({ lastView: value }) 264 + } 265 + 266 + get preferencesLoaded(): boolean { 267 + return preferencesDoc.value !== undefined 268 + } 269 + 270 + get hasSeenWelcome(): boolean { 271 + return preferencesDoc.value?.hasSeenWelcome ?? false 272 + } 273 + 274 + async setHasSeenWelcome(value: boolean): Promise<void> { 275 + await preferencesDoc.update({ hasSeenWelcome: value }) 262 276 } 263 277 264 278 // ====== DATA ======
+107
www/static/styles/theme.css
··· 784 784 opacity: 0.7; 785 785 } 786 786 787 + /* ── Welcome dialog ─────────────────────────────────────────────────────── */ 788 + 789 + .welcome-dialog { 790 + padding: var(--s4); 791 + min-width: min(420px, 90vw); 792 + display: flex; 793 + flex-direction: column; 794 + gap: var(--s3); 795 + } 796 + 797 + .welcome-title { 798 + font-size: var(--f3); 799 + font-weight: var(--fw-semibold); 800 + margin: 0; 801 + } 802 + 803 + .welcome-intro { 804 + margin: 0; 805 + font-size: var(--f5); 806 + opacity: 0.75; 807 + } 808 + 809 + .welcome-section { 810 + display: flex; 811 + flex-direction: column; 812 + gap: var(--s2); 813 + padding-top: var(--s3); 814 + border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent); 815 + } 816 + 817 + .welcome-section h3 { 818 + margin: 0; 819 + font-size: var(--f4); 820 + font-weight: var(--fw-semibold); 821 + } 822 + 823 + .welcome-muted { 824 + margin: 0; 825 + font-size: var(--f6); 826 + opacity: 0.6; 827 + } 828 + 829 + .welcome-link { 830 + font-size: var(--f5); 831 + color: var(--primary); 832 + text-decoration: none; 833 + padding: var(--s1) 0; 834 + align-self: flex-start; 835 + } 836 + 837 + .welcome-link:hover { 838 + opacity: 0.7; 839 + } 840 + 841 + .welcome-done { 842 + margin: 0; 843 + font-size: var(--f6); 844 + color: var(--success); 845 + font-weight: var(--fw-medium); 846 + } 847 + 848 + .welcome-error { 849 + margin: 0; 850 + font-size: var(--f6); 851 + color: var(--error); 852 + } 853 + 854 + .welcome-actions { 855 + display: flex; 856 + justify-content: flex-end; 857 + margin-top: var(--s2); 858 + } 859 + 860 + .welcome-tile-list { 861 + list-style: none; 862 + padding: 0; 863 + margin: 0; 864 + display: flex; 865 + flex-direction: column; 866 + gap: var(--s2); 867 + } 868 + 869 + .welcome-tile-row { 870 + display: flex; 871 + align-items: center; 872 + justify-content: space-between; 873 + gap: var(--s3); 874 + padding: var(--s2) var(--s3); 875 + border: 1px solid currentColor; 876 + border-radius: var(--br-base); 877 + } 878 + 879 + .welcome-tile-label { 880 + font-size: var(--f5); 881 + font-weight: var(--fw-medium); 882 + min-width: 0; 883 + overflow: hidden; 884 + text-overflow: ellipsis; 885 + white-space: nowrap; 886 + } 887 + 888 + .welcome-tile-action { 889 + width: auto; 890 + padding: var(--s2) var(--s3); 891 + flex-shrink: 0; 892 + } 893 + 787 894 @media (max-width: 768px) { 788 895 input, 789 896 textarea,