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 search and bookmarks

+199 -41
+10 -1
deno.lock
··· 300 300 "npm:maplibre-gl@^5.21.0", 301 301 "npm:pmtiles-offline@1", 302 302 "npm:pmtiles@^4.4.0" 303 - ] 303 + ], 304 + "members": { 305 + "data": { 306 + "dependencies": [ 307 + "jsr:@cliffy/command@1", 308 + "jsr:@std/fs@1", 309 + "jsr:@std/path@^1.1.4" 310 + ] 311 + } 312 + } 304 313 } 305 314 }
+10 -10
www/models/store.ts
··· 20 20 } 21 21 22 22 get searchHistory(): SearchHistoryEntry[] { 23 - return this.#sync.state.data.searchHistory 23 + return this.#sync.state.data.searchHistory ?? [] 24 24 } 25 25 26 26 get bookmarks(): Bookmark[] { 27 - return this.#sync.state.data.bookmarks 27 + return this.#sync.state.data.bookmarks ?? [] 28 28 } 29 29 30 30 get bookmarkFolders(): BookmarkFolder[] { 31 - return this.#sync.state.data.bookmarkFolders 31 + return this.#sync.state.data.bookmarkFolders ?? [] 32 32 } 33 33 34 34 addEventListener(fn: () => void): void { ··· 45 45 const trimmed = query.trim() 46 46 if (!trimmed) return 47 47 const data = await this.#sync.get() 48 - const existing = data.searchHistory.filter((e) => e.query !== trimmed) 48 + const existing = (data.searchHistory ?? []).filter((e) => e.query !== trimmed) 49 49 data.searchHistory = [ 50 50 SearchHistoryEntry.parse({ query: trimmed, timestamp: new Date().toISOString() }), 51 51 ...existing, ··· 70 70 ): Promise<void> { 71 71 const data = await this.#sync.get() 72 72 data.bookmarks = [ 73 - ...data.bookmarks, 73 + ...(data.bookmarks ?? []), 74 74 Bookmark.parse({ 75 75 id: crypto.randomUUID(), 76 76 name, ··· 86 86 87 87 async deleteBookmark(id: string): Promise<void> { 88 88 const data = await this.#sync.get() 89 - data.bookmarks = data.bookmarks.filter((b) => b.id !== id) 89 + data.bookmarks = (data.bookmarks ?? []).filter((b) => b.id !== id) 90 90 await this.#sync.set(data) 91 91 } 92 92 93 93 async moveBookmark(id: string, folderId: string | null): Promise<void> { 94 94 const data = await this.#sync.get() 95 - const idx = data.bookmarks.findIndex((b) => b.id === id) 95 + const idx = (data.bookmarks ?? []).findIndex((b) => b.id === id) 96 96 if (idx === -1) return 97 97 data.bookmarks[idx] = { ...data.bookmarks[idx], folderId } 98 98 await this.#sync.set(data) ··· 103 103 async addFolder(name: string): Promise<void> { 104 104 const data = await this.#sync.get() 105 105 data.bookmarkFolders = [ 106 - ...data.bookmarkFolders, 106 + ...(data.bookmarkFolders ?? []), 107 107 BookmarkFolder.parse({ 108 108 id: crypto.randomUUID(), 109 109 name, ··· 115 115 116 116 async deleteFolder(id: string): Promise<void> { 117 117 const data = await this.#sync.get() 118 - data.bookmarkFolders = data.bookmarkFolders.filter((f) => f.id !== id) 118 + data.bookmarkFolders = (data.bookmarkFolders ?? []).filter((f) => f.id !== id) 119 119 // unfiled bookmarks that were in this folder 120 - data.bookmarks = data.bookmarks.map((b) => 120 + data.bookmarks = (data.bookmarks ?? []).map((b) => 121 121 b.folderId === id ? { ...b, folderId: null } : b 122 122 ) 123 123 await this.#sync.set(data)
+60
www/routes/map.ts
··· 5 5 import { getMapNav, setMapNav } from '../utils/nav.ts' 6 6 import layers from '../utils/layers.ts' 7 7 import worldLayers from '../utils/world_layers.ts' 8 + import app from '../models/app.ts' 8 9 9 10 const protocol = new Protocol() 10 11 maplibregl.addProtocol('pmtiles', protocol.tile.bind(protocol)) ··· 19 20 20 21 export class MapPage extends LitElement { 21 22 #map: maplibregl.Map | null = null 23 + #marker: maplibregl.Marker | null = null 24 + #bookmarkMarkers: maplibregl.Marker[] = [] 22 25 23 26 protected override createRenderRoot() { 24 27 return this 25 28 } 26 29 30 + override connectedCallback() { 31 + super.connectedCallback() 32 + app.addEventListener(this.#onAppUpdate) 33 + } 34 + 35 + #onAppUpdate = () => { 36 + this.#renderBookmarkMarkers() 37 + } 38 + 27 39 override render(): TemplateResult { 28 40 return html`<div id="map"></div>` 29 41 } ··· 48 60 this.#map.on('load', async () => { 49 61 await this.#loadWorldTiles() 50 62 await this.#loadCachedDetailTiles() 63 + this.#renderBookmarkMarkers() 51 64 const target = getMapNav() 52 65 if (target) { 53 66 setMapNav(null) 54 67 this.#map!.flyTo({ center: [target.lng, target.lat], zoom: target.zoom }) 68 + if (target.marker) { 69 + this.#marker?.remove() 70 + this.#marker = new maplibregl.Marker() 71 + .setLngLat([target.lng, target.lat]) 72 + .setPopup(this.#buildPopup(target.name ?? 'Unknown location', target.lat, target.lng, target.zoom)) 73 + .addTo(this.#map!) 74 + this.#marker.togglePopup() 75 + } 55 76 } 56 77 }) 57 78 } 58 79 80 + #buildPopup(name: string, lat: number, lng: number, zoom: number): maplibregl.Popup { 81 + const container = document.createElement('div') 82 + 83 + const label = document.createElement('p') 84 + label.style.cssText = 'margin:0 0 8px;font-size:0.85em;max-width:200px;word-break:break-word' 85 + label.textContent = name 86 + 87 + const button = document.createElement('button') 88 + button.textContent = 'Add to bookmarks' 89 + 90 + button.addEventListener('click', async () => { 91 + button.disabled = true 92 + await app.addBookmark(name, lat, lng, zoom) 93 + button.textContent = 'Saved!' 94 + }) 95 + 96 + container.append(label, button) 97 + return new maplibregl.Popup({ offset: 25 }).setDOMContent(container) 98 + } 99 + 100 + #renderBookmarkMarkers() { 101 + if (!this.#map) return 102 + this.#bookmarkMarkers.forEach((m) => m.remove()) 103 + this.#bookmarkMarkers = [] 104 + for (const b of app.bookmarks) { 105 + const popup = new maplibregl.Popup({ offset: 25 }).setHTML(`<strong>${b.name}</strong>`) 106 + const marker = new maplibregl.Marker({ color: '#e05c2a' }) 107 + .setLngLat([b.lng, b.lat]) 108 + .setPopup(popup) 109 + .addTo(this.#map) 110 + this.#bookmarkMarkers.push(marker) 111 + } 112 + } 113 + 59 114 override disconnectedCallback() { 60 115 super.disconnectedCallback() 116 + app.removeEventListener(this.#onAppUpdate) 117 + this.#bookmarkMarkers.forEach((m) => m.remove()) 118 + this.#bookmarkMarkers = [] 119 + this.#marker?.remove() 120 + this.#marker = null 61 121 this.#map?.remove() 62 122 this.#map = null 63 123 }
+118 -29
www/routes/search.ts
··· 1 1 import { html, LitElement, type TemplateResult } from 'lit' 2 2 import app from '../models/app.ts' 3 + import { setMapNav } from '../utils/nav.ts' 4 + 5 + interface NominatimResult { 6 + place_id: number 7 + display_name: string 8 + lat: string 9 + lon: string 10 + boundingbox: [string, string, string, string] 11 + } 12 + 13 + function zoomFromBoundingbox(bb: [string, string, string, string]): number { 14 + const latSpan = Math.abs(parseFloat(bb[1]) - parseFloat(bb[0])) 15 + const lngSpan = Math.abs(parseFloat(bb[3]) - parseFloat(bb[2])) 16 + const span = Math.max(latSpan, lngSpan) 17 + if (span < 0.01) return 15 18 + if (span < 0.05) return 13 19 + if (span < 0.2) return 11 20 + if (span < 1) return 9 21 + if (span < 5) return 7 22 + if (span < 20) return 5 23 + return 3 24 + } 25 + 26 + async function geocode(query: string): Promise<NominatimResult[]> { 27 + const url = new URL('https://nominatim.openstreetmap.org/search') 28 + url.searchParams.set('q', query) 29 + url.searchParams.set('format', 'json') 30 + url.searchParams.set('limit', '5') 31 + const res = await fetch(url.toString(), { 32 + headers: { 'Accept-Language': navigator.language || 'en' }, 33 + }) 34 + if (!res.ok) throw new Error(`Nominatim error: ${res.status}`) 35 + return res.json() as Promise<NominatimResult[]> 36 + } 3 37 4 38 export class SearchPage extends LitElement { 5 39 private history = app.searchHistory 40 + private results: NominatimResult[] = [] 41 + private loading = false 42 + private error: string | null = null 6 43 7 44 protected override createRenderRoot() { 8 45 return this ··· 24 61 this.requestUpdate() 25 62 } 26 63 64 + #search = async (query: string) => { 65 + this.loading = true 66 + this.error = null 67 + this.results = [] 68 + this.requestUpdate() 69 + try { 70 + this.results = await geocode(query) 71 + await app.addSearch(query) 72 + } catch { 73 + this.error = 'Search failed. Please try again.' 74 + } finally { 75 + this.loading = false 76 + this.requestUpdate() 77 + } 78 + } 79 + 27 80 #handleSearch = async (e: Event) => { 28 81 e.preventDefault() 29 82 const form = e.target as HTMLFormElement 30 83 const input = form.querySelector<HTMLInputElement>('input[type="search"]') 31 84 const query = input?.value.trim() 32 85 if (!query) return 33 - await app.addSearch(query) 34 - // TODO: perform geocoding and navigate map to result 86 + await this.#search(query) 87 + } 88 + 89 + #handleResultClick = (result: NominatimResult) => { 90 + setMapNav({ 91 + lat: parseFloat(result.lat), 92 + lng: parseFloat(result.lon), 93 + zoom: zoomFromBoundingbox(result.boundingbox), 94 + marker: true, 95 + name: result.display_name, 96 + }) 97 + location.hash = '#!/' 35 98 } 36 99 37 - #handleHistoryClick = (query: string) => { 38 - // TODO: navigate map to previously searched location 100 + #handleHistoryClick = async (query: string) => { 39 101 const input = this.querySelector<HTMLInputElement>('input[type="search"]') 40 102 if (input) input.value = query 103 + await this.#search(query) 41 104 } 42 105 43 106 #handleClearHistory = async () => { ··· 56 119 > 57 120 </form> 58 121 59 - ${this.history.length > 0 60 - ? html` 61 - <div class="search-history-clear"> 62 - <button @click="${this.#handleClearHistory}">Clear history</button> 63 - </div> 64 - <div class="search-history-list" role="list"> 65 - ${this.history.map((entry) => 66 - html` 67 - <button 68 - class="search-history-item" 69 - role="listitem" 70 - @click="${() => this.#handleHistoryClick(entry.query)}" 71 - > 72 - <span>${entry.query}</span> 73 - <img 74 - src="/static/icons/navigation.svg" 75 - alt="" 76 - aria-hidden="true" 77 - style="width:16px;height:16px;opacity:0.4" 78 - > 79 - </button> 122 + ${this.loading 123 + ? html`<p class="search-empty">Searching…</p>` 124 + : this.error 125 + ? html`<p class="search-empty">${this.error}</p>` 126 + : this.results.length > 0 127 + ? html` 128 + <div class="search-history-list" role="list"> 129 + ${this.results.map((result) => 130 + html` 131 + <button 132 + class="search-history-item" 133 + role="listitem" 134 + @click="${() => this.#handleResultClick(result)}" 135 + > 136 + <span>${result.display_name}</span> 137 + <img 138 + src="/static/icons/navigation.svg" 139 + alt="" 140 + aria-hidden="true" 141 + style="width:16px;height:16px;opacity:0.4" 142 + > 143 + </button> 144 + ` 145 + )} 146 + </div> 147 + ` 148 + : this.history.length > 0 149 + ? html` 150 + <div class="search-history-clear"> 151 + <button @click="${this.#handleClearHistory}">Clear history</button> 152 + </div> 153 + <div class="search-history-list" role="list"> 154 + ${this.history.map((entry) => 155 + html` 156 + <button 157 + class="search-history-item" 158 + role="listitem" 159 + @click="${() => this.#handleHistoryClick(entry.query)}" 160 + > 161 + <span>${entry.query}</span> 162 + <img 163 + src="/static/icons/navigation.svg" 164 + alt="" 165 + aria-hidden="true" 166 + style="width:16px;height:16px;opacity:0.4" 167 + > 168 + </button> 169 + ` 170 + )} 171 + </div> 80 172 ` 81 - )} 82 - </div> 83 - ` 84 - : html`<p class="search-empty">Search for a location to get started.</p>`} 173 + : html`<p class="search-empty">Search for a location to get started.</p>`} 85 174 ` 86 175 } 87 176 }
+1 -1
www/utils/nav.ts
··· 1 - export type MapTarget = { lat: number; lng: number; zoom: number } 1 + export type MapTarget = { lat: number; lng: number; zoom: number; marker?: boolean; name?: string } 2 2 3 3 let pending: MapTarget | null = null 4 4