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: support long pluscodes, homepage bookmarks

+243 -41
+11
www/models/app.ts
··· 82 82 this.notify() 83 83 } 84 84 85 + // ====== PREFERENCES ====== 86 + 87 + get geocodingEnabled(): boolean { 88 + return this.store.geocodingEnabled 89 + } 90 + 91 + async setGeocodingEnabled(value: boolean): Promise<void> { 92 + await this.store.setGeocodingEnabled(value) 93 + this.notify() 94 + } 95 + 85 96 // ====== DATA ====== 86 97 87 98 async exportStore(filename?: string): Promise<{
+1
www/models/schema/v0.ts
··· 30 30 searchHistory: z.array(SearchHistoryEntry).default([]), 31 31 bookmarks: z.array(Bookmark).default([]), 32 32 bookmarkFolders: z.array(BookmarkFolder).default([]), 33 + geocodingEnabled: z.boolean().default(true), 33 34 }) 34 35 export type StoreState = z.infer<typeof StoreState> 35 36
+10
www/models/store.ts
··· 31 31 return this.#sync.state.data.bookmarkFolders ?? [] 32 32 } 33 33 34 + get geocodingEnabled(): boolean { 35 + return this.#sync.state.data.geocodingEnabled ?? true 36 + } 37 + 38 + async setGeocodingEnabled(value: boolean): Promise<void> { 39 + const data = await this.#sync.get() 40 + data.geocodingEnabled = value 41 + await this.#sync.set(data) 42 + } 43 + 34 44 addEventListener(fn: () => void): void { 35 45 this.#sync.addEventListener(fn) 36 46 }
+92
www/routes/map.ts
··· 6 6 import layers from '../utils/layers.ts' 7 7 import worldLayers from '../utils/world_layers.ts' 8 8 import app from '../models/app.ts' 9 + import { nominatimReverse } from '../utils/nominatim.ts' 9 10 10 11 const protocol = new Protocol() 11 12 maplibregl.addProtocol('pmtiles', protocol.tile.bind(protocol)) ··· 22 23 #map: maplibregl.Map | null = null 23 24 #marker: maplibregl.Marker | null = null 24 25 #bookmarkMarkers: maplibregl.Marker[] = [] 26 + #longPressTimer: ReturnType<typeof setTimeout> | null = null 25 27 26 28 protected override createRenderRoot() { 27 29 return this ··· 70 72 ) 71 73 72 74 this.#map.on('moveend', () => this.#saveLastView()) 75 + 76 + this.#map.on('contextmenu', (e) => { 77 + this.#placePin(e.lngLat) 78 + }) 79 + 80 + this.#map.on('touchstart', (e) => { 81 + if (e.originalEvent.touches.length !== 1) return 82 + const lngLat = e.lngLat 83 + this.#longPressTimer = setTimeout(() => this.#placePin(lngLat), 500) 84 + }) 85 + this.#map.on('touchend', () => this.#clearLongPress()) 86 + this.#map.on('touchmove', () => this.#clearLongPress()) 87 + this.#map.on('drag', () => this.#clearLongPress()) 73 88 74 89 this.#map.on('load', async () => { 75 90 await this.#loadWorldTiles() ··· 171 186 return new maplibregl.Popup({ offset: 25 }).setDOMContent(container) 172 187 } 173 188 189 + #clearLongPress() { 190 + if (this.#longPressTimer) { 191 + clearTimeout(this.#longPressTimer) 192 + this.#longPressTimer = null 193 + } 194 + } 195 + 196 + #placePin(lngLat: maplibregl.LngLat) { 197 + if (!this.#map) return 198 + const { lat, lng } = lngLat 199 + const zoom = this.#map.getZoom() 200 + 201 + const container = document.createElement('div') 202 + container.style.cssText = 203 + 'display:flex;flex-direction:column;gap:6px;min-width:180px' 204 + 205 + const addrEl = document.createElement('p') 206 + addrEl.style.cssText = 207 + 'margin:0;font-size:0.75em;opacity:0.6;word-break:break-word;max-width:220px' 208 + addrEl.textContent = 'Looking up location…' 209 + container.append(addrEl) 210 + 211 + const input = document.createElement('input') 212 + input.type = 'text' 213 + input.value = `${lat.toFixed(5)}, ${lng.toFixed(5)}` 214 + input.placeholder = 'Bookmark name' 215 + input.style.cssText = 'display:block;width:100%;box-sizing:border-box' 216 + 217 + const button = document.createElement('button') 218 + button.textContent = 'Add to bookmarks' 219 + button.style.cssText = 'width:100%' 220 + 221 + let resolvedAddress: string | undefined 222 + 223 + button.addEventListener('click', async () => { 224 + button.disabled = true 225 + await app.addBookmark( 226 + input.value.trim() || `${lat.toFixed(5)}, ${lng.toFixed(5)}`, 227 + lat, 228 + lng, 229 + zoom, 230 + null, 231 + resolvedAddress, 232 + ) 233 + button.textContent = 'Saved!' 234 + }) 235 + 236 + container.append(input, button) 237 + 238 + this.#marker?.remove() 239 + this.#marker = new maplibregl.Marker() 240 + .setLngLat([lng, lat]) 241 + .setPopup(new maplibregl.Popup({ offset: 25 }).setDOMContent(container)) 242 + .addTo(this.#map) 243 + this.#marker.togglePopup() 244 + 245 + if (!app.geocodingEnabled) { 246 + addrEl.remove() 247 + } else { 248 + nominatimReverse(lat, lng) 249 + .then((result) => { 250 + if (!result?.display_name) { 251 + addrEl.remove() 252 + return 253 + } 254 + resolvedAddress = result.display_name 255 + addrEl.textContent = result.display_name 256 + const coordsPlaceholder = `${lat.toFixed(5)}, ${lng.toFixed(5)}` 257 + if (input.value === coordsPlaceholder) { 258 + input.value = result.display_name.split(',')[0].trim() 259 + } 260 + }) 261 + .catch(() => addrEl.remove()) 262 + } 263 + } 264 + 174 265 #renderBookmarkMarkers() { 175 266 if (!this.#map) return 176 267 this.#bookmarkMarkers.forEach((m) => m.remove()) ··· 190 281 override disconnectedCallback() { 191 282 super.disconnectedCallback() 192 283 app.removeEventListener(this.#onAppUpdate) 284 + this.#clearLongPress() 193 285 this.#bookmarkMarkers.forEach((m) => m.remove()) 194 286 this.#bookmarkMarkers = [] 195 287 this.#marker?.remove()
+3 -21
www/routes/search.ts
··· 2 2 import app from '../models/app.ts' 3 3 import { setMapNav } from '../utils/nav.ts' 4 4 import { coordsFromDirectInput } from '../utils/geocode.ts' 5 - 6 - interface NominatimResult { 7 - place_id: number 8 - display_name: string 9 - lat: string 10 - lon: string 11 - boundingbox: [string, string, string, string] 12 - } 5 + import { type NominatimResult, nominatimSearch } from '../utils/nominatim.ts' 13 6 14 7 function zoomFromBoundingbox(bb: [string, string, string, string]): number { 15 8 const latSpan = Math.abs(parseFloat(bb[1]) - parseFloat(bb[0])) ··· 24 17 return 3 25 18 } 26 19 27 - async function geocode(query: string): Promise<NominatimResult[]> { 28 - const url = new URL('https://nominatim.openstreetmap.org/search') 29 - url.searchParams.set('q', query) 30 - url.searchParams.set('format', 'json') 31 - url.searchParams.set('limit', '5') 32 - const res = await fetch(url.toString(), { 33 - headers: { 'Accept-Language': navigator.language || 'en' }, 34 - }) 35 - if (!res.ok) throw new Error(`Nominatim error: ${res.status}`) 36 - return res.json() as Promise<NominatimResult[]> 37 - } 38 20 39 21 export class SearchPage extends LitElement { 40 22 private history = app.searchHistory ··· 63 45 } 64 46 65 47 #search = async (query: string) => { 66 - const direct = coordsFromDirectInput(query) 48 + const direct = await coordsFromDirectInput(query) 67 49 if (direct) { 68 50 setMapNav({ ...direct, marker: true, name: query }) 69 51 location.hash = '#!/' ··· 75 57 this.results = [] 76 58 this.requestUpdate() 77 59 try { 78 - this.results = await geocode(query) 60 + this.results = await nominatimSearch(query) 79 61 await app.addSearch(query) 80 62 } catch { 81 63 this.error = 'Search failed. Please try again.'
+34
www/routes/settings.ts
··· 6 6 return this 7 7 } 8 8 9 + #onAppUpdate = () => this.requestUpdate() 10 + 11 + override connectedCallback() { 12 + super.connectedCallback() 13 + app.addEventListener(this.#onAppUpdate) 14 + } 15 + 16 + override disconnectedCallback() { 17 + super.disconnectedCallback() 18 + app.removeEventListener(this.#onAppUpdate) 19 + } 20 + 9 21 override render(): TemplateResult { 10 22 return html` 11 23 <section> ··· 51 63 </section> 52 64 53 65 <section> 66 + <h2>Privacy</h2> 67 + <label class="settings-toggle-label"> 68 + <div> 69 + <span>Location lookup</span> 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 73 + </div> 74 + </div> 75 + <input 76 + type="checkbox" 77 + .checked="${app.geocodingEnabled}" 78 + @change="${this.#handleGeocodingToggle}" 79 + > 80 + </label> 81 + </section> 82 + 83 + <section> 54 84 <h2>Info</h2> 55 85 <nav class="settings-nav-list"> 56 86 <a class="settings-nav-link" href="#!/settings/about"> ··· 68 98 </nav> 69 99 </section> 70 100 ` 101 + } 102 + 103 + #handleGeocodingToggle(e: Event): void { 104 + app.setGeocodingEnabled((e.target as HTMLInputElement).checked) 71 105 } 72 106 73 107 #setStatus(msg: string, isError = false): void {
+53 -20
www/utils/geocode.ts
··· 1 1 import OpenLocationCode from './open_location_code.ts' 2 2 import parseGeoUri from './parse_geouri.ts' 3 + import { nominatimReverse, nominatimSearch } from './nominatim.ts' 3 4 4 5 export type DirectCoords = { lat: number; lng: number; zoom: number } 5 6 6 - export function coordsFromDirectInput(query: string): DirectCoords | null { 7 - console.log(coordsFromPlusCode(query)) 7 + export async function coordsFromDirectInput( 8 + query: string, 9 + ): Promise<DirectCoords | null> { 8 10 return coordsFromGeoUri(query) ?? coordsFromPlusCode(query) ?? 9 - coordsFromMapUrl(query) 11 + coordsFromMapUrl(query) ?? await coordsFromLongPlusCode(query) 10 12 } 11 13 12 14 /** ··· 25 27 * - If a place isn't specific coordinates (an area), google doesn't assign pluscode 26 28 */ 27 29 28 - const baseNom = 'https://nominatim.openstreetmap.org/reverse' 29 - 30 30 export default async function getDataFromMapsUrl(url: string) { 31 31 let resp 32 32 ··· 47 47 } 48 48 49 49 if (resp?.lon && resp?.lat && !resp?.display_name?.length) { 50 - const nomurl = `${baseNom}?lat=${resp.lat}&lon=${resp.lon}&format=json` 51 - return await (await fetch(nomurl, { 52 - headers: { 'Accept-Language': navigator.language || 'en' }, 53 - })).json() 50 + return await nominatimReverse(resp.lat, resp.lon) 54 51 } 55 52 56 53 return resp ··· 124 121 return { lat: coords.latitude, lng: coords.longitude, zoom } 125 122 } 126 123 127 - function coordsFromPlusCode(query: string): DirectCoords | null { 128 - const trimmed = query.trim() 129 - if (!OpenLocationCode.isFull(trimmed)) return null 130 - const area = OpenLocationCode.decode(trimmed) 131 - let zoom = 16 132 - if (area.codeLength <= 4) zoom = 5 133 - else if (area.codeLength <= 6) zoom = 9 134 - else if (area.codeLength <= 8) zoom = 13 135 - return { lat: area.latitudeCenter, lng: area.longitudeCenter, zoom } 136 - } 137 - 138 124 function coordsFromMapUrl(query: string): DirectCoords | null { 139 125 let result 140 126 try { ··· 153 139 if (!result?.lat || !result?.lon) return null 154 140 return { lat: result.lat, lng: result.lon, zoom: 15 } 155 141 } 142 + 143 + // Matches short plus codes with a location suffix, e.g. "2HM7+64 Xinyi District, Taipei City, Taiwan" 144 + const shortPlusCodePattern = 145 + /^([23456789CFGHJMPQRVWXcfghjmpqrvwx]{2,7}\+[23456789CFGHJMPQRVWXcfghjmpqrvwx]{2,3})\s+(.+)$/ 146 + 147 + function coordsFromPlusCode(query: string): DirectCoords | null { 148 + const trimmed = query.trim() 149 + if (!OpenLocationCode.isFull(trimmed)) return null 150 + const area = OpenLocationCode.decode(trimmed) 151 + let zoom = 16 152 + if (area.codeLength <= 4) zoom = 5 153 + else if (area.codeLength <= 6) zoom = 9 154 + else if (area.codeLength <= 8) zoom = 13 155 + return { lat: area.latitudeCenter, lng: area.longitudeCenter, zoom } 156 + } 157 + 158 + async function coordsFromLongPlusCode( 159 + query: string, 160 + ): Promise<DirectCoords | null> { 161 + const match = query.match(shortPlusCodePattern) 162 + if (!match) return null 163 + const [, shortCode, location] = match 164 + 165 + let refLat: number, refLng: number 166 + try { 167 + const parts = location.split(',') 168 + let results = null 169 + while (parts.length > 0) { 170 + results = await nominatimSearch(parts.join(',').trim(), 1) 171 + if (results?.length) break 172 + parts.shift() 173 + } 174 + if (!results?.length) return null 175 + refLat = parseFloat(results[0].lat) 176 + refLng = parseFloat(results[0].lon) 177 + } catch { 178 + return null 179 + } 180 + 181 + try { 182 + const fullCode = OpenLocationCode.recoverNearest(shortCode, refLat, refLng) 183 + const area = OpenLocationCode.decode(fullCode) 184 + return { lat: area.latitudeCenter, lng: area.longitudeCenter, zoom: 16 } 185 + } catch { 186 + return null 187 + } 188 + }
+39
www/utils/nominatim.ts
··· 1 + const BASE = 'https://nominatim.openstreetmap.org' 2 + 3 + function headers() { 4 + return { 'Accept-Language': navigator.language || 'en' } 5 + } 6 + 7 + export interface NominatimResult { 8 + place_id: number 9 + display_name: string 10 + lat: string 11 + lon: string 12 + boundingbox: [string, string, string, string] 13 + } 14 + 15 + export async function nominatimSearch( 16 + query: string, 17 + limit = 5, 18 + ): Promise<NominatimResult[]> { 19 + const url = new URL(`${BASE}/search`) 20 + url.searchParams.set('q', query) 21 + url.searchParams.set('format', 'json') 22 + url.searchParams.set('limit', String(limit)) 23 + const res = await fetch(url.toString(), { headers: headers() }) 24 + if (!res.ok) throw new Error(`Nominatim error: ${res.status}`) 25 + return res.json() as Promise<NominatimResult[]> 26 + } 27 + 28 + export async function nominatimReverse( 29 + lat: number, 30 + lon: number, 31 + ): Promise<NominatimResult> { 32 + const url = new URL(`${BASE}/reverse`) 33 + url.searchParams.set('lat', String(lat)) 34 + url.searchParams.set('lon', String(lon)) 35 + url.searchParams.set('format', 'json') 36 + const res = await fetch(url.toString(), { headers: headers() }) 37 + if (!res.ok) throw new Error(`Nominatim error: ${res.status}`) 38 + return res.json() as Promise<NominatimResult> 39 + }