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: cleanup search ui

+340 -111
+1 -1
README.md
··· 1 - # world.bpev.me 1 + # maps.bpev.me 2 2 3 3 This is a proof-of-concept for building offline maps as a PWA, using maplibregl and protomaps. 4 4
+4 -3
www/index.html
··· 14 14 <link rel="icon" type="image/x-icon" href="/dist/icons/icon.ico" /> 15 15 <link rel="apple-touch-icon" href="/dist/icons/icon.png"> 16 16 17 - <link rel="canonical" href="https://world.bpev.me" /> 18 - <title>world</title> 17 + <link rel="canonical" href="https://maps.bpev.me" /> 18 + <title>MapsApp</title> 19 19 20 20 <link 21 21 rel="stylesheet" ··· 31 31 32 32 <script src="/dist/index.js" type="module"></script> 33 33 </head> 34 + 34 35 <body> 35 36 <a href="#main" class="skip-to-main">Skip to main content</a> 36 37 ··· 38 39 <a id="header-back" href="#!/" hidden aria-label="Back"> 39 40 ← Back 40 41 </a> 41 - <strong id="page-title">World</strong> 42 + <strong id="page-title">MapsApp</strong> 42 43 </header> 43 44 44 45 <main id="main">
+1 -1
www/index.ts
··· 52 52 routes: { 53 53 '/': { 54 54 landmarks: { main: 'r-home' }, 55 - meta: { title: 'World', navActive: '/' }, 55 + meta: { title: 'Maps', navActive: '/' }, 56 56 }, 57 57 '/search': { 58 58 landmarks: { main: 'r-search' },
+4 -4
www/manifest.json
··· 1 1 { 2 - "short_name": "world", 3 - "name": "world", 4 - "description": "WorldApp", 5 - "id": "https://world.bpev.me", 2 + "short_name": "maps", 3 + "name": "maps", 4 + "description": "MapsApp", 5 + "id": "https://maps.bpev.me", 6 6 "categories": [], 7 7 "orientation": "any", 8 8 "start_url": "/",
+24 -6
www/models/app.ts
··· 3 3 AppState, 4 4 Bookmark, 5 5 BookmarkFolder, 6 + LastView, 6 7 SearchHistoryEntry, 7 8 } from './schema.ts' 8 9 import { Store } from './store.ts' ··· 12 13 13 14 constructor() { 14 15 super(AppState.parse({})) 15 - this.store = new Store('world-data') 16 + this.store = new Store('maps-data') 16 17 this.store.addEventListener(() => this.notify()) 17 18 } 18 19 ··· 84 85 85 86 // ====== PREFERENCES ====== 86 87 87 - get geocodingEnabled(): boolean { 88 - return this.store.geocodingEnabled 88 + get geocodingBookmarksEnabled(): boolean { 89 + return this.store.geocodingBookmarksEnabled 89 90 } 90 91 91 - async setGeocodingEnabled(value: boolean): Promise<void> { 92 - await this.store.setGeocodingEnabled(value) 92 + async setGeocodingBookmarksEnabled(value: boolean): Promise<void> { 93 + await this.store.setGeocodingBookmarksEnabled(value) 94 + this.notify() 95 + } 96 + 97 + get onlineSearchEnabled(): boolean { 98 + return this.store.onlineSearchEnabled 99 + } 100 + 101 + async setOnlineSearchEnabled(value: boolean): Promise<void> { 102 + await this.store.setOnlineSearchEnabled(value) 93 103 this.notify() 104 + } 105 + 106 + get lastView(): LastView | null { 107 + return this.store.lastView 108 + } 109 + 110 + async setLastView(value: LastView): Promise<void> { 111 + await this.store.setLastView(value) 94 112 } 95 113 96 114 // ====== DATA ====== ··· 102 120 }> { 103 121 try { 104 122 return await this.store.exportToFile( 105 - filename ?? `world-export_${Date.now()}`, 123 + filename ?? `maps-export_${Date.now()}`, 106 124 ) 107 125 } catch (error) { 108 126 return {
+1
www/models/schema.ts
··· 2 2 AppState, 3 3 Bookmark, 4 4 BookmarkFolder, 5 + LastView, 5 6 SearchHistoryEntry, 6 7 StoreState, 7 8 } from './schema/v0.ts'
+10 -1
www/models/schema/v0.ts
··· 25 25 }) 26 26 export type BookmarkFolder = z.infer<typeof BookmarkFolder> 27 27 28 + export const LastView = z.object({ 29 + lat: z.number(), 30 + lng: z.number(), 31 + zoom: z.number(), 32 + }) 33 + export type LastView = z.infer<typeof LastView> 34 + 28 35 export const StoreState = z.object({ 29 36 version: z.string().optional(), 30 37 searchHistory: z.array(SearchHistoryEntry).default([]), 31 38 bookmarks: z.array(Bookmark).default([]), 32 39 bookmarkFolders: z.array(BookmarkFolder).default([]), 33 - geocodingEnabled: z.boolean().default(true), 40 + geocodingBookmarksEnabled: z.boolean().default(false), 41 + onlineSearchEnabled: z.boolean().default(true), 42 + lastView: LastView.nullable().default(null), 34 43 }) 35 44 export type StoreState = z.infer<typeof StoreState> 36 45
+26 -5
www/models/store.ts
··· 3 3 import { 4 4 Bookmark, 5 5 BookmarkFolder, 6 + LastView, 6 7 SearchHistoryEntry, 7 8 storeMigrationConfig, 8 9 StoreState, ··· 31 32 return this.#sync.state.data.bookmarkFolders ?? [] 32 33 } 33 34 34 - get geocodingEnabled(): boolean { 35 - return this.#sync.state.data.geocodingEnabled ?? true 35 + get geocodingBookmarksEnabled(): boolean { 36 + return this.#sync.state.data.geocodingBookmarksEnabled ?? true 36 37 } 37 38 38 - async setGeocodingEnabled(value: boolean): Promise<void> { 39 + async setGeocodingBookmarksEnabled(value: boolean): Promise<void> { 39 40 const data = await this.#sync.get() 40 - data.geocodingEnabled = value 41 + data.geocodingBookmarksEnabled = value 42 + await this.#sync.set(data) 43 + } 44 + 45 + get onlineSearchEnabled(): boolean { 46 + return this.#sync.state.data.onlineSearchEnabled ?? true 47 + } 48 + 49 + async setOnlineSearchEnabled(value: boolean): Promise<void> { 50 + const data = await this.#sync.get() 51 + data.onlineSearchEnabled = value 52 + await this.#sync.set(data) 53 + } 54 + 55 + get lastView(): LastView | null { 56 + return this.#sync.state.data.lastView ?? null 57 + } 58 + 59 + async setLastView(value: LastView): Promise<void> { 60 + const data = await this.#sync.get() 61 + data.lastView = value 41 62 await this.#sync.set(data) 42 63 } 43 64 ··· 164 185 path: string 165 186 error?: string 166 187 }> { 167 - return this.#sync.exportToFile(filename ?? 'world-data') 188 + return this.#sync.exportToFile(filename ?? 'maps-data') 168 189 } 169 190 170 191 importFromFile(): Promise<{
+3 -19
www/routes/map.ts
··· 48 48 const container = this.querySelector<HTMLElement>('#map') 49 49 if (!container) return 50 50 51 - const lastView = this.#getLastView() 51 + const lastView = app.lastView 52 52 53 53 this.#map = new maplibregl.Map({ 54 54 container, ··· 117 117 }) 118 118 } 119 119 120 - #getLastView(): { lat: number; lng: number; zoom: number } | null { 121 - try { 122 - const raw = localStorage.getItem('world-last-view') 123 - return raw ? JSON.parse(raw) : null 124 - } catch { 125 - return null 126 - } 127 - } 128 - 129 120 #saveLastView() { 130 121 if (!this.#map) return 131 122 const { lat, lng } = this.#map.getCenter() 132 123 const zoom = this.#map.getZoom() 133 - try { 134 - localStorage.setItem( 135 - 'world-last-view', 136 - JSON.stringify({ lat, lng, zoom }), 137 - ) 138 - } catch { 139 - // storage may be unavailable 140 - } 124 + app.setLastView({ lat, lng, zoom }) 141 125 } 142 126 143 127 #buildPopup( ··· 242 226 .addTo(this.#map) 243 227 this.#marker.togglePopup() 244 228 245 - if (!app.geocodingEnabled) { 229 + if (!app.geocodingBookmarksEnabled) { 246 230 addrEl.remove() 247 231 } else { 248 232 nominatimReverse(lat, lng)
+208 -55
www/routes/search.ts
··· 1 1 import { html, LitElement, type TemplateResult } from 'lit' 2 2 import app from '../models/app.ts' 3 + import { type Bookmark } from '../models/schema.ts' 3 4 import { setMapNav } from '../utils/nav.ts' 4 5 import { coordsFromDirectInput } from '../utils/geocode.ts' 5 6 import { type NominatimResult, nominatimSearch } from '../utils/nominatim.ts' ··· 17 18 return 3 18 19 } 19 20 20 - 21 21 export class SearchPage extends LitElement { 22 22 private history = app.searchHistory 23 + private inputValue = '' 24 + private bookmarkResults: Bookmark[] = [] 23 25 private results: NominatimResult[] = [] 24 26 private loading = false 25 27 private error: string | null = null ··· 52 54 return 53 55 } 54 56 57 + const q = query.toLowerCase() 58 + this.bookmarkResults = app.bookmarks.filter((b) => 59 + b.name.toLowerCase().includes(q) || 60 + b.address?.toLowerCase().includes(q) 61 + ) 62 + 63 + if (!app.onlineSearchEnabled) { 64 + this.loading = false 65 + this.error = null 66 + this.results = [] 67 + this.requestUpdate() 68 + return 69 + } 70 + 55 71 this.loading = true 56 72 this.error = null 57 73 this.results = [] ··· 76 92 await this.#search(query) 77 93 } 78 94 95 + #handleBookmarkClick = (bookmark: Bookmark) => { 96 + setMapNav({ lat: bookmark.lat, lng: bookmark.lng, zoom: bookmark.zoom }) 97 + location.hash = '#!/' 98 + } 99 + 79 100 #handleResultClick = (result: NominatimResult) => { 80 101 const shortName = result.display_name.split(',')[0].trim() 81 102 setMapNav({ ··· 89 110 location.hash = '#!/' 90 111 } 91 112 113 + #handleInput = (e: Event) => { 114 + this.inputValue = (e.target as HTMLInputElement).value 115 + this.bookmarkResults = [] 116 + this.results = [] 117 + this.error = null 118 + this.loading = false 119 + this.requestUpdate() 120 + } 121 + 92 122 #handleHistoryClick = async (query: string) => { 123 + this.inputValue = query 93 124 const input = this.querySelector<HTMLInputElement>('input[type="search"]') 94 125 if (input) input.value = query 95 126 await this.#search(query) ··· 108 139 autocomplete="off" 109 140 autocorrect="off" 110 141 spellcheck="false" 142 + @input="${this.#handleInput}" 111 143 > 112 144 </form> 113 145 114 - ${this.loading 115 - ? html` 116 - <p class="search-empty">Searching…</p> 117 - ` 118 - : this.error 119 - ? html` 120 - <p class="search-empty">${this.error}</p> 121 - ` 122 - : this.results.length > 0 123 - ? html` 124 - <div class="search-history-list" role="list"> 125 - ${this.results.map((result) => 126 - html` 127 - <button 128 - class="search-history-item" 129 - role="listitem" 130 - @click="${() => this.#handleResultClick(result)}" 131 - > 132 - <span>${result.display_name}</span> 133 - <img 134 - src="/static/icons/navigation.svg" 135 - alt="" 136 - aria-hidden="true" 137 - style="width:16px;height:16px;opacity:0.4" 146 + ${(() => { 147 + const q = this.inputValue.trim().toLowerCase() 148 + const predBookmarks = q && !this.loading && !this.error && 149 + this.bookmarkResults.length === 0 && this.results.length === 0 150 + ? app.bookmarks.filter((b) => 151 + b.name.toLowerCase().includes(q) || 152 + b.address?.toLowerCase().includes(q) 153 + ) 154 + : [] 155 + const predHistory = q && !this.loading && !this.error && 156 + this.bookmarkResults.length === 0 && this.results.length === 0 157 + ? this.history.filter((e) => e.query.toLowerCase().includes(q)) 158 + : [] 159 + 160 + if (this.loading) { 161 + return html` 162 + ${this.bookmarkResults.length > 0 163 + ? html` 164 + <div class="search-history-list" role="list"> 165 + ${this.bookmarkResults.map((b) => 166 + html` 167 + <button 168 + class="search-history-item" 169 + role="listitem" 170 + @click="${() => this.#handleBookmarkClick(b)}" 171 + > 172 + <span>${b.name}</span> 173 + <img 174 + src="/static/icons/bookmark.svg" 175 + alt="Bookmark" 176 + aria-hidden="true" 177 + style="width:16px;height:16px" 178 + > 179 + </button> 180 + ` 181 + )} 182 + </div> 183 + ` 184 + : html` 185 + 186 + `} 187 + <p class="search-empty">Searching…</p> 188 + ` 189 + } 190 + 191 + if (this.error) { 192 + return html` 193 + <p class="search-empty">${this.error}</p> 194 + ` 195 + } 196 + 197 + if (this.bookmarkResults.length > 0 || this.results.length > 0) { 198 + return html` 199 + <div class="search-history-list" role="list"> 200 + ${this.bookmarkResults.map((b) => 201 + html` 202 + <button 203 + class="search-history-item" 204 + role="listitem" 205 + @click="${() => this.#handleBookmarkClick(b)}" 206 + > 207 + <span>${b.name}</span> 208 + <img 209 + src="/static/icons/bookmark.svg" 210 + alt="Bookmark" 211 + aria-hidden="true" 212 + style="width:16px;height:16px" 213 + > 214 + </button> 215 + ` 216 + )} ${this.results.map((result) => 217 + html` 218 + <button 219 + class="search-history-item" 220 + role="listitem" 221 + @click="${() => this.#handleResultClick(result)}" 138 222 > 139 - </button> 223 + <span>${result.display_name}</span> 224 + <img 225 + src="/static/icons/navigation.svg" 226 + alt="" 227 + aria-hidden="true" 228 + style="width:16px;height:16px" 229 + > 230 + </button> 231 + ` 232 + )} 233 + </div> 234 + ` 235 + } 236 + 237 + if (predBookmarks.length > 0 || predHistory.length > 0) { 238 + return html` 239 + ${predBookmarks.length > 0 240 + ? html` 241 + <p class="search-section-title">Bookmarks</p> 242 + <div class="search-history-list" role="list"> 243 + ${predBookmarks.map((b) => 244 + html` 245 + <button 246 + class="search-history-item" 247 + role="listitem" 248 + @click="${() => this.#handleBookmarkClick(b)}" 249 + > 250 + <span>${b.name}</span> 251 + <img 252 + src="/static/icons/bookmark.svg" 253 + alt="Bookmark" 254 + aria-hidden="true" 255 + style="width:16px;height:16px" 256 + > 257 + </button> 258 + ` 259 + )} 260 + </div> 261 + ` 262 + : html` 263 + 264 + `} ${predHistory.length > 0 265 + ? html` 266 + <p class="search-section-title">Recent Searches</p> 267 + <div class="search-history-list" role="list"> 268 + ${predHistory.map((entry) => 269 + html` 270 + <button 271 + class="search-history-item" 272 + role="listitem" 273 + @click="${() => this.#handleHistoryClick(entry.query)}" 274 + > 275 + <span>${entry.query}</span> 276 + <img 277 + src="/static/icons/navigation.svg" 278 + alt="" 279 + aria-hidden="true" 280 + style="width:16px;height:16px" 281 + > 282 + </button> 283 + ` 284 + )} 285 + </div> 140 286 ` 141 - )} 142 - </div> 143 - ` 144 - : this.history.length > 0 145 - ? html` 146 - <div class="search-history-clear"> 147 - <button @click="${this.#handleClearHistory}">Clear history</button> 148 - </div> 149 - <div class="search-history-list" role="list"> 150 - ${this.history.map((entry) => 151 - html` 152 - <button 153 - class="search-history-item" 154 - role="listitem" 155 - @click="${() => this.#handleHistoryClick(entry.query)}" 156 - > 157 - <span>${entry.query}</span> 158 - <img 159 - src="/static/icons/navigation.svg" 160 - alt="" 161 - aria-hidden="true" 162 - style="width:16px;height:16px;opacity:0.4" 287 + : html` 288 + 289 + `} 290 + ` 291 + } 292 + 293 + if (this.history.length > 0) { 294 + return html` 295 + <div class="search-history-clear"> 296 + <button @click="${this 297 + .#handleClearHistory}">Clear history</button> 298 + </div> 299 + <div class="search-history-list" role="list"> 300 + ${this.history.map((entry) => 301 + html` 302 + <button 303 + class="search-history-item" 304 + role="listitem" 305 + @click="${() => this.#handleHistoryClick(entry.query)}" 163 306 > 164 - </button> 165 - ` 166 - )} 167 - </div> 168 - ` 169 - : html` 307 + <span>${entry.query}</span> 308 + <img 309 + src="/static/icons/navigation.svg" 310 + alt="" 311 + aria-hidden="true" 312 + style="width:16px;height:16px" 313 + > 314 + </button> 315 + ` 316 + )} 317 + </div> 318 + ` 319 + } 320 + 321 + return html` 170 322 <p class="search-empty">Search for a location to get started.</p> 171 - `} 323 + ` 324 + })()} 172 325 ` 173 326 } 174 327 }
+2 -2
www/routes/settings-about.ts
··· 8 8 override render(): TemplateResult { 9 9 return html` 10 10 <section> 11 - <h2>World</h2> 11 + <h2>MapsApp</h2> 12 12 <p> 13 13 An offline-first map PWA. Browse detailed street maps and natural features 14 14 without an internet connection once tiles are downloaded. ··· 19 19 <section> 20 20 <h2>Map Data</h2> 21 21 <p> 22 - World overview tiles are derived from 22 + MapsApp overview tiles are derived from 23 23 <a 24 24 href="https://www.naturalearthdata.com" 25 25 rel="noopener noreferrer"
+23 -5
www/routes/settings.ts
··· 66 66 <h2>Privacy</h2> 67 67 <label class="settings-toggle-label"> 68 68 <div> 69 - <span>Location lookup</span> 69 + <span>Online search</span> 70 70 <div class="settings-nav-link-meta"> 71 - When dropping a pin, reverse-geocode coordinates using 72 - OpenStreetMap's Nominatim service to suggest a place name 71 + Search for places using OpenStreetMap's Nominatim service. Disable to 72 + rely only on your saved bookmarks. 73 73 </div> 74 74 </div> 75 75 <input 76 76 type="checkbox" 77 - .checked="${app.geocodingEnabled}" 77 + .checked="${app.onlineSearchEnabled}" 78 + @change="${this.#handleOnlineSearchToggle}" 79 + > 80 + </label> 81 + <label class="settings-toggle-label"> 82 + <div> 83 + <span>Location lookup on Bookmark</span> 84 + <div class="settings-nav-link-meta"> 85 + When dropping a pin, reverse-geocode coordinates using OpenStreetMap's 86 + Nominatim service to suggest a place name 87 + </div> 88 + </div> 89 + <input 90 + type="checkbox" 91 + .checked="${app.geocodingBookmarksEnabled}" 78 92 @change="${this.#handleGeocodingToggle}" 79 93 > 80 94 </label> ··· 100 114 ` 101 115 } 102 116 117 + #handleOnlineSearchToggle(e: Event): void { 118 + app.setOnlineSearchEnabled((e.target as HTMLInputElement).checked) 119 + } 120 + 103 121 #handleGeocodingToggle(e: Event): void { 104 - app.setGeocodingEnabled((e.target as HTMLInputElement).checked) 122 + app.setGeocodingBookmarksEnabled((e.target as HTMLInputElement).checked) 105 123 } 106 124 107 125 #setStatus(msg: string, isError = false): void {
+33 -9
www/static/styles/theme.css
··· 292 292 /* ── Search page ────────────────────────────────────────────────────────── */ 293 293 294 294 .search-input-wrap { 295 - margin-bottom: var(--s4); 295 + margin-bottom: var(--s3); 296 296 } 297 297 298 298 .search-input-wrap input[type='search'] { ··· 304 304 margin: 0; 305 305 } 306 306 307 + .search-section-title { 308 + font-size: var(--f6); 309 + font-weight: var(--fw-semibold); 310 + opacity: 0.45; 311 + text-transform: uppercase; 312 + letter-spacing: 0.06em; 313 + margin: var(--s3) 0 var(--s1) 0; 314 + padding: 0; 315 + } 316 + 307 317 .search-history-list { 308 318 display: flex; 309 319 flex-direction: column; 320 + gap: var(--s2); 310 321 } 311 322 312 323 .search-history-item { 313 324 display: flex; 314 325 align-items: center; 315 326 justify-content: space-between; 316 - padding: var(--s3); 317 - border-bottom: 1px solid currentColor; 318 - gap: var(--s2); 327 + padding: var(--s3) var(--s3); 328 + gap: var(--s3); 319 329 cursor: pointer; 320 330 background: transparent; 321 - border-left: none; 322 - border-right: none; 323 - border-top: none; 331 + border: 1px solid currentColor; 332 + border-radius: var(--br-base); 324 333 color: inherit; 325 334 text-align: left; 326 335 font-size: var(--f5); 327 336 width: 100%; 337 + min-width: 0; 338 + transition: opacity var(--transition-fast); 328 339 } 329 340 330 341 .search-history-item:hover { 331 - opacity: 0.7; 342 + opacity: 0.6; 332 343 transform: none; 333 344 } 334 345 346 + .search-history-item > span { 347 + flex: 1; 348 + min-width: 0; 349 + overflow: hidden; 350 + text-overflow: ellipsis; 351 + white-space: nowrap; 352 + } 353 + 354 + .search-history-item > img { 355 + flex-shrink: 0; 356 + opacity: 0.35; 357 + } 358 + 335 359 .search-empty { 336 360 padding: var(--s5) var(--s3); 337 361 text-align: center; ··· 342 366 .search-history-clear { 343 367 display: flex; 344 368 justify-content: flex-end; 345 - padding: var(--s2) 0; 369 + padding: var(--s1) 0 var(--s2); 346 370 } 347 371 348 372 /* ── Bookmarks page ─────────────────────────────────────────────────────── */