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 routes

+1575 -280
+8 -1
deno.json
··· 1 1 { 2 + "version": "0.0.1", 2 3 "compilerOptions": { 3 4 "lib": [ 4 5 "deno.ns", 5 6 "dom", 6 7 "dom.iterable", 7 8 "dom.asynciterable" 8 - ] 9 + ], 10 + "types": ["./www/index.d.ts"] 9 11 }, 10 12 "fmt": { 11 13 "singleQuote": true, ··· 13 15 "semiColons": false 14 16 }, 15 17 "imports": { 18 + "@civility/store": "jsr:@civility/store@^0.3.1", 19 + "@civility/sync": "jsr:@civility/sync@^0.1.1", 20 + "@civility/ui": "jsr:@civility/ui@^0.2.6", 16 21 "@civility/workers": "jsr:@civility/workers@^0.2.4", 22 + "@zod/zod": "jsr:@zod/zod@^4.3.6", 23 + "lit": "npm:lit@^3.3.2", 17 24 "maplibre-gl": "npm:maplibre-gl@^5.21.0", 18 25 "pmtiles": "npm:pmtiles@^4.4.0", 19 26 "pmtiles-offline": "npm:pmtiles-offline@^1.0.0"
+121
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", 4 8 "jsr:@civility/workers@~0.2.4": "0.2.4", 9 + "jsr:@paulmillr/qr@~0.5.5": "0.5.5", 10 + "jsr:@std/fs@^1.0.23": "1.0.23", 11 + "jsr:@std/html@^1.0.5": "1.0.5", 12 + "jsr:@std/internal@^1.0.12": "1.0.12", 13 + "jsr:@std/path@^1.1.4": "1.1.4", 14 + "jsr:@std/semver@^1.0.8": "1.0.8", 15 + "jsr:@zod/zod@^4.3.6": "4.3.6", 16 + "npm:lit@^3.3.2": "3.3.2", 5 17 "npm:maplibre-gl@^5.21.0": "5.21.0", 18 + "npm:native-file-system-adapter@^3.0.1": "3.0.1", 6 19 "npm:pmtiles-offline@1": "1.0.0", 7 20 "npm:pmtiles@^4.4.0": "4.4.0" 8 21 }, 9 22 "jsr": { 23 + "@civility/store@0.3.1": { 24 + "integrity": "0438f2cdb16145a61a97f5be509cd0b34e7cbd9f71dc657feffe2a4dd7dd0ec3", 25 + "dependencies": [ 26 + "jsr:@std/fs", 27 + "jsr:@std/semver" 28 + ] 29 + }, 30 + "@civility/sync@0.1.1": { 31 + "integrity": "9ef604671656316dffbeea4c0d8c01488c8a2f54269ddcc68c4d4731317174ad", 32 + "dependencies": [ 33 + "jsr:@civility/store@0.3", 34 + "jsr:@paulmillr/qr", 35 + "npm:native-file-system-adapter" 36 + ] 37 + }, 38 + "@civility/ui@0.2.6": { 39 + "integrity": "62e955a70507c708ce03afa845f1421450ea27c28b7d2bfd7753c27ec49680ab", 40 + "dependencies": [ 41 + "jsr:@std/html", 42 + "npm:lit" 43 + ] 44 + }, 10 45 "@civility/workers@0.2.4": { 11 46 "integrity": "38fafb96bc15a988e7723bc9b021394bdfef842c8e8372b960ec2476e5c74b43" 47 + }, 48 + "@paulmillr/qr@0.5.5": { 49 + "integrity": "2f8ff22c8d2194f2147eac1b3093f5e85f648c0a8005d5635a617fb72bf5ae38" 50 + }, 51 + "@std/fs@1.0.23": { 52 + "integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37", 53 + "dependencies": [ 54 + "jsr:@std/path" 55 + ] 56 + }, 57 + "@std/html@1.0.5": { 58 + "integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e" 59 + }, 60 + "@std/internal@1.0.12": { 61 + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" 62 + }, 63 + "@std/path@1.1.4": { 64 + "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", 65 + "dependencies": [ 66 + "jsr:@std/internal" 67 + ] 68 + }, 69 + "@std/semver@1.0.8": { 70 + "integrity": "dc830e8b8b6a380c895d53fbfd1258dc253704ca57bbe1629ac65fd7830179b7" 71 + }, 72 + "@zod/zod@4.3.6": { 73 + "integrity": "7144e5e11f8ffc3cf6e2fca624f6597a8762898aac9868cc8938e9398b96ffe4" 12 74 } 13 75 }, 14 76 "npm": { 77 + "@lit-labs/ssr-dom-shim@1.5.1": { 78 + "integrity": "sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==" 79 + }, 80 + "@lit/reactive-element@2.1.2": { 81 + "integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==", 82 + "dependencies": [ 83 + "@lit-labs/ssr-dom-shim" 84 + ] 85 + }, 15 86 "@mapbox/jsonlint-lines-primitives@2.0.2": { 16 87 "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==" 17 88 }, ··· 84 155 "@types/geojson" 85 156 ] 86 157 }, 158 + "@types/trusted-types@2.0.7": { 159 + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" 160 + }, 87 161 "earcut@3.0.2": { 88 162 "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==" 163 + }, 164 + "fetch-blob@3.2.0": { 165 + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", 166 + "dependencies": [ 167 + "node-domexception", 168 + "web-streams-polyfill" 169 + ] 89 170 }, 90 171 "fflate@0.8.2": { 91 172 "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==" ··· 99 180 "kdbush@4.0.2": { 100 181 "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==" 101 182 }, 183 + "lit-element@4.2.2": { 184 + "integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==", 185 + "dependencies": [ 186 + "@lit-labs/ssr-dom-shim", 187 + "@lit/reactive-element", 188 + "lit-html" 189 + ] 190 + }, 191 + "lit-html@3.3.2": { 192 + "integrity": "sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==", 193 + "dependencies": [ 194 + "@types/trusted-types" 195 + ] 196 + }, 197 + "lit@3.3.2": { 198 + "integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==", 199 + "dependencies": [ 200 + "@lit/reactive-element", 201 + "lit-element", 202 + "lit-html" 203 + ] 204 + }, 102 205 "maplibre-gl@5.21.0": { 103 206 "integrity": "sha512-n0v4J/Ge0EG8ix/z3TY3ragtJYMqzbtSnj1riOC0OwQbzwp0lUF2maS1ve1z8HhitQCKtZZiZJhb8to36aMMfQ==", 104 207 "dependencies": [ ··· 129 232 "murmurhash-js@1.0.0": { 130 233 "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==" 131 234 }, 235 + "native-file-system-adapter@3.0.1": { 236 + "integrity": "sha512-ocuhsYk2SY0906LPc3QIMW+rCV3MdhqGiy7wV5Bf0e8/5TsMjDdyIwhNiVPiKxzTJLDrLT6h8BoV9ERfJscKhw==", 237 + "optionalDependencies": [ 238 + "fetch-blob" 239 + ] 240 + }, 241 + "node-domexception@1.0.0": { 242 + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", 243 + "deprecated": true 244 + }, 132 245 "pbf@4.0.1": { 133 246 "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", 134 247 "dependencies": [ ··· 171 284 }, 172 285 "tinyqueue@3.0.0": { 173 286 "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==" 287 + }, 288 + "web-streams-polyfill@3.3.3": { 289 + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==" 174 290 } 175 291 }, 176 292 "workspace": { 177 293 "dependencies": [ 294 + "jsr:@civility/store@~0.3.1", 295 + "jsr:@civility/sync@~0.1.1", 296 + "jsr:@civility/ui@~0.2.6", 178 297 "jsr:@civility/workers@~0.2.4", 298 + "jsr:@zod/zod@^4.3.6", 299 + "npm:lit@^3.3.2", 179 300 "npm:maplibre-gl@^5.21.0", 180 301 "npm:pmtiles-offline@1", 181 302 "npm:pmtiles@^4.4.0"
+9
www/index.d.ts
··· 1 + export {} 2 + 3 + declare global { 4 + var __APP_VERSION__: string 5 + 6 + interface Navigator { 7 + standalone?: boolean 8 + } 9 + }
+47 -30
www/index.html
··· 1 1 <!DOCTYPE html> 2 2 <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta 6 + name="viewport" 7 + content="width=device-width, initial-scale=1, viewport-fit=cover" 8 + > 9 + <meta name="mobile-web-app-capable" content="yes"> 10 + <meta name="apple-mobile-web-app-capable" content="yes"> 11 + <meta name="description" content="An offline-capable world map"> 3 12 4 - <head> 5 - <meta charset="utf-8" /> 6 - <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> 7 - <meta name="mobile-web-app-capable" content="yes"> 8 - <meta name="apple-mobile-web-app-capable" content="yes"> 9 - <meta name="description" content="An offline-capable worldmap"> 13 + <link rel="manifest" href="manifest.json" /> 14 + <link rel="icon" type="image/x-icon" href="/dist/icons/icon.ico" /> 15 + <link rel="apple-touch-icon" href="/dist/icons/icon.png"> 10 16 11 - <link rel="manifest" href="manifest.json" /> 12 - <link rel="canonical" href="https://world.bpev.me" /> 13 - <title>world</title> 17 + <link rel="canonical" href="https://world.bpev.me" /> 18 + <title>world</title> 14 19 15 - <link rel="preload" href="/static/styles/maplibre-gl.css" as="style" 16 - onload="this.onload=null;this.rel='stylesheet'" /> 17 - <link rel="preload" href="/static/styles/civility.min.css" as="style" 18 - onload="this.onload=null;this.rel='stylesheet'" /> 19 - <link rel="preload" href="/static/styles/theme.css" as="style" onload="this.onload=null;this.rel='stylesheet'" /> 20 + <link rel="stylesheet" type="text/css" href="/static/styles/maplibre-gl.css"> 21 + <link rel="stylesheet" type="text/css" href="/static/styles/civility.min.css"> 22 + <link rel="stylesheet" type="text/css" href="/static/styles/theme.css"> 20 23 21 - <noscript> 22 - <link rel="stylesheet" href="/static/styles/maplibre-gl.css" /> 23 - <link rel="stylesheet" href="/static/styles/civility.min.css" /> 24 - <link rel="stylesheet" href="/static/styles/theme.css" /> 25 - </noscript> 24 + <script src="/dist/index.js" type="module"></script> 25 + </head> 26 + <body> 27 + <a href="#main" class="skip-to-main">Skip to main content</a> 26 28 27 - <script src="/dist/index.js" type="module"></script> 28 - </head> 29 + <header> 30 + <a id="header-back" href="#!/" hidden aria-label="Back"> 31 + ← Back 32 + </a> 33 + <strong id="page-title">World</strong> 34 + </header> 29 35 30 - <body> 31 - <main> 32 - <div id="controls"> 33 - <button id="download-monaco">Download Monaco (250 KB)</button> 34 - <button id="download-taiwan">Download Taiwan (130 MB)</button> 35 - </div> 36 - <div id="map"></div> 37 - </main> 38 - </body> 36 + <main id="main"> 37 + <ui-spinner></ui-spinner> 38 + </main> 39 39 40 + <footer fixed> 41 + <ui-bottom-bar role="navigation" aria-label="Bottom navigation"> 42 + <a href="/" data-route aria-current="page"> 43 + <img src="/static/icons/home.svg" alt="" aria-hidden="true"> 44 + <span>Map</span> 45 + </a> 46 + <a href="/search" data-route> 47 + <img src="/static/icons/navigation.svg" alt="" aria-hidden="true"> 48 + <span>Search</span> 49 + </a> 50 + <a href="/settings" data-route> 51 + <img src="/static/icons/tool.svg" alt="" aria-hidden="true"> 52 + <span>Settings</span> 53 + </a> 54 + </ui-bottom-bar> 55 + </footer> 56 + </body> 40 57 </html>
+67 -69
www/index.ts
··· 1 - import maplibregl from 'maplibre-gl' 2 - import { Protocol } from 'pmtiles' 1 + import { client } from '@civility/workers' 2 + import { createLayoutRouter } from '@civility/ui' 3 + import './routes/map.ts' 4 + import './routes/search.ts' 5 + import './routes/settings.ts' 6 + import './routes/settings-downloads.ts' 7 + import './routes/settings-about.ts' 3 8 4 - import { downloadAndSavePMTiles } from './utils/fs.ts' 5 - import layers from './utils/layers.ts' 6 - import worldLayers from './utils/world_layers.ts' 9 + client.init() 7 10 8 - const protocol = new Protocol() 9 - maplibregl.addProtocol('pmtiles', protocol.tile.bind(protocol)) 11 + interface NavMeta { 12 + title?: string 13 + navActive?: string 14 + backRoute?: string 15 + } 10 16 11 - const map = new maplibregl.Map({ 12 - container: 'map', 13 - center: [7.42661, 43.73488], 14 - zoom: 2, 15 - pitchWithRotate: false, 16 - style: { 17 - version: 8, 18 - glyphs: '/static/basemaps-assets/fonts/{fontstack}/{range}.pbf', 19 - layers: [], 20 - sources: {}, 17 + const { ready } = createLayoutRouter<NavMeta>({ 18 + router: { selectorAttrib: 'data-route', useHash: true }, 19 + defaultRoute: '/', 20 + landmarks: { 21 + main: 'main', 21 22 }, 22 - }) 23 - 24 - map.on('load', async function () { 25 - const pmtiles = await downloadAndSavePMTiles( 26 - '/static/tiles/world.pmtiles', 27 - 'world.pmtiles', 28 - ) 29 - protocol.add(pmtiles) 30 - map.addSource('world', { 31 - type: 'vector', 32 - url: 'pmtiles://world.pmtiles', 33 - attribution: 'Natural Earth', 34 - }) 35 - worldLayers.forEach((layer) => map.addLayer(layer)) 36 - }) 37 - 38 - const loaded: Record<string, boolean> = {} 39 - 40 - async function downloadAndAdd( 41 - name: string, 42 - loc: { center: [number, number]; zoom: number }, 43 - ) { 44 - if (loaded[name]) { 45 - map.flyTo(loc) 46 - return 47 - } 48 - try { 49 - const pmtiles = await downloadAndSavePMTiles( 50 - `/static/tiles/${name}.pmtiles`, 51 - `${name}.pmtiles`, 52 - ) 53 - protocol.add(pmtiles) 54 - map.addSource(name, { type: 'vector', url: `pmtiles://${name}.pmtiles` }) 55 - layers(name).forEach((layer) => map.addLayer(layer)) 23 + afterMount: (_ctx, view) => { 24 + const titleEl = document.querySelector('#page-title') 25 + if (titleEl && view.meta?.title !== undefined) { 26 + titleEl.textContent = view.meta.title 27 + } 56 28 57 - loaded[name] = true 58 - map.flyTo(loc) 59 - } catch (error) { 60 - console.error(`Failed to download or add ${name} region:`, error) 61 - } 62 - } 29 + document.querySelectorAll('ui-bottom-bar a').forEach((link) => { 30 + if ( 31 + view.meta?.navActive && 32 + link.getAttribute('href') === view.meta.navActive 33 + ) { 34 + link.setAttribute('aria-current', 'page') 35 + } else { 36 + link.removeAttribute('aria-current') 37 + } 38 + }) 63 39 64 - document 65 - .getElementById('download-monaco')! 66 - .addEventListener( 67 - 'click', 68 - () => downloadAndAdd('monaco', { center: [7.42661, 43.73488], zoom: 12 }), 69 - ) 40 + const backEl = document.querySelector<HTMLAnchorElement>('#header-back') 41 + if (backEl) { 42 + if (view.meta?.backRoute) { 43 + backEl.setAttribute('href', '#!' + view.meta.backRoute) 44 + backEl.hidden = false 45 + } else { 46 + backEl.hidden = true 47 + } 48 + } 49 + }, 50 + routes: { 51 + '/': { 52 + landmarks: { main: 'r-home' }, 53 + meta: { title: 'World', navActive: '/' }, 54 + }, 55 + '/search': { 56 + landmarks: { main: 'r-search' }, 57 + meta: { title: 'Search', navActive: '/search' }, 58 + }, 59 + '/settings': { 60 + landmarks: { main: 'r-settings' }, 61 + meta: { title: 'Settings', navActive: '/settings' }, 62 + }, 63 + '/settings/downloads': { 64 + landmarks: { main: 'r-settings-downloads' }, 65 + meta: { title: 'Offline Maps', backRoute: '/settings' }, 66 + }, 67 + '/settings/about': { 68 + landmarks: { main: 'r-settings-about' }, 69 + meta: { title: 'About', backRoute: '/settings' }, 70 + }, 71 + }, 72 + }) 70 73 71 - document 72 - .getElementById('download-taiwan')! 73 - .addEventListener( 74 - 'click', 75 - () => downloadAndAdd('taiwan', { center: [121.5987, 25.0047], zoom: 9 }), 76 - ) 74 + ready()
+68
www/models/app.ts
··· 1 + import State from '@civility/store/state' 2 + import { AppState, SearchHistoryEntry } from './schema.ts' 3 + import { Store } from './store.ts' 4 + 5 + export class App extends State<AppState> { 6 + store: Store 7 + 8 + constructor() { 9 + super(AppState.parse({})) 10 + this.store = new Store('world-data') 11 + this.store.addEventListener(() => this.notify()) 12 + } 13 + 14 + get searchHistory(): SearchHistoryEntry[] { 15 + return this.store.searchHistory 16 + } 17 + 18 + async addSearch(query: string): Promise<void> { 19 + await this.store.addSearch(query) 20 + this.notify() 21 + } 22 + 23 + async clearHistory(): Promise<void> { 24 + await this.store.clearHistory() 25 + this.notify() 26 + } 27 + 28 + async exportStore(filename?: string): Promise<{ 29 + success: boolean 30 + path: string 31 + error?: string 32 + }> { 33 + try { 34 + return await this.store.exportToFile(filename ?? `world-export_${Date.now()}`) 35 + } catch (error) { 36 + return { 37 + success: false, 38 + path: '', 39 + error: error instanceof Error ? error.message : 'Export failed', 40 + } 41 + } 42 + } 43 + 44 + async importStore(): Promise<{ 45 + success: boolean 46 + path: string 47 + error?: string 48 + }> { 49 + try { 50 + const result = await this.store.importFromFile() 51 + if (!result.success) return result 52 + this.notify() 53 + return result 54 + } catch (error) { 55 + return { 56 + success: false, 57 + path: '', 58 + error: error instanceof Error ? error.message : 'Import failed', 59 + } 60 + } 61 + } 62 + 63 + dispose(): void { 64 + this.store.dispose() 65 + } 66 + } 67 + 68 + export default new App()
+20
www/models/schema.ts
··· 1 + export { AppState, SearchHistoryEntry, StoreState } from './schema/v0.ts' 2 + import { StoreState } from './schema/v0.ts' 3 + 4 + const currentVersion = globalThis.__APP_VERSION__ 5 + 6 + export const storeMigrationConfig = { 7 + currentVersion, 8 + extractVersion: (data: unknown): string | undefined => 9 + data && typeof data === 'object' && 'version' in data 10 + ? (data as { version: string }).version 11 + : undefined, 12 + migrations: [{ 13 + fromVersion: '<1.0.0', 14 + toVersion: currentVersion, 15 + migrate: (data: unknown) => ({ 16 + ...data as StoreState, 17 + version: globalThis.__APP_VERSION__, 18 + }), 19 + }], 20 + }
+18
www/models/schema/v0.ts
··· 1 + import { z } from '@zod/zod' 2 + 3 + export const SearchHistoryEntry = z.object({ 4 + query: z.string(), 5 + timestamp: z.string(), 6 + }) 7 + export type SearchHistoryEntry = z.infer<typeof SearchHistoryEntry> 8 + 9 + export const StoreState = z.object({ 10 + version: z.string().optional(), 11 + searchHistory: z.array(SearchHistoryEntry).default([]), 12 + }) 13 + export type StoreState = z.infer<typeof StoreState> 14 + 15 + export const AppState = z.object({ 16 + error: z.string().nullable().default(null), 17 + }) 18 + export type AppState = z.infer<typeof AppState>
+73
www/models/store.ts
··· 1 + import SyncLink from '@civility/sync' 2 + import useJSON from '@civility/sync/json' 3 + import { 4 + SearchHistoryEntry, 5 + storeMigrationConfig, 6 + StoreState, 7 + } from './schema.ts' 8 + 9 + export class Store { 10 + #sync: SyncLink<StoreState> 11 + 12 + constructor(name: string) { 13 + this.#sync = new SyncLink( 14 + useJSON<StoreState>(name, StoreState.parse({}), { 15 + migrations: storeMigrationConfig, 16 + }), 17 + ) 18 + } 19 + 20 + get searchHistory(): SearchHistoryEntry[] { 21 + return this.#sync.state.data.searchHistory 22 + } 23 + 24 + addEventListener(fn: () => void): void { 25 + this.#sync.addEventListener(fn) 26 + } 27 + 28 + removeEventListener(fn: () => void): void { 29 + this.#sync.removeEventListener(fn) 30 + } 31 + 32 + async addSearch(query: string): Promise<void> { 33 + const trimmed = query.trim() 34 + if (!trimmed) return 35 + const data = await this.#sync.get() 36 + const existing = data.searchHistory.filter((e) => e.query !== trimmed) 37 + data.searchHistory = [ 38 + SearchHistoryEntry.parse({ query: trimmed, timestamp: new Date().toISOString() }), 39 + ...existing, 40 + ].slice(0, 20) 41 + await this.#sync.set(data) 42 + } 43 + 44 + async clearHistory(): Promise<void> { 45 + const data = await this.#sync.get() 46 + data.searchHistory = [] 47 + await this.#sync.set(data) 48 + } 49 + 50 + async clearAllData(): Promise<void> { 51 + await this.#sync.set(StoreState.parse({})) 52 + } 53 + 54 + exportToFile(filename?: string): Promise<{ 55 + success: boolean 56 + path: string 57 + error?: string 58 + }> { 59 + return this.#sync.exportToFile(filename ?? 'world-data') 60 + } 61 + 62 + importFromFile(): Promise<{ 63 + success: boolean 64 + path: string 65 + error?: string 66 + }> { 67 + return this.#sync.importFromFile() 68 + } 69 + 70 + dispose(): void { 71 + this.#sync.dispose() 72 + } 73 + }
+98
www/routes/map.ts
··· 1 + import { html, LitElement, type TemplateResult } from 'lit' 2 + import maplibregl from 'maplibre-gl' 3 + import { Protocol } from 'pmtiles' 4 + import { getCachedPMTiles, downloadAndSavePMTiles } from '../utils/fs.ts' 5 + import layers from '../utils/layers.ts' 6 + import worldLayers from '../utils/world_layers.ts' 7 + 8 + const protocol = new Protocol() 9 + maplibregl.addProtocol('pmtiles', protocol.tile.bind(protocol)) 10 + 11 + // Track which sources have been registered with the protocol across navigations 12 + const registeredSources = new Set<string>() 13 + 14 + const DETAIL_TILES = [ 15 + { name: 'monaco', filename: 'monaco.pmtiles' }, 16 + { name: 'taiwan', filename: 'taiwan.pmtiles' }, 17 + ] 18 + 19 + export class MapPage extends LitElement { 20 + #map: maplibregl.Map | null = null 21 + 22 + protected override createRenderRoot() { 23 + return this 24 + } 25 + 26 + override render(): TemplateResult { 27 + return html`<div id="map"></div>` 28 + } 29 + 30 + override async firstUpdated(): Promise<void> { 31 + const container = this.querySelector<HTMLElement>('#map') 32 + if (!container) return 33 + 34 + this.#map = new maplibregl.Map({ 35 + container, 36 + center: [20, 20], 37 + zoom: 2, 38 + pitchWithRotate: false, 39 + style: { 40 + version: 8, 41 + glyphs: '/static/basemaps-assets/fonts/{fontstack}/{range}.pbf', 42 + layers: [], 43 + sources: {}, 44 + }, 45 + }) 46 + 47 + this.#map.on('load', async () => { 48 + await this.#loadWorldTiles() 49 + await this.#loadCachedDetailTiles() 50 + }) 51 + } 52 + 53 + override disconnectedCallback() { 54 + super.disconnectedCallback() 55 + this.#map?.remove() 56 + this.#map = null 57 + } 58 + 59 + async #loadWorldTiles(): Promise<void> { 60 + if (!this.#map) return 61 + if (!registeredSources.has('world')) { 62 + const pmtiles = await downloadAndSavePMTiles( 63 + '/static/tiles/world.pmtiles', 64 + 'world.pmtiles', 65 + ) 66 + protocol.add(pmtiles) 67 + registeredSources.add('world') 68 + } 69 + if (!this.#map.getSource('world')) { 70 + this.#map.addSource('world', { 71 + type: 'vector', 72 + url: 'pmtiles://world.pmtiles', 73 + attribution: 'Natural Earth', 74 + }) 75 + worldLayers.forEach((layer) => this.#map!.addLayer(layer)) 76 + } 77 + } 78 + 79 + async #loadCachedDetailTiles(): Promise<void> { 80 + if (!this.#map) return 81 + for (const tile of DETAIL_TILES) { 82 + if (this.#map.getSource(tile.name)) continue 83 + const pmtiles = await getCachedPMTiles(tile.filename) 84 + if (!pmtiles) continue 85 + if (!registeredSources.has(tile.name)) { 86 + protocol.add(pmtiles) 87 + registeredSources.add(tile.name) 88 + } 89 + this.#map.addSource(tile.name, { 90 + type: 'vector', 91 + url: `pmtiles://${tile.filename}`, 92 + }) 93 + layers(tile.name).forEach((layer) => this.#map!.addLayer(layer)) 94 + } 95 + } 96 + } 97 + 98 + customElements.define('r-home', MapPage)
+89
www/routes/search.ts
··· 1 + import { html, LitElement, type TemplateResult } from 'lit' 2 + import app from '../models/app.ts' 3 + 4 + export class SearchPage extends LitElement { 5 + private history = app.searchHistory 6 + 7 + protected override createRenderRoot() { 8 + return this 9 + } 10 + 11 + override connectedCallback() { 12 + super.connectedCallback() 13 + this.history = app.searchHistory 14 + app.addEventListener(this.#onAppUpdate) 15 + } 16 + 17 + override disconnectedCallback() { 18 + super.disconnectedCallback() 19 + app.removeEventListener(this.#onAppUpdate) 20 + } 21 + 22 + #onAppUpdate = () => { 23 + this.history = app.searchHistory 24 + this.requestUpdate() 25 + } 26 + 27 + #handleSearch = async (e: Event) => { 28 + e.preventDefault() 29 + const form = e.target as HTMLFormElement 30 + const input = form.querySelector<HTMLInputElement>('input[type="search"]') 31 + const query = input?.value.trim() 32 + if (!query) return 33 + await app.addSearch(query) 34 + // TODO: perform geocoding and navigate map to result 35 + } 36 + 37 + #handleHistoryClick = (query: string) => { 38 + // TODO: navigate map to previously searched location 39 + const input = this.querySelector<HTMLInputElement>('input[type="search"]') 40 + if (input) input.value = query 41 + } 42 + 43 + #handleClearHistory = async () => { 44 + await app.clearHistory() 45 + } 46 + 47 + override render(): TemplateResult { 48 + return html` 49 + <form class="search-input-wrap" @submit="${this.#handleSearch}"> 50 + <input 51 + type="search" 52 + placeholder="Search for a place..." 53 + autocomplete="off" 54 + autocorrect="off" 55 + spellcheck="false" 56 + > 57 + </form> 58 + 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> 80 + ` 81 + )} 82 + </div> 83 + ` 84 + : html`<p class="search-empty">Search for a location to get started.</p>`} 85 + ` 86 + } 87 + } 88 + 89 + customElements.define('r-search', SearchPage)
+65
www/routes/settings-about.ts
··· 1 + import { html, LitElement, type TemplateResult } from 'lit' 2 + 3 + export class SettingsAboutPage extends LitElement { 4 + protected override createRenderRoot() { 5 + return this 6 + } 7 + 8 + override render(): TemplateResult { 9 + return html` 10 + <section> 11 + <h2>World</h2> 12 + <p> 13 + An offline-first map PWA. Browse detailed street maps and natural 14 + features without an internet connection once tiles are downloaded. 15 + </p> 16 + <ui-pwa-version></ui-pwa-version> 17 + </section> 18 + 19 + <section> 20 + <h2>Map Data</h2> 21 + <p> 22 + World overview tiles are derived from 23 + <a 24 + href="https://www.naturalearthdata.com" 25 + rel="noopener noreferrer" 26 + target="_blank" 27 + >Natural Earth</a>. 28 + Regional tiles are built from 29 + <a 30 + href="https://www.openstreetmap.org" 31 + rel="noopener noreferrer" 32 + target="_blank" 33 + >OpenStreetMap</a> 34 + data via 35 + <a 36 + href="https://download.geofabrik.de" 37 + rel="noopener noreferrer" 38 + target="_blank" 39 + >Geofabrik</a>. 40 + </p> 41 + </section> 42 + 43 + <section> 44 + <h2>Technology</h2> 45 + <p> 46 + Built with 47 + <a href="https://maplibre.org" rel="noopener noreferrer" target="_blank"> 48 + MapLibre GL JS 49 + </a> 50 + and 51 + <a href="https://protomaps.com/docs/pmtiles" rel="noopener noreferrer" target="_blank"> 52 + PMTiles 53 + </a> 54 + for offline tile storage. 55 + Rendered as a PWA using 56 + <a href="https://github.com/bpev/civility" rel="noopener noreferrer" target="_blank"> 57 + Civility 58 + </a>. 59 + </p> 60 + </section> 61 + ` 62 + } 63 + } 64 + 65 + customElements.define('r-settings-about', SettingsAboutPage)
+160
www/routes/settings-downloads.ts
··· 1 + import { html, LitElement, type TemplateResult } from 'lit' 2 + import { deletePMTiles, downloadAndSavePMTiles, isPMTilesCached } from '../utils/fs.ts' 3 + 4 + type TileStatus = 'checking' | 'cached' | 'available' | 'downloading' | 'deleting' | 'error' 5 + 6 + type TileConfig = { 7 + id: string 8 + label: string 9 + description: string 10 + filename: string 11 + path: string 12 + } 13 + 14 + const TILES: TileConfig[] = [ 15 + { 16 + id: 'monaco', 17 + label: 'Monaco', 18 + description: 'Detailed street map · ~250 KB', 19 + filename: 'monaco.pmtiles', 20 + path: '/static/tiles/monaco.pmtiles', 21 + }, 22 + { 23 + id: 'taiwan', 24 + label: 'Taiwan', 25 + description: 'Detailed street map · ~130 MB', 26 + filename: 'taiwan.pmtiles', 27 + path: '/static/tiles/taiwan.pmtiles', 28 + }, 29 + ] 30 + 31 + export class SettingsDownloadsPage extends LitElement { 32 + private statuses: Record<string, TileStatus> = Object.fromEntries( 33 + TILES.map((t) => [t.id, 'checking']), 34 + ) 35 + private errors: Record<string, string> = {} 36 + 37 + protected override createRenderRoot() { 38 + return this 39 + } 40 + 41 + override async connectedCallback() { 42 + super.connectedCallback() 43 + await this.#checkStatuses() 44 + } 45 + 46 + async #checkStatuses(): Promise<void> { 47 + await Promise.all( 48 + TILES.map(async (tile) => { 49 + const cached = await isPMTilesCached(tile.filename) 50 + this.statuses[tile.id] = cached ? 'cached' : 'available' 51 + }), 52 + ) 53 + this.requestUpdate() 54 + } 55 + 56 + async #handleDownload(tile: TileConfig): Promise<void> { 57 + this.statuses[tile.id] = 'downloading' 58 + this.requestUpdate() 59 + try { 60 + await downloadAndSavePMTiles(tile.path, tile.filename) 61 + this.statuses[tile.id] = 'cached' 62 + } catch (err) { 63 + this.statuses[tile.id] = 'error' 64 + this.errors[tile.id] = err instanceof Error ? err.message : 'Download failed' 65 + } 66 + this.requestUpdate() 67 + } 68 + 69 + async #handleDelete(tile: TileConfig): Promise<void> { 70 + this.statuses[tile.id] = 'deleting' 71 + this.requestUpdate() 72 + try { 73 + await deletePMTiles(tile.filename) 74 + this.statuses[tile.id] = 'available' 75 + } catch (err) { 76 + this.statuses[tile.id] = 'error' 77 + this.errors[tile.id] = err instanceof Error ? err.message : 'Delete failed' 78 + } 79 + this.requestUpdate() 80 + } 81 + 82 + override render(): TemplateResult { 83 + return html` 84 + <section> 85 + <h2>Offline Maps</h2> 86 + <p> 87 + Download regions to use the app without an internet connection. Downloaded 88 + maps are stored in your browser and persist across sessions. 89 + </p> 90 + <div class="tile-list"> 91 + ${TILES.map((tile) => this.#renderTileItem(tile))} 92 + </div> 93 + </section> 94 + ` 95 + } 96 + 97 + #renderTileItem(tile: TileConfig): TemplateResult { 98 + const status = this.statuses[tile.id] 99 + return html` 100 + <div class="tile-item"> 101 + <div class="tile-item-info"> 102 + <span class="tile-item-name">${tile.label}</span> 103 + <div class="tile-item-meta">${tile.description}</div> 104 + ${status === 'error' 105 + ? html`<div class="tile-item-meta settings-data-status--error"> 106 + ${this.errors[tile.id] ?? 'Download failed'} 107 + </div>` 108 + : ''} 109 + </div> 110 + <div class="tile-item-action"> 111 + ${this.#renderTileAction(tile, status)} 112 + </div> 113 + </div> 114 + ` 115 + } 116 + 117 + #renderTileAction(tile: TileConfig, status: TileStatus): TemplateResult { 118 + if (status === 'checking') { 119 + return html`<ui-spinner></ui-spinner>` 120 + } 121 + if (status === 'cached') { 122 + return html` 123 + <span class="tile-status-cached">✓ Downloaded</span> 124 + <button 125 + class="action" 126 + style="width:auto;padding:var(--s2) var(--s3)" 127 + @click="${() => this.#handleDelete(tile)}" 128 + > 129 + <img 130 + src="/static/icons/x.svg" 131 + alt="" 132 + aria-hidden="true" 133 + style="width:16px;height:16px" 134 + > 135 + Remove 136 + </button> 137 + ` 138 + } 139 + if (status === 'downloading' || status === 'deleting') { 140 + return html`<ui-spinner></ui-spinner>` 141 + } 142 + return html` 143 + <button 144 + class="action" 145 + style="width:auto;padding:var(--s2) var(--s3)" 146 + @click="${() => this.#handleDownload(tile)}" 147 + > 148 + <img 149 + src="/static/icons/download.svg" 150 + alt="" 151 + aria-hidden="true" 152 + style="width:16px;height:16px" 153 + > 154 + Download 155 + </button> 156 + ` 157 + } 158 + } 159 + 160 + customElements.define('r-settings-downloads', SettingsDownloadsPage)
+104
www/routes/settings.ts
··· 1 + import { html, LitElement, type TemplateResult } from 'lit' 2 + import app from '../models/app.ts' 3 + 4 + export class SettingsPage extends LitElement { 5 + protected override createRenderRoot() { 6 + return this 7 + } 8 + 9 + override render(): TemplateResult { 10 + return html` 11 + <section> 12 + <h2>About</h2> 13 + <ui-pwa-version></ui-pwa-version> 14 + <ui-pwa-install></ui-pwa-install> 15 + </section> 16 + 17 + <section> 18 + <h2>Map</h2> 19 + <nav class="settings-nav-list"> 20 + <a class="settings-nav-link" href="#!/settings/downloads"> 21 + <div> 22 + <span>Offline Maps</span> 23 + <div class="settings-nav-link-meta">Download regions for offline use</div> 24 + </div> 25 + <img 26 + src="/static/icons/download.svg" 27 + alt="" 28 + aria-hidden="true" 29 + style="width:18px;height:18px;opacity:0.5" 30 + > 31 + </a> 32 + </nav> 33 + </section> 34 + 35 + <section> 36 + <h2>Data</h2> 37 + <p>Export your data to a file, or import a previously exported file.</p> 38 + <div class="settings-data-actions"> 39 + <button class="action" id="settings-export" @click="${this.#handleExport}"> 40 + Export 41 + </button> 42 + <button class="action" id="settings-import" @click="${this.#handleImport}"> 43 + Import 44 + </button> 45 + </div> 46 + <p id="settings-data-status" class="settings-data-status" hidden></p> 47 + </section> 48 + 49 + <section> 50 + <h2>Info</h2> 51 + <nav class="settings-nav-list"> 52 + <a class="settings-nav-link" href="#!/settings/about"> 53 + <div> 54 + <span>About</span> 55 + <div class="settings-nav-link-meta">App info and acknowledgements</div> 56 + </div> 57 + <img 58 + src="/static/icons/book.svg" 59 + alt="" 60 + aria-hidden="true" 61 + style="width:18px;height:18px;opacity:0.5" 62 + > 63 + </a> 64 + </nav> 65 + </section> 66 + ` 67 + } 68 + 69 + #setStatus(msg: string, isError = false): void { 70 + const el = this.querySelector<HTMLElement>('#settings-data-status') 71 + if (!el) return 72 + el.textContent = msg 73 + el.hidden = false 74 + el.className = isError 75 + ? 'settings-data-status settings-data-status--error' 76 + : 'settings-data-status' 77 + } 78 + 79 + async #handleExport(): Promise<void> { 80 + const btn = this.querySelector<HTMLButtonElement>('#settings-export') 81 + if (btn) btn.disabled = true 82 + const result = await app.exportStore() 83 + if (btn) btn.disabled = false 84 + if (result.success) { 85 + this.#setStatus('Data exported successfully.') 86 + } else { 87 + this.#setStatus(result.error ?? 'Export failed.', true) 88 + } 89 + } 90 + 91 + async #handleImport(): Promise<void> { 92 + const btn = this.querySelector<HTMLButtonElement>('#settings-import') 93 + if (btn) btn.disabled = true 94 + const result = await app.importStore() 95 + if (btn) btn.disabled = false 96 + if (result.success) { 97 + this.#setStatus('Data imported successfully.') 98 + } else { 99 + this.#setStatus(result.error ?? 'Import failed.', true) 100 + } 101 + } 102 + } 103 + 104 + customElements.define('r-settings', SettingsPage)
+14
www/static/icons/activity.svg
··· 1 + <svg 2 + xmlns="http://www.w3.org/2000/svg" 3 + width="24" 4 + height="24" 5 + viewBox="0 0 24 24" 6 + fill="none" 7 + stroke="currentColor" 8 + stroke-width="2" 9 + stroke-linecap="round" 10 + stroke-linejoin="round" 11 + class="feather feather-activity" 12 + > 13 + <polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline> 14 + </svg>
+19
www/static/icons/archive.svg
··· 1 + <svg 2 + xmlns="http://www.w3.org/2000/svg" 3 + width="24" 4 + height="24" 5 + viewBox="0 0 24 24" 6 + fill="none" 7 + stroke="currentColor" 8 + stroke-width="2" 9 + stroke-linecap="round" 10 + stroke-linejoin="round" 11 + class="feather feather-archive" 12 + > 13 + <polyline points="21 8 21 21 3 21 3 8"></polyline><rect 14 + x="1" 15 + y="3" 16 + width="22" 17 + height="5" 18 + ></rect><line x1="10" y1="12" x2="14" y2="12"></line> 19 + </svg>
+19
www/static/icons/bar-chart-2.svg
··· 1 + <svg 2 + xmlns="http://www.w3.org/2000/svg" 3 + width="24" 4 + height="24" 5 + viewBox="0 0 24 24" 6 + fill="none" 7 + stroke="currentColor" 8 + stroke-width="2" 9 + stroke-linecap="round" 10 + stroke-linejoin="round" 11 + class="feather feather-bar-chart-2" 12 + > 13 + <line x1="18" y1="20" x2="18" y2="10"></line><line 14 + x1="12" 15 + y1="20" 16 + x2="12" 17 + y2="4" 18 + ></line><line x1="6" y1="20" x2="6" y2="14"></line> 19 + </svg>
+16
www/static/icons/book.svg
··· 1 + <svg 2 + xmlns="http://www.w3.org/2000/svg" 3 + width="24" 4 + height="24" 5 + viewBox="0 0 24 24" 6 + fill="none" 7 + stroke="currentColor" 8 + stroke-width="2" 9 + stroke-linecap="round" 10 + stroke-linejoin="round" 11 + class="feather feather-book" 12 + > 13 + <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path 14 + d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" 15 + ></path> 16 + </svg>
+14
www/static/icons/bookmark.svg
··· 1 + <svg 2 + xmlns="http://www.w3.org/2000/svg" 3 + width="24" 4 + height="24" 5 + viewBox="0 0 24 24" 6 + fill="none" 7 + stroke="currentColor" 8 + stroke-width="2" 9 + stroke-linecap="round" 10 + stroke-linejoin="round" 11 + class="feather feather-bookmark" 12 + > 13 + <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path> 14 + </svg>
+16
www/static/icons/copy.svg
··· 1 + <svg 2 + xmlns="http://www.w3.org/2000/svg" 3 + width="24" 4 + height="24" 5 + viewBox="0 0 24 24" 6 + fill="none" 7 + stroke="currentColor" 8 + stroke-width="2" 9 + stroke-linecap="round" 10 + stroke-linejoin="round" 11 + class="feather feather-copy" 12 + > 13 + <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path 14 + d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" 15 + ></path> 16 + </svg>
+15
www/static/icons/download.svg
··· 1 + <svg 2 + xmlns="http://www.w3.org/2000/svg" 3 + width="24" 4 + height="24" 5 + viewBox="0 0 24 24" 6 + fill="none" 7 + stroke="currentColor" 8 + stroke-width="2" 9 + stroke-linecap="round" 10 + stroke-linejoin="round" 11 + > 12 + <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> 13 + <polyline points="7,10 12,15 17,10" /> 14 + <line x1="12" y1="15" x2="12" y2="3" /> 15 + </svg>
+21
www/static/icons/external-link.svg
··· 1 + <svg 2 + xmlns="http://www.w3.org/2000/svg" 3 + width="24" 4 + height="24" 5 + viewBox="0 0 24 24" 6 + fill="none" 7 + stroke="currentColor" 8 + stroke-width="2" 9 + stroke-linecap="round" 10 + stroke-linejoin="round" 11 + class="feather feather-external-link" 12 + > 13 + <path 14 + d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" 15 + ></path><polyline points="15 3 21 3 21 9"></polyline><line 16 + x1="10" 17 + y1="14" 18 + x2="21" 19 + y2="3" 20 + ></line> 21 + </svg>
+16
www/static/icons/home.svg
··· 1 + <svg 2 + xmlns="http://www.w3.org/2000/svg" 3 + width="24" 4 + height="24" 5 + viewBox="0 0 24 24" 6 + fill="none" 7 + stroke="currentColor" 8 + stroke-width="2" 9 + stroke-linecap="round" 10 + stroke-linejoin="round" 11 + class="feather feather-home" 12 + > 13 + <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline 14 + points="9 22 9 12 15 12 15 22" 15 + ></polyline> 16 + </svg>
+21
www/static/icons/maximize-2.svg
··· 1 + <svg 2 + xmlns="http://www.w3.org/2000/svg" 3 + width="24" 4 + height="24" 5 + viewBox="0 0 24 24" 6 + fill="none" 7 + stroke="currentColor" 8 + stroke-width="2" 9 + stroke-linecap="round" 10 + stroke-linejoin="round" 11 + class="feather feather-maximize-2" 12 + > 13 + <polyline points="15 3 21 3 21 9"></polyline><polyline 14 + points="9 21 3 21 3 15" 15 + ></polyline><line x1="21" y1="3" x2="14" y2="10"></line><line 16 + x1="3" 17 + y1="21" 18 + x2="10" 19 + y2="14" 20 + ></line> 21 + </svg>
+21
www/static/icons/minimize-2.svg
··· 1 + <svg 2 + xmlns="http://www.w3.org/2000/svg" 3 + width="24" 4 + height="24" 5 + viewBox="0 0 24 24" 6 + fill="none" 7 + stroke="currentColor" 8 + stroke-width="2" 9 + stroke-linecap="round" 10 + stroke-linejoin="round" 11 + class="feather feather-minimize-2" 12 + > 13 + <polyline points="4 14 10 14 10 20"></polyline><polyline 14 + points="20 10 14 10 14 4" 15 + ></polyline><line x1="14" y1="10" x2="21" y2="3"></line><line 16 + x1="3" 17 + y1="21" 18 + x2="10" 19 + y2="14" 20 + ></line> 21 + </svg>
+18
www/static/icons/more-vertical.svg
··· 1 + <svg 2 + xmlns="http://www.w3.org/2000/svg" 3 + width="24" 4 + height="24" 5 + viewBox="0 0 24 24" 6 + fill="none" 7 + stroke="currentColor" 8 + stroke-width="2" 9 + stroke-linecap="round" 10 + stroke-linejoin="round" 11 + class="feather feather-more-vertical" 12 + > 13 + <circle cx="12" cy="12" r="1"></circle><circle 14 + cx="12" 15 + cy="5" 16 + r="1" 17 + ></circle><circle cx="12" cy="19" r="1"></circle> 18 + </svg>
+14
www/static/icons/navigation.svg
··· 1 + <svg 2 + xmlns="http://www.w3.org/2000/svg" 3 + width="24" 4 + height="24" 5 + viewBox="0 0 24 24" 6 + fill="none" 7 + stroke="currentColor" 8 + stroke-width="2" 9 + stroke-linecap="round" 10 + stroke-linejoin="round" 11 + class="feather feather-navigation" 12 + > 13 + <polygon points="3 11 22 2 13 21 11 13 3 11"></polygon> 14 + </svg>
+19
www/static/icons/plus.svg
··· 1 + <svg 2 + xmlns="http://www.w3.org/2000/svg" 3 + width="24" 4 + height="24" 5 + viewBox="0 0 24 24" 6 + fill="none" 7 + stroke="currentColor" 8 + stroke-width="2" 9 + stroke-linecap="round" 10 + stroke-linejoin="round" 11 + class="feather feather-plus" 12 + > 13 + <line x1="12" y1="5" x2="12" y2="19"></line><line 14 + x1="5" 15 + y1="12" 16 + x2="19" 17 + y2="12" 18 + ></line> 19 + </svg>
+16
www/static/icons/tag.svg
··· 1 + <svg 2 + xmlns="http://www.w3.org/2000/svg" 3 + width="24" 4 + height="24" 5 + viewBox="0 0 24 24" 6 + fill="none" 7 + stroke="currentColor" 8 + stroke-width="2" 9 + stroke-linecap="round" 10 + stroke-linejoin="round" 11 + class="feather feather-tag" 12 + > 13 + <path 14 + d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" 15 + ></path><line x1="7" y1="7" x2="7.01" y2="7"></line> 16 + </svg>
+16
www/static/icons/tool.svg
··· 1 + <svg 2 + xmlns="http://www.w3.org/2000/svg" 3 + width="24" 4 + height="24" 5 + viewBox="0 0 24 24" 6 + fill="none" 7 + stroke="currentColor" 8 + stroke-width="2" 9 + stroke-linecap="round" 10 + stroke-linejoin="round" 11 + class="feather feather-tool" 12 + > 13 + <path 14 + d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" 15 + ></path> 16 + </svg>
+14
www/static/icons/x.svg
··· 1 + <svg 2 + xmlns="http://www.w3.org/2000/svg" 3 + width="24" 4 + height="24" 5 + viewBox="0 0 24 24" 6 + fill="none" 7 + stroke="currentColor" 8 + stroke-width="2" 9 + stroke-linecap="round" 10 + stroke-linejoin="round" 11 + > 12 + <line x1="18" y1="6" x2="6" y2="18" /> 13 + <line x1="6" y1="6" x2="18" y2="18" /> 14 + </svg>
+308 -177
www/static/styles/theme.css
··· 1 - :root { 2 - /* Typography */ 3 - --font-family-base: Helvetica, sans-serif; 4 - --font-family-mono: 5 - ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; 1 + /** 2 + * World Theme 3 + */ 4 + 5 + @layer theme { 6 + :root { 7 + /* Typography */ 8 + --font-family-base: 9 + 'Inter', 'SF Pro Text', -apple-system, BlinkMacSystemFont, 'Segoe UI', 10 + 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif; 11 + 12 + /* Font size overrides (mobile-first) */ 13 + --f4: 1rem; 14 + --f5: 0.875rem; 15 + --f6: 0.75rem; 16 + --f7: 0.625rem; 17 + 18 + /* Spacing */ 19 + --s5: 2rem; 20 + --s6: 3rem; 21 + 22 + /* Border radius */ 23 + --br-sm: 6px; 24 + --br-base: 10px; 25 + --br-lg: 20px; 26 + --br-full: 9999px; 27 + 28 + /* Transitions */ 29 + --transition-fast: 0.15s cubic-bezier(0.4, 0, 0.2, 1); 30 + --transition-base: 0.25s cubic-bezier(0.4, 0, 0.2, 1); 31 + 32 + /* Primary: steel blue (from manifest theme_color #6baed6) */ 33 + --primaryH: 207; 34 + --primaryS: 55%; 35 + --primaryL: 62%; 36 + 37 + /* Border: lighter shade of primary */ 38 + --borderH: 207; 39 + --borderS: 55%; 40 + --borderL: 80%; 41 + } 6 42 7 - /* FONT SIZE */ 8 - --f-headline: 6rem; 9 - --f-subheadline: 5rem; 10 - --f1: 3rem; 11 - --f2: 2.25rem; 12 - --f3: 1.5rem; 13 - --f4: 1.25rem; 14 - --f5: 1rem; 15 - --f6: 0.875rem; 16 - --f7: 0.75rem; 43 + :root { 44 + --header-height: 56px; 45 + --footer-height: 64px; 46 + --main-padding: 0; 47 + } 48 + 49 + html { 50 + -ms-overflow-style: none; 51 + scrollbar-width: none; 52 + } 53 + 54 + /* ── Map page height chain ──────────────────────────────────────────────── */ 55 + 56 + /* Fix body height when map is visible so map fills remaining space */ 57 + body:has(r-home) { 58 + height: 100dvh; 59 + overflow: hidden; 60 + } 61 + 62 + body:has(r-home) > main { 63 + overflow: hidden; 64 + } 65 + 66 + r-home { 67 + display: block; 68 + height: 100%; 69 + overflow: hidden; 70 + } 71 + 72 + r-home #map { 73 + height: 100%; 74 + background-color: #6baed6; 75 + } 76 + 77 + /* ── Header ───────────────────────────────────────────────────────────── */ 78 + 79 + header { 80 + display: flex; 81 + align-items: center; 82 + gap: var(--s2); 83 + height: var(--header-height); 84 + padding: 0 var(--container-padding); 85 + border-bottom: 1px solid currentColor; 86 + } 87 + 88 + header strong { 89 + flex: 1; 90 + font-size: var(--f4); 91 + font-weight: var(--fw-semibold); 92 + overflow: hidden; 93 + text-overflow: ellipsis; 94 + white-space: nowrap; 95 + } 96 + 97 + #header-back[hidden] { 98 + display: none; 99 + } 100 + 101 + #header-back { 102 + display: flex; 103 + align-items: center; 104 + gap: var(--s1); 105 + font-size: var(--f6); 106 + font-weight: var(--fw-medium); 107 + color: var(--primary); 108 + text-decoration: none; 109 + padding: var(--s1) var(--s2); 110 + border-radius: var(--br-sm); 111 + white-space: nowrap; 112 + flex-shrink: 0; 113 + transition: opacity var(--transition-fast); 114 + } 115 + 116 + #header-back:hover { 117 + opacity: 0.7; 118 + } 17 119 18 - /* FONT WEIGHT */ 19 - --fw-normal: 400; 20 - --fw-medium: 500; 21 - --fw-semibold: 600; 22 - --fw-bold: 700; 120 + /* ── Fixed bottom tab bar ──────────────────────────────────────────────── */ 23 121 24 - /* LINE HEIGHT */ 25 - --lh-solid: 1; 26 - --lh-title: 1.25; 27 - --lh-tight: 1.2; 28 - --lh-copy: 1.5; 29 - --lh-base: 1.5; 30 - --lh-relaxed: 1.7; 122 + footer[fixed] { 123 + padding: 0; 124 + height: var(--footer-height); 125 + } 31 126 32 - /* SPACING */ 33 - --s0: 0; 34 - --s1: 0.25rem; 35 - --s2: 0.5rem; 36 - --s3: 1rem; 37 - --s4: 2rem; 38 - --s5: 4rem; 39 - --s6: 8rem; 40 - --s7: 16rem; 127 + ui-bottom-bar img { 128 + width: 24px; 129 + height: 24px; 130 + } 41 131 42 - /* BORDER RADIUS */ 43 - --br0: 0; 44 - --br1: 0.125rem; 45 - --br2: 0.25rem; 46 - --br3: 0.5rem; 47 - --br4: 1rem; 48 - --br-100: 100%; 49 - --br-pill: 9999px; 50 - --br-none: 0; 51 - --br-sm: 0.25rem; 52 - --br-base: 0rem; 53 - --br-lg: 0.5rem; 54 - --br-xl: 1rem; 55 - --br-full: 9999px; 132 + /* ── Page host elements ────────────────────────────────────────────────── */ 56 133 57 - /* BORDER WIDTH */ 58 - --bw0: 0; 59 - --bw1: 0.125rem; 60 - --bw2: 0.25rem; 61 - --bw3: 0.5rem; 62 - --bw4: 1rem; 63 - --bw5: 2rem; 134 + r-search, 135 + r-settings, 136 + r-settings-downloads, 137 + r-settings-about { 138 + display: block; 139 + padding: var(--s4) var(--s3); 140 + max-width: 600px; 141 + margin: 0 auto; 142 + box-sizing: border-box; 143 + } 64 144 65 - /* Shadows */ 66 - --shadow-sm: var(--black) 3px 3px 0px 0px; 67 - --shadow-base: var(--black) 3px 3px 0px 0px; 68 - --shadow-md: var(--black) 3px 3px 0px 0px; 69 - --shadow-lg: var(--black) 3px 3px 0px 0px; 70 - --shadow-xl: var(--black) 3px 3px 0px 0px; 145 + /* ── Generic settings sections ──────────────────────────────────────────── */ 71 146 72 - /* Hanzi-specific shadows */ 73 - --purple-shadow: hsl(var(--purpleH), var(--purpleS), var(--purpleL)) 3px 3px 0px 0px; 74 - --blue-shadow: hsl(var(--blueH), var(--blueS), var(--blueL)) 3px 3px 0px 0px; 75 - --pink-shadow: hsl(var(--pinkH), var(--pinkS), var(--pinkL)) 3px 3px 0px 0px; 147 + r-settings section, 148 + r-settings-downloads section, 149 + r-settings-about section { 150 + margin-bottom: var(--s5); 151 + } 76 152 77 - /* Transitions */ 78 - --transition-fast: 0.05s ease-in-out; 79 - --transition-base: 0.1s ease-in-out; 80 - --transition-slow: 0.2s ease-in-out; 153 + r-settings h2, 154 + r-settings-downloads h2, 155 + r-settings-about h2 { 156 + font-size: var(--f4); 157 + font-weight: var(--fw-semibold); 158 + margin-bottom: var(--s3); 159 + } 81 160 82 - /* Layout */ 83 - --max-width-sm: 640px; 84 - --max-width-md: 768px; 85 - --max-width-lg: 1024px; 86 - --max-width-xl: 1280px; 87 - --max-width-2xl: 1536px; 161 + r-settings section > p, 162 + r-settings-downloads section > p, 163 + r-settings-about section > p { 164 + opacity: 0.6; 165 + margin-bottom: var(--s3); 166 + font-size: var(--f5); 167 + } 88 168 89 - /* Hanzi app layout customization */ 90 - --main-max-width: var(--max-width-sm); 91 - --main-padding: var(--s3); 92 - --footer-padding: var(--s3) var(--s3); 169 + /* ── Settings nav links (to sub-pages) ────────────────────────────────── */ 93 170 94 - /* Z-index scale */ 95 - --z-dropdown: 1000; 96 - --z-sticky: 1020; 97 - --z-fixed: 1030; 98 - --z-modal-backdrop: 1040; 99 - --z-modal: 1050; 100 - --z-popover: 1060; 101 - --z-tooltip: 1070; 171 + .settings-nav-list { 172 + display: flex; 173 + flex-direction: column; 174 + gap: var(--s2); 175 + } 102 176 103 - /* Grid and breakpoints */ 104 - --grid-columns: 12; 105 - --container-padding: var(--s3); 177 + .settings-nav-link { 178 + display: flex; 179 + align-items: center; 180 + justify-content: space-between; 181 + padding: var(--s3); 182 + border: 1px solid currentColor; 183 + border-radius: var(--br-base); 184 + text-decoration: none; 185 + color: inherit; 186 + font-weight: var(--fw-medium); 187 + font-size: var(--f5); 188 + transition: opacity var(--transition-fast); 189 + } 106 190 107 - /* Navigation customization */ 108 - --nav-max-width: var(--max-width-sm); 191 + .settings-nav-link:hover { 192 + opacity: 0.7; 193 + } 109 194 110 - /* Animation curves */ 111 - --ease-in: cubic-bezier(0.4, 0, 1, 1); 112 - --ease-out: cubic-bezier(0, 0, 0.2, 1); 113 - --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); 195 + .settings-nav-link-meta { 196 + font-size: var(--f6); 197 + opacity: 0.5; 198 + font-weight: var(--fw-normal); 199 + margin-top: 2px; 200 + } 114 201 115 - /* Neutral colors */ 116 - --blackH: 0; 117 - --blackS: 0%; 118 - --blackL: 0%; 202 + /* ── Data actions (import/export) ────────────────────────────────────── */ 119 203 120 - --grayH: 0; 121 - --grayS: 0%; 122 - --grayL: 50%; 204 + .action { 205 + display: flex; 206 + align-items: center; 207 + justify-content: center; 208 + gap: var(--s2); 209 + padding: var(--s3); 210 + border: 1px solid currentColor; 211 + border-radius: var(--br-base); 212 + text-decoration: none; 213 + color: inherit; 214 + font-weight: var(--fw-medium); 215 + transition: opacity var(--transition-base); 216 + width: 100%; 217 + box-sizing: border-box; 218 + background: transparent; 219 + font-size: var(--f5); 220 + } 123 221 124 - --whiteH: 0; 125 - --whiteS: 0%; 126 - --whiteL: 100%; 222 + .action:hover { 223 + opacity: 0.7; 224 + transform: none; 225 + } 127 226 128 - /* Hanzi app brand colors - converted from original RGB values */ 129 - --purpleH: 273; 130 - --purpleS: 52%; 131 - --purpleL: 26%; 227 + .settings-data-actions { 228 + display: flex; 229 + gap: var(--s3); 230 + } 132 231 133 - --blueH: 218; 134 - --blueS: 100%; 135 - --blueL: 21%; 232 + .settings-data-status { 233 + margin-top: var(--s3); 234 + font-size: var(--f6); 235 + opacity: 0.8; 236 + } 136 237 137 - --pinkH: 359; 138 - --pinkS: 100%; 139 - --pinkL: 21%; 238 + .settings-data-status--error { 239 + color: var(--error); 240 + } 140 241 141 - /* Additional brand colors */ 142 - --greenH: 120; 143 - --greenS: 73%; 144 - --greenL: 75%; 242 + /* ── Downloads page ─────────────────────────────────────────────────────── */ 145 243 146 - --redH: 0; 147 - --redS: 73%; 148 - --redL: 69%; 244 + .tile-list { 245 + display: flex; 246 + flex-direction: column; 247 + gap: var(--s2); 248 + } 149 249 150 - --yellowH: 45; 151 - --yellowS: 93%; 152 - --yellowL: 47%; 250 + .tile-item { 251 + display: flex; 252 + align-items: center; 253 + justify-content: space-between; 254 + gap: var(--s3); 255 + padding: var(--s3); 256 + border: 1px solid currentColor; 257 + border-radius: var(--br-base); 258 + } 153 259 154 - --orangeH: 25; 155 - --orangeS: 95%; 156 - --orangeL: 53%; 260 + .tile-item-info { 261 + flex: 1; 262 + min-width: 0; 263 + } 157 264 158 - /* Semantic colors */ 159 - --errorH: var(--redH); 160 - --errorS: var(--redS); 161 - --errorL: var(--redL); 265 + .tile-item-name { 266 + font-weight: var(--fw-semibold); 267 + font-size: var(--f5); 268 + display: block; 269 + } 162 270 163 - --warningH: var(--orangeH); 164 - --warningS: var(--orangeS); 165 - --warningL: var(--orangeL); 271 + .tile-item-meta { 272 + font-size: var(--f6); 273 + opacity: 0.6; 274 + margin-top: 2px; 275 + } 166 276 167 - --infoH: var(--blueH); 168 - --infoS: var(--blueS); 169 - --infoL: var(--blueL); 277 + .tile-item-action { 278 + display: flex; 279 + align-items: center; 280 + gap: var(--s2); 281 + flex-shrink: 0; 282 + } 170 283 171 - --successH: var(--greenH); 172 - --successS: var(--greenS); 173 - --successL: var(--greenL); 284 + .tile-status-cached { 285 + font-size: var(--f6); 286 + font-weight: var(--fw-medium); 287 + color: var(--success); 288 + white-space: nowrap; 289 + } 174 290 175 - /* Application colors */ 176 - --backgroundH: var(--whiteH); 177 - --backgroundS: var(--whiteS); 178 - --backgroundL: var(--whiteL); 291 + /* ── Search page ────────────────────────────────────────────────────────── */ 179 292 180 - --bodyH: var(--blackH); 181 - --bodyS: var(--blackS); 182 - --bodyL: 13%; 293 + .search-input-wrap { 294 + margin-bottom: var(--s4); 295 + } 183 296 184 - --secondaryH: var(--grayH); 185 - --secondaryS: var(--grayS); 186 - --secondaryL: var(--grayL); 297 + .search-input-wrap input[type='search'] { 298 + width: 100%; 299 + padding: var(--s2) var(--s3); 300 + border-radius: var(--br-full); 301 + font-size: var(--f5); 302 + box-sizing: border-box; 303 + margin: 0; 304 + } 187 305 188 - --primaryH: var(--purpleH); 189 - --primaryS: var(--purpleS); 190 - --primaryL: var(--purpleL); 191 - } 306 + .search-history-list { 307 + display: flex; 308 + flex-direction: column; 309 + } 192 310 193 - body { 194 - margin: 0; 195 - } 311 + .search-history-item { 312 + display: flex; 313 + align-items: center; 314 + justify-content: space-between; 315 + padding: var(--s3); 316 + border-bottom: 1px solid currentColor; 317 + gap: var(--s2); 318 + cursor: pointer; 319 + background: transparent; 320 + border-left: none; 321 + border-right: none; 322 + border-top: none; 323 + color: inherit; 324 + text-align: left; 325 + font-size: var(--f5); 326 + width: 100%; 327 + } 196 328 197 - body>main { 198 - max-width: none; 199 - padding: 0; 200 - } 329 + .search-history-item:hover { 330 + opacity: 0.7; 331 + transform: none; 332 + } 201 333 202 - #map { 203 - height: 100dvh; 204 - width: 100%; 205 - background-color: #6baed6; 206 - } 334 + .search-empty { 335 + padding: var(--s5) var(--s3); 336 + text-align: center; 337 + opacity: 0.5; 338 + font-size: var(--f5); 339 + } 207 340 208 - #controls { 209 - position: absolute; 210 - top: 1rem; 211 - left: 1rem; 212 - z-index: 1; 213 - display: flex; 214 - gap: 0.5rem; 341 + .search-history-clear { 342 + display: flex; 343 + justify-content: flex-end; 344 + padding: var(--s2) 0; 345 + } 215 346 }
+23
www/utils/fs.ts
··· 34 34 35 35 return new PMTiles(source) 36 36 } 37 + 38 + export async function isPMTilesCached(filename: string): Promise<boolean> { 39 + const db = await getDb() 40 + const source = new IndexedDBSource(db, filename, STORE_NAME) 41 + return await source.exists() 42 + } 43 + 44 + export async function getCachedPMTiles(filename: string): Promise<PMTiles | null> { 45 + const db = await getDb() 46 + const source = new IndexedDBSource(db, filename, STORE_NAME) 47 + if (!(await source.exists())) return null 48 + return new PMTiles(source) 49 + } 50 + 51 + export async function deletePMTiles(filename: string): Promise<void> { 52 + const db = await getDb() 53 + await new Promise<void>((resolve, reject) => { 54 + const tx = db.transaction(STORE_NAME, 'readwrite') 55 + tx.objectStore(STORE_NAME).delete(filename) 56 + tx.oncomplete = () => resolve() 57 + tx.onerror = () => reject(tx.error) 58 + }) 59 + }
+8 -3
www/worker.ts
··· 10 10 withPrecache([ 11 11 '/', 12 12 '/index.html', 13 - '/static/civility.css', 14 - '/static/utilities.css', 15 - '/static/theme.css', 13 + '/static/styles/maplibre-gl.css', 14 + '/static/styles/civility.min.css', 15 + '/static/styles/theme.css', 16 + '/static/icons/home.svg', 17 + '/static/icons/navigation.svg', 18 + '/static/icons/tool.svg', 19 + '/static/icons/download.svg', 20 + '/static/icons/book.svg', 16 21 '/dist/index.js', 17 22 '/manifest.json', 18 23 ]),