diff --git a/src/api.js b/src/api.js index 1b4fda6c..b28764b5 100644 --- a/src/api.js +++ b/src/api.js @@ -618,6 +618,12 @@ export function getReportUrl(id, options) { return `${__APIHOST__}/api/reports/${id}/file?jwt=${jwt}&${queryParam}` } +export function getTileUrl(handle) { + const jwt = localStorage.getItem('access_token') + const base = `${__APIHOST__}/api/media/${handle}/tile/{z}/{x}/{y}` + return jwt === null ? base : `${base}?jwt=${jwt}` +} + export function getMediaUrl(handle, download = false) { const jwt = localStorage.getItem('access_token') if (jwt === null) { diff --git a/src/components/GrampsjsFormEditMapLayer.js b/src/components/GrampsjsFormEditMapLayer.js index c7912ce8..7ac8b123 100644 --- a/src/components/GrampsjsFormEditMapLayer.js +++ b/src/components/GrampsjsFormEditMapLayer.js @@ -159,6 +159,7 @@ class GrampsjsFormEditMapLayer extends GrampsjsObjectForm { url="${getMediaUrl(this.data.handle)}" opacity="${this.opacity}" title="${this.data.desc}" + handle="${this.data.handle}" .bounds="${this._getBounds()}" >` : '' diff --git a/src/components/GrampsjsMap.js b/src/components/GrampsjsMap.js index bb3b6147..85fbeaa1 100644 --- a/src/components/GrampsjsMap.js +++ b/src/components/GrampsjsMap.js @@ -60,7 +60,7 @@ class GrampsjsMap extends GrampsjsAppStateMixin(LitElement) { style="width:${this.width}; height:${this.height};" >
- +
${this.layerSwitcher ? this._renderLayerSwitcher() : html`
`} @@ -174,7 +174,6 @@ class GrampsjsMap extends GrampsjsAppStateMixin(LitElement) { [this.longMax, this.latMax], ]) } - this._reAddOverlays() this._slottedChildren .filter(el => typeof el.addToMap === 'function') .forEach(el => el.addToMap(this._map)) @@ -198,6 +197,14 @@ class GrampsjsMap extends GrampsjsAppStateMixin(LitElement) { return slot.assignedElements({flatten: true}) } + // Handles children added after the map's load event (e.g. async data layers). + _onSlotChange() { + if (!this._map?.isStyleLoaded()) return + this._slottedChildren + .filter(el => typeof el.addToMap === 'function') + .forEach(el => el.addToMap(this._map)) + } + panTo(latitude, longitude) { if (this._map !== undefined) { this._map.panTo([longitude, latitude]) @@ -248,25 +255,16 @@ class GrampsjsMap extends GrampsjsAppStateMixin(LitElement) { _handleOverlayToggle(e) { const {overlay, visible} = e.detail - - const overlays = this._slottedChildren.filter( - el => el.tagName === 'GRAMPSJS-MAP-OVERLAY' - ) - - overlays.forEach(overlayElement => { - // Prefer matching by stable handle when available; fall back to title/desc for backward compatibility - const matchesByHandle = - overlay.handle && - overlayElement.handle && - overlayElement.handle === overlay.handle - const matchesByTitle = - !overlay.handle && overlayElement.title === overlay.desc - - if (matchesByHandle || matchesByTitle) { + this._slottedChildren + .filter( + el => + (overlay.handle && el.handle === overlay.handle) || + (!overlay.handle && el.title === overlay.desc) + ) + .forEach(el => { // eslint-disable-next-line no-param-reassign - overlayElement.hidden = !visible - } - }) + el.hidden = !visible + }) } _handleStyleChange(style) { @@ -286,20 +284,6 @@ class GrampsjsMap extends GrampsjsAppStateMixin(LitElement) { } : undefined ) - this._map.once('style.load', () => { - this._reAddOverlays() - }) - } - - _reAddOverlays() { - const overlays = this._slottedChildren.filter( - el => el.tagName === 'GRAMPSJS-MAP-OVERLAY' - ) - overlays.forEach(overlayElement => { - // After style change, MapLibre cleared all layers. Reset overlay state and re-add. - overlayElement.resetForStyleChange() - overlayElement.addOverlay() - }) } _getStyleUrl(style) { diff --git a/src/components/GrampsjsMapOverlay.js b/src/components/GrampsjsMapOverlay.js index db608b9b..bfb8fe8d 100644 --- a/src/components/GrampsjsMapOverlay.js +++ b/src/components/GrampsjsMapOverlay.js @@ -13,7 +13,6 @@ class GrampsjsMapOverlay extends LitElement { title: {type: String}, handle: {type: String}, hidden: {type: Boolean}, - _overlay: {type: String, attribute: false}, } } @@ -25,137 +24,144 @@ class GrampsjsMapOverlay extends LitElement { this.handle = '' this.hidden = false this.bounds = [] - this._overlay = '' + this._onStyleLoad = () => { + if (this.hidden) { + this.removeOverlay() + } else { + this.addOverlay() + } + } + } + + _layerIdFor(handle, title) { + if (handle) return `overlay-${handle}` + if (title) return `overlay-${title.replace(/\s+/g, '-')}` + return '' + } + + get _layerId() { + return this._layerIdFor(this.handle, this.title) + } + + // MapLibre expects coordinates in order: top-left, top-right, bottom-right, bottom-left + _getCoordinates() { + if (!this.bounds || this.bounds.length !== 2) return null + let [[y0, x0], [y1, x1]] = this.bounds + if (y0 < y1) [y0, y1] = [y1, y0] + if (x0 > x1) [x0, x1] = [x1, x0] + return [ + [x0, y0], // top left [lng, lat] + [x1, y0], // top right + [x1, y1], // bottom right + [x0, y1], // bottom left + ] } firstUpdated() { this._map = this.parentElement._map - if (!this.hidden) { - this.addOverlay() - } + this._map.off('style.load', this._onStyleLoad) + this._map.on('style.load', this._onStyleLoad) + if (!this.hidden) this.addOverlay() } addOverlay() { - if (!this._map || !this.url || !this.bounds || this.bounds.length !== 2) - return - - // Don't add if overlay is hidden - if (this.hidden) { - return - } - - // Do nothing if overlay already exists - if (this._overlay && this._map.getLayer(this._overlay)) { - return - } + if (!this._map || !this.url || !this._layerId) return + if (this.hidden) return + if (this._map.getLayer(this._layerId)) return - // Wait for style to be loaded before adding source/layer const addOverlayWhenReady = () => { - // Don't add if hidden (could have changed while waiting) - if (this.hidden) { - return - } - - // Generate stable ID if not already set - if (!this._overlay) { - if (this.handle) { - // Prefer handle-based ID for stability across re-renders - this._overlay = `overlay-${this.handle}` - } else if (this.title) { - // Fall back to title-based ID (less stable if title changes) - this._overlay = `overlay-${this.title.replace(/\s+/g, '-')}` - } else { - // Last resort: random ID (not stable across re-renders) - this._overlay = `overlay-${Math.random().toString(36).substr(2, 9)}` - } - } - - // Check if already added (shouldn't happen but be safe) - if (this._map.getSource(this._overlay)) { - return - } - - // MapLibre expects coordinates in order: top-left, top-right, bottom-right, bottom-left - // Fix: ensure bounds[0] is top-left (northwest), bounds[1] is bottom-right (southeast) - // If bounds are [south, west], [north, east], swap as needed - let [[y0, x0], [y1, x1]] = this.bounds - // Ensure y0 > y1 (top > bottom) - if (y0 < y1) { - ;[y0, y1] = [y1, y0] - } - // Ensure x0 < x1 (left < right) - if (x0 > x1) { - ;[x0, x1] = [x1, x0] - } - this._map.addSource(this._overlay, { + if (this.hidden) return + if (this._map.getSource(this._layerId)) return + const coordinates = this._getCoordinates() + if (!coordinates) return + this._map.addSource(this._layerId, { type: 'image', url: this.url, - coordinates: [ - [x0, y0], // top left [lng, lat] - [x1, y0], // top right - [x1, y1], // bottom right - [x0, y1], // bottom left - ], + coordinates, }) this._map.addLayer({ - id: this._overlay, + id: this._layerId, type: 'raster', - source: this._overlay, - paint: { - 'raster-opacity': this.opacity, - }, + source: this._layerId, + paint: {'raster-opacity': this.opacity}, }) - // Bring to front - this._map.moveLayer(this._overlay) + this._map.moveLayer(this._layerId) } - // Check if style is already loaded if (this._map.isStyleLoaded()) { addOverlayWhenReady() } else { - // Wait for style to load this._map.once('styledata', addOverlayWhenReady) } } removeOverlay() { - if (this._map && this._overlay) { - if (this._map.getLayer(this._overlay)) { - this._map.removeLayer(this._overlay) - } - if (this._map.getSource(this._overlay)) { - this._map.removeSource(this._overlay) - } - } + if (!this._map || !this._layerId) return + if (this._map.getLayer(this._layerId)) this._map.removeLayer(this._layerId) + if (this._map.getSource(this._layerId)) + this._map.removeSource(this._layerId) } disconnectedCallback() { + if (this._map) this._map.off('style.load', this._onStyleLoad) this.removeOverlay() super.disconnectedCallback() } - resetForStyleChange() { - // After a style change, MapLibre has already cleared all layers/sources. - // We just need to reset our internal state so addOverlay() can recreate them. - this._overlay = '' + // Called by GrampsjsMap inside setStyle's transformStyle callback so the + // image source/layer survive style switches without a two-pass re-add. + getTransformStyleContribution(_prev, next) { + if (!this.url || !this._layerId || this.hidden) return next + const coordinates = this._getCoordinates() + if (!coordinates) return next + const layerId = this._layerId + return { + ...next, + sources: { + ...next.sources, + [layerId]: {type: 'image', url: this.url, coordinates}, + }, + layers: [ + ...next.layers, + { + id: layerId, + type: 'raster', + source: layerId, + layout: {visibility: this.hidden ? 'none' : 'visible'}, + paint: {'raster-opacity': this.opacity}, + }, + ], + } } updated(changed) { - if (changed.has('bounds') || changed.has('opacity') || changed.has('url')) { - this.updateOverlay() + if (changed.has('handle') || changed.has('title')) { + const oldId = this._layerIdFor( + changed.has('handle') ? changed.get('handle') : this.handle, + changed.has('title') ? changed.get('title') : this.title + ) + if (oldId && this._map) { + if (this._map.getLayer(oldId)) this._map.removeLayer(oldId) + if (this._map.getSource(oldId)) this._map.removeSource(oldId) + } + this.addOverlay() + } else if ( + changed.has('bounds') || + changed.has('opacity') || + changed.has('url') + ) { + this.removeOverlay() + this.addOverlay() } else if (changed.has('hidden')) { if (this.hidden) { this.removeOverlay() + } else if (this._map && this._map.getLayer(this._layerId)) { + this._map.setLayoutProperty(this._layerId, 'visibility', 'visible') } else { this.addOverlay() } } } - - updateOverlay() { - this.removeOverlay() - this.addOverlay() - } } window.customElements.define('grampsjs-map-overlay', GrampsjsMapOverlay) diff --git a/src/components/GrampsjsMapTileLayer.js b/src/components/GrampsjsMapTileLayer.js new file mode 100644 index 00000000..d131ab46 --- /dev/null +++ b/src/components/GrampsjsMapTileLayer.js @@ -0,0 +1,122 @@ +import {LitElement} from 'lit' +import {getTileUrl} from '../api.js' + +class GrampsjsMapTileLayer extends LitElement { + static get properties() { + return { + handle: {type: String}, + hidden: {type: Boolean}, + } + } + + constructor() { + super() + this.handle = '' + this.hidden = false + this._map = null + this._onStyleLoad = () => this._syncVisibility() + } + + // No shadow DOM — renders no UI. + createRenderRoot() { + return this + } + + get _layerId() { + return `tile-overlay-${this.handle}` + } + + _syncVisibility() { + if (!this._map || !this.handle) return + const layerId = this._layerId + if (this._map.getLayer(layerId)) { + this._map.setLayoutProperty( + layerId, + 'visibility', + this.hidden ? 'none' : 'visible' + ) + } + } + + // Called by GrampsjsMap after initial map load. + // Imperatively adds the tile source/layer since the initial Map() constructor + // doesn't use transformStyle. + addToMap(map) { + this._map = map + map.off('style.load', this._onStyleLoad) + map.on('style.load', this._onStyleLoad) + if (!this.handle) return + const layerId = this._layerId + if (!map.getSource(layerId)) { + map.addSource(layerId, { + type: 'raster', + tiles: [getTileUrl(this.handle)], + tileSize: 256, + maxzoom: 18, + }) + } + if (!map.getLayer(layerId)) { + map.addLayer({ + id: layerId, + type: 'raster', + source: layerId, + layout: {visibility: this.hidden ? 'none' : 'visible'}, + }) + } + } + + // Called by GrampsjsMap inside setStyle's transformStyle callback so the + // tile source/layer are part of the new style spec from its very first frame. + getTransformStyleContribution(_prev, next) { + if (!this.handle) return next + const layerId = this._layerId + return { + ...next, + sources: { + ...next.sources, + [layerId]: { + type: 'raster', + tiles: [getTileUrl(this.handle)], + tileSize: 256, + maxzoom: 18, + }, + }, + layers: [ + ...next.layers, + { + id: layerId, + type: 'raster', + source: layerId, + layout: {visibility: this.hidden ? 'none' : 'visible'}, + }, + ], + } + } + + disconnectedCallback() { + super.disconnectedCallback() + if (this._map) { + this._map.off('style.load', this._onStyleLoad) + const layerId = this._layerId + if (this._map.getLayer(layerId)) this._map.removeLayer(layerId) + if (this._map.getSource(layerId)) this._map.removeSource(layerId) + } + } + + updated(changed) { + if (changed.has('handle') && this._map) { + const oldHandle = changed.get('handle') + if (oldHandle) { + const oldLayerId = `tile-overlay-${oldHandle}` + if (this._map.getLayer(oldLayerId)) this._map.removeLayer(oldLayerId) + if (this._map.getSource(oldLayerId)) this._map.removeSource(oldLayerId) + } + this.addToMap(this._map) + } + if (changed.has('hidden') && this._map?.isStyleLoaded()) { + this._syncVisibility() + } + } +} + +window.customElements.define('grampsjs-map-tile-layer', GrampsjsMapTileLayer) diff --git a/src/views/GrampsjsViewMap.js b/src/views/GrampsjsViewMap.js index a619f25f..584c4099 100644 --- a/src/views/GrampsjsViewMap.js +++ b/src/views/GrampsjsViewMap.js @@ -7,7 +7,7 @@ import '../components/GrampsjsMapPlacesLayer.js' import '../components/GrampsjsMapSearchbox.js' import '../components/GrampsjsMapTimeSlider.js' import '../components/GrampsjsPlaceBox.js' -import {getMediaUrl} from '../api.js' +import '../components/GrampsjsMapTileLayer.js' import {isDateBetweenYears, getGregorianYears} from '../util.js' import {GrampsjsStaleDataMixin} from '../mixins/GrampsjsStaleDataMixin.js' @@ -308,16 +308,17 @@ export class GrampsjsViewMap extends GrampsjsStaleDataMixin(GrampsjsView) { _handleMapMarkerClicked(e) { const place = this._dataPlaces.find(p => p.handle === e.detail.handle) - if (place) this._handlePlaceSelected(place) + if (place) this._handlePlaceSelected(place, {flyTo: false}) } - _handlePlaceSelected(object) { + _handlePlaceSelected(object, {flyTo = true} = {}) { this._dataSearch = [] this._valueSearch = object.profile.name this._handlesHighlight = [object.handle] const searchbox = this.renderRoot.querySelector('grampsjs-map-searchbox') searchbox?.showDetails() if ( + flyTo && object.profile.lat != null && object.profile.long != null && !(object.profile.lat === 0 && object.profile.long === 0) @@ -350,19 +351,12 @@ export class GrampsjsViewMap extends GrampsjsStaleDataMixin(GrampsjsView) { return html` ${this._dataLayers.map(obj => this._renderMapLayer(obj))} ` } - // eslint-disable-next-line class-methods-use-this _renderMapLayer(obj) { - const bounds = obj.attribute_list.filter( - attr => attr.type === 'map:bounds' - )[0].value return html` - + ?hidden="${this._hiddenOverlaysHandles.includes(obj.handle)}" + > ` }