diff --git a/app/components/map.gjs b/app/components/map.gjs index 17064ea..557ea33 100644 --- a/app/components/map.gjs +++ b/app/components/map.gjs @@ -1005,6 +1005,62 @@ export default class MapComponent extends Component { } }); + decodeVectorTileOsmFeature(feature) { + if (!feature?.getId || !feature?.get) { + return { decoded: null, reason: 'feature does not expose get()/getId()' }; + } + + // ol-mapbox-style stores the source-layer name on `mvt:layer`. + const sourceLayer = feature.get('mvt:layer') || feature.get('layer'); + + // OpenFreeMap uses Planetiler/OpenMapTiles tiles, which encode OSM-backed + // feature ids as (osmId * 10) + sourceType. + if (sourceLayer !== 'poi') { + return { + decoded: null, + reason: `unsupported source layer: ${sourceLayer || 'unknown'}`, + }; + } + + const rawId = feature.getId(); + const encodedId = Number(rawId); + if (!Number.isSafeInteger(encodedId) || encodedId <= 0) { + return { + decoded: null, + reason: `feature id is not a positive safe integer: ${rawId}`, + }; + } + + const osmType = { + 1: 'node', + 2: 'way', + 3: 'relation', + }[encodedId % 10]; + + if (!osmType) { + return { + decoded: null, + reason: `feature id suffix ${encodedId % 10} is not an OSM type`, + }; + } + + const osmId = Math.floor(encodedId / 10); + if (osmId <= 0) { + return { + decoded: null, + reason: `decoded OSM id is invalid: ${osmId}`, + }; + } + + return { + decoded: { + osmId: String(osmId), + osmType, + }, + reason: null, + }; + } + animateToCrosshair(targetCoords) { if (!this.mapInstance || !this.crosshairElement) return; @@ -1141,6 +1197,7 @@ export default class MapComponent extends Component { }); let clickedBookmark = null; let clickedSearchResult = null; + let clickedTileOsmFeature = null; let selectedFeatureName = null; if (features && features.length > 0) { @@ -1155,6 +1212,35 @@ export default class MapComponent extends Component { clickedBookmark = bookmarkFeature.get('originalPlace'); } else if (searchResultFeature) { clickedSearchResult = searchResultFeature.get('originalPlace'); + } else { + for (const feature of features) { + const sourceLayer = + feature.get?.('mvt:layer') || feature.get?.('layer'); + const featureName = feature.get?.('name'); + const featureId = feature.getId?.(); + const { decoded, reason } = this.decodeVectorTileOsmFeature(feature); + + if (decoded) { + console.debug( + 'Decoded vector tile feature to explicit OSM place:', + { + sourceLayer, + featureName, + featureId, + decoded, + } + ); + clickedTileOsmFeature = decoded; + break; + } + + console.debug('Vector tile feature could not be decoded directly:', { + sourceLayer, + featureName, + featureId, + reason, + }); + } } // Also get visual props for standard map click logic later const props = features[0].getProperties(); @@ -1179,6 +1265,19 @@ export default class MapComponent extends Component { this.router.transitionTo('place', place); }; + const transitionToExplicitOsmPlace = ({ osmId, osmType }) => { + if ( + this.router.currentRouteName === 'search' || + (this.mapUi.currentSearch && this.mapUi.searchResults.length > 0) + ) { + this.mapUi.returnToSearch = true; + } + + this.mapUi.preventNextZoom = true; + this.mapUi.showSidebar(); + this.router.transitionTo('place', `osm:${osmType}:${osmId}`); + }; + // Special handling when sidebar is OPEN if (this.args.isSidebarOpen) { // If it's a bookmark or search result, we allow "switching" to it even if sidebar is open @@ -1192,6 +1291,15 @@ export default class MapComponent extends Component { return; } + if (clickedTileOsmFeature) { + console.debug( + 'Clicked vector tile POI while sidebar open (switching):', + clickedTileOsmFeature + ); + transitionToExplicitOsmPlace(clickedTileOsmFeature); + return; + } + // Otherwise (empty map or non-bookmark feature), close the sidebar if (this.args.onOutsideClick) { this.args.onOutsideClick(); @@ -1212,6 +1320,12 @@ export default class MapComponent extends Component { return; } + if (clickedTileOsmFeature) { + console.debug('Clicked vector tile POI:', clickedTileOsmFeature); + transitionToExplicitOsmPlace(clickedTileOsmFeature); + return; + } + if (this.mapUi.searchResults && this.mapUi.searchResults.length > 0) { console.debug('Clearing active search and markers on map click'); this.router.transitionTo('index'); diff --git a/tests/unit/components/map-test.js b/tests/unit/components/map-test.js new file mode 100644 index 0000000..f023b8c --- /dev/null +++ b/tests/unit/components/map-test.js @@ -0,0 +1,66 @@ +import MapComponent from 'marco/components/map'; +import { module, test } from 'qunit'; + +module('Unit | Component | map', function () { + test('it decodes Planetiler vector tile POI ids into OSM ids and types', function (assert) { + const feature = { + get(key) { + if (key === 'mvt:layer') return 'poi'; + return undefined; + }, + getId() { + return 12342; + }, + }; + + const result = MapComponent.prototype.decodeVectorTileOsmFeature(feature); + + assert.deepEqual(result, { + decoded: { + osmId: '1234', + osmType: 'way', + }, + reason: null, + }); + }); + + test('it ignores non-POI vector tile features', function (assert) { + const feature = { + get(key) { + if (key === 'mvt:layer') return 'transportation'; + return undefined; + }, + getId() { + return 12342; + }, + }; + + assert.deepEqual( + MapComponent.prototype.decodeVectorTileOsmFeature(feature), + { + decoded: null, + reason: 'unsupported source layer: transportation', + } + ); + }); + + test('it ignores unsupported or invalid vector tile ids', function (assert) { + const feature = { + get(key) { + if (key === 'mvt:layer') return 'poi'; + return undefined; + }, + getId() { + return 12340; + }, + }; + + assert.deepEqual( + MapComponent.prototype.decodeVectorTileOsmFeature(feature), + { + decoded: null, + reason: 'feature id suffix 0 is not an OSM type', + } + ); + }); +});