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

Configure Feed

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

fix: issue where map was unnecessarily re-rendering

+167 -12
+105 -2
www/components/m-map.ts
··· 119 119 await this.#loadWorldTiles() 120 120 await this.#loadCachedDetailTiles() 121 121 this.#renderBookmarkMarkers() 122 + this.#ensureBookmarkLayersOnTop() 122 123 const target = getMapNav() 123 124 if (target) { 124 125 setMapNav(null) ··· 327 328 source.setData(geojson) 328 329 return 329 330 } 330 - this.#map.addSource('bookmarks', { type: 'geojson', data: geojson }) 331 + this.#map.addSource('bookmarks', { 332 + type: 'geojson', 333 + data: geojson, 334 + cluster: true, 335 + clusterMaxZoom: 16, 336 + clusterRadius: 50, 337 + clusterMinPoints: 5, 338 + }) 339 + 340 + this.#map.addLayer({ 341 + id: 'bookmarks-clusters', 342 + type: 'circle', 343 + source: 'bookmarks', 344 + filter: ['>=', ['get', 'point_count'], 5], 345 + minzoom: 0, 346 + maxzoom: 24, 347 + paint: { 348 + 'circle-color': [ 349 + 'step', 350 + ['get', 'point_count'], 351 + '#e05c2a', 352 + 10, 353 + '#16a34a', 354 + 30, 355 + '#dc2626', 356 + ], 357 + 'circle-radius': ['step', ['get', 'point_count'], 18, 10, 22, 30, 24], 358 + 'circle-stroke-width': 2, 359 + 'circle-stroke-color': '#fff', 360 + }, 361 + }) 362 + this.#map.addLayer({ 363 + id: 'bookmarks-cluster-count', 364 + type: 'circle', 365 + source: 'bookmarks', 366 + filter: ['>=', ['get', 'point_count'], 5], 367 + minzoom: 0, 368 + maxzoom: 24, 369 + paint: { 370 + 'circle-color': '#fff', 371 + 'circle-radius': 0, 372 + }, 373 + }) 374 + this.#map.addLayer({ 375 + id: 'bookmarks-cluster-count-label', 376 + type: 'symbol', 377 + source: 'bookmarks', 378 + filter: ['>=', ['get', 'point_count'], 5], 379 + minzoom: 0, 380 + maxzoom: 24, 381 + layout: { 382 + 'text-field': '{point_count_abbreviated}', 383 + 'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'], 384 + 'text-size': 12, 385 + }, 386 + paint: { 387 + 'text-color': '#000', 388 + }, 389 + }) 331 390 this.#map.addLayer({ 332 391 id: 'bookmarks', 333 392 type: 'circle', 334 393 source: 'bookmarks', 394 + filter: ['!', ['has', 'point_count']], 395 + minzoom: 0, 396 + maxzoom: 24, 335 397 paint: { 336 398 'circle-radius': 8, 337 399 'circle-color': ['coalesce', ['get', '_color'], '#e05c2a'], ··· 343 405 id: 'bookmarks-hit', 344 406 type: 'circle', 345 407 source: 'bookmarks', 408 + filter: ['!', ['has', 'point_count']], 409 + minzoom: 0, 410 + maxzoom: 24, 346 411 paint: { 347 412 'circle-radius': 20, 348 413 'circle-color': 'transparent', 349 414 }, 350 415 }) 416 + 417 + this.#map.on('click', 'bookmarks-clusters', async (e) => { 418 + if (!e.features?.length || !this.#map) return 419 + const feature = e.features[0] 420 + const clusterId = feature.properties?.cluster_id 421 + if (clusterId === undefined) return 422 + const source = this.#map.getSource( 423 + 'bookmarks', 424 + ) as maplibregl.GeoJSONSource 425 + const baseZoom = await source.getClusterExpansionZoom(clusterId) 426 + const pointCount = feature.properties?.point_count ?? 0 427 + const extraZoom = pointCount > 50 ? 2 : pointCount > 20 ? 1 : 0 428 + const geometry = feature.geometry as unknown as { 429 + coordinates: [number, number] 430 + } 431 + this.#map.easeTo({ 432 + center: geometry.coordinates, 433 + zoom: baseZoom + extraZoom, 434 + }) 435 + }) 351 436 this.#map.on('click', 'bookmarks-hit', (e) => { 352 437 if (!e.features?.length || !this.#map) return 353 438 const feature = e.features[0] ··· 363 448 .setLngLat(coords) 364 449 .addTo(this.#map) 365 450 }) 451 + this.#map.on('mouseenter', 'bookmarks-clusters', () => { 452 + this.#map!.getCanvas().style.cursor = 'pointer' 453 + }) 454 + this.#map.on('mouseleave', 'bookmarks-clusters', () => { 455 + this.#map!.getCanvas().style.cursor = '' 456 + }) 366 457 this.#map.on('mouseenter', 'bookmarks-hit', () => { 367 458 this.#map!.getCanvas().style.cursor = 'pointer' 368 459 }) ··· 376 467 const center = this.#map.getCenter() 377 468 const zoom = this.#map.getZoom() 378 469 379 - if (zoom < 8) { 470 + if (zoom < 5) { 380 471 this.#availableTile = null 381 472 this.requestUpdate() 382 473 return ··· 446 537 this.#marker = null 447 538 this.#map?.remove() 448 539 this.#map = null 540 + } 541 + 542 + #ensureBookmarkLayersOnTop() { 543 + if (!this.#map) return 544 + const bookmarkLayers = [ 545 + 'bookmarks-clusters', 546 + 'bookmarks-cluster-count', 547 + 'bookmarks-cluster-count-label', 548 + 'bookmarks', 549 + 'bookmarks-hit', 550 + ] 551 + bookmarkLayers.forEach((id) => this.#map?.moveLayer(id)) 449 552 } 450 553 451 554 async #loadWorldTiles(): Promise<void> {
+2 -1
www/index.html
··· 44 44 45 45 <main id="main"> 46 46 <ui-spinner></ui-spinner> 47 - <m-map hidden></m-map> 48 47 </main> 48 + 49 + <m-map></m-map> 49 50 50 51 <footer fixed> 51 52 <ui-bottom-bar role="navigation" aria-label="Bottom navigation">
+2 -2
www/index.ts
··· 23 23 landmarks: { 24 24 main: 'main', 25 25 }, 26 - afterMount: (_ctx, view) => { 26 + afterMount: (ctx, view) => { 27 27 const mapEl = document.querySelector<HTMLElement>('m-map') 28 28 if (mapEl) { 29 - mapEl.hidden = (view as { route: string }).route !== '/' 29 + mapEl.hidden = (ctx as { path: string }).path !== '/' 30 30 } 31 31 32 32 const titleEl = document.querySelector('#page-title')
+1 -2
www/routes/map.ts
··· 1 1 import { html, LitElement, type TemplateResult } from 'lit' 2 - import '../components/m-map.ts' 3 2 4 3 export class MapPage extends LitElement { 5 4 protected override createRenderRoot() { ··· 8 7 9 8 override render(): TemplateResult { 10 9 return html` 11 - <m-map></m-map> 10 + 12 11 ` 13 12 } 14 13 }
+57 -5
www/static/styles/theme.css
··· 71 71 72 72 m-map { 73 73 display: block; 74 - height: 100%; 75 - position: relative; 74 + position: fixed; 75 + inset: 0; 76 + z-index: 1; 77 + padding-top: var(--header-height); 78 + padding-bottom: var(--footer-height); 79 + box-sizing: border-box; 80 + } 81 + 82 + m-map[hidden] { 83 + display: none; 76 84 } 77 85 78 86 m-map #map { 79 - height: 100%; 87 + height: calc(100% + var(--header-height) + var(--footer-height)); 88 + width: 100%; 89 + margin-top: calc(-1 * var(--header-height)); 90 + margin-bottom: calc(-1 * var(--footer-height)); 80 91 background-color: #6baed6; 81 92 } 82 93 94 + m-map #map canvas { 95 + z-index: 1; 96 + } 97 + 98 + m-map .maplibregl-popup { 99 + z-index: 100; 100 + } 101 + 102 + m-map > .download-btn { 103 + z-index: 10; 104 + } 105 + 106 + m-map .maplibregl-ctrl-top-left, 107 + m-map .maplibregl-ctrl-top-right { 108 + padding-top: var(--header-height); 109 + z-index: 10; 110 + } 111 + 112 + m-map .maplibregl-ctrl-bottom-left, 113 + m-map .maplibregl-ctrl-bottom-right { 114 + padding-bottom: var(--footer-height); 115 + z-index: 10; 116 + } 117 + 118 + m-map .maplibregl-ctrl-group { 119 + margin-top: var(--header-height); 120 + margin-bottom: var(--footer-height); 121 + } 122 + 123 + m-map .maplibregl-ctrl-attrib { 124 + margin-bottom: var(--footer-height); 125 + } 126 + 83 127 m-map .download-btn { 84 128 position: absolute; 85 - bottom: var(--s4); 129 + bottom: calc(var(--footer-height) + var(--s4)); 86 130 left: var(--s3); 87 131 padding: var(--s2) var(--s4); 88 132 background: var(--primary); ··· 95 139 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 96 140 z-index: 10; 97 141 transition: opacity var(--transition-fast); 142 + height: 3em; 98 143 } 99 144 100 145 m-map .download-btn:hover { ··· 680 725 } 681 726 682 727 @media (max-width: 768px) { 683 - input, textarea, select { 728 + input, 729 + textarea, 730 + select { 684 731 font-size: 1rem; 685 732 } 686 733 } 687 734 } 735 + 736 + m-map[hidden], 737 + m-map[hidden] .maplibregl-control-container .maplibregl-ctrl-attrib { 738 + visibility: hidden; 739 + }