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)}"
+ >
`
}