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: move to civility/store@1.0.0

+219 -495
+1 -2
deno.json
··· 20 20 "semiColons": false 21 21 }, 22 22 "imports": { 23 - "@civility/store": "jsr:@civility/store@^0.3.1", 24 - "@civility/sync": "jsr:@civility/sync@^0.1.1", 23 + "@civility/store": "jsr:@civility/store@^1.0.0-beta.5", 25 24 "@civility/ui": "jsr:@civility/ui@^0.2.6", 26 25 "@civility/workers": "jsr:@civility/workers@^0.2.4", 27 26 "@zod/zod": "jsr:@zod/zod@^4.3.6",
+23 -99
deno.lock
··· 1 1 { 2 2 "version": "5", 3 3 "specifiers": { 4 - "jsr:@civility/store@0.3": "0.3.1", 5 - "jsr:@civility/store@~0.3.1": "0.3.1", 6 - "jsr:@civility/sync@~0.1.1": "0.1.1", 7 - "jsr:@civility/ui@~0.2.6": "0.2.6", 8 - "jsr:@civility/workers@~0.2.4": "0.2.4", 9 - "jsr:@cliffy/command@1": "1.0.0", 10 - "jsr:@cliffy/flags@1.0.0": "1.0.0", 11 - "jsr:@cliffy/internal@1.0.0": "1.0.0", 12 - "jsr:@cliffy/table@1.0.0": "1.0.0", 13 - "jsr:@paulmillr/qr@~0.5.5": "0.5.5", 14 - "jsr:@std/fmt@^1.0.9": "1.0.9", 15 - "jsr:@std/fs@1": "1.0.23", 16 - "jsr:@std/fs@^1.0.23": "1.0.23", 4 + "jsr:@civility/store@^1.0.0-beta.5": "1.0.0-beta.5", 5 + "jsr:@civility/ui@~0.2.6": "0.2.9", 6 + "jsr:@civility/workers@~0.2.4": "0.2.5", 17 7 "jsr:@std/html@^1.0.5": "1.0.5", 18 - "jsr:@std/internal@^1.0.12": "1.0.12", 19 - "jsr:@std/path@^1.1.4": "1.1.4", 20 8 "jsr:@std/semver@^1.0.8": "1.0.8", 21 - "jsr:@std/text@^1.0.17": "1.0.17", 9 + "jsr:@std/ulid@1": "1.0.0", 22 10 "jsr:@zod/zod@^4.3.6": "4.3.6", 23 11 "npm:cheerio@^1.2.0": "1.2.0", 12 + "npm:fast-json-patch@^3.1.1": "3.1.1", 24 13 "npm:fflate@~0.8.2": "0.8.2", 25 14 "npm:lit@^3.3.2": "3.3.2", 26 15 "npm:maplibre-gl@^5.21.0": "5.21.0", 27 - "npm:native-file-system-adapter@^3.0.1": "3.0.1", 28 16 "npm:pmtiles-offline@1": "1.0.0", 29 17 "npm:pmtiles@^4.4.0": "4.4.0" 30 18 }, 31 19 "jsr": { 32 - "@civility/store@0.3.1": { 33 - "integrity": "0438f2cdb16145a61a97f5be509cd0b34e7cbd9f71dc657feffe2a4dd7dd0ec3", 20 + "@civility/store@1.0.0-beta.5": { 21 + "integrity": "afb3c70da4d4242faf9ca07e54b269889f2b3bac4e23962272c1350a3062a54d", 34 22 "dependencies": [ 35 - "jsr:@std/fs@^1.0.23", 36 - "jsr:@std/semver" 37 - ] 38 - }, 39 - "@civility/sync@0.1.1": { 40 - "integrity": "9ef604671656316dffbeea4c0d8c01488c8a2f54269ddcc68c4d4731317174ad", 41 - "dependencies": [ 42 - "jsr:@civility/store@0.3", 43 - "jsr:@paulmillr/qr", 44 - "npm:native-file-system-adapter" 23 + "jsr:@std/semver", 24 + "jsr:@std/ulid", 25 + "npm:fast-json-patch" 45 26 ] 46 27 }, 47 28 "@civility/ui@0.2.6": { ··· 51 32 "npm:lit" 52 33 ] 53 34 }, 54 - "@civility/workers@0.2.4": { 55 - "integrity": "38fafb96bc15a988e7723bc9b021394bdfef842c8e8372b960ec2476e5c74b43" 56 - }, 57 - "@cliffy/command@1.0.0": { 58 - "integrity": "c52a241ea68857fcdaff4f3173eb404f8017d7bc35553b6f533c592b89dde7d2", 59 - "dependencies": [ 60 - "jsr:@cliffy/flags", 61 - "jsr:@cliffy/internal", 62 - "jsr:@cliffy/table", 63 - "jsr:@std/fmt", 64 - "jsr:@std/semver", 65 - "jsr:@std/text" 66 - ] 67 - }, 68 - "@cliffy/flags@1.0.0": { 69 - "integrity": "8b57698adc644da8f90422d58976362d41a4ebca39c312ca1c101585d0148feb", 70 - "dependencies": [ 71 - "jsr:@cliffy/internal", 72 - "jsr:@std/text" 73 - ] 74 - }, 75 - "@cliffy/internal@1.0.0": { 76 - "integrity": "1e17ccbcd5420093c0a93e5b3827bbdc9abac5195bacf187edc44665e54bdde6" 77 - }, 78 - "@cliffy/table@1.0.0": { 79 - "integrity": "3fdaa9e1ef1ea62022108adabd826932bdea8dd05497079896febcd41322907f", 35 + "@civility/ui@0.2.9": { 36 + "integrity": "68eff67028c540669f30d5c5d434182ad3686a4610a700137c341d4202d1f996", 80 37 "dependencies": [ 81 - "jsr:@std/fmt" 38 + "jsr:@std/html", 39 + "npm:lit" 82 40 ] 83 41 }, 84 - "@paulmillr/qr@0.5.5": { 85 - "integrity": "2f8ff22c8d2194f2147eac1b3093f5e85f648c0a8005d5635a617fb72bf5ae38" 42 + "@civility/workers@0.2.4": { 43 + "integrity": "38fafb96bc15a988e7723bc9b021394bdfef842c8e8372b960ec2476e5c74b43" 86 44 }, 87 - "@std/fmt@1.0.9": { 88 - "integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0" 89 - }, 90 - "@std/fs@1.0.23": { 91 - "integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37", 92 - "dependencies": [ 93 - "jsr:@std/internal", 94 - "jsr:@std/path" 95 - ] 45 + "@civility/workers@0.2.5": { 46 + "integrity": "5a27340c55972cc71042d4b3ce9c6a8d508e31a77fe6133f94ccdb0d48db0e40" 96 47 }, 97 48 "@std/html@1.0.5": { 98 49 "integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e" 99 50 }, 100 - "@std/internal@1.0.12": { 101 - "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" 102 - }, 103 - "@std/path@1.1.4": { 104 - "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", 105 - "dependencies": [ 106 - "jsr:@std/internal" 107 - ] 108 - }, 109 51 "@std/semver@1.0.8": { 110 52 "integrity": "dc830e8b8b6a380c895d53fbfd1258dc253704ca57bbe1629ac65fd7830179b7" 111 53 }, 112 - "@std/text@1.0.17": { 113 - "integrity": "4b2c4ef67ae5b6c1dfd447c81c83a43718f52e3c7e748d8b33f694aba9895f95" 54 + "@std/ulid@1.0.0": { 55 + "integrity": "d41c3d27a907714413649fee864b7cde8d42ee68437d22b79d5de4f81d808780" 114 56 }, 115 57 "@zod/zod@4.3.6": { 116 58 "integrity": "7144e5e11f8ffc3cf6e2fca624f6597a8762898aac9868cc8938e9398b96ffe4" ··· 288 230 "entities@7.0.1": { 289 231 "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==" 290 232 }, 291 - "fetch-blob@3.2.0": { 292 - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", 293 - "dependencies": [ 294 - "node-domexception", 295 - "web-streams-polyfill" 296 - ] 233 + "fast-json-patch@3.1.1": { 234 + "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==" 297 235 }, 298 236 "fflate@0.8.2": { 299 237 "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==" ··· 374 312 "murmurhash-js@1.0.0": { 375 313 "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==" 376 314 }, 377 - "native-file-system-adapter@3.0.1": { 378 - "integrity": "sha512-ocuhsYk2SY0906LPc3QIMW+rCV3MdhqGiy7wV5Bf0e8/5TsMjDdyIwhNiVPiKxzTJLDrLT6h8BoV9ERfJscKhw==", 379 - "optionalDependencies": [ 380 - "fetch-blob" 381 - ] 382 - }, 383 - "node-domexception@1.0.0": { 384 - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", 385 - "deprecated": true 386 - }, 387 315 "nth-check@2.1.1": { 388 316 "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", 389 317 "dependencies": [ ··· 458 386 "undici@7.22.0": { 459 387 "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==" 460 388 }, 461 - "web-streams-polyfill@3.3.3": { 462 - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==" 463 - }, 464 389 "whatwg-encoding@3.1.1": { 465 390 "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", 466 391 "dependencies": [ ··· 474 399 }, 475 400 "workspace": { 476 401 "dependencies": [ 477 - "jsr:@civility/store@~0.3.1", 478 - "jsr:@civility/sync@~0.1.1", 402 + "jsr:@civility/store@^1.0.0-beta.5", 479 403 "jsr:@civility/ui@~0.2.6", 480 404 "jsr:@civility/workers@~0.2.4", 481 405 "jsr:@zod/zod@^4.3.6",
+175 -53
www/models/app.ts
··· 1 - import State from '@civility/store/state' 1 + import { Store } from '@civility/store' 2 + import { IDBStorage } from '@civility/store/idb' 2 3 import { 3 - AppState, 4 4 Bookmark, 5 5 BookmarkCollection, 6 - BookmarkProperties, 7 - LastView, 6 + type BookmarkProperties, 7 + type LastView, 8 8 SearchHistoryEntry, 9 9 } from './schema.ts' 10 - import { Store } from './store.ts' 10 + 11 + type Preferences = { 12 + geocodingBookmarksEnabled: boolean 13 + onlineSearchEnabled: boolean 14 + lastView: LastView | null 15 + } 16 + 17 + const defaultPreferences: Preferences = { 18 + geocodingBookmarksEnabled: true, 19 + onlineSearchEnabled: true, 20 + lastView: null, 21 + } 22 + 23 + const backend = new IDBStorage({ dbName: 'maps-store' }) 24 + const store = new Store(backend, { 25 + documents: ['preferences', 'searchHistory'], 26 + collections: ['bookmarks', 'bookmarkCollections'], 27 + versions: [{ 28 + version: globalThis.__APP_VERSION__, 29 + schema: { 30 + type: 'object', 31 + properties: { 32 + preferences: { type: 'object' }, 33 + searchHistory: { type: 'array' }, 34 + bookmarks: { type: 'object' }, 35 + bookmarkCollections: { type: 'object' }, 36 + }, 37 + }, 38 + }], 39 + }) 11 40 12 - export class App extends State<AppState> { 13 - store: Store 41 + const bookmarksColl = store.collection<Bookmark>('bookmarks') 42 + const collectionsColl = store.collection<BookmarkCollection>('bookmarkCollections') 43 + const preferencesDoc = store.document<Preferences>('preferences', defaultPreferences) 44 + const searchHistoryDoc = store.document<SearchHistoryEntry[]>('searchHistory', []) 45 + 46 + export class App { 47 + #subscribers = new Set<() => void>() 14 48 15 49 constructor() { 16 - super(AppState.parse({})) 17 - this.store = new Store('maps-data') 18 - this.store.addEventListener(() => this.notify()) 50 + store.subscribe(() => this.#notify()) 51 + this.#init() 52 + } 53 + 54 + async #init(): Promise<void> { 55 + await Promise.all([ 56 + bookmarksColl.preload(), 57 + collectionsColl.preload(), 58 + preferencesDoc.preload(), 59 + searchHistoryDoc.preload(), 60 + ]) 61 + this.#notify() 62 + } 63 + 64 + #notify(): void { 65 + for (const cb of this.#subscribers) cb() 66 + } 67 + 68 + addEventListener(fn: () => void): void { 69 + this.#subscribers.add(fn) 70 + } 71 + 72 + removeEventListener(fn: () => void): void { 73 + this.#subscribers.delete(fn) 19 74 } 20 75 21 76 // ====== SEARCH HISTORY ====== 22 77 23 78 get searchHistory(): SearchHistoryEntry[] { 24 - return this.store.searchHistory 79 + return searchHistoryDoc.value ?? [] 25 80 } 26 81 27 82 async addSearch(query: string): Promise<void> { 28 - await this.store.addSearch(query) 29 - this.notify() 83 + const trimmed = query.trim() 84 + if (!trimmed) return 85 + const current = searchHistoryDoc.value ?? [] 86 + const existing = current.filter((e) => e.query !== trimmed) 87 + await searchHistoryDoc.set([ 88 + SearchHistoryEntry.parse({ query: trimmed, timestamp: new Date().toISOString() }), 89 + ...existing, 90 + ].slice(0, 20)) 30 91 } 31 92 32 93 async clearHistory(): Promise<void> { 33 - await this.store.clearHistory() 34 - this.notify() 94 + await searchHistoryDoc.set([]) 35 95 } 36 96 37 97 // ====== BOOKMARKS ====== 38 98 39 99 get bookmarks(): Bookmark[] { 40 - return this.store.bookmarks 100 + return [...(bookmarksColl.value?.values() ?? [])].sort( 101 + (a, b) => a.createdAt < b.createdAt ? -1 : 1, 102 + ) 41 103 } 42 104 43 105 get bookmarkCollections(): BookmarkCollection[] { 44 - return this.store.bookmarkCollections 106 + return [...(collectionsColl.value?.values() ?? [])] 45 107 } 46 108 47 109 async addBookmark( ··· 51 113 properties: BookmarkProperties, 52 114 categories: string[] = [], 53 115 ): Promise<void> { 54 - await this.store.addBookmark(lat, lng, zoom, properties, categories) 55 - this.notify() 116 + const now = new Date().toISOString() 117 + const id = crypto.randomUUID() 118 + await bookmarksColl.set(id, Bookmark.parse({ 119 + id, 120 + type: 'Feature', 121 + geometry: { type: 'Point', coordinates: [lng, lat] }, 122 + properties, 123 + categories, 124 + zoom, 125 + createdAt: now, 126 + updatedAt: now, 127 + })) 56 128 } 57 129 58 130 async deleteBookmark(id: string): Promise<void> { 59 - await this.store.deleteBookmark(id) 60 - this.notify() 131 + await bookmarksColl.delete(id) 61 132 } 62 133 63 134 async updateBookmark( ··· 68 139 zoom?: number 69 140 }, 70 141 ): Promise<void> { 71 - await this.store.updateBookmark(id, updates) 72 - this.notify() 142 + const existing = await bookmarksColl.get(id) 143 + if (!existing) return 144 + await bookmarksColl.set(id, { 145 + ...existing, 146 + ...(updates.categories !== undefined && { categories: updates.categories }), 147 + ...(updates.zoom !== undefined && { zoom: updates.zoom }), 148 + properties: updates.properties 149 + ? { ...existing.properties, ...updates.properties } 150 + : existing.properties, 151 + updatedAt: new Date().toISOString(), 152 + }) 73 153 } 74 154 155 + // ====== COLLECTIONS ====== 156 + 75 157 async addCollection(name: string, color?: string): Promise<void> { 76 - await this.store.addCollection(name, color) 77 - this.notify() 158 + const now = new Date().toISOString() 159 + const id = crypto.randomUUID() 160 + await collectionsColl.set(id, BookmarkCollection.parse({ 161 + id, 162 + name, 163 + color, 164 + createdAt: now, 165 + updatedAt: now, 166 + })) 78 167 } 79 168 80 169 async updateCollection( 81 170 id: string, 82 171 updates: Partial<Pick<BookmarkCollection, 'name' | 'color' | 'icon'>>, 83 172 ): Promise<void> { 84 - await this.store.updateCollection(id, updates) 85 - this.notify() 173 + const existing = await collectionsColl.get(id) 174 + if (!existing) return 175 + await collectionsColl.set(id, { 176 + ...existing, 177 + ...updates, 178 + updatedAt: new Date().toISOString(), 179 + }) 86 180 } 87 181 88 182 async deleteCollection(id: string): Promise<void> { 89 - await this.store.deleteCollection(id) 90 - this.notify() 183 + await collectionsColl.delete(id) 184 + const allBookmarks = await bookmarksColl.getAll() 185 + await Promise.all( 186 + [...allBookmarks.entries()] 187 + .filter(([, b]) => b.categories.includes(id)) 188 + .map(([bId, b]) => 189 + bookmarksColl.set(bId, { 190 + ...b, 191 + categories: b.categories.filter((c) => c !== id), 192 + updatedAt: new Date().toISOString(), 193 + }) 194 + ), 195 + ) 91 196 } 92 197 93 198 // ====== PREFERENCES ====== 94 199 95 200 get geocodingBookmarksEnabled(): boolean { 96 - return this.store.geocodingBookmarksEnabled 201 + return preferencesDoc.value?.geocodingBookmarksEnabled ?? true 97 202 } 98 203 99 204 async setGeocodingBookmarksEnabled(value: boolean): Promise<void> { 100 - await this.store.setGeocodingBookmarksEnabled(value) 101 - this.notify() 205 + await preferencesDoc.update({ geocodingBookmarksEnabled: value }) 102 206 } 103 207 104 208 get onlineSearchEnabled(): boolean { 105 - return this.store.onlineSearchEnabled 209 + return preferencesDoc.value?.onlineSearchEnabled ?? true 106 210 } 107 211 108 212 async setOnlineSearchEnabled(value: boolean): Promise<void> { 109 - await this.store.setOnlineSearchEnabled(value) 110 - this.notify() 213 + await preferencesDoc.update({ onlineSearchEnabled: value }) 111 214 } 112 215 113 216 get lastView(): LastView | null { 114 - return this.store.lastView 217 + return preferencesDoc.value?.lastView ?? null 115 218 } 116 219 117 220 async setLastView(value: LastView): Promise<void> { 118 - await this.store.setLastView(value) 221 + await preferencesDoc.update({ lastView: value }) 119 222 } 120 223 121 224 // ====== DATA ====== ··· 126 229 error?: string 127 230 }> { 128 231 try { 129 - return await this.store.exportToFile( 130 - filename ?? `maps-export_${Date.now()}`, 131 - ) 232 + const data = await store.export() 233 + const name = filename ?? `maps-export_${Date.now()}` 234 + const blob = new Blob([JSON.stringify(data)], { type: 'application/json' }) 235 + const url = URL.createObjectURL(blob) 236 + const a = document.createElement('a') 237 + a.href = url 238 + a.download = `${name}.json` 239 + a.click() 240 + URL.revokeObjectURL(url) 241 + return { success: true, path: `${name}.json` } 132 242 } catch (error) { 133 243 return { 134 244 success: false, ··· 143 253 path: string 144 254 error?: string 145 255 }> { 146 - try { 147 - const result = await this.store.importFromFile() 148 - if (!result.success) return result 149 - this.notify() 150 - return result 151 - } catch (error) { 152 - return { 153 - success: false, 154 - path: '', 155 - error: error instanceof Error ? error.message : 'Import failed', 256 + return new Promise((resolve) => { 257 + const input = document.createElement('input') 258 + input.type = 'file' 259 + input.accept = '.json' 260 + input.onchange = async () => { 261 + const file = input.files?.[0] 262 + if (!file) { 263 + resolve({ success: false, path: '', error: 'No file selected' }) 264 + return 265 + } 266 + try { 267 + const data = JSON.parse(await file.text()) 268 + await store.import(data) 269 + resolve({ success: true, path: file.name }) 270 + } catch (error) { 271 + resolve({ 272 + success: false, 273 + path: '', 274 + error: error instanceof Error ? error.message : 'Import failed', 275 + }) 276 + } 156 277 } 157 - } 278 + input.click() 279 + }) 158 280 } 159 281 160 282 async deleteAllData(): Promise<{ success: boolean; error?: string }> { 161 283 try { 162 - localStorage.removeItem('maps-data') 284 + await store.deleteAll() 163 285 await new Promise<void>((resolve, reject) => { 164 286 const req = indexedDB.deleteDatabase('maps-offline') 165 287 req.onsuccess = () => resolve() ··· 181 303 } 182 304 183 305 dispose(): void { 184 - this.store.dispose() 306 + store.dispose() 185 307 } 186 308 } 187 309
-21
www/models/schema.ts
··· 1 1 export { 2 - AppState, 3 2 Bookmark, 4 3 BookmarkAddress, 5 4 BookmarkCollection, ··· 9 8 Lat, 10 9 Lng, 11 10 SearchHistoryEntry, 12 - StoreState, 13 11 } from './schema/v0.ts' 14 - import { StoreState } from './schema/v0.ts' 15 - 16 - const currentVersion = globalThis.__APP_VERSION__ 17 - 18 - export const storeMigrationConfig = { 19 - currentVersion, 20 - extractVersion: (data: unknown): string | undefined => 21 - data && typeof data === 'object' && 'version' in data 22 - ? (data as { version: string }).version 23 - : undefined, 24 - migrations: [{ 25 - fromVersion: '<1.0.0', 26 - toVersion: currentVersion, 27 - migrate: (data: unknown) => ({ 28 - ...data as StoreState, 29 - version: globalThis.__APP_VERSION__, 30 - }), 31 - }], 32 - }
-25
www/models/schema/v0.ts
··· 95 95 zoom: z.number(), 96 96 }) 97 97 export type LastView = z.infer<typeof LastView> 98 - 99 - const now = new Date().toISOString() 100 - 101 - const appVersion: string = globalThis.__APP_VERSION__ 102 - 103 - export const StoreState = z.object({ 104 - version: z._default(z.string(), appVersion), 105 - searchHistory: z._default(z.array(SearchHistoryEntry), []), 106 - bookmarks: z._default(z.array(Bookmark), []), 107 - bookmarkCollections: z._default(z.array(BookmarkCollection), [{ 108 - id: 'favorites', 109 - name: 'Favorites', 110 - createdAt: now, 111 - updatedAt: now, 112 - }]), 113 - geocodingBookmarksEnabled: z._default(z.boolean(), true), 114 - onlineSearchEnabled: z._default(z.boolean(), true), 115 - lastView: z._default(z.nullable(LastView), null), 116 - }) 117 - export type StoreState = z.infer<typeof StoreState> 118 - 119 - export const AppState = z.object({ 120 - error: z._default(z.nullable(z.string()), null), 121 - }) 122 - export type AppState = z.infer<typeof AppState>
-228
www/models/store.ts
··· 1 - import SyncLink from '@civility/sync' 2 - import useJSON from '@civility/sync/json' 3 - import { 4 - Bookmark, 5 - BookmarkCollection, 6 - BookmarkProperties, 7 - LastView, 8 - SearchHistoryEntry, 9 - storeMigrationConfig, 10 - StoreState, 11 - } from './schema.ts' 12 - 13 - export class Store { 14 - #sync: SyncLink<StoreState> 15 - 16 - constructor(name: string) { 17 - this.#sync = new SyncLink( 18 - useJSON<StoreState>(name, StoreState.parse({}), { 19 - migrations: storeMigrationConfig, 20 - }), 21 - ) 22 - } 23 - 24 - get searchHistory(): SearchHistoryEntry[] { 25 - return this.#sync.state.data.searchHistory ?? [] 26 - } 27 - 28 - get bookmarks(): Bookmark[] { 29 - return this.#sync.state.data.bookmarks ?? [] 30 - } 31 - 32 - get bookmarkCollections(): BookmarkCollection[] { 33 - return this.#sync.state.data.bookmarkCollections ?? [] 34 - } 35 - 36 - get geocodingBookmarksEnabled(): boolean { 37 - return this.#sync.state.data.geocodingBookmarksEnabled ?? true 38 - } 39 - 40 - async setGeocodingBookmarksEnabled(value: boolean): Promise<void> { 41 - const data = await this.#sync.get() 42 - data.geocodingBookmarksEnabled = value 43 - await this.#sync.set(data) 44 - } 45 - 46 - get onlineSearchEnabled(): boolean { 47 - return this.#sync.state.data.onlineSearchEnabled ?? true 48 - } 49 - 50 - async setOnlineSearchEnabled(value: boolean): Promise<void> { 51 - const data = await this.#sync.get() 52 - data.onlineSearchEnabled = value 53 - await this.#sync.set(data) 54 - } 55 - 56 - get lastView(): LastView | null { 57 - return this.#sync.state.data.lastView ?? null 58 - } 59 - 60 - async setLastView(value: LastView): Promise<void> { 61 - const data = await this.#sync.get() 62 - data.lastView = value 63 - await this.#sync.set(data) 64 - } 65 - 66 - addEventListener(fn: () => void): void { 67 - this.#sync.addEventListener(fn) 68 - } 69 - 70 - removeEventListener(fn: () => void): void { 71 - this.#sync.removeEventListener(fn) 72 - } 73 - 74 - // ====== SEARCH HISTORY ====== 75 - 76 - async addSearch(query: string): Promise<void> { 77 - const trimmed = query.trim() 78 - if (!trimmed) return 79 - const data = await this.#sync.get() 80 - const existing = (data.searchHistory ?? []).filter((e) => 81 - e.query !== trimmed 82 - ) 83 - data.searchHistory = [ 84 - SearchHistoryEntry.parse({ 85 - query: trimmed, 86 - timestamp: new Date().toISOString(), 87 - }), 88 - ...existing, 89 - ].slice(0, 20) 90 - await this.#sync.set(data) 91 - } 92 - 93 - async clearHistory(): Promise<void> { 94 - const data = await this.#sync.get() 95 - data.searchHistory = [] 96 - await this.#sync.set(data) 97 - } 98 - 99 - // ====== BOOKMARKS ====== 100 - 101 - async addBookmark( 102 - lat: number, 103 - lng: number, 104 - zoom: number, 105 - properties: BookmarkProperties, 106 - categories: string[] = [], 107 - ): Promise<void> { 108 - const now = new Date().toISOString() 109 - const data = await this.#sync.get() 110 - data.bookmarks = [ 111 - ...(data.bookmarks ?? []), 112 - Bookmark.parse({ 113 - id: crypto.randomUUID(), 114 - type: 'Feature', 115 - geometry: { type: 'Point', coordinates: [lng, lat] }, 116 - properties, 117 - categories, 118 - zoom, 119 - createdAt: now, 120 - updatedAt: now, 121 - }), 122 - ] 123 - await this.#sync.set(data) 124 - } 125 - 126 - async deleteBookmark(id: string): Promise<void> { 127 - const data = await this.#sync.get() 128 - data.bookmarks = (data.bookmarks ?? []).filter((b) => b.id !== id) 129 - await this.#sync.set(data) 130 - } 131 - 132 - async updateBookmark( 133 - id: string, 134 - updates: { 135 - properties?: Partial<BookmarkProperties> 136 - categories?: string[] 137 - zoom?: number 138 - }, 139 - ): Promise<void> { 140 - const data = await this.#sync.get() 141 - const idx = (data.bookmarks ?? []).findIndex((b) => b.id === id) 142 - if (idx === -1) return 143 - const existing = data.bookmarks[idx] 144 - data.bookmarks[idx] = { 145 - ...existing, 146 - ...(updates.categories !== undefined && 147 - { categories: updates.categories }), 148 - ...(updates.zoom !== undefined && { zoom: updates.zoom }), 149 - properties: updates.properties 150 - ? { ...existing.properties, ...updates.properties } 151 - : existing.properties, 152 - updatedAt: new Date().toISOString(), 153 - } 154 - await this.#sync.set(data) 155 - } 156 - 157 - // ====== COLLECTIONS ====== 158 - 159 - async addCollection(name: string, color?: string): Promise<void> { 160 - const now = new Date().toISOString() 161 - const data = await this.#sync.get() 162 - data.bookmarkCollections = [ 163 - ...(data.bookmarkCollections ?? []), 164 - BookmarkCollection.parse({ 165 - id: crypto.randomUUID(), 166 - name, 167 - color, 168 - createdAt: now, 169 - updatedAt: now, 170 - }), 171 - ] 172 - await this.#sync.set(data) 173 - } 174 - 175 - async updateCollection( 176 - id: string, 177 - updates: Partial<Pick<BookmarkCollection, 'name' | 'color' | 'icon'>>, 178 - ): Promise<void> { 179 - const data = await this.#sync.get() 180 - const idx = (data.bookmarkCollections ?? []).findIndex((c) => c.id === id) 181 - if (idx === -1) return 182 - data.bookmarkCollections[idx] = { 183 - ...data.bookmarkCollections[idx], 184 - ...updates, 185 - updatedAt: new Date().toISOString(), 186 - } 187 - await this.#sync.set(data) 188 - } 189 - 190 - async deleteCollection(id: string): Promise<void> { 191 - const data = await this.#sync.get() 192 - data.bookmarkCollections = (data.bookmarkCollections ?? []).filter((c) => 193 - c.id !== id 194 - ) 195 - data.bookmarks = (data.bookmarks ?? []).map((b) => 196 - b.categories.includes(id) 197 - ? { ...b, categories: b.categories.filter((c) => c !== id) } 198 - : b 199 - ) 200 - await this.#sync.set(data) 201 - } 202 - 203 - // ====== UTILITY ====== 204 - 205 - async clearAllData(): Promise<void> { 206 - await this.#sync.set(StoreState.parse({})) 207 - } 208 - 209 - exportToFile(filename?: string): Promise<{ 210 - success: boolean 211 - path: string 212 - error?: string 213 - }> { 214 - return this.#sync.exportToFile(filename ?? 'maps-data') 215 - } 216 - 217 - importFromFile(): Promise<{ 218 - success: boolean 219 - path: string 220 - error?: string 221 - }> { 222 - return this.#sync.importFromFile() 223 - } 224 - 225 - dispose(): void { 226 - this.#sync.dispose() 227 - } 228 - }
+6 -4
www/routes/map.ts
··· 373 373 374 374 async #loadWorldTiles(): Promise<void> { 375 375 if (!this.#map) return 376 - let worldFilename = 'world_z5.pmtiles' 376 + let worldFilename = 'world_z3.pmtiles' 377 377 if (!registeredSources.has('world')) { 378 378 const z7 = await getCachedPMTiles('world_z7.pmtiles') 379 379 const z6 = !z7 ? await getCachedPMTiles('world_z6.pmtiles') : null 380 + const z5 = !z6 ? await getCachedPMTiles('world_z5.pmtiles') : null 380 381 if (z7) worldFilename = 'world_z7.pmtiles' 381 382 else if (z6) worldFilename = 'world_z6.pmtiles' 382 - const pmtiles = z7 ?? z6 ?? await downloadAndSavePMTiles( 383 - '/static/tiles/world/world_z5.pmtiles', 384 - 'world_z5.pmtiles', 383 + else if (z5) worldFilename = 'world_z5.pmtiles' 384 + const pmtiles = z7 ?? z6 ?? z5 ?? await downloadAndSavePMTiles( 385 + '/static/tiles/world/world_z3.pmtiles', 386 + 'world_z3.pmtiles', 385 387 ) 386 388 protocol.add(pmtiles) 387 389 registeredSources.add('world')
+11 -6
www/utils/layers.ts
··· 10 10 'type': 'fill', 11 11 'source': sourceName, 12 12 'source-layer': 'landuse', 13 + 'minzoom': 11, 13 14 'filter': [ 14 15 'all', 15 16 ['==', '$type', 'Polygon'], ··· 704 705 'type': 'symbol', 705 706 'source': sourceName, 706 707 'source-layer': 'place', 707 - 'minzoom': 11, 708 - 'maxzoom': 14, 708 + 'minzoom': 12, 709 + 'maxzoom': 15, 709 710 'filter': ['all', ['==', '$type', 'Point'], ['==', 'class', 'town']], 710 711 'layout': { 711 712 'text-anchor': 'center', 712 713 'text-field': '{name:latin}', 713 714 'text-font': ['Noto Sans Regular'], 714 715 'text-max-width': 8, 715 - 'text-size': { 'stops': [[11, 11], [14, 13]] }, 716 + 'text-size': { 'stops': [[12, 10], [15, 13]] }, 717 + 'symbol-sort-key': ['coalesce', ['get', 'rank'], 10], 718 + 'text-allow-overlap': false, 716 719 'visibility': 'visible', 717 720 }, 718 721 'paint': { 719 - 'text-color': 'hsl(0, 0%, 25%)', 722 + 'text-color': 'hsl(0, 0%, 30%)', 720 723 'text-halo-blur': 0, 721 724 'text-halo-color': 'hsl(0, 0%, 100%)', 722 725 'text-halo-width': 1.5, ··· 757 760 'type': 'symbol', 758 761 'source': sourceName, 759 762 'source-layer': 'place', 760 - 'minzoom': 9, 763 + 'minzoom': 11, 761 764 'maxzoom': 16, 762 765 'filter': ['all', ['==', '$type', 'Point'], ['==', 'class', 'city']], 763 766 'layout': { 764 767 'text-field': '{name:latin}', 765 768 'text-font': ['Noto Sans Bold'], 766 769 'text-max-width': 10, 767 - 'text-size': { 'stops': [[9, 12], [13, 16]] }, 770 + 'text-size': { 'stops': [[9, 11], [13, 15]] }, 771 + 'symbol-sort-key': ['coalesce', ['get', 'rank'], 10], 772 + 'text-allow-overlap': false, 768 773 }, 769 774 'paint': { 770 775 'text-color': 'hsl(0, 0%, 0%)',
+3 -57
www/utils/world_layers.ts
··· 53 53 'fill-opacity': { 'base': 1, 'stops': [[6, 0.6], [9, 0.8]] }, 54 54 }, 55 55 }, 56 - { 57 - 'id': 'landcover-farmland', 58 - 'type': 'fill', 59 - 'source': 'world', 60 - 'source-layer': 'landcover', 61 - 'filter': ['==', 'class', 'farmland'], 62 - 'paint': { 63 - 'fill-color': '#eae0d0', 64 - 'fill-opacity': 0.7, 65 - }, 66 - }, 56 + 67 57 { 68 58 'id': 'landcover-wetland', 69 59 'type': 'fill', ··· 107 97 'paint': { 108 98 'fill-color': 'hsl(47, 22%, 94%)', 109 99 'fill-opacity': { 'base': 1, 'stops': [[0, 1], [8, 0.5]] }, 110 - }, 111 - }, 112 - 113 - // ── Landuse ─────────────────────────────────────────────────────────────── 114 - { 115 - 'id': 'landuse-residential', 116 - 'type': 'fill', 117 - 'source': 'world', 118 - 'source-layer': 'landuse', 119 - 'filter': [ 120 - 'in', 121 - 'class', 122 - 'residential', 123 - 'commercial', 124 - 'retail', 125 - 'industrial', 126 - ], 127 - 'paint': { 128 - 'fill-color': 'hsl(47, 13%, 86%)', 129 - 'fill-opacity': 0.7, 130 - }, 131 - }, 132 - { 133 - 'id': 'landuse-civic', 134 - 'type': 'fill', 135 - 'source': 'world', 136 - 'source-layer': 'landuse', 137 - 'filter': ['in', 'class', 'school', 'university', 'hospital'], 138 - 'paint': { 139 - 'fill-color': 'hsl(47, 13%, 86%)', 140 - 'fill-opacity': 0.7, 141 - }, 142 - }, 143 - { 144 - 'id': 'landuse-military', 145 - 'type': 'fill', 146 - 'source': 'world', 147 - 'source-layer': 'landuse', 148 - 'filter': ['==', 'class', 'military'], 149 - 'paint': { 150 - 'fill-color': 'hsl(47, 13%, 86%)', 151 - 'fill-opacity': 0.7, 152 100 }, 153 101 }, 154 102 ··· 279 227 'source': 'world', 280 228 'source-layer': 'place', 281 229 'filter': ['==', 'class', 'city'], 282 - 'minzoom': 3, 283 - // Regional tiles take over place labels from z0, so no maxzoom needed — 284 - // city dots from world and regional will match since both use OSM place data. 230 + 'minzoom': 6, 285 231 'layout': { 286 232 'text-field': ['coalesce', ['get', 'name:latin'], ['get', 'name']], 287 233 'text-font': ['Noto Sans Regular'], 288 234 'text-max-width': 10, 289 - 'text-size': { 'stops': [[3, 12], [8, 16]] }, 235 + 'text-size': { 'stops': [[6, 11], [9, 15]] }, 290 236 }, 291 237 'paint': { 292 238 'text-color': 'hsl(0, 0%, 0%)',