From d827fe263b2d22ead5a796c9044c379a0b0097cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Tue, 24 Feb 2026 11:22:57 +0400 Subject: [PATCH] Draw outlines/areas for ways and relations on map --- app/components/map.gjs | 37 +++++++++- app/routes/place.js | 23 ++++++- app/services/osm.js | 39 ++++++++++- tests/unit/routes/place-test.js | 117 ++++++++++++++++++++++++++++++++ tests/unit/services/osm-test.js | 85 +++++++++++++++++++++++ 5 files changed, 298 insertions(+), 3 deletions(-) diff --git a/app/components/map.gjs b/app/components/map.gjs index 29c691f..458771a 100644 --- a/app/components/map.gjs +++ b/app/components/map.gjs @@ -13,6 +13,7 @@ import LayerGroup from 'ol/layer/Group.js'; import VectorLayer from 'ol/layer/Vector.js'; import VectorSource from 'ol/source/Vector.js'; import Feature from 'ol/Feature.js'; +import GeoJSON from 'ol/format/GeoJSON.js'; import Point from 'ol/geom/Point.js'; import Geolocation from 'ol/Geolocation.js'; import { Style, Circle, Fill, Stroke } from 'ol/style.js'; @@ -27,6 +28,7 @@ export default class MapComponent extends Component { mapInstance; bookmarkSource; + selectedShapeSource; searchOverlay; searchOverlayElement; selectedPinOverlay; @@ -40,6 +42,22 @@ export default class MapComponent extends Component { const openfreemap = new LayerGroup(); + // Create a vector source and layer for the selected shape (outline) + this.selectedShapeSource = new VectorSource(); + const selectedShapeLayer = new VectorLayer({ + source: this.selectedShapeSource, + style: new Style({ + stroke: new Stroke({ + color: '#3388ff', + width: 4, + }), + fill: new Fill({ + color: 'rgba(51, 136, 255, 0.1)', + }), + }), + zIndex: 5, // Below bookmarks (10) but above tiles + }); + // Create a vector source and layer for bookmarks this.bookmarkSource = new VectorSource(); const bookmarkLayer = new VectorLayer({ @@ -99,7 +117,7 @@ export default class MapComponent extends Component { this.mapInstance = new Map({ target: element, - layers: [openfreemap, bookmarkLayer], + layers: [openfreemap, selectedShapeLayer, bookmarkLayer], view: view, controls: defaultControls({ zoom: true, @@ -426,6 +444,11 @@ export default class MapComponent extends Component { if (!this.selectedPinOverlay || !this.selectedPinElement) return; + // Clear any previous shape + if (this.selectedShapeSource) { + this.selectedShapeSource.clear(); + } + if (selected && selected.lat && selected.lon) { const coords = fromLonLat([selected.lon, selected.lat]); this.selectedPinOverlay.setPosition(coords); @@ -436,6 +459,18 @@ export default class MapComponent extends Component { void this.selectedPinElement.offsetWidth; this.selectedPinElement.classList.add('active'); + // Draw GeoJSON shape if available + if (selected.geojson && this.selectedShapeSource) { + try { + const feature = new GeoJSON().readFeature(selected.geojson, { + featureProjection: 'EPSG:3857', + }); + this.selectedShapeSource.addFeature(feature); + } catch (e) { + console.warn('Failed to render selected place shape:', e); + } + } + if (selected.bbox) { this.zoomToBbox(selected.bbox); } else { diff --git a/app/routes/place.js b/app/routes/place.js index f71397a..daf5e04 100644 --- a/app/routes/place.js +++ b/app/routes/place.js @@ -48,7 +48,28 @@ export default class PlaceRoute extends Route { } } - afterModel(model) { + async afterModel(model) { + // If the model comes from a search result (e.g. Photon), it might lack detailed geometry. + // We want to ensure we have the full OSM object (with polygon/linestring) for display. + if ( + model && + model.osmId && + model.osmType && + model.osmType !== 'node' && + !model.geojson + ) { + // Only fetch if it's NOT a node (nodes don't have interesting geometry anyway, just a point) + // Although fetching nodes again ensures we have the latest tags too. + console.debug('Model missing geometry, fetching full OSM details...'); + const fullDetails = await this.loadOsmPlace(model.osmId, model.osmType); + + if (fullDetails) { + // Update the model in-place with the fuller details + Object.assign(model, fullDetails); + console.debug('Enriched model with full OSM details', model); + } + } + // Notify the Map UI to show the pin if (model) { this.mapUi.selectPlace(model); diff --git a/app/services/osm.js b/app/services/osm.js index 6799ae3..debca2c 100644 --- a/app/services/osm.js +++ b/app/services/osm.js @@ -218,6 +218,7 @@ out center; let lat = displayElement.lat; let lon = displayElement.lon; let bbox = null; + let geojson = null; // If it's a way, calculate center from nodes if (targetType === 'way' && mainElement.nodes) { @@ -250,6 +251,25 @@ out center; minLon: Math.min(...lons), maxLon: Math.max(...lons), }; + + // Construct GeoJSON + if (coords.length > 1) { + const first = coords[0]; + const last = coords[coords.length - 1]; + const isClosed = first[0] === last[0] && first[1] === last[1]; + + if (isClosed) { + geojson = { + type: 'Polygon', + coordinates: [coords], + }; + } else { + geojson = { + type: 'LineString', + coordinates: coords, + }; + } + } } } else if (targetType === 'relation' && mainElement.members) { // Find all nodes that are part of this relation (directly or via ways) @@ -261,6 +281,8 @@ out center; } }); + const segments = []; + mainElement.members.forEach((member) => { if (member.type === 'node') { const node = nodeMap.get(member.ref); @@ -270,10 +292,17 @@ out center; (el) => el.type === 'way' && el.id === member.ref ); if (way && way.nodes) { + const wayCoords = []; way.nodes.forEach((nodeId) => { const node = nodeMap.get(nodeId); - if (node) allNodes.push(node); + if (node) { + allNodes.push(node); + wayCoords.push([node.lon, node.lat]); + } }); + if (wayCoords.length > 1) { + segments.push(wayCoords); + } } } }); @@ -297,6 +326,13 @@ out center; maxLon: Math.max(...lons), }; } + + if (segments.length > 0) { + geojson = { + type: 'MultiLineString', + coordinates: segments, + }; + } } const tags = displayElement.tags || {}; @@ -307,6 +343,7 @@ out center; lat, lon, bbox, + geojson, url: tags.website, osmId: String(displayElement.id), osmType: displayElement.type, diff --git a/tests/unit/routes/place-test.js b/tests/unit/routes/place-test.js index 648bfc1..550c8fc 100644 --- a/tests/unit/routes/place-test.js +++ b/tests/unit/routes/place-test.js @@ -1,5 +1,6 @@ import { module, test } from 'qunit'; import { setupTest } from 'marco/tests/helpers'; +import Service from '@ember/service'; module('Unit | Route | place', function (hooks) { setupTest(hooks); @@ -8,4 +9,120 @@ module('Unit | Route | place', function (hooks) { let route = this.owner.lookup('route:place'); assert.ok(route); }); + + test('afterModel enriches model with missing geometry', async function (assert) { + let route = this.owner.lookup('route:place'); + + // Mock Services + let fetchCalled = false; + let selectPlaceCalled = false; + + class OsmStub extends Service { + async fetchOsmObject(id, type) { + fetchCalled = true; + assert.strictEqual(id, '123', 'Correct ID passed'); + assert.strictEqual(type, 'way', 'Correct Type passed'); + return { + osmId: '123', + osmType: 'way', + geojson: { type: 'Polygon', coordinates: [] }, + tags: { updated: 'true' }, + }; + } + } + + class MapUiStub extends Service { + selectPlace(place) { + selectPlaceCalled = true; + } + stopSearch() {} + } + + this.owner.register('service:osm', OsmStub); + this.owner.register('service:map-ui', MapUiStub); + + // Initial partial model (from search) + let model = { + osmId: '123', + osmType: 'way', + title: 'Partial Place', + // No geojson + }; + + await route.afterModel(model); + + assert.ok(fetchCalled, 'fetchOsmObject should be called'); + assert.ok(selectPlaceCalled, 'selectPlace should be called'); + assert.ok(model.geojson, 'Model should now have geojson'); + assert.strictEqual( + model.tags.updated, + 'true', + 'Model should have updated tags' + ); + }); + + test('afterModel skips fetch if geometry exists', async function (assert) { + let route = this.owner.lookup('route:place'); + + let fetchCalled = false; + + class OsmStub extends Service { + async fetchOsmObject() { + fetchCalled = true; + return null; + } + } + + class MapUiStub extends Service { + selectPlace() {} + stopSearch() {} + } + + this.owner.register('service:osm', OsmStub); + this.owner.register('service:map-ui', MapUiStub); + + let model = { + osmId: '456', + osmType: 'relation', + geojson: { type: 'MultiLineString' }, + }; + + await route.afterModel(model); + + assert.notOk( + fetchCalled, + 'fetchOsmObject should NOT be called if geojson exists' + ); + }); + + test('afterModel skips fetch for nodes even if geometry is missing', async function (assert) { + let route = this.owner.lookup('route:place'); + + let fetchCalled = false; + + class OsmStub extends Service { + async fetchOsmObject() { + fetchCalled = true; + return null; + } + } + + class MapUiStub extends Service { + selectPlace() {} + stopSearch() {} + } + + this.owner.register('service:osm', OsmStub); + this.owner.register('service:map-ui', MapUiStub); + + let model = { + osmId: '789', + osmType: 'node', + // No geojson, but it's a node + }; + + await route.afterModel(model); + + assert.notOk(fetchCalled, 'fetchOsmObject should NOT be called for nodes'); + }); }); diff --git a/tests/unit/services/osm-test.js b/tests/unit/services/osm-test.js index 52b109b..b106c81 100644 --- a/tests/unit/services/osm-test.js +++ b/tests/unit/services/osm-test.js @@ -166,4 +166,89 @@ module('Unit | Service | osm', function (hooks) { assert.strictEqual(result.osmId, '999'); assert.strictEqual(result.osmType, 'relation'); }); + + test('normalizeOsmApiData creates GeoJSON for ways', function (assert) { + let service = this.owner.lookup('service:osm'); + const elements = [ + { + id: 456, + type: 'way', + nodes: [1, 2, 3], + tags: { name: 'Test Way' }, + }, + { id: 1, type: 'node', lat: 0, lon: 0 }, + { id: 2, type: 'node', lat: 10, lon: 10 }, + { id: 3, type: 'node', lat: 0, lon: 0 }, // Closed loop + ]; + + const result = service.normalizeOsmApiData(elements, 456, 'way'); + + assert.ok(result.geojson, 'GeoJSON should be present'); + assert.strictEqual( + result.geojson.type, + 'Polygon', + 'Closed way should be a Polygon' + ); + assert.strictEqual( + result.geojson.coordinates[0].length, + 3, + 'Should have 3 coordinates' + ); + assert.deepEqual(result.geojson.coordinates[0][0], [0, 0]); + assert.deepEqual(result.geojson.coordinates[0][1], [10, 10]); + }); + + test('normalizeOsmApiData creates GeoJSON MultiLineString for relations', function (assert) { + let service = this.owner.lookup('service:osm'); + /* + Relation 999 + -> Way 888 (0,0 -> 10,10) + -> Way 777 (20,20 -> 30,30) + */ + const elements = [ + { + id: 999, + type: 'relation', + members: [ + { type: 'way', ref: 888, role: 'outer' }, + { type: 'way', ref: 777, role: 'inner' }, + ], + tags: { name: 'Complex Relation' }, + }, + { + id: 888, + type: 'way', + nodes: [1, 2], + }, + { + id: 777, + type: 'way', + nodes: [3, 4], + }, + { id: 1, type: 'node', lat: 0, lon: 0 }, + { id: 2, type: 'node', lat: 10, lon: 10 }, + { id: 3, type: 'node', lat: 20, lon: 20 }, + { id: 4, type: 'node', lat: 30, lon: 30 }, + ]; + + const result = service.normalizeOsmApiData(elements, 999, 'relation'); + + assert.ok(result.geojson, 'GeoJSON should be present'); + assert.strictEqual(result.geojson.type, 'MultiLineString'); + assert.strictEqual( + result.geojson.coordinates.length, + 2, + 'Should have 2 segments' + ); + // Check first segment (Way 888) + assert.deepEqual(result.geojson.coordinates[0], [ + [0, 0], + [10, 10], + ]); + // Check second segment (Way 777) + assert.deepEqual(result.geojson.coordinates[1], [ + [20, 20], + [30, 30], + ]); + }); });