Offline-capable geomap, meant for storing location bookmarks
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

chore: cleanup m-map

+282 -222
+1 -1
deno.json
··· 1 1 { 2 - "version": "0.4.0", 2 + "version": "0.4.1", 3 3 "workspace": ["./data"], 4 4 "tasks": { 5 5 "data": "deno run -A ./data/cli/main.ts",
+34 -211
www/components/m-map.ts
··· 14 14 import { bookmarkDisplayName } from '../models/schema.ts' 15 15 import { from as nominatimToProperties } from '../models/adapters/nominatim.ts' 16 16 import { nominatimReverse } from '../utils/nominatim.ts' 17 + import { boundsIntersect, expandBounds } from '../utils/bounds.ts' 18 + import { 19 + fetchTileManifest, 20 + type TileManifestEntry, 21 + } from '../utils/tile-manifest.ts' 22 + import { 23 + buildBookmarksGeoJSON, 24 + ensureBookmarkLayersOnTop, 25 + setupBookmarkLayers, 26 + } from '../utils/bookmark-layers.ts' 17 27 18 28 const protocol = new Protocol() 19 29 maplibregl.addProtocol('pmtiles', protocol.tile.bind(protocol)) ··· 24 34 // at street-level detail, so keep them out of the style until then. 25 35 const MIN_REGIONAL_ZOOM = 6 26 36 const VIEWPORT_BUFFER = 0.5 27 - 28 - function expandBounds( 29 - bounds: maplibregl.LngLatBounds, 30 - factor: number, 31 - ): [number, number, number, number] { 32 - const w = bounds.getWest() 33 - const s = bounds.getSouth() 34 - const e = bounds.getEast() 35 - const n = bounds.getNorth() 36 - const dx = (e - w) * factor / 2 37 - const dy = (n - s) * factor / 2 38 - return [w - dx, s - dy, e + dx, n + dy] 39 - } 40 - 41 - function boundsIntersect( 42 - a: [number, number, number, number], 43 - b: [number, number, number, number], 44 - ): boolean { 45 - return !(a[2] < b[0] || a[0] > b[2] || a[3] < b[1] || a[1] > b[3]) 46 - } 47 - 48 - type TileManifestEntry = { 49 - id: string 50 - filename?: string 51 - label: string 52 - group: string 53 - bounds?: [number, number, number, number] 54 - } 55 - 56 - let _manifestCache: TileManifestEntry[] | null = null 57 - 58 - async function fetchTileManifest(): Promise<TileManifestEntry[]> { 59 - if (_manifestCache) return _manifestCache 60 - try { 61 - const res = await fetch('/static/tiles/tiles.json') 62 - if (!res.ok) return [] 63 - const entries = (await res.json()) as TileManifestEntry[] 64 - _manifestCache = entries 65 - return entries 66 - } catch { 67 - return [] 68 - } 69 - } 70 37 71 38 export class MMap extends LitElement { 72 39 #map: maplibregl.Map | null = null ··· 177 144 await this.#loadWorldTiles() 178 145 await this.#syncRegionalTilesForViewport() 179 146 this.#renderBookmarkMarkers() 180 - this.#ensureBookmarkLayersOnTop() 147 + ensureBookmarkLayersOnTop(this.#map!) 181 148 const target = getMapNav() 182 149 if (target) { 183 150 setMapNav(null) ··· 402 369 403 370 #renderBookmarkMarkers() { 404 371 if (!this.#map) return 405 - const bookmarks = [...(app.bookmarkValues?.values() ?? [])] 406 - const collectionColors = new Map( 407 - [...(app.collectionValues?.values() ?? [])] 408 - .map((c) => [c.id, c.color ?? null]), 372 + const data = buildBookmarksGeoJSON( 373 + app.bookmarkValues?.values() ?? [], 374 + app.collectionValues?.values() ?? [], 409 375 ) 410 - 411 - const geojson = { 412 - type: 'FeatureCollection' as const, 413 - features: bookmarks.map((b) => ({ 414 - ...b, 415 - properties: { 416 - ...b.properties, 417 - _id: b.id, 418 - _displayName: bookmarkDisplayName(b), 419 - _color: b.categories[0] 420 - ? (collectionColors.get(b.categories[0]) ?? null) 421 - : null, 422 - }, 423 - })), 424 - } 425 376 const source = this.#map.getSource('bookmarks') as 426 377 | maplibregl.GeoJSONSource 427 378 | undefined 428 379 if (source) { 429 - source.setData(geojson) 380 + source.setData(data) 430 381 return 431 382 } 432 - this.#map.addSource('bookmarks', { 433 - type: 'geojson', 434 - data: geojson, 435 - cluster: true, 436 - clusterMaxZoom: 16, 437 - clusterRadius: 50, 438 - clusterMinPoints: 5, 439 - }) 440 - 441 - this.#map.addLayer({ 442 - id: 'bookmarks-clusters', 443 - type: 'circle', 444 - source: 'bookmarks', 445 - filter: ['>=', ['get', 'point_count'], 5], 446 - minzoom: 0, 447 - maxzoom: 24, 448 - paint: { 449 - 'circle-color': [ 450 - 'step', 451 - ['get', 'point_count'], 452 - '#e05c2a', 453 - 10, 454 - '#16a34a', 455 - 30, 456 - '#dc2626', 457 - ], 458 - 'circle-radius': ['step', ['get', 'point_count'], 18, 10, 22, 30, 24], 459 - 'circle-stroke-width': 2, 460 - 'circle-stroke-color': '#fff', 383 + setupBookmarkLayers(this.#map, data, { 384 + onClusterClick: async (clusterId, coords, pointCount) => { 385 + if (!this.#map) return 386 + const src = this.#map.getSource( 387 + 'bookmarks', 388 + ) as maplibregl.GeoJSONSource 389 + const baseZoom = await src.getClusterExpansionZoom(clusterId) 390 + const extraZoom = pointCount > 50 ? 2 : pointCount > 20 ? 1 : 0 391 + this.#map.easeTo({ center: coords, zoom: baseZoom + extraZoom }) 461 392 }, 462 - }) 463 - this.#map.addLayer({ 464 - id: 'bookmarks-cluster-count', 465 - type: 'circle', 466 - source: 'bookmarks', 467 - filter: ['>=', ['get', 'point_count'], 5], 468 - minzoom: 0, 469 - maxzoom: 24, 470 - paint: { 471 - 'circle-color': '#fff', 472 - 'circle-radius': 0, 393 + onBookmarkClick: async (coords, id, displayName) => { 394 + if (!this.#map) return 395 + this.#bookmarkPopup?.remove() 396 + this.#bookmarkPopup = (await this.#buildBookmarkPopup( 397 + displayName, 398 + id, 399 + )) 400 + .setLngLat(coords) 401 + .addTo(this.#map) 473 402 }, 474 403 }) 475 - this.#map.addLayer({ 476 - id: 'bookmarks-cluster-count-label', 477 - type: 'symbol', 478 - source: 'bookmarks', 479 - filter: ['>=', ['get', 'point_count'], 5], 480 - minzoom: 0, 481 - maxzoom: 24, 482 - layout: { 483 - 'text-field': '{point_count_abbreviated}', 484 - 'text-font': ['Noto Sans Regular'], 485 - 'text-size': 12, 486 - }, 487 - paint: { 488 - 'text-color': '#000', 489 - }, 490 - }) 491 - this.#map.addLayer({ 492 - id: 'bookmarks', 493 - type: 'circle', 494 - source: 'bookmarks', 495 - filter: ['!', ['has', 'point_count']], 496 - minzoom: 0, 497 - maxzoom: 24, 498 - paint: { 499 - 'circle-radius': 8, 500 - 'circle-color': ['coalesce', ['get', '_color'], '#e05c2a'], 501 - 'circle-stroke-width': 2, 502 - 'circle-stroke-color': '#fff', 503 - }, 504 - }) 505 - this.#map.addLayer({ 506 - id: 'bookmarks-hit', 507 - type: 'circle', 508 - source: 'bookmarks', 509 - filter: ['!', ['has', 'point_count']], 510 - minzoom: 0, 511 - maxzoom: 24, 512 - paint: { 513 - 'circle-radius': 20, 514 - 'circle-color': 'transparent', 515 - }, 516 - }) 517 - 518 - this.#map.on('click', 'bookmarks-clusters', async (e) => { 519 - if (!e.features?.length || !this.#map) return 520 - const feature = e.features[0] 521 - const clusterId = feature.properties?.cluster_id 522 - if (clusterId === undefined) return 523 - const source = this.#map.getSource( 524 - 'bookmarks', 525 - ) as maplibregl.GeoJSONSource 526 - const baseZoom = await source.getClusterExpansionZoom(clusterId) 527 - const pointCount = feature.properties?.point_count ?? 0 528 - const extraZoom = pointCount > 50 ? 2 : pointCount > 20 ? 1 : 0 529 - const geometry = feature.geometry as unknown as { 530 - coordinates: [number, number] 531 - } 532 - this.#map.easeTo({ 533 - center: geometry.coordinates, 534 - zoom: baseZoom + extraZoom, 535 - }) 536 - }) 537 - this.#map.on('click', 'bookmarks-hit', async (e) => { 538 - if (!e.features?.length || !this.#map) return 539 - const feature = e.features[0] 540 - const coords = (feature.geometry as unknown as { 541 - coordinates: [number, number] 542 - }).coordinates 543 - const id = feature.properties?._id as string | undefined 544 - this.#bookmarkPopup?.remove() 545 - this.#bookmarkPopup = (await this.#buildBookmarkPopup( 546 - feature.properties?._displayName as string, 547 - id, 548 - )) 549 - .setLngLat(coords) 550 - .addTo(this.#map) 551 - }) 552 - this.#map.on('mouseenter', 'bookmarks-clusters', () => { 553 - this.#map!.getCanvas().style.cursor = 'pointer' 554 - }) 555 - this.#map.on('mouseleave', 'bookmarks-clusters', () => { 556 - this.#map!.getCanvas().style.cursor = '' 557 - }) 558 - this.#map.on('mouseenter', 'bookmarks-hit', () => { 559 - this.#map!.getCanvas().style.cursor = 'pointer' 560 - }) 561 - this.#map.on('mouseleave', 'bookmarks-hit', () => { 562 - this.#map!.getCanvas().style.cursor = '' 563 - }) 564 404 } 565 405 566 406 async #checkAvailableTile(): Promise<void> { ··· 737 577 this.#map = null 738 578 } 739 579 740 - #ensureBookmarkLayersOnTop() { 741 - if (!this.#map) return 742 - const allLayers = this.#map.getStyle().layers 743 - if (!allLayers?.length) return 744 - const bookmarkLayers = [ 745 - 'bookmarks-clusters', 746 - 'bookmarks-cluster-count', 747 - 'bookmarks-cluster-count-label', 748 - 'bookmarks', 749 - 'bookmarks-hit', 750 - ] 751 - // Check if bookmark layers are already at the top of the stack 752 - const topIds = allLayers.slice(-bookmarkLayers.length).map((l) => l.id) 753 - if (bookmarkLayers.every((id, i) => topIds[i] === id)) return 754 - bookmarkLayers.forEach((id) => this.#map?.moveLayer(id)) 755 - } 756 - 757 580 async #loadWorldTiles(): Promise<void> { 758 581 if (!this.#map) return 759 582 let worldFilename = 'world/world_z5.pmtiles' ··· 852 675 if (tile) await this.#addRegionalSource(tile) 853 676 } 854 677 855 - this.#ensureBookmarkLayersOnTop() 678 + ensureBookmarkLayersOnTop(this.#map) 856 679 } 857 680 } 858 681
+9 -10
www/static/styles/theme.css
··· 59 59 overflow: hidden; 60 60 } 61 61 62 - body:has(r-home)>main { 62 + body:has(r-home) > main { 63 63 overflow: hidden; 64 64 } 65 65 ··· 100 100 z-index: 100; 101 101 } 102 102 103 - m-map>.download-btn { 103 + m-map > .download-btn { 104 104 z-index: 10; 105 105 } 106 106 ··· 233 233 margin-bottom: var(--s3); 234 234 } 235 235 236 - r-settings section>p, 237 - r-settings-downloads section>p, 238 - r-settings-about section>p { 236 + r-settings section > p, 237 + r-settings-downloads section > p, 238 + r-settings-about section > p { 239 239 opacity: 0.6; 240 240 margin-bottom: var(--s3); 241 241 font-size: var(--f5); ··· 425 425 transform: none; 426 426 } 427 427 428 - .search-history-item>span { 428 + .search-history-item > span { 429 429 flex: 1; 430 430 min-width: 0; 431 431 overflow: hidden; ··· 433 433 white-space: nowrap; 434 434 } 435 435 436 - .search-history-item>img { 436 + .search-history-item > img { 437 437 flex-shrink: 0; 438 438 opacity: 0.35; 439 439 } ··· 755 755 border-radius: var(--br-base); 756 756 } 757 757 758 - .bm-import-group+.bm-import-group { 758 + .bm-import-group + .bm-import-group { 759 759 border-top: 1px solid currentColor; 760 760 } 761 761 ··· 776 776 gap: 1px; 777 777 } 778 778 779 - .bm-import-item+.bm-import-item { 779 + .bm-import-item + .bm-import-item { 780 780 border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent); 781 781 } 782 782 ··· 793 793 } 794 794 795 795 @media (max-width: 768px) { 796 - 797 796 input, 798 797 textarea, 799 798 select {
+194
www/utils/bookmark-layers.ts
··· 1 + import type maplibregl from 'maplibre-gl' 2 + import { 3 + type Bookmark, 4 + type BookmarkCollection, 5 + bookmarkDisplayName, 6 + } from '../models/schema.ts' 7 + 8 + export type BookmarkFeatureCollection = { 9 + type: 'FeatureCollection' 10 + features: Array< 11 + Bookmark & { 12 + properties: Bookmark['properties'] & { 13 + _id: string 14 + _displayName: string 15 + _color: string | null 16 + } 17 + } 18 + > 19 + } 20 + 21 + export function buildBookmarksGeoJSON( 22 + bookmarks: Iterable<Bookmark>, 23 + collections: Iterable<BookmarkCollection>, 24 + ): BookmarkFeatureCollection { 25 + const collectionColors = new Map( 26 + [...collections].map((c) => [c.id, c.color ?? null]), 27 + ) 28 + return { 29 + type: 'FeatureCollection', 30 + features: [...bookmarks].map((b) => ({ 31 + ...b, 32 + properties: { 33 + ...b.properties, 34 + _id: b.id, 35 + _displayName: bookmarkDisplayName(b), 36 + _color: b.categories[0] 37 + ? (collectionColors.get(b.categories[0]) ?? null) 38 + : null, 39 + }, 40 + })), 41 + } 42 + } 43 + 44 + export const BOOKMARK_LAYER_IDS = [ 45 + 'bookmarks-clusters', 46 + 'bookmarks-cluster-count', 47 + 'bookmarks-cluster-count-label', 48 + 'bookmarks', 49 + 'bookmarks-hit', 50 + ] as const 51 + 52 + export type BookmarkLayerHandlers = { 53 + onClusterClick: ( 54 + clusterId: number, 55 + coordinates: [number, number], 56 + pointCount: number, 57 + ) => void 58 + onBookmarkClick: ( 59 + coordinates: [number, number], 60 + id: string | undefined, 61 + displayName: string, 62 + ) => void 63 + } 64 + 65 + export function setupBookmarkLayers( 66 + map: maplibregl.Map, 67 + data: BookmarkFeatureCollection, 68 + handlers: BookmarkLayerHandlers, 69 + ): void { 70 + map.addSource('bookmarks', { 71 + type: 'geojson', 72 + data, 73 + cluster: true, 74 + clusterMaxZoom: 16, 75 + clusterRadius: 50, 76 + clusterMinPoints: 5, 77 + }) 78 + 79 + map.addLayer({ 80 + id: 'bookmarks-clusters', 81 + type: 'circle', 82 + source: 'bookmarks', 83 + filter: ['>=', ['get', 'point_count'], 5], 84 + minzoom: 0, 85 + maxzoom: 24, 86 + paint: { 87 + 'circle-color': [ 88 + 'step', 89 + ['get', 'point_count'], 90 + '#e05c2a', 91 + 10, 92 + '#16a34a', 93 + 30, 94 + '#dc2626', 95 + ], 96 + 'circle-radius': ['step', ['get', 'point_count'], 18, 10, 22, 30, 24], 97 + 'circle-stroke-width': 2, 98 + 'circle-stroke-color': '#fff', 99 + }, 100 + }) 101 + map.addLayer({ 102 + id: 'bookmarks-cluster-count', 103 + type: 'circle', 104 + source: 'bookmarks', 105 + filter: ['>=', ['get', 'point_count'], 5], 106 + minzoom: 0, 107 + maxzoom: 24, 108 + paint: { 109 + 'circle-color': '#fff', 110 + 'circle-radius': 0, 111 + }, 112 + }) 113 + map.addLayer({ 114 + id: 'bookmarks-cluster-count-label', 115 + type: 'symbol', 116 + source: 'bookmarks', 117 + filter: ['>=', ['get', 'point_count'], 5], 118 + minzoom: 0, 119 + maxzoom: 24, 120 + layout: { 121 + 'text-field': '{point_count_abbreviated}', 122 + 'text-font': ['Noto Sans Regular'], 123 + 'text-size': 12, 124 + }, 125 + paint: { 126 + 'text-color': '#000', 127 + }, 128 + }) 129 + map.addLayer({ 130 + id: 'bookmarks', 131 + type: 'circle', 132 + source: 'bookmarks', 133 + filter: ['!', ['has', 'point_count']], 134 + minzoom: 0, 135 + maxzoom: 24, 136 + paint: { 137 + 'circle-radius': 8, 138 + 'circle-color': ['coalesce', ['get', '_color'], '#e05c2a'], 139 + 'circle-stroke-width': 2, 140 + 'circle-stroke-color': '#fff', 141 + }, 142 + }) 143 + map.addLayer({ 144 + id: 'bookmarks-hit', 145 + type: 'circle', 146 + source: 'bookmarks', 147 + filter: ['!', ['has', 'point_count']], 148 + minzoom: 0, 149 + maxzoom: 24, 150 + paint: { 151 + 'circle-radius': 20, 152 + 'circle-color': 'transparent', 153 + }, 154 + }) 155 + 156 + map.on('click', 'bookmarks-clusters', (e) => { 157 + if (!e.features?.length) return 158 + const feature = e.features[0] 159 + const clusterId = feature.properties?.cluster_id 160 + if (clusterId === undefined) return 161 + const pointCount = feature.properties?.point_count ?? 0 162 + const coords = (feature.geometry as unknown as { 163 + coordinates: [number, number] 164 + }).coordinates 165 + handlers.onClusterClick(clusterId, coords, pointCount) 166 + }) 167 + map.on('click', 'bookmarks-hit', (e) => { 168 + if (!e.features?.length) return 169 + const feature = e.features[0] 170 + const coords = (feature.geometry as unknown as { 171 + coordinates: [number, number] 172 + }).coordinates 173 + const id = feature.properties?._id as string | undefined 174 + const displayName = feature.properties?._displayName as string 175 + handlers.onBookmarkClick(coords, id, displayName) 176 + }) 177 + 178 + for (const layerId of ['bookmarks-clusters', 'bookmarks-hit']) { 179 + map.on('mouseenter', layerId, () => { 180 + map.getCanvas().style.cursor = 'pointer' 181 + }) 182 + map.on('mouseleave', layerId, () => { 183 + map.getCanvas().style.cursor = '' 184 + }) 185 + } 186 + } 187 + 188 + export function ensureBookmarkLayersOnTop(map: maplibregl.Map): void { 189 + const allLayers = map.getStyle().layers 190 + if (!allLayers?.length) return 191 + const topIds = allLayers.slice(-BOOKMARK_LAYER_IDS.length).map((l) => l.id) 192 + if (BOOKMARK_LAYER_IDS.every((id, i) => topIds[i] === id)) return 193 + BOOKMARK_LAYER_IDS.forEach((id) => map.moveLayer(id)) 194 + }
+20
www/utils/bounds.ts
··· 1 + import type maplibregl from 'maplibre-gl' 2 + 3 + export type Bounds = [number, number, number, number] 4 + 5 + export function expandBounds( 6 + bounds: maplibregl.LngLatBounds, 7 + factor: number, 8 + ): Bounds { 9 + const w = bounds.getWest() 10 + const s = bounds.getSouth() 11 + const e = bounds.getEast() 12 + const n = bounds.getNorth() 13 + const dx = (e - w) * factor / 2 14 + const dy = (n - s) * factor / 2 15 + return [w - dx, s - dy, e + dx, n + dy] 16 + } 17 + 18 + export function boundsIntersect(a: Bounds, b: Bounds): boolean { 19 + return !(a[2] < b[0] || a[0] > b[2] || a[3] < b[1] || a[1] > b[3]) 20 + }
+24
www/utils/tile-manifest.ts
··· 1 + import type { Bounds } from './bounds.ts' 2 + 3 + export type TileManifestEntry = { 4 + id: string 5 + filename?: string 6 + label: string 7 + group: string 8 + bounds?: Bounds 9 + } 10 + 11 + let _cache: TileManifestEntry[] | null = null 12 + 13 + export async function fetchTileManifest(): Promise<TileManifestEntry[]> { 14 + if (_cache) return _cache 15 + try { 16 + const res = await fetch('/static/tiles/tiles.json') 17 + if (!res.ok) return [] 18 + const entries = (await res.json()) as TileManifestEntry[] 19 + _cache = entries 20 + return entries 21 + } catch { 22 + return [] 23 + } 24 + }