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 geojson import

+199 -34
+10
data/README.md
··· 129 129 ``` 130 130 tilemaker monaco-latest.osm.pbf monaco.pmtiles 131 131 ``` 132 + 133 + # World Tiles 134 + 135 + Three output tiers, all generated from the same filtered planet PBF: 136 + 137 + | File | Zoom | Approx size | What's in it | 138 + | ---------------- | ----- | ----------- | --------------------------------------------- | 139 + | world_z5.pmtiles | z0–z5 | ~5–10 MB | Ocean, country fills, country names, capitals | 140 + | world_z7.pmtiles | z0–z7 | ~25–50 MB | + major cities, state boundaries, main rivers | 141 + | world_z9.pmtiles | z0–z9 | ~100–180 MB | + towns, all highways, detailed water |
+10 -2
www/models/app.ts
··· 73 73 this.notify() 74 74 } 75 75 76 - async addFolder(name: string): Promise<void> { 77 - await this.store.addFolder(name) 76 + async addFolder(name: string, color?: string): Promise<void> { 77 + await this.store.addFolder(name, color) 78 + this.notify() 79 + } 80 + 81 + async updateFolder( 82 + id: string, 83 + updates: Partial<Pick<BookmarkFolder, 'name' | 'color' | 'icon'>>, 84 + ): Promise<void> { 85 + await this.store.updateFolder(id, updates) 78 86 this.notify() 79 87 } 80 88
+2
www/models/schema/v0.ts
··· 22 22 export const BookmarkFolder = z.object({ 23 23 id: z.string(), 24 24 name: z.string(), 25 + color: z.optional(z.string()), 26 + icon: z.optional(z.string()), 25 27 createdAt: z.string(), 26 28 }) 27 29 export type BookmarkFolder = z.infer<typeof BookmarkFolder>
+13 -1
www/models/store.ts
··· 149 149 150 150 // ====== FOLDERS ====== 151 151 152 - async addFolder(name: string): Promise<void> { 152 + async addFolder(name: string, color?: string): Promise<void> { 153 153 const data = await this.#sync.get() 154 154 data.bookmarkFolders = [ 155 155 ...(data.bookmarkFolders ?? []), 156 156 BookmarkFolder.parse({ 157 157 id: crypto.randomUUID(), 158 158 name, 159 + color, 159 160 createdAt: new Date().toISOString(), 160 161 }), 161 162 ] 163 + await this.#sync.set(data) 164 + } 165 + 166 + async updateFolder( 167 + id: string, 168 + updates: Partial<Pick<BookmarkFolder, 'name' | 'color' | 'icon'>>, 169 + ): Promise<void> { 170 + const data = await this.#sync.get() 171 + const idx = (data.bookmarkFolders ?? []).findIndex((f) => f.id === id) 172 + if (idx === -1) return 173 + data.bookmarkFolders[idx] = { ...data.bookmarkFolders[idx], ...updates } 162 174 await this.#sync.set(data) 163 175 } 164 176
+106 -11
www/routes/bookmarks.ts
··· 17 17 private showAddBookmark = false 18 18 private showAddFolder = false 19 19 private editingBookmark: Bookmark | null = null 20 + private editingFolder: BookmarkFolder | null = null 20 21 private pendingImport: ImportResult | null = null 21 22 private importError: string | null = null 22 23 private importTargetFolderId: string | null = null ··· 157 158 async #submitAddFolder(e: Event) { 158 159 e.preventDefault() 159 160 const form = e.target as HTMLFormElement 160 - const name = (new FormData(form).get('name') as string).trim() 161 + const fd = new FormData(form) 162 + const name = (fd.get('name') as string).trim() 163 + const color = (fd.get('color') as string) || undefined 161 164 if (!name) return 162 - await app.addFolder(name) 165 + await app.addFolder(name, color) 163 166 form.reset() 164 167 this.showAddFolder = false 168 + this.requestUpdate() 169 + } 170 + 171 + async #submitEditFolder(e: Event) { 172 + e.preventDefault() 173 + if (!this.editingFolder) return 174 + const form = e.target as HTMLFormElement 175 + const fd = new FormData(form) 176 + const name = (fd.get('name') as string).trim() 177 + const color = (fd.get('color') as string) || undefined 178 + if (!name) return 179 + await app.updateFolder(this.editingFolder.id, { name, color }) 180 + this.editingFolder = null 165 181 this.requestUpdate() 166 182 } 167 183 ··· 236 252 autocomplete="off" 237 253 autofocus 238 254 > 255 + <div class="bm-folder-color-row"> 256 + <label for="add-folder-color">Color</label> 257 + <input 258 + id="add-folder-color" 259 + name="color" 260 + type="color" 261 + value="#e05c2a" 262 + > 263 + </div> 239 264 <div class="bm-form-actions"> 240 265 <button type="submit">Add</button> 241 266 <button ··· 267 292 <span class="bm-folder-toggle" aria-hidden="true">${expanded 268 293 ? '▾' 269 294 : '▸'}</span> 295 + ${folder.color 296 + ? html` 297 + <span 298 + class="bm-folder-swatch" 299 + style="background:${folder.color}" 300 + aria-hidden="true" 301 + ></span> 302 + ` 303 + : ''} 270 304 <span class="bm-folder-name">${folder.name}</span> 271 305 <span class="bm-folder-count">${items.length}</span> 306 + <button 307 + class="bm-icon-btn" 308 + aria-label="Edit folder" 309 + @click="${(e: Event) => { 310 + e.stopPropagation() 311 + this.editingFolder = folder 312 + this.requestUpdate() 313 + }}" 314 + > 315 + <img src="/static/icons/edit.svg" alt="" aria-hidden="true"> 316 + </button> 272 317 <button 273 318 class="bm-icon-btn" 274 319 aria-label="Delete folder" ··· 463 508 : ''} 464 509 </div> 465 510 ${this.pendingImport.folders.length === 0 && 466 - this.folders.length > 0 511 + this.folders.length > 0 467 512 ? html` 468 513 <select 469 514 .value="${this.importTargetFolderId ?? ''}" ··· 474 519 > 475 520 <option value="">No Category</option> 476 521 ${this.folders.map( 477 - (f) => html` 478 - <option 479 - value="${f.id}" 480 - ?selected="${this.importTargetFolderId === f.id}" 481 - > 482 - ${f.name} 483 - </option> 484 - `, 522 + (f) => 523 + html` 524 + <option 525 + value="${f.id}" 526 + ?selected="${this.importTargetFolderId === f.id}" 527 + > 528 + ${f.name} 529 + </option> 530 + `, 485 531 )} 486 532 </select> 487 533 ` ··· 585 631 @click="${this.#deleteEditingBookmark}" 586 632 > 587 633 Delete 634 + </button> 635 + </div> 636 + </form> 637 + ` 638 + : ''} 639 + </article> 640 + </dialog> 641 + </ui-dialog> 642 + <ui-dialog 643 + ?open="${this.editingFolder !== null}" 644 + @dismiss="${() => { 645 + this.editingFolder = null 646 + this.requestUpdate() 647 + }}" 648 + > 649 + <dialog> 650 + <article class="bm-dialog"> 651 + ${this.editingFolder 652 + ? html` 653 + <h2 class="bm-dialog-title">Edit Folder</h2> 654 + <form @submit="${this.#submitEditFolder}"> 655 + <input 656 + name="name" 657 + type="text" 658 + placeholder="Folder name" 659 + .value="${this.editingFolder.name}" 660 + required 661 + autocomplete="off" 662 + autofocus 663 + > 664 + <div class="bm-folder-color-row"> 665 + <label for="edit-folder-color">Color</label> 666 + <input 667 + id="edit-folder-color" 668 + name="color" 669 + type="color" 670 + .value="${this.editingFolder.color ?? '#e05c2a'}" 671 + > 672 + </div> 673 + <div class="bm-form-actions"> 674 + <button type="submit">Save</button> 675 + <button 676 + type="button" 677 + @click="${() => { 678 + this.editingFolder = null 679 + this.requestUpdate() 680 + }}" 681 + > 682 + Cancel 588 683 </button> 589 684 </div> 590 685 </form>
+53 -14
www/routes/map.ts
··· 30 30 export class MapPage extends LitElement { 31 31 #map: maplibregl.Map | null = null 32 32 #marker: maplibregl.Marker | null = null 33 - #bookmarkMarkers: maplibregl.Marker[] = [] 33 + #bookmarkPopup: maplibregl.Popup | null = null 34 34 #longPressTimer: ReturnType<typeof setTimeout> | null = null 35 35 36 36 protected override createRenderRoot() { ··· 256 256 257 257 #renderBookmarkMarkers() { 258 258 if (!this.#map) return 259 - this.#bookmarkMarkers.forEach((m) => m.remove()) 260 - this.#bookmarkMarkers = [] 261 - for (const b of app.bookmarks) { 262 - const popup = new maplibregl.Popup({ offset: 25 }).setHTML( 263 - `<strong>${b.name}</strong>`, 264 - ) 265 - const marker = new maplibregl.Marker({ color: '#e05c2a' }) 266 - .setLngLat([b.lng, b.lat]) 267 - .setPopup(popup) 259 + const folderColors = new Map( 260 + app.bookmarkFolders.map((f) => [f.id, f.color ?? null]), 261 + ) 262 + const geojson = { 263 + type: 'FeatureCollection' as const, 264 + features: app.bookmarks.map((b) => ({ 265 + type: 'Feature' as const, 266 + geometry: { type: 'Point' as const, coordinates: [b.lng, b.lat] }, 267 + properties: { 268 + name: b.name, 269 + color: b.folderId ? (folderColors.get(b.folderId) ?? null) : null, 270 + }, 271 + })), 272 + } 273 + const source = this.#map.getSource('bookmarks') as 274 + | maplibregl.GeoJSONSource 275 + | undefined 276 + if (source) { 277 + source.setData(geojson) 278 + return 279 + } 280 + this.#map.addSource('bookmarks', { type: 'geojson', data: geojson }) 281 + this.#map.addLayer({ 282 + id: 'bookmarks', 283 + type: 'circle', 284 + source: 'bookmarks', 285 + paint: { 286 + 'circle-radius': 8, 287 + 'circle-color': ['coalesce', ['get', 'color'], '#e05c2a'], 288 + 'circle-stroke-width': 2, 289 + 'circle-stroke-color': '#fff', 290 + }, 291 + }) 292 + this.#map.on('click', 'bookmarks', (e) => { 293 + if (!e.features?.length || !this.#map) return 294 + const feature = e.features[0] 295 + const coords = (feature.geometry as { coordinates: [number, number] }) 296 + .coordinates 297 + const name = feature.properties?.name as string 298 + this.#bookmarkPopup?.remove() 299 + this.#bookmarkPopup = new maplibregl.Popup({ offset: 10 }) 300 + .setLngLat(coords) 301 + .setHTML(`<strong>${name}</strong>`) 268 302 .addTo(this.#map) 269 - this.#bookmarkMarkers.push(marker) 270 - } 303 + }) 304 + this.#map.on('mouseenter', 'bookmarks', () => { 305 + this.#map!.getCanvas().style.cursor = 'pointer' 306 + }) 307 + this.#map.on('mouseleave', 'bookmarks', () => { 308 + this.#map!.getCanvas().style.cursor = '' 309 + }) 271 310 } 272 311 273 312 override disconnectedCallback() { 274 313 super.disconnectedCallback() 275 314 app.removeEventListener(this.#onAppUpdate) 276 315 this.#clearLongPress() 277 - this.#bookmarkMarkers.forEach((m) => m.remove()) 278 - this.#bookmarkMarkers = [] 316 + this.#bookmarkPopup?.remove() 317 + this.#bookmarkPopup = null 279 318 this.#marker?.remove() 280 319 this.#marker = null 281 320 this.#map?.remove()
+5 -6
www/utils/import_bookmarks.ts
··· 65 65 const data = JSON.parse(text) 66 66 const bookmarks: ParsedBookmark[] = [] 67 67 68 - const features = 69 - data.type === 'FeatureCollection' 70 - ? data.features 71 - : data.type === 'Feature' 72 - ? [data] 73 - : [] 68 + const features = data.type === 'FeatureCollection' 69 + ? data.features 70 + : data.type === 'Feature' 71 + ? [data] 72 + : [] 74 73 75 74 for (const feature of features) { 76 75 if (feature?.geometry?.type !== 'Point') continue